diff --git a/.env.example b/.env.example index ce6a062..bf9ea11 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -VERSION=0.1.25 +VERSION=0.1.26 # Nest run in docker, change host to database container name DB_HOST=localhost diff --git a/apps/webapp/app/bullmq/queues/index.ts b/apps/webapp/app/bullmq/queues/index.ts index 4fc6964..ebefc4b 100644 --- a/apps/webapp/app/bullmq/queues/index.ts +++ b/apps/webapp/app/bullmq/queues/index.ts @@ -72,27 +72,6 @@ export const conversationTitleQueue = new Queue("conversation-title-queue", { }, }); -/** - * Deep search queue - */ -export const deepSearchQueue = new Queue("deep-search-queue", { - connection: getRedisConnection(), - defaultJobOptions: { - attempts: 3, - backoff: { - type: "exponential", - delay: 2000, - }, - removeOnComplete: { - age: 3600, - count: 1000, - }, - removeOnFail: { - age: 86400, - }, - }, -}); - /** * Session compaction queue */ diff --git a/apps/webapp/app/bullmq/start-workers.ts b/apps/webapp/app/bullmq/start-workers.ts index 23169b0..1d64b40 100644 --- a/apps/webapp/app/bullmq/start-workers.ts +++ b/apps/webapp/app/bullmq/start-workers.ts @@ -13,7 +13,6 @@ import { ingestWorker, documentIngestWorker, conversationTitleWorker, - deepSearchWorker, sessionCompactionWorker, closeAllWorkers, } from "./workers"; @@ -21,7 +20,6 @@ import { ingestQueue, documentIngestQueue, conversationTitleQueue, - deepSearchQueue, sessionCompactionQueue, } from "./queues"; import { @@ -47,7 +45,7 @@ export async function initWorkers(): Promise { conversationTitleQueue, "conversation-title", ); - setupWorkerLogging(deepSearchWorker, deepSearchQueue, "deep-search"); + setupWorkerLogging( sessionCompactionWorker, sessionCompactionQueue, @@ -68,7 +66,7 @@ export async function initWorkers(): Promise { queue: conversationTitleQueue, name: "conversation-title", }, - { worker: deepSearchWorker, queue: deepSearchQueue, name: "deep-search" }, + { worker: sessionCompactionWorker, queue: sessionCompactionQueue, @@ -88,7 +86,7 @@ export async function initWorkers(): Promise { logger.log( `✓ Conversation title worker: ${conversationTitleWorker.name} (concurrency: 10)`, ); - logger.log(`✓ Deep search worker: ${deepSearchWorker.name} (concurrency: 5)`); + logger.log( `✓ Session compaction worker: ${sessionCompactionWorker.name} (concurrency: 3)`, ); diff --git a/apps/webapp/app/bullmq/utils/job-finder.ts b/apps/webapp/app/bullmq/utils/job-finder.ts index 5e604c1..2212792 100644 --- a/apps/webapp/app/bullmq/utils/job-finder.ts +++ b/apps/webapp/app/bullmq/utils/job-finder.ts @@ -18,7 +18,6 @@ async function getAllQueues() { ingestQueue, documentIngestQueue, conversationTitleQueue, - deepSearchQueue, sessionCompactionQueue, } = await import("../queues"); @@ -26,7 +25,6 @@ async function getAllQueues() { ingestQueue, documentIngestQueue, conversationTitleQueue, - deepSearchQueue, sessionCompactionQueue, ]; } diff --git a/apps/webapp/app/bullmq/workers/index.ts b/apps/webapp/app/bullmq/workers/index.ts index dd8ceaa..e2d930d 100644 --- a/apps/webapp/app/bullmq/workers/index.ts +++ b/apps/webapp/app/bullmq/workers/index.ts @@ -18,10 +18,7 @@ import { processConversationTitleCreation, type CreateConversationTitlePayload, } from "~/jobs/conversation/create-title.logic"; -import { - processDeepSearch, - type ProcessDeepSearchPayload, -} from "~/jobs/deep-search/deep-search.logic"; + import { processSessionCompaction, type SessionCompactionPayload, @@ -58,14 +55,6 @@ export const ingestWorker = new Worker( }, ); -ingestWorker.on("completed", (job) => { - logger.log(`Job ${job.id} completed`); -}); - -ingestWorker.on("failed", (job, error) => { - logger.error(`Job ${job?.id} failed: ${error}`); -}); - /** * Document ingestion worker * Handles document-level ingestion with differential processing @@ -89,14 +78,6 @@ export const documentIngestWorker = new Worker( }, ); -documentIngestWorker.on("completed", (job) => { - logger.log(`Document job ${job.id} completed`); -}); - -documentIngestWorker.on("failed", (job, error) => { - logger.error(`Document job ${job?.id} failed: ${error}`); -}); - /** * Conversation title creation worker */ @@ -112,37 +93,6 @@ export const conversationTitleWorker = new Worker( }, ); -conversationTitleWorker.on("completed", (job) => { - logger.log(`Conversation title job ${job.id} completed`); -}); - -conversationTitleWorker.on("failed", (job, error) => { - logger.error(`Conversation title job ${job?.id} failed: ${error}`); -}); - -/** - * Deep search worker (non-streaming version for BullMQ) - */ -export const deepSearchWorker = new Worker( - "deep-search-queue", - async (job) => { - const payload = job.data as ProcessDeepSearchPayload; - return await processDeepSearch(payload); - }, - { - connection: getRedisConnection(), - concurrency: 5, // Process up to 5 searches in parallel - }, -); - -deepSearchWorker.on("completed", (job) => { - logger.log(`Deep search job ${job.id} completed`); -}); - -deepSearchWorker.on("failed", (job, error) => { - logger.error(`Deep search job ${job?.id} failed: ${error}`); -}); - /** * Session compaction worker */ @@ -158,14 +108,6 @@ export const sessionCompactionWorker = new Worker( }, ); -sessionCompactionWorker.on("completed", (job) => { - logger.log(`Session compaction job ${job.id} completed`); -}); - -sessionCompactionWorker.on("failed", (job, error) => { - logger.error(`Session compaction job ${job?.id} failed: ${error}`); -}); - /** * Graceful shutdown handler */ @@ -174,7 +116,7 @@ export async function closeAllWorkers(): Promise { ingestWorker.close(), documentIngestWorker.close(), conversationTitleWorker.close(), - deepSearchWorker.close(), + sessionCompactionWorker.close(), ]); logger.log("All BullMQ workers closed"); diff --git a/apps/webapp/app/components/conversation/conversation-item.client.tsx b/apps/webapp/app/components/conversation/conversation-item.client.tsx index c631926..02a2200 100644 --- a/apps/webapp/app/components/conversation/conversation-item.client.tsx +++ b/apps/webapp/app/components/conversation/conversation-item.client.tsx @@ -1,38 +1,33 @@ import { EditorContent, useEditor } from "@tiptap/react"; import { useEffect, memo } from "react"; -import { UserTypeEnum } from "@core/types"; -import { type ConversationHistory } from "@core/database"; import { cn } from "~/lib/utils"; import { extensionsForConversation } from "./editor-extensions"; import { skillExtension } from "../editor/skill-extension"; +import { type UIMessage } from "ai"; interface AIConversationItemProps { - conversationHistory: ConversationHistory; + message: UIMessage; } -const ConversationItemComponent = ({ - conversationHistory, -}: AIConversationItemProps) => { - const isUser = - conversationHistory.userType === UserTypeEnum.User || - conversationHistory.userType === UserTypeEnum.System; - - const id = `a${conversationHistory.id.replace(/-/g, "")}`; +const ConversationItemComponent = ({ message }: AIConversationItemProps) => { + const isUser = message.role === "user" || false; + const textPart = message.parts.find((part) => part.type === "text"); const editor = useEditor({ extensions: [...extensionsForConversation, skillExtension], editable: false, - content: conversationHistory.message, + content: textPart ? textPart.text : "", }); useEffect(() => { - editor?.commands.setContent(conversationHistory.message); - + if (textPart) { + editor?.commands.setContent(textPart.text); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id, conversationHistory.message]); + }, [message]); - if (!conversationHistory.message) { + if (!message) { return null; } @@ -51,10 +46,10 @@ const ConversationItemComponent = ({ }; // Memoize to prevent unnecessary re-renders -export const ConversationItem = memo(ConversationItemComponent, (prevProps, nextProps) => { - // Only re-render if the conversation history ID or message changed - return ( - prevProps.conversationHistory.id === nextProps.conversationHistory.id && - prevProps.conversationHistory.message === nextProps.conversationHistory.message - ); -}); +export const ConversationItem = memo( + ConversationItemComponent, + (prevProps, nextProps) => { + // Only re-render if the conversation history ID or message changed + return prevProps.message === nextProps.message; + }, +); diff --git a/apps/webapp/app/components/conversation/conversation-textarea.client.tsx b/apps/webapp/app/components/conversation/conversation-textarea.client.tsx index 25a542e..41d1790 100644 --- a/apps/webapp/app/components/conversation/conversation-textarea.client.tsx +++ b/apps/webapp/app/components/conversation/conversation-textarea.client.tsx @@ -13,33 +13,26 @@ import { Form, useSubmit, useActionData } from "@remix-run/react"; interface ConversationTextareaProps { defaultValue?: string; - conversationId: string; placeholder?: string; isLoading?: boolean; className?: string; onChange?: (text: string) => void; disabled?: boolean; - onConversationCreated?: (conversation: any) => void; + onConversationCreated?: (message: string) => void; + stop?: () => void; } export function ConversationTextarea({ defaultValue, isLoading = false, placeholder, - conversationId, onChange, onConversationCreated, + stop, }: ConversationTextareaProps) { const [text, setText] = useState(defaultValue ?? ""); const [editor, setEditor] = useState(); const submit = useSubmit(); - const actionData = useActionData<{ conversation?: any }>(); - - useEffect(() => { - if (actionData?.conversation && onConversationCreated) { - onConversationCreated(actionData.conversation); - } - }, [actionData]); const onUpdate = (editor: Editor) => { setText(editor.getHTML()); @@ -51,134 +44,99 @@ export function ConversationTextarea({ return; } - const data = isLoading ? {} : { message: text, conversationId }; - - // When conversationId exists and not stopping, submit to current route - // When isLoading (stopping), submit to the specific conversation route - submit(data as any, { - action: isLoading - ? `/home/conversation/${conversationId}` - : conversationId - ? `/home/conversation/${conversationId}` - : "/home/conversation", - method: "post", - }); + onConversationCreated && onConversationCreated(text); editor?.commands.clearContent(true); setText(""); }, [editor, text]); - // Send message to API - const submitForm = useCallback( - async (e: React.FormEvent) => { - const data = isLoading - ? {} - : { message: text, title: text, conversationId }; - - submit(data as any, { - action: isLoading - ? `/home/conversation/${conversationId}` - : conversationId - ? `/home/conversation/${conversationId}` - : "/home/conversation", - method: "post", - }); - - editor?.commands.clearContent(true); - setText(""); - e.preventDefault(); - }, - [text, conversationId], - ); - return ( -
submitForm(e)} - className="pt-2" - > -
- - + + placeholder ?? "Ask sol...", - includeChildren: true, - }), - History, - ]} - onCreate={async ({ editor }) => { - setEditor(editor); - await new Promise((resolve) => setTimeout(resolve, 100)); - editor.commands.focus("end"); - }} - onUpdate={({ editor }) => { - onUpdate(editor); - }} - shouldRerenderOnTransaction={false} - editorProps={{ - attributes: { - class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`, - }, - handleKeyDown(view, event) { - if (event.key === "Enter" && !event.shiftKey) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const target = event.target as any; - if (target.innerHTML.includes("suggestion")) { - return false; - } - event.preventDefault(); - if (text) { - handleSend(); - } - return true; + Placeholder.configure({ + placeholder: () => placeholder ?? "Ask sol...", + includeChildren: true, + }), + History, + ]} + onCreate={async ({ editor }) => { + setEditor(editor); + await new Promise((resolve) => setTimeout(resolve, 100)); + editor.commands.focus("end"); + }} + onUpdate={({ editor }) => { + onUpdate(editor); + }} + shouldRerenderOnTransaction={false} + editorProps={{ + attributes: { + class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`, + }, + handleKeyDown(view, event) { + if (event.key === "Enter" && !event.shiftKey) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const target = event.target as any; + if (target.innerHTML.includes("suggestion")) { + return false; } + event.preventDefault(); + if (text) { + handleSend(); + } + return true; + } - if (event.key === "Enter" && event.shiftKey) { - view.dispatch( - view.state.tr.replaceSelectionWith( - view.state.schema.nodes.hardBreak.create(), - ), - ); - return true; - } - return false; - }, - }} - immediatelyRender={false} - className={cn( - "editor-container text-md max-h-[400px] min-h-[40px] w-full min-w-full overflow-auto rounded-lg px-3", - )} - /> - -
- -
+ if (event.key === "Enter" && event.shiftKey) { + view.dispatch( + view.state.tr.replaceSelectionWith( + view.state.schema.nodes.hardBreak.create(), + ), + ); + return true; + } + return false; + }, + }} + immediatelyRender={false} + className={cn( + "editor-container text-md max-h-[400px] min-h-[40px] w-full min-w-full overflow-auto rounded-lg px-3", + )} + /> +
+
+
- +
); } diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index ba89328..7257fa8 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -47,8 +47,16 @@ const EnvironmentSchema = z POSTHOG_PROJECT_KEY: z .string() .default("phc_SwfGIzzX5gh5bazVWoRxZTBhkr7FwvzArS0NRyGXm1a"), - TELEMETRY_ENABLED: z.coerce.boolean().default(true), - TELEMETRY_ANONYMOUS: z.coerce.boolean().default(false), + TELEMETRY_ENABLED: z + .string() + .optional() + .default("true") + .transform((val) => val !== "false" && val !== "0"), + TELEMETRY_ANONYMOUS: z + .string() + .optional() + .default("false") + .transform((val) => val === "true" || val === "1"), //storage ACCESS_KEY_ID: z.string().optional(), @@ -59,12 +67,20 @@ const EnvironmentSchema = z AUTH_GOOGLE_CLIENT_ID: z.string().optional(), AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(), - ENABLE_EMAIL_LOGIN: z.coerce.boolean().default(true), + ENABLE_EMAIL_LOGIN: z + .string() + .optional() + .default("true") + .transform((val) => val !== "false" && val !== "0"), //Redis REDIS_HOST: z.string().default("localhost"), REDIS_PORT: z.coerce.number().default(6379), - REDIS_TLS_DISABLED: z.coerce.boolean().default(true), + REDIS_TLS_DISABLED: z + .string() + .optional() + .default("true") + .transform((val) => val !== "false" && val !== "0"), //Neo4j NEO4J_URI: z.string(), @@ -72,8 +88,9 @@ const EnvironmentSchema = z NEO4J_PASSWORD: z.string(), //OpenAI - OPENAI_API_KEY: z.string(), + OPENAI_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(), + GOOGLE_GENERATIVE_AI_API_KEY: z.string().optional(), EMAIL_TRANSPORT: z.string().optional(), FROM_EMAIL: z.string().optional(), @@ -81,7 +98,10 @@ const EnvironmentSchema = z RESEND_API_KEY: z.string().optional(), SMTP_HOST: z.string().optional(), SMTP_PORT: z.coerce.number().optional(), - SMTP_SECURE: z.coerce.boolean().optional(), + SMTP_SECURE: z + .string() + .optional() + .transform((val) => val === "true" || val === "1"), SMTP_USER: z.string().optional(), SMTP_PASSWORD: z.string().optional(), diff --git a/apps/webapp/app/jobs/conversation/create-title.logic.ts b/apps/webapp/app/jobs/conversation/create-title.logic.ts index 07d3db8..6f7a1a4 100644 --- a/apps/webapp/app/jobs/conversation/create-title.logic.ts +++ b/apps/webapp/app/jobs/conversation/create-title.logic.ts @@ -1,8 +1,8 @@ -import { LLMMappings } from "@core/types"; -import { generate } from "~/trigger/chat/stream-utils"; import { conversationTitlePrompt } from "~/trigger/conversation/prompt"; import { prisma } from "~/trigger/utils/prisma"; import { logger } from "~/services/logger.service"; +import { generateText, type LanguageModel } from "ai"; +import { getModel } from "~/lib/model.server"; export interface CreateConversationTitlePayload { conversationId: string; @@ -24,8 +24,9 @@ export async function processConversationTitleCreation( ): Promise { try { let conversationTitleResponse = ""; - const gen = generate( - [ + const { text } = await generateText({ + model: getModel() as LanguageModel, + messages: [ { role: "user", content: conversationTitlePrompt.replace( @@ -34,24 +35,9 @@ export async function processConversationTitleCreation( ), }, ], - false, - () => {}, - undefined, - "", - LLMMappings.GPT41, - ); + }); - for await (const chunk of gen) { - if (typeof chunk === "string") { - conversationTitleResponse += chunk; - } else if (chunk && typeof chunk === "object" && chunk.message) { - conversationTitleResponse += chunk.message; - } - } - - const outputMatch = conversationTitleResponse.match( - /(.*?)<\/output>/s, - ); + const outputMatch = text.match(/(.*?)<\/output>/s); logger.info(`Conversation title data: ${JSON.stringify(outputMatch)}`); diff --git a/apps/webapp/app/jobs/deep-search/deep-search.logic.ts b/apps/webapp/app/jobs/deep-search/deep-search.logic.ts deleted file mode 100644 index a7be659..0000000 --- a/apps/webapp/app/jobs/deep-search/deep-search.logic.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { type CoreMessage } from "ai"; -import { logger } from "~/services/logger.service"; -import { nanoid } from "nanoid"; -import { - deletePersonalAccessToken, - getOrCreatePersonalAccessToken, -} from "~/trigger/utils/utils"; -import { getReActPrompt } from "~/trigger/deep-search/prompt"; -import { createSearchMemoryTool } from "~/trigger/deep-search/utils"; -import { run } from "~/trigger/deep-search/deep-search-utils"; -import { AgentMessageType } from "~/trigger/chat/types"; - -export interface ProcessDeepSearchPayload { - content: string; - userId: string; - metadata?: any; - intentOverride?: string; -} - -export interface ProcessDeepSearchResult { - success: boolean; - synthesis?: string; - error?: string; -} - -/** - * Core business logic for deep search (non-streaming version for BullMQ) - * This is shared logic, but the streaming happens in Trigger.dev via metadata.stream - */ -export async function processDeepSearch( - payload: ProcessDeepSearchPayload, -): Promise { - const { content, userId, metadata: meta, intentOverride } = payload; - - const randomKeyName = `deepSearch_${nanoid(10)}`; - - // Get or create token for search API calls - const pat = await getOrCreatePersonalAccessToken({ - name: randomKeyName, - userId: userId as string, - }); - - if (!pat?.token) { - return { - success: false, - error: "Failed to create personal access token", - }; - } - - try { - // Create search tool that agent will use - const searchTool = createSearchMemoryTool(pat.token); - - // Build initial messages with ReAct prompt - const initialMessages: CoreMessage[] = [ - { - role: "system", - content: getReActPrompt(meta, intentOverride), - }, - { - role: "user", - content: `CONTENT TO ANALYZE:\n${content}\n\nPlease search my memory for relevant context and synthesize what you find.`, - }, - ]; - - // Run the ReAct loop generator - const llmResponse = run(initialMessages, searchTool); - - let synthesis = ""; - - // For BullMQ: iterate without streaming, just accumulate the final synthesis - for await (const step of llmResponse) { - // MESSAGE_CHUNK: Final synthesis - accumulate - if (step.type === AgentMessageType.MESSAGE_CHUNK) { - synthesis += step.message; - } - - // STREAM_END: Loop completed - if (step.type === AgentMessageType.STREAM_END) { - break; - } - } - - await deletePersonalAccessToken(pat?.id); - - // Clean up any remaining tags - synthesis = synthesis - .replace(//gi, "") - .replace(/<\/final_response>/gi, "") - .trim(); - - return { - success: true, - synthesis, - }; - } catch (error: any) { - await deletePersonalAccessToken(pat?.id); - logger.error(`Deep search error: ${error}`); - return { - success: false, - error: error.message, - }; - } -} diff --git a/apps/webapp/app/lib/model.server.ts b/apps/webapp/app/lib/model.server.ts index 8f53541..12e968d 100644 --- a/apps/webapp/app/lib/model.server.ts +++ b/apps/webapp/app/lib/model.server.ts @@ -1,31 +1,23 @@ -import { - type CoreMessage, - type LanguageModelV1, - embed, - generateText, - streamText, -} from "ai"; +import { type CoreMessage, embed, generateText, streamText } from "ai"; import { openai } from "@ai-sdk/openai"; import { logger } from "~/services/logger.service"; -import { createOllama, type OllamaProvider } from "ollama-ai-provider"; +import { createOllama } from "ollama-ai-provider-v2"; import { anthropic } from "@ai-sdk/anthropic"; import { google } from "@ai-sdk/google"; -import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"; -import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; -export type ModelComplexity = 'high' | 'low'; +export type ModelComplexity = "high" | "low"; /** * Get the appropriate model for a given complexity level. * HIGH complexity uses the configured MODEL. * LOW complexity automatically downgrades to cheaper variants if possible. */ -export function getModelForTask(complexity: ModelComplexity = 'high'): string { - const baseModel = process.env.MODEL || 'gpt-4.1-2025-04-14'; +export function getModelForTask(complexity: ModelComplexity = "high"): string { + const baseModel = process.env.MODEL || "gpt-4.1-2025-04-14"; // HIGH complexity - always use the configured model - if (complexity === 'high') { + if (complexity === "high") { return baseModel; } @@ -33,29 +25,73 @@ export function getModelForTask(complexity: ModelComplexity = 'high'): string { // If already using a cheap model, keep it const downgrades: Record = { // OpenAI downgrades - 'gpt-5-2025-08-07': 'gpt-5-mini-2025-08-07', - 'gpt-4.1-2025-04-14': 'gpt-4.1-mini-2025-04-14', + "gpt-5-2025-08-07": "gpt-5-mini-2025-08-07", + "gpt-4.1-2025-04-14": "gpt-4.1-mini-2025-04-14", // Anthropic downgrades - 'claude-sonnet-4-5': 'claude-3-5-haiku-20241022', - 'claude-3-7-sonnet-20250219': 'claude-3-5-haiku-20241022', - 'claude-3-opus-20240229': 'claude-3-5-haiku-20241022', + "claude-sonnet-4-5": "claude-3-5-haiku-20241022", + "claude-3-7-sonnet-20250219": "claude-3-5-haiku-20241022", + "claude-3-opus-20240229": "claude-3-5-haiku-20241022", // Google downgrades - 'gemini-2.5-pro-preview-03-25': 'gemini-2.5-flash-preview-04-17', - 'gemini-2.0-flash': 'gemini-2.0-flash-lite', + "gemini-2.5-pro-preview-03-25": "gemini-2.5-flash-preview-04-17", + "gemini-2.0-flash": "gemini-2.0-flash-lite", // AWS Bedrock downgrades (keep same model - already cost-optimized) - 'us.amazon.nova-premier-v1:0': 'us.amazon.nova-premier-v1:0', + "us.amazon.nova-premier-v1:0": "us.amazon.nova-premier-v1:0", }; return downgrades[baseModel] || baseModel; } +export const getModel = (takeModel?: string) => { + let model = takeModel; + + const anthropicKey = process.env.ANTHROPIC_API_KEY; + const googleKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; + const openaiKey = process.env.OPENAI_API_KEY; + let ollamaUrl = process.env.OLLAMA_URL; + model = model || process.env.MODEL || "gpt-4.1-2025-04-14"; + + let modelInstance; + let modelTemperature = Number(process.env.MODEL_TEMPERATURE) || 1; + ollamaUrl = undefined; + + // First check if Ollama URL exists and use Ollama + if (ollamaUrl) { + const ollama = createOllama({ + baseURL: ollamaUrl, + }); + modelInstance = ollama(model || "llama2"); // Default to llama2 if no model specified + } else { + // If no Ollama, check other models + + if (model.includes("claude")) { + if (!anthropicKey) { + throw new Error("No Anthropic API key found. Set ANTHROPIC_API_KEY"); + } + modelInstance = anthropic(model); + modelTemperature = 0.5; + } else if (model.includes("gemini")) { + if (!googleKey) { + throw new Error("No Google API key found. Set GOOGLE_API_KEY"); + } + modelInstance = google(model); + } else { + if (!openaiKey) { + throw new Error("No OpenAI API key found. Set OPENAI_API_KEY"); + } + modelInstance = openai(model); + } + + return modelInstance; + } +}; + export interface TokenUsage { - promptTokens: number; - completionTokens: number; - totalTokens: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; } export async function makeModelCall( @@ -63,69 +99,13 @@ export async function makeModelCall( messages: CoreMessage[], onFinish: (text: string, model: string, usage?: TokenUsage) => void, options?: any, - complexity: ModelComplexity = 'high', + complexity: ModelComplexity = "high", ) { - let modelInstance: LanguageModelV1 | undefined; let model = getModelForTask(complexity); - const ollamaUrl = process.env.OLLAMA_URL; - let ollama: OllamaProvider | undefined; + logger.info(`complexity: ${complexity}, model: ${model}`); - if (ollamaUrl) { - ollama = createOllama({ - baseURL: ollamaUrl, - }); - } - - const bedrock = createAmazonBedrock({ - region: process.env.AWS_REGION || 'us-east-1', - credentialProvider: fromNodeProviderChain(), - }); - - const generateTextOptions: any = {} - - logger.info( - `complexity: ${complexity}, model: ${model}`, - ); - switch (model) { - case "gpt-4.1-2025-04-14": - case "gpt-4.1-mini-2025-04-14": - case "gpt-5-mini-2025-08-07": - case "gpt-5-2025-08-07": - case "gpt-4.1-nano-2025-04-14": - modelInstance = openai(model, { ...options }); - generateTextOptions.temperature = 1 - break; - - case "claude-3-7-sonnet-20250219": - case "claude-3-opus-20240229": - case "claude-3-5-haiku-20241022": - modelInstance = anthropic(model, { ...options }); - break; - - case "gemini-2.5-flash-preview-04-17": - case "gemini-2.5-pro-preview-03-25": - case "gemini-2.0-flash": - case "gemini-2.0-flash-lite": - modelInstance = google(model, { ...options }); - break; - - case "us.meta.llama3-3-70b-instruct-v1:0": - case "us.deepseek.r1-v1:0": - case "qwen.qwen3-32b-v1:0": - case "openai.gpt-oss-120b-1:0": - case "us.mistral.pixtral-large-2502-v1:0": - case "us.amazon.nova-premier-v1:0": - modelInstance = bedrock(`${model}`); - generateTextOptions.maxTokens = 100000 - break; - - default: - if (ollama) { - modelInstance = ollama(model); - } - logger.warn(`Unsupported model type: ${model}`); - break; - } + const modelInstance = getModel(model); + const generateTextOptions: any = {}; if (!modelInstance) { throw new Error(`Unsupported model type: ${model}`); @@ -135,16 +115,21 @@ export async function makeModelCall( return streamText({ model: modelInstance, messages, + ...options, ...generateTextOptions, onFinish: async ({ text, usage }) => { - const tokenUsage = usage ? { - promptTokens: usage.promptTokens, - completionTokens: usage.completionTokens, - totalTokens: usage.totalTokens, - } : undefined; + const tokenUsage = usage + ? { + promptTokens: usage.inputTokens, + completionTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + } + : undefined; if (tokenUsage) { - logger.log(`[${complexity.toUpperCase()}] ${model} - Tokens: ${tokenUsage.totalTokens} (prompt: ${tokenUsage.promptTokens}, completion: ${tokenUsage.completionTokens})`); + logger.log( + `[${complexity.toUpperCase()}] ${model} - Tokens: ${tokenUsage.totalTokens} (prompt: ${tokenUsage.promptTokens}, completion: ${tokenUsage.completionTokens})`, + ); } onFinish(text, model, tokenUsage); @@ -158,14 +143,18 @@ export async function makeModelCall( ...generateTextOptions, }); - const tokenUsage = usage ? { - promptTokens: usage.promptTokens, - completionTokens: usage.completionTokens, - totalTokens: usage.totalTokens, - } : undefined; + const tokenUsage = usage + ? { + promptTokens: usage.inputTokens, + completionTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + } + : undefined; if (tokenUsage) { - logger.log(`[${complexity.toUpperCase()}] ${model} - Tokens: ${tokenUsage.totalTokens} (prompt: ${tokenUsage.promptTokens}, completion: ${tokenUsage.completionTokens})`); + logger.log( + `[${complexity.toUpperCase()}] ${model} - Tokens: ${tokenUsage.totalTokens} (prompt: ${tokenUsage.promptTokens}, completion: ${tokenUsage.completionTokens})`, + ); } onFinish(text, model, tokenUsage); @@ -177,19 +166,22 @@ export async function makeModelCall( * Determines if a given model is proprietary (OpenAI, Anthropic, Google, Grok) * or open source (accessed via Bedrock, Ollama, etc.) */ -export function isProprietaryModel(modelName?: string, complexity: ModelComplexity = 'high'): boolean { +export function isProprietaryModel( + modelName?: string, + complexity: ModelComplexity = "high", +): boolean { const model = modelName || getModelForTask(complexity); if (!model) return false; // Proprietary model patterns const proprietaryPatterns = [ - /^gpt-/, // OpenAI models - /^claude-/, // Anthropic models - /^gemini-/, // Google models - /^grok-/, // xAI models + /^gpt-/, // OpenAI models + /^claude-/, // Anthropic models + /^gemini-/, // Google models + /^grok-/, // xAI models ]; - return proprietaryPatterns.some(pattern => pattern.test(model)); + return proprietaryPatterns.some((pattern) => pattern.test(model)); } export async function getEmbedding(text: string) { diff --git a/apps/webapp/app/lib/prompt.server.ts b/apps/webapp/app/lib/prompt.server.ts new file mode 100644 index 0000000..09e1dc1 --- /dev/null +++ b/apps/webapp/app/lib/prompt.server.ts @@ -0,0 +1,326 @@ +import { tool } from "ai"; +import z from "zod"; + +export const REACT_SYSTEM_PROMPT = ` +You are a helpful AI assistant with access to user memory. Your primary capabilities are: + +1. **Memory-First Approach**: Always check user memory first to understand context and previous interactions +2. **Intelligent Information Gathering**: Analyze queries to determine if current information is needed +3. **Memory Management**: Help users store, retrieve, and organize information in their memory +4. **Contextual Assistance**: Use memory to provide personalized and contextual responses + + +Follow this intelligent approach for information gathering: + +1. **MEMORY FIRST** (Always Required) + - Always check memory FIRST using core--search_memory before any other actions + - Consider this your highest priority for EVERY interaction - as essential as breathing + - Memory provides context, personal preferences, and historical information + - Use memory to understand user's background, ongoing projects, and past conversations + +2. **INFORMATION SYNTHESIS** (Combine Sources) + - Use memory to personalize current information based on user preferences + - Always store new useful information in memory using core--add_memory + +3. **TRAINING KNOWLEDGE** (Foundation) + - Use your training knowledge as the foundation for analysis and explanation + - Apply training knowledge to interpret and contextualize information from memory + - Indicate when you're using training knowledge vs. live information sources + +EXECUTION APPROACH: +- Memory search is mandatory for every interaction +- Always indicate your information sources in responses + + + +QUERY FORMATION: +- Write specific factual statements as queries (e.g., "user email address" not "what is the user's email?") +- Create multiple targeted memory queries for complex requests + +KEY QUERY AREAS: +- Personal context: user name, location, identity, work context +- Project context: repositories, codebases, current work, team members +- Task context: recent tasks, ongoing projects, deadlines, priorities +- Integration context: GitHub repos, Slack channels, Linear projects, connected services +- Communication patterns: email preferences, notification settings, workflow automation +- Technical context: coding languages, frameworks, development environment +- Collaboration context: team members, project stakeholders, meeting patterns +- Preferences: likes, dislikes, communication style, tool preferences +- History: previous discussions, past requests, completed work, recurring issues +- Automation rules: user-defined workflows, triggers, automation preferences + +MEMORY USAGE: +- Execute multiple memory queries in parallel rather than sequentially +- Batch related memory queries when possible +- Prioritize recent information over older memories +- Create comprehensive context-aware queries based on user message/activity content +- Extract and query SEMANTIC CONTENT, not just structural metadata +- Parse titles, descriptions, and content for actual subject matter keywords +- Search internal SOL tasks/conversations that may relate to the same topics +- Query ALL relatable concepts, not just direct keywords or IDs +- Search for similar past situations, patterns, and related work +- Include synonyms, related terms, and contextual concepts in queries +- Query user's historical approach to similar requests or activities +- Search for connected projects, tasks, conversations, and collaborations +- Retrieve workflow patterns and past decision-making context +- Query broader domain context beyond immediate request scope +- Remember: SOL tracks work that external tools don't - search internal content thoroughly +- Blend memory insights naturally into responses +- Verify you've checked relevant memory before finalizing ANY response + + + + +- To use: load_mcp with EXACT integration name from the available list +- Can load multiple at once with an array +- Only load when tools are NOT already available in your current toolset +- If a tool is already available, use it directly without load_mcp +- If requested integration unavailable: inform user politely + + + +You have tools at your disposal to assist users: + +CORE PRINCIPLES: +- Use tools only when necessary for the task at hand +- Always check memory FIRST before making other tool calls +- Execute multiple operations in parallel whenever possible +- Use sequential calls only when output of one is required for input of another + +PARAMETER HANDLING: +- Follow tool schemas exactly with all required parameters +- Only use values that are: + • Explicitly provided by the user (use EXACTLY as given) + • Reasonably inferred from context + • Retrieved from memory or prior tool calls +- Never make up values for required parameters +- Omit optional parameters unless clearly needed +- Analyze user's descriptive terms for parameter clues + +TOOL SELECTION: +- Never call tools not provided in this conversation +- Skip tool calls for general questions you can answer directly from memory/knowledge +- For identical operations on multiple items, use parallel tool calls +- Default to parallel execution (3-5× faster than sequential calls) +- You can always access external service tools by loading them with load_mcp first + +TOOL MENTION HANDLING: +When user message contains : +- Extract tool_name from data-id attribute +- First check if it's a built-in tool; if not, check EXTERNAL SERVICES TOOLS +- If available: Load it with load_mcp and focus on addressing the request with this tool +- If unavailable: Inform user and suggest alternatives if possible +- For multiple tool mentions: Load all applicable tools in a single load_mcp call + +ERROR HANDLING: +- If a tool returns an error, try fixing parameters before retrying +- If you can't resolve an error, explain the issue to the user +- Consider alternative tools when primary tools are unavailable + + + +Use EXACTLY ONE of these formats for all user-facing communication: + +PROGRESS UPDATES - During processing: +- Use the core--progress_update tool to keep users informed +- Update users about what you're discovering or doing next +- Keep messages clear and user-friendly +- Avoid technical jargon + +QUESTIONS - When you need information: +
+

