diff --git a/ts/.vscode/launch.json b/ts/.vscode/launch.json index fda60abd30..7d7ed2ad12 100644 --- a/ts/.vscode/launch.json +++ b/ts/.vscode/launch.json @@ -415,14 +415,6 @@ "args": [], "console": "externalTerminal", "outFiles": ["${workspaceFolder}/**/*.js"] - }, { - "name": "Launch MCP Memory", - "type": "node", - "request": "launch", - "skipFiles": ["/**"], - "program": "${workspaceFolder}/examples/mcpMemory/src/main.ts", - "console": "externalTerminal", - "outFiles": ["${workspaceFolder}/**/*.js"], }, { "name": "Launch Website Alias Extractor", diff --git a/ts/docs/plans/vscode-devx/01-inventory.md b/ts/docs/plans/vscode-devx/01-inventory.md index 8bfa9dbf94..199674226a 100644 --- a/ts/docs/plans/vscode-devx/01-inventory.md +++ b/ts/docs/plans/vscode-devx/01-inventory.md @@ -250,7 +250,6 @@ interface AppAgent { - `examples/spelunker`, `examples/docuProc` — domain-specific memory ingestion. - `examples/classify` — classification example. - `examples/chat` — KnowPro test harness. -- `examples/mcpMemory` — MCP wrapper for memory. --- diff --git a/ts/examples/mcpMemory/README.md b/ts/examples/mcpMemory/README.md deleted file mode 100644 index b9758a406b..0000000000 --- a/ts/examples/mcpMemory/README.md +++ /dev/null @@ -1,27 +0,0 @@ -## MCP Memory - -Sample code that demonstrates how to implement Structured RAG and [knowPro](../../packages/knowPro/README.md) memory as [**MCP**](https://github.com/modelcontextprotocol/typescript-sdk) _tools_. - -- Implements a simple [conversation-memory](../../packages/memory/conversation/src/conversationMemory.ts) [**MemoryServer**](./src/memoryServer.ts) with two basic tools: **remember and recall**. -- MemoryServer is implemented using the [MCP Typescript SDK](https://github.com/modelcontextprotocol/typescript-sdk). - - MemoryServer currently uses the _stateless_ StdioServerTransport and node.js host for simplicity. - - For stateful behavior and _very fast performance_, create a version of MemoryServer that uses a express host with http transport instead. - - MemoryServer is launched in node.js using the [server.js](./src/server.ts) script. -- MemoryServer _tools_ are called using the app CLI. - - Type @help for a list of commands. - - Commands are implemented in [main.ts](./src/main.ts) -- You can find more detailed examples of using [knowPro](../../packages/knowPro/README.md) and [Structured RAG](../../../docs/content/architecture/memory.md) in the [knowPro sample](../chat/README.md). - -**Note**: Memories are stored on your filesystem in folder path: /data/testChat/knowpro/chat - -### Usage - -Sample inputs: [input.txt](./src/input.txt) - -## Trademarks - -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft -trademarks or logos is subject to and must follow -[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). -Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. -Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/ts/examples/mcpMemory/package.json b/ts/examples/mcpMemory/package.json deleted file mode 100644 index 4d7a6572bb..0000000000 --- a/ts/examples/mcpMemory/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "memory-mcp", - "version": "0.0.1", - "private": true, - "description": "Memory MCP experiment.", - "homepage": "https://github.com/microsoft/TypeAgent#readme", - "repository": { - "type": "git", - "url": "https://github.com/microsoft/TypeAgent.git", - "directory": "ts/examples/mcpMemory" - }, - "license": "MIT", - "author": "Microsoft", - "type": "module", - "scripts": { - "build": "npm run tsc", - "postbuild": "copyfiles -u 1 \"src/**/*Schema*.ts\" dist", - "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", - "prettier": "prettier --check . --ignore-path ../../.prettierignore", - "prettier:fix": "prettier --write . --ignore-path ../../.prettierignore", - "tsc": "tsc -b" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", - "@typeagent/agent-runtime": "workspace:*", - "@typeagent/config": "workspace:*", - "@typeagent/conversation-memory": "workspace:*", - "@typeagent/knowpro": "workspace:*", - "dotenv": "^16.3.1", - "examples-lib": "workspace:*", - "interactive-app": "workspace:*", - "zod": "^4.1.13" - }, - "devDependencies": { - "copyfiles": "^2.4.1", - "prettier": "^3.2.5", - "rimraf": "^5.0.5", - "typescript": "~5.4.5" - } -} diff --git a/ts/examples/mcpMemory/src/input.txt b/ts/examples/mcpMemory/src/input.txt deleted file mode 100644 index a706a5a78b..0000000000 --- a/ts/examples/mcpMemory/src/input.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Sample inputs - -@remember --memory "Jane Austen wrote Pride and Prejudice, Sense and Sensibility, Emma" - -@remember --memory "Charles Dickens wrote David Copperfield, Oliver Twist, Great Expectations" - -@recall --query "List all books" - -@recall --query "List all books by Charles Dickens" diff --git a/ts/examples/mcpMemory/src/main.ts b/ts/examples/mcpMemory/src/main.ts deleted file mode 100644 index 757a24e02c..0000000000 --- a/ts/examples/mcpMemory/src/main.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { - InteractiveIo, - CommandHandler, - addStandardHandlers, - runConsole, - CommandMetadata, - arg, - parseNamedArguments, -} from "interactive-app"; -import { fileURLToPath } from "url"; -import { ChalkWriter } from "examples-lib"; -import { callPingTool, callTextTool, createNodeClient } from "./mcp.js"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { RecallRequest, RememberRequest } from "./memoryServer.js"; - -class McpMemoryWriter extends ChalkWriter { - constructor() { - super(); - } -} - -type McpMemoryContext = { - writer: McpMemoryWriter; -}; - -async function addMcpCommands( - commandHandlers: Record, -): Promise { - const scriptPath = fileURLToPath(new URL("server.js", import.meta.url)); - - const context: McpMemoryContext = { - writer: new McpMemoryWriter(), - }; - - commandHandlers.remember = remember; - commandHandlers.recall = recall; - commandHandlers.ping = ping; - - function rememberDef(): CommandMetadata { - return { - description: "Add to conversation memory", - args: { - memory: arg( - "Memories to remember expressed in natural language", - ), - }, - options: { - name: arg("The name of the memory to use", "default"), - }, - }; - } - commandHandlers.remember.metadata = rememberDef(); - async function remember(args: string[], io: InteractiveIo) { - const namedArgs = parseNamedArguments(args, rememberDef()); - context.writer.writeLine("Remembering"); - const response = await callTextTool( - createClient, - "remember", - { - memoryName: namedArgs.name, - memory: namedArgs.memory, - }, - ); - context.writer.writeLine(response); - } - - function recallDef(): CommandMetadata { - return { - description: "Recall information from conversation memory", - args: { - query: arg("Recall with this natural language query"), - }, - options: { - name: arg("The name of the memory to use", "default"), - }, - }; - } - commandHandlers.recall.metadata = recallDef(); - async function recall(args: string[]) { - const namedArgs = parseNamedArguments(args, recallDef()); - const response = await callTextTool( - createClient, - "recall", - { - memoryName: namedArgs.name, - query: namedArgs.query, - }, - ); - context.writer.writeLine(response); - } - - commandHandlers.ping.metadata = "Ping the memory server"; - async function ping(args: string[]) { - const message = new Date().toISOString(); - context.writer.writeLine(`PING ${message}`); - const response = await callPingTool(createClient, { message }); - context.writer.writeLine(response); - } - - function createClient(): Promise { - return createNodeClient({ scriptPath }); - } - - return; -} - -const commandHandlers: Record = {}; -addStandardHandlers(commandHandlers); - -function onStart(io: InteractiveIo): void {} - -await addMcpCommands(commandHandlers); -await runConsole({ - onStart, - handlers: commandHandlers, -}); diff --git a/ts/examples/mcpMemory/src/mcp.ts b/ts/examples/mcpMemory/src/mcp.ts deleted file mode 100644 index fde3263a9f..0000000000 --- a/ts/examples/mcpMemory/src/mcp.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod/v4"; - -export type Content = { - type: string; - text: string; -}; - -export type McpClientFactory = () => Promise; - -export type NodeServerSettings = { - scriptPath: string; - clientName?: string; -}; - -export async function createNodeClient( - settings: NodeServerSettings, -): Promise { - const client = new Client({ - name: settings.clientName ?? "TypeAgent", - version: "1.0.0", - }); - const transport = new StdioClientTransport({ - command: "node", - args: [settings.scriptPath], - stderr: "pipe", - }); - await client.connect(transport); - return client; -} - -//------------------- -// -// TOOLS -// -//------------------- -export async function callTool>( - client: Client | McpClientFactory, - name: string, - request: T, -) { - if (!(client instanceof Client)) { - client = await client(); - } - try { - return (await client.callTool({ - name, - arguments: request, - })) as CallToolResult; - } finally { - client.close(); - } -} - -export async function callTextTool>( - client: Client | McpClientFactory, - name: string, - request: T, -): Promise { - const result = await callTool(client, name, request); - return result.content.length > 0 && result.content[0].type == "text" - ? result.content[0].text - : "NO response"; -} - -export function toolResult(result: string): CallToolResult { - return { - content: [{ type: "text", text: result }], - }; -} - -//------------------------ -// -// PING TOOL -// -//------------------------ - -function pingSchema() { - return { message: z.string() }; -} -const PingRequestSchema = z.object(pingSchema()); - -export type PingRequest = z.infer; -export type PingResponse = string; - -export function addPingTool(server: McpServer) { - // server.tool("ping", pingSchema(), async (pingRequest: PingRequest) => { - // let response = pingRequest.message - // ? "PONG: " + pingRequest.message - // : "pong"; - // return toolResult(response); - // }); - - server.registerTool( - "ping", - { inputSchema: pingSchema() }, - async (pingRequest: PingRequest) => { - const response = pingRequest.message - ? "PONG: " + pingRequest.message - : "pong"; - return toolResult(response); - }, - ); -} - -export async function callPingTool( - client: Client | McpClientFactory, - request: PingRequest, -): Promise { - return await callTextTool(client, "ping", request); -} diff --git a/ts/examples/mcpMemory/src/memoryServer.ts b/ts/examples/mcpMemory/src/memoryServer.ts deleted file mode 100644 index 7e67ac2a28..0000000000 --- a/ts/examples/mcpMemory/src/memoryServer.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod/v4"; -import * as cm from "@typeagent/conversation-memory"; -import { addPingTool, toolResult } from "./mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; - -function rememberRequestSchema() { - return { - memoryName: z.string(), - memory: z.string(), - source: z.string().optional(), - }; -} -const RememberRequestSchema = z.object(rememberRequestSchema()); - -function recallRequestSchema() { - return { - memoryName: z.string(), - query: z.string(), - }; -} -const RecallRequestSchema = z.object(recallRequestSchema()); - -export type RememberRequest = z.infer; -export type RecallRequest = z.infer; - -export class MemoryServer { - public server: McpServer; - public memoryName: string | undefined; - public memory?: cm.ConversationMemory | undefined; - - /** - * - * @param baseDirPath The base directory where memories are stored. Directory must already exist - * @param name - * @param debugMode - */ - constructor( - public baseDirPath: string, - debugMode: boolean = true, - ) { - this.server = new McpServer({ - name: "Memory-Server", - version: "1.0.0", - }); - this.addTools(); - if (debugMode) { - this.addDiagnosticTools(); - } - } - - public async start(transport?: StdioServerTransport): Promise { - transport ??= new StdioServerTransport(); - await this.server.connect(transport); - } - - private addTools() { - this.server.registerTool( - "remember", - { inputSchema: rememberRequestSchema() }, - async (request: RememberRequest) => this.remember(request), - ); - this.server.registerTool( - "recall", - { inputSchema: recallRequestSchema() }, - async (request: RecallRequest) => this.recall(request), - ); - } - - public async remember(request: RememberRequest): Promise { - const memory = await this.getMemory(request.memoryName); - if (!memory) { - return toolResult(`Memory ${request.memoryName} does not exist`); - } - const messageMeta = request.source - ? new cm.ConversationMessageMeta(request.source) - : undefined; - - const message = new cm.ConversationMessage(request.memory, messageMeta); - const result = await memory.addMessage(message); - if (!result.success) { - return toolResult(result.message); - } - return toolResult(`Added memories to memory: ${request.memoryName}`); - } - - public async recall(request: RecallRequest): Promise { - const memory = await this.getMemory(request.memoryName); - if (!memory) { - return toolResult(`Memory ${request.memoryName} does not exist`); - } - const result = await memory.getAnswerFromLanguage(request.query); - if (!result.success) { - return toolResult(result.message); - } - const responses = result.data; - let text = ""; - for (const response of responses) { - const [_, answer] = response; - if (text.length > 0) { - text += "\n"; - } - text += - answer.type === "Answered" ? answer.answer : answer.whyNoAnswer; - } - return toolResult(text); - } - - private addDiagnosticTools() { - addPingTool(this.server); - } - - private async getMemory( - memoryName: string, - ): Promise { - if (memoryName === this.memoryName && this.memory) { - return this.memory; - } - this.memory = undefined; - return await this.loadMemory(memoryName); - } - - private async loadMemory( - memoryName: string, - ): Promise { - const memory = await cm.createConversationMemory( - { - dirPath: this.baseDirPath, - baseFileName: memoryName, - }, - false, - ); - this.memoryName = memoryName; - return memory; - } -} diff --git a/ts/examples/mcpMemory/src/server.ts b/ts/examples/mcpMemory/src/server.ts deleted file mode 100644 index c50f6c6e5e..0000000000 --- a/ts/examples/mcpMemory/src/server.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { ensureDir } from "@typeagent/agent-runtime"; -import { MemoryServer } from "./memoryServer.js"; -import { loadConfigSync } from "@typeagent/config"; - -loadConfigSync(); - -console.log("Starting Memory Server"); - -const baseDirPath = "/data/testChat/knowpro/chat"; -await ensureDir(baseDirPath); - -const memoryServer = new MemoryServer(baseDirPath); -await memoryServer.start(); - -console.log("Exit Memory Server"); diff --git a/ts/examples/mcpMemory/src/tsconfig.json b/ts/examples/mcpMemory/src/tsconfig.json deleted file mode 100644 index 6e2a4fdd32..0000000000 --- a/ts/examples/mcpMemory/src/tsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "../dist" - } -} diff --git a/ts/packages/agentServer/server/package.json b/ts/packages/agentServer/server/package.json index 504646982a..ba7895f046 100644 --- a/ts/packages/agentServer/server/package.json +++ b/ts/packages/agentServer/server/package.json @@ -52,6 +52,9 @@ "@typeagent/copilot-macros": "workspace:*", "@typeagent/dispatcher-rpc": "workspace:*", "@typeagent/dispatcher-types": "workspace:*", + "@typeagent/memory-client": "workspace:*", + "@typeagent/memory-mcp-server": "workspace:*", + "@typeagent/memory-service": "workspace:*", "@typeagent/telemetry": "workspace:*", "@typeagent/typechat-utils": "workspace:*", "@typeagent/websocket-channel-server": "workspace:*", diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index ed49dcb692..d21806bccb 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -16,11 +16,12 @@ import { getTraceIdAsync, } from "agent-dispatcher/helpers/data"; import { + createDefaultAgentRuntime, getDefaultAppAgentProviders, - getDefaultAppAgentSources, getIndexingServiceRegistry, getDefaultConstructionProvider, McpReplayHost, + SessionMcpCredentialStore, } from "default-agent-provider"; import { getFsStorageProvider } from "dispatcher-node-providers"; import { @@ -36,10 +37,14 @@ import { } from "@typeagent/agent-server-client"; import registerDebug from "debug"; import os from "node:os"; +import path from "node:path"; import { spawn } from "node:child_process"; import { DefaultAzureCredential } from "@azure/identity"; import { otel } from "@typeagent/telemetry"; import { MacroManager } from "@typeagent/copilot-macros"; +import { MemoryServiceHost } from "@typeagent/memory-mcp-server"; +import { FileMemoryService } from "@typeagent/memory-service"; +import { InProcessMemoryServiceClient } from "@typeagent/memory-client"; // Exit code the worker uses to ask the supervisor to relaunch it in place. const RESTART_EXIT_CODE = 42; @@ -284,6 +289,7 @@ function initialIdentity(): UserIdentity { } let userIdentity: UserIdentity = initialIdentity(); +let failedStartupCleanup: (() => Promise) | undefined; // Kick off the token-based resolution asynchronously. Env override wins // if set, so skip the network call in that case. @@ -325,6 +331,50 @@ async function main() { if (developerMode) { debugStartup("developer mode enabled at startup (--dev)"); } + debugStartup("starting instance memory service"); + const memoryService = new FileMemoryService( + path.join(instanceDir, "memory"), + ); + const memoryServiceHost = await MemoryServiceHost.start(memoryService, { + onError: (error) => + console.error("[agent-server] Memory service error:", error), + }); + failedStartupCleanup = () => memoryServiceHost.close(); + const memoryCredentialStore = new SessionMcpCredentialStore(); + const memoryCredential = await memoryCredentialStore.set( + "runtime-memory-bearer", + memoryServiceHost.bearerToken, + ); + const defaultAgentRuntime = createDefaultAgentRuntime( + instanceDir, + { configName }, + { credentialStore: memoryCredentialStore }, + { + memory: { + id: "runtime:typeagent-memory", + name: "memory", + description: "Durable document and website memory", + transport: { + kind: "http", + url: memoryServiceHost.endpoint, + headers: { + authorization: { + value: "Bearer {token}", + variables: { token: memoryCredential }, + }, + }, + }, + enabled: true, + trust: "trusted", + scope: "shipped", + provenance: { + source: "agent-server", + sourceKind: "runtime", + }, + }, + }, + ); + debugStartup(`memory service ready at ${memoryServiceHost.endpoint}`); debugStartup("creating conversation manager (will lockInstanceDir)"); // Single PortRegistrar shared across every conversation in this // process. Lets external clients (browser extension, VS Code, CLI) @@ -342,9 +392,7 @@ async function main() { instanceDir, configName, ), - appAgentSources: getDefaultAppAgentSources(instanceDir, { - configName, - }), + appAgentSources: defaultAgentRuntime.appAgentSources, persistSession: true, storageProvider: getFsStorageProvider(), metrics: true, @@ -375,9 +423,22 @@ async function main() { // local-view ports so inline-browser embedding works in // connect mode, matching the standalone (in-process) shell. allowSharedLocalView: ["browser"], + agentInitOptions: { + browser: { + memoryServiceClient: new InProcessMemoryServiceClient( + memoryService, + ), + }, + }, }, instanceDir, ); + failedStartupCleanup = async () => { + await Promise.all([ + memoryServiceHost.close(), + conversationManager.close(), + ]); + }; const macroManager = new MacroManager( instanceDir, new McpReplayHost(instanceDir), @@ -441,6 +502,7 @@ async function main() { function teardownServer(): Promise { teardownPromise ??= (async () => { wss?.close(); + await memoryServiceHost.close(); await conversationManager.close(); removeServerPid(port); })(); @@ -576,6 +638,7 @@ async function main() { // any already-connected clients the moment it's detected. startStaleBuildWatcher(import.meta.url, broadcastStaleNotice); scheduleIdleShutdown(); + failedStartupCleanup = undefined; } process.on("unhandledRejection", (reason, _promise) => { @@ -589,8 +652,14 @@ process.on("uncaughtException", (err) => { }); await main().catch((err: any) => { - return otel - .shutdownTelemetry() + return (failedStartupCleanup?.() ?? Promise.resolve()) + .catch((cleanupError) => { + console.error( + "[agent-server] Startup cleanup failed:", + cleanupError, + ); + }) + .then(() => otel.shutdownTelemetry()) .catch((shutdownError) => { console.error( "[agent-server] Telemetry shutdown failed:", diff --git a/ts/packages/agents/browser/package.json b/ts/packages/agents/browser/package.json index b69dbf9e4b..ee0a5e10e0 100644 --- a/ts/packages/agents/browser/package.json +++ b/ts/packages/agents/browser/package.json @@ -67,6 +67,8 @@ "@typeagent/dispatcher-types": "workspace:*", "@typeagent/knowledge-processor": "workspace:*", "@typeagent/knowpro": "workspace:*", + "@typeagent/memory-client": "workspace:*", + "@typeagent/memory-service": "workspace:*", "@typeagent/taskflow-typeagent": "workspace:*", "@typeagent/textpro": "workspace:*", "@typeagent/website-memory": "workspace:*", diff --git a/ts/packages/agents/browser/src/agent/browserActionHandler.mts b/ts/packages/agents/browser/src/agent/browserActionHandler.mts index 346892130d..30949dd0a1 100644 --- a/ts/packages/agents/browser/src/agent/browserActionHandler.mts +++ b/ts/packages/agents/browser/src/agent/browserActionHandler.mts @@ -115,10 +115,7 @@ import { import { initializeImportWebSocketHandler } from "./import/importWebSocketHandler.mjs"; import { SchemaDiscoveryActions } from "./discovery/schema/discoveryActions.mjs"; import { ExternalBrowserActions } from "./externalBrowserActionSchema.mjs"; -import { - BrowserControl, - defaultSearchProviders, -} from "@typeagent/browser-control-rpc/types"; +import { defaultSearchProviders } from "@typeagent/browser-control-rpc/types"; import { openai, tryCreateEmbeddingModel } from "@typeagent/aiclient"; import { SearchProviderCommandHandlerTable, @@ -127,8 +124,10 @@ import { import { BrowserActionContext, getActionBrowserControl, + normalizeBrowserAgentInitOptions, saveSettings, } from "./browserActions.mjs"; +import { getBrowserMemoryService } from "./browserMemoryService.mjs"; import { ChunkChatResponse, generateAnswer, @@ -454,12 +453,6 @@ export function instantiate(): AppAgent { content: _webFlowStore.generateDynamicSchemaText(), }; }, - cancelChoice: async ( - choiceId: string, - context: SessionContext, - ) => { - context.agentContext.choiceManager?.cancelChoice(choiceId); - }, handleChoice: async ( choiceId: string, response: boolean | number[], @@ -515,9 +508,8 @@ export interface urlResolutionAction { async function initializeBrowserContext( settings?: AppAgentInitSettings, ): Promise { - const clientBrowserControl = settings?.options as - | BrowserControl - | undefined; + const { browserControl: clientBrowserControl, memoryServiceClient } = + normalizeBrowserAgentInitOptions(settings?.options); const localHostPort = settings?.localHostPort; if (localHostPort === undefined) { @@ -529,6 +521,13 @@ async function initializeBrowserContext( sessionId: "default", clientBrowserControl, useExternalBrowserControl: clientBrowserControl === undefined, + ...(memoryServiceClient === undefined ? {} : { memoryServiceClient }), + ...(memoryServiceClient === undefined + ? {} + : { + browserMemoryService: + getBrowserMemoryService(memoryServiceClient), + }), // With no in-process control (connect mode, or extension-only), leave // the preferred client type unset so selectActiveClientForSession uses // its default priority (electron > extension > any). This lets the diff --git a/ts/packages/agents/browser/src/agent/browserActions.mts b/ts/packages/agents/browser/src/agent/browserActions.mts index aac6f45835..5390acdc15 100644 --- a/ts/packages/agents/browser/src/agent/browserActions.mts +++ b/ts/packages/agents/browser/src/agent/browserActions.mts @@ -21,12 +21,21 @@ import { AgentWebSocketServer, } from "./agentWebSocketServer.mjs"; import { getClientType } from "@typeagent/agent-server-protocol"; +import type { MemoryServiceClient } from "@typeagent/memory-client"; +import type { BrowserMemoryService } from "./browserMemoryService.mjs"; + +export type BrowserAgentInitOptions = { + browserControl?: BrowserControl; + memoryServiceClient?: MemoryServiceClient; +}; export type BrowserActionContext = { sessionId: string; clientBrowserControl?: BrowserControl | undefined; externalBrowserControl?: ExternalBrowserClient | undefined; useExternalBrowserControl: boolean; + memoryServiceClient?: MemoryServiceClient; + browserMemoryService?: BrowserMemoryService; preferredClientType?: "extension" | "electron" | undefined; // Runtime override for the internet-lookup backend (@browser lookup ...); // takes precedence over azureAISearch.mode / AZURE_AI_SEARCH_LOOKUP_MODE. @@ -74,6 +83,21 @@ export type BrowserActionContext = { browserSchemaEnabled?: boolean | undefined; }; +export function normalizeBrowserAgentInitOptions( + options: unknown, +): BrowserAgentInitOptions { + if ( + typeof options === "object" && + options !== null && + ("browserControl" in options || "memoryServiceClient" in options) + ) { + return options as BrowserAgentInitOptions; + } + return options === undefined + ? {} + : { browserControl: options as BrowserControl }; +} + export function getBrowserControl(agentContext: BrowserActionContext) { const browserControl = agentContext.useExternalBrowserControl ? agentContext.externalBrowserControl?.control diff --git a/ts/packages/agents/browser/src/agent/browserMemoryService.mts b/ts/packages/agents/browser/src/agent/browserMemoryService.mts new file mode 100644 index 0000000000..7643056f9b --- /dev/null +++ b/ts/packages/agents/browser/src/agent/browserMemoryService.mts @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createHash } from "node:crypto"; +import type { MemoryServiceClient } from "@typeagent/memory-client"; +import type { + IngestionMode, + JobProgress, + MemoryEvidence, + MemoryKnowledgeGraph, + MemorySource, +} from "@typeagent/memory-service"; + +const browserCorpusName = "TypeAgent Browser Memory"; +const adapters = new WeakMap(); + +export interface BrowserMemoryDocument { + url: string; + title: string; + markdown: string; + source?: string; + domain?: string; + pageType?: string; + capturedAt?: string; + tags?: string[]; +} + +export interface BrowserMemorySearchOptions { + query: string; + limit?: number; + url?: string; + domain?: string; + pageType?: string; + source?: string; + dateFrom?: string; + dateTo?: string; +} + +export interface BrowserMemoryMatch { + evidence: MemoryEvidence; + source: MemorySource; +} + +export class BrowserMemoryService { + private corpusIdPromise: Promise | undefined; + private graphVersion = 0; + + public constructor(private readonly client: MemoryServiceClient) {} + + public async ingest( + document: BrowserMemoryDocument, + mode: IngestionMode, + options: { + signal?: AbortSignal; + onProgress?: (progress: JobProgress) => void; + } = {}, + ): Promise { + const corpusId = await this.getCorpusId(); + const result = await this.client.ingestDocument( + { + corpusId, + source: { + sourceId: sourceIdForUrl(document.url), + sourceType: "web", + title: document.title, + canonicalUri: document.url, + markdown: document.markdown, + ...(document.tags === undefined + ? {} + : { tags: document.tags }), + ...(document.capturedAt === undefined + ? {} + : { capturedAt: document.capturedAt }), + metadata: { + ...(document.domain === undefined + ? {} + : { domain: document.domain }), + ...(document.pageType === undefined + ? {} + : { pageType: document.pageType }), + ...(document.source === undefined + ? {} + : { source: document.source }), + }, + }, + pipeline: { mode, updatePolicy: "skipIfUnchanged" }, + }, + options.signal, + ); + const job = await this.client.waitForJob(result.jobId, options); + if (job.state !== "complete" && job.state !== "partial") { + throw new Error( + job.error ?? + `Memory ingestion ended in unexpected state '${job.state}'`, + ); + } + this.graphVersion++; + } + + public async search( + options: BrowserMemorySearchOptions, + ): Promise { + const corpusId = await this.getCorpusId(); + const sources = await this.client.listSources(corpusId); + const sourceIds = sources + .filter((source) => matchesFilters(source, options)) + .map((source) => source.sourceId); + if (sourceIds.length === 0) { + return []; + } + const result = await this.client.search({ + corpusId, + query: options.query, + ...(options.limit === undefined ? {} : { limit: options.limit }), + sourceTypes: ["web"], + sourceIds, + }); + const sourcesById = new Map( + sources.map((source) => [source.sourceId, source]), + ); + return result.matches.flatMap((evidence) => { + const source = sourcesById.get(evidence.sourceId); + return source === undefined ? [] : [{ evidence, source }]; + }); + } + + public async getSource(url: string): Promise { + return this.client.getSource( + await this.getCorpusId(), + sourceIdForUrl(url), + ); + } + + public async getKnowledgeGraph(): Promise { + return this.client.getKnowledgeGraph(await this.getCorpusId()); + } + + public getGraphVersion(): number { + return this.graphVersion; + } + + private getCorpusId(): Promise { + this.corpusIdPromise ??= this.findOrCreateCorpus(); + return this.corpusIdPromise; + } + + private async findOrCreateCorpus(): Promise { + const existing = (await this.client.listCorpora()).find( + (corpus) => corpus.name === browserCorpusName, + ); + return ( + existing ?? + (await this.client.createCorpus( + browserCorpusName, + "Web pages captured or imported by the TypeAgent browser agent", + )) + ).corpusId; + } +} + +export function getBrowserMemoryService( + client: MemoryServiceClient, +): BrowserMemoryService { + let service = adapters.get(client); + if (service === undefined) { + service = new BrowserMemoryService(client); + adapters.set(client, service); + } + return service; +} + +function sourceIdForUrl(url: string): string { + return `web:${createHash("sha256").update(url).digest("hex")}`; +} + +function matchesFilters( + source: MemorySource, + options: BrowserMemorySearchOptions, +): boolean { + if (options.url !== undefined && source.canonicalUri !== options.url) { + return false; + } + const metadata = source.metadata ?? {}; + if (options.domain !== undefined && metadata.domain !== options.domain) { + return false; + } + if ( + options.pageType !== undefined && + metadata.pageType !== options.pageType + ) { + return false; + } + if (options.source !== undefined && metadata.source !== options.source) { + return false; + } + const revision = source.revisions.find( + (candidate) => candidate.revisionId === source.activeRevisionId, + ); + const capturedAt = revision?.capturedAt; + return !( + (options.dateFrom !== undefined && + (capturedAt === undefined || capturedAt < options.dateFrom)) || + (options.dateTo !== undefined && + (capturedAt === undefined || capturedAt > options.dateTo)) + ); +} diff --git a/ts/packages/agents/browser/src/agent/knowledge/actions/graphActions.mts b/ts/packages/agents/browser/src/agent/knowledge/actions/graphActions.mts index 6cabdb1301..f87c29b32a 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/actions/graphActions.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/actions/graphActions.mts @@ -18,7 +18,6 @@ import { createGraphologyCache, invalidateAllGraphologyCaches, } from "../utils/graphologyCache.mjs"; -import { createGraphologyPersistenceManager } from "../utils/graphologyPersistence.mjs"; import registerDebug from "debug"; import { openai as ai } from "@typeagent/aiclient"; import { createJsonTranslator } from "typechat"; @@ -139,78 +138,6 @@ async function cacheGraphologyGraphs( ); } -function extractEntitiesFromGraphology(entityGraph: any): any[] { - const entities: any[] = []; - - // Extract entity nodes from Graphology graph - entityGraph.forEachNode((nodeId: string, attributes: any) => { - if (attributes.type === "entity") { - entities.push({ - name: attributes.name || nodeId, - entityType: attributes.entityType || "unknown", - frequency: attributes.frequency || 0, - websites: attributes.websites || [], - confidence: attributes.confidence || 1.0, - }); - } - }); - - return entities; -} - -function extractRelationshipsFromGraphology(entityGraph: any): any[] { - const relationships: any[] = []; - - // Extract relationship edges from Graphology graph - entityGraph.forEachEdge( - (edgeId: string, attributes: any, source: string, target: string) => { - relationships.push({ - id: edgeId, - rowId: edgeId, - fromEntity: source, - toEntity: target, - source: source, - target: target, - relationshipType: - attributes.relationshipType || - attributes.type || - "co_occurs", - type: - attributes.relationshipType || - attributes.type || - "co_occurs", - strength: attributes.weight || attributes.strength || 1.0, - confidence: attributes.confidence || 1.0, - count: attributes.cooccurrenceCount || attributes.count || 1, - cooccurrenceCount: - attributes.cooccurrenceCount || attributes.count || 1, - }); - }, - ); - - return relationships; -} - -function extractCommunitiesFromGraphology(entityGraph: any): any[] { - const communities: any[] = []; - - // Extract community nodes from Graphology graph - entityGraph.forEachNode((nodeId: string, attributes: any) => { - if (attributes.type === "community") { - communities.push({ - id: nodeId, - name: attributes.name || `Community ${nodeId}`, - entities: attributes.entities || [], - size: attributes.size || 0, - coherence: attributes.coherence || 0.0, - importance: attributes.importance || 0.0, - }); - } - }); - - return communities; -} - // Entity graph cache storage attached to websiteCollection function getGraphCache(websiteCollection: any): GraphCache | null { return (websiteCollection as any).__graphCache || null; @@ -375,11 +302,15 @@ async function ensureGraphCache( if (!websiteCollection) { throw new Error("Website collection not available"); } + const memoryService = context.agentContext.browserMemoryService; + if (memoryService === undefined) { + throw new Error("Durable browser memory is not available"); + } const cache = getGraphCache(websiteCollection); + const sourceVersion = memoryService.getGraphVersion(); - // Check if cache is valid (no TTL - only invalidated on rebuild) - if (cache && cache.isValid) { + if (cache?.isValid && cache.sourceVersion === sourceVersion) { debug("[Knowledge Graph] Using valid cached graph data"); return; } @@ -390,92 +321,41 @@ async function ensureGraphCache( tracker.startOperation("ensureGraphCache"); try { - // Build the graph using websiteCollection - returns Graphology graphs directly - tracker.startOperation("ensureGraphCache.buildGraphologyGraphs"); - const buildResult = await websiteCollection.buildGraph(); + tracker.startOperation("ensureGraphCache.loadDurableGraph"); + const durableGraph = await memoryService.getKnowledgeGraph(); tracker.endOperation( - "ensureGraphCache.buildGraphologyGraphs", + "ensureGraphCache.loadDurableGraph", 1, - buildResult ? 1 : 0, - ); - - if (!buildResult?.entityGraph || !buildResult?.topicGraph) { - throw new Error( - "Failed to build Graphology graphs from websiteCollection", - ); - } - - // Extract entities, relationships, and communities from Graphology graphs - tracker.startOperation("ensureGraphCache.extractFromGraphology"); - - const entityGraph = buildResult.entityGraph; - const rawEntities: any[] = []; - const relationships: any[] = []; - const communities: any[] = []; - - // Extract entities from Graphology graph - entityGraph.forEachNode((nodeId: string, attributes: any) => { - if (!attributes.type || attributes.type === "entity") { - rawEntities.push({ - name: nodeId, - id: nodeId, - type: attributes.type || "entity", - confidence: attributes.confidence || 0.5, - count: attributes.count || 1, - importance: attributes.importance || 0, - communityId: attributes.community || 0, - }); - } - }); - - // Extract relationships from Graphology graph - entityGraph.forEachEdge( - ( - edgeId: string, - attributes: any, - source: string, - target: string, - ) => { - relationships.push({ - fromEntity: source, - toEntity: target, - source: source, - target: target, - relationshipType: attributes.type || "related", - type: attributes.type || "related", - confidence: attributes.confidence || 0.5, - count: attributes.count || 1, - }); - }, + durableGraph.entities.length, ); - // Extract communities (simplified approach) - const communityMap = new Map< - number, - { id: number; entities: string[] } - >(); - rawEntities.forEach((entity) => { - const communityId = entity.communityId || 0; - if (!communityMap.has(communityId)) { - communityMap.set(communityId, { - id: communityId, - entities: [], - }); - } - communityMap.get(communityId)!.entities.push(entity.name); - }); - communities.push(...Array.from(communityMap.values())); - - tracker.endOperation( - "ensureGraphCache.extractFromGraphology", - rawEntities.length, - relationships.length, + const rawEntities: any[] = durableGraph.entities.map((entity) => ({ + name: entity.name, + id: entity.name, + type: entity.types[0] ?? "entity", + entityType: entity.types, + confidence: 1, + count: entity.mentionCount, + websites: entity.sourceIds, + })); + const relationships: any[] = durableGraph.relationships.map( + (relationship) => ({ + fromEntity: relationship.fromEntity, + toEntity: relationship.toEntity, + source: relationship.fromEntity, + target: relationship.toEntity, + relationshipType: relationship.relationshipType, + type: relationship.relationshipType, + confidence: 1, + count: relationship.count, + sourceIds: relationship.sourceIds, + }), ); + const communities: any[] = []; - console.log("[ensureGraphCache] Extracted from Graphology:", { + debug("[ensureGraphCache] Loaded from durable memory:", { entities: rawEntities.length, relationships: relationships.length, - communities: communities.length, }); // Calculate metrics with instrumentation @@ -579,6 +459,7 @@ async function ensureGraphCache( presetLayout: presetLayout, lastUpdated: Date.now(), isValid: true, + sourceVersion, }; setGraphCache(websiteCollection, newCache); @@ -609,9 +490,7 @@ async function ensureGraphCache( // Storage Abstraction Layer // ============================================================================ -/** - * Get Graphology graphs from cache or persistence (new primary method) - */ +/** Get Graphology graphs derived from the durable memory corpus. */ async function getGraphologyGraphs( context: SessionContext, ): Promise<{ @@ -623,115 +502,47 @@ async function getGraphologyGraphs( if (!websiteCollection) { throw new Error("Website collection not available"); } + const memoryService = context.agentContext.browserMemoryService; + if (memoryService === undefined) { + throw new Error("Durable browser memory is not available"); + } try { - // Try to get from memory cache first (fastest) - const entityCache = getGraphologyCache("entity_default"); - const topicCache = getGraphologyCache("topic_default"); - - if (entityCache?.graph && topicCache?.graph) { - debug("[Graphology] Using memory-cached Graphology graphs"); - return { - entityGraph: entityCache.graph, - topicGraph: topicCache.graph, - useGraphology: true, - }; - } - - // Try to load from disk persistence (fast) - debug("[Graphology] Memory cache miss, trying disk persistence..."); - const jsonStorage = context.agentContext.graphJsonStorage; - if (jsonStorage?.manager) { - const storagePath = jsonStorage.manager.getStoragePath(); - const persistenceManager = - createGraphologyPersistenceManager(storagePath); - - const entityResult = await persistenceManager.loadEntityGraph(); - const topicResult = await persistenceManager.loadTopicGraph(); - - if (entityResult?.graph && topicResult?.graph) { - debug("[Graphology] Loaded graphs from disk persistence"); - - // Cache in memory for next time - await cacheGraphologyGraphs( - websiteCollection, - entityResult.graph, - topicResult.graph, - { - buildTime: entityResult.metadata?.buildTime || 0, - loadedFromDisk: true, - }, - ); - - return { - entityGraph: entityResult.graph, - topicGraph: topicResult.graph, - useGraphology: true, - }; - } - } - - // If no cache or persistence, rebuild graphs (slowest) - debug("[Graphology] No cached graphs found, rebuilding from source..."); - const buildResult = await websiteCollection.buildGraph(); - - if (buildResult?.entityGraph && buildResult?.topicGraph) { - // Cache in memory - await cacheGraphologyGraphs( - websiteCollection, - buildResult.entityGraph, - buildResult.topicGraph, - buildResult.metadata, - ); - - // Persist to disk for next time - if (jsonStorage?.manager) { - const storagePath = jsonStorage.manager.getStoragePath(); - const persistenceManager = - createGraphologyPersistenceManager(storagePath); - - try { - debug( - `[Graphology] Persisting entity graph with ${buildResult.entityGraph.order} nodes and ${buildResult.entityGraph.size} edges to ${storagePath}`, - ); - await persistenceManager.saveEntityGraph( - buildResult.entityGraph, - buildResult.metadata, - ); - debug(`[Graphology] ✓ Entity graph saved to disk`); - - debug( - `[Graphology] Persisting topic graph with ${buildResult.topicGraph.order} nodes and ${buildResult.topicGraph.size} edges to ${storagePath}`, - ); - await persistenceManager.saveTopicGraph( - buildResult.topicGraph, - buildResult.metadata, - ); - debug(`[Graphology] ✓ Topic graph saved to disk`); - - debug( - "[Graphology] ✓ All graphs saved to disk persistence successfully", - ); - } catch (persistError) { - debug( - `[Graphology] ❌ Failed to persist graphs: ${persistError}`, - ); - console.error( - `[Graphology] Persistence error details:`, - persistError, - ); - // Continue anyway since we have the graphs in memory - } - } - - return { - entityGraph: buildResult.entityGraph, - topicGraph: buildResult.topicGraph, - useGraphology: true, - }; - } - - throw new Error("Failed to build Graphology graphs"); + const startedAt = Date.now(); + const durableGraph = await memoryService.getKnowledgeGraph(); + const entityGraph = buildGraphologyGraph( + durableGraph.entities.map((entity) => ({ + id: entity.name, + name: entity.name, + type: entity.types[0] ?? "entity", + count: entity.mentionCount, + confidence: 1, + })), + durableGraph.relationships.map((relationship) => ({ + from: relationship.fromEntity, + to: relationship.toEntity, + type: relationship.relationshipType, + strength: relationship.count, + confidence: 1, + })), + ); + const topicGraph = buildGraphologyGraph( + durableGraph.topics.map((topic) => ({ + id: topic.name, + name: topic.name, + type: "topic", + count: topic.mentionCount, + confidence: 1, + })), + [], + ); + await cacheGraphologyGraphs( + websiteCollection, + entityGraph, + topicGraph, + { buildTime: Date.now() - startedAt, source: "durable-memory" }, + ); + return { entityGraph, topicGraph, useGraphology: true }; } catch (error) { debug(`Error getting Graphology graphs: ${error}`); throw new Error( @@ -884,65 +695,11 @@ export async function buildKnowledgeGraph( const startTime = Date.now(); - // Get website collection for building Graphology graphs - const websiteCollection = context.agentContext.websiteCollection; - if (!websiteCollection) { - return { - success: false, - error: "Website collection not available", - }; - } - - // Build the graph using websiteCollection - returns Graphology graphs directly - debug( - "[Knowledge Graph] Building Graphology graphs from website collection...", - ); - const buildResult = await websiteCollection.buildGraph(); - debug("[Knowledge Graph] Graphology graph build completed"); - - // Check if we got Graphology graphs - if (!buildResult?.entityGraph || !buildResult?.topicGraph) { - throw new Error( - "Failed to build Graphology graphs from website collection", - ); - } - - const { entityGraph, topicGraph, metadata } = buildResult; - - // Cache the Graphology graphs directly - debug("[Knowledge Graph] Caching Graphology graphs..."); - await cacheGraphologyGraphs( - websiteCollection, - entityGraph, - topicGraph, - metadata, - ); - - // Persist Graphology graphs to disk - const jsonStorage = context.agentContext.graphJsonStorage; - if (jsonStorage?.manager) { - const storagePath = jsonStorage.manager.getStoragePath(); - debug(`[Graphology Persistence] Storage path: ${storagePath}`); - const persistenceManager = - createGraphologyPersistenceManager(storagePath); - - try { - debug("[Graphology Persistence] Saving entity graph..."); - await persistenceManager.saveEntityGraph(entityGraph, metadata); - - debug("[Graphology Persistence] Saving topic graph..."); - await persistenceManager.saveTopicGraph(topicGraph, metadata); - - debug("[Graphology Persistence] ✓ All graphs saved to disk"); - } catch (persistError) { - debug( - `[Graphology Persistence] ❌ Failed to persist graphs: ${persistError}`, - ); - // Continue since we have graphs in memory - } - } else { - debug("[Graphology Persistence] ❌ No storage manager available"); + const { entityGraph, topicGraph } = await getGraphologyGraphs(context); + if (!entityGraph || !topicGraph) { + throw new Error("Failed to build graphs from durable memory"); } + await ensureGraphCache(context); const timeElapsed = Date.now() - startTime; @@ -950,7 +707,11 @@ export async function buildKnowledgeGraph( const stats = { entitiesFound: entityGraph.order, relationshipsCreated: entityGraph.size, - communitiesDetected: metadata?.communityCount || 0, + communitiesDetected: new Set( + entityGraph.mapNodes((_node: string, attributes: any) => + String(attributes.community ?? "default"), + ), + ).size, timeElapsed: timeElapsed, }; @@ -983,7 +744,6 @@ export async function rebuildKnowledgeGraph( "[Knowledge Graph] Starting Graphology-only knowledge graph rebuild", ); - // Get website collection to rebuild from cache const websiteCollection = context.agentContext.websiteCollection; if (!websiteCollection) { return { @@ -992,84 +752,16 @@ export async function rebuildKnowledgeGraph( }; } - // Rebuild the knowledge graph using websiteCollection - returns Graphology graphs directly - debug( - "[Knowledge Graph] Building Graphology graphs directly from cache...", - ); - const buildResult = await websiteCollection.buildGraph(); - debug("[Knowledge Graph] Direct Graphology graph build completed"); - - // Check if we got Graphology graphs - if (!buildResult?.entityGraph || !buildResult?.topicGraph) { - throw new Error( - "Failed to build Graphology graphs from website collection", - ); + invalidateAllGraphologyCaches(); + const cache = getGraphCache(websiteCollection); + if (cache) { + cache.isValid = false; } - - const { entityGraph, topicGraph, metadata } = buildResult; - - // Cache the Graphology graphs directly in memory - debug("[Knowledge Graph] Caching Graphology graphs directly..."); - await cacheGraphologyGraphs( - websiteCollection, - entityGraph, - topicGraph, - metadata, - ); - - // Persist Graphology graphs to disk in native format - const storagePath = `.scratch/storage`; // Use direct path instead of JSON storage manager - debug(`[Graphology Persistence] Using storage path: ${storagePath}`); - const persistenceManager = - createGraphologyPersistenceManager(storagePath); - - try { - debug( - "[Graphology Persistence] Attempting to save entity graph to disk...", - ); - await persistenceManager.saveEntityGraph(entityGraph, metadata); - debug("[Graphology Persistence] ✓ Entity graph saved to disk"); - - debug( - "[Graphology Persistence] Attempting to save topic graph to disk...", - ); - await persistenceManager.saveTopicGraph(topicGraph, metadata); - debug("[Graphology Persistence] ✓ Topic graph saved to disk"); - - debug( - "[Graphology Persistence] ✓ All Graphology graphs saved to disk successfully", - ); - } catch (persistError) { - debug( - `[Graphology Persistence] ❌ Failed to persist graphs: ${persistError}`, - ); - // Continue anyway since we have the graphs in memory + const { entityGraph, topicGraph } = await getGraphologyGraphs(context); + if (!entityGraph || !topicGraph) { + throw new Error("Failed to rebuild graphs from durable memory"); } - - // Update traditional caches to maintain compatibility - const entities = extractEntitiesFromGraphology(entityGraph); - const relationships = extractRelationshipsFromGraphology(entityGraph); - const communities = extractCommunitiesFromGraphology(entityGraph); - - // Calculate entity metrics properly to avoid 0 entity count issue - const entityMetrics = calculateEntityMetrics( - entities, - relationships, - communities, - ); - - setGraphCache(websiteCollection, { - entities, - relationships, - communities, - entityMetrics, - lastUpdated: Date.now(), - isValid: true, - }); - - debug( - `[Knowledge Graph] Traditional cache updated with ${entityMetrics.length} entity metrics`, - ); + await ensureGraphCache(context); debug( "[Knowledge Graph] Graphology-only knowledge graph rebuild completed successfully", @@ -1077,7 +769,7 @@ export async function rebuildKnowledgeGraph( return { success: true, - message: `Knowledge graph rebuilt successfully using Graphology-only architecture. Entity graph: ${entityGraph.order} nodes, ${entityGraph.size} edges. Topic graph: ${topicGraph.order} nodes, ${topicGraph.size} edges. Build time: ${metadata?.buildTime || 0}ms`, + message: `Knowledge graph rebuilt successfully from durable memory. Entity graph: ${entityGraph.order} nodes, ${entityGraph.size} edges. Topic graph: ${topicGraph.order} nodes, ${topicGraph.size} edges.`, }; } catch (error) { console.error("Error rebuilding knowledge graph:", error); diff --git a/ts/packages/agents/browser/src/agent/knowledge/actions/indexingActions.mts b/ts/packages/agents/browser/src/agent/knowledge/actions/indexingActions.mts index da62ef16fa..57e52b9f97 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/actions/indexingActions.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/actions/indexingActions.mts @@ -4,78 +4,11 @@ import { SessionContext } from "@typeagent/agent-sdk"; import { BrowserActionContext } from "../../browserActions.mjs"; import * as website from "@typeagent/website-memory"; -import { AIModelRequiredError } from "@typeagent/website-memory"; -import { BrowserKnowledgeExtractor } from "../browserKnowledgeExtractor.mjs"; -import { - createExtractionInputsFromFragments, - aggregateExtractionResults, -} from "./extractionActions.mjs"; +import { createExtractionInputsFromFragments } from "./extractionActions.mjs"; import registerDebug from "debug"; -import fs from "node:fs"; const debug = registerDebug("typeagent:browser:knowledge"); -// Helper function to get actions from aggregated results -function getActionsFromAggregatedResults(aggregatedResults: any): any[] { - // If we have contentActions, use them directly - if ( - aggregatedResults.contentActions && - Array.isArray(aggregatedResults.contentActions) && - aggregatedResults.contentActions.length > 0 - ) { - return aggregatedResults.contentActions; - } - - // If we have relationships but no contentActions, convert relationships to actions - if ( - aggregatedResults.relationships && - Array.isArray(aggregatedResults.relationships) && - aggregatedResults.relationships.length > 0 - ) { - return aggregatedResults.relationships.map((relationship: any) => ({ - verbs: relationship.relationship - ? relationship.relationship - .split(/[,\s]+/) - .filter((v: string) => v.trim().length > 0) - : ["related to"], - verbTense: "present" as "past" | "present" | "future", - subjectEntityName: relationship.from || "none", - objectEntityName: relationship.to || "none", - indirectObjectEntityName: "none", - params: [], - confidence: relationship.confidence || 0.8, - })); - } - - return []; -} - -// Helper function to check if a page exists in the index -function checkPageExistsInIndex( - url: string, - context: SessionContext, -): boolean { - try { - const websiteCollection = context.agentContext.websiteCollection; - if (!websiteCollection) { - return false; - } - - const websites = websiteCollection.messages.getAll(); - return websites.some((site: any) => site.metadata.url === url); - } catch (error) { - console.error("Error checking page existence:", error); - return false; - } -} - -// Helper function to check for indexing errors -function hasIndexingErrors(result: any): boolean { - return !!( - result?.semanticRefs?.error || result?.secondaryIndexResults?.error - ); -} - export async function indexWebPageContent( parameters: { url: string; @@ -94,14 +27,11 @@ export async function indexWebPageContent( entityCount: number; }> { try { - let aggregatedResults: any; let combinedTextContent = ""; if (parameters.extractedKnowledge) { - aggregatedResults = parameters.extractedKnowledge; - combinedTextContent = aggregatedResults.summary || ""; + combinedTextContent = parameters.extractedKnowledge.summary || ""; } else { - // Create individual extraction inputs for each HTML fragment const extractionInputs = createExtractionInputsFromFragments( parameters.htmlFragments!, parameters.url, @@ -109,187 +39,35 @@ export async function indexWebPageContent( "index", parameters.timestamp, ); - - const extractionMode = parameters.mode || "content"; - const extractor = new BrowserKnowledgeExtractor(context); - - // Process each fragment individually using batch processing - const extractionResults = await extractor.extractBatch( - extractionInputs, - extractionMode, - ); - - // Aggregate results for indexing - aggregatedResults = aggregateExtractionResults(extractionResults); - - // Create combined text content for website memory indexing combinedTextContent = extractionInputs .map((input) => input.textContent) .join("\n\n"); } - const visitInfo: website.WebsiteVisitInfo = { - url: parameters.url, - title: parameters.title, - source: "history", - visitDate: parameters.timestamp, - }; - - const websiteObj = website.importWebsiteVisit( - visitInfo, - combinedTextContent, - ); - - if (aggregatedResults && aggregatedResults.entities.length > 0) { - // Set knowledge based on what the website-memory package expects - websiteObj.knowledge = { - entities: aggregatedResults.entities.map((entity: any) => ({ - ...entity, - type: Array.isArray(entity.type) - ? entity.type - : [entity.type], // Ensure type is array - })), - topics: aggregatedResults.keyTopics || aggregatedResults.topics, - actions: getActionsFromAggregatedResults(aggregatedResults), - inverseActions: [], // Required property - }; - } - - // Store detectedActions and actionSummary in metadata for retrieval - if ( - aggregatedResults && - (aggregatedResults.detectedActions || - aggregatedResults.actionSummary) - ) { - websiteObj.metadata = websiteObj.metadata || {}; - - if ( - aggregatedResults.detectedActions && - aggregatedResults.detectedActions.length > 0 - ) { - websiteObj.metadata.detectedActions = - aggregatedResults.detectedActions; - } - - if (aggregatedResults.actionSummary) { - websiteObj.metadata.actionSummary = - aggregatedResults.actionSummary; - } - } - - if (context.agentContext.websiteCollection) { - try { - const isNewPage = !checkPageExistsInIndex( - parameters.url, - context, - ); - - if (isNewPage) { - const docPart = - website.WebsiteDocPart.fromWebsite(websiteObj); - const result = - await context.agentContext.websiteCollection.addWebsiteToIndex( - docPart, - ); - if (hasIndexingErrors(result)) { - console.warn( - "Incremental indexing failed, falling back to full rebuild", - ); - context.agentContext.websiteCollection.addWebsites([ - websiteObj, - ]); - await context.agentContext.websiteCollection.buildIndex(); - } - } else { - const docPart = - website.WebsiteDocPart.fromWebsite(websiteObj); - const result = - await context.agentContext.websiteCollection.updateWebsiteInIndex( - parameters.url, - docPart, - ); - if (hasIndexingErrors(result)) { - console.warn( - "Update indexing failed, falling back to full rebuild", - ); - context.agentContext.websiteCollection.addWebsites([ - websiteObj, - ]); - await context.agentContext.websiteCollection.buildIndex(); - } - } - } catch (error) { - console.warn( - "Indexing error, falling back to full rebuild:", - error, - ); - context.agentContext.websiteCollection.addWebsites([ - websiteObj, - ]); - await context.agentContext.websiteCollection.buildIndex(); - } - - try { - if (context.agentContext.index?.path) { - // Ensure the directory exists before writing - fs.mkdirSync(context.agentContext.index.path, { - recursive: true, - }); - - await context.agentContext.websiteCollection.writeToFile( - context.agentContext.index.path, - "index", - ); - debug( - `Saved updated website collection to ${context.agentContext.index.path}`, - ); - } else { - console.warn( - "No index path available, indexed page data not persisted to disk", - ); - } - } catch (error) { - console.error("Error persisting website collection:", error); - } - - try { - if (aggregatedResults.entities?.length > 0) { - await context.agentContext.websiteCollection.updateGraph([ - websiteObj, - ]); - debug( - `Updated knowledge graph with ${aggregatedResults.entities.length} entities from ${parameters.url}`, - ); - } else { - debug( - `Skipped graph update for ${parameters.url} - no entities extracted`, - ); - } - } catch (error) { - console.warn( - "Failed to update knowledge graph incrementally:", - error, - ); - } - - try { - if ( - aggregatedResults.keyTopics?.length > 0 || - aggregatedResults.topics?.length > 0 - ) { - await context.agentContext.websiteCollection.updateHierarchicalTopics( - [websiteObj], - ); - } - } catch (error) { - console.warn( - "Failed to update hierarchical topics incrementally:", - error, - ); - } + const memoryService = context.agentContext.browserMemoryService; + if (memoryService === undefined) { + throw new Error("Durable browser memory is not available"); } + await memoryService.ingest( + { + url: parameters.url, + title: parameters.title, + markdown: combinedTextContent, + source: "current-page", + capturedAt: parameters.timestamp, + }, + parameters.mode ?? "content", + ); + debug(`Stored current page in durable memory: ${parameters.url}`); - const entityCount = aggregatedResults.entities?.length || 0; + const source = await memoryService.getSource(parameters.url); + const graph = await memoryService.getKnowledgeGraph(); + const entityCount = + source === undefined + ? 0 + : graph.entities.filter((entity) => + entity.sourceIds.includes(source.sourceId), + ).length; return { indexed: true, @@ -297,10 +75,6 @@ export async function indexWebPageContent( entityCount, }; } catch (error) { - if (error instanceof AIModelRequiredError) { - throw error; - } - console.error("Error indexing page content:", error); return { indexed: false, @@ -319,30 +93,25 @@ export async function checkPageIndexStatus( entityCount: number; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { + const memoryService = context.agentContext.browserMemoryService; + if (memoryService === undefined) { return { isIndexed: false, lastIndexed: null, entityCount: 0 }; } - - const websites = websiteCollection.messages.getAll(); - const foundWebsite = websites.find( - (site: any) => site.metadata.url === parameters.url, - ); - - if (foundWebsite) { - const knowledge = foundWebsite.getKnowledge(); - const metadata = - foundWebsite.metadata as website.WebsiteDocPartMeta; - return { - isIndexed: true, - lastIndexed: - metadata.visitDate || metadata.bookmarkDate || null, - entityCount: knowledge?.entities?.length || 0, - }; - } else { + const source = await memoryService.getSource(parameters.url); + if (source === undefined) { return { isIndexed: false, lastIndexed: null, entityCount: 0 }; } + const revision = source.revisions.find( + (candidate) => candidate.revisionId === source.activeRevisionId, + ); + const graph = await memoryService.getKnowledgeGraph(); + return { + isIndexed: true, + lastIndexed: revision?.indexedAt ?? revision?.capturedAt ?? null, + entityCount: graph.entities.filter((entity) => + entity.sourceIds.includes(source.sourceId), + ).length, + }; } catch (error) { console.error("Error checking page index status:", error); return { isIndexed: false, lastIndexed: null, entityCount: 0 }; diff --git a/ts/packages/agents/browser/src/agent/knowledge/types/knowledgeTypes.mts b/ts/packages/agents/browser/src/agent/knowledge/types/knowledgeTypes.mts index 78b2635a81..3ed96edd29 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/types/knowledgeTypes.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/types/knowledgeTypes.mts @@ -112,6 +112,7 @@ export interface GraphCache { | undefined; lastUpdated: number; isValid: boolean; + sourceVersion?: number; } export interface TopicGraphCache { diff --git a/ts/packages/agents/browser/src/agent/searchWebMemories.mts b/ts/packages/agents/browser/src/agent/searchWebMemories.mts index edf11112a4..a3ba12cbcd 100644 --- a/ts/packages/agents/browser/src/agent/searchWebMemories.mts +++ b/ts/packages/agents/browser/src/agent/searchWebMemories.mts @@ -14,6 +14,7 @@ import { getWebsiteSearchPromptPreamble } from "./search/websiteSearchPrompts.mj import { openai as ai } from "@typeagent/aiclient"; import { hookModelTokenUsage } from "./tokenUsage.mjs"; import type { TypeChatLanguageModel } from "typechat"; +import type { BrowserMemoryMatch } from "./browserMemoryService.mjs"; const debug = registerDebug("typeagent:browser:unified-search"); @@ -27,6 +28,9 @@ export interface SearchWebMemoriesRequest { // Temporal filters dateFrom?: string | undefined; dateTo?: string | undefined; + domain?: string | undefined; + pageType?: string | undefined; + source?: string | undefined; // Search configuration limit?: number | undefined; @@ -191,6 +195,7 @@ export async function searchWebMemories( context: SessionContext, ): Promise { const startTime = Date.now(); + let memoryServiceResponse: SearchWebMemoriesResponse | undefined; const timing = { parsing: 0, search: 0, @@ -212,23 +217,88 @@ export async function searchWebMemories( throw new Error("Query cannot be empty"); } + const currentPageUrl = request.metadata?.url || request.url; + const parsedQuery = parsePropertySearch(request.query); + const searchText = parsedQuery.searchText; + const propertyFilters: PropertyFilter = { + ...((request.domain ?? parsedQuery.propertyFilters.domain) === + undefined + ? {} + : { + domain: + request.domain ?? parsedQuery.propertyFilters.domain, + }), + ...((request.pageType ?? parsedQuery.propertyFilters.pageType) === + undefined + ? {} + : { + pageType: + request.pageType ?? + parsedQuery.propertyFilters.pageType, + }), + ...((request.source ?? parsedQuery.propertyFilters.source) === + undefined + ? {} + : { + source: + request.source ?? parsedQuery.propertyFilters.source, + }), + }; + const memoryService = context.agentContext.browserMemoryService; + if (memoryService !== undefined && searchText.length > 0) { + try { + const matches = await memoryService.search({ + query: searchText, + limit: request.limit ?? 20, + ...(request.searchScope === "current_page" && + currentPageUrl !== undefined + ? { url: currentPageUrl } + : {}), + ...(propertyFilters.domain === undefined + ? {} + : { domain: propertyFilters.domain }), + ...(propertyFilters.pageType === undefined + ? {} + : { pageType: propertyFilters.pageType }), + ...(propertyFilters.source === undefined + ? {} + : { source: propertyFilters.source }), + ...(request.dateFrom === undefined + ? {} + : { dateFrom: request.dateFrom }), + ...(request.dateTo === undefined + ? {} + : { dateTo: request.dateTo }), + }); + if (matches.length > 0) { + memoryServiceResponse = createMemoryServiceResponse( + matches, + startTime, + request.debug ? debugContext : undefined, + ); + } + } catch (error) { + debug(`Memory service search failed: ${String(error)}`); + debugContext.intermediateFallbacks.push("memory-service"); + } + } + const websiteCollection = context.agentContext.websiteCollection; if (!websiteCollection || websiteCollection.messages.length === 0) { - return createEmptyResponse( - "No website data available. Please import website data first using the library panel.", - startTime, - request.debug ? debugContext : undefined, + return ( + memoryServiceResponse ?? + createEmptyResponse( + "No website data available. Please import website data first using the library panel.", + startTime, + request.debug ? debugContext : undefined, + ) ); } debug(`Starting unified search for query: "${request.query}"`); - const currentPageUrl = request.metadata?.url || request.url; - - // Parse property filters from query (website-specific) - const { searchText, propertyFilters } = parsePropertySearch( - request.query, - ); + // Property filters were parsed before the service search so both + // retrieval paths apply the same exact-match semantics. if (searchText !== request.query) { debug(`Property filters detected:`, propertyFilters); debug(`Search text after filter extraction: "${searchText}"`); @@ -304,10 +374,13 @@ export async function searchWebMemories( debug(`Pre-filter took ${filterTime}ms`); } else { // No messages found for this URL - return createEmptyResponse( - `No indexed content found for the current page: ${targetUrl}`, - startTime, - request.debug ? debugContext : undefined, + return ( + memoryServiceResponse ?? + createEmptyResponse( + `No indexed content found for the current page: ${targetUrl}`, + startTime, + request.debug ? debugContext : undefined, + ) ); } } @@ -324,10 +397,13 @@ export async function searchWebMemories( ); if (!langResult.success) { - return createErrorResponse( - `Search query translation failed: ${langResult.message}`, - startTime, - request.debug ? debugContext : undefined, + return ( + memoryServiceResponse ?? + createErrorResponse( + `Search query translation failed: ${langResult.message}`, + startTime, + request.debug ? debugContext : undefined, + ) ); } @@ -900,15 +976,22 @@ export async function searchWebMemories( debug( `Search completed in ${timing.total}ms with ${websiteResults.length} results`, ); - return response; + return mergeSearchResponses( + memoryServiceResponse, + response, + request.limit ?? 20, + ); } catch (error) { timing.total = Date.now() - startTime; debug(`Search failed: ${error}`); - return createErrorResponse( - error instanceof Error ? error.message : "Unknown search error", - startTime, - request.debug ? debugContext : undefined, + return ( + memoryServiceResponse ?? + createErrorResponse( + error instanceof Error ? error.message : "Unknown search error", + startTime, + request.debug ? debugContext : undefined, + ) ); } } @@ -1666,6 +1749,103 @@ function associateInsightsWithResults( }); } +function createMemoryServiceResponse( + matches: BrowserMemoryMatch[], + startTime: number, + debugContext?: SearchDebugContext, +): SearchWebMemoriesResponse { + const websites = matches.map(({ evidence, source }) => ({ + url: evidence.canonicalUri ?? source.canonicalUri ?? source.sourceId, + title: evidence.title, + domain: + metadataString(source.metadata, "domain") ?? + domainFromUrl(evidence.canonicalUri ?? source.canonicalUri), + pageType: metadataString(source.metadata, "pageType") ?? "general", + source: metadataString(source.metadata, "source") ?? "unknown", + relevanceScore: evidence.score, + ...(evidence.capturedAt === undefined + ? {} + : { lastVisited: evidence.capturedAt }), + snippet: evidence.snippet, + })); + return { + websites, + summary: { + totalFound: websites.length, + searchTime: Date.now() - startTime, + strategies: ["memory-service"], + confidence: + websites.reduce( + (total, item) => total + item.relevanceScore, + 0, + ) / websites.length, + }, + answerType: "noAnswer", + queryIntent: "discovery", + suggestedFollowups: [], + ...(debugContext === undefined ? {} : { debugContext }), + }; +} + +function mergeSearchResponses( + memoryResponse: SearchWebMemoriesResponse | undefined, + legacyResponse: SearchWebMemoriesResponse, + limit: number, +): SearchWebMemoriesResponse { + if (memoryResponse === undefined) { + return legacyResponse; + } + const websites = new Map(); + for (const website of [ + ...memoryResponse.websites, + ...legacyResponse.websites, + ]) { + const existing = websites.get(website.url); + if ( + existing === undefined || + website.relevanceScore > existing.relevanceScore + ) { + websites.set(website.url, website); + } + } + const mergedWebsites = [...websites.values()] + .sort((left, right) => right.relevanceScore - left.relevanceScore) + .slice(0, limit); + return { + ...legacyResponse, + websites: mergedWebsites, + summary: { + ...legacyResponse.summary, + totalFound: mergedWebsites.length, + strategies: [ + ...new Set([ + ...memoryResponse.summary.strategies, + ...legacyResponse.summary.strategies, + ]), + ], + }, + }; +} + +function metadataString( + metadata: Record | undefined, + name: string, +): string | undefined { + const value = metadata?.[name]; + return typeof value === "string" ? value : undefined; +} + +function domainFromUrl(url: string | undefined): string { + if (url === undefined) { + return "unknown"; + } + try { + return new URL(url).hostname; + } catch { + return "unknown"; + } +} + function createEmptyResponse( message: string, startTime: number, diff --git a/ts/packages/agents/browser/src/agent/websiteMemory.mts b/ts/packages/agents/browser/src/agent/websiteMemory.mts index 8fbd889428..9974a2cb72 100644 --- a/ts/packages/agents/browser/src/agent/websiteMemory.mts +++ b/ts/packages/agents/browser/src/agent/websiteMemory.mts @@ -636,6 +636,15 @@ export async function importWebsiteDataFromSession( }, ); + await ingestWebsitesIntoMemoryService( + chunk, + extractionMode, + context.agentContext, + importContext, + i, + websites.length, + ); + context.agentContext.websiteCollection.addWebsites(chunk); try { @@ -1122,6 +1131,15 @@ export async function importHtmlFolderFromSession( importContext, ); + await ingestWebsitesIntoMemoryService( + chunk, + extractionMode, + context.agentContext, + importContext, + i, + websites.length, + ); + context.agentContext.websiteCollection.addWebsites(chunk); try { @@ -1349,6 +1367,72 @@ function convertWebsiteDataToWebsite(data: WebsiteData): any { return websiteInstance; } +async function ingestWebsitesIntoMemoryService( + websites: website.Website[], + mode: website.ExtractionMode, + agentContext: BrowserActionContext, + importContext: { + importId: string; + type: "websiteImport" | "htmlFolderImport"; + url?: string; + folderPath?: string; + }, + offset: number, + total: number, +): Promise { + const memoryService = agentContext.browserMemoryService; + if (memoryService === undefined) { + return; + } + for (const [index, item] of websites.entries()) { + await memoryService.ingest( + { + url: item.metadata.url, + title: item.metadata.title ?? item.metadata.url, + markdown: item.textChunks.join("\n\n"), + source: item.metadata.websiteSource, + ...(item.metadata.domain === undefined + ? {} + : { domain: item.metadata.domain }), + ...(item.metadata.pageType === undefined + ? {} + : { pageType: item.metadata.pageType }), + ...(item.timestamp === undefined + ? {} + : { capturedAt: item.timestamp }), + tags: item.tags, + }, + mode, + { + onProgress: (progress) => + logStructuredProgress( + offset + index, + total, + progress.message ?? + `Indexing ${item.metadata.title ?? item.metadata.url}`, + "persisting", + importContext, + undefined, + { + url: item.metadata.url, + ...(item.metadata.title === undefined + ? {} + : { title: item.metadata.title }), + currentAction: "indexing", + }, + ), + }, + ); + logStructuredProgress( + offset + index + 1, + total, + `Stored ${item.metadata.title ?? item.metadata.url} in durable memory`, + "persisting", + importContext, + ); + } +} + /** * Get statistics about imported website data */ diff --git a/ts/packages/agents/browser/test/browserInitOptions.test.ts b/ts/packages/agents/browser/test/browserInitOptions.test.ts new file mode 100644 index 0000000000..f6da2ece4e --- /dev/null +++ b/ts/packages/agents/browser/test/browserInitOptions.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { BrowserControl } from "@typeagent/browser-control-rpc/types"; +import type { MemoryServiceClient } from "@typeagent/memory-client"; +import { normalizeBrowserAgentInitOptions } from "../src/agent/browserActions.mjs"; + +describe("normalizeBrowserAgentInitOptions", () => { + test("preserves a legacy raw BrowserControl", () => { + const browserControl = {} as BrowserControl; + + expect(normalizeBrowserAgentInitOptions(browserControl)).toEqual({ + browserControl, + }); + }); + + test("accepts structured browser and memory dependencies", () => { + const browserControl = {} as BrowserControl; + const memoryServiceClient = {} as MemoryServiceClient; + + expect( + normalizeBrowserAgentInitOptions({ + browserControl, + memoryServiceClient, + }), + ).toEqual({ browserControl, memoryServiceClient }); + }); + + test("uses external browser control when no options are supplied", () => { + expect(normalizeBrowserAgentInitOptions(undefined)).toEqual({}); + }); +}); diff --git a/ts/packages/agents/browser/test/browserMemoryService.test.ts b/ts/packages/agents/browser/test/browserMemoryService.test.ts new file mode 100644 index 0000000000..3439e1bbd2 --- /dev/null +++ b/ts/packages/agents/browser/test/browserMemoryService.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { MemoryServiceClient } from "@typeagent/memory-client"; +import type { MemorySource } from "@typeagent/memory-service"; +import { + BrowserMemoryService, + getBrowserMemoryService, +} from "../src/agent/browserMemoryService.mjs"; + +function createClient(): jest.Mocked { + return { + createCorpus: jest.fn(async (name, description) => ({ + corpusId: "browser-corpus", + name, + description, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + status: "ready", + documentCount: 0, + })), + listCorpora: jest.fn(async () => []), + listSources: jest.fn(async () => []), + getSource: jest.fn(), + ingestDocument: jest.fn(async () => ({ + jobId: "job-1", + sourceId: "source-1", + revisionId: "revision-1", + state: "accepted", + statusUri: "typeagent-memory://jobs/job-1", + })), + getJob: jest.fn(), + cancelJob: jest.fn(), + search: jest.fn(async (request) => ({ + query: request.query, + matches: [], + warnings: [], + capabilitiesUsed: ["structured-search"], + indexVersion: "test", + })), + getKnowledgeGraph: jest.fn(async () => ({ + entities: [], + topics: [], + relationships: [], + })), + getCapabilities: jest.fn(), + waitForJob: jest.fn(async () => ({ + jobId: "job-1", + corpusId: "browser-corpus", + sourceId: "source-1", + revisionId: "revision-1", + state: "complete", + progress: { completed: 1, total: 1 }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + warnings: [], + })), + close: jest.fn(), + }; +} + +describe("BrowserMemoryService", () => { + test("shares an adapter for browser sessions using the same client", () => { + const client = createClient(); + + expect(getBrowserMemoryService(client)).toBe( + getBrowserMemoryService(client), + ); + }); + + test("reuses its corpus and assigns stable URL source IDs", async () => { + const client = createClient(); + const service = new BrowserMemoryService(client); + const document = { + url: "https://example.test/private/page", + title: "Captured page", + markdown: "# Captured page", + }; + + await service.ingest(document, "content"); + await service.ingest(document, "content"); + + expect(client.listCorpora).toHaveBeenCalledTimes(1); + expect(client.createCorpus).toHaveBeenCalledTimes(1); + const firstSourceId = client.ingestDocument.mock.calls[0][0].source + .sourceId as string; + const secondSourceId = client.ingestDocument.mock.calls[1][0].source + .sourceId as string; + expect(firstSourceId).toMatch(/^web:[a-f0-9]{64}$/); + expect(secondSourceId).toBe(firstSourceId); + expect(client.waitForJob).toHaveBeenCalledTimes(2); + }); + + test("forwards ingestion progress to browser callers", async () => { + const client = createClient(); + const onProgress = jest.fn(); + client.waitForJob.mockImplementation(async (_jobId, options) => { + options?.onProgress?.({ + completed: 2, + total: 3, + message: "Creating embeddings", + }); + return { + jobId: "job-1", + corpusId: "browser-corpus", + sourceId: "source-1", + revisionId: "revision-1", + state: "complete", + progress: { completed: 3, total: 3 }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + warnings: [], + }; + }); + + await new BrowserMemoryService(client).ingest( + { + url: "https://example.test/page", + title: "Page", + markdown: "Page content", + }, + "content", + { onProgress }, + ); + + expect(onProgress).toHaveBeenCalledWith({ + completed: 2, + total: 3, + message: "Creating embeddings", + }); + }); + + test("reads source metadata and graph data from the browser corpus", async () => { + const client = createClient(); + const service = new BrowserMemoryService(client); + + await service.getSource("https://example.test/page"); + await service.getKnowledgeGraph(); + + expect(client.getSource).toHaveBeenCalledWith( + "browser-corpus", + expect.stringMatching(/^web:[a-f0-9]{64}$/), + ); + expect(client.getKnowledgeGraph).toHaveBeenCalledWith("browser-corpus"); + }); + + test("translates URL, metadata, and date filters to source IDs", async () => { + const client = createClient(); + const matchingSource: MemorySource = { + sourceId: "matching", + corpusId: "browser-corpus", + sourceType: "web", + canonicalUri: "https://example.test/page", + title: "Matching page", + metadata: { + domain: "example.test", + pageType: "documentation", + source: "bookmark", + }, + activeRevisionId: "revision-1", + revisions: [ + { + revisionId: "revision-1", + sourceId: "matching", + contentHash: "hash", + mimeType: "text/markdown", + capturedAt: "2026-02-01T00:00:00.000Z", + pipelineVersion: "1", + state: "ready", + }, + ], + }; + client.listCorpora.mockResolvedValue([ + { + corpusId: "browser-corpus", + name: "TypeAgent Browser Memory", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + status: "ready", + documentCount: 1, + }, + ]); + client.listSources.mockResolvedValue([ + matchingSource, + { + ...matchingSource, + sourceId: "wrong-url", + canonicalUri: "https://example.test/other", + }, + ]); + + await new BrowserMemoryService(client).search({ + query: "design", + url: "https://example.test/page", + domain: "example.test", + pageType: "documentation", + source: "bookmark", + dateFrom: "2026-01-01T00:00:00.000Z", + dateTo: "2026-03-01T00:00:00.000Z", + }); + + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ sourceIds: ["matching"] }), + ); + }); +}); diff --git a/ts/packages/agents/browserExtension/src/extension/interfaces/websiteImport.types.ts b/ts/packages/agents/browserExtension/src/extension/interfaces/websiteImport.types.ts index e0be25cefa..13bf205465 100644 --- a/ts/packages/agents/browserExtension/src/extension/interfaces/websiteImport.types.ts +++ b/ts/packages/agents/browserExtension/src/extension/interfaces/websiteImport.types.ts @@ -39,6 +39,8 @@ export interface ImportProgress { | "fetching" | "processing" | "extracting" + | "graph-building" + | "persisting" | "complete" | "error"; totalItems: number; diff --git a/ts/packages/agents/browserExtension/src/extension/serviceWorker/messageHandlers.ts b/ts/packages/agents/browserExtension/src/extension/serviceWorker/messageHandlers.ts index 16e15f835e..6d8da8b192 100644 --- a/ts/packages/agents/browserExtension/src/extension/serviceWorker/messageHandlers.ts +++ b/ts/packages/agents/browserExtension/src/extension/serviceWorker/messageHandlers.ts @@ -281,8 +281,23 @@ export async function handleSearchWebMemories(message: any) { includeRelatedEntities: true, enableAdvancedSearch: true, limit: message.parameters.limit || 20, - minScore: message.parameters.filters?.minRelevance || 0.3, + minScore: + message.parameters.minScore ?? + message.parameters.filters?.minRelevance ?? + 0.3, ...message.parameters.filters, + domain: + message.parameters.domain ?? + message.parameters.filters?.domain, + source: + message.parameters.source ?? + message.parameters.filters?.source, + dateFrom: + message.parameters.dateFrom ?? + message.parameters.filters?.dateFrom, + dateTo: + message.parameters.dateTo ?? + message.parameters.filters?.dateTo, }, }); diff --git a/ts/packages/agents/browserExtension/src/extension/views/extensionServiceBase.ts b/ts/packages/agents/browserExtension/src/extension/views/extensionServiceBase.ts index eccbd3518a..a5aeacca94 100644 --- a/ts/packages/agents/browserExtension/src/extension/views/extensionServiceBase.ts +++ b/ts/packages/agents/browserExtension/src/extension/views/extensionServiceBase.ts @@ -213,6 +213,12 @@ export abstract class ExtensionServiceBase { limit: 50, minScore: filters.minRelevance || 0.3, domain: filters.domain, + source: + filters.sourceType === "bookmarks" + ? "bookmark" + : filters.sourceType, + dateFrom: filters.dateFrom, + dateTo: filters.dateTo, }, })) as any; diff --git a/ts/packages/agents/browserExtension/test/serviceWorker/messageHandlers.search.test.ts b/ts/packages/agents/browserExtension/test/serviceWorker/messageHandlers.search.test.ts new file mode 100644 index 0000000000..a7c4782732 --- /dev/null +++ b/ts/packages/agents/browserExtension/test/serviceWorker/messageHandlers.search.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { sendActionToAgent } from "../../src/extension/serviceWorker/websocket"; +import { handleSearchWebMemories } from "../../src/extension/serviceWorker/messageHandlers"; + +jest.mock("../../src/extension/serviceWorker/websocket", () => ({ + sendActionToAgent: jest.fn(async () => ({ + websites: [], + summary: { searchTime: 1 }, + })), +})); +jest.mock("../../src/extension/serviceWorker/capture", () => ({})); +jest.mock( + "../../src/extension/serviceWorker/contentDownloader.js", + () => ({ + BrowserContentDownloader: jest.fn(), + }), + { virtual: true }, +); +jest.mock("../../src/extension/serviceWorker/extensionEventHelpers", () => ({ + broadcastEvent: jest.fn(), +})); + +describe("handleSearchWebMemories", () => { + test("forwards structured filters to the browser agent", async () => { + await handleSearchWebMemories({ + parameters: { + query: "design", + limit: 50, + minScore: 0.4, + domain: "example.test", + source: "bookmark", + dateFrom: "2026-01-01T00:00:00.000Z", + dateTo: "2026-03-01T00:00:00.000Z", + }, + }); + + expect(sendActionToAgent).toHaveBeenCalledWith({ + actionName: "searchWebMemories", + parameters: expect.objectContaining({ + query: "design", + limit: 50, + minScore: 0.4, + domain: "example.test", + source: "bookmark", + dateFrom: "2026-01-01T00:00:00.000Z", + dateTo: "2026-03-01T00:00:00.000Z", + }), + }); + }); +}); diff --git a/ts/packages/agents/browserExtension/test/views/extensionServiceBase.test.ts b/ts/packages/agents/browserExtension/test/views/extensionServiceBase.test.ts new file mode 100644 index 0000000000..1b914bee41 --- /dev/null +++ b/ts/packages/agents/browserExtension/test/views/extensionServiceBase.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ProgressCallback } from "../../src/extension/interfaces/websiteImport.types"; +import type { KnowledgeProgressCallback } from "../../src/extension/interfaces/knowledgeExtraction.types"; +import { + ExtensionServiceBase, + type SearchResult, +} from "../../src/extension/views/extensionServiceBase"; + +class TestExtensionService extends ExtensionServiceBase { + public readonly messages: unknown[] = []; + + protected async sendMessage(message: unknown): Promise { + this.messages.push(message); + return { results: {} as SearchResult } as T; + } + + protected onImportProgressImpl( + _importId: string, + _callback: ProgressCallback, + ): void {} + + protected onExtractionProgressImpl( + _extractionId: string, + _callback: KnowledgeProgressCallback, + ): void {} +} + +describe("ExtensionServiceBase search filters", () => { + test("forwards structured filters using backend source names", async () => { + const service = new TestExtensionService(); + + await service.searchWebMemories("design", { + domain: "example.test", + sourceType: "bookmarks", + dateFrom: "2026-01-01T00:00:00.000Z", + dateTo: "2026-03-01T00:00:00.000Z", + }); + + expect(service.messages).toEqual([ + expect.objectContaining({ + type: "searchWebMemories", + parameters: expect.objectContaining({ + query: "design", + domain: "example.test", + source: "bookmark", + dateFrom: "2026-01-01T00:00:00.000Z", + dateTo: "2026-03-01T00:00:00.000Z", + }), + }), + ]); + }); +}); diff --git a/ts/packages/defaultAgentProvider/src/defaultAgentRuntime.ts b/ts/packages/defaultAgentProvider/src/defaultAgentRuntime.ts index fcb25424b3..a699fe8b16 100644 --- a/ts/packages/defaultAgentProvider/src/defaultAgentRuntime.ts +++ b/ts/packages/defaultAgentProvider/src/defaultAgentRuntime.ts @@ -15,6 +15,7 @@ import { SessionMcpCredentialStore } from "./mcp/mcpCredentialStore.js"; import { defaultMcpPolicy } from "./mcp/mcpPolicy.js"; import { JsonlMcpAuditSink } from "./mcp/mcpAudit.js"; import { McpConfigDiscovery } from "./mcp/mcpConfigDiscovery.js"; +import type { NormalizedMcpServerConfig } from "./mcp/mcpServerConfig.js"; export interface DefaultAgentRuntime { readonly appAgentSources: [AppAgentSource, AppAgentSource]; @@ -26,6 +27,7 @@ export function createDefaultAgentRuntime( instanceDir: string, options?: DefaultAppAgentSourceOptions, mcpServices?: Partial, + runtimeMcpSeed: Record = {}, ): DefaultAgentRuntime { const services: McpHostServices = { credentialStore: @@ -48,6 +50,7 @@ export function createDefaultAgentRuntime( getInstanceConfigProvider(instanceDir), services, discovery, + runtimeMcpSeed, ); const installed = createDefaultInstalledAgentSource( instanceDir, diff --git a/ts/packages/defaultAgentProvider/src/index.ts b/ts/packages/defaultAgentProvider/src/index.ts index a574ec1d94..32fdcbc9f0 100644 --- a/ts/packages/defaultAgentProvider/src/index.ts +++ b/ts/packages/defaultAgentProvider/src/index.ts @@ -43,6 +43,7 @@ export { type McpReplayHostOptions, } from "./mcp/mcpReplayHost.js"; export type { McpHostServices } from "./mcp/mcpServerProvider.js"; +export type { NormalizedMcpServerConfig } from "./mcp/mcpServerConfig.js"; export { createOnboardingOnlyDispatcher, type OnboardingDispatcherHandle, diff --git a/ts/packages/defaultAgentProvider/src/mcpDefaultAgentProvider.ts b/ts/packages/defaultAgentProvider/src/mcpDefaultAgentProvider.ts index 559c175b05..7ae302f077 100644 --- a/ts/packages/defaultAgentProvider/src/mcpDefaultAgentProvider.ts +++ b/ts/packages/defaultAgentProvider/src/mcpDefaultAgentProvider.ts @@ -105,6 +105,7 @@ export function createMcpAppAgentSourceForInstance( instanceConfigs: InstanceConfigProvider, services?: McpHostServices, discovery?: McpConfigDiscoveryResult, + runtimeSeed: Record = {}, ): McpAppAgentSourceForTest { const instanceDir = instanceConfigs.getInstanceDir(); if (instanceDir === undefined) { @@ -112,10 +113,13 @@ export function createMcpAppAgentSourceForInstance( "Internal error: MCP app agent source requires an instance directory.", ); } - const seed = getShippedSeed(); + const seed = { ...getShippedSeed(), ...runtimeSeed }; // Reserve ALL shipped server names (both seeded and legacy) so the user // store can never register a name owned by another provider. - const reserved = new Set(Object.keys(getProviderConfig().mcpServers ?? {})); + const reserved = new Set([ + ...Object.keys(getProviderConfig().mcpServers ?? {}), + ...Object.values(runtimeSeed).map((config) => config.name), + ]); const store = openMcpServerStore( instanceDir, reserved, diff --git a/ts/packages/defaultAgentProvider/test/defaultAgentRuntime.spec.ts b/ts/packages/defaultAgentProvider/test/defaultAgentRuntime.spec.ts index 37e8378c4c..50b599d2a8 100644 --- a/ts/packages/defaultAgentProvider/test/defaultAgentRuntime.spec.ts +++ b/ts/packages/defaultAgentProvider/test/defaultAgentRuntime.spec.ts @@ -44,4 +44,47 @@ describe("createDefaultAgentRuntime", () => { expect(context.mcpSource).toBe(runtime.mcpServerSourceApi); installedConnection.dispose(); }); + + it("keeps runtime MCP seeds protected and out of persistent storage", async () => { + const instanceDir = tmpInstanceDir(); + const runtime = createDefaultAgentRuntime( + instanceDir, + undefined, + undefined, + { + memory: { + id: "runtime:typeagent-memory", + name: "memory", + transport: { + kind: "http", + url: "http://127.0.0.1:12345/mcp", + }, + enabled: true, + trust: "trusted", + scope: "shipped", + provenance: { + source: "agent-server", + sourceKind: "runtime", + }, + }, + }, + ); + + expect( + runtime.mcpServerSourceApi.getServer("runtime:typeagent-memory"), + ).toMatchObject({ name: "memory", scope: "shipped" }); + await expect( + runtime.mcpServerSourceApi.removeServer("runtime:typeagent-memory"), + ).rejects.toThrow("Cannot remove shipped MCP server"); + + const persisted = JSON.parse( + fs.readFileSync(path.join(instanceDir, "mcpServers.json"), "utf8"), + ) as { servers: Record }; + const persistedText = JSON.stringify(persisted); + expect(persisted.servers).not.toHaveProperty( + "runtime:typeagent-memory", + ); + expect(persistedText).not.toContain("127.0.0.1:12345"); + expect(persistedText).not.toContain('"name":"memory"'); + }); }); diff --git a/ts/packages/interactiveApp/README.AUTOGEN.md b/ts/packages/interactiveApp/README.AUTOGEN.md index 1f9d440d91..eeaf2f908e 100644 --- a/ts/packages/interactiveApp/README.AUTOGEN.md +++ b/ts/packages/interactiveApp/README.AUTOGEN.md @@ -122,7 +122,6 @@ External: `string-width` - [document-processor](../../examples/docuProc/README.md) - [examples-lib](../../examples/examplesLib/README.md) - [knowpro-test](../../packages/knowProTest/README.md) -- [memory-mcp](../../examples/mcpMemory/README.md) - [playground](../../examples/playground/README.md) - schema-studio - [search-action-test](../../examples/searchActionTest/README.md) diff --git a/ts/packages/knowPro/README.AUTOGEN.md b/ts/packages/knowPro/README.AUTOGEN.md index aea63545cf..6e0c12b04e 100644 --- a/ts/packages/knowPro/README.AUTOGEN.md +++ b/ts/packages/knowPro/README.AUTOGEN.md @@ -133,7 +133,6 @@ External: `async`, `debug`, `fast-levenshtein`, `typechat` - [examples-lib](../../examples/examplesLib/README.md) - [image-memory](../../packages/memory/image/README.md) - [knowpro-test](../../packages/knowProTest/README.md) -- [memory-mcp](../../examples/mcpMemory/README.md) - [memory-storage](../../packages/memory/storage/README.md) - _…and 3 more workspace consumers._ diff --git a/ts/examples/mcpMemory/LICENSE b/ts/packages/memory/client/LICENSE similarity index 100% rename from ts/examples/mcpMemory/LICENSE rename to ts/packages/memory/client/LICENSE diff --git a/ts/packages/memory/client/README.md b/ts/packages/memory/client/README.md new file mode 100644 index 0000000000..de7d72d3d1 --- /dev/null +++ b/ts/packages/memory/client/README.md @@ -0,0 +1,11 @@ +# @typeagent/memory-client + +Typed clients and protocol schemas for the TypeAgent memory service + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/ts/packages/memory/client/package.json b/ts/packages/memory/client/package.json new file mode 100644 index 0000000000..da20669fa2 --- /dev/null +++ b/ts/packages/memory/client/package.json @@ -0,0 +1,35 @@ +{ + "name": "@typeagent/memory-client", + "version": "0.0.1", + "description": "Typed clients and protocol schemas for the TypeAgent memory service", + "homepage": "https://github.com/microsoft/TypeAgent#readme", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/TypeAgent.git", + "directory": "ts/packages/memory/client" + }, + "license": "MIT", + "author": "Microsoft", + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "types": "./dist/index.d.ts", + "scripts": { + "build": "npm run tsc", + "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", + "prettier": "prettier --check . --ignore-path ../../../.prettierignore", + "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore", + "tsc": "tsc -b" + }, + "dependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@typeagent/memory-service": "workspace:*", + "zod": "^4.1.13" + }, + "devDependencies": { + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "typescript": "~5.4.5" + } +} diff --git a/ts/packages/memory/client/src/index.ts b/ts/packages/memory/client/src/index.ts new file mode 100644 index 0000000000..dbece81af8 --- /dev/null +++ b/ts/packages/memory/client/src/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from "./memoryClient.js"; +export * from "./protocol.js"; diff --git a/ts/packages/memory/client/src/memoryClient.ts b/ts/packages/memory/client/src/memoryClient.ts new file mode 100644 index 0000000000..1a7f74e620 --- /dev/null +++ b/ts/packages/memory/client/src/memoryClient.ts @@ -0,0 +1,406 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + Client, + StreamableHTTPClientTransport, + type CallToolResult, +} from "@modelcontextprotocol/client"; +import { + StdioClientTransport, + getDefaultEnvironment, + type StdioServerParameters, +} from "@modelcontextprotocol/client/stdio"; +import type { + DocumentIngestRequest, + DocumentIngestResult, + IngestionJobStatus, + JobProgress, + MemoryCorpus, + MemoryKnowledgeGraph, + MemorySearchRequest, + MemorySearchResult, + MemoryService, + MemoryServiceCapabilities, + MemorySource, +} from "@typeagent/memory-service"; +import type { z } from "zod"; +import { + capabilitiesSchema, + corpusSchema, + ingestResultSchema, + jobStatusSchema, + knowledgeGraphSchema, + memoryToolNames, + optionalJobStatusSchema, + optionalSourceSchema, + searchResultSchema, + sourceSchema, + terminalJobStates, +} from "./protocol.js"; + +export interface MemoryClientCallOptions { + signal?: AbortSignal; + onProgress?: (progress: JobProgress) => void; +} + +export interface MemoryServiceClient extends MemoryService { + ingestDocument( + request: DocumentIngestRequest, + signal?: AbortSignal, + ): Promise; + waitForJob( + jobId: string, + options?: MemoryClientCallOptions & { pollIntervalMs?: number }, + ): Promise; + close(): Promise; +} + +export class InProcessMemoryServiceClient implements MemoryServiceClient { + public constructor(private readonly service: MemoryService) {} + + public createCorpus(name: string, description?: string) { + return this.service.createCorpus(name, description); + } + + public listCorpora() { + return this.service.listCorpora(); + } + + public listSources(corpusId: string) { + return this.service.listSources(corpusId); + } + + public getSource(corpusId: string, sourceId: string) { + return this.service.getSource(corpusId, sourceId); + } + + public ingestDocument( + request: DocumentIngestRequest, + signal?: AbortSignal, + ) { + return this.service.ingestDocument(request, signal); + } + + public getJob(jobId: string) { + return this.service.getJob(jobId); + } + + public cancelJob(jobId: string) { + return this.service.cancelJob(jobId); + } + + public search(request: MemorySearchRequest) { + return this.service.search(request); + } + + public getKnowledgeGraph(corpusId: string) { + return this.service.getKnowledgeGraph(corpusId); + } + + public getCapabilities() { + return this.service.getCapabilities(); + } + + public waitForJob( + jobId: string, + options: MemoryClientCallOptions & { pollIntervalMs?: number } = {}, + ) { + return waitForJob(this, jobId, options); + } + + public async close(): Promise {} +} + +export type MemoryMcpTransportConfig = + | { + kind: "stdio"; + command: string; + args: string[]; + env?: Record; + cwd?: string; + } + | { + kind: "http"; + url: string; + headers?: Record; + timeoutMs?: number; + }; + +type MemoryMcpTransport = StdioClientTransport | StreamableHTTPClientTransport; + +export class McpMemoryServiceClient implements MemoryServiceClient { + private constructor( + private readonly client: Client, + private readonly timeoutMs: number | undefined, + ) {} + + public static async create( + config: MemoryMcpTransportConfig, + ): Promise { + const transport = createTransport(config); + const client = new Client( + { name: "typeagent-memory-client", version: "0.0.1" }, + { versionNegotiation: { mode: "legacy" }, capabilities: {} }, + ); + try { + await client.connect( + transport, + config.kind === "http" && config.timeoutMs !== undefined + ? { timeout: config.timeoutMs } + : undefined, + ); + } catch (error) { + await transport.close().catch(() => undefined); + throw error; + } + return new McpMemoryServiceClient( + client, + config.kind === "http" ? config.timeoutMs : undefined, + ); + } + + public createCorpus( + name: string, + description?: string, + ): Promise { + return this.invoke( + memoryToolNames.corpusCreate, + { name, description }, + corpusSchema, + ); + } + + public listCorpora(): Promise { + return this.invoke( + memoryToolNames.corpusList, + {}, + corpusSchema.array(), + ); + } + + public listSources(corpusId: string): Promise { + return this.invoke( + memoryToolNames.sourceList, + { corpusId }, + sourceSchema.array(), + ); + } + + public getSource( + corpusId: string, + sourceId: string, + ): Promise { + return this.invoke( + memoryToolNames.sourceGet, + { corpusId, sourceId }, + optionalSourceSchema, + ).then((source) => source ?? undefined); + } + + public ingestDocument( + request: DocumentIngestRequest, + signal?: AbortSignal, + ): Promise { + return this.invoke( + memoryToolNames.documentIngest, + request, + ingestResultSchema, + signal === undefined ? {} : { signal }, + ); + } + + public getJob(jobId: string): Promise { + return this.invoke( + memoryToolNames.jobGet, + { jobId }, + optionalJobStatusSchema, + ).then((job) => job ?? undefined); + } + + public cancelJob(jobId: string): Promise { + return this.invoke( + memoryToolNames.jobCancel, + { jobId }, + optionalJobStatusSchema, + ).then((job) => job ?? undefined); + } + + public search(request: MemorySearchRequest): Promise { + return this.invoke(memoryToolNames.search, request, searchResultSchema); + } + + public getKnowledgeGraph(corpusId: string): Promise { + return this.invoke( + memoryToolNames.knowledgeGraphGet, + { corpusId }, + knowledgeGraphSchema, + ); + } + + public getCapabilities(): Promise { + return this.invoke( + memoryToolNames.capabilities, + {}, + capabilitiesSchema, + ); + } + + public waitForJob( + jobId: string, + options: MemoryClientCallOptions & { pollIntervalMs?: number } = {}, + ): Promise { + return this.invoke( + memoryToolNames.jobWait, + { + jobId, + ...(options.pollIntervalMs === undefined + ? {} + : { pollIntervalMs: options.pollIntervalMs }), + }, + jobStatusSchema, + options, + ); + } + + public async close(): Promise { + await this.client.close(); + } + + private async invoke( + name: string, + args: object, + schema: z.ZodType, + options: MemoryClientCallOptions = {}, + ): Promise { + const result = await this.client.callTool( + { + name, + arguments: args as Record, + }, + { + ...(this.timeoutMs === undefined + ? {} + : { timeout: this.timeoutMs }), + ...(options.signal === undefined + ? {} + : { signal: options.signal }), + ...(options.onProgress === undefined + ? {} + : { + onprogress: (progress) => + options.onProgress?.({ + completed: progress.progress, + ...(progress.total === undefined + ? {} + : { total: progress.total }), + ...(progress.message === undefined + ? {} + : { message: progress.message }), + }), + }), + }, + ); + return parseToolResult(name, result, schema); + } +} + +async function waitForJob( + client: MemoryService, + jobId: string, + options: MemoryClientCallOptions & { pollIntervalMs?: number }, +): Promise { + const interval = options.pollIntervalMs ?? 250; + while (true) { + if (options.signal?.aborted) { + await client.cancelJob(jobId); + throw options.signal.reason ?? new Error("Job wait cancelled"); + } + const job = await client.getJob(jobId); + if (job === undefined) { + throw new Error(`Unknown memory job '${jobId}'`); + } + options.onProgress?.(job.progress); + if (terminalJobStates.has(job.state)) { + return job; + } + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, interval); + options.signal?.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject( + options.signal?.reason ?? + new Error("Job wait cancelled"), + ); + }, + { once: true }, + ); + }); + } +} + +function createTransport(config: MemoryMcpTransportConfig): MemoryMcpTransport { + if (config.kind === "http") { + return new StreamableHTTPClientTransport(new URL(config.url), { + ...(config.headers === undefined + ? {} + : { requestInit: { headers: config.headers } }), + ...(config.timeoutMs === undefined + ? {} + : { + fetch: (input, init) => + globalThis.fetch(input, { + ...init, + signal: + init?.signal == null + ? AbortSignal.timeout(config.timeoutMs!) + : AbortSignal.any([ + init.signal, + AbortSignal.timeout( + config.timeoutMs!, + ), + ]), + }), + }), + }); + } + const parameters: StdioServerParameters = { + command: config.command, + args: config.args, + stderr: "pipe", + ...(config.cwd === undefined ? {} : { cwd: config.cwd }), + ...(config.env === undefined + ? {} + : { env: { ...getDefaultEnvironment(), ...config.env } }), + }; + return new StdioClientTransport(parameters); +} + +function parseToolResult( + name: string, + result: CallToolResult, + schema: z.ZodType, +): T { + if (result.isError === true) { + const message = result.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .join("\n"); + throw new Error(message || `Memory tool '${name}' failed`); + } + const envelope = result.structuredContent; + const parsed = schema.safeParse( + envelope !== null && + typeof envelope === "object" && + "result" in envelope + ? envelope.result + : undefined, + ); + if (!parsed.success) { + throw new Error( + `Memory tool '${name}' returned invalid structured content: ${parsed.error.message}`, + ); + } + return parsed.data as T; +} diff --git a/ts/packages/memory/client/src/protocol.ts b/ts/packages/memory/client/src/protocol.ts new file mode 100644 index 0000000000..184c4b17f9 --- /dev/null +++ b/ts/packages/memory/client/src/protocol.ts @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; + +export const memoryToolNames = { + corpusCreate: "memory_corpus_create", + corpusList: "memory_corpus_list", + sourceList: "memory_source_list", + sourceGet: "memory_source_get", + documentIngest: "memory_document_ingest", + jobGet: "memory_job_get", + jobWait: "memory_job_wait", + jobCancel: "memory_job_cancel", + search: "memory_search", + knowledgeGraphGet: "memory_knowledge_graph_get", + capabilities: "memory_capabilities", +} as const; + +export const identifierSchema = z + .string() + .regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/); +export const sourceTypeSchema = z.enum([ + "web", + "markdown", + "text", + "html", + "vtt", +]); +export const jobStateSchema = z.enum([ + "accepted", + "validating", + "normalizing", + "chunking", + "extracting-knowledge", + "embedding", + "building-indexes", + "persisting", + "complete", + "partial", + "failed", + "cancelling", + "cancelled", +]); +export const terminalJobStates = new Set([ + "complete", + "partial", + "failed", + "cancelled", +]); + +export const corpusSchema = z.object({ + corpusId: identifierSchema, + name: z.string(), + description: z.string().optional(), + createdAt: z.string(), + updatedAt: z.string(), + status: z.enum(["ready", "indexing", "degraded", "error"]), + documentCount: z.number().int().nonnegative(), +}); + +export const revisionSchema = z.object({ + revisionId: identifierSchema, + sourceId: identifierSchema, + contentHash: z.string(), + mimeType: z.string(), + capturedAt: z.string().optional(), + sourceModifiedAt: z.string().optional(), + indexedAt: z.string().optional(), + pipelineVersion: z.string(), + embeddingIdentity: z.string().optional(), + state: z.enum(["accepted", "processing", "ready", "failed", "deleted"]), +}); + +export const sourceSchema = z.object({ + sourceId: identifierSchema, + corpusId: identifierSchema, + sourceType: sourceTypeSchema, + canonicalUri: z.string().optional(), + title: z.string(), + tags: z.array(z.string()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + activeRevisionId: identifierSchema, + revisions: z.array(revisionSchema), +}); + +export const ingestRequestSchema = z.object({ + corpusId: identifierSchema, + source: z.object({ + sourceId: identifierSchema.optional(), + sourceType: sourceTypeSchema, + title: z.string().min(1), + canonicalUri: z.string().optional(), + markdown: z.string().optional(), + text: z.string().optional(), + html: z.string().optional(), + tags: z.array(z.string()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + capturedAt: z.string().optional(), + sourceModifiedAt: z.string().optional(), + contentHash: z.string().optional(), + }), + pipeline: z + .object({ + mode: z.enum(["basic", "summary", "content", "full"]).optional(), + maxCharsPerChunk: z.number().int().positive().optional(), + updatePolicy: z + .enum([ + "skipIfUnchanged", + "replaceActiveRevision", + "retainRevisionHistory", + "failIfExists", + ]) + .optional(), + }) + .optional(), +}); + +export const ingestResultSchema = z.object({ + jobId: identifierSchema, + sourceId: identifierSchema, + revisionId: identifierSchema, + state: jobStateSchema, + statusUri: z.string(), +}); + +export const jobProgressSchema = z.object({ + completed: z.number().nonnegative(), + total: z.number().nonnegative().optional(), + message: z.string().optional(), +}); + +export const jobStatusSchema = z.object({ + jobId: identifierSchema, + corpusId: identifierSchema, + sourceId: identifierSchema, + revisionId: identifierSchema, + state: jobStateSchema, + progress: jobProgressSchema, + createdAt: z.string(), + updatedAt: z.string(), + error: z.string().optional(), + warnings: z.array(z.string()), +}); + +export const searchRequestSchema = z.object({ + corpusId: identifierSchema, + query: z.string().min(1), + limit: z.number().int().positive().max(100).optional(), + maxResponseChars: z.number().int().positive().optional(), + sourceTypes: z.array(sourceTypeSchema).optional(), + tags: z.array(z.string()).optional(), + sourceIds: z.array(identifierSchema).optional(), +}); + +export const searchResultSchema = z.object({ + query: z.string(), + matches: z.array( + z.object({ + evidenceId: z.string(), + corpusId: identifierSchema, + sourceId: identifierSchema, + revisionId: identifierSchema, + title: z.string(), + canonicalUri: z.string().optional(), + locator: z.string().optional(), + snippet: z.string(), + score: z.number(), + sourceType: sourceTypeSchema, + capturedAt: z.string().optional(), + indexedAt: z.string(), + }), + ), + warnings: z.array(z.string()), + capabilitiesUsed: z.array(z.string()), + indexVersion: z.string(), +}); + +export const knowledgeGraphSchema = z.object({ + entities: z.array( + z.object({ + name: z.string(), + types: z.array(z.string()), + mentionCount: z.number().int().nonnegative(), + sourceIds: z.array(identifierSchema), + }), + ), + topics: z.array( + z.object({ + name: z.string(), + mentionCount: z.number().int().nonnegative(), + sourceIds: z.array(identifierSchema), + }), + ), + relationships: z.array( + z.object({ + fromEntity: z.string(), + toEntity: z.string(), + relationshipType: z.string(), + count: z.number().int().nonnegative(), + sourceIds: z.array(identifierSchema), + }), + ), +}); + +export const capabilitiesSchema = z.object({ + chatProvider: z.string().optional(), + embeddingProvider: z.string().optional(), + features: z.object({ + knowledgeExtraction: z.boolean(), + queryTranslation: z.boolean(), + vectorSimilarity: z.boolean(), + structuredSearch: z.boolean(), + exactSearch: z.boolean(), + }), + warnings: z.array(z.string()), +}); + +export const optionalJobStatusSchema = jobStatusSchema.nullable(); +export const optionalSourceSchema = sourceSchema.nullable(); diff --git a/ts/packages/memory/client/src/tsconfig.json b/ts/packages/memory/client/src/tsconfig.json new file mode 100644 index 0000000000..9e4cec4781 --- /dev/null +++ b/ts/packages/memory/client/src/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist" + }, + "include": ["./**/*.ts"], + "references": [{ "path": "../../service/src" }] +} diff --git a/ts/packages/memory/client/tsconfig.json b/ts/packages/memory/client/tsconfig.json new file mode 100644 index 0000000000..d2f13411a6 --- /dev/null +++ b/ts/packages/memory/client/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "files": [], + "references": [{ "path": "./src" }] +} diff --git a/ts/packages/memory/conversation/README.AUTOGEN.md b/ts/packages/memory/conversation/README.AUTOGEN.md index dae130994c..79c60cde5b 100644 --- a/ts/packages/memory/conversation/README.AUTOGEN.md +++ b/ts/packages/memory/conversation/README.AUTOGEN.md @@ -106,7 +106,6 @@ External: `async`, `debug`, `mailparser`, `typechat`, `webvtt-parser` - [chat-example](../../../examples/chat/README.md) - [document-processor](../../../examples/docuProc/README.md) - [knowpro-test](../../../packages/knowProTest/README.md) -- [memory-mcp](../../../examples/mcpMemory/README.md) - [telemetry-query-example](../../../examples/commandHistogram/README.md) - [website-memory](../../../packages/memory/website/README.md) diff --git a/ts/packages/memory/mcp-server/LICENSE b/ts/packages/memory/mcp-server/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/ts/packages/memory/mcp-server/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/ts/packages/memory/mcp-server/README.md b/ts/packages/memory/mcp-server/README.md new file mode 100644 index 0000000000..ca81f811f3 --- /dev/null +++ b/ts/packages/memory/mcp-server/README.md @@ -0,0 +1,11 @@ +# @typeagent/memory-mcp-server + +MCP transport adapter for the TypeAgent memory service + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/ts/packages/memory/mcp-server/jest.config.cjs b/ts/packages/memory/mcp-server/jest.config.cjs new file mode 100644 index 0000000000..756ce3eb37 --- /dev/null +++ b/ts/packages/memory/mcp-server/jest.config.cjs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +module.exports = { + ...require("../../../jest.config.js"), +}; diff --git a/ts/packages/memory/mcp-server/package.json b/ts/packages/memory/mcp-server/package.json new file mode 100644 index 0000000000..682b1af968 --- /dev/null +++ b/ts/packages/memory/mcp-server/package.json @@ -0,0 +1,47 @@ +{ + "name": "@typeagent/memory-mcp-server", + "version": "0.0.1", + "description": "MCP transport adapter for the TypeAgent memory service", + "homepage": "https://github.com/microsoft/TypeAgent#readme", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/TypeAgent.git", + "directory": "ts/packages/memory/mcp-server" + }, + "license": "MIT", + "author": "Microsoft", + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "types": "./dist/index.d.ts", + "bin": { + "typeagent-memory-mcp": "./dist/server.js" + }, + "scripts": { + "build": "npm run tsc", + "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", + "jest-esm": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", + "prettier": "prettier --check . --ignore-path ../../../.prettierignore", + "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore", + "start": "node ./dist/server.js", + "test": "npm run test:local", + "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"", + "tsc": "tsc -b" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@typeagent/aiclient": "workspace:*", + "@typeagent/config": "workspace:*", + "@typeagent/memory-client": "workspace:*", + "@typeagent/memory-service": "workspace:*", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.7", + "jest": "^29.7.0", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "typescript": "~5.4.5" + } +} diff --git a/ts/packages/memory/mcp-server/src/index.ts b/ts/packages/memory/mcp-server/src/index.ts new file mode 100644 index 0000000000..9c608b4733 --- /dev/null +++ b/ts/packages/memory/mcp-server/src/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from "./memoryMcpServer.js"; +export * from "./memoryServiceHost.js"; diff --git a/ts/packages/memory/mcp-server/src/memoryMcpServer.ts b/ts/packages/memory/mcp-server/src/memoryMcpServer.ts new file mode 100644 index 0000000000..a51e2431ea --- /dev/null +++ b/ts/packages/memory/mcp-server/src/memoryMcpServer.ts @@ -0,0 +1,328 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + McpServer, + ResourceTemplate, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + capabilitiesSchema, + corpusSchema, + identifierSchema, + ingestRequestSchema, + ingestResultSchema, + jobStatusSchema, + knowledgeGraphSchema, + memoryToolNames, + optionalJobStatusSchema, + optionalSourceSchema, + searchRequestSchema, + searchResultSchema, + sourceSchema, +} from "@typeagent/memory-client"; +import type { + DocumentIngestRequest, + MemorySearchRequest, + MemoryService, +} from "@typeagent/memory-service"; +import { z } from "zod"; + +const corpusCreateInputSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), +}); +const corpusIdInputSchema = z.object({ corpusId: identifierSchema }); +const sourceGetInputSchema = z.object({ + corpusId: identifierSchema, + sourceId: identifierSchema, +}); +const jobInputSchema = z.object({ jobId: identifierSchema }); +const jobWaitInputSchema = jobInputSchema.extend({ + pollIntervalMs: z.number().int().min(25).max(5_000).optional(), +}); + +function outputSchema(schema: z.ZodType) { + return z.object({ result: schema }); +} + +function toolResult(value: unknown): CallToolResult { + return { + content: [{ type: "text", text: JSON.stringify(value, undefined, 2) }], + structuredContent: { result: value }, + }; +} + +function toolError(error: unknown): CallToolResult { + const message = error instanceof Error ? error.message : String(error); + return { + isError: true, + content: [{ type: "text", text: message }], + }; +} + +export class MemoryMcpServer { + public readonly server: McpServer; + + public constructor(private readonly service: MemoryService) { + this.server = new McpServer({ + name: "typeagent-memory", + version: "0.0.1", + }); + this.registerTools(); + this.registerResources(); + } + + public async start(transport: Transport = new StdioServerTransport()) { + await this.service.initialize?.(); + await this.server.connect(transport); + } + + public async close(): Promise { + await this.server.close(); + } + + private registerTools(): void { + this.server.registerTool( + memoryToolNames.corpusCreate, + { + description: "Create a durable memory corpus.", + inputSchema: corpusCreateInputSchema, + outputSchema: outputSchema(corpusSchema), + annotations: { destructiveHint: false }, + }, + async ({ name, description }) => + this.run(() => this.service.createCorpus(name, description)), + ); + this.server.registerTool( + memoryToolNames.corpusList, + { + description: "List available memory corpora.", + outputSchema: outputSchema(corpusSchema.array()), + annotations: { readOnlyHint: true }, + }, + async () => this.run(() => this.service.listCorpora()), + ); + this.server.registerTool( + memoryToolNames.sourceList, + { + description: "List source metadata in a memory corpus.", + inputSchema: corpusIdInputSchema, + outputSchema: outputSchema(sourceSchema.array()), + annotations: { readOnlyHint: true }, + }, + async ({ corpusId }) => + this.run(() => this.service.listSources(corpusId)), + ); + this.server.registerTool( + memoryToolNames.sourceGet, + { + description: "Get source and revision metadata.", + inputSchema: sourceGetInputSchema, + outputSchema: outputSchema(optionalSourceSchema), + annotations: { readOnlyHint: true }, + }, + async ({ corpusId, sourceId }) => + this.run( + async () => + (await this.service.getSource(corpusId, sourceId)) ?? + null, + ), + ); + this.server.registerTool( + memoryToolNames.documentIngest, + { + description: + "Submit Markdown, text, HTML, or VTT content for durable indexing.", + inputSchema: ingestRequestSchema, + outputSchema: outputSchema(ingestResultSchema), + annotations: { destructiveHint: false }, + }, + async (request, extra) => + this.run(() => + this.service.ingestDocument( + request as DocumentIngestRequest, + extra.signal, + ), + ), + ); + this.server.registerTool( + memoryToolNames.jobGet, + { + description: "Get durable ingestion job status and progress.", + inputSchema: jobInputSchema, + outputSchema: outputSchema(optionalJobStatusSchema), + annotations: { readOnlyHint: true }, + }, + async ({ jobId }) => + this.run( + async () => (await this.service.getJob(jobId)) ?? null, + ), + ); + this.server.registerTool( + memoryToolNames.jobCancel, + { + description: "Request cancellation of an ingestion job.", + inputSchema: jobInputSchema, + outputSchema: outputSchema(optionalJobStatusSchema), + annotations: { destructiveHint: true }, + }, + async ({ jobId }) => + this.run( + async () => (await this.service.cancelJob(jobId)) ?? null, + ), + ); + this.server.registerTool( + memoryToolNames.jobWait, + { + description: + "Wait for an ingestion job while reporting MCP progress.", + inputSchema: jobWaitInputSchema, + outputSchema: outputSchema(jobStatusSchema), + annotations: { readOnlyHint: true }, + }, + async ({ jobId, pollIntervalMs }, extra) => + this.run(async () => { + const interval = pollIntervalMs ?? 250; + while (true) { + if (extra.signal.aborted) { + await this.service.cancelJob(jobId); + throw ( + extra.signal.reason ?? + new Error("Job wait cancelled") + ); + } + const job = await this.service.getJob(jobId); + if (job === undefined) { + throw new Error(`Unknown memory job '${jobId}'`); + } + const progressToken = extra._meta?.progressToken; + if (progressToken !== undefined) { + await extra.sendNotification({ + method: "notifications/progress", + params: { + progressToken, + progress: job.progress.completed, + ...(job.progress.total === undefined + ? {} + : { total: job.progress.total }), + ...(job.progress.message === undefined + ? {} + : { message: job.progress.message }), + }, + }); + } + if ( + [ + "complete", + "partial", + "failed", + "cancelled", + ].includes(job.state) + ) { + return job; + } + await new Promise((resolve) => + setTimeout(resolve, interval), + ); + } + }), + ); + this.server.registerTool( + memoryToolNames.search, + { + description: + "Search a corpus and return bounded, source-linked evidence.", + inputSchema: searchRequestSchema, + outputSchema: outputSchema(searchResultSchema), + annotations: { readOnlyHint: true }, + }, + async (request) => + this.run(() => + this.service.search(request as MemorySearchRequest), + ), + ); + this.server.registerTool( + memoryToolNames.knowledgeGraphGet, + { + description: + "Get entities, topics, and relationships extracted from a durable corpus.", + inputSchema: corpusIdInputSchema, + outputSchema: outputSchema(knowledgeGraphSchema), + annotations: { readOnlyHint: true }, + }, + async ({ corpusId }) => + this.run(() => this.service.getKnowledgeGraph(corpusId)), + ); + this.server.registerTool( + memoryToolNames.capabilities, + { + description: "Report memory service capabilities and warnings.", + outputSchema: outputSchema(capabilitiesSchema), + annotations: { readOnlyHint: true }, + }, + async () => this.run(() => this.service.getCapabilities()), + ); + } + + private registerResources(): void { + this.server.registerResource( + "memory-job", + new ResourceTemplate("typeagent-memory://jobs/{jobId}", { + list: undefined, + }), + { mimeType: "application/json" }, + async (uri, variables) => { + const job = await this.service.getJob(String(variables.jobId)); + if (job === undefined) { + throw new Error(`Unknown memory job '${variables.jobId}'`); + } + return this.jsonResource(uri, job); + }, + ); + this.server.registerResource( + "memory-source", + new ResourceTemplate( + "typeagent-memory://corpora/{corpusId}/sources/{sourceId}", + { list: undefined }, + ), + { mimeType: "application/json" }, + async (uri, variables) => { + const source = await this.service.getSource( + String(variables.corpusId), + String(variables.sourceId), + ); + if (source === undefined) { + throw new Error( + `Unknown memory source '${variables.sourceId}'`, + ); + } + return this.jsonResource(uri, source); + }, + ); + } + + private async run( + operation: () => Promise, + ): Promise { + try { + return toolResult(await operation()); + } catch (error) { + return toolError(error); + } + } + + private jsonResource(uri: URL, value: unknown) { + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify(value, undefined, 2), + }, + ], + }; + } +} diff --git a/ts/packages/memory/mcp-server/src/memoryServiceHost.ts b/ts/packages/memory/mcp-server/src/memoryServiceHost.ts new file mode 100644 index 0000000000..7c6f81617a --- /dev/null +++ b/ts/packages/memory/mcp-server/src/memoryServiceHost.ts @@ -0,0 +1,299 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { + createServer, + type IncomingHttpHeaders, + type Server, + type ServerResponse, +} from "node:http"; +import { + WebStandardStreamableHTTPServerTransport, + type WebStandardStreamableHTTPServerTransportOptions, +} from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { MemoryService } from "@typeagent/memory-service"; +import { MemoryMcpServer } from "./memoryMcpServer.js"; + +export interface MemoryServiceHostOptions { + host?: string; + port?: number; + bearerToken?: string; + maxRequestBytes?: number; + onError?: (error: Error) => void; +} + +export class MemoryServiceHost { + private closePromise: Promise | undefined; + + private constructor( + private readonly httpServer: Server, + private readonly activeServers: Set, + private readonly service: MemoryService, + private readonly beginClosing: () => void, + public readonly host: string, + public readonly port: number, + public readonly bearerToken: string, + ) {} + + public get endpoint(): string { + return `http://${this.host}:${this.port}/mcp`; + } + + public get healthEndpoint(): string { + return `http://${this.host}:${this.port}/health`; + } + + public static async start( + service: MemoryService, + options: MemoryServiceHostOptions = {}, + ): Promise { + const host = options.host ?? "127.0.0.1"; + if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") { + throw new Error("The local memory service must bind to loopback"); + } + await service.initialize?.(); + const bearerToken = + options.bearerToken ?? randomBytes(32).toString("base64url"); + const activeServers = new Set(); + let closing = false; + const httpServer = createServer(async (request, response) => { + try { + const requestUrl = new URL( + request.url ?? "/", + `http://${request.headers.host ?? host}`, + ); + if (requestUrl.pathname === "/health") { + response.writeHead(200, { + "content-type": "application/json", + }); + response.end(JSON.stringify({ status: "ready" })); + return; + } + if (requestUrl.pathname !== "/mcp") { + response.writeHead(404).end(); + return; + } + if ( + !hasBearerToken(request.headers.authorization, bearerToken) + ) { + response.writeHead(401, { + "content-type": "application/json", + "www-authenticate": "Bearer", + }); + response.end(JSON.stringify({ error: "Unauthorized" })); + return; + } + const parsedBody = + request.method === "POST" + ? await readJsonBody( + request, + options.maxRequestBytes ?? 25 * 1024 * 1024, + ) + : undefined; + if (closing) { + response.writeHead(503, { + "content-type": "application/json", + }); + response.end(JSON.stringify({ error: "Shutting down" })); + return; + } + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: false, + } as unknown as WebStandardStreamableHTTPServerTransportOptions); + const mcpServer = new MemoryMcpServer(service); + activeServers.add(mcpServer); + mcpServer.server.server.onerror = (error) => + options.onError?.(error); + try { + await mcpServer.start(transport as unknown as Transport); + const webResponse = await transport.handleRequest( + new Request(requestUrl, { + method: request.method ?? "GET", + headers: toWebHeaders(request.headers), + }), + { parsedBody }, + ); + await writeWebResponse(response, webResponse); + } finally { + activeServers.delete(mcpServer); + await mcpServer.close(); + } + } catch (error) { + options.onError?.( + error instanceof Error ? error : new Error(String(error)), + ); + if (!response.headersSent) { + response.writeHead(500, { + "content-type": "application/json", + }); + } + response.end( + JSON.stringify({ + error: + error instanceof Error + ? error.message + : "Internal server error", + }), + ); + } + }); + try { + await listen(httpServer, options.port ?? 0, host); + } catch (error) { + await closeHttpServer(httpServer); + await service.close?.(); + throw error; + } + const address = httpServer.address(); + if (address === null || typeof address === "string") { + await closeHttpServer(httpServer); + await service.close?.(); + throw new Error("Memory service did not bind a TCP address"); + } + return new MemoryServiceHost( + httpServer, + activeServers, + service, + () => { + closing = true; + }, + host, + address.port, + bearerToken, + ); + } + + public close(): Promise { + this.closePromise ??= (async () => { + this.beginClosing(); + const results = await Promise.allSettled([ + closeHttpServer(this.httpServer), + ...[...this.activeServers].map((server) => server.close()), + ]); + const serviceResult = await Promise.allSettled([ + this.service.close?.() ?? Promise.resolve(), + ]); + results.push(...serviceResult); + const failure = results.find( + (result): result is PromiseRejectedResult => + result.status === "rejected", + ); + if (failure !== undefined) { + throw failure.reason; + } + })(); + return this.closePromise; + } +} + +function toWebHeaders(headers: IncomingHttpHeaders): Headers { + const result = new Headers(); + for (const [name, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + for (const item of value) { + result.append(name, item); + } + } else if (value !== undefined) { + result.set(name, value); + } + } + return result; +} + +async function writeWebResponse( + response: ServerResponse, + webResponse: Response, +): Promise { + response.writeHead( + webResponse.status, + Object.fromEntries(webResponse.headers.entries()), + ); + if (webResponse.body === null) { + response.end(); + return; + } + const reader = webResponse.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + response.end(); + return; + } + if (!response.write(Buffer.from(value))) { + await new Promise((resolve) => + response.once("drain", resolve), + ); + } + } + } finally { + reader.releaseLock(); + } +} + +async function readJsonBody( + request: NodeJS.ReadableStream, + maxBytes: number, +): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > maxBytes) { + throw new Error(`MCP request exceeds ${maxBytes} bytes`); + } + chunks.push(buffer); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function hasBearerToken( + authorization: string | undefined, + expectedToken: string, +): boolean { + const prefix = "Bearer "; + if (!authorization?.startsWith(prefix)) { + return false; + } + const supplied = Buffer.from(authorization.slice(prefix.length)); + const expected = Buffer.from(expectedToken); + return ( + supplied.length === expected.length && + timingSafeEqual(supplied, expected) + ); +} + +async function listen( + server: Server, + port: number, + host: string, +): Promise { + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, host); + }); +} + +async function closeHttpServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolve, reject) => { + server.close((error) => + error === undefined ? resolve() : reject(error), + ); + }); +} diff --git a/ts/packages/memory/mcp-server/src/server.ts b/ts/packages/memory/mcp-server/src/server.ts new file mode 100644 index 0000000000..631214be73 --- /dev/null +++ b/ts/packages/memory/mcp-server/src/server.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { initRuntimeConfigFromProcessEnv } from "@typeagent/aiclient"; +import { loadConfigSync } from "@typeagent/config"; +import { FileMemoryService } from "@typeagent/memory-service"; +import { fileURLToPath } from "node:url"; +import { MemoryMcpServer } from "./memoryMcpServer.js"; + +loadConfigSync(); +initRuntimeConfigFromProcessEnv(); + +const rootDirectory = + process.env.TYPEAGENT_MEMORY_DIR ?? + fileURLToPath(new URL("../../data", import.meta.url)); +const service = new FileMemoryService(rootDirectory); +const server = new MemoryMcpServer(service); + +const close = async () => { + await server.close(); + await service.close(); +}; + +process.once("SIGINT", () => void close()); +process.once("SIGTERM", () => void close()); + +await server.start(); diff --git a/ts/packages/memory/mcp-server/src/tsconfig.json b/ts/packages/memory/mcp-server/src/tsconfig.json new file mode 100644 index 0000000000..8db0532cd8 --- /dev/null +++ b/ts/packages/memory/mcp-server/src/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist" + }, + "include": ["./**/*.ts"], + "references": [ + { "path": "../../client/src" }, + { "path": "../../service/src" }, + { "path": "../../../aiclient/src" }, + { "path": "../../../config/src" } + ] +} diff --git a/ts/packages/memory/mcp-server/test/memoryMcpServer.spec.ts b/ts/packages/memory/mcp-server/test/memoryMcpServer.spec.ts new file mode 100644 index 0000000000..50b9cc60ce --- /dev/null +++ b/ts/packages/memory/mcp-server/test/memoryMcpServer.spec.ts @@ -0,0 +1,403 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { + McpMemoryServiceClient, + memoryToolNames, +} from "@typeagent/memory-client"; +import type { + DocumentIngestRequest, + DocumentIngestResult, + IngestionJobStatus, + MemoryCorpus, + MemoryKnowledgeGraph, + MemorySearchRequest, + MemorySearchResult, + MemoryService, + MemoryServiceCapabilities, + MemorySource, +} from "@typeagent/memory-service"; +import { MemoryMcpServer } from "../src/memoryMcpServer.js"; +import { MemoryServiceHost } from "../src/memoryServiceHost.js"; + +class PairedTransport implements Transport { + public peer?: PairedTransport; + public onclose?: () => void; + public onerror?: (error: Error) => void; + public onmessage?: (message: JSONRPCMessage) => void; + + public async start(): Promise {} + + public async send(message: JSONRPCMessage): Promise { + queueMicrotask(() => this.peer?.onmessage?.(message)); + } + + public async close(): Promise { + this.onclose?.(); + } +} + +function createTransportPair(): [PairedTransport, PairedTransport] { + const client = new PairedTransport(); + const server = new PairedTransport(); + client.peer = server; + server.peer = client; + return [client, server]; +} + +class FakeMemoryService implements MemoryService { + public readonly corpus: MemoryCorpus = { + corpusId: "corpus-1", + name: "Test corpus", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + status: "ready", + documentCount: 1, + }; + public readonly job: IngestionJobStatus = { + jobId: "job-1", + corpusId: this.corpus.corpusId, + sourceId: "source-1", + revisionId: "revision-1", + state: "complete", + progress: { completed: 1, total: 1 }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + warnings: [], + }; + public readonly source: MemorySource = { + sourceId: "source-1", + corpusId: this.corpus.corpusId, + sourceType: "markdown", + title: "Test source", + activeRevisionId: "revision-1", + revisions: [ + { + revisionId: "revision-1", + sourceId: "source-1", + contentHash: "hash", + mimeType: "text/markdown", + indexedAt: "2026-01-01T00:00:00.000Z", + pipelineVersion: "1", + state: "ready", + }, + ], + }; + + public async createCorpus( + name: string, + description?: string, + ): Promise { + return { + ...this.corpus, + name, + ...(description === undefined ? {} : { description }), + }; + } + + public async listCorpora(): Promise { + return [this.corpus]; + } + + public async listSources(): Promise { + return [this.source]; + } + + public async getSource(): Promise { + return this.source; + } + + public async ingestDocument( + _request: DocumentIngestRequest, + _signal?: AbortSignal, + ): Promise { + return { + jobId: this.job.jobId, + sourceId: this.job.sourceId, + revisionId: this.job.revisionId, + state: "accepted", + statusUri: `typeagent-memory://jobs/${this.job.jobId}`, + }; + } + + public async getJob(jobId: string) { + return jobId === this.job.jobId ? this.job : undefined; + } + + public async cancelJob(jobId: string) { + return this.getJob(jobId); + } + + public async search( + request: MemorySearchRequest, + ): Promise { + return { + query: request.query, + matches: [], + warnings: [], + capabilitiesUsed: ["structured-search"], + indexVersion: "fixture", + }; + } + + public async getKnowledgeGraph(): Promise { + return { + entities: [ + { + name: "TypeAgent", + types: ["software"], + mentionCount: 1, + sourceIds: [this.source.sourceId], + }, + ], + topics: [], + relationships: [], + }; + } + + public async getCapabilities(): Promise { + return { + features: { + knowledgeExtraction: true, + queryTranslation: true, + vectorSimilarity: true, + structuredSearch: true, + exactSearch: true, + }, + warnings: [], + }; + } +} + +class WaitingMemoryService extends FakeMemoryService { + private markStarted: (() => void) | undefined; + public readonly waitStarted = new Promise((resolve) => { + this.markStarted = resolve; + }); + + public override async getJob( + jobId: string, + ): Promise { + this.markStarted?.(); + return jobId === this.job.jobId + ? { ...this.job, state: "building-indexes" } + : undefined; + } +} + +describe("MemoryMcpServer", () => { + let server: MemoryMcpServer; + let client: Client; + + beforeEach(async () => { + const [clientTransport, serverTransport] = createTransportPair(); + server = new MemoryMcpServer(new FakeMemoryService()); + client = new Client( + { name: "memory-protocol-test", version: "0.0.1" }, + { capabilities: {} }, + ); + await Promise.all([ + server.start(serverTransport), + client.connect(clientTransport), + ]); + }); + + afterEach(async () => { + await Promise.all([client.close(), server.close()]); + }); + + test("advertises the complete memory tool surface", async () => { + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name).sort()).toEqual( + Object.values(memoryToolNames).sort(), + ); + }); + + test("returns validated structured tool results", async () => { + const response = await client.callTool({ + name: memoryToolNames.corpusCreate, + arguments: { name: "Protocol corpus" }, + }); + expect(response.isError).not.toBe(true); + expect(response.structuredContent).toEqual({ + result: expect.objectContaining({ + corpusId: "corpus-1", + name: "Protocol corpus", + }), + }); + }); + + test("rejects invalid tool arguments before service dispatch", async () => { + const response = await client.callTool({ + name: memoryToolNames.search, + arguments: { corpusId: "invalid id", query: "test" }, + }); + expect(response.isError).toBe(true); + }); + + test("reads durable job and source resources", async () => { + const job = await client.readResource({ + uri: "typeagent-memory://jobs/job-1", + }); + const source = await client.readResource({ + uri: "typeagent-memory://corpora/corpus-1/sources/source-1", + }); + const jobContent = job.contents[0]; + const sourceContent = source.contents[0]; + if (!("text" in jobContent) || !("text" in sourceContent)) { + throw new Error("Expected JSON text resources"); + } + expect(JSON.parse(jobContent.text)).toMatchObject({ + jobId: "job-1", + state: "complete", + }); + expect(JSON.parse(sourceContent.text)).toMatchObject({ + sourceId: "source-1", + activeRevisionId: "revision-1", + }); + }); +}); + +describe("MemoryServiceHost", () => { + test("accepts initialize and initialized HTTP messages", async () => { + const host = await MemoryServiceHost.start(new FakeMemoryService()); + try { + const headers = { + accept: "application/json, text/event-stream", + authorization: `Bearer ${host.bearerToken}`, + "content-type": "application/json", + }; + const initialize = await fetch(host.endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "raw-test", version: "0.0.1" }, + }, + }), + }); + expect(initialize.status).toBe(200); + await initialize.text(); + const initialized = await fetch(host.endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + }); + expect({ + status: initialized.status, + body: await initialized.text(), + }).toEqual({ status: 202, body: "" }); + const tools = await fetch(host.endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }), + }); + const toolsBody = await tools.text(); + expect({ status: tools.status, body: toolsBody }).toEqual({ + status: 200, + body: expect.stringContaining(memoryToolNames.search), + }); + } finally { + await host.close(); + } + }); + + test("serves the typed client over authenticated Streamable HTTP", async () => { + const errors: Error[] = []; + const host = await MemoryServiceHost.start(new FakeMemoryService(), { + onError: (error) => errors.push(error), + }); + let client: McpMemoryServiceClient; + try { + client = await McpMemoryServiceClient.create({ + kind: "http", + url: host.endpoint, + headers: { authorization: `Bearer ${host.bearerToken}` }, + }); + } catch (error) { + await host.close(); + const httpError = error as { + status?: number; + statusText?: string; + text?: string; + }; + throw new Error( + `${error instanceof Error ? error.message : String(error)}; status=${httpError.status} ${httpError.statusText}; body=${httpError.text}; host errors: ${errors.map((item) => item.stack ?? item.message).join("\n")}`, + ); + } + try { + const progress: number[] = []; + expect(await client.listCorpora()).toEqual([ + expect.objectContaining({ corpusId: "corpus-1" }), + ]); + expect( + await client.waitForJob("job-1", { + onProgress: (update) => progress.push(update.completed), + }), + ).toMatchObject({ jobId: "job-1", state: "complete" }); + expect(progress).toEqual([1]); + expect(await client.getKnowledgeGraph("corpus-1")).toEqual({ + entities: [ + { + name: "TypeAgent", + types: ["software"], + mentionCount: 1, + sourceIds: ["source-1"], + }, + ], + topics: [], + relationships: [], + }); + expect(await (await fetch(host.healthEndpoint)).json()).toEqual({ + status: "ready", + }); + expect(errors).toEqual([]); + } finally { + await client.close(); + await host.close(); + await host.close(); + } + }); + + test("rejects unauthenticated MCP requests", async () => { + const host = await MemoryServiceHost.start(new FakeMemoryService()); + try { + const response = await fetch(host.endpoint, { method: "POST" }); + expect(response.status).toBe(401); + } finally { + await host.close(); + } + }); + + test("closes while an HTTP job wait is active", async () => { + const service = new WaitingMemoryService(); + const host = await MemoryServiceHost.start(service); + const client = await McpMemoryServiceClient.create({ + kind: "http", + url: host.endpoint, + headers: { authorization: `Bearer ${host.bearerToken}` }, + }); + const wait = client.waitForJob("job-1").catch(() => undefined); + await service.waitStarted; + + await expect(host.close()).resolves.toBeUndefined(); + await wait; + await client.close().catch(() => undefined); + }); +}); diff --git a/ts/packages/memory/mcp-server/test/tsconfig.json b/ts/packages/memory/mcp-server/test/tsconfig.json new file mode 100644 index 0000000000..5ddd0cdba5 --- /dev/null +++ b/ts/packages/memory/mcp-server/test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node", "jest"] + }, + "include": ["./**/*"], + "references": [ + { "path": "../src" }, + { "path": "../../service/src" }, + { "path": "../../client/src" } + ] +} diff --git a/ts/packages/memory/mcp-server/tsconfig.json b/ts/packages/memory/mcp-server/tsconfig.json new file mode 100644 index 0000000000..ef60afb43c --- /dev/null +++ b/ts/packages/memory/mcp-server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "files": [], + "references": [{ "path": "./src" }, { "path": "./test" }] +} diff --git a/ts/packages/memory/service/LICENSE b/ts/packages/memory/service/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/ts/packages/memory/service/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/ts/packages/memory/service/README.md b/ts/packages/memory/service/README.md new file mode 100644 index 0000000000..a14095c0c2 --- /dev/null +++ b/ts/packages/memory/service/README.md @@ -0,0 +1,11 @@ +# @typeagent/memory-service + +Transport-independent memory corpus service + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/ts/packages/memory/service/jest.config.cjs b/ts/packages/memory/service/jest.config.cjs new file mode 100644 index 0000000000..756ce3eb37 --- /dev/null +++ b/ts/packages/memory/service/jest.config.cjs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +module.exports = { + ...require("../../../jest.config.js"), +}; diff --git a/ts/packages/memory/service/package.json b/ts/packages/memory/service/package.json new file mode 100644 index 0000000000..09569c5ac6 --- /dev/null +++ b/ts/packages/memory/service/package.json @@ -0,0 +1,41 @@ +{ + "name": "@typeagent/memory-service", + "version": "0.0.1", + "description": "Transport-independent memory corpus service", + "homepage": "https://github.com/microsoft/TypeAgent#readme", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/TypeAgent.git", + "directory": "ts/packages/memory/service" + }, + "license": "MIT", + "author": "Microsoft", + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "types": "./dist/index.d.ts", + "scripts": { + "build": "npm run tsc", + "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", + "jest-esm": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", + "prettier": "prettier --check . --ignore-path ../../../.prettierignore", + "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore", + "test": "npm run test:local", + "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"", + "tsc": "tsc -b" + }, + "dependencies": { + "@typeagent/conversation-memory": "workspace:*", + "@typeagent/knowpro": "workspace:*", + "proper-lockfile": "^4.1.2" + }, + "devDependencies": { + "@types/jest": "^29.5.7", + "@types/proper-lockfile": "^4.1.4", + "jest": "^29.7.0", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "typescript": "~5.4.5" + } +} diff --git a/ts/packages/memory/service/src/fileMemoryService.ts b/ts/packages/memory/service/src/fileMemoryService.ts new file mode 100644 index 0000000000..339ab1a1b8 --- /dev/null +++ b/ts/packages/memory/service/src/fileMemoryService.ts @@ -0,0 +1,769 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import lockfile from "proper-lockfile"; +import { createKnowProCorpusIndex } from "./knowProCorpusIndex.js"; +import type { + CorpusIndex, + CorpusIndexFactory, + DocumentIngestRequest, + DocumentIngestResult, + IndexedDocument, + IngestionJobStatus, + JobProgress, + JobState, + MemoryCorpus, + MemoryEvidence, + MemoryKnowledgeGraph, + MemorySearchRequest, + MemorySearchResult, + MemoryService, + MemoryServiceCapabilities, + MemorySource, + SourceDocument, + SourceRevision, +} from "./types.js"; + +const manifestFileName = "manifest.json"; +const jobsDirectoryName = "jobs"; +const indexDirectoryName = "index"; +const pipelineVersion = "1"; + +interface StoredRevision extends SourceRevision { + content: string; +} + +interface StoredSource extends SourceDocument { + revisions: StoredRevision[]; +} + +interface CorpusManifest { + corpus: MemoryCorpus; + sources: StoredSource[]; + indexGeneration?: string; +} + +interface CorpusRuntime { + manifest: CorpusManifest; + index: CorpusIndex; + writeTail: Promise; +} + +export interface FileMemoryServiceOptions { + indexFactory?: CorpusIndexFactory; + capabilities?: MemoryServiceCapabilities; +} + +function now(): string { + return new Date().toISOString(); +} + +function contentFor(request: DocumentIngestRequest): string { + const { source } = request; + const candidates = [source.markdown, source.text, source.html].filter( + (value): value is string => value !== undefined, + ); + if (candidates.length !== 1 || candidates[0].trim().length === 0) { + throw new Error( + "Exactly one non-empty markdown, text, or html value is required", + ); + } + if ( + source.sourceType === "markdown" || + source.sourceType === "web" || + source.sourceType === "vtt" + ) { + if (source.markdown === undefined && source.text === undefined) { + throw new Error( + `Source type '${source.sourceType}' requires markdown or text content`, + ); + } + } else if (source.sourceType === "html" && source.html === undefined) { + throw new Error("HTML sources require html content"); + } + return candidates[0]; +} + +function hashContent(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function validateIdentifier(kind: string, value: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(value)) { + throw new Error(`Invalid ${kind} '${value}'`); + } +} + +async function readJson(filePath: string): Promise { + try { + return JSON.parse(await readFile(filePath, "utf8")) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } +} + +async function writeJsonAtomic( + filePath: string, + value: unknown, +): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${randomUUID()}.tmp`; + const backupPath = `${filePath}.${randomUUID()}.bak`; + await writeFile(temporaryPath, `${JSON.stringify(value, undefined, 2)}\n`); + let hasBackup = false; + try { + await rename(filePath, backupPath); + hasBackup = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + await rm(temporaryPath, { force: true }); + throw error; + } + } + try { + await rename(temporaryPath, filePath); + if (hasBackup) { + await rm(backupPath, { force: true }); + } + } catch (error) { + await rm(temporaryPath, { force: true }); + if (hasBackup) { + await rename(backupPath, filePath); + } + throw error; + } +} + +function defaultCapabilities(): MemoryServiceCapabilities { + return { + features: { + knowledgeExtraction: true, + queryTranslation: true, + vectorSimilarity: true, + structuredSearch: true, + exactSearch: true, + }, + warnings: [], + }; +} + +export class FileMemoryService implements MemoryService { + private readonly indexFactory: CorpusIndexFactory; + private readonly capabilities: MemoryServiceCapabilities; + private readonly corpora = new Map(); + private readonly jobs = new Map(); + private readonly controllers = new Map(); + private initializePromise: Promise | undefined; + private releaseLock: (() => Promise) | undefined; + private rootWriteTail: Promise = Promise.resolve(); + private closed = false; + + public constructor( + private readonly rootDirectory: string, + options: FileMemoryServiceOptions = {}, + ) { + this.indexFactory = options.indexFactory ?? createKnowProCorpusIndex; + this.capabilities = options.capabilities ?? defaultCapabilities(); + } + + public initialize(): Promise { + if (this.closed) { + return Promise.reject(new Error("Memory service is closed")); + } + this.initializePromise ??= this.acquireStorageLock(); + return this.initializePromise; + } + + public async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + await this.initializePromise?.catch(() => undefined); + for (const controller of this.controllers.values()) { + controller.abort(new Error("Memory service is closing")); + } + await Promise.allSettled([ + this.rootWriteTail, + ...[...this.corpora.values()].map((runtime) => runtime.writeTail), + ]); + await this.releaseLock?.(); + this.releaseLock = undefined; + } + + public async createCorpus( + name: string, + description?: string, + ): Promise { + await this.initialize(); + const normalizedName = name.trim(); + if (normalizedName.length === 0) { + throw new Error("Corpus name cannot be empty"); + } + return this.enqueueRootWrite(async () => { + const existing = (await this.listCorpora()).find( + (corpus) => corpus.name === normalizedName, + ); + if (existing !== undefined) { + return structuredClone(existing); + } + const corpusId = randomUUID(); + const timestamp = now(); + const corpus: MemoryCorpus = { + corpusId, + name: normalizedName, + ...(description === undefined ? {} : { description }), + createdAt: timestamp, + updatedAt: timestamp, + status: "ready", + documentCount: 0, + }; + const manifest: CorpusManifest = { corpus, sources: [] }; + await writeJsonAtomic(this.manifestPath(corpusId), manifest); + const index = this.createIndex(corpusId); + this.corpora.set(corpusId, { + manifest, + index, + writeTail: Promise.resolve(), + }); + return structuredClone(corpus); + }); + } + + public async listCorpora(): Promise { + await this.initialize(); + await mkdir(this.rootDirectory, { recursive: true }); + const entries = await import("node:fs/promises").then((fs) => + fs.readdir(this.rootDirectory, { withFileTypes: true }), + ); + const corpora: MemoryCorpus[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const manifest = await readJson( + this.manifestPath(entry.name), + ); + if (manifest !== undefined) { + corpora.push(manifest.corpus); + } + } + return corpora.sort((left, right) => + left.name.localeCompare(right.name), + ); + } + + public async listSources(corpusId: string): Promise { + await this.initialize(); + validateIdentifier("corpus ID", corpusId); + const runtime = await this.getCorpus(corpusId); + return runtime.manifest.sources.map((source) => + this.toMemorySource(source), + ); + } + + public async getSource( + corpusId: string, + sourceId: string, + ): Promise { + await this.initialize(); + validateIdentifier("corpus ID", corpusId); + validateIdentifier("source ID", sourceId); + const runtime = await this.getCorpus(corpusId); + const source = runtime.manifest.sources.find( + (item) => item.sourceId === sourceId, + ); + return source === undefined ? undefined : this.toMemorySource(source); + } + + public async ingestDocument( + request: DocumentIngestRequest, + signal?: AbortSignal, + ): Promise { + await this.initialize(); + validateIdentifier("corpus ID", request.corpusId); + const content = contentFor(request); + const contentHash = request.source.contentHash ?? hashContent(content); + if (contentHash !== hashContent(content)) { + throw new Error("The supplied content hash does not match content"); + } + const sourceId = request.source.sourceId ?? randomUUID(); + validateIdentifier("source ID", sourceId); + const revisionId = contentHash; + const jobId = randomUUID(); + const timestamp = now(); + const job: IngestionJobStatus = { + jobId, + corpusId: request.corpusId, + sourceId, + revisionId, + state: "accepted", + progress: { completed: 0, total: 1, message: "Accepted" }, + createdAt: timestamp, + updatedAt: timestamp, + warnings: [], + }; + await this.saveJob(job); + const controller = new AbortController(); + this.controllers.set(jobId, controller); + signal?.addEventListener( + "abort", + () => controller.abort(signal.reason), + { + once: true, + }, + ); + void this.enqueueWrite(request.corpusId, async () => { + await this.processIngestion( + request, + content, + contentHash, + sourceId, + revisionId, + job, + controller.signal, + ); + }); + return { + jobId, + sourceId, + revisionId, + state: "accepted", + statusUri: `typeagent-memory://jobs/${jobId}`, + }; + } + + public async getJob( + jobId: string, + ): Promise { + await this.initialize(); + validateIdentifier("job ID", jobId); + const job = + this.jobs.get(jobId) ?? + (await readJson(this.jobPath(jobId))); + return job === undefined ? undefined : structuredClone(job); + } + + public async cancelJob( + jobId: string, + ): Promise { + await this.initialize(); + const job = await this.getJob(jobId); + if (job === undefined) { + return undefined; + } + if (["complete", "failed", "cancelled"].includes(job.state)) { + return job; + } + await this.updateJob(job, "cancelling", { + ...job.progress, + message: "Cancellation requested", + }); + this.controllers.get(jobId)?.abort(new Error("Ingestion cancelled")); + return structuredClone(job); + } + + public async search( + request: MemorySearchRequest, + ): Promise { + await this.initialize(); + validateIdentifier("corpus ID", request.corpusId); + const query = request.query.trim(); + if (query.length === 0) { + throw new Error("Search query cannot be empty"); + } + const runtime = await this.getCorpus(request.corpusId); + await runtime.index.initialize(); + const limit = Math.max(1, Math.min(request.limit ?? 10, 100)); + const candidates = await runtime.index.search(query, limit * 4); + const sourceIds = + request.sourceIds === undefined + ? undefined + : new Set(request.sourceIds); + const sourceTypes = + request.sourceTypes === undefined + ? undefined + : new Set(request.sourceTypes); + const requestedTags = request.tags ?? []; + let usedCharacters = 0; + const maxCharacters = request.maxResponseChars ?? 50_000; + const matches: MemoryEvidence[] = []; + for (const candidate of candidates) { + const source = runtime.manifest.sources.find( + (item) => item.sourceId === candidate.sourceId, + ); + const revision = source?.revisions.find( + (item) => item.revisionId === candidate.revisionId, + ); + if ( + source === undefined || + revision === undefined || + source.activeRevisionId !== revision.revisionId || + (sourceIds !== undefined && !sourceIds.has(source.sourceId)) || + (sourceTypes !== undefined && + !sourceTypes.has(source.sourceType)) || + requestedTags.some((tag) => !source.tags?.includes(tag)) + ) { + continue; + } + if (usedCharacters + candidate.snippet.length > maxCharacters) { + break; + } + usedCharacters += candidate.snippet.length; + matches.push({ + evidenceId: `${candidate.sourceId}:${candidate.revisionId}:${candidate.locator ?? matches.length}`, + corpusId: request.corpusId, + sourceId: candidate.sourceId, + revisionId: candidate.revisionId, + title: source.title, + ...(source.canonicalUri === undefined + ? {} + : { canonicalUri: source.canonicalUri }), + ...(candidate.locator === undefined + ? {} + : { locator: candidate.locator }), + snippet: candidate.snippet, + score: candidate.score, + sourceType: source.sourceType, + ...(revision.capturedAt === undefined + ? {} + : { capturedAt: revision.capturedAt }), + indexedAt: + revision.indexedAt ?? revision.sourceModifiedAt ?? now(), + }); + if (matches.length === limit) { + break; + } + } + return { + query, + matches, + warnings: [...this.capabilities.warnings], + capabilitiesUsed: ["structured-search"], + indexVersion: hashContent( + runtime.manifest.sources + .map((source) => source.activeRevisionId) + .sort() + .join("\n"), + ), + }; + } + + public async getCapabilities(): Promise { + await this.initialize(); + return structuredClone(this.capabilities); + } + + public async getKnowledgeGraph( + corpusId: string, + ): Promise { + const runtime = await this.getCorpus(corpusId); + return runtime.index.getKnowledgeGraph(); + } + + private async acquireStorageLock(): Promise { + await mkdir(this.rootDirectory, { recursive: true }); + this.releaseLock = await lockfile.lock(this.rootDirectory, { + realpath: false, + retries: 0, + stale: 10_000, + }); + } + + private enqueueRootWrite(operation: () => Promise): Promise { + const result = this.rootWriteTail.then(operation, operation); + this.rootWriteTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async processIngestion( + request: DocumentIngestRequest, + content: string, + contentHash: string, + sourceId: string, + revisionId: string, + job: IngestionJobStatus, + signal: AbortSignal, + ): Promise { + let candidateIndexDirectory: string | undefined; + try { + await this.updateJob(job, "validating", { + completed: 0, + total: 1, + message: "Validating source", + }); + this.throwIfAborted(signal); + const runtime = await this.getCorpus(request.corpusId); + const existing = runtime.manifest.sources.find( + (source) => source.sourceId === sourceId, + ); + const policy = request.pipeline?.updatePolicy ?? "skipIfUnchanged"; + if (existing?.activeRevisionId === revisionId) { + if (policy === "failIfExists") { + throw new Error(`Source '${sourceId}' already exists`); + } + await this.updateJob(job, "complete", { + completed: 1, + total: 1, + message: "Source is unchanged", + }); + return; + } + if (existing !== undefined && policy === "failIfExists") { + throw new Error(`Source '${sourceId}' already exists`); + } + const timestamp = now(); + const revision: StoredRevision = { + revisionId, + sourceId, + contentHash, + mimeType: this.mimeType(request.source.sourceType), + ...(request.source.capturedAt === undefined + ? {} + : { capturedAt: request.source.capturedAt }), + ...(request.source.sourceModifiedAt === undefined + ? {} + : { sourceModifiedAt: request.source.sourceModifiedAt }), + pipelineVersion, + state: "processing", + content, + }; + const source: StoredSource = { + sourceId, + corpusId: request.corpusId, + sourceType: request.source.sourceType, + ...(request.source.canonicalUri === undefined + ? {} + : { canonicalUri: request.source.canonicalUri }), + title: request.source.title, + ...(request.source.tags === undefined + ? {} + : { tags: request.source.tags }), + ...(request.source.metadata === undefined + ? {} + : { metadata: request.source.metadata }), + activeRevisionId: revisionId, + revisions: + existing === undefined + ? [revision] + : policy === "retainRevisionHistory" + ? [...existing.revisions, revision] + : [revision], + }; + const candidateManifest = structuredClone(runtime.manifest); + candidateManifest.sources = [ + ...candidateManifest.sources.filter( + (item) => item.sourceId !== sourceId, + ), + source, + ]; + candidateManifest.corpus.status = "indexing"; + candidateManifest.corpus.updatedAt = timestamp; + candidateManifest.corpus.documentCount = + candidateManifest.sources.length; + const indexGeneration = randomUUID(); + candidateManifest.indexGeneration = indexGeneration; + candidateIndexDirectory = this.indexDirectory( + request.corpusId, + indexGeneration, + ); + const candidateIndex = this.indexFactory( + request.corpusId, + candidateIndexDirectory, + ); + await this.updateJob(job, "building-indexes", { + completed: 0, + message: "Building corpus indexes", + }); + const documents = this.activeDocuments(candidateManifest); + await candidateIndex.rebuild( + documents, + signal, + async (progress) => { + await this.updateJob(job, "building-indexes", progress); + }, + ); + this.throwIfAborted(signal); + revision.state = "ready"; + revision.indexedAt = now(); + candidateManifest.corpus.status = "ready"; + await this.updateJob(job, "persisting", { + completed: 1, + total: 1, + message: "Persisting corpus metadata", + }); + await writeJsonAtomic( + this.manifestPath(request.corpusId), + candidateManifest, + ); + runtime.manifest = candidateManifest; + runtime.index = candidateIndex; + candidateIndexDirectory = undefined; + await this.updateJob(job, "complete", { + completed: 1, + total: 1, + message: "Ingestion complete", + }); + } catch (error) { + if (candidateIndexDirectory !== undefined) { + await rm(candidateIndexDirectory, { + recursive: true, + force: true, + }); + } + const cancelled = signal.aborted; + await this.updateJob( + job, + cancelled ? "cancelled" : "failed", + { + ...job.progress, + message: cancelled + ? "Ingestion cancelled" + : "Ingestion failed", + }, + error instanceof Error ? error.message : String(error), + ); + } finally { + this.controllers.delete(job.jobId); + } + } + + private async enqueueWrite( + corpusId: string, + operation: () => Promise, + ): Promise { + const runtime = await this.getCorpus(corpusId); + const queued = runtime.writeTail.then(operation, operation); + runtime.writeTail = queued.then( + () => undefined, + () => undefined, + ); + return queued; + } + + private async getCorpus(corpusId: string): Promise { + const cached = this.corpora.get(corpusId); + if (cached !== undefined) { + return cached; + } + const manifest = await readJson( + this.manifestPath(corpusId), + ); + if (manifest === undefined) { + throw new Error(`Unknown corpus '${corpusId}'`); + } + const runtime: CorpusRuntime = { + manifest, + index: this.createIndex(corpusId, manifest.indexGeneration), + writeTail: Promise.resolve(), + }; + this.corpora.set(corpusId, runtime); + return runtime; + } + + private createIndex( + corpusId: string, + indexGeneration?: string, + ): CorpusIndex { + return this.indexFactory( + corpusId, + this.indexDirectory(corpusId, indexGeneration), + ); + } + + private indexDirectory(corpusId: string, indexGeneration?: string): string { + return path.join( + this.rootDirectory, + corpusId, + indexDirectoryName, + ...(indexGeneration === undefined ? [] : [indexGeneration]), + ); + } + + private activeDocuments(manifest: CorpusManifest): IndexedDocument[] { + return manifest.sources.map((source) => { + const revision = source.revisions.find( + (item) => item.revisionId === source.activeRevisionId, + ); + if (revision === undefined) { + throw new Error( + `Source '${source.sourceId}' has no active revision`, + ); + } + return { source, revision, content: revision.content }; + }); + } + + private toMemorySource(source: StoredSource): MemorySource { + const { revisions, ...document } = source; + return { + ...structuredClone(document), + revisions: revisions.map(({ content: _content, ...revision }) => + structuredClone(revision), + ), + }; + } + + private async saveJob(job: IngestionJobStatus): Promise { + this.jobs.set(job.jobId, job); + await writeJsonAtomic(this.jobPath(job.jobId), job); + } + + private async updateJob( + job: IngestionJobStatus, + state: JobState, + progress: JobProgress, + error?: string, + ): Promise { + job.state = state; + job.progress = progress; + job.updatedAt = now(); + if (error !== undefined) { + job.error = error; + } + await this.saveJob(job); + } + + private manifestPath(corpusId: string): string { + return path.join(this.rootDirectory, corpusId, manifestFileName); + } + + private jobPath(jobId: string): string { + return path.join( + this.rootDirectory, + jobsDirectoryName, + `${jobId}.json`, + ); + } + + private mimeType( + sourceType: DocumentIngestRequest["source"]["sourceType"], + ): string { + switch (sourceType) { + case "html": + return "text/html"; + case "markdown": + case "web": + return "text/markdown"; + case "vtt": + return "text/vtt"; + case "text": + return "text/plain"; + } + } + + private throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw signal.reason ?? new Error("Ingestion cancelled"); + } + } +} diff --git a/ts/packages/memory/service/src/index.ts b/ts/packages/memory/service/src/index.ts new file mode 100644 index 0000000000..459139025b --- /dev/null +++ b/ts/packages/memory/service/src/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from "./types.js"; +export * from "./fileMemoryService.js"; +export * from "./knowProCorpusIndex.js"; diff --git a/ts/packages/memory/service/src/knowProCorpusIndex.ts b/ts/packages/memory/service/src/knowProCorpusIndex.ts new file mode 100644 index 0000000000..dc945e7275 --- /dev/null +++ b/ts/packages/memory/service/src/knowProCorpusIndex.ts @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + DocMemory, + DocPart, + docPartsFromHtml, + docPartsFromMarkdown, + docPartsFromText, + docPartsFromVtt, +} from "@typeagent/conversation-memory"; +import * as kp from "@typeagent/knowpro"; +import type { + CorpusIndex, + CorpusIndexMatch, + IndexedDocument, + JobProgress, + MemoryKnowledgeGraph, +} from "./types.js"; + +const indexBaseName = "corpus"; + +interface EntityKnowledge { + name: string; + type: string[]; +} + +interface TopicKnowledge { + text: string; +} + +interface ActionKnowledge { + verbs: string[]; + subjectEntityName: string; + objectEntityName: string; +} + +function sourceUri(document: IndexedDocument): string { + return `typeagent-memory://sources/${encodeURIComponent(document.source.sourceId)}/revisions/${encodeURIComponent(document.revision.revisionId)}`; +} + +function toDocParts(document: IndexedDocument): DocPart[] { + const uri = sourceUri(document); + switch (document.source.sourceType) { + case "html": + return docPartsFromHtml(document.content, false, 8_000, uri); + case "markdown": + case "web": + return docPartsFromMarkdown(document.content, 8_000, uri); + case "vtt": + return docPartsFromVtt(document.content, uri); + case "text": + return docPartsFromText(document.content, 8_000, uri); + } +} + +function parseSourceUri( + uri: string | undefined, +): { sourceId: string; revisionId: string } | undefined { + if (uri === undefined) { + return undefined; + } + const match = + /^typeagent-memory:\/\/sources\/([^/]+)\/revisions\/(.+)$/.exec(uri); + if (match === null) { + return undefined; + } + return { + sourceId: decodeURIComponent(match[1]), + revisionId: decodeURIComponent(match[2]), + }; +} + +export class KnowProCorpusIndex implements CorpusIndex { + private memory: DocMemory | undefined; + + public constructor( + private readonly corpusId: string, + private readonly indexDirectory: string, + ) {} + + public async initialize(): Promise { + this.memory = await DocMemory.readFromFile( + this.indexDirectory, + indexBaseName, + ); + } + + public async rebuild( + documents: IndexedDocument[], + signal: AbortSignal, + onProgress: (progress: JobProgress) => Promise, + ): Promise { + const parts = documents.flatMap(toDocParts); + const memory = new DocMemory(this.corpusId, parts); + let completed = 0; + let progressTail = Promise.resolve(); + const total = Math.max(parts.length, 1); + const report = (message: string): boolean => { + if (signal.aborted) { + return false; + } + completed = Math.min(completed + 1, total); + const progress = { completed, total, message }; + progressTail = progressTail.then(() => onProgress(progress)); + return true; + }; + const result = await memory.buildIndex({ + onKnowledgeExtracted: () => report("Extracting knowledge"), + onEmbeddingsCreated: () => report("Creating embeddings"), + onTextIndexed: () => report("Indexing text"), + }); + if (signal.aborted) { + throw signal.reason ?? new Error("Ingestion cancelled"); + } + const indexingError = + result.semanticRefs?.error ?? + result.secondaryIndexResults?.message?.error ?? + result.secondaryIndexResults?.relatedTerms?.error; + if (indexingError !== undefined) { + throw new Error(indexingError); + } + await progressTail; + await memory.writeToFile(this.indexDirectory, indexBaseName); + this.memory = memory; + await onProgress({ + completed: total, + total, + message: "Index persisted", + }); + } + + public async search( + query: string, + limit: number, + ): Promise { + if (this.memory === undefined) { + throw new Error(`Corpus '${this.corpusId}' has not been indexed`); + } + const options = kp.createLanguageSearchOptionsTypical(); + options.maxMessageMatches = limit; + options.maxKnowledgeMatches = limit; + const result = await this.memory.searchWithLanguage(query, options); + if (!result.success) { + throw new Error(result.message); + } + const matches = new Map(); + for (const searchResult of result.data) { + for (const match of searchResult.messageMatches) { + const previous = matches.get(match.messageOrdinal); + if (previous === undefined || match.score > previous) { + matches.set(match.messageOrdinal, match.score); + } + } + } + return [...matches] + .sort((left, right) => right[1] - left[1]) + .slice(0, limit) + .flatMap(([messageOrdinal, score]) => { + const message = this.memory?.messages.get(messageOrdinal); + const source = parseSourceUri(message?.metadata.sourceUrl); + if (message === undefined || source === undefined) { + return []; + } + return [ + { + ...source, + snippet: message.textChunks.join("\n"), + score, + locator: `message:${messageOrdinal}`, + }, + ]; + }); + } + + public async getKnowledgeGraph(): Promise { + if (this.memory === undefined) { + throw new Error(`Corpus '${this.corpusId}' has not been indexed`); + } + const entities = new Map< + string, + { + name: string; + types: Set; + mentionCount: number; + sourceIds: Set; + } + >(); + const topics = new Map< + string, + { + name: string; + mentionCount: number; + sourceIds: Set; + } + >(); + const relationships = new Map< + string, + { + fromEntity: string; + toEntity: string; + relationshipType: string; + count: number; + sourceIds: Set; + } + >(); + + for (const semanticRef of this.memory.semanticRefs ?? []) { + const message = this.memory.messages.get( + semanticRef.range.start.messageOrdinal, + ); + const sourceId = parseSourceUri( + message?.metadata.sourceUrl, + )?.sourceId; + if (semanticRef.knowledgeType === "entity") { + const entity = semanticRef.knowledge as EntityKnowledge; + const key = entity.name.trim().toLocaleLowerCase(); + if (key.length === 0) { + continue; + } + const aggregate = entities.get(key) ?? { + name: entity.name.trim(), + types: new Set(), + mentionCount: 0, + sourceIds: new Set(), + }; + aggregate.mentionCount++; + entity.type.forEach((type) => aggregate.types.add(type)); + if (sourceId !== undefined) { + aggregate.sourceIds.add(sourceId); + } + entities.set(key, aggregate); + } else if (semanticRef.knowledgeType === "topic") { + const name = ( + semanticRef.knowledge as TopicKnowledge + ).text.trim(); + const key = name.toLocaleLowerCase(); + if (key.length === 0) { + continue; + } + const aggregate = topics.get(key) ?? { + name, + mentionCount: 0, + sourceIds: new Set(), + }; + aggregate.mentionCount++; + if (sourceId !== undefined) { + aggregate.sourceIds.add(sourceId); + } + topics.set(key, aggregate); + } else if (semanticRef.knowledgeType === "action") { + const action = semanticRef.knowledge as ActionKnowledge; + const fromEntity = action.subjectEntityName.trim(); + const toEntity = action.objectEntityName.trim(); + if ( + fromEntity.toLocaleLowerCase() === "none" || + toEntity.toLocaleLowerCase() === "none" + ) { + continue; + } + const relationshipType = action.verbs.join(" ").trim(); + const key = `${fromEntity.toLocaleLowerCase()}\0${toEntity.toLocaleLowerCase()}\0${relationshipType.toLocaleLowerCase()}`; + const aggregate = relationships.get(key) ?? { + fromEntity, + toEntity, + relationshipType, + count: 0, + sourceIds: new Set(), + }; + aggregate.count++; + if (sourceId !== undefined) { + aggregate.sourceIds.add(sourceId); + } + relationships.set(key, aggregate); + } + } + + return { + entities: [...entities.values()].map((entity) => ({ + ...entity, + types: [...entity.types], + sourceIds: [...entity.sourceIds], + })), + topics: [...topics.values()].map((topic) => ({ + ...topic, + sourceIds: [...topic.sourceIds], + })), + relationships: [...relationships.values()].map((relationship) => ({ + ...relationship, + sourceIds: [...relationship.sourceIds], + })), + }; + } +} + +export function createKnowProCorpusIndex( + corpusId: string, + indexDirectory: string, +): CorpusIndex { + return new KnowProCorpusIndex(corpusId, indexDirectory); +} diff --git a/ts/packages/memory/service/src/tsconfig.json b/ts/packages/memory/service/src/tsconfig.json new file mode 100644 index 0000000000..6b9b46cb1a --- /dev/null +++ b/ts/packages/memory/service/src/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist" + }, + "include": ["./**/*.ts"], + "references": [ + { "path": "../../conversation/src" }, + { "path": "../../../knowPro/src" } + ] +} diff --git a/ts/packages/memory/service/src/types.ts b/ts/packages/memory/service/src/types.ts new file mode 100644 index 0000000000..12f77f35e1 --- /dev/null +++ b/ts/packages/memory/service/src/types.ts @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type CorpusState = "ready" | "indexing" | "degraded" | "error"; + +export type SourceType = "web" | "markdown" | "text" | "html" | "vtt"; + +export type IngestionMode = "basic" | "summary" | "content" | "full"; + +export type UpdatePolicy = + | "skipIfUnchanged" + | "replaceActiveRevision" + | "retainRevisionHistory" + | "failIfExists"; + +export type JobState = + | "accepted" + | "validating" + | "normalizing" + | "chunking" + | "extracting-knowledge" + | "embedding" + | "building-indexes" + | "persisting" + | "complete" + | "partial" + | "failed" + | "cancelling" + | "cancelled"; + +export interface MemoryCorpus { + corpusId: string; + name: string; + description?: string; + createdAt: string; + updatedAt: string; + status: CorpusState; + documentCount: number; +} + +export interface SourceDocument { + sourceId: string; + corpusId: string; + sourceType: SourceType; + canonicalUri?: string; + title: string; + tags?: string[]; + metadata?: Record; + activeRevisionId: string; +} + +export interface SourceRevision { + revisionId: string; + sourceId: string; + contentHash: string; + mimeType: string; + capturedAt?: string; + sourceModifiedAt?: string; + indexedAt?: string; + pipelineVersion: string; + embeddingIdentity?: string; + state: "accepted" | "processing" | "ready" | "failed" | "deleted"; +} + +export interface MemorySource extends SourceDocument { + revisions: SourceRevision[]; +} + +export interface IngestionSource { + sourceId?: string; + sourceType: SourceType; + title: string; + canonicalUri?: string; + markdown?: string; + text?: string; + html?: string; + tags?: string[]; + metadata?: Record; + capturedAt?: string; + sourceModifiedAt?: string; + contentHash?: string; +} + +export interface DocumentIngestRequest { + corpusId: string; + source: IngestionSource; + pipeline?: { + mode?: IngestionMode; + maxCharsPerChunk?: number; + updatePolicy?: UpdatePolicy; + }; +} + +export interface DocumentIngestResult { + jobId: string; + sourceId: string; + revisionId: string; + state: JobState; + statusUri: string; +} + +export interface JobProgress { + completed: number; + total?: number; + message?: string; +} + +export interface IngestionJobStatus { + jobId: string; + corpusId: string; + sourceId: string; + revisionId: string; + state: JobState; + progress: JobProgress; + createdAt: string; + updatedAt: string; + error?: string; + warnings: string[]; +} + +export interface MemorySearchRequest { + corpusId: string; + query: string; + limit?: number; + maxResponseChars?: number; + sourceTypes?: SourceType[]; + tags?: string[]; + sourceIds?: string[]; +} + +export interface MemoryEvidence { + evidenceId: string; + corpusId: string; + sourceId: string; + revisionId: string; + title: string; + canonicalUri?: string; + locator?: string; + snippet: string; + score: number; + sourceType: SourceType; + capturedAt?: string; + indexedAt: string; +} + +export interface MemorySearchResult { + query: string; + matches: MemoryEvidence[]; + warnings: string[]; + capabilitiesUsed: string[]; + indexVersion: string; +} + +export interface MemoryGraphEntity { + name: string; + types: string[]; + mentionCount: number; + sourceIds: string[]; +} + +export interface MemoryGraphTopic { + name: string; + mentionCount: number; + sourceIds: string[]; +} + +export interface MemoryGraphRelationship { + fromEntity: string; + toEntity: string; + relationshipType: string; + count: number; + sourceIds: string[]; +} + +export interface MemoryKnowledgeGraph { + entities: MemoryGraphEntity[]; + topics: MemoryGraphTopic[]; + relationships: MemoryGraphRelationship[]; +} + +export interface MemoryServiceCapabilities { + chatProvider?: string; + embeddingProvider?: string; + features: { + knowledgeExtraction: boolean; + queryTranslation: boolean; + vectorSimilarity: boolean; + structuredSearch: boolean; + exactSearch: boolean; + }; + warnings: string[]; +} + +export interface MemoryService { + initialize?(): Promise; + close?(): Promise; + createCorpus(name: string, description?: string): Promise; + listCorpora(): Promise; + listSources(corpusId: string): Promise; + getSource( + corpusId: string, + sourceId: string, + ): Promise; + ingestDocument( + request: DocumentIngestRequest, + signal?: AbortSignal, + ): Promise; + getJob(jobId: string): Promise; + cancelJob(jobId: string): Promise; + search(request: MemorySearchRequest): Promise; + getKnowledgeGraph(corpusId: string): Promise; + getCapabilities(): Promise; +} + +export interface IndexedDocument { + source: SourceDocument; + revision: SourceRevision; + content: string; +} + +export interface CorpusIndexMatch { + sourceId: string; + revisionId: string; + snippet: string; + score: number; + locator?: string; +} + +export interface CorpusIndex { + initialize(): Promise; + rebuild( + documents: IndexedDocument[], + signal: AbortSignal, + onProgress: (progress: JobProgress) => Promise, + ): Promise; + search(query: string, limit: number): Promise; + getKnowledgeGraph(): Promise; +} + +export type CorpusIndexFactory = ( + corpusId: string, + indexDirectory: string, +) => CorpusIndex; diff --git a/ts/packages/memory/service/test/fileMemoryService.spec.ts b/ts/packages/memory/service/test/fileMemoryService.spec.ts new file mode 100644 index 0000000000..1d5000bf66 --- /dev/null +++ b/ts/packages/memory/service/test/fileMemoryService.spec.ts @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { FileMemoryService } from "../src/fileMemoryService.js"; +import type { + CorpusIndex, + CorpusIndexMatch, + IndexedDocument, + IngestionJobStatus, + JobProgress, + MemoryKnowledgeGraph, +} from "../src/types.js"; + +class FakeCorpusIndex implements CorpusIndex { + public documents: IndexedDocument[] = []; + public failNextRebuild = false; + public blockNextRebuild = false; + public graph: MemoryKnowledgeGraph = { + entities: [], + topics: [], + relationships: [], + }; + + public async initialize(): Promise {} + + public async rebuild( + documents: IndexedDocument[], + signal: AbortSignal, + onProgress: (progress: JobProgress) => Promise, + ): Promise { + if (this.failNextRebuild) { + this.failNextRebuild = false; + throw new Error("Expected index failure"); + } + if (this.blockNextRebuild) { + this.blockNextRebuild = false; + await new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + } + await onProgress({ + completed: documents.length, + total: documents.length, + message: "Fake index complete", + }); + this.documents = structuredClone(documents); + } + + public async search( + query: string, + limit: number, + ): Promise { + return this.documents + .filter((document) => + document.content.toLowerCase().includes(query.toLowerCase()), + ) + .slice(0, limit) + .map((document, index) => ({ + sourceId: document.source.sourceId, + revisionId: document.revision.revisionId, + snippet: document.content, + score: 1 - index / 10, + locator: "fixture:1", + })); + } + + public async getKnowledgeGraph(): Promise { + return structuredClone(this.graph); + } +} + +async function waitForTerminalJob( + service: FileMemoryService, + jobId: string, +): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const job = await service.getJob(jobId); + if ( + job !== undefined && + ["complete", "failed", "cancelled"].includes(job.state) + ) { + return job; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Job '${jobId}' did not finish`); +} + +describe("FileMemoryService", () => { + let rootDirectory: string; + let index: FakeCorpusIndex; + let service: FileMemoryService; + + beforeEach(async () => { + rootDirectory = await mkdtemp( + path.join(os.tmpdir(), "typeagent-memory-service-"), + ); + index = new FakeCorpusIndex(); + service = new FileMemoryService(rootDirectory, { + indexFactory: () => index, + }); + }); + + afterEach(async () => { + await service.close(); + await rm(rootDirectory, { recursive: true, force: true }); + }); + + test("locks the storage root until the service closes", async () => { + await service.initialize(); + const competingService = new FileMemoryService(rootDirectory, { + indexFactory: () => new FakeCorpusIndex(), + }); + + await expect(competingService.initialize()).rejects.toThrow(); + await competingService.close(); + await service.close(); + + const replacementService = new FileMemoryService(rootDirectory, { + indexFactory: () => new FakeCorpusIndex(), + }); + await expect(replacementService.initialize()).resolves.toBeUndefined(); + await replacementService.close(); + }); + + test("releases a lock acquired concurrently with close", async () => { + const racingService = new FileMemoryService(rootDirectory, { + indexFactory: () => new FakeCorpusIndex(), + }); + + await Promise.all([racingService.initialize(), racingService.close()]); + + const replacementService = new FileMemoryService(rootDirectory, { + indexFactory: () => new FakeCorpusIndex(), + }); + await expect(replacementService.initialize()).resolves.toBeUndefined(); + await replacementService.close(); + }); + + test("creates a named corpus idempotently under concurrency", async () => { + const [first, second] = await Promise.all([ + service.createCorpus("Browser"), + service.createCorpus("Browser"), + ]); + + expect(second.corpusId).toBe(first.corpusId); + expect(await service.listCorpora()).toEqual([first]); + }); + + test("publishes an ingested document as source-linked evidence", async () => { + const corpus = await service.createCorpus("Engineering"); + const accepted = await service.ingestDocument({ + corpusId: corpus.corpusId, + source: { + sourceId: "design-doc", + sourceType: "markdown", + title: "Design", + canonicalUri: "https://example.test/design", + markdown: "# Design\n\nUse a supervised memory sidecar.", + tags: ["architecture"], + }, + }); + + const job = await waitForTerminalJob(service, accepted.jobId); + expect(job.state).toBe("complete"); + + const result = await service.search({ + corpusId: corpus.corpusId, + query: "sidecar", + }); + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + sourceId: "design-doc", + title: "Design", + canonicalUri: "https://example.test/design", + sourceType: "markdown", + }); + const source = await service.getSource(corpus.corpusId, "design-doc"); + expect(source?.revisions).toHaveLength(1); + expect(source).not.toHaveProperty("revisions.0.content"); + }); + + test("treats an unchanged source as an idempotent import", async () => { + const corpus = await service.createCorpus("Engineering"); + const request = { + corpusId: corpus.corpusId, + source: { + sourceId: "runbook", + sourceType: "markdown" as const, + title: "Runbook", + markdown: "Restart the service after configuration changes.", + }, + }; + const first = await service.ingestDocument(request); + expect((await waitForTerminalJob(service, first.jobId)).state).toBe( + "complete", + ); + const second = await service.ingestDocument(request); + const secondJob = await waitForTerminalJob(service, second.jobId); + + expect(secondJob.state).toBe("complete"); + expect(secondJob.progress.message).toBe("Source is unchanged"); + expect(index.documents).toHaveLength(1); + }); + + test("applies source and tag filters", async () => { + const corpus = await service.createCorpus("Engineering"); + for (const source of [ + { + sourceId: "public-doc", + title: "Public", + tags: ["public"], + }, + { + sourceId: "private-doc", + title: "Private", + tags: ["private"], + }, + ]) { + const accepted = await service.ingestDocument({ + corpusId: corpus.corpusId, + source: { + ...source, + sourceType: "markdown", + markdown: "Shared authentication guidance.", + }, + }); + await waitForTerminalJob(service, accepted.jobId); + } + + const result = await service.search({ + corpusId: corpus.corpusId, + query: "authentication", + tags: ["private"], + }); + expect(result.matches.map((match) => match.sourceId)).toEqual([ + "private-doc", + ]); + }); + + test("returns the knowledge graph from the durable corpus index", async () => { + const corpus = await service.createCorpus("Engineering"); + index.graph.entities.push({ + name: "TypeAgent", + types: ["software"], + mentionCount: 2, + sourceIds: ["design-doc"], + }); + + await expect( + service.getKnowledgeGraph(corpus.corpusId), + ).resolves.toEqual(index.graph); + }); + + test("keeps the prior committed revision when rebuilding fails", async () => { + const corpus = await service.createCorpus("Engineering"); + const first = await service.ingestDocument({ + corpusId: corpus.corpusId, + source: { + sourceId: "design-doc", + sourceType: "markdown", + title: "Design", + markdown: "The stable design uses queues.", + }, + }); + await waitForTerminalJob(service, first.jobId); + + index.failNextRebuild = true; + const failed = await service.ingestDocument({ + corpusId: corpus.corpusId, + source: { + sourceId: "design-doc", + sourceType: "markdown", + title: "Design", + markdown: "This unpublished revision uses streams.", + }, + }); + expect((await waitForTerminalJob(service, failed.jobId)).state).toBe( + "failed", + ); + + const stable = await service.search({ + corpusId: corpus.corpusId, + query: "queues", + }); + const unpublished = await service.search({ + corpusId: corpus.corpusId, + query: "streams", + }); + expect(stable.matches).toHaveLength(1); + expect(unpublished.matches).toHaveLength(0); + }); + + test("cancels an active index rebuild", async () => { + const corpus = await service.createCorpus("Engineering"); + index.blockNextRebuild = true; + const accepted = await service.ingestDocument({ + corpusId: corpus.corpusId, + source: { + sourceId: "large-doc", + sourceType: "markdown", + title: "Large document", + markdown: "A long-running import.", + }, + }); + + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const job = await service.getJob(accepted.jobId); + if (job?.state === "building-indexes") { + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + await service.cancelJob(accepted.jobId); + + const job = await waitForTerminalJob(service, accepted.jobId); + expect(job.state).toBe("cancelled"); + expect(index.documents).toHaveLength(0); + }); +}); diff --git a/ts/packages/memory/service/test/tsconfig.json b/ts/packages/memory/service/test/tsconfig.json new file mode 100644 index 0000000000..effb9cf309 --- /dev/null +++ b/ts/packages/memory/service/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node", "jest"] + }, + "include": ["./**/*"], + "references": [{ "path": "../src" }] +} diff --git a/ts/examples/mcpMemory/tsconfig.json b/ts/packages/memory/service/tsconfig.json similarity index 50% rename from ts/examples/mcpMemory/tsconfig.json rename to ts/packages/memory/service/tsconfig.json index b6e1577e45..94dfc60bb1 100644 --- a/ts/examples/mcpMemory/tsconfig.json +++ b/ts/packages/memory/service/tsconfig.json @@ -1,10 +1,10 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../../tsconfig.base.json", "compilerOptions": { "composite": true }, "include": [], - "references": [{ "path": "./src" }], + "references": [{ "path": "./src" }, { "path": "./test" }], "ts-node": { "esm": true } diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 6117a9eb00..f06fc047b3 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -497,49 +497,6 @@ importers: specifier: ~5.4.5 version: 5.4.5 - examples/mcpMemory: - dependencies: - '@modelcontextprotocol/sdk': - specifier: 1.26.0 - version: 1.26.0(supports-color@8.1.1)(zod@4.1.13) - '@typeagent/agent-runtime': - specifier: workspace:* - version: link:../../packages/typeagent - '@typeagent/config': - specifier: workspace:* - version: link:../../packages/config - '@typeagent/conversation-memory': - specifier: workspace:* - version: link:../../packages/memory/conversation - '@typeagent/knowpro': - specifier: workspace:* - version: link:../../packages/knowPro - dotenv: - specifier: ^16.3.1 - version: 16.5.0 - examples-lib: - specifier: workspace:* - version: link:../examplesLib - interactive-app: - specifier: workspace:* - version: link:../../packages/interactiveApp - zod: - specifier: ^4.1.13 - version: 4.1.13 - devDependencies: - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 - prettier: - specifier: ^3.2.5 - version: 3.5.3 - rimraf: - specifier: ^5.0.5 - version: 5.0.10 - typescript: - specifier: ~5.4.5 - version: 5.4.5 - examples/memoryProviders: dependencies: '@elastic/elasticsearch': @@ -1790,6 +1747,15 @@ importers: '@typeagent/dispatcher-types': specifier: workspace:* version: link:../../dispatcher/types + '@typeagent/memory-client': + specifier: workspace:* + version: link:../../memory/client + '@typeagent/memory-mcp-server': + specifier: workspace:* + version: link:../../memory/mcp-server + '@typeagent/memory-service': + specifier: workspace:* + version: link:../../memory/service '@typeagent/telemetry': specifier: workspace:* version: link:../../telemetry @@ -2005,6 +1971,12 @@ importers: '@typeagent/knowpro': specifier: workspace:* version: link:../../knowPro + '@typeagent/memory-client': + specifier: workspace:* + version: link:../../memory/client + '@typeagent/memory-service': + specifier: workspace:* + version: link:../../memory/service '@typeagent/taskflow-typeagent': specifier: workspace:* version: link:../taskflow @@ -4549,7 +4521,7 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: 1.26.0 - version: 1.26.0(supports-color@8.1.1)(zod@4.1.13) + version: 1.26.0(zod@4.1.13) '@typeagent/agent-sdk': specifier: workspace:* version: link:../agentSdk @@ -4976,7 +4948,7 @@ importers: version: 1.0.13 '@modelcontextprotocol/sdk': specifier: 1.26.0 - version: 1.26.0(supports-color@8.1.1)(zod@4.1.13) + version: 1.26.0(zod@4.1.13) '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 @@ -5583,6 +5555,28 @@ importers: specifier: ~5.4.5 version: 5.4.5 + packages/memory/client: + dependencies: + '@modelcontextprotocol/client': + specifier: ^2.0.0 + version: 2.0.0 + '@typeagent/memory-service': + specifier: workspace:* + version: link:../service + zod: + specifier: ^4.1.13 + version: 4.4.3 + devDependencies: + prettier: + specifier: ^3.5.3 + version: 3.5.3 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + typescript: + specifier: ~5.4.5 + version: 5.4.5 + packages/memory/conversation: dependencies: '@typeagent/agent-runtime': @@ -5723,6 +5717,74 @@ importers: specifier: ~5.4.5 version: 5.4.5 + packages/memory/mcp-server: + dependencies: + '@modelcontextprotocol/sdk': + specifier: 1.26.0 + version: 1.26.0(zod@4.4.3) + '@typeagent/aiclient': + specifier: workspace:* + version: link:../../aiclient + '@typeagent/config': + specifier: workspace:* + version: link:../../config + '@typeagent/memory-client': + specifier: workspace:* + version: link:../client + '@typeagent/memory-service': + specifier: workspace:* + version: link:../service + zod: + specifier: ^4.1.13 + version: 4.4.3 + devDependencies: + '@types/jest': + specifier: ^29.5.7 + version: 29.5.14 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@26.4.1)(ts-node@10.9.2(@types/node@26.4.1)(typescript@5.4.5)) + prettier: + specifier: ^3.5.3 + version: 3.5.3 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + typescript: + specifier: ~5.4.5 + version: 5.4.5 + + packages/memory/service: + dependencies: + '@typeagent/conversation-memory': + specifier: workspace:* + version: link:../conversation + '@typeagent/knowpro': + specifier: workspace:* + version: link:../../knowPro + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 + devDependencies: + '@types/jest': + specifier: ^29.5.7 + version: 29.5.14 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@26.4.1)(ts-node@10.9.2(@types/node@26.4.1)(typescript@5.4.5)) + prettier: + specifier: ^3.5.3 + version: 3.5.3 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + typescript: + specifier: ~5.4.5 + version: 5.4.5 + packages/memory/storage: dependencies: '@azure/search-documents': @@ -14738,10 +14800,6 @@ packages: resolution: {integrity: sha1-W+DO7WfKecbEvFzw1+6EPc6hEMQ=} engines: {node: '>=10.13.0'} - ip-address@10.3.1: - resolution: {integrity: sha1-kp+WKdFyT34bdIXOiXUvNnUzahA=} - engines: {node: '>= 12'} - ip-address@10.7.0: resolution: {integrity: sha1-lxNCnxZ4ft6PZC9iVbQftRXIJdk=} engines: {node: '>= 12'} @@ -15268,9 +15326,6 @@ packages: jose@5.10.0: resolution: {integrity: sha1-w3NGoJnWRnxAE1GpoMIWHg9SxL4=} - jose@6.2.3: - resolution: {integrity: sha1-CXUZetlzJRIhxlijzdxLlRolDC0=} - jose@6.2.8: resolution: {integrity: sha1-OcFFn+XqyE6zmxYjuAd9z5ymxQY=} @@ -19300,7 +19355,7 @@ snapshots: '@anthropic-ai/claude-agent-sdk@0.3.162(@anthropic-ai/sdk@0.93.0(zod@4.1.13))(@modelcontextprotocol/sdk@1.26.0(zod@4.1.13))(zod@4.1.13)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.1.13) - '@modelcontextprotocol/sdk': 1.26.0(supports-color@8.1.1)(zod@4.1.13) + '@modelcontextprotocol/sdk': 1.26.0(zod@4.1.13) zod: 4.1.13 optionalDependencies: '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.162 @@ -23100,7 +23155,7 @@ snapshots: dependencies: zod: 4.4.3 - '@modelcontextprotocol/sdk@1.26.0(supports-color@8.1.1)(zod@4.1.13)': + '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.5) ajv: 8.20.0 @@ -23110,19 +23165,19 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1(supports-color@8.1.1) + express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) hono: 4.13.5 - jose: 6.2.3 + jose: 6.2.8 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.1.13 - zod-to-json-schema: 3.25.2(zod@4.1.13) + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.26.0(zod@4.1.13)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.5) ajv: 8.20.0 @@ -23132,15 +23187,15 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1(supports-color@8.1.1) + express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) hono: 4.13.5 - jose: 6.2.3 + jose: 6.2.8 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod: 4.1.13 + zod-to-json-schema: 3.25.2(zod@4.1.13) transitivePeerDependencies: - supports-color @@ -23154,10 +23209,10 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1(supports-color@8.1.1) + express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) hono: 4.13.5 - jose: 6.2.3 + jose: 6.2.8 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -23176,10 +23231,10 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1(supports-color@8.1.1) + express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) hono: 4.13.5 - jose: 6.2.3 + jose: 6.2.8 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -23190,7 +23245,7 @@ snapshots: '@modelcontextprotocol/server-filesystem@2026.1.14(zod@4.1.13)': dependencies: - '@modelcontextprotocol/sdk': 1.26.0(supports-color@8.1.1)(zod@4.1.13) + '@modelcontextprotocol/sdk': 1.26.0(zod@4.1.13) diff: 5.2.2 glob: 10.5.0 minimatch: 10.2.5 @@ -26463,7 +26518,7 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.3.0(supports-color@8.1.1): + body-parser@2.3.0: dependencies: bytes: 3.1.2 content-type: 2.0.0 @@ -28518,8 +28573,8 @@ snapshots: express-rate-limit@8.5.2(express@5.2.1): dependencies: - express: 5.2.1(supports-color@8.1.1) - ip-address: 10.3.1 + express: 5.2.1 + ip-address: 10.7.0 express@4.22.1: dependencies: @@ -28593,10 +28648,10 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.2.1(supports-color@8.1.1): + express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.3.0(supports-color@8.1.1) + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -28606,7 +28661,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1(supports-color@8.1.1) + finalhandler: 2.1.1 fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -28617,8 +28672,8 @@ snapshots: proxy-addr: 2.0.7 qs: 6.16.0 range-parser: 1.3.0 - router: 2.2.0(supports-color@8.1.1) - send: 1.2.1(supports-color@8.1.1) + router: 2.2.0 + send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 type-is: 2.1.0 @@ -28757,7 +28812,7 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.1(supports-color@8.1.1): + finalhandler@2.1.1: dependencies: debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 @@ -29635,8 +29690,6 @@ snapshots: interpret@3.1.1: {} - ip-address@10.3.1: {} - ip-address@10.7.0: {} ip-regex@4.3.0: {} @@ -30669,8 +30722,6 @@ snapshots: jose@5.10.0: {} - jose@6.2.3: {} - jose@6.2.8: {} js-stringify@1.0.2: {} @@ -33563,7 +33614,7 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - router@2.2.0(supports-color@8.1.1): + router@2.2.0: dependencies: debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 @@ -33760,7 +33811,7 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.2.1(supports-color@8.1.1): + send@1.2.1: dependencies: debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 @@ -33819,7 +33870,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1(supports-color@8.1.1) + send: 1.2.1 transitivePeerDependencies: - supports-color