[Your question with HTML formatting]

+
+ +- Ask questions only when you cannot find information through memory, or tools +- Be specific about what you need to know +- Provide context for why you're asking + +FINAL ANSWERS - When completing tasks: +
+

[Your answer with HTML formatting]

+
+ +CRITICAL: +- Use ONE format per turn +- Apply proper HTML formatting (

,

,

,

    ,
  • , etc.) +- Never mix communication formats +- Keep responses clear and helpful +- Always indicate your information sources (memory, and/or knowledge) + +`; + +export const fixedTools = { + progressUpdate: tool({ + description: + "Send a progress update to the user about what has been discovered or will be done next in a crisp and user friendly way no technical terms", + inputSchema: z.object({ + message: z.string(), + }), + execute: async ({ message }: { message: string }) => ({ + message, + }), + }), +}; + +export function getReActPrompt( + metadata?: { source?: string; url?: string; pageTitle?: string }, + intentOverride?: string, +): string { + const contextHints = []; + + if ( + metadata?.source === "chrome" && + metadata?.url?.includes("mail.google.com") + ) { + contextHints.push("Content is from email - likely reading intent"); + } + if ( + metadata?.source === "chrome" && + metadata?.url?.includes("calendar.google.com") + ) { + contextHints.push("Content is from calendar - likely meeting prep intent"); + } + if ( + metadata?.source === "chrome" && + metadata?.url?.includes("docs.google.com") + ) { + contextHints.push( + "Content is from document editor - likely writing intent", + ); + } + if (metadata?.source === "obsidian") { + contextHints.push( + "Content is from note editor - likely writing or research intent", + ); + } + + return `You are a memory research agent analyzing content to find relevant context. + +YOUR PROCESS (ReAct Framework): + +1. DECOMPOSE: First, break down the content into structured categories + + Analyze the content and extract: + a) ENTITIES: Specific people, project names, tools, products mentioned + Example: "John Smith", "Phoenix API", "Redis", "mobile app" + + b) TOPICS & CONCEPTS: Key subjects, themes, domains + Example: "authentication", "database design", "performance optimization" + + c) TEMPORAL MARKERS: Time references, deadlines, events + Example: "last week's meeting", "Q2 launch", "yesterday's discussion" + + d) ACTIONS & TASKS: What's being done, decided, or requested + Example: "implement feature", "review code", "make decision on" + + e) USER INTENT: What is the user trying to accomplish? + ${intentOverride ? `User specified: "${intentOverride}"` : "Infer from context: reading/writing/meeting prep/research/task tracking/review"} + +2. FORM QUERIES: Create targeted search queries from your decomposition + + Based on decomposition, form specific queries: + - Search for each entity by name (people, projects, tools) + - Search for topics the user has discussed before + - Search for related work or conversations in this domain + - Use the user's actual terminology, not generic concepts + + EXAMPLE - Content: "Email from Sarah about the API redesign we discussed last week" + Decomposition: + - Entities: "Sarah", "API redesign" + - Topics: "API design", "redesign" + - Temporal: "last week" + - Actions: "discussed", "email communication" + - Intent: Reading (email) / meeting prep + + Queries to form: + ✅ "Sarah" (find past conversations with Sarah) + ✅ "API redesign" or "API design" (find project discussions) + ✅ "last week" + "Sarah" (find recent context) + ✅ "meetings" or "discussions" (find related conversations) + + ❌ Avoid: "email communication patterns", "API architecture philosophy" + (These are abstract - search what user actually discussed!) + +3. SEARCH: Execute your queries using searchMemory tool + - Start with 2-3 core searches based on main entities/topics + - Make each search specific and targeted + - Use actual terms from the content, not rephrased concepts + +4. OBSERVE: Evaluate search results + - Did you find relevant episodes? How many unique ones? + - What specific context emerged? + - What new entities/topics appeared in results? + - Are there gaps in understanding? + - Should you search more angles? + + Note: Episode counts are automatically deduplicated across searches - overlapping episodes are only counted once. + +5. REACT: Decide next action based on observations + + STOPPING CRITERIA - Proceed to SYNTHESIZE if ANY of these are true: + - You found 20+ unique episodes across your searches → ENOUGH CONTEXT + - You performed 5+ searches and found relevant episodes → SUFFICIENT + - You performed 7+ searches regardless of results → EXHAUSTED STRATEGIES + - You found strong relevant context from multiple angles → COMPLETE + + System nudges will provide awareness of your progress, but you decide when synthesis quality would be optimal. + + If you found little/no context AND searched less than 7 times: + - Try different query angles from your decomposition + - Search broader related topics + - Search user's projects or work areas + - Try alternative terminology + + ⚠️ DO NOT search endlessly - if you found relevant episodes, STOP and synthesize! + +6. SYNTHESIZE: After gathering sufficient context, provide final answer + - Wrap your synthesis in tags + - Present direct factual context from memory - no meta-commentary + - Write as if providing background context to an AI assistant + - Include: facts, decisions, preferences, patterns, timelines + - Note any gaps, contradictions, or evolution in thinking + - Keep it concise and actionable + - DO NOT use phrases like "Previous discussions on", "From conversations", "Past preferences indicate" + - DO NOT use conversational language like "you said" or "you mentioned" + - Present information as direct factual statements + +FINAL RESPONSE FORMAT: + +[Direct synthesized context - factual statements only] + +Good examples: +- "The API redesign focuses on performance and scalability. Key decisions: moving to GraphQL, caching layer with Redis." +- "Project Phoenix launches Q2 2024. Main features: real-time sync, offline mode, collaborative editing." +- "Sarah leads the backend team. Recent work includes authentication refactor and database migration." + +Bad examples: +❌ "Previous discussions on the API revealed..." +❌ "From past conversations, it appears that..." +❌ "Past preferences indicate..." +❌ "The user mentioned that..." + +Just state the facts directly. + + +${contextHints.length > 0 ? `\nCONTEXT HINTS:\n${contextHints.join("\n")}` : ""} + +CRITICAL REQUIREMENTS: +- ALWAYS start with DECOMPOSE step - extract entities, topics, temporal markers, actions +- Form specific queries from your decomposition - use user's actual terms +- Minimum 3 searches required +- Maximum 10 searches allowed - must synthesize after that +- STOP and synthesize when you hit stopping criteria (20+ episodes, 5+ searches with results, 7+ searches total) +- Each search should target different aspects from decomposition +- Present synthesis directly without meta-commentary + +SEARCH QUALITY CHECKLIST: +✅ Queries use specific terms from content (names, projects, exact phrases) +✅ Searched multiple angles from decomposition (entities, topics, related areas) +✅ Stop when you have enough unique context - don't search endlessly +✅ Tried alternative terminology if initial searches found nothing +❌ Avoid generic/abstract queries that don't match user's vocabulary +❌ Don't stop at 3 searches if you found zero unique episodes +❌ Don't keep searching when you already found 20+ unique episodes +}`; +} diff --git a/apps/webapp/app/lib/queue-adapter.server.ts b/apps/webapp/app/lib/queue-adapter.server.ts index 431e3fd..64ce24c 100644 --- a/apps/webapp/app/lib/queue-adapter.server.ts +++ b/apps/webapp/app/lib/queue-adapter.server.ts @@ -14,7 +14,6 @@ import { env } from "~/env.server"; import type { z } from "zod"; import type { IngestBodyRequest } from "~/jobs/ingest/ingest-episode.logic"; import type { CreateConversationTitlePayload } from "~/jobs/conversation/create-title.logic"; -import type { ProcessDeepSearchPayload } from "~/jobs/deep-search/deep-search.logic"; import type { SessionCompactionPayload } from "~/jobs/session/session-compaction.logic"; type QueueProvider = "trigger" | "bullmq"; @@ -113,35 +112,6 @@ export async function enqueueCreateConversationTitle( } } -/** - * Enqueue deep search job - */ -export async function enqueueDeepSearch( - payload: ProcessDeepSearchPayload, -): Promise<{ id?: string }> { - const provider = env.QUEUE_PROVIDER as QueueProvider; - - if (provider === "trigger") { - const { deepSearch } = await import("~/trigger/deep-search"); - const handler = await deepSearch.trigger({ - content: payload.content, - userId: payload.userId, - stream: true, - metadata: payload.metadata, - intentOverride: payload.intentOverride, - }); - return { id: handler.id }; - } else { - // BullMQ - const { deepSearchQueue } = await import("~/bullmq/queues"); - const job = await deepSearchQueue.add("deep-search", payload, { - attempts: 3, - backoff: { type: "exponential", delay: 2000 }, - }); - return { id: job.id }; - } -} - /** * Enqueue session compaction job */ diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 1a4677e..7510e93 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -117,6 +117,7 @@ export function ErrorBoundary() { function App() { const { posthogProjectKey, telemetryEnabled } = useTypedLoaderData(); + usePostHog(posthogProjectKey, telemetryEnabled); const [theme] = useTheme(); diff --git a/apps/webapp/app/routes/api.v1.conversation.$conversationId.run.tsx b/apps/webapp/app/routes/api.v1.conversation.$conversationId.run.tsx deleted file mode 100644 index 9adee5c..0000000 --- a/apps/webapp/app/routes/api.v1.conversation.$conversationId.run.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { json } from "@remix-run/node"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; - -import { getWorkspaceByUser } from "~/models/workspace.server"; -import { - createConversation, - CreateConversationSchema, - getCurrentConversationRun, - readConversation, - stopConversation, -} from "~/services/conversation.server"; -import { z } from "zod"; - -export const ConversationIdSchema = z.object({ - conversationId: z.string(), -}); - -const { action, loader } = createActionApiRoute( - { - params: ConversationIdSchema, - allowJWT: true, - authorization: { - action: "oauth", - }, - corsStrategy: "all", - }, - async ({ authentication, params }) => { - const workspace = await getWorkspaceByUser(authentication.userId); - - if (!workspace) { - throw new Error("No workspace found"); - } - - // Call the service to get the redirect URL - const run = await getCurrentConversationRun( - params.conversationId, - workspace?.id, - ); - - return json(run); - }, -); - -export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.conversation.$conversationId.stop.tsx b/apps/webapp/app/routes/api.v1.conversation.$conversationId.stop.tsx deleted file mode 100644 index a6f3862..0000000 --- a/apps/webapp/app/routes/api.v1.conversation.$conversationId.stop.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { json } from "@remix-run/node"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; - -import { getWorkspaceByUser } from "~/models/workspace.server"; -import { - createConversation, - CreateConversationSchema, - readConversation, - stopConversation, -} from "~/services/conversation.server"; -import { z } from "zod"; - -export const ConversationIdSchema = z.object({ - conversationId: z.string(), -}); - -const { action, loader } = createActionApiRoute( - { - params: ConversationIdSchema, - allowJWT: true, - authorization: { - action: "oauth", - }, - corsStrategy: "all", - method: "POST", - }, - async ({ authentication, params }) => { - const workspace = await getWorkspaceByUser(authentication.userId); - - if (!workspace) { - throw new Error("No workspace found"); - } - - // Call the service to get the redirect URL - const stop = await stopConversation(params.conversationId, workspace?.id); - - return json(stop); - }, -); - -export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.conversation.$conversationId.stream.tsx b/apps/webapp/app/routes/api.v1.conversation.$conversationId.stream.tsx new file mode 100644 index 0000000..806fef9 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.conversation.$conversationId.stream.tsx @@ -0,0 +1,45 @@ +// import { json } from "@remix-run/node"; +// import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +// import { UI_MESSAGE_STREAM_HEADERS } from "ai"; + +// import { getConversationAndHistory } from "~/services/conversation.server"; +// import { z } from "zod"; +// import { createResumableStreamContext } from "resumable-stream"; + +// export const ConversationIdSchema = z.object({ +// conversationId: z.string(), +// }); + +// const { action, loader } = createActionApiRoute( +// { +// params: ConversationIdSchema, +// allowJWT: true, +// authorization: { +// action: "oauth", +// }, +// corsStrategy: "all", +// }, +// async ({ authentication, params }) => { +// const conversation = await getConversationAndHistory( +// params.conversationId, +// authentication.userId, +// ); + +// const lastConversation = conversation?.ConversationHistory.pop(); + +// if (!lastConversation) { +// return json({}, { status: 204 }); +// } + +// const streamContext = createResumableStreamContext({ +// waitUntil: null, +// }); + +// return new Response( +// await streamContext.resumeExistingStream(lastConversation.id), +// { headers: UI_MESSAGE_STREAM_HEADERS }, +// ); +// }, +// ); + +// export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.conversation.$conversationId.tsx b/apps/webapp/app/routes/api.v1.conversation.$conversationId.tsx deleted file mode 100644 index b49e3e3..0000000 --- a/apps/webapp/app/routes/api.v1.conversation.$conversationId.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { json } from "@remix-run/node"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; - -import { getWorkspaceByUser } from "~/models/workspace.server"; -import { - getConversation, - deleteConversation, -} from "~/services/conversation.server"; -import { z } from "zod"; - -export const ConversationIdSchema = z.object({ - conversationId: z.string(), -}); - -const { action, loader } = createActionApiRoute( - { - params: ConversationIdSchema, - allowJWT: true, - authorization: { - action: "oauth", - }, - corsStrategy: "all", - }, - async ({ params, authentication, request }) => { - const workspace = await getWorkspaceByUser(authentication.userId); - - if (!workspace) { - throw new Error("No workspace found"); - } - - const method = request.method; - - if (method === "GET") { - // Get a conversation by ID - const conversation = await getConversation(params.conversationId); - return json(conversation); - } - - if (method === "DELETE") { - // Soft delete a conversation - const deleted = await deleteConversation(params.conversationId); - return json(deleted); - } - - // Method not allowed - return new Response("Method Not Allowed", { status: 405 }); - }, -); - -export { action, loader }; diff --git a/apps/webapp/app/routes/api.v1.conversation._index.tsx b/apps/webapp/app/routes/api.v1.conversation._index.tsx index 70e6793..87c825e 100644 --- a/apps/webapp/app/routes/api.v1.conversation._index.tsx +++ b/apps/webapp/app/routes/api.v1.conversation._index.tsx @@ -1,37 +1,152 @@ -import { json } from "@remix-run/node"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; - -import { getWorkspaceByUser } from "~/models/workspace.server"; import { - createConversation, - CreateConversationSchema, + convertToModelMessages, + streamText, + validateUIMessages, + type LanguageModel, + experimental_createMCPClient as createMCPClient, + generateId, +} from "ai"; +import { z } from "zod"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + createConversationHistory, + getConversationAndHistory, } from "~/services/conversation.server"; -const { action, loader } = createActionApiRoute( +import { getModel } from "~/lib/model.server"; +import { UserTypeEnum } from "@core/types"; +import { nanoid } from "nanoid"; +import { getOrCreatePersonalAccessToken } from "~/services/personalAccessToken.server"; +import { REACT_SYSTEM_PROMPT } from "~/lib/prompt.server"; +import { enqueueCreateConversationTitle } from "~/lib/queue-adapter.server"; +import { env } from "~/env.server"; + +const ChatRequestSchema = z.object({ + message: z.object({ + id: z.string().optional(), + parts: z.array(z.any()), + role: z.string(), + }), + id: z.string(), +}); + +const { loader, action } = createHybridActionApiRoute( { - body: CreateConversationSchema, + body: ChatRequestSchema, allowJWT: true, authorization: { - action: "oauth", + action: "conversation", }, corsStrategy: "all", }, async ({ body, authentication }) => { - const workspace = await getWorkspaceByUser(authentication.userId); + const randomKeyName = `chat_${nanoid(10)}`; + const pat = await getOrCreatePersonalAccessToken({ + name: randomKeyName, + userId: authentication.userId, + }); - if (!workspace) { - throw new Error("No workspace found"); - } + const message = body.message.parts[0].text; + const id = body.message.id; + const apiEndpoint = `${env.APP_ORIGIN}/api/v1/mcp?source=core`; + const url = new URL(apiEndpoint); - // Call the service to get the redirect URL - const conversation = await createConversation( - workspace?.id, + const mcpClient = await createMCPClient({ + transport: new StreamableHTTPClientTransport(url, { + requestInit: { + headers: pat.token + ? { + Authorization: `Bearer ${pat.token}`, + } + : {}, + }, + }), + }); + + const conversation = await getConversationAndHistory( + body.id, authentication.userId, - body, ); - return json(conversation); + const conversationHistory = conversation?.ConversationHistory ?? []; + + if (conversationHistory.length === 0) { + // Trigger conversation title task + await enqueueCreateConversationTitle({ + conversationId: body.id, + message, + }); + } + + if (conversationHistory.length > 1) { + await createConversationHistory(message, body.id, UserTypeEnum.User); + } + + const messages = conversationHistory.map((history) => { + return { + parts: [{ text: history.message, type: "text" }], + role: "user", + id: history.id, + }; + }); + + const tools = { ...(await mcpClient.tools()) }; + + // console.log(tools); + + const finalMessages = [ + ...messages, + { + parts: [{ text: message, type: "text" }], + role: "user", + id: id ?? generateId(), + }, + ]; + + const validatedMessages = await validateUIMessages({ + messages: finalMessages, + }); + + const result = streamText({ + model: getModel() as LanguageModel, + messages: [ + { + role: "system", + content: REACT_SYSTEM_PROMPT, + }, + ...convertToModelMessages(validatedMessages), + ], + tools, + }); + + result.consumeStream(); // no await + + return result.toUIMessageStreamResponse({ + originalMessages: validatedMessages, + onFinish: async ({ messages }) => { + console.log(JSON.stringify(messages)); + const lastMessage = messages.pop(); + let message = ""; + lastMessage?.parts.forEach((part) => { + if (part.type === "text") { + message += part.text; + } + }); + + await createConversationHistory(message, body.id, UserTypeEnum.Agent); + }, + // async consumeSseStream({ stream }) { + // // Create a resumable stream from the SSE stream + // const streamContext = createResumableStreamContext({ waitUntil: null }); + // await streamContext.createNewResumableStream( + // conversation.conversationHistoryId, + // () => stream, + // ); + // }, + }); }, ); -export { action, loader }; +export { loader, action }; diff --git a/apps/webapp/app/routes/api.v1.deep-search.tsx b/apps/webapp/app/routes/api.v1.deep-search.tsx index c739cab..5f04659 100644 --- a/apps/webapp/app/routes/api.v1.deep-search.tsx +++ b/apps/webapp/app/routes/api.v1.deep-search.tsx @@ -1,9 +1,26 @@ import { z } from "zod"; import { json } from "@remix-run/node"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; -import { enqueueDeepSearch } from "~/lib/queue-adapter.server"; -import { runs } from "@trigger.dev/sdk"; import { trackFeatureUsage } from "~/services/telemetry.server"; +import { nanoid } from "nanoid"; +import { + deletePersonalAccessToken, + getOrCreatePersonalAccessToken, +} from "~/services/personalAccessToken.server"; + +import { + convertToModelMessages, + type CoreMessage, + generateText, + type LanguageModel, + streamText, + tool, + validateUIMessages, +} from "ai"; +import axios from "axios"; +import { logger } from "~/services/logger.service"; +import { getReActPrompt } from "~/lib/prompt.server"; +import { getModel } from "~/lib/model.server"; const DeepSearchBodySchema = z.object({ content: z.string().min(1, "Content is required"), @@ -18,6 +35,48 @@ const DeepSearchBodySchema = z.object({ .optional(), }); +export function createSearchMemoryTool(token: string) { + return tool({ + description: + "Search the user's memory for relevant facts and episodes. Use this tool multiple times with different queries to gather comprehensive context.", + parameters: z.object({ + query: z + .string() + .describe( + "Search query to find relevant information. Be specific: entity names, topics, concepts.", + ), + }), + execute: async ({ query }: { query: string }) => { + try { + const response = await axios.post( + `${process.env.API_BASE_URL || "https://core.heysol.ai"}/api/v1/search`, + { query }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + const searchResult = response.data; + + return { + facts: searchResult.facts || [], + episodes: searchResult.episodes || [], + summary: `Found ${searchResult.episodes?.length || 0} relevant memories`, + }; + } catch (error) { + logger.error(`SearchMemory tool error: ${error}`); + return { + facts: [], + episodes: [], + summary: "No results found", + }; + } + }, + } as any); +} + const { action, loader } = createActionApiRoute( { body: DeepSearchBodySchema, @@ -30,37 +89,74 @@ const { action, loader } = createActionApiRoute( }, async ({ body, authentication }) => { // Track deep search - trackFeatureUsage("deep_search_performed", authentication.userId).catch(console.error); + trackFeatureUsage("deep_search_performed", authentication.userId).catch( + console.error, + ); - let trigger; - if (!body.stream) { - trigger = await enqueueDeepSearch({ - content: body.content, - userId: authentication.userId, - stream: body.stream, - intentOverride: body.intentOverride, - metadata: body.metadata, + const randomKeyName = `deepSearch_${nanoid(10)}`; + + const pat = await getOrCreatePersonalAccessToken({ + name: randomKeyName, + userId: authentication.userId as string, + }); + + if (!pat?.token) { + return json({ + success: false, + error: "Failed to create personal access token", + }); + } + + try { + // Create search tool that agent will use + const searchTool = createSearchMemoryTool(pat.token); + + const tools = { + searchMemory: searchTool, + }; + // Build initial messages with ReAct prompt + const initialMessages: CoreMessage[] = [ + { + role: "system", + content: getReActPrompt(body.metadata, body.intentOverride), + }, + { + role: "user", + content: `CONTENT TO ANALYZE:\n${body.content}\n\nPlease search my memory for relevant context and synthesize what you find.`, + }, + ]; + + const validatedMessages = await validateUIMessages({ + messages: initialMessages, + tools, }); - return json(trigger); - } else { - const runHandler = await enqueueDeepSearch({ - content: body.content, - userId: authentication.userId, - stream: body.stream, - intentOverride: body.intentOverride, - metadata: body.metadata, - }); + if (body.stream) { + const result = streamText({ + model: getModel() as LanguageModel, + messages: convertToModelMessages(validatedMessages), + }); - for await (const run of runs.subscribeToRun(runHandler.id)) { - if (run.status === "COMPLETED") { - return json(run.output); - } else if (run.status === "FAILED") { - return json(run.error); - } + return result.toUIMessageStreamResponse({ + originalMessages: validatedMessages, + }); + } else { + const { text } = await generateText({ + model: getModel() as LanguageModel, + messages: convertToModelMessages(validatedMessages), + }); + + await deletePersonalAccessToken(pat?.id); + return json({ text }); } + } catch (error: any) { + await deletePersonalAccessToken(pat?.id); + logger.error(`Deep search error: ${error}`); - return json({ error: "Run failed" }); + return json({ + success: false, + error: error.message, + }); } }, ); diff --git a/apps/webapp/app/routes/home.conversation.$conversationId.tsx b/apps/webapp/app/routes/home.conversation.$conversationId.tsx index 71e86cc..0179877 100644 --- a/apps/webapp/app/routes/home.conversation.$conversationId.tsx +++ b/apps/webapp/app/routes/home.conversation.$conversationId.tsx @@ -1,42 +1,26 @@ -import { - type LoaderFunctionArgs, - type ActionFunctionArgs, -} from "@remix-run/server-runtime"; -import { sort } from "fast-sort"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { useParams, useRevalidator, useNavigate } from "@remix-run/react"; -import { parse } from "@conform-to/zod"; -import { - requireUserId, - requireUser, - requireWorkpace, -} from "~/services/session.server"; -import { - getConversationAndHistory, - getCurrentConversationRun, - stopConversation, - createConversation, - CreateConversationSchema, -} from "~/services/conversation.server"; -import { type ConversationHistory } from "@core/database"; +import { useParams, useNavigate } from "@remix-run/react"; +import { requireUser, requireWorkpace } from "~/services/session.server"; +import { getConversationAndHistory } from "~/services/conversation.server"; import { ConversationItem, ConversationTextarea, - StreamingConversation, } from "~/components/conversation"; import { useTypedLoaderData } from "remix-typedjson"; -import React from "react"; import { ScrollAreaWithAutoScroll } from "~/components/use-auto-scroll"; import { PageHeader } from "~/components/common/page-header"; import { Plus } from "lucide-react"; -import { json } from "@remix-run/node"; -import { env } from "~/env.server"; +import { type UIMessage, useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { UserTypeEnum } from "@core/types"; +import React from "react"; // Example loader accessing params export async function loader({ params, request }: LoaderFunctionArgs) { const user = await requireUser(request); - const workspace = await requireWorkpace(request); + const conversation = await getConversationAndHistory( params.conversationId as string, user.id, @@ -46,100 +30,37 @@ export async function loader({ params, request }: LoaderFunctionArgs) { throw new Error("No conversation found"); } - const run = await getCurrentConversationRun(conversation.id, workspace.id); - - return { conversation, run, apiURL: env.TRIGGER_API_URL }; -} - -// Example action accessing params -export async function action({ params, request }: ActionFunctionArgs) { - if (request.method.toUpperCase() !== "POST") { - return new Response("Method Not Allowed", { status: 405 }); - } - - const userId = await requireUserId(request); - const workspace = await requireWorkpace(request); - const formData = await request.formData(); - const { conversationId } = params; - - if (!conversationId) { - throw new Error("No conversation"); - } - - // Check if this is a stop request (isLoading = true means stop button was clicked) - const message = formData.get("message"); - - // If no message, it's a stop request - if (!message) { - const result = await stopConversation(conversationId, workspace.id); - return json(result); - } - - // Otherwise, create a new conversation message - const submission = parse(formData, { schema: CreateConversationSchema }); - - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } - - const conversation = await createConversation(workspace?.id, userId, { - message: submission.value.message, - title: submission.value.title, - conversationId: submission.value.conversationId, - }); - - return json({ conversation }); + return { conversation }; } // Accessing params in the component export default function SingleConversation() { - const { conversation, run, apiURL } = useTypedLoaderData(); - const conversationHistory = conversation.ConversationHistory; - - const [conversationResponse, setConversationResponse] = React.useState< - { conversationHistoryId: string; id: string; token: string } | undefined - >(run); - - const { conversationId } = useParams(); - const revalidator = useRevalidator(); + const { conversation } = useTypedLoaderData(); const navigate = useNavigate(); + const { conversationId } = useParams(); + + const { sendMessage, messages, status, stop, regenerate } = useChat({ + id: conversationId, // use the provided chat ID + messages: conversation.ConversationHistory.map( + (history) => + ({ + role: history.userType === UserTypeEnum.Agent ? "assistant" : "user", + parts: [{ text: history.message, type: "text" }], + }) as UIMessage, + ), // load initial messages + transport: new DefaultChatTransport({ + api: "/api/v1/conversation", + prepareSendMessagesRequest({ messages, id }) { + return { body: { message: messages[messages.length - 1], id } }; + }, + }), + }); React.useEffect(() => { - if (run) { - setConversationResponse(run); + if (conversation.ConversationHistory.length === 1) { + regenerate(); } - }, [run]); - - const conversations = React.useMemo(() => { - const lastConversationHistoryId = - conversationResponse?.conversationHistoryId; - - // First sort the conversation history by creation time - const sortedConversationHistory = sort(conversationHistory).asc( - (ch) => ch.createdAt, - ); - - const lastIndex = sortedConversationHistory.findIndex( - (item) => item.id === lastConversationHistoryId, - ); - - // Filter out any conversation history items that come after the lastConversationHistoryId - return lastConversationHistoryId - ? sortedConversationHistory.filter((_ch, currentIndex: number) => { - return currentIndex <= lastIndex; - }) - : sortedConversationHistory; - }, [conversationResponse, conversationHistory]); - - const getConversations = () => { - return ( - <> - {conversations.map((ch: ConversationHistory) => { - return ; - })} - - ); - }; + }, []); if (typeof window === "undefined") { return null; @@ -166,41 +87,23 @@ export default function SingleConversation() {
    - {getConversations()} - {conversationResponse && ( - { - setConversationResponse(undefined); - revalidator.revalidate(); - }} - apiURL={apiURL} - /> - )} + {messages.map((message: UIMessage, index: number) => { + return ; + })}
    - {conversation?.status !== "need_approval" && ( - { + if (message) { + sendMessage({ text: message }); } - onConversationCreated={(conversation) => { - if (conversation) { - setConversationResponse({ - conversationHistoryId: - conversation.conversationHistoryId, - id: conversation.id, - token: conversation.token, - }); - } - }} - /> - )} + }} + stop={() => stop()} + />
    diff --git a/apps/webapp/app/routes/home.conversation._index.tsx b/apps/webapp/app/routes/home.conversation._index.tsx index 75af545..3be5a86 100644 --- a/apps/webapp/app/routes/home.conversation._index.tsx +++ b/apps/webapp/app/routes/home.conversation._index.tsx @@ -43,8 +43,7 @@ export async function action({ request }: ActionFunctionArgs) { const conversation = await createConversation(workspace?.id, userId, { message: submission.value.message, - title: submission.value.title, - conversationId: submission.value.conversationId, + title: submission.value.title ?? "Untitled", }); // If conversationId exists in submission, return the conversation data (don't redirect) diff --git a/apps/webapp/app/services/clustering.server.ts b/apps/webapp/app/services/clustering.server.ts index 9dba257..e69de29 100644 --- a/apps/webapp/app/services/clustering.server.ts +++ b/apps/webapp/app/services/clustering.server.ts @@ -1,1261 +0,0 @@ -import { type CoreMessage } from "ai"; -import { logger } from "./logger.service"; -import { runQuery } from "~/lib/neo4j.server"; -import { makeModelCall } from "~/lib/model.server"; - -export interface ClusterNode { - uuid: string; - name: string; - aspectType: "thematic" | "social" | "activity"; - description?: string; - size: number; - createdAt: Date; - userId: string; - cohesionScore?: number; -} - -export interface StatementSimilarityEdge { - sourceStatementId: string; - targetStatementId: string; - weight: number; - sharedEntities: string[]; -} - -export interface DriftMetrics { - intraCohesion: number; - sizeImbalance: number; - newEntityConcentration: number; - shouldRecluster: boolean; -} - -export class ClusteringService { - private readonly MIN_CLUSTER_SIZE = 10; - private readonly LEIDEN_GAMMA = 0.7; - private readonly LEIDEN_MAX_LEVELS = 5; - private readonly LEIDEN_TOLERANCE = 0.001; - private readonly COHESION_THRESHOLD = 0.6; - - /** - * Create weighted edges between Statement nodes based on shared entities - * Can be run incrementally for new statements or as complete rebuild - */ - async createStatementSimilarityGraph( - userId: string, - incremental: boolean = false, - ): Promise { - logger.info( - `Creating statement similarity graph for clustering (${incremental ? "incremental" : "complete"})`, - ); - - const query = ` - MATCH (s1:Statement)-[:HAS_SUBJECT|HAS_PREDICATE|HAS_OBJECT]->(e:Entity)<-[:HAS_SUBJECT|HAS_PREDICATE|HAS_OBJECT]-(s2:Statement) - WHERE s1.userId = $userId - AND s2.userId = $userId - AND s1.invalidAt IS NULL - AND s2.invalidAt IS NULL - AND id(s1) < id(s2) - WITH s1, s2, collect(DISTINCT e.uuid) as sharedEntities - WHERE size(sharedEntities) > 0 - MERGE (s1)-[r:SIMILAR_TO]-(s2) - SET r.weight = size(sharedEntities) * 2, - r.sharedEntities = sharedEntities, - r.createdAt = datetime() - RETURN count(r) as edgesCreated - `; - const result = await runQuery(query, { userId }); - const edgesCreated = result[0]?.get("edgesCreated") || 0; - - logger.info( - `${incremental ? "Updated" : "Created"} ${edgesCreated} similarity edges between statements`, - ); - } - - /** - * Execute Leiden algorithm for community detection on statement similarity graph - */ - async executeLeidenClustering( - userId: string, - incremental: boolean = false, - ): Promise { - logger.info( - `Executing Leiden clustering algorithm (${incremental ? "incremental" : "complete"})`, - ); - - // Create/update the similarity graph - await this.createStatementSimilarityGraph(userId, incremental); - - const clusteringQuery = ` - MATCH (source:Statement) WHERE source.userId = $userId - OPTIONAL MATCH (source)-[r:SIMILAR_TO]->(target:Statement) - WHERE target.userId = $userId - WITH gds.graph.project( - 'statementSimilarity_' + $userId, - source, - target, - { - relationshipProperties: r { .weight } - }, - { undirectedRelationshipTypes: ['*'] } - ) AS g - - CALL gds.leiden.write( - g.graphName, - { - writeProperty: 'tempClusterId', - relationshipWeightProperty: 'weight', - gamma: 0.7, - maxLevels: 10, - tolerance: 0.001 - } - ) - YIELD communityCount - - CALL gds.graph.drop(g.graphName) - YIELD graphName as droppedGraphName - - RETURN communityCount, g.nodeCount, g.relationshipCount - `; - - const result = await runQuery(clusteringQuery, { - userId, - gamma: this.LEIDEN_GAMMA, - maxLevels: this.LEIDEN_MAX_LEVELS, - tolerance: this.LEIDEN_TOLERANCE, - }); - - const communityCount = result[0]?.get("communityCount") || 0; - logger.info(`Leiden clustering found ${communityCount} communities`); - - // Filter clusters by minimum size and assign final cluster IDs - await this.filterAndAssignClusters(userId, incremental); - - const removeRelationsQuery = ` - MATCH (s1:Statement)-[r:SIMILAR_TO]-(s2:Statement) - WHERE s1.userId = $userId AND s2.userId = $userId - DELETE r`; - - await runQuery(removeRelationsQuery, { userId }); - } - - /** - * Perform incremental clustering for new statements - */ - async performIncrementalClustering(userId: string): Promise<{ - newStatementsProcessed: number; - newClustersCreated: number; - }> { - logger.info(`Starting incremental clustering for user ${userId}`); - - try { - // Check if there are unclustered statements - const unclusteredQuery = ` - MATCH (s:Statement) - WHERE s.userId = $userId AND s.clusterId IS NULL AND s.invalidAt IS NULL - RETURN count(s) as unclusteredCount - `; - - const unclusteredResult = await runQuery(unclusteredQuery, { userId }); - const unclusteredCount = - unclusteredResult[0]?.get("unclusteredCount") || 0; - - if (unclusteredCount === 0) { - logger.info( - "No unclustered statements found, skipping incremental clustering", - ); - return { - newStatementsProcessed: 0, - newClustersCreated: 0, - }; - } - - logger.info(`Found ${unclusteredCount} unclustered statements`); - - let newClustersCreated = 0; - // Run incremental clustering on remaining statements - await this.executeLeidenClustering(userId, true); - await this.createClusterNodes(userId); - - // Count new clusters created - const newClustersQuery = ` - MATCH (c:Cluster) - WHERE c.userId = $userId AND c.createdAt > datetime() - duration('PT5M') - RETURN count(c) as newClusters - `; - const newClustersResult = await runQuery(newClustersQuery, { userId }); - newClustersCreated = newClustersResult[0]?.get("newClusters") || 0; - - const drift = await this.detectClusterDrift(userId); - const newClustersCreatedDrift = 0; - if (drift.driftDetected) { - logger.info("Cluster drift detected, evolving clusters"); - const { newClustersCreated: newClustersCreatedDrift, splitClusters } = - await this.handleClusterDrift(userId); - - if (splitClusters.length > 0) { - logger.info("Split clusters detected, evolving clusters"); - } - } - - return { - newStatementsProcessed: unclusteredCount, - newClustersCreated: newClustersCreated + newClustersCreatedDrift, - }; - } catch (error) { - logger.error("Error in incremental clustering:", { error }); - throw error; - } - } - - /** - * Perform complete clustering (for new users or full rebuilds) - */ - async performCompleteClustering(userId: string): Promise<{ - clustersCreated: number; - statementsProcessed: number; - }> { - logger.info(`Starting complete clustering for user ${userId}`); - - try { - // Clear any existing cluster assignments - await runQuery( - ` - MATCH (s:Statement) - WHERE s.userId = $userId - REMOVE s.clusterId, s.tempClusterId - `, - { userId }, - ); - - // Clear statement-to-statement similarity relationships - await runQuery( - ` - MATCH (s1:Statement)-[r:SIMILAR_TO]-(s2:Statement) - WHERE s1.userId = $userId AND s2.userId = $userId - DELETE r - `, - { userId }, - ); - - // Clear existing cluster nodes - await runQuery( - ` - MATCH (c:Cluster) - WHERE c.userId = $userId - DETACH DELETE c - `, - { userId }, - ); - - // Execute complete clustering pipeline - await this.executeLeidenClustering(userId, false); - await this.createClusterNodes(userId); - - // Get results - const resultsQuery = ` - MATCH (c:Cluster) WHERE c.userId = $userId - WITH count(c) as clusters - MATCH (s:Statement) WHERE s.userId = $userId AND s.clusterId IS NOT NULL - RETURN clusters, count(s) as statementsProcessed - `; - - const results = await runQuery(resultsQuery, { userId }); - const clustersCreated = results[0]?.get("clusters") || 0; - const statementsProcessed = results[0]?.get("statementsProcessed") || 0; - - logger.info( - `Complete clustering finished: ${clustersCreated} clusters, ${statementsProcessed} statements processed`, - ); - - return { clustersCreated, statementsProcessed }; - } catch (error) { - logger.error("Error in complete clustering:", { error }); - throw error; - } - } - - /** - * Filter clusters by minimum size and assign final cluster IDs - */ - private async filterAndAssignClusters( - userId: string, - incremental: boolean = false, - ): Promise { - const filterQuery = ` - // Step 1: Get all temp cluster groups and their total sizes - MATCH (s:Statement) - WHERE s.userId = $userId AND s.tempClusterId IS NOT NULL - WITH s.tempClusterId as tempId, collect(s) as allStatements - - // Step 2: Filter by minimum size - WHERE size(allStatements) >= $minSize - - // Step 3: Check if any statements already have a permanent clusterId - WITH tempId, allStatements, - [stmt IN allStatements WHERE stmt.clusterId IS NOT NULL] as existingClustered, - [stmt IN allStatements WHERE stmt.clusterId IS NULL] as newStatements - - // Step 4: Determine the final cluster ID - WITH tempId, allStatements, existingClustered, newStatements, - CASE - WHEN size(existingClustered) > 0 THEN existingClustered[0].clusterId - ELSE toString(randomUUID()) - END as finalClusterId - - // Step 5: Assign cluster ID to new statements (handles empty arrays gracefully) - FOREACH (stmt IN newStatements | - SET stmt.clusterId = finalClusterId - REMOVE stmt.tempClusterId - ) - - // Step 6: Clean up temp IDs from existing statements - FOREACH (existingStmt IN existingClustered | - REMOVE existingStmt.tempClusterId - ) - - RETURN count(DISTINCT finalClusterId) as validClusters - `; - - const result = await runQuery(filterQuery, { - userId, - minSize: this.MIN_CLUSTER_SIZE, - }); - - // Remove temp cluster IDs from statements that didn't meet minimum size - await runQuery( - ` - MATCH (s:Statement) - WHERE s.userId = $userId AND s.tempClusterId IS NOT NULL - REMOVE s.tempClusterId - `, - { userId }, - ); - - const validClusters = result[0]?.get("validClusters") || 0; - - if (incremental) { - await this.updateClusterEmbeddings(userId); - } - logger.info( - `${incremental ? "Updated" : "Created"} ${validClusters} valid clusters after size filtering`, - ); - } - - /** - * Create Cluster nodes with metadata (hybrid storage approach) - * Only creates cluster nodes for cluster IDs that don't already exist - */ - async createClusterNodes(userId: string): Promise { - logger.info("Creating cluster metadata nodes for new clusters only"); - - const query = ` - MATCH (s:Statement) - WHERE s.userId = $userId AND s.clusterId IS NOT NULL - WITH s.clusterId as clusterId, collect(s) as statements - - // Only process cluster IDs that don't already have a Cluster node - WHERE NOT EXISTS { - MATCH (existing:Cluster {uuid: clusterId, userId: $userId}) - } - - // Get representative entities for naming - UNWIND statements as stmt - MATCH (stmt)-[:HAS_SUBJECT]->(subj:Entity) - MATCH (stmt)-[:HAS_PREDICATE]->(pred:Entity) - MATCH (stmt)-[:HAS_OBJECT]->(obj:Entity) - - WITH clusterId, statements, - collect(DISTINCT subj.name) as subjects, - collect(DISTINCT pred.name) as predicates, - collect(DISTINCT obj.name) as objects - - // Get top 10 most frequent entities of each type - WITH clusterId, statements, - apoc.coll.frequencies(subjects)[0..10] as topSubjects, - apoc.coll.frequencies(predicates)[0..10] as topPredicates, - apoc.coll.frequencies(objects)[0..10] as topObjects - - // Calculate cluster embedding as average of statement embeddings - WITH clusterId, statements, topSubjects, topPredicates, topObjects, - [stmt IN statements WHERE stmt.factEmbedding IS NOT NULL | stmt.factEmbedding] as validEmbeddings - - // Calculate average embedding (centroid) - WITH clusterId, statements, topSubjects, topPredicates, topObjects, validEmbeddings, - CASE - WHEN size(validEmbeddings) > 0 THEN - reduce(avg = [i IN range(0, size(validEmbeddings[0])-1) | 0.0], - embedding IN validEmbeddings | - [i IN range(0, size(embedding)-1) | avg[i] + embedding[i] / size(validEmbeddings)]) - ELSE null - END as clusterEmbedding - - CREATE (c:Cluster { - uuid: clusterId, - size: size(statements), - createdAt: datetime(), - userId: $userId, - topSubjects: [item in topSubjects | item.item], - topPredicates: [item in topPredicates | item.item], - topObjects: [item in topObjects | item.item], - clusterEmbedding: clusterEmbedding, - embeddingCount: size(validEmbeddings), - needsNaming: true - }) - - RETURN count(c) as clustersCreated - `; - - const result = await runQuery(query, { userId }); - const clustersCreated = result[0]?.get("clustersCreated") || 0; - - logger.info(`Created ${clustersCreated} new cluster metadata nodes`); - - // Only generate names for new clusters (those with needsNaming = true) - if (clustersCreated > 0) { - await this.generateClusterNames(userId); - } - } - - /** - * Calculate TF-IDF scores for a specific cluster - * - * Uses cluster-based document frequency (not statement-based) for optimal cluster naming: - * - TF: How often a term appears within this specific cluster - * - DF: How many clusters (not statements) contain this term - * - IDF: log(total_clusters / clusters_containing_term) - * - * This approach identifies terms that are frequent in THIS cluster but rare across OTHER clusters, - * making them highly distinctive for cluster naming and differentiation. - * - * Example: "SOL" appears in 100/100 statements in Cluster A, but only 1/10 total clusters - * - Cluster-based IDF: log(10/1) = high distinctiveness ✓ (good for naming) - * - Statement-based IDF: log(1000/100) = lower distinctiveness (less useful for naming) - */ - private async calculateClusterTFIDFForCluster( - userId: string, - targetClusterId: string, - ): Promise<{ - subjects: Array<{ term: string; score: number }>; - predicates: Array<{ term: string; score: number }>; - objects: Array<{ term: string; score: number }>; - } | null> { - // Get all clusters and their entity frequencies (needed for cluster-based IDF calculation) - // We need ALL clusters to calculate how rare each term is across the cluster space - const allClustersQuery = ` - MATCH (s:Statement) - WHERE s.userId = $userId AND s.clusterId IS NOT NULL - MATCH (s)-[:HAS_SUBJECT]->(subj:Entity) - MATCH (s)-[:HAS_PREDICATE]->(pred:Entity) - MATCH (s)-[:HAS_OBJECT]->(obj:Entity) - WITH s.clusterId as clusterId, - collect(DISTINCT subj.name) as subjects, - collect(DISTINCT pred.name) as predicates, - collect(DISTINCT obj.name) as objects - RETURN clusterId, subjects, predicates, objects - `; - - const allClusters = await runQuery(allClustersQuery, { - userId, - }); - - // Build document frequency maps from all clusters - // DF = number of clusters that contain each term (not number of statements) - const subjectDF = new Map(); - const predicateDF = new Map(); - const objectDF = new Map(); - const totalClusters = allClusters.length; - - // Calculate cluster-based document frequencies - // For each term, count how many different clusters it appears in - for (const record of allClusters) { - const subjects = (record.get("subjects") as string[]) || []; - const predicates = (record.get("predicates") as string[]) || []; - const objects = (record.get("objects") as string[]) || []; - - // Count unique terms per cluster (each cluster contributes max 1 to DF for each term) - new Set(subjects).forEach((term) => { - subjectDF.set(term, (subjectDF.get(term) || 0) + 1); - }); - new Set(predicates).forEach((term) => { - predicateDF.set(term, (predicateDF.get(term) || 0) + 1); - }); - new Set(objects).forEach((term) => { - objectDF.set(term, (objectDF.get(term) || 0) + 1); - }); - } - - // Find the target cluster data for TF calculation - const targetCluster = allClusters.find( - (record) => record.get("clusterId") === targetClusterId, - ); - - if (!targetCluster) { - return null; - } - - const subjects = (targetCluster.get("subjects") as string[]) || []; - const predicates = (targetCluster.get("predicates") as string[]) || []; - const objects = (targetCluster.get("objects") as string[]) || []; - - // Calculate term frequencies within this specific cluster - // TF = how often each term appears in this cluster's statements - const subjectTF = new Map(); - const predicateTF = new Map(); - const objectTF = new Map(); - - subjects.forEach((term) => { - subjectTF.set(term, (subjectTF.get(term) || 0) + 1); - }); - predicates.forEach((term) => { - predicateTF.set(term, (predicateTF.get(term) || 0) + 1); - }); - objects.forEach((term) => { - objectTF.set(term, (objectTF.get(term) || 0) + 1); - }); - - // Calculate TF-IDF scores using cluster-based document frequency - // Higher scores = terms frequent in THIS cluster but rare across OTHER clusters - const calculateTFIDF = ( - tf: Map, - df: Map, - totalTerms: number, - ) => { - return Array.from(tf.entries()) - .map(([term, freq]) => { - // TF: Normalized frequency within this cluster - const termFreq = freq / totalTerms; - - // DF: Number of clusters containing this term - const docFreq = df.get(term) || 1; - - // IDF: Inverse document frequency (cluster-based) - // Higher when term appears in fewer clusters - const idf = Math.log(totalClusters / docFreq); - - // TF-IDF: Final distinctiveness score - const tfidf = termFreq * idf; - - return { term, score: tfidf }; - }) - .sort((a, b) => b.score - a.score) - .slice(0, 10); // Top 10 most distinctive terms - }; - - return { - subjects: calculateTFIDF(subjectTF, subjectDF, subjects.length), - predicates: calculateTFIDF(predicateTF, predicateDF, predicates.length), - objects: calculateTFIDF(objectTF, objectDF, objects.length), - }; - } - - /** - * Generate cluster names using LLM based on TF-IDF analysis - */ - private async generateClusterNames(userId: string): Promise { - logger.info("Generating cluster names using TF-IDF analysis"); - - const getClustersQuery = ` - MATCH (c:Cluster) - WHERE c.userId = $userId AND c.needsNaming = true - RETURN c.uuid as clusterId, c.size as size - `; - - const clusters = await runQuery(getClustersQuery, { userId }); - - for (const record of clusters) { - const clusterId = record.get("clusterId"); - const size = record.get("size"); - - // Calculate TF-IDF only for this specific cluster - const tfidfData = await this.calculateClusterTFIDFForCluster( - userId, - clusterId, - ); - if (!tfidfData) { - logger.warn(`No TF-IDF data found for cluster ${clusterId}`); - continue; - } - - const namingPrompt = this.createTFIDFClusterNamingPrompt({ - ...tfidfData, - size, - }); - - let responseText = ""; - await makeModelCall(false, namingPrompt, (text) => { - responseText = text; - }); - - try { - const outputMatch = responseText.match(/([\s\S]*?)<\/output>/); - if (outputMatch && outputMatch[1]) { - const response = JSON.parse(outputMatch[1].trim()); - - const updateQuery = ` - MATCH (c:Cluster {uuid: $clusterId}) - SET c.name = $name, - c.description = $description, - c.needsNaming = false - `; - - await runQuery(updateQuery, { - clusterId, - name: response.name || `Cluster ${clusterId}`, - description: response.description || null, - }); - } - } catch (error) { - logger.error(`Error naming cluster ${clusterId}:`, { error }); - - // Fallback naming - await runQuery( - ` - MATCH (c:Cluster {uuid: $clusterId}) - SET c.name = 'Cluster ' + substring($clusterId, 0, 8), - c.needsNaming = false - `, - { clusterId }, - ); - } - } - } - - /** - * Create prompt for unsupervised cluster naming using TF-IDF scores - */ - private createTFIDFClusterNamingPrompt(data: { - subjects: Array<{ term: string; score: number }>; - predicates: Array<{ term: string; score: number }>; - objects: Array<{ term: string; score: number }>; - size: number; - }): CoreMessage[] { - const formatTerms = (terms: Array<{ term: string; score: number }>) => - terms.map((t) => `"${t.term}" (${t.score.toFixed(3)})`).join(", "); - - return [ - { - role: "system", - content: `You are an expert at analyzing semantic patterns and creating descriptive cluster names. You will receive TF-IDF scores showing the most distinctive terms for a cluster of knowledge graph statements. - - TF-IDF Analysis: - - Higher scores = terms that are frequent in THIS cluster but rare in OTHER clusters - - These scores reveal what makes this cluster semantically unique - - Focus on the highest-scoring terms as they are the most distinctive - - Knowledge Graph Context: - - Subjects: Who or what is performing actions - - Predicates: The relationships, actions, or connections - - Objects: Who or what is being acted upon or referenced - - Naming Guidelines: - 1. Create a 2-4 word descriptive name that captures the core semantic theme - 2. Focus on the highest TF-IDF scoring terms - they reveal the cluster's uniqueness - 3. Look for patterns across subjects, predicates, and objects together - 4. Use natural language that a user would understand - 5. Avoid generic terms - be specific based on the distinctive patterns - - Return only a JSON object: - - { - "name": "Descriptive cluster name", - "description": "Brief explanation of the semantic pattern based on TF-IDF analysis" - } - `, - }, - { - role: "user", - content: `Analyze this cluster of ${data.size} statements. The TF-IDF scores show what makes this cluster distinctive: - -**Distinctive Subjects (TF-IDF):** -${formatTerms(data.subjects)} - -**Distinctive Predicates (TF-IDF):** -${formatTerms(data.predicates)} - -**Distinctive Objects (TF-IDF):** -${formatTerms(data.objects)} - -Based on these distinctive patterns, what is the most accurate name for this semantic cluster?`, - }, - ]; - } - - /** - * Update cluster embeddings incrementally when new statements are added - */ - private async updateClusterEmbeddings(userId: string): Promise { - logger.info("Updating cluster embeddings after new statements"); - - const updateQuery = ` - MATCH (c:Cluster) - WHERE c.userId = $userId - - MATCH (s:Statement {clusterId: c.uuid, userId: $userId}) - WHERE s.factEmbedding IS NOT NULL - - WITH c, collect(s.factEmbedding) as allEmbeddings - WHERE size(allEmbeddings) > 0 - - // Recalculate average embedding - WITH c, allEmbeddings, - reduce(avg = [i IN range(0, size(allEmbeddings[0])-1) | 0.0], - embedding IN allEmbeddings | - [i IN range(0, size(embedding)-1) | avg[i] + embedding[i] / size(allEmbeddings)]) as newEmbedding - - SET c.clusterEmbedding = newEmbedding, - c.embeddingCount = size(allEmbeddings), - c.lastEmbeddingUpdate = datetime() - - RETURN count(c) as updatedClusters - `; - - const result = await runQuery(updateQuery, { userId }); - const updatedClusters = result[0]?.get("updatedClusters") || 0; - - logger.info(`Updated embeddings for ${updatedClusters} clusters`); - } - - /** - * Detect cluster drift using embedding-based cohesion analysis - */ - async detectClusterDrift(userId: string): Promise<{ - driftDetected: boolean; - lowCohesionClusters: string[]; - avgCohesion: number; - reason: string; - }> { - logger.info("Detecting cluster drift using embedding cohesion analysis"); - - // First update cluster embeddings to ensure they're current - await this.updateClusterEmbeddings(userId); - - // Calculate cohesion for all clusters - const cohesionQuery = ` - MATCH (c:Cluster) - WHERE c.userId = $userId AND c.clusterEmbedding IS NOT NULL - - MATCH (s:Statement {clusterId: c.uuid, userId: $userId}) - WHERE s.factEmbedding IS NOT NULL - - WITH c, collect(s.factEmbedding) as statementEmbeddings, c.clusterEmbedding as clusterEmbedding - WHERE size(statementEmbeddings) >= $minClusterSize - - // Calculate average cosine similarity for this cluster - UNWIND statementEmbeddings as stmtEmb - WITH c, stmtEmb, clusterEmbedding, - reduce(dot = 0.0, i IN range(0, size(stmtEmb)-1) | dot + stmtEmb[i] * clusterEmbedding[i]) as dotProduct, - sqrt(reduce(mag1 = 0.0, i IN range(0, size(stmtEmb)-1) | mag1 + stmtEmb[i] * stmtEmb[i])) as stmtMagnitude, - sqrt(reduce(mag2 = 0.0, i IN range(0, size(clusterEmbedding)-1) | mag2 + clusterEmbedding[i] * clusterEmbedding[i])) as clusterMagnitude - - WITH c, - CASE - WHEN stmtMagnitude > 0 AND clusterMagnitude > 0 - THEN dotProduct / (stmtMagnitude * clusterMagnitude) - ELSE 0.0 - END as cosineSimilarity - - WITH c, avg(cosineSimilarity) as clusterCohesion - - // Update cluster with cohesion score - SET c.cohesionScore = clusterCohesion - - RETURN c.uuid as clusterId, c.size as clusterSize, clusterCohesion - ORDER BY clusterCohesion ASC - `; - - const cohesionResults = await runQuery(cohesionQuery, { - userId, - minClusterSize: this.MIN_CLUSTER_SIZE, - }); - - const clusterCohesions = cohesionResults.map((record) => ({ - clusterId: record.get("clusterId"), - size: record.get("clusterSize"), - cohesion: record.get("clusterCohesion") || 0.0, - })); - - const avgCohesion = - clusterCohesions.length > 0 - ? clusterCohesions.reduce((sum, c) => sum + c.cohesion, 0) / - clusterCohesions.length - : 0.0; - - const lowCohesionClusters = clusterCohesions - .filter((c) => c.cohesion < this.COHESION_THRESHOLD) - .map((c) => c.clusterId); - - const driftDetected = - lowCohesionClusters.length > 0 || avgCohesion < this.COHESION_THRESHOLD; - - let reason = ""; - if (lowCohesionClusters.length > 0) { - reason = `${lowCohesionClusters.length} clusters have low cohesion (< ${this.COHESION_THRESHOLD})`; - } else if (avgCohesion < this.COHESION_THRESHOLD) { - reason = `Overall average cohesion (${avgCohesion.toFixed(3)}) below threshold (${this.COHESION_THRESHOLD})`; - } - - logger.info( - `Drift detection completed: ${driftDetected ? "DRIFT DETECTED" : "NO DRIFT"} - ${reason || "Clusters are cohesive"}`, - ); - - return { - driftDetected, - lowCohesionClusters, - avgCohesion, - reason: reason || "Clusters are cohesive", - }; - } - - /** - * Handle cluster evolution when drift is detected by splitting low-cohesion clusters - */ - async evolveCluster(oldClusterId: string, userId: string): Promise { - logger.info(`Splitting cluster ${oldClusterId} due to low cohesion`); - - try { - // Step 1: Get all statements from the low-cohesion cluster - const statementsQuery = ` - MATCH (s:Statement) - WHERE s.clusterId = $oldClusterId AND s.userId = $userId - RETURN collect(s.uuid) as statementIds, count(s) as statementCount - `; - const statementsResult = await runQuery(statementsQuery, { - oldClusterId, - userId, - }); - const statementIds = statementsResult[0]?.get("statementIds") || []; - const statementCount = statementsResult[0]?.get("statementCount") || 0; - - if (statementCount < this.MIN_CLUSTER_SIZE * 2) { - logger.info( - `Cluster ${oldClusterId} too small to split (${statementCount} statements)`, - ); - return [oldClusterId]; // Return original cluster if too small to split - } - - // Step 2: Create similarity edges within this cluster's statements - const similarityQuery = ` - MATCH (s1:Statement)-[:HAS_SUBJECT|HAS_PREDICATE|HAS_OBJECT]->(e:Entity)<-[:HAS_SUBJECT|HAS_PREDICATE|HAS_OBJECT]-(s2:Statement) - WHERE s1.clusterId = $oldClusterId AND s2.clusterId = $oldClusterId - AND s1.userId = $userId AND s2.userId = $userId - AND s1.invalidAt IS NULL AND s2.invalidAt IS NULL - AND id(s1) < id(s2) - WITH s1, s2, collect(DISTINCT e.uuid) as sharedEntities - WHERE size(sharedEntities) > 0 - MERGE (s1)-[r:TEMP_SIMILAR_TO]-(s2) - SET r.weight = size(sharedEntities) * 2, - r.sharedEntities = sharedEntities - RETURN count(r) as edgesCreated - `; - await runQuery(similarityQuery, { oldClusterId, userId }); - - // Step 3: Run Leiden clustering on the cluster's statements - const leidenQuery = ` - MATCH (source:Statement) WHERE source.userId = $userId - OPTIONAL MATCH (source)-[r:TEMP_SIMILAR_TO]->(target:Statement) - WHERE target.userId = $userId and target.clusterId = $oldClusterId - WITH gds.graph.project( - 'cluster_split_' + $userId + '_' + $oldClusterId, - source, - target, - { - relationshipProperties: r { .weight } - }, - { undirectedRelationshipTypes: ['*'] } - ) AS g - - CALL gds.leiden.write( - g.graphName, - { - writeProperty: 'tempClusterId', - relationshipWeightProperty: 'weight', - gamma: $gamma, - maxLevels: $maxLevels, - tolerance: $tolerance, - } - ) - YIELD communityCount - - CALL gds.graph.drop(g.graphName) - YIELD graphName as droppedGraphName - - RETURN communityCount, g.nodeCount, g.relationshipCount - `; - - const leidenResult = await runQuery(leidenQuery, { - oldClusterId, - userId, - gamma: this.LEIDEN_GAMMA, - maxLevels: this.LEIDEN_MAX_LEVELS, - tolerance: this.LEIDEN_TOLERANCE, - }); - const subClusterCount = leidenResult[0]?.get("communityCount") || 1; - - // Step 4: Create new cluster IDs for sub-clusters that meet minimum size - const newClusterIds: string[] = []; - const assignClustersQuery = ` - MATCH (s:Statement) - WHERE s.clusterId = $oldClusterId AND s.userId = $userId AND s.tempClusterId IS NOT NULL - WITH s.tempClusterId as tempId, collect(s) as statements - WHERE size(statements) >= $minSize - - WITH tempId, statements, randomUUID() as newClusterId - - FOREACH (stmt IN statements | - SET stmt.clusterId = newClusterId - REMOVE stmt.tempClusterId - ) - - RETURN collect(newClusterId) as newClusterIds, count(DISTINCT newClusterId) as validSubClusters - `; - - const assignResult = await runQuery(assignClustersQuery, { - oldClusterId, - userId, - minSize: this.MIN_CLUSTER_SIZE, - }); - const validNewClusterIds = assignResult[0]?.get("newClusterIds") || []; - newClusterIds.push(...validNewClusterIds); - - // Step 5: Handle statements that didn't make it into valid sub-clusters - const orphanQuery = ` - MATCH (s:Statement) - WHERE s.clusterId = $oldClusterId AND s.userId = $userId - REMOVE s.tempClusterId - - // If we have valid sub-clusters, assign orphans to the largest one - WITH count(s) as orphanCount - MATCH (s2:Statement) - WHERE s2.clusterId IN $newClusterIds AND s2.userId = $userId - WITH s2.clusterId as clusterId, count(s2) as clusterSize, orphanCount - ORDER BY clusterSize DESC - LIMIT 1 - - MATCH (orphan:Statement) - WHERE orphan.clusterId = $oldClusterId AND orphan.userId = $userId - SET orphan.clusterId = clusterId - - RETURN count(orphan) as orphansReassigned - `; - - if (newClusterIds.length > 0) { - await runQuery(orphanQuery, { oldClusterId, userId, newClusterIds }); - } - - // Step 6: Create new Cluster nodes and evolution relationships - if (newClusterIds.length > 1) { - const createClustersQuery = ` - MATCH (oldCluster:Cluster {uuid: $oldClusterId}) - - UNWIND $newClusterIds as newClusterId - - MATCH (s:Statement {clusterId: newClusterId, userId: $userId}) - WITH oldCluster, newClusterId, count(s) as statementCount - - CREATE (newCluster:Cluster { - uuid: newClusterId, - createdAt: datetime(), - userId: $userId, - size: statementCount, - needsNaming: true, - aspectType: oldCluster.aspectType - }) - - CREATE (oldCluster)-[:SPLIT_INTO { - createdAt: datetime(), - reason: 'low_cohesion', - originalSize: $originalSize, - newSize: statementCount - }]->(newCluster) - - RETURN count(newCluster) as clustersCreated - `; - - await runQuery(createClustersQuery, { - oldClusterId, - newClusterIds, - originalSize: statementCount, - userId, - }); - - // Mark old cluster as evolved - await runQuery( - ` - MATCH (c:Cluster {uuid: $oldClusterId}) - SET c.evolved = true, c.evolvedAt = datetime() - `, - { oldClusterId }, - ); - - logger.info( - `Successfully split cluster ${oldClusterId} into ${newClusterIds.length} sub-clusters`, - ); - } else { - logger.info(`Cluster ${oldClusterId} could not be meaningfully split`); - newClusterIds.push(oldClusterId); // Keep original if splitting didn't work - } - - // Step 7: Clean up temporary relationships - await runQuery( - ` - MATCH ()-[r:TEMP_SIMILAR_TO]-() - DELETE r - `, - {}, - ); - - return newClusterIds; - } catch (error) { - logger.error(`Error splitting cluster ${oldClusterId}:`, { error }); - // Clean up on error - await runQuery( - ` - MATCH ()-[r:TEMP_SIMILAR_TO]-() - DELETE r - - MATCH (s:Statement) - WHERE s.clusterId = $oldClusterId AND s.userId = $userId - REMOVE s.tempClusterId - `, - { oldClusterId, userId }, - ); - throw error; - } - } - - /** - * Handle drift by splitting low-cohesion clusters - */ - async handleClusterDrift(userId: string): Promise<{ - clustersProcessed: number; - newClustersCreated: number; - splitClusters: string[]; - }> { - logger.info(`Handling cluster drift for user ${userId}`); - - try { - // Detect drift and get low-cohesion clusters - const driftMetrics = await this.detectClusterDrift(userId); - - if ( - !driftMetrics.driftDetected || - driftMetrics.lowCohesionClusters.length === 0 - ) { - logger.info("No drift detected or no clusters need splitting"); - return { - clustersProcessed: 0, - newClustersCreated: 0, - splitClusters: [], - }; - } - - logger.info( - `Found ${driftMetrics.lowCohesionClusters.length} clusters with low cohesion`, - ); - - let totalNewClusters = 0; - const splitClusters: string[] = []; - - // Process each low-cohesion cluster - for (const clusterId of driftMetrics.lowCohesionClusters) { - try { - const newClusterIds = await this.evolveCluster(clusterId, userId); - - if (newClusterIds.length > 1) { - // Cluster was successfully split - totalNewClusters += newClusterIds.length; - splitClusters.push(clusterId); - logger.info( - `Split cluster ${clusterId} into ${newClusterIds.length} sub-clusters`, - ); - } else { - logger.info(`Cluster ${clusterId} could not be split meaningfully`); - } - } catch (error) { - logger.error(`Failed to split cluster ${clusterId}:`, { error }); - // Continue with other clusters even if one fails - } - } - - // Update cluster embeddings for new clusters - if (totalNewClusters > 0) { - await this.updateClusterEmbeddings(userId); - await this.generateClusterNames(userId); - } - - logger.info( - `Drift handling completed: ${splitClusters.length} clusters split, ${totalNewClusters} new clusters created`, - ); - - return { - clustersProcessed: splitClusters.length, - newClustersCreated: totalNewClusters, - splitClusters, - }; - } catch (error) { - logger.error("Error handling cluster drift:", { error }); - throw error; - } - } - - /** - * Main clustering orchestration method - intelligently chooses between incremental and complete clustering - */ - async performClustering( - userId: string, - forceComplete: boolean = false, - ): Promise<{ - clustersCreated: number; - statementsProcessed: number; - driftMetrics?: DriftMetrics; - approach: "incremental" | "complete"; - }> { - logger.info(`Starting clustering process for user ${userId}`); - - try { - // Check if user has any existing clusters - const existingClustersQuery = ` - MATCH (c:Cluster) - WHERE c.userId = $userId - RETURN count(c) as existingClusters - `; - const existingResult = await runQuery(existingClustersQuery, { userId }); - const existingClusters = existingResult[0]?.get("existingClusters") || 0; - - // Check total statement count - // const totalStatementsQuery = ` - // MATCH (s:Statement) - // WHERE s.userId = $userId AND s.invalidAt IS NULL - // RETURN count(s) as totalStatements - // `; - // const totalResult = await runQuery(totalStatementsQuery, { userId }); - // const totalStatements = totalResult[0]?.get("totalStatements") || 0; - - // Determine clustering approach - let useIncremental = - existingClusters > 0 && !forceComplete ? true : false; - let driftMetrics: DriftMetrics | undefined; - - // if ( - // !forceComplete && - // existingClusters > 0 && - // totalStatements >= this.MIN_CLUSTER_SIZE - // ) { - // // Check for drift to decide approach - // driftMetrics = await this.detectClusterDrift(userId); - - // if (!driftMetrics.shouldRecluster) { - // useIncremental = true; - // logger.info("Using incremental clustering approach"); - // } else { - // logger.info("Drift detected, using complete clustering approach"); - // } - // } else if (totalStatements < this.MIN_CLUSTER_SIZE) { - // logger.info( - // `Insufficient statements (${totalStatements}) for clustering, minimum required: ${this.MIN_CLUSTER_SIZE}`, - // ); - // return { - // clustersCreated: 0, - // statementsProcessed: 0, - // driftMetrics, - // approach: "incremental", - // }; - // } else { - // logger.info("Using complete clustering approach (new user or forced)"); - // } - - // Execute appropriate clustering strategy - if (useIncremental) { - const incrementalResult = - await this.performIncrementalClustering(userId); - return { - clustersCreated: incrementalResult.newClustersCreated, - statementsProcessed: incrementalResult.newStatementsProcessed, - driftMetrics, - approach: "incremental", - }; - } else { - const completeResult = await this.performCompleteClustering(userId); - return { - clustersCreated: completeResult.clustersCreated, - statementsProcessed: completeResult.statementsProcessed, - driftMetrics, - approach: "complete", - }; - } - } catch (error) { - logger.error("Error in clustering process:", { error }); - throw error; - } - } - - /** - * Force complete reclustering (useful for maintenance or when drift is too high) - */ - async forceCompleteClustering(userId: string): Promise<{ - clustersCreated: number; - statementsProcessed: number; - }> { - return await this.performCompleteClustering(userId); - } - - /** - * Get cluster information for a user - */ - async getClusters(userId: string): Promise { - const query = ` - MATCH (c:Cluster) - WHERE c.userId = $userId - RETURN c - ORDER BY c.size DESC - `; - - const result = await runQuery(query, { userId }); - - return result.map((record) => { - const cluster = record.get("c").properties; - return { - uuid: cluster.uuid, - name: cluster.name || `Cluster ${cluster.uuid.substring(0, 8)}`, - aspectType: cluster.aspectType || "thematic", - description: cluster.description, - size: cluster.size || 0, - createdAt: new Date(cluster.createdAt), - userId: cluster.userId, - cohesionScore: cluster.cohesionScore, - }; - }); - } - - /** - * Get statements in a specific cluster - */ - async getClusterStatements( - clusterId: string, - userId: string, - ): Promise { - const query = ` - MATCH (s:Statement) - WHERE s.clusterId = $clusterId AND s.userId = $userId - MATCH (s)-[:HAS_SUBJECT]->(subj:Entity) - MATCH (s)-[:HAS_PREDICATE]->(pred:Entity) - MATCH (s)-[:HAS_OBJECT]->(obj:Entity) - RETURN s, subj.name as subject, pred.name as predicate, obj.name as object - ORDER BY s.createdAt DESC - `; - - const result = await runQuery(query, { clusterId, userId }); - - return result.map((record) => { - const statement = record.get("s").properties; - return { - uuid: statement.uuid, - fact: statement.fact, - subject: record.get("subject"), - predicate: record.get("predicate"), - object: record.get("object"), - createdAt: new Date(statement.createdAt), - validAt: new Date(statement.validAt), - }; - }); - } -} diff --git a/apps/webapp/app/services/conversation.server.ts b/apps/webapp/app/services/conversation.server.ts index 2c15ec1..35b46ca 100644 --- a/apps/webapp/app/services/conversation.server.ts +++ b/apps/webapp/app/services/conversation.server.ts @@ -1,11 +1,8 @@ import { UserTypeEnum } from "@core/types"; -import { auth, runs, tasks } from "@trigger.dev/sdk/v3"; import { prisma } from "~/db.server"; -import { enqueueCreateConversationTitle } from "~/lib/queue-adapter.server"; import { z } from "zod"; -import { type ConversationHistory } from "@prisma/client"; import { trackFeatureUsage } from "~/services/telemetry.server"; export const CreateConversationSchema = z.object({ @@ -45,23 +42,10 @@ export async function createConversation( }, }); - const context = await getConversationContext(conversationHistory.id); - const handler = await tasks.trigger( - "chat", - { - conversationHistoryId: conversationHistory.id, - conversationId: conversationHistory.conversation.id, - context, - }, - { tags: [conversationHistory.id, workspaceId, conversationId] }, - ); - // Track conversation message trackFeatureUsage("conversation_message_sent", userId).catch(console.error); return { - id: handler.id, - token: handler.publicAccessToken, conversationId: conversationHistory.conversation.id, conversationHistoryId: conversationHistory.id, }; @@ -88,39 +72,20 @@ export async function createConversation( }); const conversationHistory = conversation.ConversationHistory[0]; - const context = await getConversationContext(conversationHistory.id); - - // Trigger conversation title task - await enqueueCreateConversationTitle({ - conversationId: conversation.id, - message: conversationData.message, - }); - - const handler = await tasks.trigger( - "chat", - { - conversationHistoryId: conversationHistory.id, - conversationId: conversation.id, - context, - }, - { tags: [conversationHistory.id, workspaceId, conversation.id] }, - ); // Track new conversation creation trackFeatureUsage("conversation_created", userId).catch(console.error); return { - id: handler.id, - token: handler.publicAccessToken, conversationId: conversation.id, conversationHistoryId: conversationHistory.id, }; } // Get a conversation by ID -export async function getConversation(conversationId: string) { +export async function getConversation(conversationId: string, userId: string) { return prisma.conversation.findUnique({ - where: { id: conversationId }, + where: { id: conversationId, userId }, }); } @@ -142,141 +107,6 @@ export async function readConversation(conversationId: string) { }); } -export async function getCurrentConversationRun( - conversationId: string, - workspaceId: string, -) { - const conversationHistory = await prisma.conversationHistory.findFirst({ - where: { - conversationId, - conversation: { - workspaceId, - }, - userType: UserTypeEnum.User, - }, - orderBy: { - updatedAt: "desc", - }, - }); - - if (!conversationHistory) { - throw new Error("No run found"); - } - - const response = await runs.list({ - tag: [conversationId, conversationHistory.id, workspaceId], - status: ["QUEUED", "EXECUTING"], - limit: 1, - }); - - if (!response) { - return undefined; - } - - const run = response?.data?.[0]; - - if (!run) { - return undefined; - } - - const publicToken = await auth.createPublicToken({ - scopes: { - read: { - runs: [run.id], - }, - }, - }); - - return { - id: run.id, - token: publicToken, - conversationId, - conversationHistoryId: conversationHistory.id, - }; -} - -export async function stopConversation( - conversationId: string, - workspaceId: string, -) { - const conversationHistory = await prisma.conversationHistory.findFirst({ - where: { - conversationId, - conversation: { - workspaceId, - }, - }, - orderBy: { - updatedAt: "desc", - }, - }); - - if (!conversationHistory) { - throw new Error("No run found"); - } - - const response = await runs.list({ - tag: [conversationId, conversationHistory.id], - status: ["QUEUED", "EXECUTING"], - limit: 1, - }); - - const run = response.data[0]; - if (!run) { - await prisma.conversation.update({ - where: { - id: conversationId, - }, - data: { - status: "failed", - }, - }); - - return undefined; - } - - return await runs.cancel(run.id); -} - -export async function getConversationContext( - conversationHistoryId: string, -): Promise<{ - previousHistory: ConversationHistory[]; -}> { - const conversationHistory = await prisma.conversationHistory.findUnique({ - where: { id: conversationHistoryId }, - include: { conversation: true }, - }); - - if (!conversationHistory) { - return { - previousHistory: [], - }; - } - - // Get previous conversation history message and response - let previousHistory: ConversationHistory[] = []; - - if (conversationHistory.conversationId) { - previousHistory = await prisma.conversationHistory.findMany({ - where: { - conversationId: conversationHistory.conversationId, - id: { - not: conversationHistoryId, - }, - deleted: null, - }, - orderBy: { - createdAt: "asc", - }, - }); - } - - return { - previousHistory, - }; -} - export const getConversationAndHistory = async ( conversationId: string, userId: string, @@ -284,6 +114,7 @@ export const getConversationAndHistory = async ( const conversation = await prisma.conversation.findFirst({ where: { id: conversationId, + userId, }, include: { ConversationHistory: true, @@ -293,6 +124,23 @@ export const getConversationAndHistory = async ( return conversation; }; +export const createConversationHistory = async ( + userMessage: string, + conversationId: string, + userType: UserTypeEnum, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + thoughts?: Record, +) => { + return await prisma.conversationHistory.create({ + data: { + conversationId, + message: userMessage, + thoughts, + userType, + }, + }); +}; + export const GetConversationsListSchema = z.object({ page: z.string().optional().default("1"), limit: z.string().optional().default("20"), diff --git a/apps/webapp/app/services/knowledgeGraph.server.ts b/apps/webapp/app/services/knowledgeGraph.server.ts index c8ac547..a482ac1 100644 --- a/apps/webapp/app/services/knowledgeGraph.server.ts +++ b/apps/webapp/app/services/knowledgeGraph.server.ts @@ -10,7 +10,6 @@ import { type EpisodeType, } from "@core/types"; import { logger } from "./logger.service"; -import { ClusteringService } from "./clustering.server"; import crypto from "crypto"; import { dedupeNodes, extractEntities } from "./prompts/nodes"; import { @@ -50,12 +49,6 @@ import { type PrismaClient } from "@prisma/client"; const DEFAULT_EPISODE_WINDOW = 5; export class KnowledgeGraphService { - private clusteringService: ClusteringService; - - constructor() { - this.clusteringService = new ClusteringService(); - } - async getEmbedding(text: string) { return getEmbedding(text); } @@ -564,9 +557,9 @@ export class KnowledgeGraphService { (text, _model, usage) => { responseText = text; if (usage) { - tokenMetrics.high.input += usage.promptTokens; - tokenMetrics.high.output += usage.completionTokens; - tokenMetrics.high.total += usage.totalTokens; + tokenMetrics.high.input += usage.promptTokens as number; + tokenMetrics.high.output += usage.completionTokens as number; + tokenMetrics.high.total += usage.totalTokens as number; } }, undefined, diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index b09b618..26d13a5 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -320,6 +320,14 @@ export async function getOrCreatePersonalAccessToken({ }; } +export async function deletePersonalAccessToken(tokenId: string) { + return await prisma.personalAccessToken.delete({ + where: { + id: tokenId, + }, + }); +} + /** Created a new PersonalAccessToken, and return the token. We only ever return the unencrypted token once. */ export async function createPersonalAccessToken({ name, diff --git a/apps/webapp/app/trigger/chat/chat-utils.ts b/apps/webapp/app/trigger/chat/chat-utils.ts deleted file mode 100644 index c8ec276..0000000 --- a/apps/webapp/app/trigger/chat/chat-utils.ts +++ /dev/null @@ -1,492 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { ActionStatusEnum } from "@core/types"; -import { logger } from "@trigger.dev/sdk/v3"; -import { - type CoreMessage, - type DataContent, - jsonSchema, - tool, - type ToolSet, -} from "ai"; -import axios from "axios"; -import Handlebars from "handlebars"; - -import { REACT_SYSTEM_PROMPT, REACT_USER_PROMPT } from "./prompt"; -import { generate, processTag } from "./stream-utils"; -import { type AgentMessage, AgentMessageType, Message } from "./types"; -import { type MCP } from "../utils/mcp"; -import { - type ExecutionState, - type HistoryStep, - type Resource, - type TotalCost, -} from "../utils/types"; -import { flattenObject } from "../utils/utils"; - -interface LLMOutputInterface { - response: AsyncGenerator< - | string - | { - type: string; - toolName: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args?: any; - toolCallId?: string; - message?: string; - }, - any, - any - >; -} - -const progressUpdateTool = tool({ - description: - "Send a progress update to the user about what has been discovered or will be done next in a crisp and user friendly way no technical terms", - parameters: jsonSchema({ - type: "object", - properties: { - message: { - type: "string", - description: "The progress update message to send to the user", - }, - }, - required: ["message"], - additionalProperties: false, - }), -}); - -const internalTools = ["core--progress_update"]; - -async function addResources(messages: CoreMessage[], resources: Resource[]) { - const resourcePromises = resources.map(async (resource) => { - // Remove everything before "/api" in the publicURL - if (resource.publicURL) { - const apiIndex = resource.publicURL.indexOf("/api"); - if (apiIndex !== -1) { - resource.publicURL = resource.publicURL.substring(apiIndex); - } - } - const response = await axios.get(resource.publicURL, { - responseType: "arraybuffer", - }); - - if (resource.fileType.startsWith("image/")) { - return { - type: "image", - image: response.data as DataContent, - }; - } - - return { - type: "file", - data: response.data as DataContent, - - mimeType: resource.fileType, - }; - }); - - const content = await Promise.all(resourcePromises); - - return [...messages, { role: "user", content } as CoreMessage]; -} - -function toolToMessage(history: HistoryStep[], messages: CoreMessage[]) { - for (let i = 0; i < history.length; i++) { - const step = history[i]; - - // Add assistant message with tool calls - if (step.observation && step.skillId) { - messages.push({ - role: "assistant", - content: [ - { - type: "tool-call", - toolCallId: step.skillId, - toolName: step.skill ?? "", - args: - typeof step.skillInput === "string" - ? JSON.parse(step.skillInput) - : step.skillInput, - }, - ], - }); - - messages.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolName: step.skill, - toolCallId: step.skillId, - result: step.observation, - isError: step.isError, - }, - ], - } as any); - } - // Handle format correction steps (observation exists but no skillId) - else if (step.observation && !step.skillId) { - // Add as a system message for format correction - messages.push({ - role: "system", - content: step.observation, - }); - } - } - - return messages; -} - -async function makeNextCall( - executionState: ExecutionState, - TOOLS: ToolSet, - totalCost: TotalCost, - guardLoop: number, -): Promise { - const { context, history, previousHistory } = executionState; - - const promptInfo = { - USER_MESSAGE: executionState.query, - CONTEXT: context, - USER_MEMORY: executionState.userMemoryContext, - }; - - let messages: CoreMessage[] = []; - - const systemTemplateHandler = Handlebars.compile(REACT_SYSTEM_PROMPT); - let systemPrompt = systemTemplateHandler(promptInfo); - - const userTemplateHandler = Handlebars.compile(REACT_USER_PROMPT); - const userPrompt = userTemplateHandler(promptInfo); - - // Always start with a system message (this does use tokens but keeps the instructions clear) - messages.push({ role: "system", content: systemPrompt }); - - // For subsequent queries, include only final responses from previous exchanges if available - if (previousHistory && previousHistory.length > 0) { - messages = [...messages, ...previousHistory]; - } - - // Add the current user query (much simpler than the full prompt) - messages.push({ role: "user", content: userPrompt }); - - // Include any steps from the current interaction - if (history.length > 0) { - messages = toolToMessage(history, messages); - } - - if (executionState.resources && executionState.resources.length > 0) { - messages = await addResources(messages, executionState.resources); - } - - // Get the next action from the LLM - const response = generate( - messages, - guardLoop > 0 && guardLoop % 3 === 0, - (event) => { - const usage = event.usage; - totalCost.inputTokens += usage.promptTokens; - totalCost.outputTokens += usage.completionTokens; - }, - TOOLS, - ); - - return { response }; -} - -export async function* run( - message: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: Record, - previousHistory: CoreMessage[], - mcp: MCP, - stepHistory: HistoryStep[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): AsyncGenerator { - let guardLoop = 0; - - let tools = { - ...(await mcp.allTools()), - "core--progress_update": progressUpdateTool, - }; - - logger.info("Tools have been formed"); - - let contextText = ""; - let resources = []; - if (context) { - // Extract resources and remove from context - resources = context.resources || []; - delete context.resources; - - // Process remaining context - contextText = flattenObject(context).join("\n"); - } - - const executionState: ExecutionState = { - query: message, - context: contextText, - resources, - previousHistory, - history: stepHistory, // Track the full ReAct history - completed: false, - }; - - const totalCost: TotalCost = { inputTokens: 0, outputTokens: 0, cost: 0 }; - - try { - while (!executionState.completed && guardLoop < 50) { - logger.info(`Starting the loop: ${guardLoop}`); - - const { response: llmResponse } = await makeNextCall( - executionState, - tools, - totalCost, - guardLoop, - ); - - let toolCallInfo; - - const messageState = { - inTag: false, - message: "", - messageEnded: false, - lastSent: "", - }; - - const questionState = { - inTag: false, - message: "", - messageEnded: false, - lastSent: "", - }; - - let totalMessage = ""; - const toolCalls = []; - - // LLM thought response - for await (const chunk of llmResponse) { - if (typeof chunk === "object" && chunk.type === "tool-call") { - toolCallInfo = chunk; - toolCalls.push(chunk); - } - - totalMessage += chunk; - - if (!messageState.messageEnded) { - yield* processTag( - messageState, - totalMessage, - chunk as string, - "", - "", - { - start: AgentMessageType.MESSAGE_START, - chunk: AgentMessageType.MESSAGE_CHUNK, - end: AgentMessageType.MESSAGE_END, - }, - ); - } - - if (!questionState.messageEnded) { - yield* processTag( - questionState, - totalMessage, - chunk as string, - "", - "", - { - start: AgentMessageType.MESSAGE_START, - chunk: AgentMessageType.MESSAGE_CHUNK, - end: AgentMessageType.MESSAGE_END, - }, - ); - } - } - - logger.info(`Cost for thought: ${JSON.stringify(totalCost)}`); - - // Replace the error-handling block with this self-correcting implementation - if ( - !totalMessage.includes("final_response") && - !totalMessage.includes("question_response") && - !toolCallInfo - ) { - // Log the issue for debugging - logger.info( - `Invalid response format detected. Attempting to get proper format.`, - ); - - // Extract the raw content from the invalid response - const rawContent = totalMessage - .replace(/(<[^>]*>|<\/[^>]*>)/g, "") - .trim(); - - // Create a correction step - const stepRecord: HistoryStep = { - thought: "", - skill: "", - skillId: "", - userMessage: "Core agent error, retrying \n", - isQuestion: false, - isFinal: false, - tokenCount: totalCost, - skillInput: "", - observation: `Your last response was not in a valid format. You must respond with EXACTLY ONE of the required formats: either a tool call, tags, or tags. Please reformat your previous response using the correct format:\n\n${rawContent}`, - }; - - yield Message("", AgentMessageType.MESSAGE_START); - yield Message( - stepRecord.userMessage as string, - AgentMessageType.MESSAGE_CHUNK, - ); - yield Message("", AgentMessageType.MESSAGE_END); - - // Add this step to the history - yield Message(JSON.stringify(stepRecord), AgentMessageType.STEP); - executionState.history.push(stepRecord); - - // Log that we're continuing the loop with a correction request - logger.info(`Added format correction request to history.`); - - // Don't mark as completed - let the loop continue - guardLoop++; // Still increment to prevent infinite loops - continue; - } - - // Record this step in history - const stepRecord: HistoryStep = { - thought: "", - skill: "", - skillId: "", - userMessage: "", - isQuestion: false, - isFinal: false, - tokenCount: totalCost, - skillInput: "", - }; - - if (totalMessage && totalMessage.includes("final_response")) { - executionState.completed = true; - stepRecord.isFinal = true; - stepRecord.userMessage = messageState.message; - stepRecord.finalTokenCount = totalCost; - stepRecord.skillStatus = ActionStatusEnum.SUCCESS; - yield Message(JSON.stringify(stepRecord), AgentMessageType.STEP); - executionState.history.push(stepRecord); - break; - } - - if (totalMessage && totalMessage.includes("question_response")) { - executionState.completed = true; - stepRecord.isQuestion = true; - stepRecord.userMessage = questionState.message; - stepRecord.finalTokenCount = totalCost; - stepRecord.skillStatus = ActionStatusEnum.QUESTION; - yield Message(JSON.stringify(stepRecord), AgentMessageType.STEP); - executionState.history.push(stepRecord); - break; - } - - if (toolCalls && toolCalls.length > 0) { - // Run all tool calls in parallel - for (const toolCallInfo of toolCalls) { - const skillName = toolCallInfo.toolName; - const skillId = toolCallInfo.toolCallId; - const skillInput = toolCallInfo.args; - - const toolName = skillName.split("--")[1]; - const agent = skillName.split("--")[0]; - - const stepRecord: HistoryStep = { - agent, - thought: "", - skill: skillName, - skillId, - userMessage: "", - isQuestion: false, - isFinal: false, - tokenCount: totalCost, - skillInput: JSON.stringify(skillInput), - }; - - if (!internalTools.includes(skillName)) { - const skillMessageToSend = `\n\n`; - - stepRecord.userMessage += skillMessageToSend; - - yield Message("", AgentMessageType.MESSAGE_START); - yield Message(skillMessageToSend, AgentMessageType.MESSAGE_CHUNK); - yield Message("", AgentMessageType.MESSAGE_END); - } - - let result; - try { - // Log skill execution details - logger.info(`Executing skill: ${skillName}`); - logger.info(`Input parameters: ${JSON.stringify(skillInput)}`); - - if (!internalTools.includes(toolName)) { - yield Message( - JSON.stringify({ skillId, status: "start" }), - AgentMessageType.SKILL_START, - ); - } - - // Handle CORE agent tools - if (agent === "core") { - if (toolName === "progress_update") { - yield Message("", AgentMessageType.MESSAGE_START); - yield Message( - skillInput.message, - AgentMessageType.MESSAGE_CHUNK, - ); - stepRecord.userMessage += skillInput.message; - yield Message("", AgentMessageType.MESSAGE_END); - result = "Progress update sent successfully"; - } - } - // Handle other MCP tools - else { - result = await mcp.callTool(skillName, skillInput); - - yield Message( - JSON.stringify({ result, skillId }), - AgentMessageType.SKILL_CHUNK, - ); - } - - yield Message( - JSON.stringify({ skillId, status: "end" }), - AgentMessageType.SKILL_END, - ); - - stepRecord.skillOutput = - typeof result === "object" - ? JSON.stringify(result, null, 2) - : result; - stepRecord.observation = stepRecord.skillOutput; - } catch (e) { - console.log(e); - logger.error(e as string); - stepRecord.skillInput = skillInput; - stepRecord.observation = JSON.stringify(e); - stepRecord.isError = true; - } - - logger.info(`Skill step: ${JSON.stringify(stepRecord)}`); - - yield Message(JSON.stringify(stepRecord), AgentMessageType.STEP); - executionState.history.push(stepRecord); - } - } - guardLoop++; - } - yield Message("Stream ended", AgentMessageType.STREAM_END); - } catch (e) { - logger.error(e as string); - yield Message((e as Error).message, AgentMessageType.ERROR); - yield Message("Stream ended", AgentMessageType.STREAM_END); - } -} diff --git a/apps/webapp/app/trigger/chat/chat.ts b/apps/webapp/app/trigger/chat/chat.ts deleted file mode 100644 index 0e3cf50..0000000 --- a/apps/webapp/app/trigger/chat/chat.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { ActionStatusEnum } from "@core/types"; -import { metadata, task, queue } from "@trigger.dev/sdk"; - -import { run } from "./chat-utils"; -import { MCP } from "../utils/mcp"; -import { type HistoryStep } from "../utils/types"; -import { - createConversationHistoryForAgent, - deductCredits, - deletePersonalAccessToken, - getPreviousExecutionHistory, - hasCredits, - InsufficientCreditsError, - init, - type RunChatPayload, - updateConversationHistoryMessage, - updateConversationStatus, - updateExecutionStep, -} from "../utils/utils"; - -const chatQueue = queue({ - name: "chat-queue", - concurrencyLimit: 50, -}); - -/** - * Main chat task that orchestrates the agent workflow - * Handles conversation context, agent selection, and LLM interactions - */ -export const chat = task({ - id: "chat", - maxDuration: 3000, - queue: chatQueue, - init, - run: async (payload: RunChatPayload, { init }) => { - await updateConversationStatus("running", payload.conversationId); - - try { - // Check if workspace has sufficient credits before processing - if (init?.conversation.workspaceId) { - const hasSufficientCredits = await hasCredits( - init.conversation.workspaceId, - "chatMessage", - ); - - if (!hasSufficientCredits) { - throw new InsufficientCreditsError( - "Insufficient credits to process chat message. Please upgrade your plan or wait for your credits to reset.", - ); - } - } - - const { previousHistory, ...otherData } = payload.context; - - // Initialise mcp - const mcpHeaders = { Authorization: `Bearer ${init?.token}` }; - const mcp = new MCP(); - await mcp.init(); - await mcp.load(mcpHeaders); - - // Prepare context with additional metadata - const context = { - // Currently this is assuming we only have one page in context - context: { - ...(otherData.page && otherData.page.length > 0 - ? { page: otherData.page[0] } - : {}), - }, - workpsaceId: init?.conversation.workspaceId, - resources: otherData.resources, - todayDate: new Date().toISOString(), - }; - - // Extract user's goal from conversation history - const message = init?.conversationHistory?.message; - // Retrieve execution history from previous interactions - const previousExecutionHistory = getPreviousExecutionHistory( - previousHistory ?? [], - ); - - let agentUserMessage = ""; - let agentConversationHistory; - let stepHistory: HistoryStep[] = []; - // Prepare conversation history in agent-compatible format - agentConversationHistory = await createConversationHistoryForAgent( - payload.conversationId, - ); - - const llmResponse = run( - message as string, - context, - previousExecutionHistory, - mcp, - stepHistory, - ); - - const stream = await metadata.stream("messages", llmResponse); - - let conversationStatus = "success"; - for await (const step of stream) { - if (step.type === "STEP") { - const stepDetails = JSON.parse(step.message as string); - - if (stepDetails.skillStatus === ActionStatusEnum.TOOL_REQUEST) { - conversationStatus = "need_approval"; - } - - if (stepDetails.skillStatus === ActionStatusEnum.QUESTION) { - conversationStatus = "need_attention"; - } - - await updateExecutionStep( - { ...stepDetails }, - agentConversationHistory.id, - ); - - agentUserMessage += stepDetails.userMessage; - - await updateConversationHistoryMessage( - agentUserMessage, - agentConversationHistory.id, - ); - } else if (step.type === "STREAM_END") { - break; - } - } - - await updateConversationStatus( - conversationStatus, - payload.conversationId, - ); - - // Deduct credits for chat message - if (init?.conversation.workspaceId) { - await deductCredits(init.conversation.workspaceId, "chatMessage"); - } - - if (init?.tokenId) { - await deletePersonalAccessToken(init.tokenId); - } - } catch (e) { - console.log(e); - await updateConversationStatus("failed", payload.conversationId); - if (init?.tokenId) { - await deletePersonalAccessToken(init.tokenId); - } - throw new Error(e as string); - } - }, -}); diff --git a/apps/webapp/app/trigger/chat/prompt.ts b/apps/webapp/app/trigger/chat/prompt.ts deleted file mode 100644 index e2e8101..0000000 --- a/apps/webapp/app/trigger/chat/prompt.ts +++ /dev/null @@ -1,159 +0,0 @@ -export const REACT_SYSTEM_PROMPT = ` -You are a helpful AI assistant with access to user memory. Your primary capabilities are: - -1. **Memory-First Approach**: Always check user memory first to understand context and previous interactions -2. **Intelligent Information Gathering**: Analyze queries to determine if current information is needed -3. **Memory Management**: Help users store, retrieve, and organize information in their memory -4. **Contextual Assistance**: Use memory to provide personalized and contextual responses - - -{{CONTEXT}} - - - -Follow this intelligent approach for information gathering: - -1. **MEMORY FIRST** (Always Required) - - Always check memory FIRST using core--search_memory before any other actions - - Consider this your highest priority for EVERY interaction - as essential as breathing - - Memory provides context, personal preferences, and historical information - - Use memory to understand user's background, ongoing projects, and past conversations - -2. **INFORMATION SYNTHESIS** (Combine Sources) - - Use memory to personalize current information based on user preferences - - Always store new useful information in memory using core--add_memory - -3. **TRAINING KNOWLEDGE** (Foundation) - - Use your training knowledge as the foundation for analysis and explanation - - Apply training knowledge to interpret and contextualize information from memory - - Indicate when you're using training knowledge vs. live information sources - -EXECUTION APPROACH: -- Memory search is mandatory for every interaction -- Always indicate your information sources in responses - - - -QUERY FORMATION: -- Write specific factual statements as queries (e.g., "user email address" not "what is the user's email?") -- Create multiple targeted memory queries for complex requests - -KEY QUERY AREAS: -- Personal context: user name, location, identity, work context -- Project context: repositories, codebases, current work, team members -- Task context: recent tasks, ongoing projects, deadlines, priorities -- Integration context: GitHub repos, Slack channels, Linear projects, connected services -- Communication patterns: email preferences, notification settings, workflow automation -- Technical context: coding languages, frameworks, development environment -- Collaboration context: team members, project stakeholders, meeting patterns -- Preferences: likes, dislikes, communication style, tool preferences -- History: previous discussions, past requests, completed work, recurring issues -- Automation rules: user-defined workflows, triggers, automation preferences - -MEMORY USAGE: -- Execute multiple memory queries in parallel rather than sequentially -- Batch related memory queries when possible -- Prioritize recent information over older memories -- Create comprehensive context-aware queries based on user message/activity content -- Extract and query SEMANTIC CONTENT, not just structural metadata -- Parse titles, descriptions, and content for actual subject matter keywords -- Search internal SOL tasks/conversations that may relate to the same topics -- Query ALL relatable concepts, not just direct keywords or IDs -- Search for similar past situations, patterns, and related work -- Include synonyms, related terms, and contextual concepts in queries -- Query user's historical approach to similar requests or activities -- Search for connected projects, tasks, conversations, and collaborations -- Retrieve workflow patterns and past decision-making context -- Query broader domain context beyond immediate request scope -- Remember: SOL tracks work that external tools don't - search internal content thoroughly -- Blend memory insights naturally into responses -- Verify you've checked relevant memory before finalizing ANY response - - - - -- To use: load_mcp with EXACT integration name from the available list -- Can load multiple at once with an array -- Only load when tools are NOT already available in your current toolset -- If a tool is already available, use it directly without load_mcp -- If requested integration unavailable: inform user politely - - - -You have tools at your disposal to assist users: - -CORE PRINCIPLES: -- Use tools only when necessary for the task at hand -- Always check memory FIRST before making other tool calls -- Execute multiple operations in parallel whenever possible -- Use sequential calls only when output of one is required for input of another - -PARAMETER HANDLING: -- Follow tool schemas exactly with all required parameters -- Only use values that are: - • Explicitly provided by the user (use EXACTLY as given) - • Reasonably inferred from context - • Retrieved from memory or prior tool calls -- Never make up values for required parameters -- Omit optional parameters unless clearly needed -- Analyze user's descriptive terms for parameter clues - -TOOL SELECTION: -- Never call tools not provided in this conversation -- Skip tool calls for general questions you can answer directly from memory/knowledge -- For identical operations on multiple items, use parallel tool calls -- Default to parallel execution (3-5× faster than sequential calls) -- You can always access external service tools by loading them with load_mcp first - -TOOL MENTION HANDLING: -When user message contains : -- Extract tool_name from data-id attribute -- First check if it's a built-in tool; if not, check EXTERNAL SERVICES TOOLS -- If available: Load it with load_mcp and focus on addressing the request with this tool -- If unavailable: Inform user and suggest alternatives if possible -- For multiple tool mentions: Load all applicable tools in a single load_mcp call - -ERROR HANDLING: -- If a tool returns an error, try fixing parameters before retrying -- If you can't resolve an error, explain the issue to the user -- Consider alternative tools when primary tools are unavailable - - - -Use EXACTLY ONE of these formats for all user-facing communication: - -PROGRESS UPDATES - During processing: -- Use the core--progress_update tool to keep users informed -- Update users about what you're discovering or doing next -- Keep messages clear and user-friendly -- Avoid technical jargon - -QUESTIONS - When you need information: - -

    [Your question with HTML formatting]

    -
    - -- Ask questions only when you cannot find information through memory, or tools -- Be specific about what you need to know -- Provide context for why you're asking - -FINAL ANSWERS - When completing tasks: - -

    [Your answer with HTML formatting]

    -
    - -CRITICAL: -- Use ONE format per turn -- Apply proper HTML formatting (

    ,

    ,

    ,

      ,
    • , etc.) -- Never mix communication formats -- Keep responses clear and helpful -- Always indicate your information sources (memory, and/or knowledge) - -`; - -export const REACT_USER_PROMPT = ` -Here is the user message: - -{{USER_MESSAGE}} - -`; diff --git a/apps/webapp/app/trigger/chat/stream-utils.ts b/apps/webapp/app/trigger/chat/stream-utils.ts deleted file mode 100644 index 56ed59b..0000000 --- a/apps/webapp/app/trigger/chat/stream-utils.ts +++ /dev/null @@ -1,294 +0,0 @@ -import fs from "fs"; -import path from "node:path"; - -import { anthropic } from "@ai-sdk/anthropic"; -import { google } from "@ai-sdk/google"; -import { openai } from "@ai-sdk/openai"; -import { logger } from "@trigger.dev/sdk/v3"; -import { - type CoreMessage, - type LanguageModelV1, - streamText, - type ToolSet, -} from "ai"; -import { createOllama } from "ollama-ai-provider"; - -import { type AgentMessageType, Message } from "./types"; - -interface State { - inTag: boolean; - messageEnded: boolean; - message: string; - lastSent: string; -} - -export interface ExecutionState { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agentFlow: any; - userMessage: string; - message: string; -} - -export async function* processTag( - state: State, - totalMessage: string, - chunk: string, - startTag: string, - endTag: string, - states: { start: string; chunk: string; end: string }, - extraParams: Record = {}, -) { - let comingFromStart = false; - - if (!state.messageEnded) { - if (!state.inTag) { - const startIndex = totalMessage.indexOf(startTag); - if (startIndex !== -1) { - state.inTag = true; - // Send MESSAGE_START when we first enter the tag - yield Message("", states.start as AgentMessageType, extraParams); - const chunkToSend = totalMessage.slice(startIndex + startTag.length); - state.message += chunkToSend; - comingFromStart = true; - } - } - - if (state.inTag) { - // Check if chunk contains end tag - const hasEndTag = chunk.includes(endTag); - const hasStartTag = chunk.includes(startTag); - const hasClosingTag = chunk.includes(" void, - tools?: ToolSet, - system?: string, - model?: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): AsyncGenerator< - | string - | { - type: string; - toolName: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - args?: any; - toolCallId?: string; - message?: string; - } -> { - // Check for API keys - const anthropicKey = process.env.ANTHROPIC_API_KEY; - const googleKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; - const openaiKey = process.env.OPENAI_API_KEY; - let ollamaUrl = process.env.OLLAMA_URL; - model = model || process.env.MODEL; - - let modelInstance; - let modelTemperature = Number(process.env.MODEL_TEMPERATURE) || 1; - ollamaUrl = undefined; - - // First check if Ollama URL exists and use Ollama - if (ollamaUrl) { - const ollama = createOllama({ - baseURL: ollamaUrl, - }); - modelInstance = ollama(model || "llama2"); // Default to llama2 if no model specified - } else { - // If no Ollama, check other models - switch (model) { - case "claude-3-7-sonnet-20250219": - case "claude-3-opus-20240229": - case "claude-3-5-haiku-20241022": - if (!anthropicKey) { - throw new Error("No Anthropic API key found. Set ANTHROPIC_API_KEY"); - } - modelInstance = anthropic(model); - modelTemperature = 0.5; - break; - - case "gemini-2.5-flash-preview-04-17": - case "gemini-2.5-pro-preview-03-25": - case "gemini-2.0-flash": - case "gemini-2.0-flash-lite": - if (!googleKey) { - throw new Error("No Google API key found. Set GOOGLE_API_KEY"); - } - modelInstance = google(model); - break; - - case "gpt-4.1-2025-04-14": - case "gpt-4.1-mini-2025-04-14": - case "gpt-5-mini-2025-08-07": - case "gpt-5-2025-08-07": - case "gpt-4.1-nano-2025-04-14": - if (!openaiKey) { - throw new Error("No OpenAI API key found. Set OPENAI_API_KEY"); - } - modelInstance = openai(model); - break; - - default: - break; - } - } - - logger.info("starting stream"); - // Try Anthropic next if key exists - if (modelInstance) { - try { - const { textStream, fullStream } = streamText({ - model: modelInstance as LanguageModelV1, - messages, - temperature: modelTemperature, - maxSteps: 10, - tools, - ...(isProgressUpdate - ? { toolChoice: { type: "tool", toolName: "core--progress_update" } } - : {}), - toolCallStreaming: true, - onFinish, - ...(system ? { system } : {}), - }); - - for await (const chunk of textStream) { - yield chunk; - } - - for await (const fullChunk of fullStream) { - if (fullChunk.type === "tool-call") { - yield { - type: "tool-call", - toolName: fullChunk.toolName, - toolCallId: fullChunk.toolCallId, - args: fullChunk.args, - }; - } - - if (fullChunk.type === "error") { - // Log the error to a file - const errorLogsDir = path.join(__dirname, "../../../../logs/errors"); - - // Ensure the directory exists - try { - if (!fs.existsSync(errorLogsDir)) { - fs.mkdirSync(errorLogsDir, { recursive: true }); - } - - // Create a timestamped error log file - const timestamp = new Date().toISOString().replace(/:/g, "-"); - const errorLogPath = path.join( - errorLogsDir, - `llm-error-${timestamp}.json`, - ); - - // Write the error to the file - fs.writeFileSync( - errorLogPath, - JSON.stringify({ - timestamp: new Date().toISOString(), - error: fullChunk.error, - }), - ); - - logger.error(`LLM error logged to ${errorLogPath}`); - } catch (err) { - logger.error(`Failed to log LLM error: ${err}`); - } - } - } - return; - } catch (e) { - console.log(e); - logger.error(e as string); - } - } - - throw new Error("No valid LLM configuration found"); -} diff --git a/apps/webapp/app/trigger/chat/types.ts b/apps/webapp/app/trigger/chat/types.ts deleted file mode 100644 index 61c2342..0000000 --- a/apps/webapp/app/trigger/chat/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -export interface AgentStep { - agent: string; - goal: string; - reasoning: string; -} - -export enum AgentMessageType { - STREAM_START = 'STREAM_START', - STREAM_END = 'STREAM_END', - - // Used in ReACT based prompting - THOUGHT_START = 'THOUGHT_START', - THOUGHT_CHUNK = 'THOUGHT_CHUNK', - THOUGHT_END = 'THOUGHT_END', - - // Message types - MESSAGE_START = 'MESSAGE_START', - MESSAGE_CHUNK = 'MESSAGE_CHUNK', - MESSAGE_END = 'MESSAGE_END', - - // This is used to return action input - SKILL_START = 'SKILL_START', - SKILL_CHUNK = 'SKILL_CHUNK', - SKILL_END = 'SKILL_END', - - STEP = 'STEP', - ERROR = 'ERROR', -} - -export interface AgentMessage { - message?: string; - type: AgentMessageType; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - metadata: Record; -} - -export const Message = ( - message: string, - type: AgentMessageType, - extraParams: Record = {}, -): AgentMessage => { - // For all message types, we use the message field - // The type field differentiates how the message should be interpreted - // For STEP and SKILL types, the message can contain JSON data as a string - return { message, type, metadata: extraParams }; -}; diff --git a/apps/webapp/app/trigger/cluster/index.ts b/apps/webapp/app/trigger/cluster/index.ts deleted file mode 100644 index 7a62d93..0000000 --- a/apps/webapp/app/trigger/cluster/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { queue, task } from "@trigger.dev/sdk"; -import { z } from "zod"; -import { ClusteringService } from "~/services/clustering.server"; -import { logger } from "~/services/logger.service"; - -const clusteringService = new ClusteringService(); - -// Define the payload schema for cluster tasks -export const ClusterPayload = z.object({ - userId: z.string(), - mode: z.enum(["auto", "incremental", "complete", "drift"]).default("auto"), - forceComplete: z.boolean().default(false), -}); - -const clusterQueue = queue({ - name: "cluster-queue", - concurrencyLimit: 10, -}); - -/** - * Single clustering task that handles all clustering operations based on payload mode - */ -export const clusterTask = task({ - id: "cluster", - queue: clusterQueue, - maxDuration: 1800, // 30 minutes max - run: async (payload: z.infer) => { - logger.info(`Starting ${payload.mode} clustering task for user ${payload.userId}`); - - try { - let result; - - switch (payload.mode) { - case "incremental": - result = await clusteringService.performIncrementalClustering( - payload.userId, - ); - logger.info(`Incremental clustering completed for user ${payload.userId}:`, { - newStatementsProcessed: result.newStatementsProcessed, - newClustersCreated: result.newClustersCreated, - }); - break; - - case "complete": - result = await clusteringService.performCompleteClustering( - payload.userId, - ); - logger.info(`Complete clustering completed for user ${payload.userId}:`, { - clustersCreated: result.clustersCreated, - statementsProcessed: result.statementsProcessed, - }); - break; - - case "drift": - // First detect drift - const driftMetrics = await clusteringService.detectClusterDrift( - payload.userId, - ); - - if (driftMetrics.driftDetected) { - // Handle drift by splitting low-cohesion clusters - const driftResult = await clusteringService.handleClusterDrift( - payload.userId, - ); - - logger.info(`Cluster drift handling completed for user ${payload.userId}:`, { - driftDetected: true, - clustersProcessed: driftResult.clustersProcessed, - newClustersCreated: driftResult.newClustersCreated, - splitClusters: driftResult.splitClusters, - }); - - result = { - driftDetected: true, - ...driftResult, - driftMetrics, - }; - } else { - logger.info(`No cluster drift detected for user ${payload.userId}`); - result = { - driftDetected: false, - clustersProcessed: 0, - newClustersCreated: 0, - splitClusters: [], - driftMetrics, - }; - } - break; - - case "auto": - default: - result = await clusteringService.performClustering( - payload.userId, - payload.forceComplete, - ); - logger.info(`Auto clustering completed for user ${payload.userId}:`, { - clustersCreated: result.clustersCreated, - statementsProcessed: result.statementsProcessed, - approach: result.approach, - }); - break; - } - - return { - success: true, - data: result, - }; - } catch (error) { - logger.error(`${payload.mode} clustering failed for user ${payload.userId}:`, { - error, - }); - throw error; - } - }, -}); diff --git a/apps/webapp/app/trigger/deep-search/deep-search-utils.ts b/apps/webapp/app/trigger/deep-search/deep-search-utils.ts deleted file mode 100644 index 8cb9b2d..0000000 --- a/apps/webapp/app/trigger/deep-search/deep-search-utils.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { type CoreMessage } from "ai"; -import { logger } from "@trigger.dev/sdk/v3"; -import { generate } from "./stream-utils"; -import { processTag } from "../chat/stream-utils"; -import { type AgentMessage, AgentMessageType, Message } from "../chat/types"; -import { type TotalCost } from "../utils/types"; - -/** - * Run the deep search ReAct loop - * Async generator that yields AgentMessage objects for streaming - * Follows the exact same pattern as chat-utils.ts - */ -export async function* run( - initialMessages: CoreMessage[], - searchTool: any, -): AsyncGenerator { - let messages = [...initialMessages]; - let completed = false; - let guardLoop = 0; - let searchCount = 0; - let totalEpisodesFound = 0; - const seenEpisodeIds = new Set(); // Track unique episodes - const totalCost: TotalCost = { - inputTokens: 0, - outputTokens: 0, - cost: 0, - }; - - const tools = { - searchMemory: searchTool, - }; - - logger.info("Starting deep search ReAct loop"); - - try { - while (!completed && guardLoop < 50) { - logger.info( - `ReAct loop iteration ${guardLoop}, searches: ${searchCount}`, - ); - - // Call LLM with current message history - const response = generate( - messages, - (event) => { - const usage = event.usage; - totalCost.inputTokens += usage.promptTokens; - totalCost.outputTokens += usage.completionTokens; - }, - tools, - ); - - let totalMessage = ""; - const toolCalls: any[] = []; - - // States for streaming final_response tags - const messageState = { - inTag: false, - message: "", - messageEnded: false, - lastSent: "", - }; - - // Process streaming response - for await (const chunk of response) { - if (typeof chunk === "object" && chunk.type === "tool-call") { - // Agent made a tool call - toolCalls.push(chunk); - logger.info(`Tool call: ${chunk.toolName}`); - } else if (typeof chunk === "string") { - totalMessage += chunk; - - // Stream final_response tags using processTag - if (!messageState.messageEnded) { - yield* processTag( - messageState, - totalMessage, - chunk, - "", - "", - { - start: AgentMessageType.MESSAGE_START, - chunk: AgentMessageType.MESSAGE_CHUNK, - end: AgentMessageType.MESSAGE_END, - }, - ); - } - } - } - - // Check for final response - if (totalMessage.includes("")) { - const match = totalMessage.match( - /(.*?)<\/final_response>/s, - ); - - if (match) { - // Accept synthesis - completed - completed = true; - logger.info( - `Final synthesis accepted after ${searchCount} searches, ${totalEpisodesFound} unique episodes found`, - ); - break; - } - } - - // Execute tool calls in parallel for better performance - if (toolCalls.length > 0) { - // Notify about all searches starting - for (const toolCall of toolCalls) { - logger.info(`Executing search: ${JSON.stringify(toolCall.args)}`); - yield Message("", AgentMessageType.SKILL_START); - yield Message( - `\nSearching memory: "${toolCall.args.query}"...\n`, - AgentMessageType.SKILL_CHUNK, - ); - yield Message("", AgentMessageType.SKILL_END); - } - - // Execute all searches in parallel - const searchPromises = toolCalls.map((toolCall) => - searchTool.execute(toolCall.args).then((result: any) => ({ - toolCall, - result, - })), - ); - - const searchResults = await Promise.all(searchPromises); - - // Process results and add to message history - for (const { toolCall, result } of searchResults) { - searchCount++; - - // Deduplicate episodes - track unique IDs - let uniqueNewEpisodes = 0; - if (result.episodes && Array.isArray(result.episodes)) { - for (const episode of result.episodes) { - const episodeId = - episode.id || episode._id || JSON.stringify(episode); - if (!seenEpisodeIds.has(episodeId)) { - seenEpisodeIds.add(episodeId); - uniqueNewEpisodes++; - } - } - } - - const episodesInThisSearch = result.episodes?.length || 0; - totalEpisodesFound = seenEpisodeIds.size; // Use unique count - - messages.push({ - role: "assistant", - content: [ - { - type: "tool-call", - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - args: toolCall.args, - }, - ], - }); - - // Add tool result to message history - messages.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolName: toolCall.toolName, - toolCallId: toolCall.toolCallId, - result: result, - }, - ], - }); - - logger.info( - `Search ${searchCount} completed: ${episodesInThisSearch} episodes (${uniqueNewEpisodes} new, ${totalEpisodesFound} unique total)`, - ); - } - - // If found no episodes and haven't exhausted search attempts, require more searches - if (totalEpisodesFound === 0 && searchCount < 7) { - logger.info( - `Agent attempted synthesis with 0 unique episodes after ${searchCount} searches - requiring more attempts`, - ); - - yield Message("", AgentMessageType.SKILL_START); - yield Message( - `No relevant context found yet - trying different search angles...`, - AgentMessageType.SKILL_CHUNK, - ); - yield Message("", AgentMessageType.SKILL_END); - - messages.push({ - role: "system", - content: `You have performed ${searchCount} searches but found 0 unique relevant episodes. Your queries may be too abstract or not matching the user's actual conversation topics. - -Review your DECOMPOSITION: -- Are you using specific terms from the content? -- Try searching broader related topics the user might have discussed -- Try different terminology or related concepts -- Search for user's projects, work areas, or interests - -Continue with different search strategies (you can search up to 7-10 times total).`, - }); - - guardLoop++; - continue; - } - - // Soft nudging after all searches executed (awareness, not commands) - if (totalEpisodesFound >= 30 && searchCount >= 3) { - logger.info( - `Nudging: ${totalEpisodesFound} unique episodes found - suggesting synthesis consideration`, - ); - - messages.push({ - role: "system", - content: `Context awareness: You have found ${totalEpisodesFound} unique episodes across ${searchCount} searches. This represents substantial context. Consider whether you have sufficient information for quality synthesis, or if additional search angles would meaningfully improve understanding.`, - }); - } else if (totalEpisodesFound >= 15 && searchCount >= 5) { - logger.info( - `Nudging: ${totalEpisodesFound} unique episodes after ${searchCount} searches - suggesting evaluation`, - ); - - messages.push({ - role: "system", - content: `Progress update: You have ${totalEpisodesFound} unique episodes from ${searchCount} searches. Evaluate whether you have covered the main angles from your decomposition, or if important aspects remain unexplored.`, - }); - } else if (searchCount >= 7) { - logger.info( - `Nudging: ${searchCount} searches completed with ${totalEpisodesFound} unique episodes`, - ); - - messages.push({ - role: "system", - content: `Search depth: You have performed ${searchCount} searches and found ${totalEpisodesFound} unique episodes. Consider whether additional searches would yield meaningfully different context, or if it's time to synthesize what you've discovered.`, - }); - } - if (searchCount >= 10) { - logger.info( - `Reached maximum search limit (10), forcing synthesis with ${totalEpisodesFound} unique episodes`, - ); - - yield Message("", AgentMessageType.SKILL_START); - yield Message( - `Maximum searches reached - synthesizing results...`, - AgentMessageType.SKILL_CHUNK, - ); - yield Message("", AgentMessageType.SKILL_END); - - messages.push({ - role: "system", - content: `You have performed 10 searches and found ${totalEpisodesFound} unique episodes. This is the maximum allowed. You MUST now provide your final synthesis wrapped in tags based on what you've found.`, - }); - } - } - - // Safety check - if no tool calls and no final response, something went wrong - if ( - toolCalls.length === 0 && - !totalMessage.includes("") - ) { - logger.warn("Agent produced neither tool calls nor final response"); - - messages.push({ - role: "system", - content: - "You must either use the searchMemory tool to search for more context, or provide your final synthesis wrapped in tags.", - }); - } - - guardLoop++; - } - - if (!completed) { - logger.warn( - `Loop ended without completion after ${guardLoop} iterations`, - ); - yield Message("", AgentMessageType.MESSAGE_START); - yield Message( - "Deep search did not complete - maximum iterations reached.", - AgentMessageType.MESSAGE_CHUNK, - ); - yield Message("", AgentMessageType.MESSAGE_END); - } - - yield Message("Stream ended", AgentMessageType.STREAM_END); - } catch (error) { - logger.error(`Deep search error: ${error}`); - yield Message((error as Error).message, AgentMessageType.ERROR); - yield Message("Stream ended", AgentMessageType.STREAM_END); - } -} diff --git a/apps/webapp/app/trigger/deep-search/index.ts b/apps/webapp/app/trigger/deep-search/index.ts deleted file mode 100644 index a8b61f4..0000000 --- a/apps/webapp/app/trigger/deep-search/index.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { metadata, task } from "@trigger.dev/sdk"; -import { type CoreMessage } from "ai"; -import { logger } from "@trigger.dev/sdk/v3"; -import { nanoid } from "nanoid"; -import { - deletePersonalAccessToken, - getOrCreatePersonalAccessToken, -} from "../utils/utils"; -import { getReActPrompt } from "./prompt"; -import { type DeepSearchPayload, type DeepSearchResponse } from "./types"; -import { createSearchMemoryTool } from "./utils"; -import { run } from "./deep-search-utils"; -import { AgentMessageType } from "../chat/types"; - -export const deepSearch = task({ - id: "deep-search", - maxDuration: 3000, - run: async (payload: DeepSearchPayload): Promise => { - const { content, userId, stream, metadata: meta, intentOverride } = payload; - - const randomKeyName = `deepSearch_${nanoid(10)}`; - - // Get or create token for search API calls - const pat = await getOrCreatePersonalAccessToken({ - name: randomKeyName, - userId: userId as string, - }); - - if (!pat?.token) { - throw new Error("Failed to create personal access token"); - } - - try { - // Create search tool that agent will use - const searchTool = createSearchMemoryTool(pat.token); - - // Build initial messages with ReAct prompt - const initialMessages: CoreMessage[] = [ - { - role: "system", - content: getReActPrompt(meta, intentOverride), - }, - { - role: "user", - content: `CONTENT TO ANALYZE:\n${content}\n\nPlease search my memory for relevant context and synthesize what you find.`, - }, - ]; - - // Run the ReAct loop generator - const llmResponse = run(initialMessages, searchTool); - - // Streaming mode: stream via metadata.stream like chat.ts does - // This makes all message types available to clients in real-time - const messageStream = await metadata.stream("messages", llmResponse); - - let synthesis = ""; - - for await (const step of messageStream) { - // MESSAGE_CHUNK: Final synthesis - accumulate and stream - if (step.type === AgentMessageType.MESSAGE_CHUNK) { - synthesis += step.message; - } - - // STREAM_END: Loop completed - if (step.type === AgentMessageType.STREAM_END) { - break; - } - } - - await deletePersonalAccessToken(pat?.id); - - // Clean up any remaining tags - synthesis = synthesis - .replace(//gi, "") - .replace(/<\/final_response>/gi, "") - .trim(); - - return { synthesis }; - } catch (error) { - await deletePersonalAccessToken(pat?.id); - logger.error(`Deep search error: ${error}`); - throw error; - } - }, -}); diff --git a/apps/webapp/app/trigger/deep-search/prompt.ts b/apps/webapp/app/trigger/deep-search/prompt.ts deleted file mode 100644 index fa56b44..0000000 --- a/apps/webapp/app/trigger/deep-search/prompt.ts +++ /dev/null @@ -1,148 +0,0 @@ -export function getReActPrompt( - metadata?: { source?: string; url?: string; pageTitle?: string }, - intentOverride?: string -): string { - const contextHints = []; - - if (metadata?.source === "chrome" && metadata?.url?.includes("mail.google.com")) { - contextHints.push("Content is from email - likely reading intent"); - } - if (metadata?.source === "chrome" && metadata?.url?.includes("calendar.google.com")) { - contextHints.push("Content is from calendar - likely meeting prep intent"); - } - if (metadata?.source === "chrome" && metadata?.url?.includes("docs.google.com")) { - contextHints.push("Content is from document editor - likely writing intent"); - } - if (metadata?.source === "obsidian") { - contextHints.push("Content is from note editor - likely writing or research intent"); - } - - return `You are a memory research agent analyzing content to find relevant context. - -YOUR PROCESS (ReAct Framework): - -1. DECOMPOSE: First, break down the content into structured categories - - Analyze the content and extract: - a) ENTITIES: Specific people, project names, tools, products mentioned - Example: "John Smith", "Phoenix API", "Redis", "mobile app" - - b) TOPICS & CONCEPTS: Key subjects, themes, domains - Example: "authentication", "database design", "performance optimization" - - c) TEMPORAL MARKERS: Time references, deadlines, events - Example: "last week's meeting", "Q2 launch", "yesterday's discussion" - - d) ACTIONS & TASKS: What's being done, decided, or requested - Example: "implement feature", "review code", "make decision on" - - e) USER INTENT: What is the user trying to accomplish? - ${intentOverride ? `User specified: "${intentOverride}"` : "Infer from context: reading/writing/meeting prep/research/task tracking/review"} - -2. FORM QUERIES: Create targeted search queries from your decomposition - - Based on decomposition, form specific queries: - - Search for each entity by name (people, projects, tools) - - Search for topics the user has discussed before - - Search for related work or conversations in this domain - - Use the user's actual terminology, not generic concepts - - EXAMPLE - Content: "Email from Sarah about the API redesign we discussed last week" - Decomposition: - - Entities: "Sarah", "API redesign" - - Topics: "API design", "redesign" - - Temporal: "last week" - - Actions: "discussed", "email communication" - - Intent: Reading (email) / meeting prep - - Queries to form: - ✅ "Sarah" (find past conversations with Sarah) - ✅ "API redesign" or "API design" (find project discussions) - ✅ "last week" + "Sarah" (find recent context) - ✅ "meetings" or "discussions" (find related conversations) - - ❌ Avoid: "email communication patterns", "API architecture philosophy" - (These are abstract - search what user actually discussed!) - -3. SEARCH: Execute your queries using searchMemory tool - - Start with 2-3 core searches based on main entities/topics - - Make each search specific and targeted - - Use actual terms from the content, not rephrased concepts - -4. OBSERVE: Evaluate search results - - Did you find relevant episodes? How many unique ones? - - What specific context emerged? - - What new entities/topics appeared in results? - - Are there gaps in understanding? - - Should you search more angles? - - Note: Episode counts are automatically deduplicated across searches - overlapping episodes are only counted once. - -5. REACT: Decide next action based on observations - - STOPPING CRITERIA - Proceed to SYNTHESIZE if ANY of these are true: - - You found 20+ unique episodes across your searches → ENOUGH CONTEXT - - You performed 5+ searches and found relevant episodes → SUFFICIENT - - You performed 7+ searches regardless of results → EXHAUSTED STRATEGIES - - You found strong relevant context from multiple angles → COMPLETE - - System nudges will provide awareness of your progress, but you decide when synthesis quality would be optimal. - - If you found little/no context AND searched less than 7 times: - - Try different query angles from your decomposition - - Search broader related topics - - Search user's projects or work areas - - Try alternative terminology - - ⚠️ DO NOT search endlessly - if you found relevant episodes, STOP and synthesize! - -6. SYNTHESIZE: After gathering sufficient context, provide final answer - - Wrap your synthesis in tags - - Present direct factual context from memory - no meta-commentary - - Write as if providing background context to an AI assistant - - Include: facts, decisions, preferences, patterns, timelines - - Note any gaps, contradictions, or evolution in thinking - - Keep it concise and actionable - - DO NOT use phrases like "Previous discussions on", "From conversations", "Past preferences indicate" - - DO NOT use conversational language like "you said" or "you mentioned" - - Present information as direct factual statements - -FINAL RESPONSE FORMAT: - -[Direct synthesized context - factual statements only] - -Good examples: -- "The API redesign focuses on performance and scalability. Key decisions: moving to GraphQL, caching layer with Redis." -- "Project Phoenix launches Q2 2024. Main features: real-time sync, offline mode, collaborative editing." -- "Sarah leads the backend team. Recent work includes authentication refactor and database migration." - -Bad examples: -❌ "Previous discussions on the API revealed..." -❌ "From past conversations, it appears that..." -❌ "Past preferences indicate..." -❌ "The user mentioned that..." - -Just state the facts directly. - - -${contextHints.length > 0 ? `\nCONTEXT HINTS:\n${contextHints.join("\n")}` : ""} - -CRITICAL REQUIREMENTS: -- ALWAYS start with DECOMPOSE step - extract entities, topics, temporal markers, actions -- Form specific queries from your decomposition - use user's actual terms -- Minimum 3 searches required -- Maximum 10 searches allowed - must synthesize after that -- STOP and synthesize when you hit stopping criteria (20+ episodes, 5+ searches with results, 7+ searches total) -- Each search should target different aspects from decomposition -- Present synthesis directly without meta-commentary - -SEARCH QUALITY CHECKLIST: -✅ Queries use specific terms from content (names, projects, exact phrases) -✅ Searched multiple angles from decomposition (entities, topics, related areas) -✅ Stop when you have enough unique context - don't search endlessly -✅ Tried alternative terminology if initial searches found nothing -❌ Avoid generic/abstract queries that don't match user's vocabulary -❌ Don't stop at 3 searches if you found zero unique episodes -❌ Don't keep searching when you already found 20+ unique episodes -}` -} diff --git a/apps/webapp/app/trigger/deep-search/stream-utils.ts b/apps/webapp/app/trigger/deep-search/stream-utils.ts deleted file mode 100644 index 11910c6..0000000 --- a/apps/webapp/app/trigger/deep-search/stream-utils.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { openai } from "@ai-sdk/openai"; -import { logger } from "@trigger.dev/sdk/v3"; -import { - type CoreMessage, - type LanguageModelV1, - streamText, - type ToolSet, -} from "ai"; - -/** - * Generate LLM responses with tool calling support - * Simplified version for deep-search use case - NO maxSteps for manual ReAct control - */ -export async function* generate( - messages: CoreMessage[], - onFinish?: (event: any) => void, - tools?: ToolSet, - model?: string, -): AsyncGenerator< - | string - | { - type: string; - toolName: string; - args?: any; - toolCallId?: string; - } -> { - const modelToUse = model || process.env.MODEL || "gpt-4.1-2025-04-14"; - const modelInstance = openai(modelToUse) as LanguageModelV1; - - logger.info(`Starting LLM generation with model: ${modelToUse}`); - - try { - const { textStream, fullStream } = streamText({ - model: modelInstance, - messages, - temperature: 1, - tools, - // NO maxSteps - we handle tool execution manually in the ReAct loop - toolCallStreaming: true, - onFinish, - }); - - // Yield text chunks - for await (const chunk of textStream) { - yield chunk; - } - - // Yield tool calls - for await (const fullChunk of fullStream) { - if (fullChunk.type === "tool-call") { - yield { - type: "tool-call", - toolName: fullChunk.toolName, - toolCallId: fullChunk.toolCallId, - args: fullChunk.args, - }; - } - - if (fullChunk.type === "error") { - logger.error(`LLM error: ${JSON.stringify(fullChunk)}`); - } - } - } catch (error) { - logger.error(`LLM generation error: ${error}`); - throw error; - } -} diff --git a/apps/webapp/app/trigger/deep-search/types.ts b/apps/webapp/app/trigger/deep-search/types.ts deleted file mode 100644 index b54dcec..0000000 --- a/apps/webapp/app/trigger/deep-search/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface DeepSearchPayload { - content: string; - userId: string; - stream: boolean; - intentOverride?: string; - metadata?: { - source?: "chrome" | "obsidian" | "mcp"; - url?: string; - pageTitle?: string; - }; -} - -export interface DeepSearchResponse { - synthesis: string; - episodes?: Array<{ - content: string; - createdAt: Date; - spaceIds: string[]; - }>; -} diff --git a/apps/webapp/app/trigger/deep-search/utils.ts b/apps/webapp/app/trigger/deep-search/utils.ts deleted file mode 100644 index 75148c8..0000000 --- a/apps/webapp/app/trigger/deep-search/utils.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { tool } from "ai"; -import { z } from "zod"; -import axios from "axios"; -import { logger } from "@trigger.dev/sdk/v3"; - -export function createSearchMemoryTool(token: string) { - return tool({ - description: - "Search the user's memory for relevant facts and episodes. Use this tool multiple times with different queries to gather comprehensive context.", - parameters: z.object({ - query: z - .string() - .describe( - "Search query to find relevant information. Be specific: entity names, topics, concepts.", - ), - }), - execute: async ({ query }) => { - try { - const response = await axios.post( - `${process.env.API_BASE_URL || "https://core.heysol.ai"}/api/v1/search`, - { query }, - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - const searchResult = response.data; - - return { - facts: searchResult.facts || [], - episodes: searchResult.episodes || [], - summary: `Found ${searchResult.episodes?.length || 0} relevant memories`, - }; - } catch (error) { - logger.error(`SearchMemory tool error: ${error}`); - return { - facts: [], - episodes: [], - summary: "No results found", - }; - } - }, - }); -} - -// Helper to extract unique episodes from tool calls -export function extractEpisodesFromToolCalls(toolCalls: any[]): any[] { - const episodes: any[] = []; - - for (const call of toolCalls || []) { - if (call.toolName === "searchMemory" && call.result?.episodes) { - episodes.push(...call.result.episodes); - } - } - - // Deduplicate by content + createdAt - const uniqueEpisodes = Array.from( - new Map(episodes.map((e) => [`${e.content}-${e.createdAt}`, e])).values(), - ); - - return uniqueEpisodes.slice(0, 10); -} diff --git a/apps/webapp/app/trigger/utils/mcp.ts b/apps/webapp/app/trigger/utils/mcp.ts index db865f0..1aaed85 100644 --- a/apps/webapp/app/trigger/utils/mcp.ts +++ b/apps/webapp/app/trigger/utils/mcp.ts @@ -1,163 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { logger } from "@trigger.dev/sdk/v3"; -import { jsonSchema, tool, type ToolSet } from "ai"; + import * as fs from "fs"; import * as path from "path"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { prisma } from "./prisma"; -export const configureStdioMCPEnvironment = ( - spec: any, - account: any, -): { env: Record; args: any[] } => { - if (!spec.mcp) { - return { env: {}, args: [] }; - } - - const mcpSpec = spec.mcp; - const configuredMCP = { ...mcpSpec }; - - // Replace config placeholders in environment variables - if (configuredMCP.env) { - for (const [key, value] of Object.entries(configuredMCP.env)) { - if (typeof value === "string" && value.includes("${config:")) { - // Extract the config key from the placeholder - const configKey = value.match(/\$\{config:(.*?)\}/)?.[1]; - if ( - configKey && - account.integrationConfiguration && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (account.integrationConfiguration as any)[configKey] - ) { - configuredMCP.env[key] = value.replace( - `\${config:${configKey}}`, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (account.integrationConfiguration as any)[configKey], - ); - } - } - - if (typeof value === "string" && value.includes("${integrationConfig:")) { - // Extract the config key from the placeholder - const configKey = value.match(/\$\{integrationConfig:(.*?)\}/)?.[1]; - if ( - configKey && - account.integrationDefinition.config && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (account.integrationDefinition.config as any)[configKey] - ) { - configuredMCP.env[key] = value.replace( - `\${integrationConfig:${configKey}}`, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (account.integrationDefinition.config as any)[configKey], - ); - } - } - } - } - - return { - env: configuredMCP.env || {}, - args: Array.isArray(configuredMCP.args) ? configuredMCP.args : [], - }; -}; - -export class MCP { - private Client: any; - private client: any = {}; - - constructor() {} - - public async init() { - this.Client = await MCP.importClient(); - } - - private static async importClient() { - const { Client } = await import( - "@modelcontextprotocol/sdk/client/index.js" - ); - return Client; - } - - async load(headers: any) { - return await this.connectToServer( - `${process.env.API_BASE_URL}/api/v1/mcp?source=core`, - headers, - ); - } - - async allTools(): Promise { - try { - const { tools } = await this.client.listTools(); - - const finalTools: ToolSet = {}; - - tools.map(({ name, description, inputSchema }: any) => { - finalTools[name] = tool({ - description, - parameters: jsonSchema(inputSchema), - }); - }); - - return finalTools; - } catch (error) { - return {}; - } - - // Flatten and convert to object - } - - async getTool(name: string) { - try { - const { tools: clientTools } = await this.client.listTools(); - const clientTool = clientTools.find((to: any) => to.name === name); - - return JSON.stringify(clientTool); - } catch (e) { - logger.error((e as string) ?? "Getting tool failed"); - throw new Error("Getting tool failed"); - } - } - - async callTool(name: string, parameters: any) { - const response = await this.client.callTool({ - name, - arguments: parameters, - }); - - return response; - } - - async connectToServer(url: string, headers: any) { - try { - const client = new this.Client( - { - name: "Core", - version: "1.0.0", - }, - { - capabilities: {}, - }, - ); - - // Configure the transport for MCP server - const transport = new StreamableHTTPClientTransport(new URL(url), { - requestInit: { headers }, - }); - - // Connect to the MCP server - await client.connect(transport, { timeout: 60 * 1000 * 5 }); - this.client = client; - - logger.info(`Connected to MCP server`); - } catch (e) { - logger.error(`Failed to connect to MCP server: `, { e }); - throw e; - } - } -} - export const fetchAndSaveStdioIntegrations = async () => { try { logger.info("Starting stdio integrations fetch and save process"); diff --git a/apps/webapp/app/trigger/utils/utils.ts b/apps/webapp/app/trigger/utils/utils.ts index f7dea40..2c04a95 100644 --- a/apps/webapp/app/trigger/utils/utils.ts +++ b/apps/webapp/app/trigger/utils/utils.ts @@ -8,11 +8,9 @@ import { type Workspace, } from "@prisma/client"; -import { logger } from "@trigger.dev/sdk/v3"; import { type CoreMessage } from "ai"; import { type HistoryStep } from "./types"; -import axios from "axios"; import nodeCrypto from "node:crypto"; import { customAlphabet, nanoid } from "nanoid"; import { prisma } from "./prisma"; @@ -148,58 +146,6 @@ export interface RunChatPayload { isContinuation?: boolean; } -export const init = async ({ payload }: { payload: InitChatPayload }) => { - logger.info("Loading init"); - const conversationHistory = await prisma.conversationHistory.findUnique({ - where: { id: payload.conversationHistoryId }, - include: { conversation: true }, - }); - - const conversation = conversationHistory?.conversation as Conversation; - - const workspace = await prisma.workspace.findUnique({ - where: { id: conversation.workspaceId as string }, - }); - - if (!workspace) { - return { conversation, conversationHistory }; - } - - const randomKeyName = `chat_${nanoid(10)}`; - const pat = await getOrCreatePersonalAccessToken({ - name: randomKeyName, - userId: workspace.userId as string, - }); - - const user = await prisma.user.findFirst({ - where: { id: workspace.userId as string }, - }); - - // Set up axios interceptor for memory operations - axios.interceptors.request.use((config) => { - if (config.url?.startsWith("https://core::memory")) { - // Handle both search and ingest endpoints - config.url = config.url.replace( - "https://core::memory", - process.env.API_BASE_URL ?? "", - ); - - config.headers.Authorization = `Bearer ${pat.token}`; - } - - return config; - }); - - return { - conversation, - conversationHistory, - tokenId: pat.id, - token: pat.token, - userId: user?.id, - userName: user?.name, - }; -}; - export const createConversationHistoryForAgent = async ( conversationId: string, ) => { diff --git a/apps/webapp/app/utils/mcp/memory.ts b/apps/webapp/app/utils/mcp/memory.ts index dafe414..0bf2f28 100644 --- a/apps/webapp/app/utils/mcp/memory.ts +++ b/apps/webapp/app/utils/mcp/memory.ts @@ -3,7 +3,6 @@ import { addToQueue } from "~/lib/ingest.server"; import { logger } from "~/services/logger.service"; import { SearchService } from "~/services/search.server"; import { SpaceService } from "~/services/space.server"; -import { deepSearch } from "~/trigger/deep-search"; import { IntegrationLoader } from "./integration-loader"; import { hasCredits } from "~/services/billing.server"; import { prisma } from "~/db.server"; @@ -229,8 +228,8 @@ export async function callMemoryTool( return await handleGetIntegrationActions({ ...args }); case "execute_integration_action": return await handleExecuteIntegrationAction({ ...args }); - case "memory_deep_search": - return await handleMemoryDeepSearch({ ...args, userId, source }); + // case "memory_deep_search": + // return await handleMemoryDeepSearch({ ...args, userId, source }); default: throw new Error(`Unknown memory tool: ${toolName}`); } @@ -596,58 +595,3 @@ async function handleExecuteIntegrationAction(args: any) { }; } } - -// Handler for memory_deep_search -async function handleMemoryDeepSearch(args: any) { - try { - const { content, intentOverride, userId, source } = args; - - if (!content) { - throw new Error("content is required"); - } - - // Trigger non-streaming deep search task - const handle = await deepSearch.triggerAndWait({ - content, - userId, - stream: false, // MCP doesn't need streaming - intentOverride, - metadata: { source }, - }); - - // Wait for task completion - if (handle.ok) { - return { - content: [ - { - type: "text", - text: JSON.stringify(handle.output), - }, - ], - isError: false, - }; - } else { - return { - content: [ - { - type: "text", - text: `Error performing deep search: ${handle.error instanceof Error ? handle.error.message : String(handle.error)}`, - }, - ], - isError: true, - }; - } - } catch (error) { - logger.error(`MCP deep search error: ${error}`); - - return { - content: [ - { - type: "text", - text: `Error performing deep search: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - }; - } -} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 9659f65..aae6e45 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -14,11 +14,12 @@ "trigger:deploy": "pnpm dlx trigger.dev@4.0.4 deploy" }, "dependencies": { - "@ai-sdk/amazon-bedrock": "2.2.12", - "@ai-sdk/anthropic": "^1.2.12", - "@ai-sdk/google": "^1.2.22", - "@ai-sdk/openai": "^1.3.21", - "@anthropic-ai/sdk": "^0.60.0", + "@ai-sdk/amazon-bedrock": "3.0.47", + "@ai-sdk/anthropic": "^2.0.37", + "@ai-sdk/google": "^2.0.23", + "@ai-sdk/openai": "^2.0.53", + "@ai-sdk/react": "2.0.78", + "@anthropic-ai/sdk": "^0.67.0", "@aws-sdk/client-s3": "3.879.0", "@aws-sdk/credential-providers": "^3.894.0", "@aws-sdk/s3-request-presigner": "3.879.0", @@ -81,7 +82,7 @@ "@tiptap/starter-kit": "2.11.9", "@trigger.dev/react-hooks": "4.0.4", "@trigger.dev/sdk": "4.0.4", - "ai": "4.3.19", + "ai": "5.0.78", "axios": "^1.10.0", "bullmq": "^5.53.2", "cheerio": "^1.1.2", @@ -117,7 +118,7 @@ "neo4j-driver": "^5.28.1", "non.geist": "^1.0.2", "novel": "^1.0.2", - "ollama-ai-provider": "1.2.0", + "ollama-ai-provider-v2": "1.5.1", "openai": "^5.12.2", "posthog-js": "^1.116.6", "posthog-node": "^5.10.3", @@ -128,6 +129,7 @@ "react-resizable-panels": "^1.0.9", "react-hotkeys-hook": "^4.5.0", "react-virtualized": "^9.22.6", + "resumable-stream": "2.2.8", "remix-auth": "^4.2.0", "remix-auth-oauth2": "^3.4.1", "remix-themes": "^2.0.4", diff --git a/hosting/docker/.env b/hosting/docker/.env index 9f0420a..0ad47a9 100644 --- a/hosting/docker/.env +++ b/hosting/docker/.env @@ -1,4 +1,4 @@ -VERSION=0.1.25 +VERSION=0.1.26 # Nest run in docker, change host to database container name DB_HOST=postgres @@ -48,4 +48,8 @@ OLLAMA_URL=http://ollama:11434 EMBEDDING_MODEL=text-embedding-3-small MODEL=gpt-4.1-2025-04-14 + +## for opensource embedding model +# EMBEDDING_MODEL=mxbai-embed-large + QUEUE_PROVIDER=bullmq \ No newline at end of file diff --git a/hosting/docker/docker-compose.yaml b/hosting/docker/docker-compose.yaml index e4bce7f..5c934a6 100644 --- a/hosting/docker/docker-compose.yaml +++ b/hosting/docker/docker-compose.yaml @@ -109,24 +109,25 @@ services: retries: 10 start_period: 20s - ollama: - container_name: core-ollama - image: ollama/ollama:0.12.6 - ports: - - "11434:11434" - volumes: - - ollama_data:/root/.ollama - - ./scripts/ollama-init.sh:/usr/local/bin/ollama-init.sh:ro - networks: - - core - entrypoint: ["/bin/bash", "/usr/local/bin/ollama-init.sh"] - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 90s - restart: unless-stopped + # Uncomment this if you want to use a local embedding modal + # ollama: + # container_name: core-ollama + # image: ollama/ollama:0.12.6 + # ports: + # - "11434:11434" + # volumes: + # - ollama_data:/root/.ollama + # - ./scripts/ollama-init.sh:/usr/local/bin/ollama-init.sh:ro + # networks: + # - core + # entrypoint: ["/bin/bash", "/usr/local/bin/ollama-init.sh"] + # healthcheck: + # test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"] + # interval: 30s + # timeout: 10s + # retries: 5 + # start_period: 90s + # restart: unless-stopped networks: core: diff --git a/package.json b/package.json index 4f2c867..5dc756d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "core", "private": true, - "version": "0.1.25", + "version": "0.1.26", "workspaces": [ "apps/*", "packages/*" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8b2ef8..1a78a8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,20 +281,23 @@ importers: apps/webapp: dependencies: '@ai-sdk/amazon-bedrock': - specifier: 2.2.12 - version: 2.2.12(zod@3.25.76) + specifier: 3.0.47 + version: 3.0.47(zod@3.25.76) '@ai-sdk/anthropic': - specifier: ^1.2.12 - version: 1.2.12(zod@3.25.76) + specifier: ^2.0.37 + version: 2.0.37(zod@3.25.76) '@ai-sdk/google': - specifier: ^1.2.22 - version: 1.2.22(zod@3.25.76) + specifier: ^2.0.23 + version: 2.0.23(zod@3.25.76) '@ai-sdk/openai': - specifier: ^1.3.21 - version: 1.3.22(zod@3.25.76) + specifier: ^2.0.53 + version: 2.0.53(zod@3.25.76) + '@ai-sdk/react': + specifier: 2.0.78 + version: 2.0.78(react@18.3.1)(zod@3.25.76) '@anthropic-ai/sdk': - specifier: ^0.60.0 - version: 0.60.0 + specifier: ^0.67.0 + version: 0.67.0(zod@3.25.76) '@aws-sdk/client-s3': specifier: 3.879.0 version: 3.879.0 @@ -480,10 +483,10 @@ importers: version: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@trigger.dev/sdk': specifier: 4.0.4 - version: 4.0.4(ai@4.3.19(react@18.3.1)(zod@3.25.76))(zod@3.25.76) + version: 4.0.4(ai@5.0.78(zod@3.25.76))(zod@3.25.76) ai: - specifier: 4.3.19 - version: 4.3.19(react@18.3.1)(zod@3.25.76) + specifier: 5.0.78 + version: 5.0.78(zod@3.25.76) axios: specifier: ^1.10.0 version: 1.10.0 @@ -589,9 +592,9 @@ importers: novel: specifier: ^1.0.2 version: 1.0.2(@tiptap/extension-code-block@2.11.9(@tiptap/core@2.25.0(@tiptap/pm@2.25.0))(@tiptap/pm@2.25.0))(@types/react-dom@18.3.7(@types/react@18.2.69))(@types/react@18.2.69)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - ollama-ai-provider: - specifier: 1.2.0 - version: 1.2.0(zod@3.25.76) + ollama-ai-provider-v2: + specifier: 1.5.1 + version: 1.5.1(zod@3.25.76) openai: specifier: ^5.12.2 version: 5.12.2(ws@8.18.3)(zod@3.25.76) @@ -637,6 +640,9 @@ importers: remix-utils: specifier: ^7.7.0 version: 7.7.0(@remix-run/node@2.1.0(typescript@5.8.3))(@remix-run/react@2.16.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3))(@remix-run/router@1.23.0)(crypto-js@4.2.0)(react@18.3.1)(zod@3.25.76) + resumable-stream: + specifier: 2.2.8 + version: 2.2.8 sigma: specifier: ^3.0.2 version: 3.0.2(graphology-types@0.24.8) @@ -928,56 +934,56 @@ importers: packages: - '@ai-sdk/amazon-bedrock@2.2.12': - resolution: {integrity: sha512-m8gARnh45pr1s08Uu4J/Pm8913mwJPejPOm59b+kUqMsP9ilhUtH/bp8432Ra/v+vHuMoBrglG2ZvXtctAaH2g==} + '@ai-sdk/amazon-bedrock@3.0.47': + resolution: {integrity: sha512-oTAxTU4k1+EIKP41nvLGN7dWwoK7dg1JptrX6csn7abmSfQSsygDrfeMf8/7Mdnr+frt9i5ogvpQkp1ak0916Q==} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/anthropic@1.2.12': - resolution: {integrity: sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==} + '@ai-sdk/anthropic@2.0.37': + resolution: {integrity: sha512-r2e9BWoobisH9B5b7x3yYG/k9WlsZqa4D94o7gkwktReqrjjv83zNMop4KmlJsh/zBhbsaP8S8SUfiwK+ESxgg==} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/google@1.2.22': - resolution: {integrity: sha512-Ppxu3DIieF1G9pyQ5O1Z646GYR0gkC57YdBqXJ82qvCdhEhZHu0TWhmnOoeIWe2olSbuDeoOY+MfJrW8dzS3Hw==} + '@ai-sdk/gateway@2.0.1': + resolution: {integrity: sha512-vPVIbnP35ZnayS937XLo85vynR85fpBQWHCdUweq7apzqFOTU2YkUd4V3msebEHbQ2Zro60ZShDDy9SMiyWTqA==} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/openai@1.3.22': - resolution: {integrity: sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw==} + '@ai-sdk/google@2.0.23': + resolution: {integrity: sha512-VbCnKR+6aWUVLkAiSW5gUEtST7KueEmlt+d6qwDikxlLnFG9pzy59je8MiDVeM5G2tuSXbvZQF78PGIfXDBmow==} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider-utils@2.2.8': - resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==} + '@ai-sdk/openai@2.0.53': + resolution: {integrity: sha512-GIkR3+Fyif516ftXv+YPSPstnAHhcZxNoR2s8uSHhQ1yBT7I7aQYTVwpjAuYoT3GR+TeP50q7onj2/nDRbT2FQ==} engines: {node: '>=18'} peerDependencies: - zod: ^3.23.8 + zod: ^3.25.76 || ^4.1.8 - '@ai-sdk/provider@1.1.3': - resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} + '@ai-sdk/provider-utils@3.0.12': + resolution: {integrity: sha512-ZtbdvYxdMoria+2SlNarEk6Hlgyf+zzcznlD55EAl+7VZvJaSg2sqPvwArY7L6TfDEDJsnCq0fdhBSkYo0Xqdg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@2.0.0': + resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} - '@ai-sdk/react@1.2.12': - resolution: {integrity: sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==} + '@ai-sdk/react@2.0.78': + resolution: {integrity: sha512-f5inDBHJyUEzbtNxc9HiTxbcGjtot0uuc//0/khGrl8IZlLxw+yTxO/T1Qq95Rw5QPwTx9/Aw7wIZei3qws9hA==} engines: {node: '>=18'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 + zod: ^3.25.76 || ^4.1.8 peerDependenciesMeta: zod: optional: true - '@ai-sdk/ui-utils@1.2.11': - resolution: {integrity: sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==} - engines: {node: '>=18'} - peerDependencies: - zod: ^3.23.8 - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -986,9 +992,14 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} - '@anthropic-ai/sdk@0.60.0': - resolution: {integrity: sha512-9zu/TXaUy8BZhXedDtt1wT3H4LOlpKDO1/ftiFpeR3N1PCr3KJFKkxxlQWWt1NNp08xSwUNJ3JNY8yhl8av6eQ==} + '@anthropic-ai/sdk@0.67.0': + resolution: {integrity: sha512-Buxbf6jYJ+pPtfCgXe1pcFtZmdXPrbdqhBjiscFt9irS1G0hCsmR/fPA+DwKTk4GPjqeNnnCYNecXH6uVZ4G/A==} hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true '@arr/every@1.0.1': resolution: {integrity: sha512-UQFQ6SgyJ6LX42W8rHCs8KVc0JS0tzVL9ct4XYedJukskYVWTo49tNiMEK9C2HTyarbNiT/RVIRSY82vH+6sTg==} @@ -5108,6 +5119,9 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@swc/core-darwin-arm64@1.3.101': resolution: {integrity: sha512-mNFK+uHNPRXSnfTOG34zJOeMl2waM4hF4a2NY7dkMXrPqw9CoJn4MwTXJcyMiSz1/BnNjjTCHF3Yhj0jPxmkzQ==} engines: {node: '>=10'} @@ -5685,9 +5699,6 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - '@types/diff-match-patch@1.0.36': - resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} - '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -6121,6 +6132,10 @@ packages: '@vanilla-extract/private@1.0.8': resolution: {integrity: sha512-oRAbUlq1SyTWCo7dQnTVm+xgJMqNl8K1dEempQHXzQvUuyEfBabMt0wNGf+VCHzvKbx/Bzr9p/2wy8WA9+2z2g==} + '@vercel/oidc@3.0.3': + resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==} + engines: {node: '>= 20'} + '@web3-storage/multipart-parser@1.0.0': resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} @@ -6229,15 +6244,11 @@ packages: resolution: {integrity: sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w==} engines: {node: '>=12'} - ai@4.3.19: - resolution: {integrity: sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==} + ai@5.0.78: + resolution: {integrity: sha512-ec77fmQwJGLduswMrW4AAUGSOiu8dZaIwMmWHHGKsrMUFFS6ugfkTyx0srtuKYHNRRLRC2dT7cPirnUl98VnxA==} engines: {node: '>=18'} peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 - peerDependenciesMeta: - react: - optional: true + zod: ^3.25.76 || ^4.1.8 ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} @@ -7286,9 +7297,6 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - diff-match-patch@1.0.5: - resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} - diff@5.2.0: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} @@ -7775,6 +7783,10 @@ packages: resolution: {integrity: sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==} engines: {node: '>=20.0.0'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} @@ -8730,6 +8742,10 @@ packages: resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -8754,11 +8770,6 @@ packages: jsonc-parser@3.2.1: resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} - jsondiffpatch@0.6.0: - resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -9726,14 +9737,11 @@ packages: ohash@1.1.6: resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==} - ollama-ai-provider@1.2.0: - resolution: {integrity: sha512-jTNFruwe3O/ruJeppI/quoOUxG7NA6blG3ZyQj3lei4+NnJo7bi3eIRWqlVpRlu/mbzbFXeJSBuYQWF6pzGKww==} + ollama-ai-provider-v2@1.5.1: + resolution: {integrity: sha512-5R3z7Y+mm8VEtoq+rIoIqkEy83oYM3DXX6Nyrn6yofYvYl56BCoJMNwXsPrpmCI0O4fN/gAIDTLpznYMRGzZ5g==} engines: {node: '>=18'} peerDependencies: - zod: ^3.0.0 - peerDependenciesMeta: - zod: - optional: true + zod: ^4.0.16 on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} @@ -9923,9 +9931,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - partial-json@0.1.7: - resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} - partysocket@1.1.4: resolution: {integrity: sha512-jXP7PFj2h5/v4UjDS8P7MZy6NJUQ7sspiFyxL4uc/+oKOL+KdtXzHnTV8INPGxBrLTXgalyG3kd12Qm7WrYc3A==} @@ -10923,6 +10928,9 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + resumable-stream@2.2.8: + resolution: {integrity: sha512-F9+SLKw/a/p7hRjy2CNwzT66UIlY7aY4D3Sg9xwuZMA7nxVQrVPXCWU27qIGcO4jlauL0T3XkCN2218qi6ugTw==} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -11020,9 +11028,6 @@ packages: resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==} engines: {node: '>= 10.13.0'} - secure-json-parse@2.7.0: - resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} - selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -11610,6 +11615,9 @@ packages: resolution: {integrity: sha512-kr8SKKw94OI+xTGOkfsvwZQ8mWoikZDd2n8XZHjJVZUARZT+4/VV6cacRS6CLsH9bNm+HFIPU1Zx4CnNnb4qlQ==} engines: {node: '>=6'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -12381,61 +12389,62 @@ packages: snapshots: - '@ai-sdk/amazon-bedrock@2.2.12(zod@3.25.76)': + '@ai-sdk/amazon-bedrock@3.0.47(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + '@ai-sdk/anthropic': 2.0.37(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) '@smithy/eventstream-codec': 4.1.1 '@smithy/util-utf8': 4.1.0 aws4fetch: 1.0.20 zod: 3.25.76 - '@ai-sdk/anthropic@1.2.12(zod@3.25.76)': + '@ai-sdk/anthropic@2.0.37(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/google@1.2.22(zod@3.25.76)': + '@ai-sdk/gateway@2.0.1(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) + '@vercel/oidc': 3.0.3 zod: 3.25.76 - '@ai-sdk/openai@1.3.22(zod@3.25.76)': + '@ai-sdk/google@2.0.23(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/provider-utils@2.2.8(zod@3.25.76)': + '@ai-sdk/openai@2.0.53(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 1.1.3 - nanoid: 3.3.8 - secure-json-parse: 2.7.0 + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/provider@1.1.3': + '@ai-sdk/provider-utils@3.0.12(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + + '@ai-sdk/provider@2.0.0': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.12(react@18.3.1)(zod@3.25.76)': + '@ai-sdk/react@2.0.78(react@18.3.1)(zod@3.25.76)': dependencies: - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) + ai: 5.0.78(zod@3.25.76) react: 18.3.1 swr: 2.3.3(react@18.3.1) throttleit: 2.1.0 optionalDependencies: zod: 3.25.76 - '@ai-sdk/ui-utils@1.2.11(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - zod: 3.25.76 - zod-to-json-schema: 3.24.5(zod@3.25.76) - '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -12443,7 +12452,11 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 - '@anthropic-ai/sdk@0.60.0': {} + '@anthropic-ai/sdk@0.67.0(zod@3.25.76)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 '@arr/every@1.0.1': {} @@ -17989,6 +18002,8 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.0.0': {} + '@swc/core-darwin-arm64@1.3.101': optional: true @@ -18457,7 +18472,7 @@ snapshots: - supports-color - utf-8-validate - '@trigger.dev/sdk@4.0.4(ai@4.3.19(react@18.3.1)(zod@3.25.76))(zod@3.25.76)': + '@trigger.dev/sdk@4.0.4(ai@5.0.78(zod@3.25.76))(zod@3.25.76)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.36.0 @@ -18473,7 +18488,7 @@ snapshots: ws: 8.18.3 zod: 3.25.76 optionalDependencies: - ai: 4.3.19(react@18.3.1)(zod@3.25.76) + ai: 5.0.78(zod@3.25.76) transitivePeerDependencies: - bufferutil - supports-color @@ -18635,8 +18650,6 @@ snapshots: dependencies: '@types/ms': 2.1.0 - '@types/diff-match-patch@1.0.36': {} - '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -19146,6 +19159,8 @@ snapshots: '@vanilla-extract/private@1.0.8': {} + '@vercel/oidc@3.0.3': {} + '@web3-storage/multipart-parser@1.0.0': {} '@webassemblyjs/ast@1.14.1': @@ -19277,17 +19292,13 @@ snapshots: clean-stack: 4.2.0 indent-string: 5.0.0 - ai@4.3.19(react@18.3.1)(zod@3.25.76): + ai@5.0.78(zod@3.25.76): dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - '@ai-sdk/react': 1.2.12(react@18.3.1)(zod@3.25.76) - '@ai-sdk/ui-utils': 1.2.11(zod@3.25.76) + '@ai-sdk/gateway': 2.0.1(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) '@opentelemetry/api': 1.9.0 - jsondiffpatch: 0.6.0 zod: 3.25.76 - optionalDependencies: - react: 18.3.1 ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: @@ -20434,8 +20445,6 @@ snapshots: didyoumean@1.2.2: {} - diff-match-patch@1.0.5: {} - diff@5.2.0: {} dir-glob@3.0.1: @@ -21225,6 +21234,8 @@ snapshots: eventsource-parser@3.0.3: {} + eventsource-parser@3.0.6: {} + eventsource@3.0.7: dependencies: eventsource-parser: 3.0.3 @@ -22274,6 +22285,11 @@ snapshots: json-parse-even-better-errors@3.0.2: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.27.6 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -22290,12 +22306,6 @@ snapshots: jsonc-parser@3.2.1: {} - jsondiffpatch@0.6.0: - dependencies: - '@types/diff-match-patch': 1.0.36 - chalk: 5.4.1 - diff-match-patch: 1.0.5 - jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -23597,12 +23607,10 @@ snapshots: ohash@1.1.6: {} - ollama-ai-provider@1.2.0(zod@3.25.76): + ollama-ai-provider-v2@1.5.1(zod@3.25.76): dependencies: - '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.8(zod@3.25.76) - partial-json: 0.1.7 - optionalDependencies: + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.12(zod@3.25.76) zod: 3.25.76 on-finished@2.3.0: @@ -23801,8 +23809,6 @@ snapshots: parseurl@1.3.3: {} - partial-json@0.1.7: {} - partysocket@1.1.4: dependencies: event-target-polyfill: 0.0.4 @@ -24912,6 +24918,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + resumable-stream@2.2.8: {} + retry@0.12.0: {} retry@0.13.1: {} @@ -25039,8 +25047,6 @@ snapshots: ajv-formats: 2.1.1(ajv@8.17.1) ajv-keywords: 5.1.0(ajv@8.17.1) - secure-json-parse@2.7.0: {} - selderee@0.11.0: dependencies: parseley: 0.12.1 @@ -25794,6 +25800,8 @@ snapshots: dependencies: matchit: 1.1.0 + ts-algebra@2.0.0: {} + ts-api-utils@1.4.3(typescript@5.8.3): dependencies: typescript: 5.8.3 @@ -26619,10 +26627,6 @@ snapshots: dependencies: zod: 3.23.8 - zod-to-json-schema@3.24.5(zod@3.25.76): - dependencies: - zod: 3.25.76 - zod-validation-error@1.5.0(zod@3.23.8): dependencies: zod: 3.23.8 diff --git a/turbo.json b/turbo.json index 42dd5ff..e68f5fa 100644 --- a/turbo.json +++ b/turbo.json @@ -92,6 +92,8 @@ "PRO_PLAN_CREDITS", "PRO_OVERAGE_PRICE", "MAX_PLAN_CREDITS", - "MAX_OVERAGE_PRICE" + "MAX_OVERAGE_PRICE", + "TELEMETRY_ENABLED", + "TELEMETRY_ANONYMOUS" ] }