From 248273c6c59e654cd1fc8f32007dd684e374e37f Mon Sep 17 00:00:00 2001 From: Hillary Mutisya <150286414+hillary-mutisya@users.noreply.github.com> Date: Mon, 21 Sep 2026 01:06:06 -0700 Subject: [PATCH 1/3] Use durable browser memory Migrates browser knowledge/search/indexing from the legacy website collection to the durable memory service, adding RPC-safe service facades, corpus clearing, KnowPro-backed indexing, and memory-backed graph/analytics queries. Also improves extension import progress routing and offscreen download cancellation/retry handling. --- ts/packages/agentRpc/src/client.ts | 10 +- .../agentRpc/test/actionContext.spec.ts | 69 + .../import_1789977182556_rngoatppp.json | 13 + .../.import-states/website-1789954951090.json | 13 + .../.import-states/website-1789963444009.json | 13 + .../.import-states/website-1789972889887.json | 13 + .../.import-states/website-1789973347252.json | 13 + ts/packages/agentServer/server/src/server.ts | 29 +- ts/packages/agents/browser/package.json | 3 +- .../src/agent/agentServiceHandlers.mts | 29 +- .../src/agent/browserActionHandler.mts | 196 +- .../browser/src/agent/browserActions.mts | 12 +- .../src/agent/browserMemoryService.mts | 90 +- .../browser/src/agent/durableWebSearch.mts | 390 + .../agent/indexing/browserIndexingService.ts | 463 - .../browser/src/agent/indexing/index.mts | 4 +- .../knowledge/actions/analyticsActions.mts | 1441 +- .../knowledge/actions/extractionActions.mts | 239 +- .../agent/knowledge/actions/graphActions.mts | 1561 +- .../knowledge/actions/indexingActions.mts | 69 +- .../actions/knowledgeActionRouter.mts | 8 +- .../agent/knowledge/actions/queryActions.mts | 231 +- .../knowledge/extractKnowledgeCommand.mts | 47 +- .../src/agent/knowledge/knowledgeHandler.mts | 154 - .../knowledge/knowledgeHandler.mts.backup | 3408 -- .../agent/knowledge/types/knowledgeTypes.mts | 8 - .../agents/browser/src/agent/manifest.json | 6 - .../agent/search/answerEnhancementAdapter.mts | 110 - .../src/agent/search/answerGenerator.mts | 178 - .../src/agent/search/queryAnalyzer.mts | 183 - .../agent/search/queryEnhancementAdapter.mts | 313 - .../agent/search/schema/answerEnhancement.mts | 96 - .../src/agent/search/schema/queryAnalysis.mts | 98 - .../src/agent/search/utils/contextBuilder.mts | 162 - .../src/agent/search/utils/metadataRanker.mts | 188 - .../src/agent/search/websiteSearchPrompts.mts | 22 - .../browser/src/agent/searchWebMemories.mts | 2581 -- .../browser/src/agent/websiteMemory.mts | 354 +- .../browser/test/browserInitOptions.test.ts | 33 +- .../browser/test/browserMemoryService.test.ts | 49 +- .../test/search/queryEnhancement.test.ts | 115 - .../browserControlRpc/src/browserControl.ts | 46 + .../interfaces/websiteImport.types.ts | 7 +- .../extension/offscreen/contentProcessor.ts | 40 +- .../src/extension/offscreen/types.ts | 3 +- .../serviceWorker/chromeRpcServer.ts | 6 +- .../serviceWorker/contentDownloader.ts | 165 +- .../serviceWorker/messageHandlers.ts | 70 +- .../src/extension/views/chromeRpcClient.ts | 6 +- .../extension/views/extensionServiceBase.ts | 2 +- .../src/extension/views/knowledgeLibrary.html | 2 +- .../src/extension/views/knowledgeUtilities.ts | 23 +- .../extension/views/websiteImportManager.ts | 1 + .../serviceWorker/chromeRpcRouting.test.ts | 72 + .../serviceWorker/contentDownloader.test.ts | 199 + .../messageHandlers.search.test.ts | 88 +- ts/packages/memory/client/src/memoryClient.ts | 53 +- ts/packages/memory/client/src/protocol.ts | 2 + .../memory/mcp-server/src/memoryMcpServer.ts | 13 + .../mcp-server/test/memoryMcpServer.spec.ts | 4 + ts/packages/memory/service/package.json | 3 +- .../memory/service/src/fileMemoryService.ts | 70 +- ts/packages/memory/service/src/index.ts | 1 + .../memory/service/src/knowProCorpusIndex.ts | 12 +- ts/packages/memory/service/src/rpcFacade.ts | 73 + ts/packages/memory/service/src/types.ts | 1 + .../service/test/fileMemoryService.spec.ts | 93 +- .../website/src/extraction/batchProcessor.ts | 69 +- .../memory/website/src/importWebsites.ts | 4 +- .../website/test/batchProcessor.spec.ts | 48 + ts/packages/shell/src/main/instance.ts | 7 +- ts/packages/textPro/src/html.ts | 2 +- ...el, 1st Earl of Lovelace - Wikipedia.mhtml | 14432 +++++++++ .../ada-lovelace.html | 26845 ++++++++++++++++ .../run-acceptance.mts | 321 + 75 files changed, 44434 insertions(+), 11363 deletions(-) create mode 100644 ts/packages/agentServer/server/.import-states/import_1789977182556_rngoatppp.json create mode 100644 ts/packages/agentServer/server/.import-states/website-1789954951090.json create mode 100644 ts/packages/agentServer/server/.import-states/website-1789963444009.json create mode 100644 ts/packages/agentServer/server/.import-states/website-1789972889887.json create mode 100644 ts/packages/agentServer/server/.import-states/website-1789973347252.json create mode 100644 ts/packages/agents/browser/src/agent/durableWebSearch.mts delete mode 100644 ts/packages/agents/browser/src/agent/indexing/browserIndexingService.ts delete mode 100644 ts/packages/agents/browser/src/agent/knowledge/knowledgeHandler.mts delete mode 100644 ts/packages/agents/browser/src/agent/knowledge/knowledgeHandler.mts.backup delete mode 100644 ts/packages/agents/browser/src/agent/search/answerEnhancementAdapter.mts delete mode 100644 ts/packages/agents/browser/src/agent/search/answerGenerator.mts delete mode 100644 ts/packages/agents/browser/src/agent/search/queryAnalyzer.mts delete mode 100644 ts/packages/agents/browser/src/agent/search/queryEnhancementAdapter.mts delete mode 100644 ts/packages/agents/browser/src/agent/search/schema/answerEnhancement.mts delete mode 100644 ts/packages/agents/browser/src/agent/search/schema/queryAnalysis.mts delete mode 100644 ts/packages/agents/browser/src/agent/search/utils/contextBuilder.mts delete mode 100644 ts/packages/agents/browser/src/agent/search/utils/metadataRanker.mts delete mode 100644 ts/packages/agents/browser/src/agent/search/websiteSearchPrompts.mts delete mode 100644 ts/packages/agents/browser/src/agent/searchWebMemories.mts delete mode 100644 ts/packages/agents/browser/test/search/queryEnhancement.test.ts create mode 100644 ts/packages/agents/browserExtension/test/serviceWorker/chromeRpcRouting.test.ts create mode 100644 ts/packages/memory/service/src/rpcFacade.ts create mode 100644 ts/packages/memory/website/test/batchProcessor.spec.ts create mode 100644 ts/tmp/browser-memory-acceptance/1/William King-Noel, 1st Earl of Lovelace - Wikipedia.mhtml create mode 100644 ts/tmp/browser-memory-acceptance/ada-lovelace.html create mode 100644 ts/tmp/browser-memory-acceptance/run-acceptance.mts diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index de26956080..6781115ab3 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -206,10 +206,18 @@ function createOptionsRpc( fn = options[name]; } else { const funcName = names.pop(); - thisObject = getObjectProperty(options, name); + thisObject = getObjectProperty( + options, + names.join("."), + ); fn = thisObject[funcName!]; } } + if (typeof fn !== "function") { + throw new Error( + `Options callback '${name}' for object ${param.id} is not a function`, + ); + } return fn.call(thisObject, ...param.args); }, }, diff --git a/ts/packages/agentRpc/test/actionContext.spec.ts b/ts/packages/agentRpc/test/actionContext.spec.ts index bd22ef8389..a139501b0c 100644 --- a/ts/packages/agentRpc/test/actionContext.spec.ts +++ b/ts/packages/agentRpc/test/actionContext.spec.ts @@ -18,6 +18,75 @@ import { } from "@typeagent/agent-sdk/helpers/action"; describe("agent action context RPC", () => { + test("invokes nested initialization option methods with their receiver", async () => { + let clientProvider: ChannelProviderAdapter; + let serverProvider: ChannelProviderAdapter; + clientProvider = createChannelProviderAdapter( + "options-client", + (message, callback) => { + queueMicrotask(() => + serverProvider.notifyMessage( + JSON.parse(JSON.stringify(message)), + ), + ); + callback?.(null); + }, + ); + serverProvider = createChannelProviderAdapter( + "options-server", + (message, callback) => { + queueMicrotask(() => + clientProvider.notifyMessage( + JSON.parse(JSON.stringify(message)), + ), + ); + callback?.(null); + }, + ); + + let result: string | undefined; + const serverAgent: AppAgent = { + initializeAgentContext: async (settings) => { + const options = settings?.options as { + service: { + prefix: string; + getValue(value: string): string; + }; + }; + result = await options.service.getValue("value"); + return {}; + }, + }; + const server = createAgentRpcServer( + "nested-options", + serverAgent, + serverProvider, + ); + const client = await createAgentRpcClient( + "nested-options", + clientProvider, + server.agentInterface, + ); + + try { + await client.initializeAgentContext?.({ + options: { + service: { + prefix: "nested", + getValue(value: string) { + return `${this.prefix}:${value}`; + }, + }, + }, + }); + expect(result).toBe("nested:value"); + } finally { + server.closeFn(); + clientProvider.notifyDisconnected(); + serverProvider.notifyDisconnected(); + } + }); + test("cancels a real SDK choice over agent RPC without invoking its callback", async () => { let clientProvider: ChannelProviderAdapter; let serverProvider: ChannelProviderAdapter; diff --git a/ts/packages/agentServer/server/.import-states/import_1789977182556_rngoatppp.json b/ts/packages/agentServer/server/.import-states/import_1789977182556_rngoatppp.json new file mode 100644 index 0000000000..77a7a66693 --- /dev/null +++ b/ts/packages/agentServer/server/.import-states/import_1789977182556_rngoatppp.json @@ -0,0 +1,13 @@ +{ + "importId": "import_1789977182556_rngoatppp", + "totalWebsites": 10, + "processedWebsites": 3, + "lastSavePoint": 3, + "failedUrls": [], + "startTime": 1789977182611, + "lastProgressTime": 1789977799152, + "extractionMode": "content", + "source": "chrome", + "type": "bookmarks", + "filePath": "C:\\Users\\hillarym\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Bookmarks" +} \ No newline at end of file diff --git a/ts/packages/agentServer/server/.import-states/website-1789954951090.json b/ts/packages/agentServer/server/.import-states/website-1789954951090.json new file mode 100644 index 0000000000..4412819076 --- /dev/null +++ b/ts/packages/agentServer/server/.import-states/website-1789954951090.json @@ -0,0 +1,13 @@ +{ + "importId": "website-1789954951090", + "totalWebsites": 100, + "processedWebsites": 0, + "lastSavePoint": 0, + "failedUrls": [], + "startTime": 1789956179010, + "lastProgressTime": 1789956179010, + "extractionMode": "content", + "source": "chrome", + "type": "bookmarks", + "filePath": "C:\\Users\\hillarym\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Bookmarks" +} diff --git a/ts/packages/agentServer/server/.import-states/website-1789963444009.json b/ts/packages/agentServer/server/.import-states/website-1789963444009.json new file mode 100644 index 0000000000..090aab1adb --- /dev/null +++ b/ts/packages/agentServer/server/.import-states/website-1789963444009.json @@ -0,0 +1,13 @@ +{ + "importId": "website-1789963444009", + "totalWebsites": 10, + "processedWebsites": 0, + "lastSavePoint": 0, + "failedUrls": [], + "startTime": 1789963564797, + "lastProgressTime": 1789963564797, + "extractionMode": "content", + "source": "chrome", + "type": "bookmarks", + "filePath": "C:\\Users\\hillarym\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Bookmarks" +} diff --git a/ts/packages/agentServer/server/.import-states/website-1789972889887.json b/ts/packages/agentServer/server/.import-states/website-1789972889887.json new file mode 100644 index 0000000000..8abd841142 --- /dev/null +++ b/ts/packages/agentServer/server/.import-states/website-1789972889887.json @@ -0,0 +1,13 @@ +{ + "importId": "website-1789972889887", + "totalWebsites": 5, + "processedWebsites": 0, + "lastSavePoint": 0, + "failedUrls": [], + "startTime": 1789972988601, + "lastProgressTime": 1789972988601, + "extractionMode": "content", + "source": "chrome", + "type": "bookmarks", + "filePath": "C:\\Users\\hillarym\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Bookmarks" +} \ No newline at end of file diff --git a/ts/packages/agentServer/server/.import-states/website-1789973347252.json b/ts/packages/agentServer/server/.import-states/website-1789973347252.json new file mode 100644 index 0000000000..aedd55f7ca --- /dev/null +++ b/ts/packages/agentServer/server/.import-states/website-1789973347252.json @@ -0,0 +1,13 @@ +{ + "importId": "website-1789973347252", + "totalWebsites": 2, + "processedWebsites": 0, + "lastSavePoint": 0, + "failedUrls": [], + "startTime": 1789973446374, + "lastProgressTime": 1789973446374, + "extractionMode": "content", + "source": "chrome", + "type": "bookmarks", + "filePath": "C:\\Users\\hillarym\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Bookmarks" +} \ No newline at end of file diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index d21806bccb..4b09bb5fb3 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -43,8 +43,13 @@ 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"; +import { + FileMemoryService, + createKnowProCorpusIndex, +} from "@typeagent/memory-service"; +import { createMemoryServiceRpcFacade } from "@typeagent/memory-service/rpc"; +import { createDocMemorySettings } from "@typeagent/conversation-memory"; +import { openai } from "@typeagent/aiclient"; // Exit code the worker uses to ask the supervisor to relaunch it in place. const RESTART_EXIT_CODE = 42; @@ -334,6 +339,21 @@ async function main() { debugStartup("starting instance memory service"); const memoryService = new FileMemoryService( path.join(instanceDir, "memory"), + { + indexFactory: (corpusId, indexDirectory) => + createKnowProCorpusIndex(corpusId, indexDirectory, () => + createDocMemorySettings( + 64, + undefined, + openai.createChatModel( + openai.GPT_5_6_LUNA, + undefined, + undefined, + ["website-knowledge", "durable-index"], + ), + ), + ), + }, ); const memoryServiceHost = await MemoryServiceHost.start(memoryService, { onError: (error) => @@ -425,9 +445,8 @@ async function main() { allowSharedLocalView: ["browser"], agentInitOptions: { browser: { - memoryServiceClient: new InProcessMemoryServiceClient( - memoryService, - ), + memoryServiceClient: + createMemoryServiceRpcFacade(memoryService), }, }, }, diff --git a/ts/packages/agents/browser/package.json b/ts/packages/agents/browser/package.json index ee0a5e10e0..9c6eb1f684 100644 --- a/ts/packages/agents/browser/package.json +++ b/ts/packages/agents/browser/package.json @@ -17,8 +17,7 @@ "type": "module", "exports": { "./agent/manifest": "./src/agent/manifest.json", - "./agent/handlers": "./dist/agent/browserActionHandler.mjs", - "./agent/indexing": "./dist/agent/indexing/browserIndexingService.js" + "./agent/handlers": "./dist/agent/browserActionHandler.mjs" }, "main": "index.js", "files": [ diff --git a/ts/packages/agents/browser/src/agent/agentServiceHandlers.mts b/ts/packages/agents/browser/src/agent/agentServiceHandlers.mts index 3cd2c01bcb..2aa54f3fc3 100644 --- a/ts/packages/agents/browser/src/agent/agentServiceHandlers.mts +++ b/ts/packages/agents/browser/src/agent/agentServiceHandlers.mts @@ -3,7 +3,10 @@ import { SessionContext } from "@typeagent/agent-sdk"; import type { BrowserAgentInvokeFunctions } from "@typeagent/browser-control-rpc/serviceTypes"; -import type { BrowserActionContext } from "./browserActions.mjs"; +import { + getSessionBrowserControl, + type BrowserActionContext, +} from "./browserActions.mjs"; import { handleKnowledgeAction } from "./knowledge/actions/knowledgeActionRouter.mjs"; import { handleSchemaDiscoveryAction } from "./discovery/actionHandler.mjs"; import { @@ -24,6 +27,27 @@ export function createAgentInvokeHandlers( return handleKnowledgeAction(method, params, context); } + async function extractionHandler(params: any): Promise { + if (Array.isArray(params.htmlFragments)) { + return knowledgeHandler("extractKnowledgeFromPage", params); + } + const browserControl = getSessionBrowserControl(context); + const url = params.url ?? (await browserControl.getPageUrl()); + const htmlFragments = await browserControl.getHtmlFragments( + false, + "knowledgeExtraction", + ); + return knowledgeHandler("extractKnowledgeFromPage", { + ...params, + url, + title: params.title ?? url, + htmlFragments, + extractEntities: params.extractEntities ?? true, + extractRelationships: params.extractRelationships ?? true, + suggestQuestions: params.suggestQuestions ?? true, + }); + } + async function discoveryHandler(method: string, params: any): Promise { const result = await handleSchemaDiscoveryAction( { actionName: method as any, parameters: params }, @@ -38,8 +62,7 @@ export function createAgentInvokeHandlers( const handlers: BrowserAgentInvokeFunctions = { // Knowledge extraction - extractKnowledgeFromPage: (params: any) => - knowledgeHandler("extractKnowledgeFromPage", params), + extractKnowledgeFromPage: extractionHandler, // Knowledge queries searchWebMemories: (params: any) => websiteHandler("searchWebMemories", params), diff --git a/ts/packages/agents/browser/src/agent/browserActionHandler.mts b/ts/packages/agents/browser/src/agent/browserActionHandler.mts index 30949dd0a1..99f4da4796 100644 --- a/ts/packages/agents/browser/src/agent/browserActionHandler.mts +++ b/ts/packages/agents/browser/src/agent/browserActionHandler.mts @@ -61,8 +61,6 @@ import { import registerDebug from "debug"; -import * as website from "@typeagent/website-memory"; -import { createGraphologyPersistenceManager } from "./knowledge/utils/graphologyPersistence.mjs"; import { ExtractKnowledgeHandler } from "./knowledge/extractKnowledgeCommand.mjs"; import { performKnowledgeExtraction, @@ -84,7 +82,7 @@ import { searchByTopics, hybridSearch, generateWebSearchMarkdown, -} from "./searchWebMemories.mjs"; +} from "./durableWebSearch.mjs"; import { loadAllowDynamicAgentDomains, @@ -537,7 +535,6 @@ async function initializeBrowserContext( // because it provides the control in-process. preferredClientType: clientBrowserControl === undefined ? undefined : "electron", - index: undefined, localHostPort, // Shared WebSocket server is created lazily on the first // updateBrowserContext(true, ...) call so the bind happens with @@ -602,11 +599,6 @@ async function updateBrowserContext( context.agentContext.tabTitleIndex = createTabTitleIndex(); } - // Load the website index from disk - if (!context.agentContext.websiteCollection) { - await initializeWebsiteIndex(context); - } - // Initialize fuzzy matching model for website search if (!context.agentContext.fuzzyMatchingModel) { context.agentContext.fuzzyMatchingModel = tryCreateEmbeddingModel(); @@ -1089,192 +1081,6 @@ export function sendWebFlowRefreshToClient( } } -async function initializeWebsiteIndex( - context: SessionContext, -) { - try { - const websiteIndexes = await context.indexes("website"); - - if (websiteIndexes.length > 0) { - context.agentContext.index = websiteIndexes[0]; - context.agentContext.websiteCollection = - await website.WebsiteCollection.readFromFile( - websiteIndexes[0].path, - "index", - ); - - // Initialize JSON storage alongside SQLite - await initializeGraphologyStorage(context, websiteIndexes[0].path); - - debug( - `Loaded website index with ${context.agentContext.websiteCollection?.messages.length || 0} websites`, - ); - } else { - debug( - "No existing website index found, checking for index file at target path", - ); - - let indexPath: string | undefined; - let websiteCollection: website.WebsiteCollection | undefined; - - // Try to determine the target index path - try { - const sessionDir = await getSessionFolderPath(context); - if (sessionDir) { - // Create index path following IndexManager pattern: sessionDir/indexes/website - indexPath = path.resolve( - sessionDir, - "..", - "indexes", - "website", - "index", - ); - - // Check if the index database file exists and try to read it - const dbFile = path.join( - indexPath, - "index_dataFrames.sqlite", - ); - if (fs.existsSync(dbFile)) { - try { - websiteCollection = - await website.WebsiteCollection.readFromFile( - indexPath, - "index", - ); - - if ( - websiteCollection && - websiteCollection.messages.length > 0 - ) { - context.agentContext.websiteCollection = - websiteCollection; - - // Create proper IndexData object for the loaded collection - context.agentContext.index = { - source: "website", - name: "website-index", - location: "browser-agent", - size: websiteCollection.messages.length, - path: indexPath, - state: "finished", - progress: 100, - sizeOnDisk: 0, - }; - - // Initialize JSON storage and perform migration if needed - await initializeGraphologyStorage( - context, - indexPath, - ); - - debug( - `Loaded existing website collection with ${websiteCollection.messages.length} websites from ${indexPath}`, - ); - } else { - debug( - `Database exists but collection is empty at ${indexPath}, will create new collection`, - ); - websiteCollection = undefined; - } - } catch (readError) { - debug( - `Failed to read existing collection: ${readError}`, - ); - websiteCollection = undefined; - } - } else { - debug(`No existing database file found at ${dbFile}`); - } - } - } catch (pathError) { - debug(`Error determining index path: ${pathError}`); - indexPath = undefined; - } - - // If we couldn't load an existing collection, create a new one - if (!websiteCollection) { - context.agentContext.websiteCollection = - new website.WebsiteCollection(); - - // Set up index metadata if we have a valid path - // Directory will be created when writeToFile is called - if (indexPath) { - context.agentContext.index = { - source: "website", - name: "website-index", - location: "browser-agent", - size: 0, - path: indexPath, - state: "new", - progress: 0, - sizeOnDisk: 0, - }; - - // Initialize JSON storage for new index - await initializeGraphologyStorage(context, indexPath); - - debug( - `Index will be created at ${indexPath} when first page is indexed`, - ); - } else { - context.agentContext.index = undefined; - debug( - "No index path available, collection will be in-memory only", - ); - } - } - - // Log final state - if (!context.agentContext.index) { - debug( - "Website collection created without persistent index - data will be in-memory only", - ); - } - } - } catch (error) { - debug("Error initializing website collection:", error); - // SQLite/website-memory may be unavailable; keep browser agent alive. - // Knowledge features will remain unavailable until dependency issues are resolved. - context.agentContext.websiteCollection = undefined; - context.agentContext.index = undefined; - } -} - -/** - * Initialize Graphology storage for pure Graphology architecture - */ -async function initializeGraphologyStorage( - context: SessionContext, - indexPath: string, -): Promise { - try { - debug("Initializing Graphology storage"); - - // Create storage path for Graphology files - const graphologyStoragePath = path.join(indexPath, "storage"); - - // Create Graphology persistence manager - const persistenceManager = createGraphologyPersistenceManager( - graphologyStoragePath, - ); - - // Store reference in context for later use (maintaining compatibility) - if (!context.agentContext.graphJsonStorage) { - context.agentContext.graphJsonStorage = { - manager: persistenceManager, - lastEntityGraphUpdate: null, - lastTopicGraphUpdate: null, - }; - } - - debug("Graphology storage initialization complete"); - } catch (error) { - debug(`Error initializing Graphology storage: ${error}`); - // Don't throw - this should not break the main initialization - } -} - async function getSessionFolderPath( context: SessionContext, ) { diff --git a/ts/packages/agents/browser/src/agent/browserActions.mts b/ts/packages/agents/browser/src/agent/browserActions.mts index 5390acdc15..552b48db9a 100644 --- a/ts/packages/agents/browser/src/agent/browserActions.mts +++ b/ts/packages/agents/browser/src/agent/browserActions.mts @@ -10,7 +10,6 @@ import type { AiSearchLookupMode } from "./lookup/aiSearchLookup.mjs"; import { ChildProcess } from "child_process"; import { TabTitleIndex } from "./tabTitleIndex.mjs"; import { TextEmbeddingModel } from "@typeagent/aiclient"; -import type { WebsiteCollection, IndexData } from "@typeagent/website-memory"; import { ActionContext, SessionContext } from "@typeagent/agent-sdk"; import { ChoiceManager } from "@typeagent/agent-sdk/helpers/action"; @@ -21,12 +20,13 @@ import { AgentWebSocketServer, } from "./agentWebSocketServer.mjs"; import { getClientType } from "@typeagent/agent-server-protocol"; -import type { MemoryServiceClient } from "@typeagent/memory-client"; +import type { MemoryService } from "@typeagent/memory-service"; import type { BrowserMemoryService } from "./browserMemoryService.mjs"; +import type { GraphCache } from "./knowledge/types/knowledgeTypes.mjs"; export type BrowserAgentInitOptions = { browserControl?: BrowserControl; - memoryServiceClient?: MemoryServiceClient; + memoryServiceClient?: MemoryService; }; export type BrowserActionContext = { @@ -34,7 +34,7 @@ export type BrowserActionContext = { clientBrowserControl?: BrowserControl | undefined; externalBrowserControl?: ExternalBrowserClient | undefined; useExternalBrowserControl: boolean; - memoryServiceClient?: MemoryServiceClient; + memoryServiceClient?: MemoryService; browserMemoryService?: BrowserMemoryService; preferredClientType?: "extension" | "electron" | undefined; // Runtime override for the internet-lookup backend (@browser lookup ...); @@ -49,10 +49,8 @@ export type BrowserActionContext = { browserProcess?: ChildProcess | undefined; tabTitleIndex?: TabTitleIndex | undefined; allowDynamicAgentDomains?: string[]; - websiteCollection?: WebsiteCollection | undefined; - graphJsonStorage?: any | undefined; // GraphologyPersistenceManager - field name maintained for compatibility + graphCache?: GraphCache | undefined; fuzzyMatchingModel?: TextEmbeddingModel | undefined; - index: IndexData | undefined; viewProcess?: ChildProcess | undefined; localHostPort: number; // Handle returned by sessionContext.registerPort for the views diff --git a/ts/packages/agents/browser/src/agent/browserMemoryService.mts b/ts/packages/agents/browser/src/agent/browserMemoryService.mts index 7643056f9b..7047136673 100644 --- a/ts/packages/agents/browser/src/agent/browserMemoryService.mts +++ b/ts/packages/agents/browser/src/agent/browserMemoryService.mts @@ -2,17 +2,18 @@ // Licensed under the MIT License. import { createHash } from "node:crypto"; -import type { MemoryServiceClient } from "@typeagent/memory-client"; import type { IngestionMode, JobProgress, MemoryEvidence, MemoryKnowledgeGraph, + MemoryService, MemorySource, } from "@typeagent/memory-service"; +import { waitForMemoryJob } from "@typeagent/memory-service/rpc"; const browserCorpusName = "TypeAgent Browser Memory"; -const adapters = new WeakMap(); +const adapters = new WeakMap(); export interface BrowserMemoryDocument { url: string; @@ -28,6 +29,7 @@ export interface BrowserMemoryDocument { export interface BrowserMemorySearchOptions { query: string; limit?: number; + sourceIds?: string[]; url?: string; domain?: string; pageType?: string; @@ -45,7 +47,7 @@ export class BrowserMemoryService { private corpusIdPromise: Promise | undefined; private graphVersion = 0; - public constructor(private readonly client: MemoryServiceClient) {} + public constructor(private readonly client: MemoryService) {} public async ingest( document: BrowserMemoryDocument, @@ -56,38 +58,33 @@ export class BrowserMemoryService { } = {}, ): 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 + 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 ? {} - : { tags: document.tags }), - ...(document.capturedAt === undefined + : { pageType: document.pageType }), + ...(document.source === undefined ? {} - : { capturedAt: document.capturedAt }), - metadata: { - ...(document.domain === undefined - ? {} - : { domain: document.domain }), - ...(document.pageType === undefined - ? {} - : { pageType: document.pageType }), - ...(document.source === undefined - ? {} - : { source: document.source }), - }, + : { source: document.source }), }, - pipeline: { mode, updatePolicy: "skipIfUnchanged" }, }, - options.signal, - ); - const job = await this.client.waitForJob(result.jobId, options); + pipeline: { mode, updatePolicy: "skipIfUnchanged" }, + }); + const job = await waitForMemoryJob(this.client, result.jobId, options); if (job.state !== "complete" && job.state !== "partial") { throw new Error( job.error ?? @@ -102,8 +99,17 @@ export class BrowserMemoryService { ): Promise { const corpusId = await this.getCorpusId(); const sources = await this.client.listSources(corpusId); + const requestedSourceIds = + options.sourceIds === undefined + ? undefined + : new Set(options.sourceIds); const sourceIds = sources - .filter((source) => matchesFilters(source, options)) + .filter( + (source) => + (requestedSourceIds === undefined || + requestedSourceIds.has(source.sourceId)) && + matchesFilters(source, options), + ) .map((source) => source.sourceId); if (sourceIds.length === 0) { return []; @@ -131,6 +137,24 @@ export class BrowserMemoryService { ); } + public async getSourceById( + sourceId: string, + ): Promise { + return this.client.getSource(await this.getCorpusId(), sourceId); + } + + public async listSources(): Promise { + return this.client.listSources(await this.getCorpusId()); + } + + public async clear(): Promise { + const clearedCount = await this.client.clearCorpus( + await this.getCorpusId(), + ); + this.graphVersion++; + return clearedCount; + } + public async getKnowledgeGraph(): Promise { return this.client.getKnowledgeGraph(await this.getCorpusId()); } @@ -159,7 +183,7 @@ export class BrowserMemoryService { } export function getBrowserMemoryService( - client: MemoryServiceClient, + client: MemoryService, ): BrowserMemoryService { let service = adapters.get(client); if (service === undefined) { diff --git a/ts/packages/agents/browser/src/agent/durableWebSearch.mts b/ts/packages/agents/browser/src/agent/durableWebSearch.mts new file mode 100644 index 0000000000..f91cafce48 --- /dev/null +++ b/ts/packages/agents/browser/src/agent/durableWebSearch.mts @@ -0,0 +1,390 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { SessionContext } from "@typeagent/agent-sdk"; +import type { MemoryKnowledgeGraph } from "@typeagent/memory-service"; +import type { BrowserActionContext } from "./browserActions.mjs"; +import type { + Entity, + WebPageReference, +} from "./knowledge/schema/knowledgeExtraction.mjs"; + +export interface SearchWebMemoriesRequest { + originalUserRequest?: string | undefined; + query: string; + searchScope?: "current_page" | "all_indexed" | undefined; + url?: string | undefined; + dateFrom?: string | undefined; + dateTo?: string | undefined; + domain?: string | undefined; + pageType?: string | undefined; + source?: string | undefined; + limit?: number | undefined; + minScore?: number | undefined; + exactMatch?: boolean | undefined; + generateAnswer?: boolean | undefined; + includeRelatedEntities?: boolean | undefined; + enableAdvancedSearch?: boolean | undefined; + knowledgeTopK?: number | undefined; + chunking?: boolean | undefined; + fastStop?: boolean | undefined; + combineAnswers?: boolean | undefined; + choices?: string | undefined; + maxCharsInBudget?: number | undefined; + debug?: boolean | undefined; + metadata?: any; +} + +export interface SearchSummary { + totalFound: number; + searchTime: number; + strategies: string[]; + confidence: number; +} + +export interface SearchDebugContext { + searchTerms: string[]; + searchStrategies: string[]; + knowledgeMatchCount: number; + timing: { + parsing: number; + search: number; + processing: number; + total: number; + }; + intermediateFallbacks: string[]; +} + +export interface WebsiteResult { + url: string; + title: string; + domain: string; + pageType: string; + source: string; + relevanceScore: number; + lastVisited?: string; + snippet?: string; +} + +export interface SearchWebMemoriesResponse { + websites: WebsiteResult[]; + summary: SearchSummary; + answer?: string; + answerType?: "direct" | "synthesized" | "noAnswer"; + answerSources?: WebPageReference[]; + confidence?: number; + relatedEntities?: Entity[]; + topTopics?: string[]; + queryIntent?: "question" | "discovery" | "mixed"; + searchTerms?: string[]; + suggestedFollowups?: string[]; + debugContext?: SearchDebugContext; +} + +function emptyResponse( + message: string, + startedAt: number, +): SearchWebMemoriesResponse { + return { + websites: [], + summary: { + totalFound: 0, + searchTime: Date.now() - startedAt, + strategies: ["durable-memory"], + confidence: 0, + }, + answer: message, + answerType: "noAnswer", + answerSources: [], + queryIntent: "discovery", + suggestedFollowups: [], + }; +} + +function sourceMetadata( + metadata: Record | undefined, + name: string, +): string | undefined { + const value = metadata?.[name]; + return typeof value === "string" ? value : undefined; +} + +async function searchDurable( + request: SearchWebMemoriesRequest, + context: SessionContext, + sourceIds?: string[], +): Promise { + const startedAt = Date.now(); + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { + return emptyResponse( + "Durable browser memory is not available", + startedAt, + ); + } + const query = request.query.trim(); + if (query.length === 0) { + return emptyResponse("Query cannot be empty", startedAt); + } + + try { + const matches = await memory.search({ + query, + limit: request.limit ?? 20, + ...(request.searchScope === "current_page" && request.url + ? { url: request.url } + : {}), + ...(request.domain === undefined ? {} : { domain: request.domain }), + ...(request.pageType === undefined + ? {} + : { pageType: request.pageType }), + ...(request.source === undefined ? {} : { source: request.source }), + ...(request.dateFrom === undefined + ? {} + : { dateFrom: request.dateFrom }), + ...(request.dateTo === undefined ? {} : { dateTo: request.dateTo }), + ...(sourceIds === undefined ? {} : { sourceIds }), + }); + const websites: WebsiteResult[] = matches.map( + ({ evidence, source }) => { + let domain = sourceMetadata(source.metadata, "domain"); + if (domain === undefined && source.canonicalUri !== undefined) { + try { + domain = new URL(source.canonicalUri).hostname; + } catch { + domain = "unknown"; + } + } + return { + url: source.canonicalUri ?? "", + title: source.title, + domain: domain ?? "unknown", + pageType: + sourceMetadata(source.metadata, "pageType") ?? + "webpage", + source: + sourceMetadata(source.metadata, "source") ?? "memory", + relevanceScore: evidence.score, + ...(evidence.capturedAt === undefined + ? {} + : { lastVisited: evidence.capturedAt }), + snippet: evidence.snippet, + }; + }, + ); + const debugContext: SearchDebugContext | undefined = request.debug + ? { + searchTerms: [query], + searchStrategies: ["durable-memory"], + knowledgeMatchCount: websites.length, + timing: { + parsing: 0, + search: Date.now() - startedAt, + processing: 0, + total: Date.now() - startedAt, + }, + intermediateFallbacks: [], + } + : undefined; + return { + websites, + summary: { + totalFound: websites.length, + searchTime: Date.now() - startedAt, + strategies: ["durable-memory"], + confidence: + websites.length === 0 + ? 0 + : Math.max( + ...websites.map((site) => site.relevanceScore), + ), + }, + answer: + websites.length === 0 + ? `No indexed websites matched "${query}".` + : `Found ${websites.length} indexed website${websites.length === 1 ? "" : "s"}.`, + answerType: websites.length === 0 ? "noAnswer" : "direct", + answerSources: matches.map(({ evidence, source }) => ({ + url: source.canonicalUri ?? "", + title: source.title, + relevanceScore: evidence.score, + lastIndexed: evidence.indexedAt, + })), + queryIntent: "discovery", + searchTerms: [query], + suggestedFollowups: [], + ...(debugContext === undefined ? {} : { debugContext }), + }; + } catch (error) { + return emptyResponse( + error instanceof Error ? error.message : "Durable search failed", + startedAt, + ); + } +} + +function matchingSourceIds( + names: string[], + graph: MemoryKnowledgeGraph, + kind: "entity" | "topic", +): string[] { + const requested = new Set(names.map((name) => name.toLocaleLowerCase())); + const matches = kind === "entity" ? graph.entities : graph.topics; + return [ + ...new Set( + matches + .filter((item) => requested.has(item.name.toLocaleLowerCase())) + .flatMap((item) => item.sourceIds), + ), + ]; +} + +export function searchWebMemories( + request: SearchWebMemoriesRequest, + context: SessionContext, +): Promise { + return searchDurable(request, context); +} + +export async function searchByEntities( + request: { + entities: string[]; + url?: string; + maxResults?: number; + searchScope?: "current_page" | "all_indexed"; + includeMetadata?: boolean; + }, + context: SessionContext, +): Promise { + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { + return emptyResponse( + "Durable browser memory is not available", + Date.now(), + ); + } + const sourceIds = matchingSourceIds( + request.entities, + await memory.getKnowledgeGraph(), + "entity", + ); + if (sourceIds.length === 0) { + return emptyResponse( + `No websites found containing entities: ${request.entities.join(", ")}`, + Date.now(), + ); + } + return searchDurable( + { + query: request.entities.join(" OR "), + url: request.url, + searchScope: request.searchScope, + limit: request.maxResults, + generateAnswer: false, + }, + context, + sourceIds, + ); +} + +export async function searchByTopics( + request: { + topics: string[]; + url?: string; + maxResults?: number; + searchScope?: "current_page" | "all_indexed"; + includeMetadata?: boolean; + }, + context: SessionContext, +): Promise { + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { + return emptyResponse( + "Durable browser memory is not available", + Date.now(), + ); + } + const sourceIds = matchingSourceIds( + request.topics, + await memory.getKnowledgeGraph(), + "topic", + ); + if (sourceIds.length === 0) { + return emptyResponse( + `No websites found containing topics: ${request.topics.join(", ")}`, + Date.now(), + ); + } + return searchDurable( + { + query: request.topics.join(" OR "), + url: request.url, + searchScope: request.searchScope, + limit: request.maxResults, + generateAnswer: false, + }, + context, + sourceIds, + ); +} + +export function hybridSearch( + request: { + query: string; + url?: string; + maxResults?: number; + searchScope?: "current_page" | "all_indexed"; + includeMetadata?: boolean; + combineStrategies?: boolean; + }, + context: SessionContext, +): Promise { + return searchDurable( + { + query: request.query, + url: request.url, + searchScope: request.searchScope, + limit: request.maxResults, + generateAnswer: false, + }, + context, + ); +} + +export function generateWebSearchMarkdown( + searchResponse: SearchWebMemoriesResponse, + _query?: string, +): string { + let content = `Found ${searchResponse.websites.length} result(s) in ${searchResponse.summary.searchTime}ms\n\n`; + if (searchResponse.answer && searchResponse.answerType !== "noAnswer") { + content += `**Answer:** ${searchResponse.answer}\n\n`; + } + if (searchResponse.websites.length > 0) { + content += "**Top Results:**\n\n"; + searchResponse.websites.slice(0, 10).forEach((site, index) => { + content += `${index + 1}. ${site.title}\n([link](${site.url}))`; + if (site.lastVisited) { + content += ` - Last visited: ${new Date(site.lastVisited).toLocaleDateString()}`; + } + content += "\n\n"; + }); + } + if (searchResponse.relatedEntities?.length) { + content += `**Related Entities:**\n\n${searchResponse.relatedEntities + .slice(0, 5) + .map((entity) => `- ${entity.name}`) + .join("\n")}\n\n`; + } + if (searchResponse.topTopics?.length) { + content += `**Top Topics:**\n\n${searchResponse.topTopics + .slice(0, 5) + .map((topic) => `- ${topic}`) + .join("\n")}\n\n`; + } + if (searchResponse.suggestedFollowups?.length) { + content += `**Suggested Follow-ups:**\n\n${searchResponse.suggestedFollowups + .map((followup) => `- ${followup}`) + .join("\n")}\n`; + } + return content; +} diff --git a/ts/packages/agents/browser/src/agent/indexing/browserIndexingService.ts b/ts/packages/agents/browser/src/agent/indexing/browserIndexingService.ts deleted file mode 100644 index 49b21866be..0000000000 --- a/ts/packages/agents/browser/src/agent/indexing/browserIndexingService.ts +++ /dev/null @@ -1,463 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import registerDebug from "debug"; - -import { - importWebsites, - Website, - WebsiteCollection, -} from "@typeagent/website-memory"; - -import { IndexingKnowledgeExtractor } from "./indexingKnowledgeExtractor.mjs"; - -const debug = registerDebug("typeagent:browser:IndexingService"); - -// Types from website-memory (re-exported for clarity) -export type IndexSource = "website" | "image" | "email"; - -export type IndexData = { - source: IndexSource; - name: string; - location: string; - size: number; - path: string; - state: "new" | "indexing" | "finished" | "stopped" | "idle" | "error"; - progress: number; - sizeOnDisk: number; - sourceType?: "bookmarks" | "history"; - browserType?: "chrome" | "edge"; -}; - -/** - * Browser Agent Indexing Service - * Runs as separate process, uses browser agent knowledge processing infrastructure - * Provides AI-enhanced indexing with content summarization and quality assessment - */ -export class BrowserIndexingService { - private knowledgeExtractor: IndexingKnowledgeExtractor; - private index: IndexData | undefined = undefined; - - constructor() { - this.knowledgeExtractor = new IndexingKnowledgeExtractor(); - } - - /** - * Initialize the service with AI models and adapters - */ - async initialize(): Promise { - debug("Initializing browser indexing service..."); - - try { - await this.knowledgeExtractor.initialize(); - debug("Knowledge extractor initialized"); - - debug("Browser indexing service ready"); - debug("Capabilities:", this.knowledgeExtractor.getCapabilities()); - } catch (error) { - debug("Initialization error:", error); - throw error; - } - } - - /** - * Start indexing process with provided index data - */ - async startIndexing(indexData: IndexData): Promise { - this.index = indexData; - debug( - `Starting indexing for: ${indexData.name} (${indexData.sourceType} from ${indexData.browserType})`, - ); - - try { - // Load existing collection first (maintains existing behavior) - const websites = await this.loadExistingCollection(); - - // Import bookmarks/history using browser agent processing - const importedWebsites = await this.importWithBrowserAgent( - indexData.browserType || "chrome", - indexData.sourceType || "bookmarks", - indexData.location, - ); - - // Filter websites that already exist in the collection - const existingUrls = new Set( - websites.getWebsites().map((w) => w.metadata.url), - ); - const newWebsites = importedWebsites.filter( - (w) => !existingUrls.has(w.metadata.url), - ); - - if (newWebsites.length === 0) { - debug("No new websites to index"); - this.index.state = "finished"; - this.index.progress = 100; - this.sendIndexStatus(); - return; - } - - debug( - `📝 PROCESSING ${newWebsites.length} websites incrementally with enhanced knowledge extraction`, - ); - - // Process websites incrementally - process, add to collection, and index each one - await this.processWebsitesIncrementally(websites, newWebsites); - - // Final save of the index - await this.saveIndexToDisk(websites); - - this.index.state = "finished"; - this.index.progress = 100; - this.index.size = websites.getWebsites().length; - this.sendIndexStatus(); - - debug("Enhanced indexing completed successfully"); - } catch (error) { - debug("Indexing failed:", error); - this.index.state = "error"; - this.sendIndexStatus(); - } - } - - /** - * Load existing website collection from the index path - */ - private async loadExistingCollection(): Promise { - try { - const websites = await WebsiteCollection.readFromFile( - this.index!.path, - "index", - ); - if (websites && websites.messages.length > 0) { - debug( - `Loaded existing collection with ${websites.messages.length} websites`, - ); - return websites; - } else { - debug( - "No existing collection found or empty, creating new one", - ); - return new WebsiteCollection(); - } - } catch (error) { - debug( - `Failed to load existing collection: ${error}. Creating new collection.`, - ); - return new WebsiteCollection(); - } - } - - /** - * Import websites using browser agent infrastructure - */ - private async importWithBrowserAgent( - browserType: string, - sourceType: string, - location: string, - ): Promise { - debug(`Importing ${sourceType} from ${browserType} at ${location}`); - - // Resolve browser file path - needed when location is not a direct file path - let resolvedLocation = location; - if ( - location === "browser-agent" || - location === "default" || - !location.includes(path.sep) - ) { - const { getDefaultBrowserPaths } = await import( - "@typeagent/website-memory" - ); - const defaultPaths = getDefaultBrowserPaths(); - - if (browserType === "chrome") { - resolvedLocation = - sourceType === "bookmarks" - ? defaultPaths.chrome.bookmarks - : defaultPaths.chrome.history; - } else if (browserType === "edge") { - resolvedLocation = - sourceType === "bookmarks" - ? defaultPaths.edge.bookmarks - : defaultPaths.edge.history; - } - - debug( - `Resolved location from '${location}' to '${resolvedLocation}'`, - ); - } - - return await importWebsites( - browserType as "chrome" | "edge", - sourceType as "bookmarks" | "history", - resolvedLocation, - { - limit: 10000, - // Standard content extractor - enhanced processing happens later - }, - this.indexingProgress.bind(this), - ); - } - - /** - * Calculate overall quality score from extraction metrics - */ - private calculateQualityScore(metrics: any): number { - // Combine multiple factors into overall quality score (0-1) - const factors = [ - Math.min(metrics.confidence || 0, 1), - Math.min((metrics.entityCount || 0) / 10, 1), // Normalize entity count - Math.min((metrics.topicCount || 0) / 5, 1), // Normalize topic count - metrics.aiProcessingTime ? 0.2 : 0, // Bonus for AI processing - ]; - - return ( - factors.reduce((sum, factor) => sum + factor, 0) / factors.length - ); - } - - /** - * Process websites incrementally - for each website: process, add to collection, and index - * This follows the pattern from websiteMemory.mts line 739 - */ - private async processWebsitesIncrementally( - websiteCollection: WebsiteCollection, - newWebsites: Website[], - ): Promise { - debug( - `INCREMENTAL PROCESSING: Starting processing of ${newWebsites.length} websites`, - ); - - for (let i = 0; i < newWebsites.length; i++) { - const website = newWebsites[i]; - - try { - debug( - `📄 PROCESSING WEBSITE ${i + 1}/${newWebsites.length}: ${website.metadata.title || website.metadata.url}`, - ); - - // Step 1: Enhanced knowledge extraction for this website - await this.processWebsiteWithKnowledge( - website, - i + 1, - newWebsites.length, - ); - - // Step 2: Add to collection - websiteCollection.addWebsites([website]); // Add to collection - - // Step 3: Incrementally add to search index - try { - await websiteCollection.addToIndex(); - } catch (indexError) { - debug( - `⚠️ INCREMENTAL INDEX FAILED: Falling back to full rebuild: ${indexError}`, - ); - await websiteCollection.buildIndex(); - } - - // Update progress - this.indexingProgress( - i + 1, - newWebsites.length, - website.metadata.title || website.metadata.url, - ); - - // Periodic save every 10 websites to preserve progress - if ((i + 1) % 10 === 0) { - debug( - `💾 PERIODIC SAVE: Saving progress after ${i + 1} websites`, - ); - await this.saveIndexToDisk(websiteCollection); - } - } catch (error) { - debug( - `❌ FAILED TO PROCESS: ${website.metadata.title || website.metadata.url}: ${error}`, - ); - // Continue with next website rather than failing entire batch - } - } - - debug( - `✅ INCREMENTAL PROCESSING COMPLETE: Successfully processed ${newWebsites.length} websites`, - ); - } - - /** - * Process a single website with enhanced knowledge extraction - */ - private async processWebsiteWithKnowledge( - website: Website, - current: number, - total: number, - ): Promise { - try { - // Determine extraction mode based on URL - const extractionMode = - this.knowledgeExtractor.getExtractionModeForUrl( - website.metadata.url, - ); - - debug( - `Processing ${website.metadata.url} with ${extractionMode} mode`, - ); - - // Enhanced knowledge extraction with context information - const result = await this.knowledgeExtractor.extractKnowledge( - { - url: website.metadata.url, - title: website.metadata.title || "", - textContent: - website.textChunks?.join("\n") || - website.metadata.description || - "", - source: "import" as const, - timestamp: new Date().toISOString(), - folder: website.metadata.folder, - } as any, - extractionMode, - ); - - // Update website with extracted knowledge - if (result.knowledge) { - website.knowledge = result.knowledge; - - // Extract and assign topicHierarchy if present - const topicHierarchy = (result.knowledge as any) - ?.topicHierarchy; - if (topicHierarchy) { - (website as any).topicHierarchy = topicHierarchy; - } - } - - // Add processing metadata - (website.metadata as any).extractionMode = extractionMode; - (website.metadata as any).processingTime = result.processingTime; - (website.metadata as any).aiProcessingUsed = - result.aiProcessingUsed; - - // Add quality metrics if available - if (result.qualityMetrics) { - (website.metadata as any).qualityScore = - this.calculateQualityScore(result.qualityMetrics); - } - - // Add summary data if enhanced with summarization - if ((result as any).summaryData) { - (website as any).summaryData = (result as any).summaryData; - (website.metadata as any).enhancedWithSummary = true; - } - - debug( - `Successfully processed ${website.metadata.url} (AI: ${result.aiProcessingUsed})`, - ); - } catch (error) { - debug( - `❌ KNOWLEDGE EXTRACTION ERROR: ${website.metadata.url}:`, - error, - ); - } - } - - /** - * Save the index to disk - */ - private async saveIndexToDisk(websites: WebsiteCollection): Promise { - try { - // Ensure index directory exists before writing - const { ensureDir } = await import("@typeagent/agent-runtime"); - await ensureDir(this.index!.path); - - // Save the index to disk - await websites.writeToFile(this.index!.path, "index"); - debug(`Index saved to ${this.index!.path}`); - } catch (error) { - debug("Error saving index to disk:", error); - throw error; - } - } - - /** - * Report indexing progress - */ - private indexingProgress( - current: number, - total: number, - itemName: string, - ): void { - if (!this.index) return; - - this.index.progress = current; - this.index.size = current; - this.index.state = "indexing"; - - debug(`Progress: ${current}/${total} - ${itemName}`); - - // Send progress to parent process (every 10 items or at completion) - if (current % 10 === 0 || current === total) { - this.sendIndexStatus(); - } - } - - /** - * Send index status to parent process - */ - private sendIndexStatus(): void { - process.send?.(this.index); - } -} - -// Process entry point for separate process execution -if ( - process.argv.filter((value: string) => { - const thisFile = fileURLToPath(import.meta.url); - return path.basename(value) === path.basename(thisFile); - }).length > 0 -) { - const service = new BrowserIndexingService(); - - /** - * Indicate to the host/parent process that we've started successfully - */ - process.send?.("Success"); - - /** - * Process messages received from the host/parent process - */ - process.on("message", async (message: any) => { - debug("Received message from parent:", message); - - if (message !== undefined) { - try { - // Initialize service - await service.initialize(); - - // Start indexing with provided data - await service.startIndexing(message as IndexData); - } catch (error) { - debug("Error in message handling:", error); - process.send?.({ - ...message, - state: "error", - error: - error instanceof Error - ? error.message - : "Unknown error", - }); - } - } - }); - - /** - * Closes this process at the request of the host/parent process - */ - process.on("disconnect", () => { - debug("Parent process disconnected, exiting"); - process.exit(1); - }); - - debug( - "Browser indexing service started successfully and waiting for instructions", - ); -} diff --git a/ts/packages/agents/browser/src/agent/indexing/index.mts b/ts/packages/agents/browser/src/agent/indexing/index.mts index 1c9bdd8077..819f74b2a1 100644 --- a/ts/packages/agents/browser/src/agent/indexing/index.mts +++ b/ts/packages/agents/browser/src/agent/indexing/index.mts @@ -2,11 +2,9 @@ // Licensed under the MIT License. /** - * Browser Agent Indexing Service - * Enhanced indexing with ContentSummaryAdapter and browser agent infrastructure + * Browser knowledge extraction adapters. */ -export { BrowserIndexingService } from "./browserIndexingService.js"; export { ContentSummaryAdapter } from "./contentSummaryAdapter.mjs"; export { IndexingKnowledgeExtractor } from "./indexingKnowledgeExtractor.mjs"; diff --git a/ts/packages/agents/browser/src/agent/knowledge/actions/analyticsActions.mts b/ts/packages/agents/browser/src/agent/knowledge/actions/analyticsActions.mts index a68e610e06..7d133f929d 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/actions/analyticsActions.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/actions/analyticsActions.mts @@ -1,79 +1,151 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { SessionContext } from "@typeagent/agent-sdk"; -import { BrowserActionContext } from "../../browserActions.mjs"; -import * as website from "@typeagent/website-memory"; -import { ExtractionMode } from "@typeagent/website-memory"; -import { DetailedKnowledgeStats } from "../../browserKnowledgeSchema.js"; -import { AnalyticsDataResponse } from "../types/knowledgeTypes.mjs"; +import type { SessionContext } from "@typeagent/agent-sdk"; +import type { + MemoryKnowledgeGraph, + MemorySource, +} from "@typeagent/memory-service"; +import type { BrowserActionContext } from "../../browserActions.mjs"; +import type { DetailedKnowledgeStats } from "../../browserKnowledgeSchema.js"; +import type { AnalyticsDataResponse } from "../types/knowledgeTypes.mjs"; + +type BrowserSnapshot = { + sources: MemorySource[]; + graph: MemoryKnowledgeGraph; +}; + +async function loadSnapshot( + context: SessionContext, +): Promise { + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { + throw new Error("Durable browser memory is not available"); + } + const [sources, graph] = await Promise.all([ + memory.listSources(), + memory.getKnowledgeGraph(), + ]); + return { sources, graph }; +} + +function sourceTimestamp(source: MemorySource): string | undefined { + const revision = source.revisions.find( + (item) => item.revisionId === source.activeRevisionId, + ); + return revision?.capturedAt ?? revision?.indexedAt; +} + +function sourceDomain(source: MemorySource): string { + const metadataDomain = source.metadata?.domain; + if (typeof metadataDomain === "string" && metadataDomain.length > 0) { + return metadataDomain; + } + try { + return source.canonicalUri === undefined + ? "unknown" + : new URL(source.canonicalUri).hostname; + } catch { + return "unknown"; + } +} + +function sourceIdsFor( + sourceId: string, + graph: MemoryKnowledgeGraph, +): { + entityCount: number; + topicCount: number; + relationshipCount: number; +} { + return { + entityCount: graph.entities.filter((item) => + item.sourceIds.includes(sourceId), + ).length, + topicCount: graph.topics.filter((item) => + item.sourceIds.includes(sourceId), + ).length, + relationshipCount: graph.relationships.filter((item) => + item.sourceIds.includes(sourceId), + ).length, + }; +} + +function qualityScore(counts: ReturnType): number { + return Math.min( + 1, + 0.2 + + (counts.entityCount > 0 ? 0.3 : 0) + + (counts.topicCount > 0 ? 0.2 : 0) + + (counts.relationshipCount > 0 ? 0.3 : 0), + ); +} export async function getExtractionAnalytics( - parameters: { - timeRange?: string; - mode?: ExtractionMode; - }, + parameters: { timeRange?: string; mode?: string }, context: SessionContext, -): Promise<{ - success: boolean; - analytics: any; -}> { +): Promise<{ success: boolean; analytics: any }> { try { - // Analytics functionality moved to website-memory package - // For now, return basic analytics info + const { sources } = await loadSnapshot(context); + const modes = { basic: 0, content: 0, actions: 0, full: 0 }; + for (const source of sources) { + const mode = source.metadata?.extractionMode; + if (typeof mode === "string" && mode in modes) { + modes[mode as keyof typeof modes]++; + } + } return { success: true, analytics: { - totalExtractions: 0, - successRate: 100, + totalExtractions: sources.length, + successRate: sources.length === 0 ? 0 : 100, averageProcessingTime: 0, - modes: { - basic: 0, - content: 0, - actions: 0, - full: 0, - }, + modes, }, }; - } catch (error) { - console.error("Error getting extraction analytics:", error); - return { - success: false, - analytics: null, - }; + } catch { + return { success: false, analytics: null }; } } export async function generateQualityReport( parameters: {}, context: SessionContext, -): Promise<{ - success: boolean; - report: any; -}> { +): Promise<{ success: boolean; report: any }> { try { - // Quality monitoring functionality moved to website-memory package - // For now, return basic quality report + const { sources, graph } = await loadSnapshot(context); + const scores = sources.map((source) => + qualityScore(sourceIdsFor(source.sourceId, graph)), + ); + const average = + scores.length === 0 + ? 0 + : scores.reduce((sum, score) => sum + score, 0) / scores.length; return { success: true, report: { - overallQuality: "good", - averageConfidence: 0.8, - totalItems: 0, + overallQuality: + average >= 0.8 + ? "excellent" + : average >= 0.5 + ? "good" + : average > 0 + ? "fair" + : "poor", + averageConfidence: average, + totalItems: sources.length, qualityDistribution: { - excellent: 0, - good: 0, - fair: 0, - poor: 0, + excellent: scores.filter((score) => score >= 0.8).length, + good: scores.filter((score) => score >= 0.6 && score < 0.8) + .length, + fair: scores.filter((score) => score >= 0.4 && score < 0.6) + .length, + poor: scores.filter((score) => score < 0.4).length, }, }, }; - } catch (error) { - console.error("Error generating quality report:", error); - return { - success: false, - report: null, - }; + } catch { + return { success: false, report: null }; } } @@ -88,263 +160,37 @@ export async function getPageQualityMetrics( extractionMode: string; lastUpdated: string | null; }> { + const empty = { + score: 0, + entityCount: 0, + topicCount: 0, + actionCount: 0, + extractionMode: "unknown", + lastUpdated: null, + }; try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - score: 0, - entityCount: 0, - topicCount: 0, - actionCount: 0, - extractionMode: "unknown", - lastUpdated: null, - }; - } - - const websites = websiteCollection.messages.getAll(); - const foundWebsite = websites.find( - (site: any) => site.metadata.url === parameters.url, - ); - - if (!foundWebsite) { - return { - score: 0, - entityCount: 0, - topicCount: 0, - actionCount: 0, - extractionMode: "unknown", - lastUpdated: null, - }; - } - - const knowledge = foundWebsite.getKnowledge(); - const metadata = foundWebsite.metadata as any; - - const entityCount = knowledge?.entities?.length || 0; - const topicCount = knowledge?.topics?.length || 0; - const actionCount = knowledge?.actions?.length || 0; - - // Calculate quality score based on knowledge richness - let score = 0.2; // Base score - - if (entityCount > 0) score += 0.2; - if (topicCount > 2) score += 0.2; - if (actionCount > 0) score += 0.2; - if (entityCount > 5) score += 0.1; - if (topicCount > 5) score += 0.1; - - score = Math.min(score, 1.0); - - // Determine extraction mode based on knowledge richness - let extractionMode = "basic"; - if (actionCount > 0) { - extractionMode = "full"; - } else if (entityCount > 3 && topicCount > 2) { - extractionMode = "content"; - } - - return { - score, - entityCount, - topicCount, - actionCount, - extractionMode, - lastUpdated: metadata.visitDate || metadata.bookmarkDate || null, - }; - } catch (error) { - console.error("Error getting page quality metrics:", error); - return { - score: 0, - entityCount: 0, - topicCount: 0, - actionCount: 0, - extractionMode: "unknown", - lastUpdated: null, - }; - } -} - -export async function getAnalyticsData( - parameters: { - timeRange?: string; - includeQuality?: boolean; - includeProgress?: boolean; - topDomainsLimit?: number; - activityGranularity?: "day" | "week" | "month"; - }, - context: SessionContext, -): Promise { - try { - // Single coordinated data collection using Promise.all for efficiency - const [ - knowledgeStats, - topDomains, - activityTrends, - extractionAnalytics, - recentKnowledgeItems, - ] = await Promise.all([ - getDetailedKnowledgeStats( - { - includeQuality: parameters.includeQuality !== false, - includeProgress: parameters.includeProgress !== false, - timeRange: 30, - }, - context, - ), - getTopDomains( - { - limit: parameters.topDomainsLimit || 10, - }, - context, - ), - getActivityTrends( - { - timeRange: parameters.timeRange || "30d", - granularity: parameters.activityGranularity || "day", - }, - context, - ), - getExtractionAnalytics( - { - timeRange: parameters.timeRange || "30d", - }, - context, - ), - getRecentKnowledgeItems({ limit: 10, type: "all" }, context), - ]); - - // Get basic website statistics from websiteCollection - const websiteCollection = context.agentContext.websiteCollection; - let totalSites = 0; - let totalBookmarks = 0; - let totalHistory = 0; - let totalActions = 0; - - if (websiteCollection) { - const websites = websiteCollection.messages.getAll(); - totalSites = websites.length; - - // Count bookmarks vs history and total actions - websites.forEach((site) => { - const metadata = site.metadata as website.WebsiteDocPartMeta; - if (metadata?.bookmarkDate) { - totalBookmarks++; - } else { - totalHistory++; - } - - // Count actions in this site's knowledge - const knowledge = site.getKnowledge(); - if (knowledge) { - const actions = - (knowledge as any).actions || - (knowledge as any).detectedActions || - []; - if (Array.isArray(actions)) { - totalActions += actions.length; - } - } - }); - } - + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) return empty; + const source = await memory.getSource(parameters.url); + if (source === undefined) return empty; + const graph = await memory.getKnowledgeGraph(); + const counts = sourceIdsFor(source.sourceId, graph); return { - overview: { - totalSites, - totalBookmarks, - totalHistory, - topDomains: topDomains.domains?.length || 0, - knowledgeExtracted: knowledgeStats.totalPages || 0, - }, - knowledge: { - extractionProgress: knowledgeStats.extractionProgress || { - entityProgress: 0, - topicProgress: 0, - actionProgress: 0, - }, - qualityDistribution: knowledgeStats.qualityDistribution || { - highQuality: 0, - mediumQuality: 0, - lowQuality: 0, - }, - totalEntities: knowledgeStats.totalEntities || 0, - totalTopics: knowledgeStats.totalTopics || 0, - totalActions: totalActions, - totalRelationships: knowledgeStats.totalRelationships || 0, - recentItems: knowledgeStats.recentActivity || [], - recentEntities: recentKnowledgeItems.entities || [], - recentTopics: recentKnowledgeItems.topics || [], - recentActions: recentKnowledgeItems.actions || [], - recentRelationships: recentKnowledgeItems.relationships || [], - }, - domains: { - topDomains: topDomains.domains || [], - totalSites: topDomains.totalSites || 0, - }, - activity: { - trends: activityTrends.trends || [], - summary: activityTrends.summary || { - totalActivity: 0, - peakDay: null, - averagePerDay: 0, - timeRange: parameters.timeRange || "30d", - }, - }, - analytics: { - extractionMetrics: extractionAnalytics.analytics || {}, - qualityReport: extractionAnalytics.analytics || {}, - }, - }; - } catch (error) { - console.error("Error aggregating analytics data:", error); - // Return empty analytics data on error - return { - overview: { - totalSites: 0, - totalBookmarks: 0, - totalHistory: 0, - topDomains: 0, - knowledgeExtracted: 0, - }, - knowledge: { - extractionProgress: { - entityProgress: 0, - topicProgress: 0, - actionProgress: 0, - }, - qualityDistribution: { - highQuality: 0, - mediumQuality: 0, - lowQuality: 0, - }, - totalEntities: 0, - totalTopics: 0, - totalActions: 0, - totalRelationships: 0, - recentItems: [], - }, - domains: { - topDomains: [], - totalSites: 0, - }, - activity: { - trends: [], - summary: { - totalActivity: 0, - peakDay: null, - averagePerDay: 0, - timeRange: parameters.timeRange || "30d", - }, - }, - analytics: { - extractionMetrics: {}, - qualityReport: {}, - }, + score: qualityScore(counts), + entityCount: counts.entityCount, + topicCount: counts.topicCount, + actionCount: counts.relationshipCount, + extractionMode: + typeof source.metadata?.extractionMode === "string" + ? source.metadata.extractionMode + : "durable", + lastUpdated: sourceTimestamp(source) ?? null, }; + } catch { + return empty; } } -// Helper function dependencies for getAnalyticsData export async function getRecentKnowledgeItems( parameters: { limit?: number; @@ -378,235 +224,65 @@ export async function getRecentKnowledgeItems( success: boolean; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { + const { sources, graph } = await loadSnapshot(context); + const limit = parameters.limit ?? 10; + const type = parameters.type ?? "all"; + const sourceById = new Map( + sources.map((source) => [source.sourceId, source]), + ); + const details = (sourceId: string) => { + const source = sourceById.get(sourceId); return { - entities: [], - topics: [], - actions: [], - relationships: [], - success: false, + fromPage: source?.title ?? "Unknown Page", + extractedAt: + source === undefined ? "" : (sourceTimestamp(source) ?? ""), }; - } - - const websites = websiteCollection.messages.getAll(); - const limit = parameters.limit || 10; - const type = parameters.type || "all"; - - const recentEntities: Array<{ - name: string; - type: string; - fromPage: string; - extractedAt: string; - }> = []; - const recentTopics: Array<{ - name: string; - fromPage: string; - extractedAt: string; - }> = []; - const recentActions: Array<{ - type: string; - element: string; - text?: string; - confidence: number; - fromPage: string; - extractedAt: string; - }> = []; - const recentRelationships: Array<{ - from: string; - relationship: string; - to: string; - confidence: number; - fromPage: string; - extractedAt: string; - }> = []; - - // Process all websites and extract entities/topics with timestamps - for (const site of websites) { - const knowledge = site.getKnowledge(); - const metadata = site.metadata as any; - const extractedAt = - metadata.visitDate || - metadata.bookmarkDate || - new Date().toISOString(); - const pageTitle = metadata.title || metadata.url || "Unknown Page"; - - if (knowledge) { - // Extract entities - if ( - (type === "entities" || type === "all") && - knowledge.entities - ) { - for (const entity of knowledge.entities) { - recentEntities.push({ - name: entity.name, - type: Array.isArray(entity.type) - ? entity.type.join(", ") - : entity.type, - fromPage: pageTitle, - extractedAt: extractedAt, - }); - } - } - - // Extract topics - if ((type === "topics" || type === "all") && knowledge.topics) { - for (const topic of knowledge.topics) { - recentTopics.push({ - name: topic, - fromPage: pageTitle, - extractedAt: extractedAt, - }); - } - } - - // Extract actions (if available) - // Note: Actions might not be available in current website-memory structure - if (type === "actions" || type === "all") { - // Try to get actions from various possible sources in the knowledge object - const actions = - (knowledge as any).actions || - (knowledge as any).detectedActions || - []; - - if (Array.isArray(actions)) { - for (const action of actions) { - // Handle different action object structures gracefully - const actionType = - (action as any).actionType || - (action as any).type || - "unknown"; - const actionElement = - (action as any).target?.name || - (action as any).name || - (action as any).element || - "element"; - const actionText = - (action as any).name || - (action as any).text || - (action as any).target?.name; - const actionConfidence = - (action as any).confidence || 0.8; - - recentActions.push({ - type: actionType, - element: actionElement, - text: actionText, - confidence: actionConfidence, - fromPage: pageTitle, - extractedAt: extractedAt, - }); - } - } - } - - // Extract relationships from actions data - // This provides properly formatted relationship data for the UI - if (type === "relationships" || type === "all") { - const actions = (knowledge as any).actions || []; - - if (Array.isArray(actions)) { - for (const action of actions) { - // Transform action data to relationship format - const from = - action.subjectEntityName || "Unknown Entity"; - const relationship = - action.verbs?.join(", ") || "related to"; - const to = - action.objectEntityName || "Unknown Target"; - const confidence = action.confidence || 0.8; - - recentRelationships.push({ - from: from, - relationship: relationship, - to: to, - confidence: confidence, - fromPage: pageTitle, - extractedAt: extractedAt, - }); - } - } - } - } - } - - // Sort by extraction date (most recent first) and limit results - recentEntities.sort( - (a, b) => - new Date(b.extractedAt).getTime() - - new Date(a.extractedAt).getTime(), - ); - recentTopics.sort( - (a, b) => - new Date(b.extractedAt).getTime() - - new Date(a.extractedAt).getTime(), - ); - recentActions.sort( - (a, b) => - new Date(b.extractedAt).getTime() - - new Date(a.extractedAt).getTime(), - ); - recentRelationships.sort( - (a, b) => - new Date(b.extractedAt).getTime() - - new Date(a.extractedAt).getTime(), - ); - - // Remove duplicates while preserving order - const uniqueEntities = recentEntities - .filter( - (entity, index, arr) => - arr.findIndex( - (e) => - e.name.toLowerCase() === entity.name.toLowerCase(), - ) === index, - ) - .slice(0, limit); - - const uniqueTopics = recentTopics - .filter( - (topic, index, arr) => - arr.findIndex( - (t) => - t.name.toLowerCase() === topic.name.toLowerCase(), - ) === index, - ) - .slice(0, limit); - - const uniqueActions = recentActions - .filter( - (action, index, arr) => - arr.findIndex( - (a) => - a.type === action.type && - a.element === action.element && - a.fromPage === action.fromPage, - ) === index, - ) - .slice(0, limit); - - const uniqueRelationships = recentRelationships - .filter( - (relationship, index, arr) => - arr.findIndex( - (r) => - r.from === relationship.from && - r.relationship === relationship.relationship && - r.to === relationship.to, - ) === index, - ) - .slice(0, limit); - + }; + const entities = + type === "entities" || type === "all" + ? graph.entities.flatMap((entity) => + entity.sourceIds.map((sourceId) => ({ + name: entity.name, + type: entity.types.join(", "), + ...details(sourceId), + })), + ) + : []; + const topics = + type === "topics" || type === "all" + ? graph.topics.flatMap((topic) => + topic.sourceIds.map((sourceId) => ({ + name: topic.name, + ...details(sourceId), + })), + ) + : []; + const relationships = + type === "relationships" || type === "all" + ? graph.relationships.flatMap((relationship) => + relationship.sourceIds.map((sourceId) => ({ + from: relationship.fromEntity, + relationship: relationship.relationshipType, + to: relationship.toEntity, + confidence: 0.8, + ...details(sourceId), + })), + ) + : []; + const newestFirst = (items: T[]) => + items + .sort((left, right) => + right.extractedAt.localeCompare(left.extractedAt), + ) + .slice(0, limit); return { - entities: uniqueEntities, - topics: uniqueTopics, - actions: uniqueActions, - relationships: uniqueRelationships, + entities: newestFirst(entities), + topics: newestFirst(topics), + actions: [], + relationships: newestFirst(relationships), success: true, }; - } catch (error) { - console.error("Error getting recent knowledge items:", error); + } catch { return { entities: [], topics: [], @@ -618,80 +294,47 @@ export async function getRecentKnowledgeItems( } export async function getTopDomains( - parameters: { - limit?: number; - }, + parameters: { limit?: number }, context: SessionContext, ): Promise<{ - domains: Array<{ - domain: string; - count: number; - percentage: number; - }>; + domains: Array<{ domain: string; count: number; percentage: number }>; totalSites: number; success: boolean; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - domains: [], - totalSites: 0, - success: false, - }; - } - - const websites = websiteCollection.messages.getAll(); - const limit = parameters.limit || 10; - - // Count sites by domain - const domainCounts: { [domain: string]: number } = {}; - const totalCount = websites.length; - - for (const site of websites) { - const metadata = site.metadata as any; - const domain = metadata.domain || "unknown"; - domainCounts[domain] = (domainCounts[domain] || 0) + 1; + const { sources } = await loadSnapshot(context); + const counts = new Map(); + for (const source of sources) { + const domain = sourceDomain(source); + counts.set(domain, (counts.get(domain) ?? 0) + 1); } - - // Sort by count and limit results - const sortedDomains = Object.entries(domainCounts) - .sort(([, a], [, b]) => b - a) - .slice(0, limit) - .map(([domain, count]) => ({ - domain, - count, - percentage: parseFloat(((count / totalCount) * 100).toFixed(1)), - })); - return { - domains: sortedDomains, - totalSites: totalCount, + domains: [...counts] + .sort((left, right) => right[1] - left[1]) + .slice(0, parameters.limit ?? 10) + .map(([domain, count]) => ({ + domain, + count, + percentage: + sources.length === 0 + ? 0 + : Number( + ((count / sources.length) * 100).toFixed(1), + ), + })), + totalSites: sources.length, success: true, }; - } catch (error) { - console.error("Error getting top domains:", error); - return { - domains: [], - totalSites: 0, - success: false, - }; + } catch { + return { domains: [], totalSites: 0, success: false }; } } export async function getActivityTrends( - parameters: { - timeRange?: string; - granularity?: string; - }, + parameters: { timeRange?: string; granularity?: string }, context: SessionContext, ): Promise<{ - trends: Array<{ - date: string; - visits: number; - bookmarks: number; - }>; + trends: Array<{ date: string; visits: number; bookmarks: number }>; summary: { totalActivity: number; peakDay: string | null; @@ -700,123 +343,59 @@ export async function getActivityTrends( }; success: boolean; }> { + const timeRange = parameters.timeRange ?? "30d"; try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - trends: [], - summary: { - totalActivity: 0, - peakDay: null, - averagePerDay: 0, - timeRange: parameters.timeRange || "30d", - }, - success: false, - }; - } - - const websites = websiteCollection.messages.getAll(); - const timeRange = parameters.timeRange || "30d"; - - // Calculate date range - const endDate = new Date(); - const startDate = new Date(); - switch (timeRange) { - case "7d": - startDate.setDate(endDate.getDate() - 7); - break; - case "30d": - startDate.setDate(endDate.getDate() - 30); - break; - case "90d": - startDate.setDate(endDate.getDate() - 90); - break; - default: - startDate.setDate(endDate.getDate() - 30); - } - - // Extract activity data from websites - const activityMap = new Map< + const { sources } = await loadSnapshot(context); + const days = Number.parseInt(timeRange, 10) || 30; + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + const activity = new Map< string, { visits: number; bookmarks: number } >(); - - for (const site of websites) { - const metadata = site.metadata as any; - - // Process visit dates - if (metadata.visitDate) { - const visitDate = new Date(metadata.visitDate); - if (visitDate >= startDate && visitDate <= endDate) { - const dateKey = visitDate.toISOString().split("T")[0]; - const current = activityMap.get(dateKey) || { - visits: 0, - bookmarks: 0, - }; - current.visits += metadata.visitCount || 1; - activityMap.set(dateKey, current); - } - } - - // Process bookmark dates - if (metadata.bookmarkDate) { - const bookmarkDate = new Date(metadata.bookmarkDate); - if (bookmarkDate >= startDate && bookmarkDate <= endDate) { - const dateKey = bookmarkDate.toISOString().split("T")[0]; - const current = activityMap.get(dateKey) || { - visits: 0, - bookmarks: 0, - }; - current.bookmarks += 1; - activityMap.set(dateKey, current); - } - } + for (const source of sources) { + const timestamp = sourceTimestamp(source); + if (timestamp === undefined || Date.parse(timestamp) < cutoff) + continue; + const date = timestamp.slice(0, 10); + const current = activity.get(date) ?? { visits: 0, bookmarks: 0 }; + if (source.metadata?.source === "bookmark") current.bookmarks++; + else current.visits++; + activity.set(date, current); } - - // Convert to trends array - const trends = Array.from(activityMap.entries()) - .map(([date, activity]) => ({ - date, - visits: activity.visits, - bookmarks: activity.bookmarks, - })) - .sort((a, b) => a.date.localeCompare(b.date)); - - // Calculate summary statistics - const totalVisits = trends.reduce((sum, t) => sum + t.visits, 0); - const totalBookmarks = trends.reduce((sum, t) => sum + t.bookmarks, 0); - const peakDay = trends.reduce( - (peak, current) => - current.visits + current.bookmarks > - peak.visits + peak.bookmarks - ? current - : peak, - trends[0] || { date: null, visits: 0, bookmarks: 0 }, + const trends = [...activity] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([date, counts]) => ({ date, ...counts })); + const totalActivity = trends.reduce( + (sum, item) => sum + item.visits + item.bookmarks, + 0, + ); + const peak = trends.reduce<(typeof trends)[number] | undefined>( + (best, item) => + best === undefined || + item.visits + item.bookmarks > best.visits + best.bookmarks + ? item + : best, + undefined, ); - return { trends, summary: { - totalActivity: totalVisits + totalBookmarks, - peakDay: peakDay.date, + totalActivity, + peakDay: peak?.date ?? null, averagePerDay: - trends.length > 0 - ? (totalVisits + totalBookmarks) / trends.length - : 0, + trends.length === 0 ? 0 : totalActivity / trends.length, timeRange, }, success: true, }; - } catch (error) { - console.error("Error getting activity trends:", error); + } catch { return { trends: [], summary: { totalActivity: 0, peakDay: null, averagePerDay: 0, - timeRange: parameters.timeRange || "30d", + timeRange, }, success: false, }; @@ -831,333 +410,199 @@ export async function getDetailedKnowledgeStats( }, context: SessionContext, ): Promise { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return createEmptyKnowledgeStats(); + try { + const { sources, graph } = await loadSnapshot(context); + const entityTypes = new Map(); + for (const entity of graph.entities) { + for (const type of entity.types) { + entityTypes.set(type, (entityTypes.get(type) ?? 0) + 1); + } + } + const domainCounts = new Map(); + const activityCounts = new Map(); + const sourceKnowledge = sources.map((source) => { + const domain = sourceDomain(source); + domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1); + const timestamp = sourceTimestamp(source); + if (timestamp !== undefined) { + const date = timestamp.slice(0, 10); + activityCounts.set(date, (activityCounts.get(date) ?? 0) + 1); + } + return sourceIdsFor(source.sourceId, graph); + }); + const pagesWithEntities = sourceKnowledge.filter( + (item) => item.entityCount > 0, + ).length; + const pagesWithTopics = sourceKnowledge.filter( + (item) => item.topicCount > 0, + ).length; + const pagesWithActions = sourceKnowledge.filter( + (item) => item.relationshipCount > 0, + ).length; + const percentage = (count: number) => + sources.length === 0 ? 0 : (count / sources.length) * 100; + const scores = sourceKnowledge.map(qualityScore); + return { + totalPages: sources.length, + totalEntities: graph.entities.length, + totalTopics: graph.topics.length, + totalRelationships: graph.relationships.length, + uniqueDomains: domainCounts.size, + topEntityTypes: [...entityTypes] + .sort((left, right) => right[1] - left[1]) + .slice(0, 10) + .map(([type, count]) => ({ type, count })), + topDomains: [...domainCounts] + .sort((left, right) => right[1] - left[1]) + .slice(0, 10) + .map(([domain, pageCount]) => ({ domain, pageCount })), + recentActivity: [...activityCounts] + .sort(([left], [right]) => right.localeCompare(left)) + .slice(0, parameters.timeRange ?? 30) + .map(([date, pagesIndexed]) => ({ date, pagesIndexed })), + storageSize: { + totalBytes: 0, + entitiesBytes: 0, + contentBytes: 0, + metadataBytes: 0, + }, + extractionProgress: { + entityProgress: percentage(pagesWithEntities), + topicProgress: percentage(pagesWithTopics), + actionProgress: percentage(pagesWithActions), + }, + qualityDistribution: { + highQuality: scores.filter((score) => score >= 0.8).length, + mediumQuality: scores.filter( + (score) => score >= 0.5 && score < 0.8, + ).length, + lowQuality: scores.filter((score) => score < 0.5).length, + }, + completionRates: { + pagesWithEntities, + pagesWithTopics, + pagesWithActions, + totalProcessedPages: sources.length, + }, + }; + } catch { + return { + totalPages: 0, + totalEntities: 0, + totalTopics: 0, + totalRelationships: 0, + uniqueDomains: 0, + topEntityTypes: [], + topDomains: [], + recentActivity: [], + storageSize: { + totalBytes: 0, + entitiesBytes: 0, + contentBytes: 0, + metadataBytes: 0, + }, + extractionProgress: { + entityProgress: 0, + topicProgress: 0, + actionProgress: 0, + }, + qualityDistribution: { + highQuality: 0, + mediumQuality: 0, + lowQuality: 0, + }, + completionRates: { + pagesWithEntities: 0, + pagesWithTopics: 0, + pagesWithActions: 0, + totalProcessedPages: 0, + }, + }; } - - const websites = websiteCollection.messages.getAll(); - - // Calculate base stats - const baseStats = await calculateBaseStats(websites); - - // Calculate extraction progress - const extractionProgress = calculateExtractionProgress(websites); - - // Calculate quality distribution - const qualityDistribution = - parameters.includeQuality !== false - ? calculateQualityDistribution(websites) - : { highQuality: 0, mediumQuality: 0, lowQuality: 0 }; - - // Calculate completion rates - const completionRates = calculateCompletionRates(websites); - - return { - ...baseStats, - extractionProgress, - qualityDistribution, - completionRates, - }; } -function createEmptyKnowledgeStats(): DetailedKnowledgeStats { +export async function getAnalyticsData( + parameters: { + timeRange?: string; + includeQuality?: boolean; + includeProgress?: boolean; + topDomainsLimit?: number; + activityGranularity?: "day" | "week" | "month"; + }, + context: SessionContext, +): Promise { + const [stats, domains, activity, extraction, recent, quality] = + await Promise.all([ + getDetailedKnowledgeStats( + { + ...(parameters.includeQuality === undefined + ? {} + : { includeQuality: parameters.includeQuality }), + ...(parameters.includeProgress === undefined + ? {} + : { includeProgress: parameters.includeProgress }), + timeRange: Number.parseInt( + parameters.timeRange ?? "30", + 10, + ), + }, + context, + ), + getTopDomains({ limit: parameters.topDomainsLimit ?? 10 }, context), + getActivityTrends( + { + timeRange: parameters.timeRange ?? "30d", + granularity: parameters.activityGranularity ?? "day", + }, + context, + ), + getExtractionAnalytics( + { timeRange: parameters.timeRange ?? "30d" }, + context, + ), + getRecentKnowledgeItems({ limit: 10, type: "all" }, context), + generateQualityReport({}, context), + ]); + const sources = await context.agentContext.browserMemoryService + ?.listSources() + .catch(() => []); + const totalBookmarks = + sources?.filter((source) => source.metadata?.source === "bookmark") + .length ?? 0; + const totalHistory = + sources?.filter((source) => source.metadata?.source === "history") + .length ?? 0; return { - totalPages: 0, - totalEntities: 0, - totalTopics: 0, - totalRelationships: 0, - uniqueDomains: 0, - topEntityTypes: [], - topDomains: [], - recentActivity: [], - storageSize: { - totalBytes: 0, - entitiesBytes: 0, - contentBytes: 0, - metadataBytes: 0, + overview: { + totalSites: stats.totalPages, + totalBookmarks, + totalHistory, + topDomains: domains.domains.length, + knowledgeExtracted: stats.completionRates.totalProcessedPages, }, - extractionProgress: { - entityProgress: 0, - topicProgress: 0, - actionProgress: 0, + knowledge: { + extractionProgress: stats.extractionProgress, + qualityDistribution: stats.qualityDistribution, + totalEntities: stats.totalEntities, + totalTopics: stats.totalTopics, + totalActions: stats.totalRelationships, + totalRelationships: stats.totalRelationships, + recentEntities: recent.entities, + recentTopics: recent.topics, + recentActions: recent.actions, + recentRelationships: recent.relationships, }, - qualityDistribution: { - highQuality: 0, - mediumQuality: 0, - lowQuality: 0, + domains: { + topDomains: domains.domains, + totalSites: domains.totalSites, }, - completionRates: { - pagesWithEntities: 0, - pagesWithTopics: 0, - pagesWithActions: 0, - totalProcessedPages: 0, + activity: { + trends: activity.trends, + summary: activity.summary, }, - }; -} - -async function calculateBaseStats(websites: any[]): Promise<{ - totalPages: number; - totalEntities: number; - totalTopics: number; - totalRelationships: number; - uniqueDomains: number; - topEntityTypes: Array<{ type: string; count: number }>; - topDomains: Array<{ domain: string; pageCount: number }>; - recentActivity: Array<{ date: string; pagesIndexed: number }>; - storageSize: { - totalBytes: number; - entitiesBytes: number; - contentBytes: number; - metadataBytes: number; - }; -}> { - let totalEntities = 0; - let totalTopics = 0; - let totalRelationships = 0; - const domains = new Set(); - const entityTypeCounts = new Map(); - const domainCounts = new Map(); - const uniqueTopicsSet = new Set(); - let totalContent = 0; - - for (const site of websites) { - try { - const knowledge = site.getKnowledge(); - const metadata = site.metadata as website.WebsiteDocPartMeta; - - // Extract domain from URL - if (metadata?.url) { - try { - const domain = new URL(metadata.url).hostname; - domains.add(domain); - domainCounts.set( - domain, - (domainCounts.get(domain) || 0) + 1, - ); - } catch (error) { - // Invalid URL, skip domain extraction - } - } - - if (knowledge) { - // Count entities and their types - if (knowledge.entities?.length > 0) { - totalEntities += knowledge.entities.length; - knowledge.entities.forEach((entity: any) => { - const type = entity.type || "Unknown"; - entityTypeCounts.set( - type, - (entityTypeCounts.get(type) || 0) + 1, - ); - }); - } - - // Count unique topics - if (knowledge.topics?.length > 0) { - knowledge.topics.forEach((topic: string) => { - uniqueTopicsSet.add(topic.toLowerCase().trim()); - }); - } - - // Count relationships/actions - if (knowledge.actions?.length > 0) { - totalRelationships += knowledge.actions.length; - } - } - - // Calculate content size - const textContent = site.textChunks?.join("") || ""; - totalContent += textContent.length; - } catch (error) { - console.warn("Error processing site for stats:", error); - } - } - - // Set totalTopics to the count of unique topics found - totalTopics = uniqueTopicsSet.size; - - // Convert entity types to sorted array - const topEntityTypes = Array.from(entityTypeCounts.entries()) - .sort(([, a], [, b]) => b - a) - .slice(0, 10) - .map(([type, count]) => ({ type, count })); - - // Convert domains to sorted array - const topDomains = Array.from(domainCounts.entries()) - .sort(([, a], [, b]) => b - a) - .slice(0, 10) - .map(([domain, pageCount]) => ({ domain, pageCount })); - - // Simple recent activity (last 7 days) - const recentActivity = generateRecentActivity(websites); - - return { - totalPages: websites.length, - totalEntities, - totalTopics, - totalRelationships, - uniqueDomains: domains.size, - topEntityTypes, - topDomains, - recentActivity, - storageSize: { - totalBytes: totalContent, - entitiesBytes: Math.round(totalContent * 0.3), // Estimate - contentBytes: Math.round(totalContent * 0.6), // Estimate - metadataBytes: Math.round(totalContent * 0.1), // Estimate + analytics: { + extractionMetrics: extraction.analytics, + qualityReport: quality.report, }, }; } - -function calculateExtractionProgress(websites: any[]): { - entityProgress: number; - topicProgress: number; - actionProgress: number; -} { - let pagesWithEntities = 0; - let pagesWithTopics = 0; - let pagesWithActions = 0; - - websites.forEach((site) => { - try { - const knowledge = site.getKnowledge(); - if (knowledge) { - if (knowledge.entities?.length > 0) pagesWithEntities++; - if (knowledge.topics?.length > 0) pagesWithTopics++; - if (knowledge.actions?.length > 0) pagesWithActions++; - } - } catch (error) { - // Skip sites with knowledge extraction errors - } - }); - - const total = websites.length || 1; // Prevent division by zero - - return { - entityProgress: Math.round((pagesWithEntities / total) * 100), - topicProgress: Math.round((pagesWithTopics / total) * 100), - actionProgress: Math.round((pagesWithActions / total) * 100), - }; -} - -function calculateQualityDistribution(websites: any[]): { - highQuality: number; - mediumQuality: number; - lowQuality: number; -} { - let high = 0, - medium = 0, - low = 0; - let totalPagesWithKnowledge = 0; - - websites.forEach((site) => { - try { - const knowledge = site.getKnowledge(); - if (knowledge && knowledge.entities?.length > 0) { - totalPagesWithKnowledge++; - - // Calculate average confidence across entities - const confidences = knowledge.entities - .map((e: any) => e.confidence || 0) - .filter((c: number) => c > 0); - - if (confidences.length > 0) { - const avgConfidence = - confidences.reduce((a: number, b: number) => a + b) / - confidences.length; - - if (avgConfidence >= 0.8) high++; - else if (avgConfidence >= 0.5) medium++; - else low++; - } else { - // No confidence scores, assume medium quality - medium++; - } - } - } catch (error) { - // Skip sites with knowledge extraction errors - } - }); - - const total = totalPagesWithKnowledge || 1; - - return { - highQuality: Math.round((high / total) * 100), - mediumQuality: Math.round((medium / total) * 100), - lowQuality: Math.round((low / total) * 100), - }; -} - -function calculateCompletionRates(websites: any[]): { - pagesWithEntities: number; - pagesWithTopics: number; - pagesWithActions: number; - totalProcessedPages: number; -} { - let pagesWithEntities = 0; - let pagesWithTopics = 0; - let pagesWithActions = 0; - - websites.forEach((site) => { - try { - const knowledge = site.getKnowledge(); - if (knowledge) { - if (knowledge.entities?.length > 0) pagesWithEntities++; - if (knowledge.topics?.length > 0) pagesWithTopics++; - if (knowledge.actions?.length > 0) pagesWithActions++; - } - } catch (error) { - // Skip sites with knowledge extraction errors - } - }); - - return { - pagesWithEntities, - pagesWithTopics, - pagesWithActions, - totalProcessedPages: websites.length, - }; -} - -function generateRecentActivity( - websites: any[], -): Array<{ date: string; pagesIndexed: number }> { - const activityMap = new Map(); - const now = new Date(); - - // Initialize last 7 days with 0 - for (let i = 6; i >= 0; i--) { - const date = new Date(now); - date.setDate(date.getDate() - i); - const dateStr = date.toISOString().split("T")[0]; - activityMap.set(dateStr, 0); - } - - // Count pages by date - websites.forEach((site) => { - try { - const metadata = site.metadata as website.WebsiteDocPartMeta; - const siteDate = metadata?.visitDate || metadata?.bookmarkDate; - - if (siteDate) { - const date = new Date(siteDate); - const dateStr = date.toISOString().split("T")[0]; - - if (activityMap.has(dateStr)) { - activityMap.set( - dateStr, - (activityMap.get(dateStr) || 0) + 1, - ); - } - } - } catch (error) { - // Skip sites with invalid dates - } - }); - - return Array.from(activityMap.entries()) - .map(([date, pagesIndexed]) => ({ date, pagesIndexed })) - .sort((a, b) => a.date.localeCompare(b.date)); -} diff --git a/ts/packages/agents/browser/src/agent/knowledge/actions/extractionActions.mts b/ts/packages/agents/browser/src/agent/knowledge/actions/extractionActions.mts index 81dddba365..0b491fbe51 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/actions/extractionActions.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/actions/extractionActions.mts @@ -26,7 +26,6 @@ import { ExtractionInput, EXTRACTION_MODE_CONFIGS, } from "@typeagent/website-memory"; -import * as website from "@typeagent/website-memory"; import { BrowserKnowledgeExtractor } from "../browserKnowledgeExtractor.mjs"; import { docPartsFromHtml } from "@typeagent/conversation-memory"; import { handleKnowledgeAction } from "./knowledgeActionRouter.mjs"; @@ -41,64 +40,6 @@ import { convert } from "html-to-text"; 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; - } -} - -function hasIndexingErrors(result: any): boolean { - return result && result.errors && result.errors.length > 0; -} - // TODO: Move this to common and use the same schema in extension and agent interface KnowledgeExtractionProgress { extractionId: string; @@ -450,13 +391,13 @@ async function saveExtractedKnowledgeWithChunks( aggregatedResults: any, context: SessionContext, ): Promise { - if (!context.agentContext.websiteCollection) { - debug("No websiteCollection available, skipping save"); - return; + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { + throw new Error("Durable browser memory is not available"); } try { - debug(`Saving extracted knowledge to index for URL: ${url}`); + debug(`Saving extracted knowledge to durable memory for URL: ${url}`); // Extract text chunks from docParts (preserves chunking structure) const allTextChunks: string[] = []; @@ -482,116 +423,19 @@ async function saveExtractedKnowledgeWithChunks( `Extracted ${allTextChunks.length} text chunks from ${extractionInputs.length} extraction inputs with ${totalDocParts} docParts`, ); - // Create WebsiteMeta - const timestamp = new Date().toISOString(); - const meta = new website.WebsiteMeta({ - url, - title, - source: "history", - visitDate: timestamp, - }); - - // Create Website object directly with chunked text - const websiteObj = new website.Website( - meta, - allTextChunks, // Preserves chunking! - [], - aggregatedResults.entities && aggregatedResults.entities.length > 0 - ? { - entities: aggregatedResults.entities.map( - (entity: any) => ({ - ...entity, - type: Array.isArray(entity.type) - ? entity.type - : [entity.type], - }), - ), - topics: - aggregatedResults.keyTopics || - aggregatedResults.topics, - actions: - getActionsFromAggregatedResults(aggregatedResults), - inverseActions: [], - } - : undefined, - undefined, // topicHierarchy - will be built during indexing - undefined, // deletionInfo - true, // isNew + await memory.ingest( + { + url, + title, + markdown: allTextChunks.join("\n\n"), + source: "current-page", + capturedAt: new Date().toISOString(), + }, + "full", ); - // Add metadata - if ( - aggregatedResults.detectedActions || - aggregatedResults.actionSummary - ) { - websiteObj.metadata.detectedActions = - aggregatedResults.detectedActions; - websiteObj.metadata.actionSummary = aggregatedResults.actionSummary; - } - if (aggregatedResults.summary) { - websiteObj.metadata.contentSummary = aggregatedResults.summary; - } - - // Check if page already exists - const isNewPage = !checkPageExistsInIndex(url, context); - - // Save to index - if (isNewPage) { - const docPart = website.WebsiteDocPart.fromWebsite(websiteObj); - const result = - await context.agentContext.websiteCollection.addWebsiteToIndex( - docPart, - ); - if (hasIndexingErrors(result)) { - debug( - "Incremental indexing failed, falling back to full rebuild", - ); - context.agentContext.websiteCollection.addWebsites([ - websiteObj, - ]); - await context.agentContext.websiteCollection.buildIndex(); - } - debug(`Saved new page to index: ${url}`); - } else { - const docPart = website.WebsiteDocPart.fromWebsite(websiteObj); - - const result = - await context.agentContext.websiteCollection.updateWebsiteInIndex( - url, - docPart, - ); - if (hasIndexingErrors(result)) { - debug( - "Incremental update failed, falling back to full rebuild", - ); - context.agentContext.websiteCollection.addWebsites([ - websiteObj, - ]); - await context.agentContext.websiteCollection.buildIndex(); - } - debug(`Updated existing page in index: ${url}`); - } - - try { - if (context.agentContext.index?.path) { - 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); - } - debug( - `Knowledge saved successfully for ${url} with ${allTextChunks.length} text chunks`, + `Knowledge stored durably for ${url} with ${allTextChunks.length} text chunks and ${aggregatedResults.entities?.length ?? 0} extracted entities`, ); } catch (error) { console.error("Error saving knowledge to index:", error); @@ -1123,29 +967,6 @@ export async function extractKnowledgeFromPageStreaming( // Global tracking for active knowledge extractions const activeKnowledgeExtractions = new Map(); -// Convert stored knowledge format (with actions) back to display format (with relationships) -function convertStoredKnowledgeToDisplayFormat(storedKnowledge: any): any { - const displayKnowledge = { ...storedKnowledge }; - - // Convert actions array to relationships array - if (storedKnowledge.actions && Array.isArray(storedKnowledge.actions)) { - displayKnowledge.relationships = storedKnowledge.actions.map( - (action: any) => ({ - from: action.subjectEntityName || "unknown", - relationship: Array.isArray(action.verbs) - ? action.verbs.join(" ") - : action.verbs || "related to", - to: action.objectEntityName || "unknown", - confidence: action.confidence || 0.8, - }), - ); - } else { - displayKnowledge.relationships = []; - } - - return displayKnowledge; -} - // Helper functions for enhanced navigation with index integration async function checkKnowledgeInIndex( url: string, @@ -1157,26 +978,26 @@ async function checkKnowledgeInIndex( // Get the session context - either directly or from action context const sessionContext = "sessionContext" in context ? context.sessionContext : context; - const websiteCollection = sessionContext.agentContext.websiteCollection; - - if (!websiteCollection) { + const memory = sessionContext.agentContext.browserMemoryService; + if (memory === undefined) { return null; } - - const websites = websiteCollection.messages.getAll(); - const foundWebsite = websites.find( - (site: any) => site.metadata.url === url, - ); - - if (foundWebsite) { - const knowledge = foundWebsite.getKnowledge(); - if (knowledge) { - return convertStoredKnowledgeToDisplayFormat(knowledge); - } + const source = await memory.getSource(url); + if (source === undefined) { return null; } - - return null; + const graph = await memory.getKnowledgeGraph(); + return { + entities: graph.entities.filter((entity) => + entity.sourceIds.includes(source.sourceId), + ), + topics: graph.topics + .filter((topic) => topic.sourceIds.includes(source.sourceId)) + .map((topic) => topic.name), + relationships: graph.relationships.filter((relationship) => + relationship.sourceIds.includes(source.sourceId), + ), + }; } catch (error) { debug("No existing knowledge found in index for:", url); return null; 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 f87c29b32a..6dd3903cf9 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/actions/graphActions.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/actions/graphActions.mts @@ -3,7 +3,12 @@ import { SessionContext } from "@typeagent/agent-sdk"; import { BrowserActionContext } from "../../browserActions.mjs"; -import { GraphCache, TopicGraphCache } from "../types/knowledgeTypes.mjs"; +import { GraphCache } from "../types/knowledgeTypes.mjs"; +import type { + MemoryKnowledgeGraph, + MemorySource, +} from "@typeagent/memory-service"; +import type { BrowserMemoryService } from "../../browserMemoryService.mjs"; import { getPerformanceTracker } from "../utils/performanceInstrumentation.mjs"; import { buildGraphologyGraph, @@ -19,29 +24,6 @@ import { invalidateAllGraphologyCaches, } from "../utils/graphologyCache.mjs"; import registerDebug from "debug"; -import { openai as ai } from "@typeagent/aiclient"; -import { createJsonTranslator } from "typechat"; -import { createTypeScriptJsonValidator } from "typechat/ts"; -import { TopicRelationshipAnalysis } from "./schema/topicRelationship.mjs"; -import fs from "fs"; -import path from "path"; -import { getBrowserPackageFilePath } from "../../utils/packageFilePath.mjs"; - -function getSchemaFileContents(fileName: string): string { - return fs.readFileSync( - getBrowserPackageFilePath( - path.join( - "src", - "agent", - "knowledge", - "actions", - "schema", - fileName, - ), - ), - "utf8", - ); -} // ============================================================================ // Topic Timeline Types @@ -95,7 +77,6 @@ const debug = registerDebug("typeagent:browser:knowledge:graph"); // Graphology Integration Helper Functions async function cacheGraphologyGraphs( - websiteCollection: any, entityGraph: any, topicGraph: any, metadata: any, @@ -138,34 +119,15 @@ async function cacheGraphologyGraphs( ); } -// Entity graph cache storage attached to websiteCollection -function getGraphCache(websiteCollection: any): GraphCache | null { - return (websiteCollection as any).__graphCache || null; +function getGraphCache(agentContext: BrowserActionContext): GraphCache | null { + return agentContext.graphCache ?? null; } -function setGraphCache(websiteCollection: any, cache: GraphCache): void { - (websiteCollection as any).__graphCache = cache; -} - -// Topic graph cache storage attached to websiteCollection -function setTopicGraphCache( - websiteCollection: any, - cache: TopicGraphCache, +function setGraphCache( + agentContext: BrowserActionContext, + cache: GraphCache, ): void { - (websiteCollection as any).__topicGraphCache = cache; -} - -// Invalidate topic cache (called on graph rebuild or knowledge import) -function invalidateTopicCache(websiteCollection: any): void { - setTopicGraphCache(websiteCollection, { - topics: [], - relationships: [], - topicMetrics: [], - lastUpdated: 0, - isValid: false, - }); - // Also clear the graphology layout cache - invalidateAllGraphologyCaches(); + agentContext.graphCache = cache; } function calculateEntityMetrics( @@ -298,16 +260,12 @@ function calculateEntityMetrics( async function ensureGraphCache( context: SessionContext, ): Promise { - const websiteCollection = context.agentContext.websiteCollection; - 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 cache = getGraphCache(context.agentContext); const sourceVersion = memoryService.getGraphVersion(); if (cache?.isValid && cache.sourceVersion === sourceVersion) { @@ -462,7 +420,7 @@ async function ensureGraphCache( sourceVersion, }; - setGraphCache(websiteCollection, newCache); + setGraphCache(context.agentContext, newCache); debug( `[Knowledge Graph] Cached ${rawEntities.length} entities, ${relationships.length} relationships, ${communities.length} communities`, @@ -479,7 +437,7 @@ async function ensureGraphCache( tracker.endOperation("ensureGraphCache", 0, 0); // Mark cache as invalid but keep existing data if available - const existingCache = getGraphCache(websiteCollection); + const existingCache = getGraphCache(context.agentContext); if (existingCache) { existingCache.isValid = false; } @@ -498,10 +456,6 @@ async function getGraphologyGraphs( topicGraph?: any; useGraphology: boolean; }> { - const websiteCollection = context.agentContext.websiteCollection; - 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"); @@ -536,12 +490,10 @@ async function getGraphologyGraphs( })), [], ); - await cacheGraphologyGraphs( - websiteCollection, - entityGraph, - topicGraph, - { buildTime: Date.now() - startedAt, source: "durable-memory" }, - ); + await cacheGraphologyGraphs(entityGraph, topicGraph, { + buildTime: Date.now() - startedAt, + source: "durable-memory", + }); return { entityGraph, topicGraph, useGraphology: true }; } catch (error) { debug(`Error getting Graphology graphs: ${error}`); @@ -562,14 +514,8 @@ async function getEntityStatistics( communityCount: number; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - if (!websiteCollection) { - console.log("[getEntityStatistics] No websiteCollection available"); - return { entityCount: 0, relationshipCount: 0, communityCount: 0 }; - } - await ensureGraphCache(context); - const cache = getGraphCache(websiteCollection); + const cache = getGraphCache(context.agentContext); console.log("[getEntityStatistics] Cache state:", { cacheExists: !!cache, @@ -744,16 +690,8 @@ export async function rebuildKnowledgeGraph( "[Knowledge Graph] Starting Graphology-only knowledge graph rebuild", ); - const websiteCollection = context.agentContext.websiteCollection; - if (!websiteCollection) { - return { - success: false, - error: "Website collection not available", - }; - } - invalidateAllGraphologyCaches(); - const cache = getGraphCache(websiteCollection); + const cache = getGraphCache(context.agentContext); if (cache) { cache.isValid = false; } @@ -780,183 +718,6 @@ export async function rebuildKnowledgeGraph( } } -async function analyzeTopicRelationshipsWithLLM(topicNames: string[]): Promise< - Map< - string, - { - action: "keep_root" | "make_child" | "merge"; - targetTopic?: string; - confidence: number; - reasoning: string; - } - > -> { - const relationshipMap = new Map(); - - if (topicNames.length === 0) { - return relationshipMap; - } - - const BATCH_SIZE = 50; - const totalTopics = topicNames.length; - const needsBatching = totalTopics > BATCH_SIZE; - - console.log(`[LLM Topic Analysis] Analyzing ${totalTopics} topics...`); - console.log(`[LLM Topic Analysis] Sample topics:`, topicNames.slice(0, 10)); - - if (needsBatching) { - const numBatches = Math.ceil(totalTopics / BATCH_SIZE); - console.log( - `[LLM Topic Analysis] Processing in ${numBatches} batches of up to ${BATCH_SIZE} topics each`, - ); - - for (let i = 0; i < numBatches; i++) { - const start = i * BATCH_SIZE; - const end = Math.min(start + BATCH_SIZE, totalTopics); - const batch = topicNames.slice(start, end); - - console.log( - `[LLM Topic Analysis] Processing batch ${i + 1}/${numBatches} (topics ${start + 1}-${end})...`, - ); - - const batchResults = await analyzeBatchOfTopics(batch, topicNames); - - for (const [topic, relationship] of batchResults) { - relationshipMap.set(topic, relationship); - } - } - - let makeChildCount = 0; - let mergeCount = 0; - let keepRootCount = 0; - const sampleRelationships: string[] = []; - - for (const [topic, relationship] of relationshipMap) { - if (relationship.action === "make_child") { - makeChildCount++; - if (sampleRelationships.length < 5) { - sampleRelationships.push( - ` "${topic}" → child of "${relationship.targetTopic}" (${relationship.confidence.toFixed(2)})`, - ); - } - } else if (relationship.action === "merge") { - mergeCount++; - if (sampleRelationships.length < 5) { - sampleRelationships.push( - ` "${topic}" → merge into "${relationship.targetTopic}" (${relationship.confidence.toFixed(2)})`, - ); - } - } else { - keepRootCount++; - } - } - - console.log(`[LLM Topic Analysis] Final Summary:`); - console.log(` - Keep as root: ${keepRootCount}`); - console.log(` - Make child: ${makeChildCount}`); - console.log(` - Merge: ${mergeCount}`); - - if (sampleRelationships.length > 0) { - console.log(`[LLM Topic Analysis] Sample relationships:`); - sampleRelationships.forEach((rel) => console.log(rel)); - } - - return relationshipMap; - } else { - return await analyzeBatchOfTopics(topicNames, topicNames); - } -} - -async function analyzeBatchOfTopics( - batchTopics: string[], - allTopics: string[], -): Promise< - Map< - string, - { - action: "keep_root" | "make_child" | "merge"; - targetTopic?: string; - confidence: number; - reasoning: string; - } - > -> { - const relationshipMap = new Map(); - - try { - const schemaText = getSchemaFileContents("topicRelationship.mts"); - - const apiSettings = ai.azureApiSettingsFromEnv( - ai.ModelType.Chat, - undefined, - "GPT_4_O", - ); - const model = ai.createChatModel(apiSettings); - - const validator = - createTypeScriptJsonValidator( - schemaText, - "TopicRelationshipAnalysis", - ); - const translator = createJsonTranslator(model, validator); - - const topicList = batchTopics - .map((t, i) => `${i + 1}. ${t}`) - .join("\n"); - - const allTopicsList = - batchTopics.length < allTopics.length - ? `\n\nFor context, here are all topics in the system (consider these as potential parent topics):\n${allTopics.join(", ")}` - : ""; - // all-topics list is getting truncated - not useful! - const prompt = `Analyze these topic names and identify semantic relationships between them. - -Topics to analyze: -${topicList}${allTopicsList} - -For each topic, determine the appropriate action based on the TopicRelationshipAnalysis schema.`; - - const estimatedPromptSize = prompt.length + schemaText.length; - const estimatedTokens = Math.ceil(estimatedPromptSize / 4); - - console.log(`[LLM Topic Analysis] Batch request details:`); - console.log(` - Batch size: ${batchTopics.length} topics`); - console.log(` - Prompt size: ${prompt.length} chars`); - console.log(` - Schema size: ${schemaText.length} chars`); - console.log( - ` - Estimated total: ${estimatedPromptSize} chars (~${estimatedTokens} tokens)`, - ); - - const response = await translator.translate(prompt); - - if (!response.success) { - console.warn("LLM batch analysis failed:", response.message); - return relationshipMap; - } - - const analysisResult = response.data; - - console.log( - `[LLM Topic Analysis] Batch received ${analysisResult.relationships.length} relationship recommendations`, - ); - - for (const relationship of analysisResult.relationships) { - if (relationship.topic && relationship.action) { - relationshipMap.set(relationship.topic, { - action: relationship.action, - targetTopic: relationship.targetTopic, - confidence: relationship.confidence || 0.5, - reasoning: relationship.reasoning || "LLM analysis", - }); - } - } - - return relationshipMap; - } catch (error) { - console.error("[LLM Topic Analysis] Batch error:", error); - return relationshipMap; - } -} export async function mergeTopicHierarchies( parameters: {}, context: SessionContext, @@ -966,45 +727,13 @@ export async function mergeTopicHierarchies( message?: string; error?: string; }> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - success: false, - mergeCount: 0, - error: "Website collection not available", - }; - } - - console.log( - "[Merge Action] Starting topic hierarchy merge with LLM analysis...", - ); - - const result = await websiteCollection.mergeTopicHierarchiesWithLLM( - analyzeTopicRelationshipsWithLLM, - ); - - invalidateTopicCache(websiteCollection); - - const message = `✓ Topic merge completed! ${result.mergeCount} topics reorganized. Reload the page to see updated hierarchy.`; - console.log(`[Merge Action] ${message}`); - - return { - success: true, - mergeCount: result.mergeCount, - message, - }; - } catch (error) { - console.error("Error merging topic hierarchies:", error); - const errorMsg = - error instanceof Error ? error.message : "Unknown error"; - return { - success: false, - mergeCount: 0, - error: `Failed to merge topics: ${errorMsg}`, - }; - } + void parameters; + void context; + return { + success: false, + mergeCount: 0, + error: "Topic hierarchy merging is unsupported for durable browser memory because MemoryService does not provide a hierarchy mutation API.", + }; } // ============================================================================ @@ -1425,6 +1154,114 @@ export async function getEntityNeighborhoodLayoutData( * Discover related entities and topics from the knowledge graph * Performs multi-hop graph traversal to find connected knowledge */ +async function loadDurableGraphSnapshot(memory: BrowserMemoryService): Promise<{ + graph: MemoryKnowledgeGraph; + sources: MemorySource[]; + sourcesById: Map; +}> { + const [graph, sources] = await Promise.all([ + memory.getKnowledgeGraph(), + memory.listSources(), + ]); + return { + graph, + sources, + sourcesById: new Map( + sources.map((source) => [source.sourceId, source]), + ), + }; +} + +function countSourceOverlap(left: string[], right: string[]): number { + const rightIds = new Set(right); + return left.reduce( + (count, sourceId) => count + (rightIds.has(sourceId) ? 1 : 0), + 0, + ); +} + +function getActiveRevisionTimestamp(source: MemorySource): string | undefined { + const revision = source.revisions.find( + (candidate) => candidate.revisionId === source.activeRevisionId, + ); + return revision?.capturedAt ?? revision?.indexedAt; +} + +async function getSourcesById( + memory: BrowserMemoryService, + sourceIds: string[], +): Promise { + const sources = await Promise.all( + [...new Set(sourceIds)].map((sourceId) => + memory.getSourceById(sourceId), + ), + ); + return sources.filter( + (source): source is MemorySource => source !== undefined, + ); +} + +function getRelatedTopicsBySourceOverlap( + seedTopics: string[], + depth: number, + graph: MemoryKnowledgeGraph, +): Map { + const topicsByName = new Map( + graph.topics.map((topic) => [topic.name.toLowerCase(), topic]), + ); + const seedNames = new Set(seedTopics.map((topic) => topic.toLowerCase())); + const related = new Map< + string, + { name: string; cooccurrenceCount: number; distance: number } + >(); + let frontier = [...seedNames]; + + for (let distance = 1; distance <= Math.max(1, depth); distance++) { + const nextFrontier = new Set(); + for (const currentName of frontier) { + const current = topicsByName.get(currentName); + if (current === undefined) { + continue; + } + for (const candidate of graph.topics) { + const candidateName = candidate.name.toLowerCase(); + if ( + candidateName === currentName || + seedNames.has(candidateName) + ) { + continue; + } + const overlap = countSourceOverlap( + current.sourceIds, + candidate.sourceIds, + ); + if (overlap === 0) { + continue; + } + const existing = related.get(candidateName); + if ( + existing === undefined || + distance < existing.distance || + (distance === existing.distance && + overlap > existing.cooccurrenceCount) + ) { + related.set(candidateName, { + name: candidate.name, + cooccurrenceCount: overlap, + distance, + }); + } + nextFrontier.add(candidateName); + } + } + frontier = [...nextFrontier]; + if (frontier.length === 0) { + break; + } + } + return related; +} + export async function discoverRelatedKnowledge( parameters: { entities: Array<{ name: string; type: string }>; @@ -1451,9 +1288,8 @@ export async function discoverRelatedKnowledge( success: boolean; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - if (!websiteCollection) { - debug("[discoverRelatedKnowledge] No website collection available"); + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { return { relatedEntities: [], relatedTopics: [], @@ -1464,12 +1300,39 @@ export async function discoverRelatedKnowledge( const depth = parameters.depth || 2; const maxEntities = parameters.maxEntities || 10; const maxTopics = parameters.maxTopics || 10; + const { graph } = await loadDurableGraphSnapshot(memory); debug( `[discoverRelatedKnowledge] Starting discovery with ${parameters.entities.length} entities, ${parameters.topics.length} topics, depth=${depth}`, ); - // Discover related entities via graph traversal + const entitiesByName = new Map( + graph.entities.map((entity) => [entity.name.toLowerCase(), entity]), + ); + const seedEntityNames = new Set( + parameters.entities.map((entity) => entity.name.toLowerCase()), + ); + const adjacency = new Map< + string, + Array<{ name: string; relationshipType: string }> + >(); + for (const relationship of graph.relationships) { + const from = relationship.fromEntity.toLowerCase(); + const to = relationship.toEntity.toLowerCase(); + const fromEdges = adjacency.get(from) ?? []; + fromEdges.push({ + name: relationship.toEntity, + relationshipType: relationship.relationshipType, + }); + adjacency.set(from, fromEdges); + const toEdges = adjacency.get(to) ?? []; + toEdges.push({ + name: relationship.fromEntity, + relationshipType: relationship.relationshipType, + }); + adjacency.set(to, toEdges); + } + const relatedEntitiesMap = new Map< string, { @@ -1481,158 +1344,63 @@ export async function discoverRelatedKnowledge( cooccurrenceCount: number; } >(); - - // Traverse from each seed entity for (const seedEntity of parameters.entities) { - try { - const neighborhoodResult = await getEntityNeighborhood( - { entityId: seedEntity.name, depth, maxNodes: 50 }, - context, - ); - - if (neighborhoodResult.neighbors) { - for (const neighbor of neighborhoodResult.neighbors) { - // Skip if this is one of the seed entities - if ( - parameters.entities.some( - (e) => - e.name.toLowerCase() === - neighbor.name.toLowerCase(), - ) - ) { + let frontier = [ + { + name: seedEntity.name, + relationshipPath: [] as string[], + }, + ]; + const visited = new Set([seedEntity.name.toLowerCase()]); + for (let distance = 1; distance <= depth; distance++) { + const nextFrontier: typeof frontier = []; + for (const current of frontier) { + for (const edge of adjacency.get( + current.name.toLowerCase(), + ) ?? []) { + const normalizedName = edge.name.toLowerCase(); + if (visited.has(normalizedName)) { continue; } - - const existingEntry = relatedEntitiesMap.get( - neighbor.name.toLowerCase(), - ); - - // Calculate distance from relationships - const relationships = - neighborhoodResult.relationships?.filter( - (r: any) => - r.toEntity === neighbor.name || - r.fromEntity === neighbor.name, - ) || []; - - const distance = relationships.length > 0 ? 1 : depth; - - // Calculate co-occurrence count (how many pages this entity appears on) - const cooccurrenceCount = - neighbor.occurrences?.length || 1; - - if ( - !existingEntry || - distance < existingEntry.distance - ) { - // Get relationship path - const relationshipPath: string[] = []; - if (relationships.length > 0) { - relationshipPath.push( - relationships[0].relationshipType || - "related_to", - ); - } - - relatedEntitiesMap.set( - neighbor.name.toLowerCase(), - { - name: neighbor.name, - type: neighbor.type || "unknown", - relationshipPath, - distance, - confidence: neighbor.confidence || 0.5, - cooccurrenceCount, - }, - ); + visited.add(normalizedName); + const relationshipPath = [ + ...current.relationshipPath, + edge.relationshipType, + ]; + nextFrontier.push({ + name: edge.name, + relationshipPath, + }); + if (seedEntityNames.has(normalizedName)) { + continue; + } + const entity = entitiesByName.get(normalizedName); + const existing = relatedEntitiesMap.get(normalizedName); + if (entity !== undefined && existing === undefined) { + relatedEntitiesMap.set(normalizedName, { + name: entity.name, + type: entity.types[0] ?? "unknown", + relationshipPath, + distance, + confidence: 1, + cooccurrenceCount: entity.sourceIds.length, + }); } } } - } catch (error) { - debug( - `[discoverRelatedKnowledge] Error processing entity ${seedEntity.name}: ${error}`, - ); - } - } - - // Discover related topics via co-occurrence - const relatedTopicsMap = new Map< - string, - { - name: string; - cooccurrenceCount: number; - distance: number; - } - >(); - - if (parameters.topics.length > 0) { - try { - const expandedTopics = await expandTopicNeighborhood( - parameters.topics, - depth, - websiteCollection, - ); - - for (const topic of expandedTopics) { - // Skip if this is one of the seed topics - if ( - parameters.topics.some( - (t) => t.toLowerCase() === topic.toLowerCase(), - ) - ) { - continue; - } - - // Get co-occurrence count - let cooccurrenceCount = 0; - if ( - websiteCollection.knowledgeTopics && - (websiteCollection.knowledgeTopics as any) - .getRelatedTopics - ) { - const relatedEntries = ( - websiteCollection.knowledgeTopics as any - ).getRelatedTopics(topic, 100); - cooccurrenceCount = relatedEntries?.length || 1; - } - - // Calculate distance (1 for direct co-occurrence, 2+ for multi-hop) - const isDirectlyRelated = parameters.topics.some( - (seedTopic) => { - if ( - websiteCollection.knowledgeTopics && - (websiteCollection.knowledgeTopics as any) - .getRelatedTopics - ) { - const related = - ( - websiteCollection.knowledgeTopics as any - ).getRelatedTopics(seedTopic, 50) || []; - return related.some( - (r: any) => - r.topic?.toLowerCase() === - topic.toLowerCase(), - ); - } - return false; - }, - ); - - const distance = isDirectlyRelated ? 1 : 2; - - relatedTopicsMap.set(topic.toLowerCase(), { - name: topic, - cooccurrenceCount, - distance, - }); + frontier = nextFrontier; + if (frontier.length === 0) { + break; } - } catch (error) { - debug( - `[discoverRelatedKnowledge] Error expanding topics: ${error}`, - ); } } + const relatedTopicsMap = getRelatedTopicsBySourceOverlap( + parameters.topics, + depth, + graph, + ); + // Rank and filter entities const rankedEntities = Array.from(relatedEntitiesMap.values()) .map((entity) => ({ @@ -1699,30 +1467,9 @@ export async function getGlobalImportanceLayer( }; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - console.log(`[ServerPerf] No website collection available`); - return { - graphologyLayout: { - elements: [], - layoutDuration: 0, - avgSpacing: 0, - communityCount: 0, - }, - metadata: { - totalEntitiesInSystem: 0, - selectedEntityCount: 0, - coveragePercentage: 0, - importanceThreshold: 0, - layer: "global_importance", - }, - }; - } - // Ensure cache is populated (this loads from Graphology and creates the cache) await ensureGraphCache(context); - const cache = getGraphCache(websiteCollection); + const cache = getGraphCache(context.agentContext); if (!cache || !cache.isValid) { console.log( @@ -2059,28 +1806,8 @@ export async function getGlobalGraphLayoutData( }; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - graphologyLayout: { - elements: [], - layoutDuration: 0, - avgSpacing: 0, - communityCount: 0, - }, - metadata: { - totalEntitiesInSystem: 0, - selectedEntityCount: 0, - coveragePercentage: 0, - importanceThreshold: 0, - layer: "global_graph_layout", - }, - }; - } - await ensureGraphCache(context); - const cache = getGraphCache(websiteCollection); + const cache = getGraphCache(context.agentContext); if (!cache || !cache.isValid) { return { @@ -2468,17 +2195,11 @@ export async function getImportanceStatistics( levelPreview: Array<{ level: number; nodeCount: number; coverage: number }>; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { distribution: [], recommendedLevel: 1, levelPreview: [] }; - } - // Ensure cache is populated await ensureGraphCache(context); // Get cached data - const cache = getGraphCache(websiteCollection); + const cache = getGraphCache(context.agentContext); if (!cache || !cache.isValid) { return { distribution: [], recommendedLevel: 1, levelPreview: [] }; } @@ -2767,129 +2488,47 @@ export async function getTopicDetails( error?: string; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { return { success: false, - error: "Website collection not available", + error: "Durable browser memory is not available", }; } - - const allTopics = websiteCollection.getTopicHierarchy() || []; - - if (allTopics.length === 0) { - return { - success: false, - error: "Hierarchical topics not available", - }; - } - - const topic = allTopics.find( - (t: any) => t.topicId === parameters.topicId, + const graph = await memory.getKnowledgeGraph(); + const topic = graph.topics.find( + (candidate) => + candidate.name.toLowerCase() === + parameters.topicId.toLowerCase(), ); - - if (!topic) { + if (topic === undefined) { return { success: false, error: "Topic not found", }; } - - const topicData: any = topic; - - const entityReferences: Set = new Set(); - const keywords: Set = new Set(); - let firstSeen: string | undefined; - let lastSeen: string | undefined; - - const sourceRefOrdinals: Set = new Set(); - - if ( - topicData.sourceRefOrdinals && - Array.isArray(topicData.sourceRefOrdinals) - ) { - topicData.sourceRefOrdinals.forEach((ordinal: number) => - sourceRefOrdinals.add(ordinal), - ); - } - - if (topicData.childIds && Array.isArray(topicData.childIds)) { - topicData.childIds.forEach((childId: string) => { - const childTopic: any = allTopics.find( - (t: any) => t.topicId === childId, - ); - if ( - childTopic && - childTopic.sourceRefOrdinals && - Array.isArray(childTopic.sourceRefOrdinals) - ) { - childTopic.sourceRefOrdinals.forEach((ordinal: number) => - sourceRefOrdinals.add(ordinal), - ); - } - }); - } - - const timestamps: string[] = []; - const processedMessages = new Set(); - - if (websiteCollection.semanticRefs && sourceRefOrdinals.size > 0) { - for (const ordinal of sourceRefOrdinals) { - const semanticRef = websiteCollection.semanticRefs.get(ordinal); - if (semanticRef) { - const messageOrdinal = - semanticRef.range.start.messageOrdinal; - - if (!processedMessages.has(messageOrdinal)) { - processedMessages.add(messageOrdinal); - - const message = - websiteCollection.messages.get(messageOrdinal); - if (message) { - if (message.timestamp) { - timestamps.push(message.timestamp); - } - - const knowledge = message.knowledge; - if (knowledge) { - if ( - knowledge.entities && - Array.isArray(knowledge.entities) - ) { - knowledge.entities.forEach( - (entity: any) => { - if (entity.name) { - entityReferences.add( - entity.name, - ); - } - }, - ); - } - - if ( - knowledge.topics && - Array.isArray(knowledge.topics) - ) { - knowledge.topics.forEach((topic: any) => { - if (typeof topic === "string") { - keywords.add(topic); - } - }); - } - } - } - } - } - } - } - - if (timestamps.length > 0) { - timestamps.sort(); - firstSeen = timestamps[0]; - lastSeen = timestamps[timestamps.length - 1]; - } + const topicSourceIds = new Set(topic.sourceIds); + const entityReferences = graph.entities + .filter((entity) => + entity.sourceIds.some((sourceId) => + topicSourceIds.has(sourceId), + ), + ) + .map((entity) => entity.name); + const keywords = graph.topics + .filter( + (candidate) => + candidate.name !== topic.name && + candidate.sourceIds.some((sourceId) => + topicSourceIds.has(sourceId), + ), + ) + .map((candidate) => candidate.name); + const sources = await getSourcesById(memory, topic.sourceIds); + const timestamps = sources + .map(getActiveRevisionTimestamp) + .filter((timestamp): timestamp is string => timestamp !== undefined) + .sort(); const details: { topicId: string; @@ -2903,19 +2542,18 @@ export async function getTopicDetails( parentTopicId?: string; childCount?: number; } = { - topicId: topic.topicId, - topicName: topic.topicName, - level: topic.level || 0, - confidence: topic.confidence || 0, - entityReferences: Array.from(entityReferences), - keywords: Array.from(keywords), + topicId: topic.name, + topicName: topic.name, + level: 0, + confidence: 1, + entityReferences, + keywords, + childCount: 0, }; - - if (firstSeen) details.firstSeen = firstSeen; - if (lastSeen) details.lastSeen = lastSeen; - if (topic.parentTopicId) details.parentTopicId = topic.parentTopicId; - if (topicData.childCount !== undefined) - details.childCount = topicData.childCount as number; + if (timestamps.length > 0) { + details.firstSeen = timestamps[0]; + details.lastSeen = timestamps[timestamps.length - 1]; + } return { success: true, @@ -2958,165 +2596,85 @@ export async function getEntityDetails( error?: string; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - success: false, - error: "Website collection not available", - }; - } - - // Use cache for performance - loads from JSON storage if needed - await ensureGraphCache(context); - const cache = getGraphCache(websiteCollection); - - if (!cache || !cache.isValid || !cache.entityMetrics) { + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { return { success: false, - error: "Entity cache not available", + error: "Durable browser memory is not available", }; } - - const entity = cache.entityMetrics.find( - (e: any) => e.name === parameters.entityName, + const graph = await memory.getKnowledgeGraph(); + const entity = graph.entities.find( + (candidate) => + candidate.name.toLowerCase() === + parameters.entityName.toLowerCase(), ); - - if (!entity) { + if (entity === undefined) { return { success: false, error: "Entity not found", }; } - - const entityReferences: Set = new Set(); - const topics: Set = new Set(); - const websites: Set = new Set(); - const timestamps: string[] = []; - const processedMessages = new Set(); - - const kp = await import("@typeagent/knowpro"); - const searchTermGroup = kp.createEntitySearchTermGroup( - parameters.entityName, - undefined, - undefined, - undefined, - false, - ); - - const whenFilter = { knowledgeType: "entity" as const }; - const searchResult = await kp.searchConversationKnowledge( - websiteCollection, - searchTermGroup, - whenFilter, - { maxKnowledgeMatches: 100 }, - ); - - if (searchResult) { - for (const [, result] of searchResult) { - if (result.semanticRefMatches) { - for (const scoredRef of result.semanticRefMatches) { - const semanticRef = websiteCollection.semanticRefs.get( - scoredRef.semanticRefOrdinal, - ); - if (semanticRef) { - const messageOrdinal = - semanticRef.range.start.messageOrdinal; - - if (!processedMessages.has(messageOrdinal)) { - processedMessages.add(messageOrdinal); - - const message = - websiteCollection.messages.get( - messageOrdinal, - ); - if (message) { - if (message.timestamp) { - timestamps.push(message.timestamp); - } - - if ((message as any).url) { - websites.add((message as any).url); - } - - const knowledge = message.knowledge; - if (knowledge) { - if ( - knowledge.entities && - Array.isArray(knowledge.entities) - ) { - knowledge.entities.forEach( - (e: any) => { - if ( - e.name && - e.name !== - parameters.entityName - ) { - entityReferences.add( - e.name, - ); - } - }, - ); - } - - if ( - knowledge.topics && - Array.isArray(knowledge.topics) - ) { - knowledge.topics.forEach( - (topic: any) => { - if ( - typeof topic === - "string" - ) { - topics.add(topic); - } - }, - ); - } - } - } - } - } - } - } + const entitySourceIds = new Set(entity.sourceIds); + const relatedEntities = new Set(); + let degree = 0; + for (const relationship of graph.relationships) { + if (relationship.fromEntity === entity.name) { + relatedEntities.add(relationship.toEntity); + degree++; + } else if (relationship.toEntity === entity.name) { + relatedEntities.add(relationship.fromEntity); + degree++; } } + const topics = graph.topics + .filter((topic) => + topic.sourceIds.some((sourceId) => + entitySourceIds.has(sourceId), + ), + ) + .map((topic) => topic.name); + const sources = await getSourcesById(memory, entity.sourceIds); + const websites = sources.flatMap((source) => + source.canonicalUri === undefined ? [] : [source.canonicalUri], + ); + const timestamps = sources + .map(getActiveRevisionTimestamp) + .filter((timestamp): timestamp is string => timestamp !== undefined) + .sort(); - let firstSeen: string | undefined; - let lastSeen: string | undefined; - if (timestamps.length > 0) { - timestamps.sort(); - firstSeen = timestamps[0]; - lastSeen = timestamps[timestamps.length - 1]; - } - - const details: any = { + const details: { + name: string; + type: string; + confidence: number; + count: number; + degree?: number; + importance?: number; + topicAffinity?: string[]; + relatedEntities?: string[]; + websites?: string[]; + firstSeen?: string; + lastSeen?: string; + } = { name: entity.name, - type: entity.type || "entity", - confidence: entity.confidence || 0.5, - count: entity.count || 1, + type: entity.types[0] ?? "entity", + confidence: 1, + count: entity.mentionCount, + degree, }; - - if (entity.degree !== undefined) details.degree = entity.degree; - if (entity.importance !== undefined) - details.importance = entity.importance; - - if (topics.size > 0) { - details.topicAffinity = Array.from(topics).slice(0, 15); + if (topics.length > 0) { + details.topicAffinity = topics.slice(0, 15); } - - if (entityReferences.size > 0) { - details.relatedEntities = Array.from(entityReferences).slice(0, 15); + if (relatedEntities.size > 0) { + details.relatedEntities = Array.from(relatedEntities).slice(0, 15); } - - if (websites.size > 0) { - details.websites = Array.from(websites).slice(0, 15); + if (websites.length > 0) { + details.websites = websites.slice(0, 15); + } + if (timestamps.length > 0) { + details.firstSeen = timestamps[0]; + details.lastSeen = timestamps[timestamps.length - 1]; } - - if (firstSeen) details.firstSeen = firstSeen; - if (lastSeen) details.lastSeen = lastSeen; return { success: true, @@ -3162,17 +2720,18 @@ export async function getUrlContentBreakdown( error?: string; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { return { success: false, - error: "Website collection not available", + error: "Durable browser memory is not available", }; } const tracker = getPerformanceTracker(); tracker.startOperation("getUrlContentBreakdown"); + const { graph, sources, sourcesById } = + await loadDurableGraphSnapshot(memory); const urlStats = new Map< string, @@ -3183,89 +2742,42 @@ export async function getUrlContentBreakdown( relationshipCount: number; } >(); - - // Count topics per URL - tracker.startOperation("getUrlContentBreakdown.countTopics"); - try { - const topics = websiteCollection.getTopicHierarchy() || []; - for (const topic of topics) { - const url = topic.url; - if (!urlStats.has(url)) { - urlStats.set(url, { - topicCount: 0, - entityCount: 0, - semanticRefCount: 0, - relationshipCount: 0, - }); - } - urlStats.get(url)!.topicCount++; - } - tracker.endOperation( - "getUrlContentBreakdown.countTopics", - topics.length, - urlStats.size, - ); - } catch (error) { - console.warn("Failed to count topics per URL:", error); - tracker.endOperation("getUrlContentBreakdown.countTopics", 0, 0); + for (const source of sources) { + urlStats.set(source.canonicalUri ?? source.sourceId, { + topicCount: 0, + entityCount: 0, + semanticRefCount: 0, + relationshipCount: 0, + }); } - // Count entities per URL - tracker.startOperation("getUrlContentBreakdown.countEntities"); - if (websiteCollection.knowledgeEntities) { - try { - const entities = - (websiteCollection.knowledgeEntities as any).getTopEntities( - 10000, - ) || []; - for (const entity of entities) { - const sources = entity.sources || []; - const sourceUrls = - typeof sources === "string" - ? JSON.parse(sources) - : sources; - for (const url of sourceUrls) { - if (!urlStats.has(url)) { - urlStats.set(url, { - topicCount: 0, - entityCount: 0, - semanticRefCount: 0, - relationshipCount: 0, - }); - } - urlStats.get(url)!.entityCount++; - } + const increment = ( + sourceIds: string[], + field: "topicCount" | "entityCount" | "relationshipCount", + ) => { + for (const sourceId of new Set(sourceIds)) { + const source = sourcesById.get(sourceId); + if (source === undefined) { + continue; } - tracker.endOperation( - "getUrlContentBreakdown.countEntities", - entities.length, - urlStats.size, - ); - } catch (error) { - console.warn("Failed to count entities per URL:", error); - tracker.endOperation( - "getUrlContentBreakdown.countEntities", - 0, - 0, + const stats = urlStats.get( + source.canonicalUri ?? source.sourceId, ); + if (stats !== undefined) { + stats[field]++; + } } - } + }; - // Count semantic refs per URL - TODO: implement when URL association is available - tracker.startOperation("getUrlContentBreakdown.countSemanticRefs"); - // SemanticRef interface doesn't directly contain URL info - skip for now - const semanticRefCount = websiteCollection.semanticRefs - ? websiteCollection.semanticRefs.getAll().length - : 0; - tracker.endOperation( - "getUrlContentBreakdown.countSemanticRefs", - semanticRefCount, - 0, + graph.topics.forEach((topic) => + increment(topic.sourceIds, "topicCount"), + ); + graph.entities.forEach((entity) => + increment(entity.sourceIds, "entityCount"), + ); + graph.relationships.forEach((relationship) => + increment(relationship.sourceIds, "relationshipCount"), ); - - // Note: Relationship counting removed - relationships now computed from Graphology graphs - tracker.startOperation("getUrlContentBreakdown.countRelationships"); - tracker.endOperation("getUrlContentBreakdown.countRelationships", 0, 0); // Build breakdown array const breakdown = Array.from(urlStats.entries()) @@ -3324,7 +2836,9 @@ export async function getUrlContentBreakdown( tracker.endOperation( "getUrlContentBreakdown", - urlStats.size, + graph.entities.length + + graph.topics.length + + graph.relationships.length, breakdown.length, ); tracker.printReport("getUrlContentBreakdown"); @@ -3366,9 +2880,8 @@ export async function getTopicTimelines( context: SessionContext, ): Promise { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { return { success: false, timelines: [], @@ -3377,9 +2890,10 @@ export async function getTopicTimelines( timeRange: { earliest: "", latest: "" }, topicsWithActivity: 0, }, - error: "Website collection not available", + error: "Durable browser memory is not available", }; } + const { graph, sourcesById } = await loadDurableGraphSnapshot(memory); debug( `[Topic Timelines] Processing ${parameters.topicNames.length} topics`, @@ -3389,10 +2903,10 @@ export async function getTopicTimelines( let allTopics = [...parameters.topicNames]; if (parameters.includeRelatedTopics) { - allTopics = await expandTopicNeighborhood( + allTopics = expandTopicNeighborhood( parameters.topicNames, parameters.neighborhoodDepth || 1, - websiteCollection, + graph, ); debug( `[Topic Timelines] Expanded to ${allTopics.length} topics including neighbors`, @@ -3403,9 +2917,10 @@ export async function getTopicTimelines( const timelines: TopicTimeline[] = []; for (const topicName of allTopics) { - const timeline = await buildTopicTimeline( + const timeline = buildTopicTimeline( topicName, - websiteCollection, + graph, + sourcesById, parameters, ); if (timeline.activities.length > 0) { @@ -3488,234 +3003,31 @@ export async function getTopicTimelines( } } -async function expandTopicNeighborhood( +function expandTopicNeighborhood( seedTopics: string[], depth: number, - websiteCollection: any, -): Promise { - const allTopics = new Set(seedTopics); - - try { - // Use existing topic relationship functionality to find connected topics - for (const seedTopic of seedTopics) { - // Get related topics from knowledge topics table - if ( - websiteCollection.knowledgeTopics && - websiteCollection.knowledgeTopics.getRelatedTopics - ) { - const relatedTopics = - websiteCollection.knowledgeTopics.getRelatedTopics( - seedTopic, - 10, - ) || []; - - relatedTopics.forEach((topicEntry: any) => { - if (topicEntry.topic && topicEntry.topic !== seedTopic) { - allTopics.add(topicEntry.topic); - } - }); - } - } - - debug( - `[Topic Neighborhood] Expanded ${seedTopics.length} seed topics to ${allTopics.size} total topics`, - ); - } catch (error) { - debug(`[Topic Neighborhood] Error expanding topics: ${error}`); - // Return original topics if expansion fails - return seedTopics; - } - - return Array.from(allTopics); + graph: MemoryKnowledgeGraph, +): string[] { + const related = getRelatedTopicsBySourceOverlap(seedTopics, depth, graph); + return [ + ...seedTopics, + ...Array.from(related.values(), (topic) => topic.name), + ]; } -async function buildTopicTimeline( +function buildTopicTimeline( topicName: string, - websiteCollection: any, - parameters: any, -): Promise { - const activities: TopicActivity[] = []; - - try { - // 1. Get all URLs associated with this topic from knowledgeTopics table - let topicEntries: any[] = []; - - if (websiteCollection.knowledgeTopics) { - // Query the database directly for topics matching the name - const stmt = websiteCollection.knowledgeTopics.db.prepare(` - SELECT * FROM knowledgeTopics - WHERE topic LIKE ? - ORDER BY relevance DESC - `); - topicEntries = stmt.all(`%${topicName}%`) || []; - } - - debug( - `[Topic Timeline] Found ${topicEntries.length} topic entries for "${topicName}"`, - ); - - // 2. For each URL, get temporal engagement data from website collection - const websites = websiteCollection.getWebsiteDocParts() || []; - const urlToWebsiteMap = new Map(); - - websites.forEach((website: any) => { - if (website.url) { - urlToWebsiteMap.set(website.url, website); - } - }); - - for (const topicEntry of topicEntries) { - const websiteData = urlToWebsiteMap.get(topicEntry.url); - - if (websiteData && websiteData.metadata) { - const metadata = websiteData.metadata; - const title = - metadata.title || websiteData.title || "Unknown Title"; - const snippet = - metadata.description || - metadata.contentSummary || - websiteData.snippet; - - // Add bookmark activity - if (metadata.bookmarkDate) { - activities.push({ - timestamp: metadata.bookmarkDate, - activityType: "bookmark", - url: topicEntry.url, - title: title, - domain: - topicEntry.domain || - metadata.domain || - extractDomainFromUrl(topicEntry.url), - relevance: topicEntry.relevance || 0, - snippet: snippet, - metadata: { - extractionDate: topicEntry.extractionDate, - }, - }); - } - - // Add visit activity - if (metadata.visitDate) { - activities.push({ - timestamp: metadata.visitDate, - activityType: "visit", - url: topicEntry.url, - title: title, - domain: - topicEntry.domain || - metadata.domain || - extractDomainFromUrl(topicEntry.url), - relevance: topicEntry.relevance || 0, - snippet: snippet, - metadata: { - visitCount: metadata.visitCount, - extractionDate: topicEntry.extractionDate, - }, - }); - } - - // Add knowledge extraction activity - if (topicEntry.extractionDate) { - const knowledgeChunk = await getKnowledgeChunkForTopic( - websiteData, - topicName, - ); - - activities.push({ - timestamp: topicEntry.extractionDate, - activityType: "extraction", - url: topicEntry.url, - title: title, - domain: - topicEntry.domain || - metadata.domain || - extractDomainFromUrl(topicEntry.url), - relevance: topicEntry.relevance || 0, - knowledgeChunk: knowledgeChunk, - metadata: { - confidence: topicEntry.relevance, - }, - }); - } - } - } - - // Deduplicate activities with same URL and timestamp - // Priority: bookmark > visit > extraction - const activityPriority: Record = { - bookmark: 3, - visit: 2, - extraction: 1, - }; - - const dedupeMap = new Map(); - - for (const activity of activities) { - const key = `${activity.url}|${activity.timestamp}`; - const existing = dedupeMap.get(key); - - if (!existing) { - dedupeMap.set(key, activity); - } else { - // Keep the activity with higher priority - const existingPriority = - activityPriority[existing.activityType] || 0; - const newPriority = - activityPriority[activity.activityType] || 0; - - if (newPriority > existingPriority) { - dedupeMap.set(key, activity); - } - } - } - - // Convert deduplicated map back to array - const deduplicatedActivities = Array.from(dedupeMap.values()); - - debug( - `[Topic Timeline] Deduplicated ${activities.length} activities to ${deduplicatedActivities.length} unique entries`, - ); - - // Sort activities by timestamp (most recent first) - deduplicatedActivities.sort( - (a, b) => - new Date(b.timestamp).getTime() - - new Date(a.timestamp).getTime(), - ); - - // Limit activities if specified - const maxEntries = parameters.maxTimelineEntries || 50; - const limitedActivities = deduplicatedActivities.slice(0, maxEntries); - - // Calculate activity distribution based on deduplicated activities - const activityDistribution = { - bookmarks: deduplicatedActivities.filter( - (a) => a.activityType === "bookmark", - ).length, - visits: deduplicatedActivities.filter( - (a) => a.activityType === "visit", - ).length, - extractions: deduplicatedActivities.filter( - (a) => a.activityType === "extraction", - ).length, - }; - - debug( - `[Topic Timeline] Built timeline for "${topicName}" with ${deduplicatedActivities.length} activities (${limitedActivities.length} limited)`, - ); - - return { - topicName, - totalActivity: deduplicatedActivities.length, - activities: limitedActivities, - relatedTopics: [], // Could be populated from topic relationships - activityDistribution, - }; - } catch (error) { - debug( - `[Topic Timeline] Error building timeline for "${topicName}": ${error}`, - ); + graph: MemoryKnowledgeGraph, + sourcesById: Map, + parameters: { + maxTimelineEntries?: number; + timeRange?: { startDate?: string; endDate?: string }; + }, +): TopicTimeline { + const topic = graph.topics.find( + (candidate) => candidate.name.toLowerCase() === topicName.toLowerCase(), + ); + if (topic === undefined) { return { topicName, totalActivity: 0, @@ -3724,51 +3036,68 @@ async function buildTopicTimeline( activityDistribution: { bookmarks: 0, visits: 0, extractions: 0 }, }; } -} -async function getKnowledgeChunkForTopic( - websiteData: any, - topicName: string, -): Promise { - try { - // Try to find text chunks that mention this topic - if (websiteData.text && typeof websiteData.text === "string") { - const text = websiteData.text.toLowerCase(); - const topicLower = topicName.toLowerCase(); - - if (text.includes(topicLower)) { - // Find the sentence or paragraph containing the topic - const sentences = websiteData.text.split(/[.!?]+/); - for (const sentence of sentences) { - if (sentence.toLowerCase().includes(topicLower)) { - return ( - sentence.trim().substring(0, 200) + - (sentence.length > 200 ? "..." : "") - ); - } - } - } - } - - // Fallback to content summary or description - if (websiteData.metadata) { - return ( - websiteData.metadata.contentSummary?.substring(0, 200) + - (websiteData.metadata.contentSummary?.length > 200 - ? "..." - : "") || - websiteData.metadata.description?.substring(0, 200) + - (websiteData.metadata.description?.length > 200 - ? "..." - : "") - ); + const startTime = parameters.timeRange?.startDate + ? Date.parse(parameters.timeRange.startDate) + : undefined; + const endTime = parameters.timeRange?.endDate + ? Date.parse(parameters.timeRange.endDate) + : undefined; + const activities = topic.sourceIds.flatMap((sourceId): TopicActivity[] => { + const source = sourcesById.get(sourceId); + if (source === undefined) { + return []; + } + const timestamp = getActiveRevisionTimestamp(source); + if (timestamp === undefined) { + return []; + } + const activityTime = Date.parse(timestamp); + if ( + (startTime !== undefined && activityTime < startTime) || + (endTime !== undefined && activityTime > endTime) + ) { + return []; } - - return undefined; - } catch (error) { - debug(`[Knowledge Chunk] Error extracting chunk: ${error}`); - return undefined; - } + const url = source.canonicalUri ?? source.sourceId; + const metadataDomain = source.metadata?.domain; + return [ + { + timestamp, + activityType: "extraction", + url, + title: source.title, + domain: + typeof metadataDomain === "string" + ? metadataDomain + : extractDomainFromUrl(url), + relevance: 1, + metadata: { confidence: 1, extractionDate: timestamp }, + }, + ]; + }); + activities.sort( + (left, right) => + Date.parse(right.timestamp) - Date.parse(left.timestamp), + ); + const maxEntries = Math.max(0, parameters.maxTimelineEntries ?? 50); + const limitedActivities = activities.slice(0, maxEntries); + const relatedTopics = Array.from( + getRelatedTopicsBySourceOverlap([topic.name], 1, graph).values(), + (related) => related.name, + ); + return { + topicName: topic.name, + topicId: topic.name, + totalActivity: activities.length, + activities: limitedActivities, + relatedTopics, + activityDistribution: { + bookmarks: 0, + visits: 0, + extractions: activities.length, + }, + }; } function extractDomainFromUrl(url: string): string { 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 57e52b9f97..58bb62a7a7 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/actions/indexingActions.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/actions/indexingActions.mts @@ -3,7 +3,6 @@ import { SessionContext } from "@typeagent/agent-sdk"; import { BrowserActionContext } from "../../browserActions.mjs"; -import * as website from "@typeagent/website-memory"; import { createExtractionInputsFromFragments } from "./extractionActions.mjs"; import registerDebug from "debug"; @@ -129,9 +128,8 @@ export async function getKnowledgeIndexStats( indexSize: string; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { return { totalPages: 0, totalEntities: 0, @@ -141,44 +139,24 @@ export async function getKnowledgeIndexStats( }; } - const websites = websiteCollection.messages.getAll(); - let totalEntities = 0; - let totalRelationships = 0; - let lastIndexed: string | null = null; - - for (const site of websites) { - try { - const knowledge = site.getKnowledge(); - if (knowledge) { - totalEntities += knowledge.entities?.length || 0; - totalRelationships += knowledge.actions?.length || 0; - } - } catch (error) { - console.warn("Error getting knowledge for site:", error); - // Continue processing other sites - } - - const metadata = site.metadata as website.WebsiteDocPartMeta; - - const siteDate = metadata?.visitDate || metadata?.bookmarkDate; - if (siteDate && (!lastIndexed || siteDate > lastIndexed)) { - lastIndexed = siteDate; - } - } - - const totalContent = websites.reduce( - (sum: number, site: any) => - sum + (site.textChunks?.join("").length || 0), - 0, - ); - const indexSize = `${Math.round(totalContent / 1024)} KB`; + const [sources, graph] = await Promise.all([ + memory.listSources(), + memory.getKnowledgeGraph(), + ]); + const indexedDates = sources.flatMap((source) => { + const revision = source.revisions.find( + (item) => item.revisionId === source.activeRevisionId, + ); + const date = revision?.indexedAt ?? revision?.capturedAt; + return date === undefined ? [] : [date]; + }); return { - totalPages: websites.length, - totalEntities, - totalRelationships, - lastIndexed: lastIndexed || "Never", - indexSize, + totalPages: sources.length, + totalEntities: graph.entities.length, + totalRelationships: graph.relationships.length, + lastIndexed: indexedDates.sort().at(-1) ?? "Never", + indexSize: "Unknown", }; } catch (error) { console.error("Error getting knowledge index stats:", error); @@ -197,18 +175,17 @@ export async function clearKnowledgeIndex( context: SessionContext, ): Promise<{ success: boolean; message: string }> { try { - const websiteCollection = context.agentContext.websiteCollection; + const memory = context.agentContext.browserMemoryService; - if (!websiteCollection) { + if (memory === undefined) { return { success: false, - message: "No website collection found to clear.", + message: "Durable browser memory is not available.", }; } - const itemsCleared = websiteCollection.messages.length; - context.agentContext.websiteCollection = - new website.WebsiteCollection(); + const itemsCleared = await memory.clear(); + context.agentContext.graphCache = undefined; return { success: true, diff --git a/ts/packages/agents/browser/src/agent/knowledge/actions/knowledgeActionRouter.mts b/ts/packages/agents/browser/src/agent/knowledge/actions/knowledgeActionRouter.mts index 1702ad3f7f..10fcd34cf6 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/actions/knowledgeActionRouter.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/actions/knowledgeActionRouter.mts @@ -3,7 +3,7 @@ import { SessionContext } from "@typeagent/agent-sdk"; import { BrowserActionContext } from "../../browserActions.mjs"; -import { searchWebMemories } from "../../searchWebMemories.mjs"; +import { searchWebMemories } from "../../durableWebSearch.mjs"; import { extractKnowledgeFromPage, extractKnowledgeFromPageStreaming, @@ -30,7 +30,9 @@ import { rebuildKnowledgeGraph, mergeTopicHierarchies, getEntityNeighborhood, + getEntityNeighborhoodLayoutData, getGlobalImportanceLayer, + getGlobalGraphLayoutData, getTopicImportanceLayer, getImportanceStatistics, getTopicMetrics, @@ -93,6 +95,10 @@ export async function handleKnowledgeAction( return await mergeTopicHierarchies(parameters, context); case "getEntityNeighborhood": return await getEntityNeighborhood(parameters, context); + case "getEntityNeighborhoodLayoutData": + return await getEntityNeighborhoodLayoutData(parameters, context); + case "getGlobalGraphLayoutData": + return await getGlobalGraphLayoutData(parameters, context); case "getGlobalImportanceLayer": return await getGlobalImportanceLayer(parameters, context); case "getImportanceStatistics": diff --git a/ts/packages/agents/browser/src/agent/knowledge/actions/queryActions.mts b/ts/packages/agents/browser/src/agent/knowledge/actions/queryActions.mts index 6f2631ae85..0f87095ec2 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/actions/queryActions.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/actions/queryActions.mts @@ -23,165 +23,52 @@ export async function getPageIndexedKnowledge( error?: string; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { return { isIndexed: false, - error: "No website collection available", + error: "Durable browser memory is not available", }; } - - const websites = websiteCollection.messages.getAll(); - const foundWebsite = websites.find( - (site: any) => site.metadata.url === parameters.url, - ); - - if (!foundWebsite) { + const source = await memory.getSource(parameters.url); + if (source === undefined) { return { isIndexed: false, error: "Page not found in index", }; } - - try { - const knowledge = foundWebsite.getKnowledge(); - - if (!knowledge) { - return { - isIndexed: true, - knowledge: { - title: "", - entities: [], - relationships: [], - keyTopics: [], - detectedActions: [], - suggestedQuestions: [], - summary: - "Page is indexed but no knowledge was extracted.", - contentMetrics: { - readingTime: 0, - wordCount: 0, - }, - }, - }; - } - - let detectedActions: any[] = []; - - // Check websiteObj metadata for detectedActions first (with safe property access) - if ( - foundWebsite.metadata && - (foundWebsite.metadata as any).detectedActions && - Array.isArray((foundWebsite.metadata as any).detectedActions) - ) { - detectedActions = (foundWebsite.metadata as any) - .detectedActions; - } - - // Also check knowledge object for detectedActions (fallback) - if ( - (knowledge as any).detectedActions && - Array.isArray((knowledge as any).detectedActions) - ) { - detectedActions.push(...(knowledge as any).detectedActions); - } - - // Convert the stored knowledge to the expected format - const entities: Entity[] = - knowledge.entities?.map((entity) => ({ - name: entity.name, - type: Array.isArray(entity.type) - ? entity.type.join(", ") - : entity.type, - description: entity.facets?.find( - (f) => f.name === "description", - )?.value as string, - confidence: 0.8, // Default confidence for indexed content - })) || []; - - const keyTopics: string[] = knowledge.topics || []; - - const allRelationships: Relationship[] = - knowledge.actions?.map((action) => ({ - from: action.subjectEntityName || "unknown", - relationship: action.verbs?.join(", ") || "related to", - to: action.objectEntityName || "unknown", - confidence: 0.8, // Default confidence for indexed content - })) || []; - - // Deduplicate relationships - const relationships = allRelationships.filter( - (rel, index, arr) => - arr.findIndex( - (r) => - r.from === rel.from && - r.relationship === rel.relationship && - r.to === rel.to, - ) === index, - ); - - // Generate contextual questions for indexed content - const suggestedQuestions: string[] = []; - /* - const suggestedQuestions: string[] = - await generateSmartSuggestedQuestions( - knowledge, - null, - parameters.url, - context, - ); - */ - - // Calculate content metrics from the stored text - const textContent = foundWebsite.textChunks?.join("\n\n") || ""; - const wordCount = textContent.split(/\s+/).length; - const contentMetrics = { - readingTime: Math.ceil(wordCount / 225), - wordCount: wordCount, - }; - - const summary = `Retrieved indexed knowledge: ${entities.length} entities, ${keyTopics.length} topics, ${relationships.length} relationships.`; - - return { - isIndexed: true, - knowledge: { - title: (knowledge as any).title || "", - entities, - relationships, - keyTopics, - detectedActions, - contentActions: knowledge.actions || [], - actionSummary: foundWebsite.metadata - ? (foundWebsite.metadata as any).actionSummary - : undefined, - suggestedQuestions, - summary, - contentMetrics, - }, - }; - } catch (knowledgeError) { - console.warn( - "Error extracting knowledge from indexed page:", - knowledgeError, - ); - return { - isIndexed: true, - knowledge: { - title: "", - entities: [], - relationships: [], - keyTopics: [], - detectedActions: [], - suggestedQuestions: [], - summary: "Page is indexed but knowledge extraction failed.", - contentMetrics: { - readingTime: 0, - wordCount: 0, - }, - }, - }; - } + const graph = await memory.getKnowledgeGraph(); + const entities: Entity[] = graph.entities + .filter((entity) => entity.sourceIds.includes(source.sourceId)) + .map((entity) => ({ + name: entity.name, + type: entity.types.join(", "), + confidence: 0.8, + })); + const keyTopics = graph.topics + .filter((topic) => topic.sourceIds.includes(source.sourceId)) + .map((topic) => topic.name); + const relationships: Relationship[] = graph.relationships + .filter((item) => item.sourceIds.includes(source.sourceId)) + .map((item) => ({ + from: item.fromEntity, + relationship: item.relationshipType, + to: item.toEntity, + confidence: 0.8, + })); + return { + isIndexed: true, + knowledge: { + title: source.title, + entities, + relationships, + keyTopics, + detectedActions: [], + suggestedQuestions: [], + summary: `Retrieved indexed knowledge: ${entities.length} entities, ${keyTopics.length} topics, ${relationships.length} relationships.`, + contentMetrics: { readingTime: 0, wordCount: 0 }, + }, + }; } catch (error) { console.error("Error getting page indexed knowledge:", error); return { @@ -229,9 +116,8 @@ export async function getDiscoverInsights( success: boolean; }> { try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { + const memory = context.agentContext.browserMemoryService; + if (memory === undefined) { return { trendingTopics: [], readingPatterns: [], @@ -241,7 +127,32 @@ export async function getDiscoverInsights( }; } - const websites = websiteCollection.messages.getAll(); + const [sources, graph] = await Promise.all([ + memory.listSources(), + memory.getKnowledgeGraph(), + ]); + const websites = sources.map((source) => { + const revision = source.revisions.find( + (item) => item.revisionId === source.activeRevisionId, + ); + const capturedAt = revision?.capturedAt ?? revision?.indexedAt; + return { + metadata: { + url: source.canonicalUri, + title: source.title, + ...(source.metadata?.source === "bookmark" + ? { bookmarkDate: capturedAt } + : { visitDate: capturedAt }), + }, + getKnowledge: () => ({ + entities: graph.entities + .filter((entity) => + entity.sourceIds.includes(source.sourceId), + ) + .map((entity) => ({ name: entity.name })), + }), + }; + }); const limit = parameters.limit || 10; const timeframe = parameters.timeframe || "30d"; @@ -295,17 +206,17 @@ export async function generateSmartSuggestedQuestions( } } - // Use DataFrames for context-aware questions - const websiteCollection = context.agentContext.websiteCollection; - if (websiteCollection && websiteCollection.visitFrequency) { + // Add history-oriented questions only when durable browser memory is available. + if (context.agentContext.browserMemoryService !== undefined) { try { - // Domain visit history - simplified approach for now debug("Checking domain visit data for enhanced questions"); if (domain) { questions.push(`When did I first visit ${domain}?`); questions.push(`What's my learning journey on ${domain}?`); } + questions.push("When did I first encounter this information?"); + questions.push("What have I learned recently in this domain?"); } catch (error) { console.warn("Error querying domain data:", error); } @@ -322,10 +233,6 @@ export async function generateSmartSuggestedQuestions( questions.push("What should I learn next in this area?"); questions.push("Are there any knowledge gaps I should fill?"); - // Temporal questions - questions.push("When did I first encounter this information?"); - questions.push("What have I learned recently in this domain?"); - return questions.slice(0, 8); // Limit to most relevant questions } diff --git a/ts/packages/agents/browser/src/agent/knowledge/extractKnowledgeCommand.mts b/ts/packages/agents/browser/src/agent/knowledge/extractKnowledgeCommand.mts index 68698987f1..d847585e72 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/extractKnowledgeCommand.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/extractKnowledgeCommand.mts @@ -34,29 +34,6 @@ const debug = registerDebug("typeagent:browser:action"); // Knowledge extraction progress tracking const activeKnowledgeExtractions = new Map(); -// Utility functions -function convertStoredKnowledgeToDisplayFormat(storedKnowledge: any): any { - const displayKnowledge = { ...storedKnowledge }; - - // Convert actions array to relationships array - if (storedKnowledge.actions && Array.isArray(storedKnowledge.actions)) { - displayKnowledge.relationships = storedKnowledge.actions.map( - (action: any) => ({ - from: action.subjectEntityName || "unknown", - relationship: Array.isArray(action.verbs) - ? action.verbs.join(" ") - : action.verbs || "related to", - to: action.objectEntityName || "unknown", - confidence: action.confidence || 0.8, - }), - ); - } else { - displayKnowledge.relationships = []; - } - - return displayKnowledge; -} - async function checkKnowledgeInIndex( url: string, context: ActionContext | any, @@ -65,26 +42,12 @@ async function checkKnowledgeInIndex( // Get the session context - either directly or from action context const sessionContext = "sessionContext" in context ? context.sessionContext : context; - const websiteCollection = sessionContext.agentContext.websiteCollection; - - if (!websiteCollection) { - return null; - } - - const websites = websiteCollection.messages.getAll(); - const foundWebsite = websites.find( - (site: any) => site.metadata.url === url, + const result = await handleKnowledgeAction( + "getPageIndexedKnowledge", + { url }, + sessionContext, ); - - if (foundWebsite) { - const knowledge = foundWebsite.getKnowledge(); - if (knowledge) { - return convertStoredKnowledgeToDisplayFormat(knowledge); - } - return null; - } - - return null; + return result.isIndexed ? result.knowledge : null; } catch (error) { debug("No existing knowledge found in index for:", url); return null; diff --git a/ts/packages/agents/browser/src/agent/knowledge/knowledgeHandler.mts b/ts/packages/agents/browser/src/agent/knowledge/knowledgeHandler.mts deleted file mode 100644 index 174929bb21..0000000000 --- a/ts/packages/agents/browser/src/agent/knowledge/knowledgeHandler.mts +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { SessionContext } from "@typeagent/agent-sdk"; -import { BrowserActionContext } from "../browserActions.mjs"; -import { searchWebMemories } from "../searchWebMemories.mjs"; -import { KnowledgeExtractionResult } from "./schema/knowledgeExtraction.mjs"; -import { - extractKnowledgeFromPage, - extractKnowledgeFromPageStreaming, -} from "./actions/extractionActions.mjs"; -import { - indexWebPageContent, - checkPageIndexStatus, - getKnowledgeIndexStats, - clearKnowledgeIndex, -} from "./actions/indexingActions.mjs"; -import { - getExtractionAnalytics, - generateQualityReport, - getPageQualityMetrics, - getAnalyticsData, - getRecentKnowledgeItems, - getTopDomains, - getActivityTrends, - getDetailedKnowledgeStats, -} from "./actions/analyticsActions.mjs"; -import { - getKnowledgeGraphStatus, - buildKnowledgeGraph, - rebuildKnowledgeGraph, - getEntityNeighborhood, - getGlobalImportanceLayer, - getImportanceStatistics, -} from "./actions/graphActions.mjs"; -import { - checkAIModelStatus, - checkActionDetectionStatus, -} from "./actions/utilityActions.mjs"; -import { - getPageIndexedKnowledge, - getDiscoverInsights, - generateSmartSuggestedQuestions, -} from "./actions/queryActions.mjs"; - -export interface WebPageDocument { - url: string; - title: string; - content: string; - htmlFragments: any[]; - timestamp: string; - indexed: boolean; - knowledge?: KnowledgeExtractionResult; - metadata?: { - quality: string; - textOnly: boolean; - contentLength: number; - entityCount: number; - }; -} - -export async function handleKnowledgeAction( - actionName: string, - parameters: any, - context: SessionContext, -): Promise { - switch (actionName) { - case "extractKnowledgeFromPage": - return await extractKnowledgeFromPage(parameters, context); - - case "extractKnowledgeFromPageStreaming": - return await extractKnowledgeFromPageStreaming(parameters, context); - - case "indexWebPageContent": - return await indexWebPageContent(parameters, context); - - case "searchWebMemories": - return await searchWebMemories(parameters, context); - - case "checkPageIndexStatus": - return await checkPageIndexStatus(parameters, context); - - case "getKnowledgeIndexStats": - return await getKnowledgeIndexStats(parameters, context); - - case "getKnowledgeStats": - return await getDetailedKnowledgeStats(parameters, context); - - case "clearKnowledgeIndex": - return await clearKnowledgeIndex(parameters, context); - - case "getExtractionAnalytics": - return await getExtractionAnalytics(parameters, context); - - case "generateQualityReport": - return await generateQualityReport(parameters, context); - - case "getPageQualityMetrics": - return await getPageQualityMetrics(parameters, context); - - case "checkAIModelStatus": - return await checkAIModelStatus(parameters, context); - - case "checkActionDetectionStatus": - return await checkActionDetectionStatus(parameters, context); - - case "getRecentKnowledgeItems": - return await getRecentKnowledgeItems(parameters, context); - - case "getTopDomains": - return await getTopDomains(parameters, context); - - case "getActivityTrends": - return await getActivityTrends(parameters, context); - - case "getPageIndexedKnowledge": - return await getPageIndexedKnowledge(parameters, context); - - case "getDiscoverInsights": - return await getDiscoverInsights(parameters, context); - - case "getAnalyticsData": - return await getAnalyticsData(parameters, context); - - case "getKnowledgeGraphStatus": - return await getKnowledgeGraphStatus(parameters, context); - - case "buildKnowledgeGraph": - return await buildKnowledgeGraph(parameters, context); - - case "rebuildKnowledgeGraph": - return await rebuildKnowledgeGraph(parameters, context); - - case "getEntityNeighborhood": - return await getEntityNeighborhood(parameters, context); - - case "getGlobalImportanceLayer": - return await getGlobalImportanceLayer(parameters, context); - - case "getImportanceStatistics": - return await getImportanceStatistics(parameters, context); - - case "generateSmartSuggestedQuestions": - return await generateSmartSuggestedQuestions( - parameters.knowledge, - parameters.extractionResult, - parameters.url, - context, - ); - - default: - throw new Error(`Unknown knowledge action: ${actionName}`); - } -} diff --git a/ts/packages/agents/browser/src/agent/knowledge/knowledgeHandler.mts.backup b/ts/packages/agents/browser/src/agent/knowledge/knowledgeHandler.mts.backup deleted file mode 100644 index 865a496172..0000000000 --- a/ts/packages/agents/browser/src/agent/knowledge/knowledgeHandler.mts.backup +++ /dev/null @@ -1,3408 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { SessionContext } from "@typeagent/agent-sdk"; -import { BrowserActionContext } from "../browserActions.mjs"; -import { searchByEntities, searchWebMemories } from "../searchWebMemories.mjs"; -import * as website from "website-memory"; -import { - KnowledgeExtractionResult, - EnhancedKnowledgeExtractionResult, - Entity, - Relationship, -} from "./schema/knowledgeExtraction.mjs"; -import { - ExtractionMode, - AIModelRequiredError, -} from "website-memory"; -import { BrowserKnowledgeExtractor } from "./browserKnowledgeExtractor.mjs"; -import { DetailedKnowledgeStats } from "../browserKnowledgeSchema.js"; -import { - extractKnowledgeFromPage, - extractKnowledgeFromPageStreaming, - createExtractionInputsFromFragments, - aggregateExtractionResults, -} from "./actions/extractionActions.mjs"; -import { - indexWebPageContent, - checkPageIndexStatus, - getKnowledgeIndexStats, - clearKnowledgeIndex, -} from "./actions/indexingActions.mjs"; -import { - getExtractionAnalytics, - generateQualityReport, - getPageQualityMetrics, - getAnalyticsData, - getRecentKnowledgeItems, - getTopDomains, - getActivityTrends, - getDetailedKnowledgeStats, -} from "./actions/analyticsActions.mjs"; -import { - getKnowledgeGraphStatus, - buildKnowledgeGraph, - rebuildKnowledgeGraph, - getAllRelationships, - getAllCommunities, - getAllEntitiesWithMetrics, - getEntityNeighborhood, - getGlobalImportanceLayer, - getViewportBasedNeighborhood, - getImportanceStatistics, -} from "./actions/graphActions.mjs"; -import { - checkAIModelStatus, - checkActionDetectionStatus, -} from "./actions/utilityActions.mjs"; -import { - getPageIndexedKnowledge, - getDiscoverInsights, - generateSmartSuggestedQuestions, -} from "./actions/queryActions.mjs"; -import registerDebug from "debug"; -const debug = registerDebug("typeagent:browser:knowledge"); - - - - -export interface WebPageDocument { - url: string; - title: string; - content: string; - htmlFragments: any[]; - timestamp: string; - indexed: boolean; - knowledge?: KnowledgeExtractionResult; - metadata?: { - quality: string; - textOnly: boolean; - contentLength: number; - entityCount: number; - }; -} - -export async function handleKnowledgeAction( - actionName: string, - parameters: any, - context: SessionContext, -): Promise { - switch (actionName) { - case "extractKnowledgeFromPage": - return await extractKnowledgeFromPage(parameters, context); - - case "extractKnowledgeFromPageStreaming": - return await extractKnowledgeFromPageStreaming(parameters, context); - - case "indexWebPageContent": - return await indexWebPageContent(parameters, context); - - case "searchWebMemories": - return await searchWebMemories(parameters, context); - - case "checkPageIndexStatus": - return await checkPageIndexStatus(parameters, context); - - case "getKnowledgeIndexStats": - return await getKnowledgeIndexStats(parameters, context); - - case "getKnowledgeStats": - return await getDetailedKnowledgeStats(parameters, context); - - case "clearKnowledgeIndex": - return await clearKnowledgeIndex(parameters, context); - - case "getExtractionAnalytics": - return await getExtractionAnalytics(parameters, context); - - case "generateQualityReport": - return await generateQualityReport(parameters, context); - - case "getPageQualityMetrics": - return await getPageQualityMetrics(parameters, context); - - case "checkAIModelStatus": - return await checkAIModelStatus(parameters, context); - - case "checkActionDetectionStatus": - return await checkActionDetectionStatus(parameters, context); - - case "getRecentKnowledgeItems": - return await getRecentKnowledgeItems(parameters, context); - - case "getTopDomains": - return await getTopDomains(parameters, context); - - case "getActivityTrends": - return await getActivityTrends(parameters, context); - - case "getPageIndexedKnowledge": - return await getPageIndexedKnowledge(parameters, context); - - case "getDiscoverInsights": - return await getDiscoverInsights(parameters, context); - - case "getAnalyticsData": - return await getAnalyticsData(parameters, context); - - case "getKnowledgeGraphStatus": - return await getKnowledgeGraphStatus(parameters, context); - - case "buildKnowledgeGraph": - return await buildKnowledgeGraph(parameters, context); - - case "rebuildKnowledgeGraph": - return await rebuildKnowledgeGraph(parameters, context); - - case "getAllRelationships": - return await getAllRelationships(parameters, context); - - case "getAllCommunities": - return await getAllCommunities(parameters, context); - - case "getAllEntitiesWithMetrics": - return await getAllEntitiesWithMetrics(parameters, context); - - case "getEntityNeighborhood": - return await getEntityNeighborhood(parameters, context); - - case "getGlobalImportanceLayer": - return await getGlobalImportanceLayer(parameters, context); - - case "getViewportBasedNeighborhood": - return await getViewportBasedNeighborhood(parameters, context); - - case "getImportanceStatistics": - return await getImportanceStatistics(parameters, context); - - default: - throw new Error(`Unknown knowledge action: ${actionName}`); - } -} - - -// Convert aggregated results to actions array, handling both contentActions and relationships -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 []; -} - - -// Enhanced suggested questions using content analysis and DataFrames -export async function generateSmartSuggestedQuestions( - knowledge: any, - extractionResult: any, - url: string, - context: SessionContext, -): Promise { - const questions: string[] = []; - const domain = extractDomainFromUrl(url); - - // Content-specific questions based on extraction result - if (extractionResult?.pageContent) { - if (extractionResult.pageContent.readingTime > 10) { - questions.push("What are the key points from this long article?"); - } - } - - // Use DataFrames for context-aware questions - const websiteCollection = context.agentContext.websiteCollection; - if (websiteCollection && websiteCollection.visitFrequency) { - try { - // Domain visit history - simplified approach for now - debug("Checking domain visit data for enhanced questions"); - - if (domain) { - questions.push(`When did I first visit ${domain}?`); - questions.push(`What's my learning journey on ${domain}?`); - } - } catch (error) { - console.warn("Error querying domain data:", error); - } - } - - // Topic-based cross-references - if (knowledge.topics && knowledge.topics.length > 0) { - for (const topic of knowledge.topics.slice(0, 2)) { - questions.push(`What other ${topic} resources do I have?`); - } - } - - // Learning progression questions - questions.push("What should I learn next in this area?"); - questions.push("Are there any knowledge gaps I should fill?"); - - // Temporal questions - questions.push("When did I first encounter this information?"); - questions.push("What have I learned recently in this domain?"); - - return questions.slice(0, 8); // Limit to most relevant questions -} - -// Extract domain from URL -function extractDomainFromUrl(url: string): string { - try { - const urlObj = new URL(url); - return urlObj.hostname; - } catch { - return url; - } -} - - - - - - -export async function getRecentKnowledgeItems( - parameters: { - limit?: number; - type?: "entities" | "topics" | "actions" | "relationships" | "all"; - }, - context: SessionContext, -): Promise<{ - entities: Array<{ - name: string; - type: string; - fromPage: string; - extractedAt: string; - }>; - topics: Array<{ name: string; fromPage: string; extractedAt: string }>; - actions: Array<{ - type: string; - element: string; - text?: string; - confidence: number; - fromPage: string; - extractedAt: string; - }>; - relationships: Array<{ - from: string; - relationship: string; - to: string; - confidence: number; - fromPage: string; - extractedAt: string; - }>; - success: boolean; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - entities: [], - topics: [], - actions: [], - relationships: [], - success: false, - }; - } - - const websites = websiteCollection.messages.getAll(); - const limit = parameters.limit || 10; - const type = parameters.type || "all"; - - const recentEntities: Array<{ - name: string; - type: string; - fromPage: string; - extractedAt: string; - }> = []; - const recentTopics: Array<{ - name: string; - fromPage: string; - extractedAt: string; - }> = []; - const recentActions: Array<{ - type: string; - element: string; - text?: string; - confidence: number; - fromPage: string; - extractedAt: string; - }> = []; - const recentRelationships: Array<{ - from: string; - relationship: string; - to: string; - confidence: number; - fromPage: string; - extractedAt: string; - }> = []; - - // Process all websites and extract entities/topics with timestamps - for (const site of websites) { - const knowledge = site.getKnowledge(); - const metadata = site.metadata as any; - const extractedAt = - metadata.visitDate || - metadata.bookmarkDate || - new Date().toISOString(); - const pageTitle = metadata.title || metadata.url || "Unknown Page"; - - if (knowledge) { - // Extract entities - if ( - (type === "entities" || type === "all") && - knowledge.entities - ) { - for (const entity of knowledge.entities) { - recentEntities.push({ - name: entity.name, - type: Array.isArray(entity.type) - ? entity.type.join(", ") - : entity.type, - fromPage: pageTitle, - extractedAt: extractedAt, - }); - } - } - - // Extract topics - if ((type === "topics" || type === "all") && knowledge.topics) { - for (const topic of knowledge.topics) { - recentTopics.push({ - name: topic, - fromPage: pageTitle, - extractedAt: extractedAt, - }); - } - } - - // Extract actions (if available) - // Note: Actions might not be available in current website-memory structure - if (type === "actions" || type === "all") { - // Try to get actions from various possible sources in the knowledge object - const actions = - (knowledge as any).actions || - (knowledge as any).detectedActions || - []; - - if (Array.isArray(actions)) { - for (const action of actions) { - // Handle different action object structures gracefully - const actionType = - (action as any).actionType || - (action as any).type || - "unknown"; - const actionElement = - (action as any).target?.name || - (action as any).name || - (action as any).element || - "element"; - const actionText = - (action as any).name || - (action as any).text || - (action as any).target?.name; - const actionConfidence = - (action as any).confidence || 0.8; - - recentActions.push({ - type: actionType, - element: actionElement, - text: actionText, - confidence: actionConfidence, - fromPage: pageTitle, - extractedAt: extractedAt, - }); - } - } - } - - // Extract relationships from actions data - // This provides properly formatted relationship data for the UI - if (type === "relationships" || type === "all") { - const actions = (knowledge as any).actions || []; - - if (Array.isArray(actions)) { - for (const action of actions) { - // Transform action data to relationship format - const from = - action.subjectEntityName || "Unknown Entity"; - const relationship = - action.verbs?.join(", ") || "related to"; - const to = - action.objectEntityName || "Unknown Target"; - const confidence = action.confidence || 0.8; - - recentRelationships.push({ - from: from, - relationship: relationship, - to: to, - confidence: confidence, - fromPage: pageTitle, - extractedAt: extractedAt, - }); - } - } - } - } - } - - // Sort by extraction date (most recent first) and limit results - recentEntities.sort( - (a, b) => - new Date(b.extractedAt).getTime() - - new Date(a.extractedAt).getTime(), - ); - recentTopics.sort( - (a, b) => - new Date(b.extractedAt).getTime() - - new Date(a.extractedAt).getTime(), - ); - recentActions.sort( - (a, b) => - new Date(b.extractedAt).getTime() - - new Date(a.extractedAt).getTime(), - ); - recentRelationships.sort( - (a, b) => - new Date(b.extractedAt).getTime() - - new Date(a.extractedAt).getTime(), - ); - - // Remove duplicates while preserving order - const uniqueEntities = recentEntities - .filter( - (entity, index, arr) => - arr.findIndex( - (e) => - e.name.toLowerCase() === entity.name.toLowerCase(), - ) === index, - ) - .slice(0, limit); - - const uniqueTopics = recentTopics - .filter( - (topic, index, arr) => - arr.findIndex( - (t) => - t.name.toLowerCase() === topic.name.toLowerCase(), - ) === index, - ) - .slice(0, limit); - - const uniqueActions = recentActions - .filter( - (action, index, arr) => - arr.findIndex( - (a) => - a.type === action.type && - a.element === action.element && - a.fromPage === action.fromPage, - ) === index, - ) - .slice(0, limit); - - const uniqueRelationships = recentRelationships - .filter( - (relationship, index, arr) => - arr.findIndex( - (r) => - r.from === relationship.from && - r.relationship === relationship.relationship && - r.to === relationship.to, - ) === index, - ) - .slice(0, limit); - - return { - entities: uniqueEntities, - topics: uniqueTopics, - actions: uniqueActions, - relationships: uniqueRelationships, - success: true, - }; - } catch (error) { - console.error("Error getting recent knowledge items:", error); - return { - entities: [], - topics: [], - actions: [], - relationships: [], - success: false, - }; - } -} - -export async function getTopDomains( - parameters: { - limit?: number; - }, - context: SessionContext, -): Promise<{ - domains: Array<{ - domain: string; - count: number; - percentage: number; - }>; - totalSites: number; - success: boolean; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - domains: [], - totalSites: 0, - success: false, - }; - } - - const websites = websiteCollection.messages.getAll(); - const limit = parameters.limit || 10; - - // Count sites by domain - const domainCounts: { [domain: string]: number } = {}; - let totalCount = websites.length; - - for (const site of websites) { - const metadata = site.metadata as any; - const domain = metadata.domain || "unknown"; - domainCounts[domain] = (domainCounts[domain] || 0) + 1; - } - - // Sort by count and limit results - const sortedDomains = Object.entries(domainCounts) - .sort(([, a], [, b]) => b - a) - .slice(0, limit) - .map(([domain, count]) => ({ - domain, - count, - percentage: parseFloat(((count / totalCount) * 100).toFixed(1)), - })); - - return { - domains: sortedDomains, - totalSites: totalCount, - success: true, - }; - } catch (error) { - console.error("Error getting top domains:", error); - return { - domains: [], - totalSites: 0, - success: false, - }; - } -} - -export async function getActivityTrends( - parameters: { - timeRange?: string; - granularity?: string; - }, - context: SessionContext, -): Promise<{ - trends: Array<{ - date: string; - visits: number; - bookmarks: number; - }>; - summary: { - totalActivity: number; - peakDay: string | null; - averagePerDay: number; - timeRange: string; - }; - success: boolean; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - trends: [], - summary: { - totalActivity: 0, - peakDay: null, - averagePerDay: 0, - timeRange: parameters.timeRange || "30d", - }, - success: false, - }; - } - - const websites = websiteCollection.messages.getAll(); - const timeRange = parameters.timeRange || "30d"; - - // Calculate date range - const endDate = new Date(); - const startDate = new Date(); - switch (timeRange) { - case "7d": - startDate.setDate(endDate.getDate() - 7); - break; - case "30d": - startDate.setDate(endDate.getDate() - 30); - break; - case "90d": - startDate.setDate(endDate.getDate() - 90); - break; - default: - startDate.setDate(endDate.getDate() - 30); - } - - // Extract activity data from websites - const activityMap = new Map< - string, - { visits: number; bookmarks: number } - >(); - - for (const site of websites) { - const metadata = site.metadata as any; - - // Process visit dates - if (metadata.visitDate) { - const visitDate = new Date(metadata.visitDate); - if (visitDate >= startDate && visitDate <= endDate) { - const dateKey = visitDate.toISOString().split("T")[0]; - const current = activityMap.get(dateKey) || { - visits: 0, - bookmarks: 0, - }; - current.visits += metadata.visitCount || 1; - activityMap.set(dateKey, current); - } - } - - // Process bookmark dates - if (metadata.bookmarkDate) { - const bookmarkDate = new Date(metadata.bookmarkDate); - if (bookmarkDate >= startDate && bookmarkDate <= endDate) { - const dateKey = bookmarkDate.toISOString().split("T")[0]; - const current = activityMap.get(dateKey) || { - visits: 0, - bookmarks: 0, - }; - current.bookmarks += 1; - activityMap.set(dateKey, current); - } - } - } - - // Convert to trends array - const trends = Array.from(activityMap.entries()) - .map(([date, activity]) => ({ - date, - visits: activity.visits, - bookmarks: activity.bookmarks, - })) - .sort((a, b) => a.date.localeCompare(b.date)); - - // Calculate summary statistics - const totalVisits = trends.reduce((sum, t) => sum + t.visits, 0); - const totalBookmarks = trends.reduce((sum, t) => sum + t.bookmarks, 0); - const peakDay = trends.reduce( - (peak, current) => - current.visits + current.bookmarks > - peak.visits + peak.bookmarks - ? current - : peak, - trends[0] || { date: null, visits: 0, bookmarks: 0 }, - ); - - return { - trends, - summary: { - totalActivity: totalVisits + totalBookmarks, - peakDay: peakDay.date, - averagePerDay: - trends.length > 0 - ? (totalVisits + totalBookmarks) / trends.length - : 0, - timeRange, - }, - success: true, - }; - } catch (error) { - console.error("Error getting activity trends:", error); - return { - trends: [], - summary: { - totalActivity: 0, - peakDay: null, - averagePerDay: 0, - timeRange: parameters.timeRange || "30d", - }, - success: false, - }; - } -} - -export async function getPageIndexedKnowledge( - parameters: { url: string }, - context: SessionContext, -): Promise<{ - isIndexed: boolean; - knowledge?: EnhancedKnowledgeExtractionResult; - error?: string; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - isIndexed: false, - error: "No website collection available", - }; - } - - const websites = websiteCollection.messages.getAll(); - const foundWebsite = websites.find( - (site: any) => site.metadata.url === parameters.url, - ); - - if (!foundWebsite) { - return { - isIndexed: false, - error: "Page not found in index", - }; - } - - try { - const knowledge = foundWebsite.getKnowledge(); - - if (!knowledge) { - return { - isIndexed: true, - knowledge: { - title: "", - entities: [], - relationships: [], - keyTopics: [], - detectedActions: [], - suggestedQuestions: [], - summary: - "Page is indexed but no knowledge was extracted.", - contentMetrics: { - readingTime: 0, - wordCount: 0, - }, - }, - }; - } - - let detectedActions: any[] = []; - - // Check websiteObj metadata for detectedActions first (with safe property access) - if ( - foundWebsite.metadata && - (foundWebsite.metadata as any).detectedActions && - Array.isArray((foundWebsite.metadata as any).detectedActions) - ) { - detectedActions = (foundWebsite.metadata as any) - .detectedActions; - } - - // Also check knowledge object for detectedActions (fallback) - if ( - (knowledge as any).detectedActions && - Array.isArray((knowledge as any).detectedActions) - ) { - detectedActions.push(...(knowledge as any).detectedActions); - } - - // Convert the stored knowledge to the expected format - const entities: Entity[] = - knowledge.entities?.map((entity) => ({ - name: entity.name, - type: Array.isArray(entity.type) - ? entity.type.join(", ") - : entity.type, - description: entity.facets?.find( - (f) => f.name === "description", - )?.value as string, - confidence: 0.8, // Default confidence for indexed content - })) || []; - - const keyTopics: string[] = knowledge.topics || []; - - const allRelationships: Relationship[] = - knowledge.actions?.map((action) => ({ - from: action.subjectEntityName || "unknown", - relationship: action.verbs?.join(", ") || "related to", - to: action.objectEntityName || "unknown", - confidence: 0.8, // Default confidence for indexed content - })) || []; - - // Deduplicate relationships - const relationships = allRelationships.filter( - (rel, index, arr) => - arr.findIndex( - (r) => - r.from === rel.from && - r.relationship === rel.relationship && - r.to === rel.to, - ) === index, - ); - - // Generate contextual questions for indexed content - const suggestedQuestions: string[] = []; - /* - const suggestedQuestions: string[] = - await generateSmartSuggestedQuestions( - knowledge, - null, - parameters.url, - context, - ); - */ - - // Calculate content metrics from the stored text - const textContent = foundWebsite.textChunks?.join("\n\n") || ""; - const wordCount = textContent.split(/\s+/).length; - const contentMetrics = { - readingTime: Math.ceil(wordCount / 225), - wordCount: wordCount, - }; - - const summary = `Retrieved indexed knowledge: ${entities.length} entities, ${keyTopics.length} topics, ${relationships.length} relationships.`; - - return { - isIndexed: true, - knowledge: { - title: (knowledge as any).title || "", - entities, - relationships, - keyTopics, - detectedActions, - contentActions: knowledge.actions || [], - actionSummary: foundWebsite.metadata - ? (foundWebsite.metadata as any).actionSummary - : undefined, - suggestedQuestions, - summary, - contentMetrics, - }, - }; - } catch (knowledgeError) { - console.warn( - "Error extracting knowledge from indexed page:", - knowledgeError, - ); - return { - isIndexed: true, - knowledge: { - title: "", - entities: [], - relationships: [], - keyTopics: [], - detectedActions: [], - suggestedQuestions: [], - summary: "Page is indexed but knowledge extraction failed.", - contentMetrics: { - readingTime: 0, - wordCount: 0, - }, - }, - }; - } - } catch (error) { - console.error("Error getting page indexed knowledge:", error); - return { - isIndexed: false, - error: "Failed to retrieve indexed knowledge", - }; - } -} - -export async function getDiscoverInsights( - parameters: { - limit?: number; - timeframe?: string; - }, - context: SessionContext, -): Promise<{ - trendingTopics: Array<{ - topic: string; - count: number; - trend: "up" | "down" | "stable"; - percentage: number; - }>; - readingPatterns: Array<{ - timeframe: string; - activity: number; - peak: boolean; - }>; - popularPages: Array<{ - url: string; - title: string; - visitCount: number; - isBookmarked: boolean; - domain: string; - lastVisited: string; - }>; - topDomains: Array<{ - domain: string; - count: number; - favicon?: string; - trend: "up" | "down" | "stable"; - }>; - success: boolean; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - trendingTopics: [], - readingPatterns: [], - popularPages: [], - topDomains: [], - success: false, - }; - } - - const websites = websiteCollection.messages.getAll(); - const limit = parameters.limit || 10; - const timeframe = parameters.timeframe || "30d"; - - // Analyze trending topics from titles and knowledge entities - const trendingTopics = analyzeTrendingTopics(websites, limit); - - // Analyze reading patterns from temporal data - const readingPatterns = analyzeReadingPatterns(websites, timeframe); - - // Identify popular pages by activity metrics - const popularPages = analyzePopularPages(websites, limit); - - // Enhanced domain analysis with trends - const topDomains = analyzeTopDomains(websites, limit); - - return { - trendingTopics, - readingPatterns, - popularPages, - topDomains, - success: true, - }; - } catch (error) { - console.error("Error getting discover insights:", error); - return { - trendingTopics: [], - readingPatterns: [], - popularPages: [], - topDomains: [], - success: false, - }; - } -} - -function analyzeTrendingTopics(websites: any[], limit: number) { - const topicCounts = new Map(); - const recentTopicCounts = new Map(); - const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - - for (const site of websites) { - const metadata = site.metadata as any; - const title = metadata.title || ""; - const knowledge = site.getKnowledge(); - - // Extract topics from title words (basic implementation) - const titleWords = title - .toLowerCase() - .split(/\s+/) - .filter( - (word: string) => - word.length > 3 && - ![ - "the", - "and", - "for", - "are", - "but", - "not", - "you", - "all", - "can", - "had", - "her", - "was", - "one", - "our", - "out", - "day", - "get", - "has", - "him", - "his", - "how", - "its", - "may", - "new", - "now", - "old", - "see", - "two", - "way", - "who", - "boy", - "did", - "man", - "car", - "got", - "let", - "say", - "she", - "too", - "use", - ].includes(word), - ); - - titleWords.forEach((word: string) => { - topicCounts.set(word, (topicCounts.get(word) || 0) + 1); - - const visitDate = metadata.visitDate || metadata.bookmarkDate; - if (visitDate && new Date(visitDate) > thirtyDaysAgo) { - recentTopicCounts.set( - word, - (recentTopicCounts.get(word) || 0) + 1, - ); - } - }); - - // Extract topics from knowledge entities - if (knowledge?.entities) { - knowledge.entities.forEach((entity: any) => { - const entityName = entity.name?.toLowerCase(); - if (entityName && entityName.length > 2) { - topicCounts.set( - entityName, - (topicCounts.get(entityName) || 0) + 1, - ); - - const visitDate = - metadata.visitDate || metadata.bookmarkDate; - if (visitDate && new Date(visitDate) > thirtyDaysAgo) { - recentTopicCounts.set( - entityName, - (recentTopicCounts.get(entityName) || 0) + 1, - ); - } - } - }); - } - } - - const sortedTopics = Array.from(topicCounts.entries()) - .sort(([, a], [, b]) => b - a) - .slice(0, limit); - - return sortedTopics.map(([topic, count]) => { - const recentCount = recentTopicCounts.get(topic) || 0; - const historicalCount = count - recentCount; - let trend: "up" | "down" | "stable" = "stable"; - - if (recentCount > historicalCount * 1.5) { - trend = "up"; - } else if (recentCount < historicalCount * 0.5) { - trend = "down"; - } - - return { - topic, - count, - trend, - percentage: Math.round((count / websites.length) * 100), - }; - }); -} - -function analyzeReadingPatterns(websites: any[], timeframe: string) { - const patterns = new Map(); - const dayOfWeek = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ]; - - for (const site of websites) { - const metadata = site.metadata as any; - const visitDate = metadata.visitDate || metadata.bookmarkDate; - - if (visitDate) { - const date = new Date(visitDate); - const day = dayOfWeek[date.getDay()]; - patterns.set(day, (patterns.get(day) || 0) + 1); - } - } - - const maxActivity = Math.max(...Array.from(patterns.values())); - - return dayOfWeek.map((day) => ({ - timeframe: day, - activity: patterns.get(day) || 0, - peak: (patterns.get(day) || 0) === maxActivity && maxActivity > 0, - })); -} - -function analyzePopularPages(websites: any[], limit: number) { - const pageStats = new Map< - string, - { - url: string; - title: string; - visitCount: number; - isBookmarked: boolean; - domain: string; - lastVisited: string; - } - >(); - - for (const site of websites) { - const metadata = site.metadata as any; - const url = metadata.url || ""; - const title = metadata.title || url; - const domain = url ? new URL(url).hostname : ""; - const isBookmarked = !!metadata.bookmarkDate; - const lastVisited = - metadata.visitDate || - metadata.bookmarkDate || - new Date().toISOString(); - - if (url) { - const existing = pageStats.get(url); - if (existing) { - existing.visitCount++; - if (new Date(lastVisited) > new Date(existing.lastVisited)) { - existing.lastVisited = lastVisited; - } - if (isBookmarked) { - existing.isBookmarked = true; - } - } else { - pageStats.set(url, { - url, - title, - visitCount: 1, - isBookmarked, - domain, - lastVisited, - }); - } - } - } - - return Array.from(pageStats.values()) - .sort((a, b) => { - // Prioritize bookmarked pages and visit count - const scoreA = (a.isBookmarked ? 10 : 0) + a.visitCount; - const scoreB = (b.isBookmarked ? 10 : 0) + b.visitCount; - return scoreB - scoreA; - }) - .slice(0, limit); -} - -function analyzeTopDomains(websites: any[], limit: number) { - const domainCounts = new Map(); - const recentDomainCounts = new Map(); - const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - - for (const site of websites) { - const metadata = site.metadata as any; - const url = metadata.url; - - if (url) { - try { - const domain = new URL(url).hostname; - domainCounts.set(domain, (domainCounts.get(domain) || 0) + 1); - - const visitDate = metadata.visitDate || metadata.bookmarkDate; - if (visitDate && new Date(visitDate) > thirtyDaysAgo) { - recentDomainCounts.set( - domain, - (recentDomainCounts.get(domain) || 0) + 1, - ); - } - } catch (error) { - // Invalid URL, skip - } - } - } - - return Array.from(domainCounts.entries()) - .sort(([, a], [, b]) => b - a) - .slice(0, limit) - .map(([domain, count]) => { - const recentCount = recentDomainCounts.get(domain) || 0; - const historicalCount = count - recentCount; - let trend: "up" | "down" | "stable" = "stable"; - - if (recentCount > historicalCount * 1.5) { - trend = "up"; - } else if (recentCount < historicalCount * 0.5) { - trend = "down"; - } - - return { - domain, - count, - trend, - favicon: `https://www.google.com/s2/favicons?domain=${domain}`, - }; - }); -} - -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; - } -} - -function hasIndexingErrors(result: any): boolean { - return !!( - result?.semanticRefs?.error || result?.secondaryIndexResults?.error - ); -} - -export async function getDetailedKnowledgeStats( - parameters: { - includeQuality?: boolean; - includeProgress?: boolean; - timeRange?: number; - }, - context: SessionContext, -): Promise { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return createEmptyKnowledgeStats(); - } - - const websites = websiteCollection.messages.getAll(); - - // Calculate base stats - const baseStats = await calculateBaseStats(websites); - - // Calculate extraction progress - const extractionProgress = calculateExtractionProgress(websites); - - // Calculate quality distribution - const qualityDistribution = - parameters.includeQuality !== false - ? calculateQualityDistribution(websites) - : { highQuality: 0, mediumQuality: 0, lowQuality: 0 }; - - // Calculate completion rates - const completionRates = calculateCompletionRates(websites); - - return { - ...baseStats, - extractionProgress, - qualityDistribution, - completionRates, - }; -} - -function createEmptyKnowledgeStats(): DetailedKnowledgeStats { - return { - totalPages: 0, - totalEntities: 0, - totalTopics: 0, - totalRelationships: 0, - uniqueDomains: 0, - topEntityTypes: [], - topDomains: [], - recentActivity: [], - storageSize: { - totalBytes: 0, - entitiesBytes: 0, - contentBytes: 0, - metadataBytes: 0, - }, - extractionProgress: { - entityProgress: 0, - topicProgress: 0, - actionProgress: 0, - }, - qualityDistribution: { - highQuality: 0, - mediumQuality: 0, - lowQuality: 0, - }, - completionRates: { - pagesWithEntities: 0, - pagesWithTopics: 0, - pagesWithActions: 0, - totalProcessedPages: 0, - }, - }; -} - -async function calculateBaseStats(websites: any[]): Promise<{ - totalPages: number; - totalEntities: number; - totalTopics: number; - totalRelationships: number; - uniqueDomains: number; - topEntityTypes: Array<{ type: string; count: number }>; - topDomains: Array<{ domain: string; pageCount: number }>; - recentActivity: Array<{ date: string; pagesIndexed: number }>; - storageSize: { - totalBytes: number; - entitiesBytes: number; - contentBytes: number; - metadataBytes: number; - }; -}> { - let totalEntities = 0; - let totalTopics = 0; - let totalRelationships = 0; - const domains = new Set(); - const entityTypeCounts = new Map(); - const domainCounts = new Map(); - const uniqueTopicsSet = new Set(); - let totalContent = 0; - - for (const site of websites) { - try { - const knowledge = site.getKnowledge(); - const metadata = site.metadata as website.WebsiteDocPartMeta; - - // Extract domain from URL - if (metadata?.url) { - try { - const domain = new URL(metadata.url).hostname; - domains.add(domain); - domainCounts.set( - domain, - (domainCounts.get(domain) || 0) + 1, - ); - } catch (error) { - // Invalid URL, skip domain extraction - } - } - - if (knowledge) { - // Count entities and their types - if (knowledge.entities?.length > 0) { - totalEntities += knowledge.entities.length; - knowledge.entities.forEach((entity: any) => { - const type = entity.type || "Unknown"; - entityTypeCounts.set( - type, - (entityTypeCounts.get(type) || 0) + 1, - ); - }); - } - - // Count unique topics - if (knowledge.topics?.length > 0) { - knowledge.topics.forEach((topic: string) => { - uniqueTopicsSet.add(topic.toLowerCase().trim()); - }); - } - - // Count relationships/actions - if (knowledge.actions?.length > 0) { - totalRelationships += knowledge.actions.length; - } - } - - // Calculate content size - const textContent = site.textChunks?.join("") || ""; - totalContent += textContent.length; - } catch (error) { - console.warn("Error processing site for stats:", error); - } - } - - // Set totalTopics to the count of unique topics found - totalTopics = uniqueTopicsSet.size; - - // Convert entity types to sorted array - const topEntityTypes = Array.from(entityTypeCounts.entries()) - .sort(([, a], [, b]) => b - a) - .slice(0, 10) - .map(([type, count]) => ({ type, count })); - - // Convert domains to sorted array - const topDomains = Array.from(domainCounts.entries()) - .sort(([, a], [, b]) => b - a) - .slice(0, 10) - .map(([domain, pageCount]) => ({ domain, pageCount })); - - // Simple recent activity (last 7 days) - const recentActivity = generateRecentActivity(websites); - - return { - totalPages: websites.length, - totalEntities, - totalTopics, - totalRelationships, - uniqueDomains: domains.size, - topEntityTypes, - topDomains, - recentActivity, - storageSize: { - totalBytes: totalContent, - entitiesBytes: Math.round(totalContent * 0.3), // Estimate - contentBytes: Math.round(totalContent * 0.6), // Estimate - metadataBytes: Math.round(totalContent * 0.1), // Estimate - }, - }; -} - -function calculateExtractionProgress(websites: any[]): { - entityProgress: number; - topicProgress: number; - actionProgress: number; -} { - let pagesWithEntities = 0; - let pagesWithTopics = 0; - let pagesWithActions = 0; - - websites.forEach((site) => { - try { - const knowledge = site.getKnowledge(); - if (knowledge) { - if (knowledge.entities?.length > 0) pagesWithEntities++; - if (knowledge.topics?.length > 0) pagesWithTopics++; - if (knowledge.actions?.length > 0) pagesWithActions++; - } - } catch (error) { - // Skip sites with knowledge extraction errors - } - }); - - const total = websites.length || 1; // Prevent division by zero - - return { - entityProgress: Math.round((pagesWithEntities / total) * 100), - topicProgress: Math.round((pagesWithTopics / total) * 100), - actionProgress: Math.round((pagesWithActions / total) * 100), - }; -} - -function calculateQualityDistribution(websites: any[]): { - highQuality: number; - mediumQuality: number; - lowQuality: number; -} { - let high = 0, - medium = 0, - low = 0; - let totalPagesWithKnowledge = 0; - - websites.forEach((site) => { - try { - const knowledge = site.getKnowledge(); - if (knowledge && knowledge.entities?.length > 0) { - totalPagesWithKnowledge++; - - // Calculate average confidence across entities - const confidences = knowledge.entities - .map((e: any) => e.confidence || 0) - .filter((c: number) => c > 0); - - if (confidences.length > 0) { - const avgConfidence = - confidences.reduce((a: number, b: number) => a + b) / - confidences.length; - - if (avgConfidence >= 0.8) high++; - else if (avgConfidence >= 0.5) medium++; - else low++; - } else { - // No confidence scores, assume medium quality - medium++; - } - } - } catch (error) { - // Skip sites with knowledge extraction errors - } - }); - - const total = totalPagesWithKnowledge || 1; - - return { - highQuality: Math.round((high / total) * 100), - mediumQuality: Math.round((medium / total) * 100), - lowQuality: Math.round((low / total) * 100), - }; -} - -function calculateCompletionRates(websites: any[]): { - pagesWithEntities: number; - pagesWithTopics: number; - pagesWithActions: number; - totalProcessedPages: number; -} { - let pagesWithEntities = 0; - let pagesWithTopics = 0; - let pagesWithActions = 0; - - websites.forEach((site) => { - try { - const knowledge = site.getKnowledge(); - if (knowledge) { - if (knowledge.entities?.length > 0) pagesWithEntities++; - if (knowledge.topics?.length > 0) pagesWithTopics++; - if (knowledge.actions?.length > 0) pagesWithActions++; - } - } catch (error) { - // Skip sites with knowledge extraction errors - } - }); - - return { - pagesWithEntities, - pagesWithTopics, - pagesWithActions, - totalProcessedPages: websites.length, - }; -} - -function generateRecentActivity( - websites: any[], -): Array<{ date: string; pagesIndexed: number }> { - const activityMap = new Map(); - const now = new Date(); - - // Initialize last 7 days with 0 - for (let i = 6; i >= 0; i--) { - const date = new Date(now); - date.setDate(date.getDate() - i); - const dateStr = date.toISOString().split("T")[0]; - activityMap.set(dateStr, 0); - } - - // Count pages by date - websites.forEach((site) => { - try { - const metadata = site.metadata as website.WebsiteDocPartMeta; - const siteDate = metadata?.visitDate || metadata?.bookmarkDate; - - if (siteDate) { - const date = new Date(siteDate); - const dateStr = date.toISOString().split("T")[0]; - - if (activityMap.has(dateStr)) { - activityMap.set( - dateStr, - (activityMap.get(dateStr) || 0) + 1, - ); - } - } - } catch (error) { - // Skip sites with invalid dates - } - }); - - return Array.from(activityMap.entries()) - .map(([date, pagesIndexed]) => ({ date, pagesIndexed })) - .sort((a, b) => a.date.localeCompare(b.date)); -} - -export async function getAnalyticsData( - parameters: { - timeRange?: string; - includeQuality?: boolean; - includeProgress?: boolean; - topDomainsLimit?: number; - activityGranularity?: "day" | "week" | "month"; - }, - context: SessionContext, -): Promise { - try { - // Single coordinated data collection using Promise.all for efficiency - const [ - knowledgeStats, - topDomains, - activityTrends, - extractionAnalytics, - recentKnowledgeItems, - ] = await Promise.all([ - getDetailedKnowledgeStats( - { - includeQuality: parameters.includeQuality !== false, - includeProgress: parameters.includeProgress !== false, - timeRange: 30, - }, - context, - ), - getTopDomains( - { - limit: parameters.topDomainsLimit || 10, - }, - context, - ), - getActivityTrends( - { - timeRange: parameters.timeRange || "30d", - granularity: parameters.activityGranularity || "day", - }, - context, - ), - getExtractionAnalytics( - { - timeRange: parameters.timeRange || "30d", - }, - context, - ), - getRecentKnowledgeItems({ limit: 10, type: "all" }, context), - ]); - - // Get basic website statistics from websiteCollection - const websiteCollection = context.agentContext.websiteCollection; - let totalSites = 0; - let totalBookmarks = 0; - let totalHistory = 0; - let totalActions = 0; - - if (websiteCollection) { - const websites = websiteCollection.messages.getAll(); - totalSites = websites.length; - - // Count bookmarks vs history and total actions - websites.forEach((site) => { - const metadata = site.metadata as website.WebsiteDocPartMeta; - if (metadata?.bookmarkDate) { - totalBookmarks++; - } else { - totalHistory++; - } - - // Count actions in this site's knowledge - const knowledge = site.getKnowledge(); - if (knowledge) { - const actions = - (knowledge as any).actions || - (knowledge as any).detectedActions || - []; - if (Array.isArray(actions)) { - totalActions += actions.length; - } - } - }); - } - - return { - overview: { - totalSites, - totalBookmarks, - totalHistory, - topDomains: topDomains.domains?.length || 0, - knowledgeExtracted: knowledgeStats.totalPages || 0, - }, - knowledge: { - extractionProgress: knowledgeStats.extractionProgress || { - entityProgress: 0, - topicProgress: 0, - actionProgress: 0, - }, - qualityDistribution: knowledgeStats.qualityDistribution || { - highQuality: 0, - mediumQuality: 0, - lowQuality: 0, - }, - totalEntities: knowledgeStats.totalEntities || 0, - totalTopics: knowledgeStats.totalTopics || 0, - totalActions: totalActions, - totalRelationships: knowledgeStats.totalRelationships || 0, - recentItems: knowledgeStats.recentActivity || [], - recentEntities: recentKnowledgeItems.entities || [], - recentTopics: recentKnowledgeItems.topics || [], - recentActions: recentKnowledgeItems.actions || [], - recentRelationships: recentKnowledgeItems.relationships || [], - }, - domains: { - topDomains: topDomains.domains || [], - totalSites: topDomains.totalSites || 0, - }, - activity: { - trends: activityTrends.trends || [], - summary: activityTrends.summary || { - totalActivity: 0, - peakDay: null, - averagePerDay: 0, - timeRange: parameters.timeRange || "30d", - }, - }, - analytics: { - extractionMetrics: extractionAnalytics.analytics || {}, - qualityReport: extractionAnalytics.analytics || {}, - }, - }; - } catch (error) { - console.error("Error aggregating analytics data:", error); - // Return empty analytics data on error - return { - overview: { - totalSites: 0, - totalBookmarks: 0, - totalHistory: 0, - topDomains: 0, - knowledgeExtracted: 0, - }, - knowledge: { - extractionProgress: { - entityProgress: 0, - topicProgress: 0, - actionProgress: 0, - }, - qualityDistribution: { - highQuality: 0, - mediumQuality: 0, - lowQuality: 0, - }, - totalEntities: 0, - totalTopics: 0, - totalActions: 0, - totalRelationships: 0, - recentItems: [], - }, - domains: { - topDomains: [], - totalSites: 0, - }, - activity: { - trends: [], - summary: { - totalActivity: 0, - peakDay: null, - averagePerDay: 0, - timeRange: parameters.timeRange || "30d", - }, - }, - analytics: { - extractionMetrics: {}, - qualityReport: {}, - }, - }; - } -} - -export async function getKnowledgeGraphStatus( - parameters: {}, - context: SessionContext, -): Promise<{ - hasGraph: boolean; - entityCount: number; - relationshipCount: number; - communityCount: number; - isBuilding: boolean; - error?: string; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - debug("website collection not found"); - return { - hasGraph: false, - entityCount: 0, - relationshipCount: 0, - communityCount: 0, - isBuilding: false, - error: "Website collection not available", - }; - } - - // Check if relationships and communities tables exist - if ( - !websiteCollection.relationships || - !websiteCollection.communities - ) { - // Tables not initialized, no graph exists - return { - hasGraph: false, - entityCount: 0, - relationshipCount: 0, - communityCount: 0, - isBuilding: false, - }; - } - - // Get entity count from knowledge entities table - let entityCount = 0; - try { - if (websiteCollection.knowledgeEntities) { - entityCount = ( - websiteCollection.knowledgeEntities as any - ).getTotalEntityCount(); - } - } catch (error) { - console.warn("Failed to get entity count:", error); - } - - // Get relationship count - let relationshipCount = 0; - try { - const relationships = - websiteCollection.relationships.getAllRelationships(); - relationshipCount = relationships.length; - } catch (error) { - console.warn("Failed to get relationship count:", error); - } - - // Get community count - let communityCount = 0; - try { - const communities = - websiteCollection.communities.getAllCommunities(); - communityCount = communities.length; - } catch (error) { - console.warn("Failed to get community count:", error); - } - - // Determine if graph exists based on actual data - const hasGraph = relationshipCount > 0 || entityCount > 0; - - return { - hasGraph: hasGraph, - entityCount, - relationshipCount, - communityCount, - isBuilding: false, - }; - } catch (error) { - console.error("Error getting knowledge graph status:", error); - return { - hasGraph: false, - entityCount: 0, - relationshipCount: 0, - communityCount: 0, - isBuilding: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } -} - -export async function buildKnowledgeGraph( - parameters: {}, - context: SessionContext, -): Promise<{ - success: boolean; - message?: string; - error?: string; - stats?: { - entitiesFound: number; - relationshipsCreated: number; - communitiesDetected: number; - timeElapsed: number; - }; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - success: false, - error: "Website collection not available", - }; - } - - debug( - "[Knowledge Graph] Starting knowledge graph build with parameters:", - parameters, - ); - - const startTime = Date.now(); - await websiteCollection.buildGraph(); - const timeElapsed = Date.now() - startTime; - - // Get stats directly from websiteCollection using existing status method - const status = await getKnowledgeGraphStatus({}, context); - - const stats = { - entitiesFound: status.entityCount, - relationshipsCreated: status.relationshipCount, - communitiesDetected: status.communityCount, - timeElapsed: timeElapsed, - }; - - debug("[Knowledge Graph] Build completed:", stats); - - return { - success: true, - message: `Knowledge graph build completed in ${timeElapsed}ms`, - stats, - }; - } catch (error) { - console.error("[Knowledge Graph] Error building:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } -} - -export async function rebuildKnowledgeGraph( - parameters: {}, - context: SessionContext, -): Promise<{ - success: boolean; - message?: string; - error?: string; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - success: false, - error: "Website collection not available", - }; - } - - // Clear existing graph data and rebuild - try { - // Clear existing graph tables if they exist - if (websiteCollection.relationships) { - websiteCollection.relationships.clear(); - } - if (websiteCollection.communities) { - websiteCollection.communities.clear(); - } - } catch (clearError) { - // Continue even if clearing fails, as the rebuild might overwrite - console.warn("Failed to clear existing graph data:", clearError); - } - - // Rebuild the knowledge graph - await websiteCollection.buildGraph(); - - return { - success: true, - message: "Knowledge graph rebuilt successfully", - }; - } catch (error) { - console.error("Error rebuilding knowledge graph:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } -} - -export async function getAllRelationships( - parameters: {}, - context: SessionContext, -): Promise<{ - relationships: any[]; - error?: string; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - relationships: [], - error: "Website collection not available", - }; - } - - const relationships = - websiteCollection.relationships?.getAllRelationships() || []; - - // Apply same optimization as getGlobalImportanceLayer for consistency - const optimizedRelationships = relationships.map((rel: any) => ({ - rowId: rel.rowId, - fromEntity: rel.fromEntity, - toEntity: rel.toEntity, - relationshipType: rel.relationshipType, - confidence: rel.confidence, - // Deduplicate sources using Set, then limit to first 3 entries - sources: rel.sources - ? typeof rel.sources === "string" - ? Array.from(new Set(JSON.parse(rel.sources))).slice(0, 3) - : Array.isArray(rel.sources) - ? Array.from(new Set(rel.sources)).slice(0, 3) - : rel.sources - : undefined, - count: rel.count, - })); - - return { - relationships: optimizedRelationships, - }; - } catch (error) { - console.error("Error getting all relationships:", error); - return { - relationships: [], - error: error instanceof Error ? error.message : "Unknown error", - }; - } -} - -export async function getAllCommunities( - parameters: {}, - context: SessionContext, -): Promise<{ - communities: any[]; - error?: string; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - communities: [], - error: "Website collection not available", - }; - } - - const communities = - websiteCollection.communities?.getAllCommunities() || []; - - return { - communities: communities, - }; - } catch (error) { - console.error("Error getting all communities:", error); - return { - communities: [], - error: error instanceof Error ? error.message : "Unknown error", - }; - } -} - -// Simple in-memory cache for graph data -interface GraphCache { - entities: any[]; - relationships: any[]; - communities: any[]; - entityMetrics: any[]; - lastUpdated: number; - isValid: boolean; -} - -// Cache storage attached to websiteCollection -function getGraphCache(websiteCollection: any): GraphCache | null { - return (websiteCollection as any).__graphCache || null; -} - -function setGraphCache(websiteCollection: any, cache: GraphCache): void { - (websiteCollection as any).__graphCache = cache; -} - -// Ensure graph data is cached for fast access -async function ensureGraphCache(websiteCollection: any): Promise { - const cache = getGraphCache(websiteCollection); - const CACHE_TTL = 5 * 60 * 1000; // 5 minutes - - // Check if cache is valid - if (cache && cache.isValid && Date.now() - cache.lastUpdated < CACHE_TTL) { - debug("[Knowledge Graph] Using valid cached graph data"); - return; - } - - debug("[Knowledge Graph] Building in-memory cache for graph data"); - - try { - // Fetch raw data - const entities = - (websiteCollection.knowledgeEntities as any)?.getTopEntities( - 5000, - ) || []; - const relationships = - websiteCollection.relationships?.getAllRelationships() || []; - const communities = - websiteCollection.communities?.getAllCommunities() || []; - - // Calculate metrics - const entityMetrics = calculateEntityMetrics( - entities, - relationships, - communities, - ); - - // Store in cache - const newCache: GraphCache = { - entities: entities, - relationships: relationships, - communities: communities, - entityMetrics: entityMetrics, - lastUpdated: Date.now(), - isValid: true, - }; - - setGraphCache(websiteCollection, newCache); - - debug( - `[Knowledge Graph] Cached ${entities.length} entities, ${relationships.length} relationships, ${communities.length} communities`, - ); - } catch (error) { - console.error("[Knowledge Graph] Failed to build cache:", error); - - // Mark cache as invalid but keep existing data if available - const existingCache = getGraphCache(websiteCollection); - if (existingCache) { - existingCache.isValid = false; - } - } -} - -export async function getAllEntitiesWithMetrics( - parameters: {}, - context: SessionContext, -): Promise<{ - entities: any[]; - error?: string; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - entities: [], - error: "Website collection not available", - }; - } - - // Ensure cache is populated - await ensureGraphCache(websiteCollection); - - // Get cached data - const cache = getGraphCache(websiteCollection); - if (cache && cache.isValid && cache.entityMetrics.length > 0) { - debug( - `[Knowledge Graph] Using cached entity data: ${cache.entityMetrics.length} entities`, - ); - - // Apply entity optimization similar to getGlobalImportanceLayer - const optimizedEntities = cache.entityMetrics.map( - (entity: any) => ({ - id: entity.id || entity.name, - name: entity.name, - type: entity.type || "entity", - confidence: entity.confidence || 0.5, - count: entity.count, - degree: entity.degree, - importance: entity.importance, - communityId: entity.communityId, - size: entity.size, - }), - ); - - return { - entities: optimizedEntities, - }; - } - - // Fallback to live computation if no cache - debug( - "[Knowledge Graph] Cache not available, computing entities with metrics", - ); - const entities = - (websiteCollection.knowledgeEntities as any)?.getTopEntities( - 5000, - ) || []; - const relationships = - websiteCollection.relationships?.getAllRelationships() || []; - const communities = - websiteCollection.communities?.getAllCommunities() || []; - - const entityMetrics = calculateEntityMetrics( - entities, - relationships, - communities, - ); - - const optimizedEntities = entityMetrics.map((entity: any) => ({ - id: entity.id || entity.name, - name: entity.name, - type: entity.type || "entity", - confidence: entity.confidence || 0.5, - count: entity.count, - degree: entity.degree, - importance: entity.importance, - communityId: entity.communityId, - size: entity.size, - })); - - return { - entities: optimizedEntities, - }; - } catch (error) { - console.error("Error getting all entities with metrics:", error); - return { - entities: [], - error: error instanceof Error ? error.message : "Unknown error", - }; - } -} - -export async function getEntityNeighborhood( - parameters: { - entityId: string; - depth?: number; - maxNodes?: number; - }, - context: SessionContext, -): Promise<{ - centerEntity?: any; - neighbors: any[]; - relationships: any[]; - searchData?: any; - metadata?: any; - error?: string; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - neighbors: [], - relationships: [], - error: "Website collection not available", - }; - } - - const { entityId, depth = 2, maxNodes = 100 } = parameters; - - // Ensure cache is populated - await ensureGraphCache(websiteCollection); - - // Get cached data - const cache = getGraphCache(websiteCollection); - if (!cache || !cache.isValid) { - return { - neighbors: [], - relationships: [], - error: "Graph cache not available", - }; - } - - debug( - `[Knowledge Graph] Performing BFS for entity "${entityId}" (depth: ${depth}, maxNodes: ${maxNodes})`, - ); - - // Perform BFS to find neighborhood - const neighborhoodResult = performBFS( - entityId, - cache.entityMetrics, - cache.relationships, - depth, - maxNodes, - ); - - if (!neighborhoodResult.centerEntity) { - const searchNeibhbors = await searchByEntities( - { entities: [entityId], maxResults: 20 }, - context, - ); - - if (searchNeibhbors) { - return { - centerEntity: { - id: entityId, - name: entityId, - type: "entity", - confidence: 0.5, - count: 1, - }, - neighbors: searchNeibhbors.relatedEntities || [], - relationships: [], - searchData: { - relatedEntities: searchNeibhbors?.relatedEntities || [], - topTopics: searchNeibhbors?.topTopics || [], - websites: searchNeibhbors?.websites || [], - }, - metadata: { - source: "in_memory_cache", - queryDepth: depth, - maxNodes: maxNodes, - actualNodes: - (searchNeibhbors?.relatedEntities?.length || 0) + 1, - actualEdges: 0, - searchEnrichment: { - relatedEntities: - searchNeibhbors?.relatedEntities?.length || 0, - topTopics: searchNeibhbors?.topTopics?.length || 0, - websites: searchNeibhbors?.websites?.length || 0, - }, - }, - }; - } else { - return { - neighbors: [], - relationships: [], - error: `Entity "${entityId}" not found`, - }; - } - } - - // Get search enrichment for topics and related entities - let searchData: any = null; - try { - const searchResults = await searchByEntities( - { entities: [entityId], maxResults: 20 }, - context, - ); - - if (searchResults) { - searchData = { - websites: searchResults.websites?.slice(0, 15) || [], - relatedEntities: - searchResults.relatedEntities?.slice(0, 15) || [], - topTopics: searchResults.topTopics?.slice(0, 10) || [], - }; - - debug( - `[Knowledge Graph] Search enrichment found: ${searchData.websites.length} websites, ${searchData.relatedEntities.length} related entities, ${searchData.topTopics.length} topics`, - ); - } - } catch (searchError) { - console.warn( - `[Knowledge Graph] Search enrichment failed:`, - searchError, - ); - } - - // Optimize relationships (same as other functions) - const optimizedRelationships = neighborhoodResult.relationships.map( - (rel: any) => ({ - rowId: rel.rowId, - fromEntity: rel.fromEntity, - toEntity: rel.toEntity, - relationshipType: rel.relationshipType, - confidence: rel.confidence, - sources: rel.sources - ? typeof rel.sources === "string" - ? Array.from(new Set(JSON.parse(rel.sources))).slice( - 0, - 3, - ) - : Array.isArray(rel.sources) - ? Array.from(new Set(rel.sources)).slice(0, 3) - : rel.sources - : undefined, - count: rel.count, - }), - ); - - // Optimize entities (centerEntity and neighbors) - const optimizeEntity = (entity: any) => - entity - ? { - id: entity.id || entity.name, - name: entity.name, - type: entity.type || "entity", - confidence: entity.confidence || 0.5, - count: entity.count, - degree: entity.degree, - importance: entity.importance, - communityId: entity.communityId, - size: entity.size, - } - : null; - - const optimizedResult = { - centerEntity: optimizeEntity(neighborhoodResult.centerEntity), - neighbors: neighborhoodResult.neighbors.map(optimizeEntity), - relationships: optimizedRelationships, - searchData: { - relatedEntities: searchData?.relatedEntities || [], - topTopics: searchData?.topTopics || [], - websites: searchData?.websites || [], - }, - metadata: { - source: "in_memory_cache", - queryDepth: depth, - maxNodes: maxNodes, - actualNodes: neighborhoodResult.neighbors.length + 1, - actualEdges: neighborhoodResult.relationships.length, - searchEnrichment: { - relatedEntities: searchData?.relatedEntities?.length || 0, - topTopics: searchData?.topTopics?.length || 0, - websites: searchData?.websites?.length || 0, - }, - }, - }; - - return optimizedResult; - } catch (error) { - console.error("Error getting entity neighborhood:", error); - return { - neighbors: [], - relationships: [], - error: error instanceof Error ? error.message : "Unknown error", - }; - } -} - -// BFS implementation for finding entity neighborhood -function performBFS( - entityId: string, - entities: any[], - relationships: any[], - maxDepth: number, - maxNodes: number, -): { - centerEntity?: any; - neighbors: any[]; - relationships: any[]; -} { - // Find center entity (case insensitive) - const centerEntity = entities.find( - (e) => - e.name?.toLowerCase() === entityId.toLowerCase() || - e.id?.toLowerCase() === entityId.toLowerCase(), - ); - - if (!centerEntity) { - return { neighbors: [], relationships: [] }; - } - - // Build adjacency map for fast lookups - const adjacencyMap = new Map(); - const relationshipMap = new Map(); - - relationships.forEach((rel) => { - const fromName = rel.fromEntity || rel.from; - const toName = rel.toEntity || rel.to; - - if (fromName && toName) { - // Normalize entity names for lookup - const fromKey = fromName.toLowerCase(); - const toKey = toName.toLowerCase(); - - if (!adjacencyMap.has(fromKey)) adjacencyMap.set(fromKey, []); - if (!adjacencyMap.has(toKey)) adjacencyMap.set(toKey, []); - - adjacencyMap.get(fromKey)!.push(toKey); - adjacencyMap.get(toKey)!.push(fromKey); - - const relKey = `${fromKey}-${toKey}`; - const relKey2 = `${toKey}-${fromKey}`; - relationshipMap.set(relKey, rel); - relationshipMap.set(relKey2, rel); - } - }); - - // BFS traversal - const visited = new Set(); - const queue: Array<{ entityName: string; depth: number }> = []; - const result = { - neighbors: [] as any[], - relationships: [] as any[], - }; - - const centerKey = - centerEntity.name?.toLowerCase() || centerEntity.id?.toLowerCase(); - queue.push({ entityName: centerKey, depth: 0 }); - visited.add(centerKey); - - while (queue.length > 0 && result.neighbors.length < maxNodes) { - const current = queue.shift()!; - - if (current.depth > 0) { - // Find the actual entity object - const entity = entities.find( - (e) => - e.name?.toLowerCase() === current.entityName || - e.id?.toLowerCase() === current.entityName, - ); - - if (entity) { - result.neighbors.push(entity); - } - } - - if (current.depth < maxDepth) { - const neighbors = adjacencyMap.get(current.entityName) || []; - - for (const neighborKey of neighbors) { - if ( - !visited.has(neighborKey) && - result.neighbors.length < maxNodes - ) { - visited.add(neighborKey); - queue.push({ - entityName: neighborKey, - depth: current.depth + 1, - }); - - // Add relationship - const relKey = `${current.entityName}-${neighborKey}`; - const relationship = relationshipMap.get(relKey); - if ( - relationship && - !result.relationships.find( - (r) => r.rowId === relationship.rowId, - ) - ) { - result.relationships.push(relationship); - } - } - } - } - } - - // add relationships between neighbors - for (let i = 0; i < result.neighbors.length; i++) { - for (let j = i + 1; j < result.neighbors.length; j++) { - const neighborA = result.neighbors[i]; - const neighborB = result.neighbors[j]; - const relKey = `${neighborA.name?.toLowerCase() || neighborA.id?.toLowerCase()}-${neighborB.name?.toLowerCase() || neighborB.id?.toLowerCase()}`; - const relationship = relationshipMap.get(relKey); - if ( - relationship && - !result.relationships.find( - (r) => r.rowId === relationship.rowId, - ) - ) { - result.relationships.push(relationship); - } - } - } - - return { - centerEntity, - neighbors: result.neighbors, - relationships: result.relationships, - }; -} - -function calculateEntityMetrics( - entities: any[], - relationships: any[], - communities: any[], -): any[] { - const entityMap = new Map(); - const degreeMap = new Map(); - const communityMap = new Map(); - - entities.forEach((entity) => { - const entityName = entity.entityName || entity.name; - entityMap.set(entityName, { - id: entityName, - name: entityName, - type: entity.entityType || entity.type || "entity", - confidence: entity.confidence || 0.5, - count: entity.count || 1, - }); - degreeMap.set(entityName, 0); - }); - - communities.forEach((community, index) => { - let communityEntities: string[] = []; - try { - communityEntities = - typeof community.entities === "string" - ? JSON.parse(community.entities) - : Array.isArray(community.entities) - ? community.entities - : []; - } catch (e) { - communityEntities = []; - } - - communityEntities.forEach((entityName) => { - communityMap.set(entityName, community.id || `community_${index}`); - }); - }); - - relationships.forEach((rel) => { - const from = rel.fromEntity; - const to = rel.toEntity; - - if (degreeMap.has(from)) { - degreeMap.set(from, degreeMap.get(from)! + 1); - } else { - debug( - `[DEBUG-Backend] Warning: fromEntity '${from}' not found in degreeMap`, - ); - } - if (degreeMap.has(to)) { - degreeMap.set(to, degreeMap.get(to)! + 1); - } else { - debug( - `[DEBUG-Backend] Warning: toEntity '${to}' not found in degreeMap`, - ); - } - }); - - // Debug: Show degree map statistics - const degreeValues = Array.from(degreeMap.values()); - const nonZeroDegrees = degreeValues.filter((d) => d > 0); - debug( - `[DEBUG-Backend] Degree map stats: total entities=${degreeValues.length}, nonZero=${nonZeroDegrees.length}, max=${Math.max(...degreeValues)}`, - ); - if (nonZeroDegrees.length > 0 && nonZeroDegrees.length <= 10) { - debug( - `[DEBUG-Backend] Non-zero degrees:`, - Array.from(degreeMap.entries()).filter(([, v]) => v > 0), - ); - } - - const maxDegree = Math.max(...Array.from(degreeMap.values())) || 1; - - debug( - `[DEBUG-Backend] calculateEntityMetrics: entityCount=${entities.length}, relationshipCount=${relationships.length}, maxDegree=${maxDegree}`, - ); - - const results = Array.from(entityMap.values()).map((entity) => { - const degree = degreeMap.get(entity.name) || 0; - const importance = degree / maxDegree; - return { - ...entity, - degree: degree, - importance: importance, - communityId: communityMap.get(entity.name) || "default", - size: Math.max(8, Math.min(40, 8 + Math.sqrt(degree * 3))), - }; - }); - - return results; -} - -// ============================================================================ -// Hierarchical Partitioned Loading API Methods -// ============================================================================ - -interface ImportanceLevel { - level: 1 | 2 | 3 | 4; - threshold: number; - maxNodes: number; - description: string; -} - -const IMPORTANCE_LEVELS: ImportanceLevel[] = [ - { - level: 1, - threshold: 0.8, - maxNodes: 1000, - description: "Critical Nodes Only", - }, - { - level: 2, - threshold: 0.5, - maxNodes: 5000, - description: "Important Nodes", - }, - { level: 3, threshold: 0.2, maxNodes: 15000, description: "Most Nodes" }, - { level: 4, threshold: 0.0, maxNodes: 50000, description: "All Nodes" }, -]; - -export async function getGlobalImportanceLayer( - parameters: { - maxNodes?: number; - minImportanceThreshold?: number; - includeConnectivity?: boolean; - }, - context: SessionContext, -): Promise<{ - entities: any[]; - relationships: any[]; - metadata: any; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - console.log(`[ServerPerf] No website collection available`); - return { - entities: [], - relationships: [], - metadata: { - totalEntitiesInSystem: 0, - selectedEntityCount: 0, - coveragePercentage: 0, - importanceThreshold: 0, - layer: "global_importance", - }, - }; - } - - // Ensure cache is populated - await ensureGraphCache(websiteCollection); - - // Get cached data - const cache = getGraphCache(websiteCollection); - - if (!cache || !cache.isValid) { - console.log( - `[ServerPerf] Cache validation failed: ${JSON.stringify({ - hasCache: !!cache, - isValid: cache?.isValid, - })}`, - ); - return { - entities: [], - relationships: [], - metadata: { - error: "Graph cache not available", - layer: "global_importance", - }, - }; - } - - // Get all entities and calculate metrics - const allEntities = cache.entityMetrics || []; - const allRelationships = cache.relationships || []; - const communities = cache.communities || []; - - if (allEntities.length === 0) { - return { - entities: [], - relationships: [], - metadata: { - totalEntitiesInSystem: 0, - selectedEntityCount: 0, - coveragePercentage: 0, - importanceThreshold: 0, - layer: "global_importance", - }, - }; - } - - const entitiesWithMetrics = calculateEntityMetrics( - allEntities, - allRelationships, - communities, - ); - - // Sort by importance and select top nodes - const maxNodes = parameters.maxNodes || 500; - const sortedEntities = entitiesWithMetrics.sort( - (a, b) => (b.importance || 0) - (a.importance || 0), - ); - - let selectedEntities = sortedEntities.slice(0, maxNodes); - // Ensure connectivity by adding bridge nodes if needed - if (parameters.includeConnectivity !== false) { - selectedEntities = ensureGlobalConnectivity( - selectedEntities, - allRelationships, - maxNodes, - ); - } - - // Get all relationships between selected entities - const selectedEntityNames = new Set( - selectedEntities.map((e) => e.name), - ); - const selectedRelationships = allRelationships.filter( - (rel: any) => - selectedEntityNames.has(rel.fromEntity) && - selectedEntityNames.has(rel.toEntity), - ); - - const metadata = { - totalEntitiesInSystem: allEntities.length, - selectedEntityCount: selectedEntities.length, - coveragePercentage: - (selectedEntities.length / allEntities.length) * 100, - importanceThreshold: - selectedEntities[selectedEntities.length - 1]?.importance || 0, - connectedComponents: analyzeConnectivity( - selectedEntities, - selectedRelationships, - ), - layer: "global_importance", - }; - - const optimizedRelationships = selectedRelationships.map( - (rel: any) => ({ - rowId: rel.rowId, - fromEntity: rel.fromEntity, - toEntity: rel.toEntity, - relationshipType: rel.relationshipType, - confidence: rel.confidence, - // Deduplicate sources using Set, then limit to first 3 entries - sources: rel.sources - ? typeof rel.sources === "string" - ? Array.from(new Set(JSON.parse(rel.sources))).slice( - 0, - 3, - ) - : Array.isArray(rel.sources) - ? Array.from(new Set(rel.sources)).slice(0, 3) - : rel.sources - : undefined, - count: rel.count, - }), - ); - - const optimizedEntities = selectedEntities.map((entity: any) => ({ - id: entity.id || entity.name, - name: entity.name, - type: entity.type || "entity", - confidence: entity.confidence || 0.5, - count: entity.count, - degree: entity.degree, - importance: entity.importance, - communityId: entity.communityId, - size: entity.size, - })); - - return { - entities: optimizedEntities, - relationships: optimizedRelationships, - metadata: metadata, - }; - } catch (error) { - console.error("Error getting global importance layer:", error); - return { - entities: [], - relationships: [], - metadata: { - error: error instanceof Error ? error.message : "Unknown error", - layer: "global_importance", - }, - }; - } -} - -export async function getViewportBasedNeighborhood( - parameters: { - centerEntity: string; - viewportNodeNames: string[]; - maxNodes?: number; - importanceWeighting?: boolean; - includeGlobalContext?: boolean; - exploreFromAllViewportNodes?: boolean; - minDepthFromViewport?: number; - }, - context: SessionContext, -): Promise<{ - entities: any[]; - relationships: any[]; - metadata: any; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { - entities: [], - relationships: [], - metadata: { - error: "Website collection not available", - layer: "viewport_neighborhood", - }, - }; - } - - // Ensure cache is populated - await ensureGraphCache(websiteCollection); - - // Get cached data - const cache = getGraphCache(websiteCollection); - if (!cache || !cache.isValid) { - return { - entities: [], - relationships: [], - metadata: { - error: "Graph cache not available", - layer: "viewport_neighborhood", - }, - }; - } - - const allEntities = cache.entityMetrics || []; - const allRelationships = cache.relationships || []; - const communities = cache.communities || []; - - const entitiesWithMetrics = calculateEntityMetrics( - allEntities, - allRelationships, - communities, - ); - const maxNodes = parameters.maxNodes || 500; - const minDepthFromViewport = parameters.minDepthFromViewport || 1; - const exploreFromAll = parameters.exploreFromAllViewportNodes !== false; - - // Find center entity - const centerEntity = entitiesWithMetrics.find( - (e) => - e.name?.toLowerCase() === - parameters.centerEntity.toLowerCase() || - e.id?.toLowerCase() === parameters.centerEntity.toLowerCase(), - ); - - if (!centerEntity) { - return { - entities: [], - relationships: [], - metadata: { - error: "Center entity not found", - layer: "viewport_neighborhood", - }, - }; - } - - // Find viewport entities - const viewportEntities: any[] = []; - const viewportNodeNamesLower = (parameters.viewportNodeNames || []).map( - (name) => name.toLowerCase(), - ); - - for (const nodeName of viewportNodeNamesLower) { - const entity = entitiesWithMetrics.find( - (e) => - e.name?.toLowerCase() === nodeName || - e.id?.toLowerCase() === nodeName, - ); - if (entity) { - viewportEntities.push(entity); - } - } - - if ( - viewportEntities.length === 0 && - parameters.viewportNodeNames && - parameters.viewportNodeNames.length > 0 - ) { - console.warn( - `No viewport entities found from ${parameters.viewportNodeNames.length} names`, - ); - } - - // Build adjacency map with importance weighting - const adjacencyMap = buildImportanceWeightedAdjacency( - entitiesWithMetrics, - allRelationships, - ); - - // Start with center entity and viewport entities as initial set - const initialEntities = [centerEntity, ...viewportEntities]; - const visited = new Set(); - const result: any[] = []; - - // Add all initial entities to result and visited set - initialEntities.forEach((entity) => { - if (!visited.has(entity.name.toLowerCase())) { - visited.add(entity.name.toLowerCase()); - result.push(entity); - } - }); - - // BFS queue: [entity, depth from nearest viewport node, source] - type QueueItem = { - entity: any; - depth: number; - importance: number; - source: string; - }; - const queue: QueueItem[] = []; - - // Initialize queue with neighbors of all initial entities - if (exploreFromAll) { - // Explore from all viewport nodes simultaneously - initialEntities.forEach((startEntity) => { - const neighbors = - adjacencyMap.get(startEntity.name.toLowerCase()) || []; - neighbors.forEach((neighbor) => { - if (!visited.has(neighbor.entity.name.toLowerCase())) { - queue.push({ - entity: neighbor.entity, - depth: 1, - importance: neighbor.importance, - source: startEntity.name, - }); - } - }); - }); - } else { - // Only explore from center entity - const centerNeighbors = - adjacencyMap.get(centerEntity.name.toLowerCase()) || []; - centerNeighbors.forEach((neighbor) => { - if (!visited.has(neighbor.entity.name.toLowerCase())) { - queue.push({ - entity: neighbor.entity, - depth: 1, - importance: neighbor.importance, - source: centerEntity.name, - }); - } - }); - } - - // Sort queue by importance if weighting is enabled - if (parameters.importanceWeighting !== false) { - queue.sort((a, b) => b.importance - a.importance); - } - - // Expand neighborhood using BFS - let actualMaxDepth = 0; - while (queue.length > 0 && result.length < maxNodes) { - const current = queue.shift()!; - - // Skip if already visited or depth exceeds minimum - if (visited.has(current.entity.name.toLowerCase())) continue; - if (current.depth < minDepthFromViewport) { - // Still need to explore neighbors even if not adding this node yet - const neighbors = - adjacencyMap.get(current.entity.name.toLowerCase()) || []; - neighbors.forEach((neighbor) => { - if (!visited.has(neighbor.entity.name.toLowerCase())) { - queue.push({ - entity: neighbor.entity, - depth: current.depth + 1, - importance: neighbor.importance, - source: current.source, - }); - } - }); - - // Re-sort if importance weighting is enabled - if (parameters.importanceWeighting !== false) { - queue.sort((a, b) => b.importance - a.importance); - } - continue; - } - - // Add to result - visited.add(current.entity.name.toLowerCase()); - result.push(current.entity); - actualMaxDepth = Math.max(actualMaxDepth, current.depth); - - // Add neighbors to queue - const neighbors = - adjacencyMap.get(current.entity.name.toLowerCase()) || []; - neighbors.forEach((neighbor) => { - if (!visited.has(neighbor.entity.name.toLowerCase())) { - queue.push({ - entity: neighbor.entity, - depth: current.depth + 1, - importance: neighbor.importance, - source: current.source, - }); - } - }); - - // Re-sort queue by importance after adding new neighbors - if (parameters.importanceWeighting !== false) { - queue.sort((a, b) => b.importance - a.importance); - } - } - - // Optionally include global context nodes - if (parameters.includeGlobalContext) { - const availableSlots = maxNodes - result.length; - if (availableSlots > 0) { - const resultNames = new Set(result.map((e) => e.name)); - const globalNodes = entitiesWithMetrics - .filter((e) => !resultNames.has(e.name)) - .sort((a, b) => (b.importance || 0) - (a.importance || 0)) - .slice( - 0, - Math.min( - availableSlots, - Math.floor(availableSlots * 0.1), - ), - ); // Add up to 10% global context - result.push(...globalNodes); - } - } - - // Get relationships between all included entities - const entityNames = new Set(result.map((e) => e.name)); - const neighborhoodRelationships = allRelationships.filter( - (rel: any) => - entityNames.has(rel.fromEntity) && - entityNames.has(rel.toEntity), - ); - - return { - entities: result, - relationships: neighborhoodRelationships, - metadata: { - centerEntity: centerEntity.name, - viewportEntities: viewportEntities.map((e) => e.name), - viewportNodesFound: viewportEntities.length, - viewportNodesRequested: - parameters.viewportNodeNames?.length || 0, - actualDepth: actualMaxDepth, - entityCount: result.length, - relationshipCount: neighborhoodRelationships.length, - importanceRange: - result.length > 0 - ? { - min: Math.min( - ...result.map((e) => e.importance || 0), - ), - max: Math.max( - ...result.map((e) => e.importance || 0), - ), - } - : { min: 0, max: 0 }, - exploreFromAllViewportNodes: exploreFromAll, - minDepthFromViewport: minDepthFromViewport, - layer: "viewport_neighborhood", - }, - }; - } catch (error) { - console.error("Error getting viewport-based neighborhood:", error); - return { - entities: [], - relationships: [], - metadata: { - error: error instanceof Error ? error.message : "Unknown error", - layer: "viewport_neighborhood", - }, - }; - } -} - -export async function getImportanceStatistics( - parameters: {}, - context: SessionContext, -): Promise<{ - distribution: number[]; - recommendedLevel: number; - levelPreview: Array<{ level: number; nodeCount: number; coverage: number }>; -}> { - try { - const websiteCollection = context.agentContext.websiteCollection; - - if (!websiteCollection) { - return { distribution: [], recommendedLevel: 1, levelPreview: [] }; - } - - // Ensure cache is populated - await ensureGraphCache(websiteCollection); - - // Get cached data - const cache = getGraphCache(websiteCollection); - if (!cache || !cache.isValid) { - return { distribution: [], recommendedLevel: 1, levelPreview: [] }; - } - - const entities = cache.entityMetrics || []; - const relationships = cache.relationships || []; - const communities = cache.communities || []; - - const entitiesWithMetrics = calculateEntityMetrics( - entities, - relationships, - communities, - ); - - // Calculate importance distribution - const importanceScores = entitiesWithMetrics - .map((e) => e.importance || 0) - .sort((a, b) => b - a); - - // Preview node counts at each level - const levelPreviews = IMPORTANCE_LEVELS.map((level) => ({ - level: level.level, - nodeCount: importanceScores.filter( - (score) => score >= level.threshold, - ).length, - coverage: - importanceScores.filter((score) => score >= level.threshold) - .length / importanceScores.length, - })); - - // Recommend level based on graph size - const totalNodes = entities.length; - const recommendedLevel = - totalNodes > 25000 - ? 1 - : totalNodes > 10000 - ? 2 - : totalNodes > 3000 - ? 3 - : 4; - - return { - distribution: calculateDistributionPercentiles(importanceScores), - recommendedLevel, - levelPreview: levelPreviews, - }; - } catch (error) { - console.error("Error getting importance statistics:", error); - return { distribution: [], recommendedLevel: 1, levelPreview: [] }; - } -} - -// Helper functions for hierarchical loading - -function ensureGlobalConnectivity( - importantEntities: any[], - allRelationships: any[], - maxNodes: number, -): any[] { - const components = findConnectedComponents( - importantEntities, - allRelationships, - ); - - // If multiple components, add bridge nodes to connect them - if (components.length > 1) { - const bridgeNodes = findBridgeNodes( - components, - allRelationships, - maxNodes - importantEntities.length, - ); - return [...importantEntities, ...bridgeNodes]; - } - - return importantEntities; -} - -function findConnectedComponents( - entities: any[], - relationships: any[], -): any[][] { - const entityNames = new Set(entities.map((e) => e.name)); - const adjacencyList = new Map(); - - // Build adjacency list - entities.forEach((entity) => adjacencyList.set(entity.name, [])); - relationships.forEach((rel) => { - if (entityNames.has(rel.fromEntity) && entityNames.has(rel.toEntity)) { - adjacencyList.get(rel.fromEntity)?.push(rel.toEntity); - adjacencyList.get(rel.toEntity)?.push(rel.fromEntity); - } - }); - - const visited = new Set(); - const components: any[][] = []; - - entities.forEach((entity) => { - if (!visited.has(entity.name)) { - const component: any[] = []; - const stack = [entity.name]; - - while (stack.length > 0) { - const current = stack.pop()!; - if (visited.has(current)) continue; - - visited.add(current); - const currentEntity = entities.find((e) => e.name === current); - if (currentEntity) component.push(currentEntity); - - const neighbors = adjacencyList.get(current) || []; - neighbors.forEach((neighbor) => { - if (!visited.has(neighbor)) { - stack.push(neighbor); - } - }); - } - - if (component.length > 0) { - components.push(component); - } - } - }); - - return components; -} - -function findBridgeNodes( - components: any[][], - allRelationships: any[], - maxBridgeNodes: number, -): any[] { - // Find nodes that connect different components - const bridgeNodes: any[] = []; - // Note: Bridge detection algorithm can be implemented here in the future - - // For now, return empty array - can be enhanced with actual bridge detection - return bridgeNodes; -} - -function analyzeConnectivity(entities: any[], relationships: any[]): any { - const components = findConnectedComponents(entities, relationships); - return { - componentCount: components.length, - largestComponentSize: Math.max(...components.map((c) => c.length)), - averageComponentSize: - components.reduce((sum, c) => sum + c.length, 0) / - components.length, - }; -} - -function buildImportanceWeightedAdjacency( - entities: any[], - relationships: any[], -): Map> { - const adjacencyMap = new Map< - string, - Array<{ entity: any; importance: number }> - >(); - const entityMap = new Map(); - - entities.forEach((entity) => { - entityMap.set(entity.name.toLowerCase(), entity); - adjacencyMap.set(entity.name.toLowerCase(), []); - }); - - relationships.forEach((rel) => { - const fromKey = rel.fromEntity.toLowerCase(); - const toKey = rel.toEntity.toLowerCase(); - - const fromEntity = entityMap.get(fromKey); - const toEntity = entityMap.get(toKey); - - if (fromEntity && toEntity) { - adjacencyMap.get(fromKey)?.push({ - entity: toEntity, - importance: toEntity.importance || 0, - }); - adjacencyMap.get(toKey)?.push({ - entity: fromEntity, - importance: fromEntity.importance || 0, - }); - } - }); - - return adjacencyMap; -} - -function calculateDistributionPercentiles( - importanceScores: number[], -): number[] { - if (importanceScores.length === 0) return []; - - const percentiles = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99, 1.0]; - return percentiles.map((p) => { - const index = Math.floor(p * (importanceScores.length - 1)); - return importanceScores[index] || 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 3ed96edd29..9fe2ce683a 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/types/knowledgeTypes.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/types/knowledgeTypes.mts @@ -115,14 +115,6 @@ export interface GraphCache { sourceVersion?: number; } -export interface TopicGraphCache { - topics: any[]; - relationships: any[]; - topicMetrics: any[]; - lastUpdated: number; - isValid: boolean; -} - export interface ImportanceLevel { entities: Array<{ id: string; diff --git a/ts/packages/agents/browser/src/agent/manifest.json b/ts/packages/agents/browser/src/agent/manifest.json index 8dfafc7fbf..53118497fb 100644 --- a/ts/packages/agents/browser/src/agent/manifest.json +++ b/ts/packages/agents/browser/src/agent/manifest.json @@ -4,12 +4,6 @@ "description": "Agent that allows you control an existing browser window", "localView": true, "allowDynamicAgents": true, - "indexingServices": { - "website": { - "serviceScript": "./dist/agent/indexing/browserIndexingService.js", - "description": "Enhanced website indexing with knowledge extraction" - } - }, "cachedActivities": { "browsingWebPage": "shared" }, diff --git a/ts/packages/agents/browser/src/agent/search/answerEnhancementAdapter.mts b/ts/packages/agents/browser/src/agent/search/answerEnhancementAdapter.mts deleted file mode 100644 index 4b3cf4e136..0000000000 --- a/ts/packages/agents/browser/src/agent/search/answerEnhancementAdapter.mts +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { Website } from "@typeagent/website-memory"; -import { QueryAnalysis } from "./schema/queryAnalysis.mjs"; -import { AnswerEnhancement } from "./schema/answerEnhancement.mjs"; -import { AnswerGenerator } from "./answerGenerator.mjs"; -import { ContextBuilder } from "./utils/contextBuilder.mjs"; -import registerDebug from "debug"; - -const debug = registerDebug("typeagent:browser:answer-enhancement"); - -/** - * AnswerEnhancementAdapter generates dynamic AI summaries and smart follow-up suggestions - * in a single efficient LLM call, replacing static/templated responses. - */ -export class AnswerEnhancementAdapter { - private answerGenerator: AnswerGenerator; - private contextBuilder: ContextBuilder; - private isInitialized: boolean = false; - - constructor() { - this.answerGenerator = new AnswerGenerator(); - this.contextBuilder = new ContextBuilder(); - } - - /** - * Enhance search results with dynamic summary and smart follow-up suggestions - */ - async enhanceSearchResults( - originalQuery: string, - queryAnalysis: QueryAnalysis | undefined, - searchResults: Website[], - ): Promise { - try { - const startTime = Date.now(); - await this.ensureInitialized(); - - debug( - `Enhancing search results for query: "${originalQuery}" with ${searchResults.length} results`, - ); - - // Skip enhancement if no query analysis or insufficient results - if (!queryAnalysis || searchResults.length === 0) { - debug( - "Skipping enhancement: missing query analysis or no results", - ); - return undefined; - } - - // Build context from search results - const searchContext = this.contextBuilder.buildContext( - originalQuery, - searchResults, - ); - debug( - `Built context with ${searchContext.patterns.dominantDomains.length} domains`, - ); - - // Generate enhanced summary and follow-ups - const enhancement = await this.answerGenerator.generateEnhancement( - originalQuery, - queryAnalysis, - searchContext, - ); - - if (!enhancement) { - debug("Enhancement generation failed, skipping enhancement"); - return undefined; - } - - // Update generation time with actual elapsed time - const actualGenerationTime = Date.now() - startTime; - const finalEnhancement: AnswerEnhancement = { - ...enhancement, - generationTime: actualGenerationTime, - }; - - debug( - `Enhancement complete in ${actualGenerationTime}ms with confidence: ${finalEnhancement.confidence}`, - ); - debug( - `Generated summary with ${finalEnhancement.summary.keyFindings.length} key findings`, - ); - debug( - `Generated ${finalEnhancement.followups.length} follow-up suggestions`, - ); - - return finalEnhancement; - } catch (error) { - debug(`Error enhancing search results: ${error}`); - return undefined; // Let UI fall back to static content - } - } - - private async ensureInitialized(): Promise { - if (this.isInitialized) { - return; - } - - try { - // Generator initializes itself when first used - this.isInitialized = true; - debug("AnswerEnhancementAdapter initialized successfully"); - } catch (error) { - debug(`Failed to initialize AnswerEnhancementAdapter: ${error}`); - throw error; - } - } -} diff --git a/ts/packages/agents/browser/src/agent/search/answerGenerator.mts b/ts/packages/agents/browser/src/agent/search/answerGenerator.mts deleted file mode 100644 index 5ddc333580..0000000000 --- a/ts/packages/agents/browser/src/agent/search/answerGenerator.mts +++ /dev/null @@ -1,178 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { createJsonTranslator, TypeChatJsonTranslator } from "typechat"; -import { createTypeScriptJsonValidator } from "typechat/ts"; -import { openai as ai } from "@typeagent/aiclient"; -import { AnswerEnhancement } from "./schema/answerEnhancement.mjs"; -import { QueryAnalysis } from "./schema/queryAnalysis.mjs"; -import { SearchContext } from "./utils/contextBuilder.mjs"; -import registerDebug from "debug"; -import path from "path"; -import fs from "fs"; -import { getBrowserPackageFilePath } from "../utils/packageFilePath.mjs"; - -const debug = registerDebug("typeagent:browser:answer-generator"); - -function getSchemaFileContents(fileName: string): string { - return fs.readFileSync( - getBrowserPackageFilePath( - path.join("src", "agent", "search", "schema", fileName), - ), - "utf8", - ); -} - -/** - * AnswerGenerator creates both dynamic summary and smart follow-ups - * in a single LLM call for maximum efficiency and consistency - */ -export class AnswerGenerator { - private enhancementTranslator: TypeChatJsonTranslator | null = - null; - private isInitialized: boolean = false; - private schemaText: string; - - constructor() { - this.schemaText = getSchemaFileContents("answerEnhancement.mts"); - } - - /** - * Generate complete answer enhancement (summary + followups) in a single LLM call - */ - async generateEnhancement( - query: string, - queryAnalysis: QueryAnalysis, - searchContext: SearchContext, - ): Promise { - try { - await this.ensureInitialized(); - - if (!this.enhancementTranslator) { - debug( - "Enhancement translator not available, skipping generation", - ); - return undefined; - } - - debug(`Generating unified enhancement for query: "${query}"`); - - const prompt = this.buildEnhancementPrompt( - query, - queryAnalysis, - searchContext, - ); - const response = await this.enhancementTranslator.translate(prompt); - - if (!response.success) { - debug(`Enhancement generation failed: ${response.message}`); - return undefined; - } - - const enhancement = response.data; - debug( - `Generated enhancement with ${enhancement.followups.length} followups and confidence: ${enhancement.confidence}`, - ); - - return enhancement; - } catch (error) { - debug(`Error generating enhancement: ${error}`); - return undefined; - } - } - - private async ensureInitialized(): Promise { - if (this.isInitialized) { - return; - } - - try { - const model = ai.createJsonChatModel( - ai.apiSettingsFromEnv(ai.ModelType.Chat), - ["unifiedEnhancementGeneration"], - ); - - const validator = createTypeScriptJsonValidator( - this.schemaText, - "AnswerEnhancement", - ); - - this.enhancementTranslator = createJsonTranslator(model, validator); - this.isInitialized = true; - - debug("AnswerGenerator initialized successfully"); - } catch (error) { - debug(`Failed to initialize AnswerGenerator: ${error}`); - throw error; - } - } - - private buildEnhancementPrompt( - query: string, - queryAnalysis: QueryAnalysis, - searchContext: SearchContext, - ): string { - const basePrompt = `Generate a comprehensive answer enhancement for this user's search, including both a dynamic summary and smart follow-up suggestions. - -Original Query: "${query}" -Query Intent: ${queryAnalysis.intent.type} - ${queryAnalysis.intent.description} - -Search Context: -- Total Results: ${searchContext.totalResults} -- Dominant Domains: ${searchContext.patterns.dominantDomains.map((d) => d.domain).join(", ")} -- Time Range: ${searchContext.patterns.timeRange?.earliest} to ${searchContext.patterns.timeRange?.latest || "present"} - -Available Content: -${searchContext.results.map((result, i) => `${i + 1}. ${result.title} (${result.domain}): ${result.snippet}`).join("\n")} - -Generate a SINGLE complete "${this.enhancementTranslator?.validator.getTypeName()}" response using the typescript schema below. - -''' -${this.enhancementTranslator?.validator.getSchemaText()} -''' - -## INTENT-SPECIFIC GUIDANCE: -${this.getIntentSpecificGuidance(queryAnalysis.intent.type)} - -Provide a complete AnswerEnhancement response that helps the user understand and explore their search results more effectively.`; - - return basePrompt; - } - - private getIntentSpecificGuidance(intentType: string): string { - const guidanceMap: Record = { - find_latest: ` -- **Summary Focus**: Emphasize recency, trends, and what's newest -- **Key Findings**: Highlight temporal patterns and recent developments -- **Follow-ups**: Suggest broader timeframes, trend tracking, comparisons with older content`, - - find_earliest: ` -- **Summary Focus**: Provide historical context and evolution over time -- **Key Findings**: Identify patterns in early adoption, foundational content -- **Follow-ups**: Suggest progression tracking, modern comparisons, related historical content`, - - find_most_frequent: ` -- **Summary Focus**: Analyze usage patterns, popularity, and user behavior -- **Key Findings**: Explain why certain content is frequently accessed -- **Follow-ups**: Suggest related popular content, alternatives, deeper exploration of top items`, - - summarize: ` -- **Summary Focus**: Comprehensive content synthesis, main themes, key insights -- **Key Findings**: Important information, common threads, notable patterns -- **Follow-ups**: Suggest deeper dives, related topics, different perspectives`, - - find_specific: ` -- **Summary Focus**: Precision and relevance to the specific request -- **Key Findings**: How results match the specific criteria, quality of matches -- **Follow-ups**: Suggest refinements, related searches, broader context`, - }; - - return ( - guidanceMap[intentType] || - ` -- **Summary Focus**: Analyze results contextually based on user's apparent information need -- **Key Findings**: Identify the most relevant patterns and insights from the search results -- **Follow-ups**: Suggest logical next steps based on the content and user intent` - ); - } -} diff --git a/ts/packages/agents/browser/src/agent/search/queryAnalyzer.mts b/ts/packages/agents/browser/src/agent/search/queryAnalyzer.mts deleted file mode 100644 index 3294857563..0000000000 --- a/ts/packages/agents/browser/src/agent/search/queryAnalyzer.mts +++ /dev/null @@ -1,183 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { createJsonTranslator, TypeChatJsonTranslator } from "typechat"; -import { createTypeScriptJsonValidator } from "typechat/ts"; -import { openai as ai } from "@typeagent/aiclient"; -import { QueryAnalysis, TemporalExpression } from "./schema/queryAnalysis.mjs"; -import registerDebug from "debug"; -import path from "path"; -import fs from "fs"; -import { getBrowserPackageFilePath } from "../utils/packageFilePath.mjs"; - -const debug = registerDebug("typeagent:browser:query-analyzer"); - -function getSchemaFileContents(fileName: string): string { - return fs.readFileSync( - getBrowserPackageFilePath( - path.join("src", "agent", "search", "schema", fileName), - ), - "utf8", - ); -} - -/** - * QueryAnalyzer uses LLM-based analysis for robust query understanding. - * Always analyzes queries for maximum accuracy - no optimizations. - */ -export class QueryAnalyzer { - private queryTranslator: TypeChatJsonTranslator | null = - null; - private isInitialized: boolean = false; - private schemaText: string; - - constructor() { - this.schemaText = getSchemaFileContents("queryAnalysis.mts"); - } - - /** - * Analyze search query for intent, temporal expressions, and content classification - * Always performs full analysis for maximum accuracy - */ - async analyzeQuery(query: string): Promise { - try { - await this.ensureInitialized(); - - if (!this.queryTranslator) { - debug("Query translator not available, skipping analysis"); - return null; - } - - debug(`Analyzing query: "${query}"`); - - const prompt = this.buildAnalysisPrompt(query); - const response = await this.queryTranslator.translate(prompt); - - if (!response.success) { - debug(`Query analysis failed: ${response.message}`); - return null; - } - - const analysis = response.data; - debug(`Analysis result: ${JSON.stringify(analysis)}`); - - // Post-process temporal dates if provided as strings - this.processTemporalDates(analysis); - - return analysis; - } catch (error) { - debug(`Error analyzing query: ${error}`); - return null; - } - } - - private async ensureInitialized(): Promise { - if (this.isInitialized) { - return; - } - - try { - // Use the same model configuration as other adapters - const model = ai.createJsonChatModel( - ai.apiSettingsFromEnv(ai.ModelType.Chat), - ["queryAnalysis"], - ); - - const validator = createTypeScriptJsonValidator( - this.schemaText, - "QueryAnalysis", - ); - - this.queryTranslator = createJsonTranslator(model, validator); - this.isInitialized = true; - - debug("QueryAnalyzer initialized successfully"); - } catch (error) { - debug(`Failed to initialize QueryAnalyzer: ${error}`); - throw error; - } - } - - private buildAnalysisPrompt(query: string): string { - return `Analyze this search query to understand user intent, temporal requirements, content preferences, and ranking needs. - -Query: "${query}" - -Determine: -1. What type of search intent this represents -2. Any temporal expressions (time periods, recency preferences) -3. Content type being sought (repositories, news, reviews, etc.) -4. How results should be ranked (by date, frequency, relevance) -5. Your confidence in this analysis - -For temporal expressions that need specific date ranges (like "last week", "last month"), include startDate and endDate as ISO date strings (YYYY-MM-DDTHH:mm:ss.sssZ format). - -Focus on practical search needs - what would help find the most relevant results for this query.`; - } - - private processTemporalDates(analysis: QueryAnalysis): void { - // If LLM provided date strings, they're already in the correct format - // No additional processing needed since we'll parse them when needed - } - - /** - * Utility method to get Date objects from temporal expression - */ - getTemporalDates(temporal: TemporalExpression | null): { - startDate?: Date; - endDate?: Date; - } { - if (!temporal || temporal.period === "none") { - return {}; - } - - const result: { startDate?: Date; endDate?: Date } = {}; - - // If LLM provided date strings, parse them - if (temporal.startDate) { - result.startDate = new Date(temporal.startDate); - } - if (temporal.endDate) { - result.endDate = new Date(temporal.endDate); - } - - // If no date strings provided, compute them based on period - if (!result.startDate && !result.endDate) { - const now = new Date(); - - switch (temporal.period) { - case "last_week": - result.startDate = new Date( - now.getTime() - 7 * 24 * 60 * 60 * 1000, - ); - result.endDate = now; - break; - - case "last_month": - result.startDate = new Date( - now.getFullYear(), - now.getMonth() - 1, - now.getDate(), - ); - result.endDate = now; - break; - - case "last_year": - result.startDate = new Date( - now.getFullYear() - 1, - now.getMonth(), - now.getDate(), - ); - result.endDate = now; - break; - - case "latest": - case "earliest": - // These don't need specific date ranges - break; - } - } - - return result; - } -} diff --git a/ts/packages/agents/browser/src/agent/search/queryEnhancementAdapter.mts b/ts/packages/agents/browser/src/agent/search/queryEnhancementAdapter.mts deleted file mode 100644 index f1834ecd0e..0000000000 --- a/ts/packages/agents/browser/src/agent/search/queryEnhancementAdapter.mts +++ /dev/null @@ -1,313 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { SearchWebMemoriesRequest } from "../searchWebMemories.mjs"; -import { Website } from "@typeagent/website-memory"; -import { QueryAnalysis } from "./schema/queryAnalysis.mjs"; -import { QueryAnalyzer } from "./queryAnalyzer.mjs"; -import { MetadataRanker } from "./utils/metadataRanker.mjs"; -import registerDebug from "debug"; - -const debug = registerDebug("typeagent:browser:search-enhancement"); - -export interface EnhancedSearchContext { - websiteCollection?: any; - userContext?: any; - searchHistory?: string[]; -} - -/** - * QueryEnhancementAdapter enhances semantic search with comprehensive LLM-based query understanding. - * Always performs full analysis for maximum accuracy. - */ -export class QueryEnhancementAdapter { - private queryAnalyzer: QueryAnalyzer; - private metadataRanker: MetadataRanker; - private isInitialized: boolean = false; - - constructor() { - this.queryAnalyzer = new QueryAnalyzer(); - this.metadataRanker = new MetadataRanker(); - } - - /** - * Enhance search request with comprehensive LLM-based query understanding - * Always analyzes every query for maximum accuracy - */ - async enhanceSearchRequest( - request: SearchWebMemoriesRequest, - context: EnhancedSearchContext, - ): Promise { - try { - await this.ensureInitialized(); - - debug(`Enhancing search request for query: "${request.query}"`); - - // Always analyze query with LLM for comprehensive understanding - const analysis = await this.queryAnalyzer.analyzeQuery( - request.query, - ); - - if (!analysis) { - debug("No analysis available, returning original request"); - return request; - } - - debug(`Query analysis: ${JSON.stringify(analysis)}`); - - // Apply analysis to enhance request - const enhancedRequest = this.applyAnalysisToRequest( - request, - analysis, - ); - - // Store analysis for post-processing - enhancedRequest.metadata = { - ...enhancedRequest.metadata, - analysis, - }; - - debug(`Enhanced request: filters applied based on analysis`); - return enhancedRequest; - } catch (error) { - debug(`Error enhancing search request: ${error}`); - // Graceful degradation: return original request - return request; - } - } - - /** - * Post-search processing: Apply comprehensive LLM-informed ranking - */ - async enhanceSearchResults( - results: Website[], - originalRequest: SearchWebMemoriesRequest, - analysis?: QueryAnalysis, - ): Promise { - try { - if (!analysis) { - // Try to extract analysis from request metadata - analysis = (originalRequest as any).metadata?.analysis; - } - - if (!analysis) { - debug("No analysis available for result enhancement"); - return results; - } - - debug( - `Enhancing ${results.length} results with LLM-informed ranking`, - ); - debug( - `Analysis: intent=${analysis.intent.type}, ranking=${analysis.ranking?.primaryFactor}`, - ); - - // Always apply LLM-informed ranking when analysis is available - const rankedResults = await this.metadataRanker.rankByAnalysis( - results, - analysis, - ); - - debug( - `Ranking complete, returning ${rankedResults.length} results`, - ); - return rankedResults; - } catch (error) { - debug(`Error enhancing search results: ${error}`); - return results; - } - } - - private async ensureInitialized(): Promise { - if (this.isInitialized) { - return; - } - - try { - // QueryAnalyzer initializes itself when first used - this.isInitialized = true; - debug("QueryEnhancementAdapter initialized successfully"); - } catch (error) { - debug(`Failed to initialize QueryEnhancementAdapter: ${error}`); - throw error; - } - } - - private optimizeQueryForSemanticSearch( - query: string, - analysis: QueryAnalysis, - ): string { - let optimizedQuery = query; - - debug(`Optimizing query for semantic search: "${query}"`); - debug( - `Analysis: intent=${analysis.intent.type}, content=${JSON.stringify(analysis.content)}`, - ); - - // Remove temporal ranking terms that hurt semantic search (now handled by date filters) - if (analysis.intent.type === "find_latest") { - optimizedQuery = optimizedQuery.replace( - /\b(most recently|latest|most recent)\b/gi, - "", - ); - } - - if (analysis.intent.type === "find_earliest") { - optimizedQuery = optimizedQuery.replace( - /\b(earliest|first)\b/gi, - "", - ); - } - - if (analysis.intent.type === "find_most_frequent") { - optimizedQuery = optimizedQuery.replace( - /\b(most often|most visited|most frequently|frequently)\b/gi, - "", - ); - } - - // Remove temporal terms that are now handled by date filters - if (analysis.temporal) { - optimizedQuery = optimizedQuery.replace( - /\b(last week|last month|last year|this year|recently|in \d{4}|since \d{4}|before \d{4})\b/gi, - "", - ); - } - - // Remove source-specific terms (handled by filters) - optimizedQuery = optimizedQuery.replace( - /\b(bookmarked|visited)\b/gi, - "", - ); - - // Remove summarization requests - if (analysis.intent.type === "summarize") { - optimizedQuery = optimizedQuery.replace( - /\b(summarize|summary of)\b/gi, - "", - ); - } - - // Enhance content type terms for better semantic matching - if (analysis.content?.contentType) { - switch (analysis.content.contentType) { - case "repository": - optimizedQuery = optimizedQuery.replace( - /\brepo\b/gi, - "repository", - ); - break; - case "news": - optimizedQuery = optimizedQuery.replace( - /\b(news site|news)\b/gi, - "news article", - ); - break; - case "review": - // Keep "review" as is - good semantic term - break; - case "article": - // Keep "article" as is - good semantic term - break; - case "documentation": - optimizedQuery = optimizedQuery.replace( - /\bdocs?\b/gi, - "documentation", - ); - break; - } - } - - // Clean up extra whitespace - optimizedQuery = optimizedQuery.replace(/\s+/g, " ").trim(); - - // Fallback to original if optimization resulted in empty or very short query - if (optimizedQuery.length < 3) { - debug(`Optimization resulted in too short query, using original`); - return query; - } - - debug(`Query optimization: "${query}" -> "${optimizedQuery}"`); - return optimizedQuery; - } - - private applyAnalysisToRequest( - request: SearchWebMemoriesRequest, - analysis: QueryAnalysis, - ): SearchWebMemoriesRequest { - const enhanced = { ...request }; - - debug(`Applying analysis to request: ${JSON.stringify(analysis)}`); - - // NEW: Optimize query for better semantic search - const optimizedQuery = this.optimizeQueryForSemanticSearch( - request.query, - analysis, - ); - if (optimizedQuery !== request.query) { - enhanced.query = optimizedQuery; - debug( - `Applied query optimization: "${request.query}" -> "${optimizedQuery}"`, - ); - } - - // Apply temporal filters - if (analysis.temporal) { - const { startDate, endDate } = this.queryAnalyzer.getTemporalDates( - analysis.temporal, - ); - if (startDate) { - enhanced.dateFrom = startDate.toISOString(); - if (endDate) { - enhanced.dateTo = endDate.toISOString(); - } - debug( - `Applied temporal filter: ${enhanced.dateFrom}${endDate ? ` to ${enhanced.dateTo}` : ""}`, - ); - } - } - - // Add domain filtering directly to metadata - if (analysis.content?.domain) { - enhanced.metadata = { - ...enhanced.metadata, - domainFilter: analysis.content.domain, - }; - } - - // Store original query and analysis for debugging/logging - enhanced.metadata = { - ...enhanced.metadata, - analysis, - originalQuery: request.query, - }; - - // Adjust search parameters based on intent for comprehensive results - switch (analysis.intent.type) { - case "find_latest": - case "find_earliest": - enhanced.limit = Math.max(enhanced.limit || 20, 50); - debug( - `Increased limit to ${enhanced.limit} for temporal query`, - ); - break; - - case "find_most_frequent": - enhanced.limit = Math.max(enhanced.limit || 20, 100); - debug( - `Increased limit to ${enhanced.limit} for frequency query`, - ); - break; - - case "summarize": - enhanced.limit = Math.max(enhanced.limit || 20, 30); - enhanced.generateAnswer = true; - debug( - `Configured for summarization: limit=${enhanced.limit}, generateAnswer=true`, - ); - break; - } - - return enhanced; - } -} diff --git a/ts/packages/agents/browser/src/agent/search/schema/answerEnhancement.mts b/ts/packages/agents/browser/src/agent/search/schema/answerEnhancement.mts deleted file mode 100644 index cd73d07ab6..0000000000 --- a/ts/packages/agents/browser/src/agent/search/schema/answerEnhancement.mts +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Answer enhancement schema for LLM-based dynamic summaries and follow-ups. - * The LLM should generate comprehensive answer enhancements that help users - * understand and explore their search results more effectively. - * - * NOTE: This file is read verbatim as LLM prompt text (see AnswerGenerator). - * The shared TypeScript contract for these types is duplicated at - * @typeagent/browser-control-rpc/answerEnhancement and MUST be kept - * structurally in sync with the interfaces below. - */ - -/** - * Complete answer enhancement containing both summary and follow-up suggestions. - */ -export interface AnswerEnhancement { - summary: DynamicSummary; - - /** 3-4 smart follow-up suggestions for further exploration */ - followups: SmartFollowup[]; - confidence: number; - generationTime: number; -} - -/** - * Dynamic summary that contextualizes search results with insights and patterns. - * - * GENERATION GUIDELINES: - * - Write conversationally, not like a search report - * - Highlight what makes these results notable or useful - * - Identify trends, dominant sources, timeframes - * - Provide specific insights beyond just "found X results" - * - */ -export interface DynamicSummary { - /** - * Main summary text explaining what was found and why it's relevant. - * Should be conversational and insightful, not just "Found X results". - */ - text: string; - - /** - * 2-4 key insights or patterns discovered in the results. - * Focus on what makes these results interesting or notable. - */ - keyFindings: string[]; - - statistics: { - totalResults: number; - - /** - * Time span covered by results (optional). - * EXAMPLES: "last 2 weeks", "past month", "from 2020-2023" - */ - timeSpan?: string; - - /** - * Top domains/sources in the results. - * EXAMPLES: ["github.com"], ["react.dev", "medium.com"] - */ - dominantDomains: string[]; - }; - - confidence: number; -} - -/** - * Smart follow-up suggestion that builds naturally from the search results. - * - * GENERATION GUIDELINES: - * - Use natural language the user would actually type - * - Build logically from current results (temporal, domain, content, comparative) - * - Address logical next steps or refinements - * - Provide clear reasoning for why this follow-up would be helpful - * - */ -export interface SmartFollowup { - /** - * Natural language query the user would actually type. - */ - query: string; - - /** - * Clear explanation of why this follow-up would be helpful. - * Should explain the logical connection to current results. - */ - reasoning: string; - - /** Type of exploration this follow-up represents */ - type: "temporal" | "domain" | "content" | "comparative"; - - /** Confidence in this follow-up's usefulness (0.0 to 1.0) */ - confidence: number; -} diff --git a/ts/packages/agents/browser/src/agent/search/schema/queryAnalysis.mts b/ts/packages/agents/browser/src/agent/search/schema/queryAnalysis.mts deleted file mode 100644 index 2b1d5285c4..0000000000 --- a/ts/packages/agents/browser/src/agent/search/schema/queryAnalysis.mts +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Query analysis schema for LLM-based intent detection - * Used with TypeChat for structured query understanding - */ - -export interface QueryAnalysis { - intent: QueryIntent; - temporal: TemporalExpression | null; - content: ContentClassification | null; - ranking: RankingRequirement | null; - confidence: number; // 0.0 to 1.0 -} - -export interface QueryIntent { - type: - | "find_latest" - | "find_earliest" - | "find_most_frequent" - | "summarize" - | "find_specific"; - description: string; // Brief explanation of detected intent -} - -export interface TemporalExpression { - period: - | "last_week" - | "last_month" - | "last_year" - | "earliest" - | "latest" - | "specific_date" - | "none"; - direction: "recent" | "historical" | "any"; - // Date strings in ISO format (YYYY-MM-DDTHH:mm:ss.sssZ) - will be parsed to Date objects - startDate?: string; - endDate?: string; -} - -export interface ContentClassification { - contentType: - | "repository" - | "news" - | "review" - | "article" - | "documentation" - | "tutorial" - | "forum" - | "blog" - | "reference" - | "other"; - domain?: string; // the target domain e.g. "github.com". Only provide it if we have high confidence in the value. - subject?: string; // e.g., "machine learning", "car reviews", "transformers" -} - -export interface RankingRequirement { - primaryFactor: "date" | "frequency" | "relevance" | "composite"; - direction: "ascending" | "descending"; - sourcePreference?: "bookmark" | "history" | "any"; -} - -/** - * Example valid QueryAnalysis objects: - * - * For "most recently bookmarked github repo": - * { - * intent: { type: "find_latest", description: "Find the most recent item" }, - * temporal: { period: "latest", direction: "recent" }, - * content: { contentType: "repository", domain: "github.com" }, - * ranking: { primaryFactor: "date", direction: "descending", sourcePreference: "bookmark" }, - * confidence: 0.95 - * } - * - * For "summarize car reviews last week": - * { - * intent: { type: "summarize", description: "Provide a summary of multiple items" }, - * temporal: { - * period: "last_week", - * direction: "recent", - * startDate: "2025-07-12T00:00:00.000Z", - * endDate: "2025-07-19T23:59:59.999Z" - * }, - * content: { contentType: "review", subject: "car reviews" }, - * ranking: { primaryFactor: "date", direction: "descending" }, - * confidence: 0.90 - * } - * - * For "most often visited news site": - * { - * intent: { type: "find_most_frequent", description: "Find the most frequently accessed item" }, - * temporal: { period: "none", direction: "any" }, - * content: { contentType: "news" }, - * ranking: { primaryFactor: "frequency", direction: "descending" }, - * confidence: 0.92 - * } - */ diff --git a/ts/packages/agents/browser/src/agent/search/utils/contextBuilder.mts b/ts/packages/agents/browser/src/agent/search/utils/contextBuilder.mts deleted file mode 100644 index 27cad09f32..0000000000 --- a/ts/packages/agents/browser/src/agent/search/utils/contextBuilder.mts +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { Website } from "@typeagent/website-memory"; -import registerDebug from "debug"; - -const debug = registerDebug("typeagent:browser:context-builder"); - -export interface SearchContext { - query: string; - totalResults: number; - results: ResultContext[]; - patterns: { - dominantDomains: Array<{ domain: string; count: number }>; - timeRange?: { earliest?: string; latest?: string }; - hasKnowledge: boolean; - }; -} - -export interface ResultContext { - title: string; - domain: string; - snippet: string; - visitCount?: number; - lastVisited?: string; - source: "bookmarks" | "history"; - hasKnowledge: boolean; -} - -/** - * ContextBuilder extracts meaningful context from search results for LLM processing - */ -export class ContextBuilder { - /** - * Build simplified context from search query and results - */ - buildContext(query: string, results: Website[]): SearchContext { - debug( - `Building context for query: "${query}" with ${results.length} results`, - ); - - // Extract basic result information - const resultContexts = results - .slice(0, 10) - .map((result) => this.extractResultContext(result)); - - // Analyze patterns in the results - const patterns = this.analyzePatterns(results); - - const context: SearchContext = { - query, - totalResults: results.length, - results: resultContexts, - patterns, - }; - - debug( - `Context built: ${patterns.dominantDomains.length} domains, hasKnowledge: ${patterns.hasKnowledge}`, - ); - - return context; - } - - /** - * Convert context to JSON string for LLM consumption - */ - contextToString(context: SearchContext): string { - return JSON.stringify(context, null, 2); - } - - private extractResultContext(website: Website): ResultContext { - const metadata = website.metadata as any; - const knowledge = website.getKnowledge?.(); - - return { - title: this.truncateText(metadata.title || "", 100), - domain: metadata.domain || "", - snippet: this.truncateText(metadata.snippet || "", 200), - visitCount: metadata.visitCount, - lastVisited: - metadata.lastVisited || - metadata.visitDate || - metadata.bookmarkDate, - source: - metadata.source || - (metadata.bookmarkDate ? "bookmarks" : "history"), - hasKnowledge: !!( - knowledge && - (knowledge.entities?.length > 0 || knowledge.topics?.length > 0) - ), - }; - } - - private analyzePatterns(results: Website[]): SearchContext["patterns"] { - // Count domains - const domainCounts = new Map(); - let hasKnowledge = false; - let earliestDate: string | undefined; - let latestDate: string | undefined; - - for (const result of results) { - const metadata = result.metadata as any; - - // Domain analysis - if (metadata.domain) { - const count = domainCounts.get(metadata.domain) || 0; - domainCounts.set(metadata.domain, count + 1); - } - - // Knowledge analysis - const knowledge = result.getKnowledge?.(); - if ( - knowledge && - (knowledge.entities?.length > 0 || knowledge.topics?.length > 0) - ) { - hasKnowledge = true; - } - - // Time analysis - check multiple possible date fields - const dateFields = [ - metadata.lastVisited, - metadata.visitDate, - metadata.bookmarkDate, - ]; - for (const dateField of dateFields) { - if (dateField) { - if (!earliestDate || dateField < earliestDate) { - earliestDate = dateField; - } - if (!latestDate || dateField > latestDate) { - latestDate = dateField; - } - break; // Use the first available date field - } - } - } - - // Convert domain counts to sorted array - const dominantDomains = Array.from(domainCounts.entries()) - .map(([domain, count]) => ({ domain, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 5); // Top 5 domains - - const result: SearchContext["patterns"] = { - dominantDomains, - hasKnowledge, - }; - - if (earliestDate && latestDate) { - result.timeRange = { earliest: earliestDate, latest: latestDate }; - } - - return result; - } - - private truncateText(text: string, maxLength: number): string { - if (text.length <= maxLength) { - return text; - } - return text.substring(0, maxLength - 3) + "..."; - } -} diff --git a/ts/packages/agents/browser/src/agent/search/utils/metadataRanker.mts b/ts/packages/agents/browser/src/agent/search/utils/metadataRanker.mts deleted file mode 100644 index 87b60f238f..0000000000 --- a/ts/packages/agents/browser/src/agent/search/utils/metadataRanker.mts +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { Website } from "@typeagent/website-memory"; -import { QueryAnalysis } from "../schema/queryAnalysis.mjs"; -import registerDebug from "debug"; - -const debug = registerDebug("typeagent:browser:metadata-ranking"); - -export class MetadataRanker { - /** - * Rank results based on comprehensive LLM analysis - * Always applies full ranking logic for maximum accuracy - */ - async rankByAnalysis( - results: Website[], - analysis: QueryAnalysis, - ): Promise { - if (!analysis.ranking) { - debug("No ranking requirements in analysis, using semantic order"); - return results; - } - - debug( - `Ranking ${results.length} results by ${analysis.ranking.primaryFactor} (${analysis.ranking.direction})`, - ); - - const rankedResults = [...results]; - - switch (analysis.ranking.primaryFactor) { - case "date": - return this.rankByDate(rankedResults, analysis); - case "frequency": - return this.rankByFrequency(rankedResults, analysis); - case "composite": - return this.rankByComposite(rankedResults, analysis); - case "relevance": - default: - debug("Using semantic relevance ranking"); - return rankedResults; // Keep original semantic ranking - } - } - - private rankByDate(results: Website[], analysis: QueryAnalysis): Website[] { - debug("Applying date-based ranking"); - - return results.sort((a, b) => { - const aDate = this.getRelevantDate(a, analysis); - const bDate = this.getRelevantDate(b, analysis); - - const comparison = - analysis.ranking?.direction === "ascending" - ? aDate.getTime() - bDate.getTime() - : bDate.getTime() - aDate.getTime(); - - return comparison; - }); - } - - private rankByFrequency( - results: Website[], - analysis: QueryAnalysis, - ): Website[] { - debug("Applying frequency-based ranking"); - - return results.sort((a, b) => { - const aMetadata = a.metadata as any; - const bMetadata = b.metadata as any; - - const aCount = aMetadata.visitCount || 0; - const bCount = bMetadata.visitCount || 0; - - const comparison = - analysis.ranking?.direction === "ascending" - ? aCount - bCount - : bCount - aCount; - - return comparison; - }); - } - - private rankByComposite( - results: Website[], - analysis: QueryAnalysis, - ): Website[] { - debug("Applying composite ranking based on LLM analysis"); - - return results.sort((a, b) => { - let scoreA = 0; - let scoreB = 0; - - // Get weights based on LLM-determined intent - const weights = this.getCompositeWeights(analysis); - debug(`Using composite weights: ${JSON.stringify(weights)}`); - - // Date factor - if (weights.date > 0) { - const aDate = this.getRelevantDate(a, analysis).getTime(); - const bDate = this.getRelevantDate(b, analysis).getTime(); - const maxTime = Math.max(aDate, bDate); - const minTime = Math.min(aDate, bDate); - const range = maxTime - minTime || 1; - - scoreA += ((aDate - minTime) / range) * weights.date; - scoreB += ((bDate - minTime) / range) * weights.date; - } - - // Frequency factor - if (weights.frequency > 0) { - const aCount = (a.metadata as any).visitCount || 0; - const bCount = (b.metadata as any).visitCount || 0; - const maxCount = Math.max(aCount, bCount); - const range = maxCount || 1; - - scoreA += (aCount / range) * weights.frequency; - scoreB += (bCount / range) * weights.frequency; - } - - // Knowledge richness factor - if (weights.knowledge > 0) { - const aKnowledge = this.calculateKnowledgeRichness(a); - const bKnowledge = this.calculateKnowledgeRichness(b); - scoreA += aKnowledge * weights.knowledge; - scoreB += bKnowledge * weights.knowledge; - } - - return scoreB - scoreA; // Higher score wins - }); - } - - private getRelevantDate(website: Website, analysis: QueryAnalysis): Date { - const metadata = website.metadata as any; - - // Choose date field based on source preference from LLM analysis - if (analysis.ranking?.sourcePreference === "bookmark") { - return new Date(metadata.bookmarkDate || metadata.visitDate || 0); - } - - return new Date(metadata.visitDate || metadata.bookmarkDate || 0); - } - - private getCompositeWeights(analysis: QueryAnalysis): { - date: number; - frequency: number; - knowledge: number; - } { - // Determine weights based on query intent from LLM analysis - switch (analysis.intent.type) { - case "find_latest": - case "find_earliest": - return { date: 0.8, frequency: 0.1, knowledge: 0.1 }; - - case "find_most_frequent": - return { date: 0.1, frequency: 0.8, knowledge: 0.1 }; - - case "summarize": - return { date: 0.3, frequency: 0.2, knowledge: 0.5 }; - - default: - return { date: 0.4, frequency: 0.3, knowledge: 0.3 }; - } - } - - private calculateKnowledgeRichness(website: Website): number { - const knowledge = website.getKnowledge?.(); - if (!knowledge) return 0; - - let richness = 0; - - if (knowledge.entities) { - richness += knowledge.entities.length * 0.3; - } - - if (knowledge.topics) { - richness += knowledge.topics.length * 0.2; - } - - if (knowledge.actions) { - richness += knowledge.actions.length * 0.1; - } - - const textChunks = website.textChunks || []; - const totalLength = textChunks.join(" ").length; - richness += Math.min(totalLength / 1000, 2.0) * 0.4; - - return Math.min(richness, 10.0) / 10.0; // Normalize to 0-1 - } -} diff --git a/ts/packages/agents/browser/src/agent/search/websiteSearchPrompts.mts b/ts/packages/agents/browser/src/agent/search/websiteSearchPrompts.mts deleted file mode 100644 index 2b8b25f86d..0000000000 --- a/ts/packages/agents/browser/src/agent/search/websiteSearchPrompts.mts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import type { PromptSection } from "typechat"; -import type { WebsiteCollection } from "@typeagent/website-memory"; - -export function getWebsiteSearchPromptPreamble( - websiteCollection: WebsiteCollection, -): PromptSection[] { - return [ - { - role: "system", - content: `Searching WEB KNOWLEDGE BASE (bookmarked pages, browsing history). - -Schema interpretations for web: -- EntityTerm.type: repository, article, documentation, tutorial, blog -- EntityTerm.facets: domain(github.com), pageType(doc), source(bookmark) -- ActionTerm verbs: bookmarked, visited, read, saved -- actorEntities: "*" means user`, - }, - ]; -} diff --git a/ts/packages/agents/browser/src/agent/searchWebMemories.mts b/ts/packages/agents/browser/src/agent/searchWebMemories.mts deleted file mode 100644 index a3ba12cbcd..0000000000 --- a/ts/packages/agents/browser/src/agent/searchWebMemories.mts +++ /dev/null @@ -1,2581 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { SessionContext } from "@typeagent/agent-sdk"; -import { BrowserActionContext } from "./browserActions.mjs"; -import * as website from "@typeagent/website-memory"; -import * as kp from "@typeagent/knowpro"; -import registerDebug from "debug"; -import { - Entity, - WebPageReference, -} from "./knowledge/schema/knowledgeExtraction.mjs"; -import { getWebsiteSearchPromptPreamble } from "./search/websiteSearchPrompts.mjs"; -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"); - -// Core interfaces for unified search -export interface SearchWebMemoriesRequest { - originalUserRequest?: string | undefined; - query: string; - searchScope?: "current_page" | "all_indexed" | undefined; - url?: string | undefined; // Current page URL for scope filtering - - // Temporal filters - dateFrom?: string | undefined; - dateTo?: string | undefined; - domain?: string | undefined; - pageType?: string | undefined; - source?: string | undefined; - - // Search configuration - limit?: number | undefined; - minScore?: number | undefined; - exactMatch?: boolean | undefined; - - // Processing options (consumer controls cost) - generateAnswer?: boolean | undefined; // Default: true - includeRelatedEntities?: boolean | undefined; // Default: true - enableAdvancedSearch?: boolean | undefined; // Use advanced patterns - - // Advanced options - knowledgeTopK?: number | undefined; - chunking?: boolean | undefined; - fastStop?: boolean | undefined; - combineAnswers?: boolean | undefined; - choices?: string | undefined; // Multiple choice (semicolon separated) - maxCharsInBudget?: number | undefined; // Character budget for context windows - debug?: boolean | undefined; - - // Internal metadata for query enhancement - metadata?: any; -} - -export interface SearchSummary { - totalFound: number; - searchTime: number; - strategies: string[]; - confidence: number; -} - -export interface SearchDebugContext { - searchTerms: string[]; - searchStrategies: string[]; - knowledgeMatchCount: number; - timing: { - parsing: number; - search: number; - processing: number; - total: number; - }; - intermediateFallbacks: string[]; -} - -export interface WebsiteResult { - url: string; - title: string; - domain: string; - pageType: string; - source: string; - relevanceScore: number; - lastVisited?: string | undefined; - snippet?: string; - insights?: { - topics: Array<{ - name: string; - relevance: number; - occurrences: number; - type: "primary" | "secondary" | "related"; - }>; - entities: Array<{ - name: string; - type: string; - confidence: number; - mentions: number; - }>; - relevanceScore: number; - }; -} - -export interface SearchWebMemoriesResponse { - // Core results - always provided - websites: WebsiteResult[]; - summary: SearchSummary; - - // Q&A results - when generateAnswer=true - answer?: string | undefined; - answerType?: "direct" | "synthesized" | "noAnswer" | undefined; - answerSources?: WebPageReference[] | undefined; - confidence?: number | undefined; - - // Knowledge results - when includeRelatedEntities=true - relatedEntities?: Entity[] | undefined; - topTopics?: string[] | undefined; - - // Query understanding - queryIntent?: "question" | "discovery" | "mixed" | undefined; - searchTerms?: string[] | undefined; - suggestedFollowups?: string[] | undefined; - - // Debug info - when debug=true - debugContext?: SearchDebugContext | undefined; -} - -interface PropertyFilter { - domain?: string; - pageType?: string; - source?: string; -} - -interface ParsedQuery { - searchText: string; - propertyFilters: PropertyFilter; -} - -function parsePropertySearch(query: string): ParsedQuery { - const propertyFilters: PropertyFilter = {}; - let searchText = query; - - const domainMatch = query.match(/\bdomain:(\S+)/); - if (domainMatch) { - propertyFilters.domain = domainMatch[1]; - searchText = searchText.replace(domainMatch[0], "").trim(); - } - - const pageTypeMatch = query.match(/\bpageType:(\S+)/); - if (pageTypeMatch) { - propertyFilters.pageType = pageTypeMatch[1]; - searchText = searchText.replace(pageTypeMatch[0], "").trim(); - } - - const sourceMatch = query.match(/\bsource:(\S+)/); - if (sourceMatch) { - propertyFilters.source = sourceMatch[1]; - searchText = searchText.replace(sourceMatch[0], "").trim(); - } - - return { searchText, propertyFilters }; -} - -function convertSearchResultsToWebsites( - results: kp.ConversationSearchResult[], - websiteCollection: website.WebsiteCollection, -): website.Website[] { - const websites: website.Website[] = []; - const seenUrls = new Set(); - - for (const result of results) { - for (const msgMatch of result.messageMatches) { - const msg = websiteCollection.messages.get(msgMatch.messageOrdinal); - console.log("Message from search: ", JSON.stringify(msg)); - - if (msg && msg.metadata) { - const url = (msg.metadata as any).url; - if (url && !seenUrls.has(url)) { - seenUrls.add(url); - websites.push(msg as unknown as website.Website); - } - } - } - } - - return websites; -} - -/** - * Unified website search function that replaces both queryWebKnowledge and searchWebsites - * while incorporating advanced search capabilities - */ -export async function searchWebMemories( - request: SearchWebMemoriesRequest, - context: SessionContext, -): Promise { - const startTime = Date.now(); - let memoryServiceResponse: SearchWebMemoriesResponse | undefined; - const timing = { - parsing: 0, - search: 0, - processing: 0, - total: 0, - }; - - const debugContext: SearchDebugContext = { - searchTerms: [], - searchStrategies: [], - knowledgeMatchCount: 0, - timing, - intermediateFallbacks: [], - }; - - try { - // Validate inputs - if (!request.query || request.query.trim().length === 0) { - 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 ( - 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}"`); - - // 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}"`); - } - - // PHASE 1: Use knowpro's natural language search - const parseStart = Date.now(); - - const model = ai.createChatModel( - ai.azureApiSettingsFromEnv(ai.ModelType.Chat), - ) as TypeChatLanguageModel; - hookModelTokenUsage(model); - const queryTranslator = kp.createSearchQueryTranslator(model); - - const langOptions = kp.createLanguageSearchOptions(); - langOptions.modelInstructions = - getWebsiteSearchPromptPreamble(websiteCollection); - if (request.limit) { - langOptions.maxKnowledgeMatches = request.limit; - langOptions.maxMessageMatches = request.limit; - } - if (request.minScore) { - langOptions.thresholdScore = request.minScore; - } - if (request.maxCharsInBudget) { - langOptions.maxCharsInBudget = request.maxCharsInBudget; - } - - langOptions.fallbackRagOptions = { - maxMessageMatches: request.limit || 10, - maxCharsInBudget: request.maxCharsInBudget || 10000, - thresholdScore: request.minScore || 0.7, - }; - - timing.parsing = Date.now() - parseStart; - - // Determine search scope BEFORE searching to enable pre-filtering - const effectiveScope = request.searchScope || "all_indexed"; - - // Create filtered conversation for scoped searches - let conversationToSearch = websiteCollection; - - if (effectiveScope === "current_page" && currentPageUrl) { - const targetUrl = currentPageUrl; - const filterStart = Date.now(); - - // Get all messages and filter by target URL - const allMessages = websiteCollection.messages.getAll(); - const filteredMessages: any[] = []; - - for (let ordinal = 0; ordinal < allMessages.length; ordinal++) { - const msg = allMessages[ordinal]; - const metadata = msg.metadata as any; - if (metadata.url === targetUrl) { - filteredMessages.push(msg); - } - } - - if (filteredMessages.length > 0) { - // Create new message collection with filtered messages - const filteredMessageCollection = new kp.MessageCollection( - filteredMessages, - ); - - // Create filtered conversation with only target URL messages - // Note: This reduces search scope before embedding lookups - conversationToSearch = { - ...websiteCollection, - messages: filteredMessageCollection, - } as any; - - const filterTime = Date.now() - filterStart; - debug(`Pre-filter took ${filterTime}ms`); - } else { - // No messages found for this URL - return ( - memoryServiceResponse ?? - createEmptyResponse( - `No indexed content found for the current page: ${targetUrl}`, - startTime, - request.debug ? debugContext : undefined, - ) - ); - } - } - - const searchStart = Date.now(); - const langDebugContext: kp.LanguageSearchDebugContext = {}; - const langResult = await kp.searchConversationWithLanguage( - conversationToSearch, - searchText, - queryTranslator, - langOptions, - undefined, - request.debug ? langDebugContext : undefined, - ); - - if (!langResult.success) { - return ( - memoryServiceResponse ?? - createErrorResponse( - `Search query translation failed: ${langResult.message}`, - startTime, - request.debug ? debugContext : undefined, - ) - ); - } - - timing.search = Date.now() - searchStart; - debug(`Found ${langResult.data.length} conversation results`); - - if (langDebugContext.usedSimilarityFallback) { - const usedFallback = langDebugContext.usedSimilarityFallback.some( - (v) => v === true, - ); - if (usedFallback) { - debug( - `Embedding similarity fallback was used for some queries`, - ); - debugContext.searchStrategies.push("embedding-fallback"); - } - } - - // Convert ConversationSearchResult to Website[] - const processingStart = Date.now(); - let websites = convertSearchResultsToWebsites( - langResult.data, - conversationToSearch, - ); - - // Apply property filters (website-specific post-processing) - if (Object.keys(propertyFilters).length > 0) { - debug(`Applying property filters:`, propertyFilters); - websites = websites.filter((website) => { - if ( - propertyFilters.domain && - website.metadata.domain !== propertyFilters.domain - ) { - return false; - } - if ( - propertyFilters.pageType && - website.metadata.pageType !== propertyFilters.pageType - ) { - return false; - } - if ( - propertyFilters.source && - website.metadata.websiteSource !== propertyFilters.source - ) { - return false; - } - return true; - }); - debug(`After property filtering: ${websites.length} results`); - } - - // Apply limit - const limitedWebsites = websites.slice(0, request.limit || 20); - - // Convert to website results format - let websiteResults = convertToWebsiteResults(limitedWebsites); - - // Extract knowledge if requested - let relatedEntities: Entity[] | undefined; - let topTopics: string[] | undefined; - - if ( - request.includeRelatedEntities !== false && - limitedWebsites.length > 0 - ) { - const knowledgeResult = - await extractKnowledgeFromResults(limitedWebsites); - relatedEntities = knowledgeResult.entities; - topTopics = knowledgeResult.topics; - - // Associate insights with individual results - websiteResults = associateInsightsWithResults( - websiteResults, - limitedWebsites, - knowledgeResult.topicMap, - knowledgeResult.entityMap, - ); - } - - // Generate answer if requested - let answer: string | undefined; - let answerType: "direct" | "synthesized" | "noAnswer" | undefined; - let answerSources: WebPageReference[] | undefined; - let confidence: number | undefined; - - if (request.generateAnswer !== false && langResult.data.length > 0) { - const answerStart = Date.now(); - debug(`Generating answer for query: "${searchText}"`); - - try { - const answerGenerator = new kp.AnswerGenerator( - kp.createAnswerGeneratorSettings(), - ); - - const contextOptions: kp.AnswerContextOptions = { - entitiesTopK: request.knowledgeTopK || 20, - topicsTopK: request.knowledgeTopK || 20, - messagesTopK: request.limit || 20, - chunking: request.chunking ?? true, - }; - - debug( - `Answer context options - entities: ${contextOptions.entitiesTopK}, topics: ${contextOptions.topicsTopK}, messages: ${contextOptions.messagesTopK}`, - ); - - // Build the actual context that will be sent to the LLM - const actualContext: any = { - entities: { - timeRanges: [], - values: [], - }, - topics: { - timeRanges: [], - values: [], - }, - actions: { - timeRanges: [], - values: [], - }, - messages: [], - }; - - // Populate entities and topics from semantic references in search results - // Collect all semanticRefMatches from search results, grouped by knowledge type - const combinedSemanticRefMatches = new Map< - kp.KnowledgeType, - Map - >(); - - langResult.data.forEach((searchResult, idx) => { - if ( - searchResult.knowledgeMatches && - searchResult.knowledgeMatches.size > 0 - ) { - for (const [ - knowledgeType, - semanticRefSearchResult, - ] of searchResult.knowledgeMatches.entries()) { - // Skip undefined knowledgeTypes to prevent errors - if (!knowledgeType) { - debug( - `Warning: Found undefined knowledgeType in search result ${idx}`, - ); - continue; - } - - if ( - !combinedSemanticRefMatches.has(knowledgeType) - ) { - combinedSemanticRefMatches.set( - knowledgeType, - new Map(), - ); - } - - const dedupeMap = - combinedSemanticRefMatches.get(knowledgeType)!; - - semanticRefSearchResult.semanticRefMatches.forEach( - (scoredRef) => { - if ( - !dedupeMap.has( - scoredRef.semanticRefOrdinal, - ) || - scoredRef.score > - dedupeMap.get( - scoredRef.semanticRefOrdinal, - )!.score - ) { - dedupeMap.set( - scoredRef.semanticRefOrdinal, - scoredRef, - ); - } - }, - ); - } - } - }); - - // Use ALL semantic refs (no threshold filtering) - // Instead, we'll rank chunks by cumulative score and apply a token budget - const refsToUse = combinedSemanticRefMatches; - - // Use KnowPro helper functions to extract entities and topics from semantic refs - if (refsToUse.has("entity")) { - const entitySemanticRefs = refsToUse.get("entity")!; - const entitySearchResult: kp.SemanticRefSearchResult = { - termMatches: new Set(), - semanticRefMatches: Array.from( - entitySemanticRefs.values(), - ), - }; - - const relevantEntities = kp.getRelevantEntitiesForAnswer( - websiteCollection as any, - entitySearchResult, - contextOptions.entitiesTopK, - ); - - actualContext.entities.values = relevantEntities.map( - (re) => re.knowledge, - ); - if ( - relevantEntities.length > 0 && - relevantEntities[0].timeRange - ) { - actualContext.entities.timeRanges = [ - relevantEntities[0].timeRange, - ]; - } - } - - if (refsToUse.has("topic")) { - const topicSemanticRefs = refsToUse.get("topic")!; - const topicSearchResult: kp.SemanticRefSearchResult = { - termMatches: new Set(), - semanticRefMatches: Array.from( - topicSemanticRefs.values(), - ), - }; - - const relevantTopics = kp.getRelevantTopicsForAnswer( - websiteCollection as any, - topicSearchResult, - contextOptions.topicsTopK, - ); - - actualContext.topics.values = relevantTopics.map( - (rt) => rt.knowledge, - ); - if ( - relevantTopics.length > 0 && - relevantTopics[0].timeRange - ) { - actualContext.topics.timeRanges = [ - relevantTopics[0].timeRange, - ]; - } - } - - // Calculate cumulative scores for each chunk - // Key: "messageOrdinal:chunkOrdinal", Value: {score, text, messageOrdinal, chunkOrdinal} - const chunkScores = new Map< - string, - { - cumulativeScore: number; - text: string; - messageOrdinal: kp.MessageOrdinal; - chunkOrdinal: number; - } - >(); - - refsToUse.forEach((dedupeMap, knowledgeType) => { - dedupeMap.forEach((scoredRef) => { - if (websiteCollection.semanticRefs) { - const semanticRef = - websiteCollection.semanticRefs.get( - scoredRef.semanticRefOrdinal, - ); - - // Skip if semanticRef is undefined to prevent range access errors - if (!semanticRef || !semanticRef.range) { - debug( - `Warning: SemanticRef not found for ordinal ${scoredRef.semanticRefOrdinal}`, - ); - return; // Skip this iteration - } - - const messageOrdinal = - semanticRef.range.start.messageOrdinal; - const msg = - websiteCollection.messages.get(messageOrdinal); - - if (msg && msg.textChunks.length > 0) { - const startChunk = - semanticRef.range.start.chunkOrdinal || 0; - const endChunk = - semanticRef.range.end?.chunkOrdinal || - msg.textChunks.length - 1; - - // Add score to each chunk in the range - for ( - let chunkOrdinal = startChunk; - chunkOrdinal <= endChunk; - chunkOrdinal++ - ) { - if (chunkOrdinal < msg.textChunks.length) { - const chunkKey = `${messageOrdinal}:${chunkOrdinal}`; - const existing = - chunkScores.get(chunkKey); - - if (existing) { - existing.cumulativeScore += - scoredRef.score; - } else { - chunkScores.set(chunkKey, { - cumulativeScore: - scoredRef.score, - text: msg.textChunks[ - chunkOrdinal - ], - messageOrdinal, - chunkOrdinal, - }); - } - } - } - } - } - }); - }); - - // Rank chunks by cumulative score and select top chunks within token budget - const rankedChunks = Array.from(chunkScores.values()).sort( - (a, b) => b.cumulativeScore - a.cumulativeScore, - ); - - // Token budget: ~16K tokens ≈ 64K characters (using 4 chars/token average) - const targetTokens = request.maxCharsInBudget - ? request.maxCharsInBudget / 4 - : 16000; - const maxChars = targetTokens * 4; - const selectedChunks: typeof rankedChunks = []; - let totalChars = 0; - - for (const chunk of rankedChunks) { - const chunkLength = chunk.text.length; - if (totalChars + chunkLength <= maxChars) { - selectedChunks.push(chunk); - totalChars += chunkLength; - } else { - break; - } - } - - debug( - `Selected ${selectedChunks.length} chunks (${totalChars} chars, ~${Math.round(totalChars / 4)} tokens) from ${rankedChunks.length} total chunks`, - ); - - // Group selected chunks back by message for coherence - const messageChunksMap = new Map< - kp.MessageOrdinal, - Array<{ ordinal: number; text: string; score: number }> - >(); - - for (const chunk of selectedChunks) { - if (!messageChunksMap.has(chunk.messageOrdinal)) { - messageChunksMap.set(chunk.messageOrdinal, []); - } - messageChunksMap.get(chunk.messageOrdinal)!.push({ - ordinal: chunk.chunkOrdinal, - text: chunk.text, - score: chunk.cumulativeScore, - }); - } - - // Build messages from selected chunks - const matchedMessages: Array<{ - timestamp: string; - value: string; - score: number; - title?: string; - url?: string; - chunkCount: number; - }> = []; - - messageChunksMap.forEach((chunks, messageOrdinal) => { - const msg = websiteCollection.messages.get(messageOrdinal); - if (!msg) return; - - const metadata = msg.metadata as any; - - // Sort chunks by ordinal for coherence - chunks.sort((a, b) => a.ordinal - b.ordinal); - - // Combine chunks (maintaining order) - const combinedText = chunks.map((c) => c.text).join("\n\n"); - - // Calculate average score for this message - const avgScore = - chunks.reduce((sum, c) => sum + c.score, 0) / - chunks.length; - - const currMessage = { - timestamp: - metadata.lastVisitTime || - metadata.visitDate || - metadata.bookmarkDate || - new Date().toISOString(), - value: combinedText, - score: avgScore, - title: metadata.title, - url: metadata.url, - chunkCount: chunks.length, - }; - - // Apply scope filtering - if (effectiveScope === "current_page" && request.url) { - if (metadata.url === request.url) { - matchedMessages.push(currMessage); - } - } else { - matchedMessages.push(currMessage); - } - }); - - // Sort by score (highest first) and take top K - matchedMessages.sort((a, b) => b.score - a.score); - const topMatchedMessages = matchedMessages.slice( - 0, - contextOptions.messagesTopK, - ); - - // Map to the format expected by AnswerContext - actualContext.messages = topMatchedMessages.map((msg) => ({ - timestamp: msg.timestamp, - value: msg.value, - })); - - if ( - actualContext.entities.values.length === 0 && - actualContext.topics.values.length === 0 && - topMatchedMessages.length === 0 - ) { - debug( - `Warning: Answer context is empty - no entities, topics, or messages matched`, - ); - } - - // Build filtered AnswerContext from semantic refs - const filteredAnswerContext: any = {}; - - // Add entities extracted from semantic refs - if ( - refsToUse.has("entity") && - actualContext.entities.values.length > 0 - ) { - const entitySemanticRefs = refsToUse.get("entity")!; - const entitySearchResult: kp.SemanticRefSearchResult = { - termMatches: new Set(), - semanticRefMatches: Array.from( - entitySemanticRefs.values(), - ), - }; - filteredAnswerContext.entities = - kp.getRelevantEntitiesForAnswer( - websiteCollection as any, - entitySearchResult, - contextOptions.entitiesTopK, - ); - } - - // Add topics extracted from semantic refs - if ( - refsToUse.has("topic") && - actualContext.topics.values.length > 0 - ) { - const topicSemanticRefs = refsToUse.get("topic")!; - const topicSearchResult: kp.SemanticRefSearchResult = { - termMatches: new Set(), - semanticRefMatches: Array.from( - topicSemanticRefs.values(), - ), - }; - filteredAnswerContext.topics = - kp.getRelevantTopicsForAnswer( - websiteCollection as any, - topicSearchResult, - contextOptions.topicsTopK, - ); - } - - // Use the ranked chunks approach for filtered answer context - // (matchedMessages are already built from ranked chunks) - const topFilteredMessages = matchedMessages.slice( - 0, - contextOptions.messagesTopK, - ); - - filteredAnswerContext.messages = topFilteredMessages.map( - (msg) => ({ - timestamp: msg.timestamp, - value: msg.value, - }), - ); - - // Call generator directly with filtered context - const answerResult = await answerGenerator.generateAnswer( - searchText, - filteredAnswerContext, - ); - - if (answerResult.success) { - const answerResponse = answerResult.data; - - if (answerResponse.type === "Answered") { - answer = answerResponse.answer; - answerType = "synthesized"; - confidence = 0.8; - - answerSources = limitedWebsites - .slice(0, 5) - .map((site, index) => ({ - url: site.metadata.url, - title: site.metadata.title || "", - relevanceScore: 1.0 - index * 0.1, - lastIndexed: - site.metadata.lastVisitTime || - new Date().toISOString(), - })); - - debug( - `Generated answer (${answer!.length} chars) in ${Date.now() - answerStart}ms`, - ); - } else { - answerType = "noAnswer"; - debug( - `No answer generated: ${answerResponse.whyNoAnswer}`, - ); - } - } else { - debug(`Answer generation failed: ${answerResult.message}`); - } - } catch (error) { - debug(`Answer generation error: ${error}`); - } - } - - timing.processing = Date.now() - processingStart; - timing.total = Date.now() - startTime; - - // Update debug context - debugContext.knowledgeMatchCount = limitedWebsites.length; - debugContext.timing = timing; - debugContext.searchStrategies.push("knowpro-language-search"); - - // Build response - const response: SearchWebMemoriesResponse = { - websites: websiteResults, - summary: { - totalFound: websites.length, - searchTime: timing.total, - strategies: debugContext.searchStrategies, - confidence: 0.8, - }, - queryIntent: "discovery", - searchTerms: [searchText], - suggestedFollowups: [], - }; - - // Add answer fields if generated - if (answer !== undefined) { - response.answer = answer; - response.answerType = answerType; - response.answerSources = answerSources; - response.confidence = confidence; - } - - if (relatedEntities !== undefined) { - response.relatedEntities = relatedEntities || undefined; - response.topTopics = topTopics || undefined; - } - - if (request.debug) { - response.debugContext = debugContext; - } - - debug( - `Search completed in ${timing.total}ms with ${websiteResults.length} results`, - ); - return mergeSearchResponses( - memoryServiceResponse, - response, - request.limit ?? 20, - ); - } catch (error) { - timing.total = Date.now() - startTime; - debug(`Search failed: ${error}`); - - return ( - memoryServiceResponse ?? - createErrorResponse( - error instanceof Error ? error.message : "Unknown search error", - startTime, - request.debug ? debugContext : undefined, - ) - ); - } -} - -// OLD SEARCH FUNCTIONS - REMOVED (replaced by knowpro searchConversationWithLanguage) -// - performComprehensiveSearch -// - performAdvancedSearch -// - performHybridSearch -// - performBasicSemanticSearch -// - isSingleTermQuery -// - hasCapitalizedTerms -// - buildEnhancedWhenFilter - -// RESTORED: Direct entity/topic search helpers for entity graph view -// These call knowpro APIs directly for fast, deterministic lookups - -async function performEntitySearch( - request: SearchWebMemoriesRequest, - websiteCollection: website.WebsiteCollection, -): Promise { - try { - debug(`Attempting entity search for: "${request.query}"`); - - const entityType = request.metadata?.entityType; - const facetName = request.metadata?.facetName; - const facetValue = request.metadata?.facetValue; - - const entityResults = await websiteCollection.searchByEntities( - [request.query], - entityType, - facetName, - facetValue, - ); - - debug(`Found ${entityResults.length} results using entity search`); - - const websites = entityResults.map((result) => result.toWebsite()); - const deduplicatedWebsites = deduplicateByUrl(websites); - - debug( - `Entity search: ${websites.length} results (${deduplicatedWebsites.length} after deduplication)`, - ); - - return deduplicatedWebsites.slice(0, request.limit || 20); - } catch (error) { - debug(`Entity search failed: ${error}`); - return []; - } -} - -async function performTopicSearch( - request: SearchWebMemoriesRequest, - websiteCollection: website.WebsiteCollection, -): Promise { - try { - debug(`Attempting topic search for: "${request.query}"`); - - const whenFilter = - request.dateFrom || request.dateTo - ? { - dateRange: { - start: request.dateFrom - ? new Date(request.dateFrom) - : new Date(0), - end: request.dateTo - ? new Date(request.dateTo) - : new Date(), - }, - } - : undefined; - - const searchOptions = { - maxKnowledgeMatches: request.limit || 20, - exactMatch: request.exactMatch || false, - }; - - const topicResults = await websiteCollection.searchByTopics( - [request.query], - whenFilter, - searchOptions, - ); - - debug(`Found ${topicResults.length} results using topic search`); - - const websites = topicResults.map((result) => result.toWebsite()); - const deduplicatedWebsites = deduplicateByUrl(websites); - - debug( - `Topic search: ${websites.length} results (${deduplicatedWebsites.length} after deduplication)`, - ); - - return deduplicatedWebsites.slice(0, request.limit || 20); - } catch (error) { - debug(`Topic search failed: ${error}`); - return []; - } -} - -function deduplicateByUrl(websites: website.Website[]): website.Website[] { - const seenUrls = new Set(); - const deduplicated: website.Website[] = []; - - for (const site of websites) { - const metadata = site.metadata as any; - const url = metadata?.url; - - if (url && !seenUrls.has(url)) { - seenUrls.add(url); - deduplicated.push(site); - } else if (!url) { - deduplicated.push(site); - } - } - - return deduplicated; -} - -// Helper functions - -function convertToWebsiteResults(websites: website.Website[]): WebsiteResult[] { - return websites.map((site) => { - const metadata = site.metadata as any; - return { - url: metadata.url, - title: metadata.title || metadata.url, - domain: metadata.domain || "unknown", - pageType: metadata.pageType || "general", - source: metadata.websiteSource || "unknown", - relevanceScore: 0.8, // Could be enhanced with actual scoring - lastVisited: - metadata.visitDate || metadata.bookmarkDate || undefined, - snippet: extractSnippet(site), - }; - }); -} - -function extractSnippet(website: website.Website): string { - const textContent = website.textChunks?.join(" ") || ""; - return ( - textContent.substring(0, 200) + (textContent.length > 200 ? "..." : "") - ); -} - -function calculateSimplePageRank( - nodes: string[], - relationships: Map>, - iterations: number = 5, - dampingFactor: number = 0.85, -): Map { - const n = nodes.length; - if (n === 0) return new Map(); - - // Build index mapping - const nodeIndex = new Map(); - nodes.forEach((node, index) => nodeIndex.set(node, index)); - - // Build adjacency and out-degree - const adjacency = new Map>(); - const outDegree = new Array(n).fill(0); - - for (let i = 0; i < n; i++) { - adjacency.set(i, new Set()); - } - - relationships.forEach((targets, source) => { - const sourceIdx = nodeIndex.get(source); - if (sourceIdx !== undefined) { - targets.forEach((target) => { - const targetIdx = nodeIndex.get(target); - if (targetIdx !== undefined && sourceIdx !== targetIdx) { - adjacency.get(sourceIdx)!.add(targetIdx); - outDegree[sourceIdx]++; - } - }); - } - }); - - // Initialize PageRank - let pageRank = new Array(n).fill(1.0 / n); - let newPageRank = new Array(n).fill(0); - - // Iterate - for (let iter = 0; iter < iterations; iter++) { - newPageRank.fill((1.0 - dampingFactor) / n); - - for (let i = 0; i < n; i++) { - if (outDegree[i] > 0) { - const contribution = - (dampingFactor * pageRank[i]) / outDegree[i]; - for (const neighbor of adjacency.get(i)!) { - newPageRank[neighbor] += contribution; - } - } - } - - [pageRank, newPageRank] = [newPageRank, pageRank]; - } - - // Convert to map - const result = new Map(); - nodes.forEach((node, index) => { - result.set(node, pageRank[index]); - }); - - return result; -} - -function rankTopicsWithPageRank( - topicMap: Map< - string, - { - topic: string; - count: number; - sites: string[]; - } - >, -): string[] { - // Build co-occurrence graph: topics that appear on same pages are related - const relationships = new Map>(); - const topicsBySite = new Map>(); - - // Group topics by site - topicMap.forEach((entry, topicKey) => { - entry.sites.forEach((site) => { - if (!topicsBySite.has(site)) { - topicsBySite.set(site, new Set()); - } - topicsBySite.get(site)!.add(topicKey); - }); - }); - - // Build relationships from co-occurrence - topicsBySite.forEach((topics) => { - const topicArray = Array.from(topics); - for (let i = 0; i < topicArray.length; i++) { - const topic1 = topicArray[i]; - if (!relationships.has(topic1)) { - relationships.set(topic1, new Set()); - } - for (let j = i + 1; j < topicArray.length; j++) { - const topic2 = topicArray[j]; - relationships.get(topic1)!.add(topic2); - if (!relationships.has(topic2)) { - relationships.set(topic2, new Set()); - } - relationships.get(topic2)!.add(topic1); - } - } - }); - - // Calculate PageRank scores - const topicKeys = Array.from(topicMap.keys()); - const pageRanks = calculateSimplePageRank(topicKeys, relationships); - - // Combine PageRank with occurrence count for final ranking - const scoredTopics = topicKeys.map((key) => { - const entry = topicMap.get(key)!; - const pageRank = pageRanks.get(key) || 0; - const normalizedCount = entry.count / Math.max(1, topicMap.size); - const combinedScore = pageRank * 0.6 + normalizedCount * 0.4; - - return { - topic: entry.topic, - score: combinedScore, - }; - }); - - // Sort by combined score and return top 10 - return scoredTopics - .sort((a, b) => b.score - a.score) - .slice(0, 10) - .map((item) => item.topic); -} - -function rankEntitiesWithPageRank( - entityMap: Map< - string, - { - entity: any; - count: number; - totalConfidence: number; - sites: string[]; - } - >, -): Entity[] { - // Build co-occurrence graph: entities that appear on same pages are related - const relationships = new Map>(); - const entitiesBySite = new Map>(); - - // Group entities by site - entityMap.forEach((entry, entityKey) => { - entry.sites.forEach((site) => { - if (!entitiesBySite.has(site)) { - entitiesBySite.set(site, new Set()); - } - entitiesBySite.get(site)!.add(entityKey); - }); - }); - - // Build relationships from co-occurrence - entitiesBySite.forEach((entities) => { - const entityArray = Array.from(entities); - for (let i = 0; i < entityArray.length; i++) { - const entity1 = entityArray[i]; - if (!relationships.has(entity1)) { - relationships.set(entity1, new Set()); - } - for (let j = i + 1; j < entityArray.length; j++) { - const entity2 = entityArray[j]; - relationships.get(entity1)!.add(entity2); - if (!relationships.has(entity2)) { - relationships.set(entity2, new Set()); - } - relationships.get(entity2)!.add(entity1); - } - } - }); - - // Calculate PageRank scores - const entityKeys = Array.from(entityMap.keys()); - const pageRanks = calculateSimplePageRank(entityKeys, relationships); - - // Combine PageRank with occurrence count and confidence for final ranking - const scoredEntities = entityKeys.map((key) => { - const entry = entityMap.get(key)!; - const pageRank = pageRanks.get(key) || 0; - const normalizedCount = entry.count / Math.max(1, entityMap.size); - const avgConfidence = entry.totalConfidence / entry.count; - const combinedScore = - pageRank * 0.5 + normalizedCount * 0.3 + avgConfidence * 0.2; - - return { - entity: entry.entity, - score: combinedScore, - }; - }); - - // Sort by combined score and return top 10 - return scoredEntities - .sort((a, b) => b.score - a.score) - .slice(0, 10) - .map((item) => { - // Update confidence to average across all occurrences - const entry = entityMap.get(item.entity.name.toLowerCase())!; - item.entity.confidence = entry.totalConfidence / entry.count; - // Add occurrence count metadata - item.entity.occurrenceCount = entry.count; - item.entity.sourceSites = entry.sites.length; - return item.entity; - }); -} - -async function extractKnowledgeFromResults( - results: website.Website[], -): Promise<{ - entities: Entity[]; - topics: string[]; - topicMap: Map< - string, - { topic: string; count: number; sites: string[]; pageRank?: number } - >; - entityMap: Map< - string, - { - entity: any; - count: number; - totalConfidence: number; - sites: string[]; - pageRank?: number; - } - >; -}> { - // Entity aggregation with count tracking - const entityMap = new Map< - string, - { - entity: any; - count: number; - totalConfidence: number; - sites: string[]; - pageRank?: number; - } - >(); - - // Topic aggregation with count tracking - const topicMap = new Map< - string, - { - topic: string; - count: number; - sites: string[]; - pageRank?: number; - } - >(); - - // Process all sites - limit each to 5 topics - for (const site of results) { - const knowledge = site.getKnowledge(); - const siteUrl = (site as any).url || "unknown"; - - // Process ALL entities from this site - if (knowledge?.entities) { - for (const entity of knowledge.entities) { - if (!entity.name) continue; - - const entityNameLower = entity.name.toLowerCase(); - - if (entityMap.has(entityNameLower)) { - // Entity already exists - increment count and update confidence - const existing = entityMap.get(entityNameLower)!; - existing.count += 1; - existing.totalConfidence += - (entity as any).confidence || 0.7; - existing.sites.push(siteUrl); - - // Update entity data if current entity has facets and existing doesn't - if ((entity as any).facets && !existing.entity.facets) { - existing.entity.facets = (entity as any).facets; - } - } else { - // New entity - create entry - const extractedEntity: any = { - name: entity.name, - type: Array.isArray(entity.type) - ? entity.type.join(", ") - : entity.type, - confidence: (entity as any).confidence || 0.7, - }; - - // Add description if available from facets - if ((entity as any).facets) { - const facets = (entity as any).facets; - const descriptionFacet = facets.find( - (f: any) => f.name === "description", - ); - if (descriptionFacet) { - extractedEntity.description = - descriptionFacet.value; - } - - // Add facets array to the entity - extractedEntity.facets = facets.map((facet: any) => ({ - name: facet.name || facet.category || "Unknown", - value: Array.isArray(facet.value) - ? facet.value.join(", ") - : facet.value || - (facet.values ? facet.values.join(", ") : ""), - })); - } - - entityMap.set(entityNameLower, { - entity: extractedEntity, - count: 1, - totalConfidence: extractedEntity.confidence, - sites: [siteUrl], - }); - } - } - } - - // Process all topics from this site for comprehensive recall - if (knowledge?.topics) { - for (const topic of knowledge.topics) { - const topicName = - typeof topic === "string" - ? topic - : (topic as any).name || (topic as any).topic || topic; - if (!topicName) continue; - - const topicNameLower = topicName.toLowerCase(); - - if (topicMap.has(topicNameLower)) { - // Topic already exists - increment count - const existing = topicMap.get(topicNameLower)!; - existing.count += 1; - existing.sites.push(siteUrl); - } else { - // New topic - create entry - topicMap.set(topicNameLower, { - topic: topicName, - count: 1, - sites: [siteUrl], - }); - } - } - } - } - - // Calculate PageRank and store in maps - const topicNodes = Array.from(topicMap.keys()); - const topicRelationships = buildTopicRelationships(topicMap); - const topicPageRanks = calculateSimplePageRank( - topicNodes, - topicRelationships, - ); - topicPageRanks.forEach((rank, topic) => { - const entry = topicMap.get(topic); - if (entry) entry.pageRank = rank; - }); - - const entityNodes = Array.from(entityMap.keys()); - const entityRelationships = buildEntityRelationships(entityMap); - const entityPageRanks = calculateSimplePageRank( - entityNodes, - entityRelationships, - ); - entityPageRanks.forEach((rank, entity) => { - const entry = entityMap.get(entity); - if (entry) entry.pageRank = rank; - }); - - // Apply PageRank-based ranking for topics - const rankedTopics = rankTopicsWithPageRank(topicMap); - - // Apply PageRank-based ranking for entities - const rankedEntities = rankEntitiesWithPageRank(entityMap); - - return { - entities: rankedEntities, - topics: rankedTopics, - topicMap, - entityMap, - }; -} - -function buildTopicRelationships( - topicMap: Map, -): Map> { - const relationships = new Map>(); - const topicsBySite = new Map>(); - - topicMap.forEach((entry, topicKey) => { - entry.sites.forEach((site) => { - if (!topicsBySite.has(site)) { - topicsBySite.set(site, new Set()); - } - topicsBySite.get(site)!.add(topicKey); - }); - }); - - topicsBySite.forEach((topics) => { - const topicArray = Array.from(topics); - for (let i = 0; i < topicArray.length; i++) { - const topic1 = topicArray[i]; - if (!relationships.has(topic1)) { - relationships.set(topic1, new Set()); - } - for (let j = i + 1; j < topicArray.length; j++) { - const topic2 = topicArray[j]; - relationships.get(topic1)!.add(topic2); - if (!relationships.has(topic2)) { - relationships.set(topic2, new Set()); - } - relationships.get(topic2)!.add(topic1); - } - } - }); - - return relationships; -} - -function buildEntityRelationships( - entityMap: Map< - string, - { entity: any; count: number; totalConfidence: number; sites: string[] } - >, -): Map> { - const relationships = new Map>(); - const entitiesBySite = new Map>(); - - entityMap.forEach((entry, entityKey) => { - entry.sites.forEach((site) => { - if (!entitiesBySite.has(site)) { - entitiesBySite.set(site, new Set()); - } - entitiesBySite.get(site)!.add(entityKey); - }); - }); - - entitiesBySite.forEach((entities) => { - const entityArray = Array.from(entities); - for (let i = 0; i < entityArray.length; i++) { - const entity1 = entityArray[i]; - if (!relationships.has(entity1)) { - relationships.set(entity1, new Set()); - } - for (let j = i + 1; j < entityArray.length; j++) { - const entity2 = entityArray[j]; - relationships.get(entity1)!.add(entity2); - if (!relationships.has(entity2)) { - relationships.set(entity2, new Set()); - } - relationships.get(entity2)!.add(entity1); - } - } - }); - - return relationships; -} - -function extractInsightsForWebsite( - websiteResult: WebsiteResult, - websiteSite: website.Website, - topicMap: Map< - string, - { topic: string; count: number; sites: string[]; pageRank?: number } - >, - entityMap: Map< - string, - { - entity: any; - count: number; - totalConfidence: number; - sites: string[]; - pageRank?: number; - } - >, -): { topics: any[]; entities: any[]; relevanceScore: number } { - const websiteText = - `${websiteResult.title} ${websiteResult.snippet || ""} ${websiteResult.domain}`.toLowerCase(); - const knowledge = websiteSite.getKnowledge(); - - const topics: any[] = []; - const entities: any[] = []; - - // Extract topics from this website's knowledge - if (knowledge?.topics) { - for (const topic of knowledge.topics) { - const topicName = - typeof topic === "string" - ? topic - : (topic as any).name || (topic as any).topic || topic; - if (!topicName) continue; - - const topicKey = topicName.toLowerCase(); - const topicInfo = topicMap.get(topicKey); - - if (topicInfo) { - const occurrences = countOccurrences(websiteText, topicKey); - const pageRank = topicInfo.pageRank || 0; - const normalizedCount = - topicInfo.count / Math.max(1, topicMap.size); - const relevance = pageRank * 0.6 + normalizedCount * 0.4; - - topics.push({ - name: topicInfo.topic, - relevance, - occurrences: Math.max(occurrences, 1), - type: - relevance > 0.7 - ? "primary" - : relevance > 0.4 - ? "secondary" - : "related", - }); - } - } - } - - // Extract entities from this website's knowledge - if (knowledge?.entities) { - for (const entity of knowledge.entities) { - if (!entity.name) continue; - - const entityKey = entity.name.toLowerCase(); - const entityInfo = entityMap.get(entityKey); - - if (entityInfo) { - const mentions = countOccurrences(websiteText, entityKey); - const avgConfidence = - entityInfo.totalConfidence / entityInfo.count; - - entities.push({ - name: entityInfo.entity.name, - type: entityInfo.entity.type, - confidence: avgConfidence, - mentions: Math.max(mentions, 1), - }); - } - } - } - - // Sort and limit - topics.sort((a, b) => b.relevance - a.relevance); - entities.sort((a, b) => b.confidence - a.confidence); - - const topTopics = topics.slice(0, 5); - const topEntities = entities.slice(0, 3); - - // Calculate overall relevance score - const topicScore = - topTopics.reduce((sum, t) => sum + t.relevance, 0) / - Math.max(topTopics.length, 1); - const entityScore = - topEntities.reduce((sum, e) => sum + e.confidence, 0) / - Math.max(topEntities.length, 1); - const relevanceScore = - topTopics.length > 0 || topEntities.length > 0 - ? (topicScore + entityScore) / 2 - : 0; - - return { - topics: topTopics, - entities: topEntities, - relevanceScore, - }; -} - -function countOccurrences(text: string, searchTerm: string): number { - const regex = new RegExp( - searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), - "gi", - ); - const matches = text.match(regex); - return matches ? matches.length : 0; -} - -function associateInsightsWithResults( - websiteResults: WebsiteResult[], - websites: website.Website[], - topicMap: Map< - string, - { topic: string; count: number; sites: string[]; pageRank?: number } - >, - entityMap: Map< - string, - { - entity: any; - count: number; - totalConfidence: number; - sites: string[]; - pageRank?: number; - } - >, -): WebsiteResult[] { - return websiteResults.map((result, index) => { - const websiteSite = websites[index]; - if (!websiteSite) return result; - - const insights = extractInsightsForWebsite( - result, - websiteSite, - topicMap, - entityMap, - ); - - if (insights.topics.length > 0 || insights.entities.length > 0) { - return { - ...result, - insights, - }; - } - - return result; - }); -} - -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, - debugContext?: SearchDebugContext | undefined, -): SearchWebMemoriesResponse { - return { - websites: [], - summary: { - totalFound: 0, - searchTime: Date.now() - startTime, - strategies: [], - confidence: 0, - }, - answer: message, - answerType: "noAnswer", - answerSources: [], - queryIntent: "discovery", - suggestedFollowups: [], - debugContext: debugContext || undefined, - }; -} - -function createErrorResponse( - error: string, - startTime: number, - debugContext?: SearchDebugContext | undefined, -): SearchWebMemoriesResponse { - return { - websites: [], - summary: { - totalFound: 0, - searchTime: Date.now() - startTime, - strategies: [], - confidence: 0, - }, - answer: `Error occurred during search: ${error}`, - answerType: "noAnswer", - answerSources: [], - queryIntent: "discovery", - suggestedFollowups: [], - debugContext: debugContext || undefined, - }; -} - -// Entity-based search function - Uses direct entity search for fast, deterministic lookups -export async function searchByEntities( - request: { - entities: string[]; - url?: string; - maxResults?: number; - searchScope?: "current_page" | "all_indexed"; - includeMetadata?: boolean; - }, - context: SessionContext, -): Promise { - const startTime = Date.now(); - debug( - `Starting entity search for entities: ${request.entities.join(", ")}`, - ); - - try { - const websiteCollection = context.agentContext.websiteCollection; - if (!websiteCollection || websiteCollection.messages.length === 0) { - debug("No website collection available"); - return createEmptyResponse( - "No website data available for entity search", - startTime, - ); - } - - const searchRequest: SearchWebMemoriesRequest = { - query: request.entities.join(" OR "), - searchScope: request.searchScope || "all_indexed", - limit: request.maxResults || 10, - generateAnswer: false, - includeRelatedEntities: true, - exactMatch: false, - minScore: 0.3, - }; - - // Use direct entity search (calls knowpro searchByEntities) - const websites = await performEntitySearch( - searchRequest, - websiteCollection, - ); - - if (!websites || websites.length === 0) { - debug( - `No results found for entities: ${request.entities.join(", ")}`, - ); - return createEmptyResponse( - `No websites found containing entities: ${request.entities.join(", ")}`, - startTime, - ); - } - - debug( - `Entity search found ${websites.length} results for entities: ${request.entities.join(", ")}`, - ); - - let websiteResults = convertToWebsiteResults(websites); - const knowledgeResult = await extractKnowledgeFromResults(websites); - - // Associate insights with individual results - websiteResults = associateInsightsWithResults( - websiteResults, - websites, - knowledgeResult.topicMap, - knowledgeResult.entityMap, - ); - - return { - websites: websiteResults, - summary: { - totalFound: websiteResults.length, - searchTime: Date.now() - startTime, - strategies: ["entity-direct"], - confidence: websiteResults.length > 0 ? 0.9 : 0, - }, - answer: - websiteResults.length > 0 - ? `Found ${websiteResults.length} websites containing the requested entities.` - : `No websites found containing entities: ${request.entities.join(", ")}`, - answerType: websiteResults.length > 0 ? "direct" : "noAnswer", - answerSources: [], - queryIntent: "discovery", - relatedEntities: knowledgeResult.entities, - suggestedFollowups: [], - topTopics: knowledgeResult.topics, - }; - } catch (error) { - console.error("Error in entity search:", error); - return createErrorResponse( - error instanceof Error ? error.message : "Entity search failed", - startTime, - ); - } -} - -// Topic-based search function - Uses direct topic search for fast, deterministic lookups -export async function searchByTopics( - request: { - topics: string[]; - url?: string; - maxResults?: number; - searchScope?: "current_page" | "all_indexed"; - includeMetadata?: boolean; - }, - context: SessionContext, -): Promise { - const startTime = Date.now(); - debug(`Starting topic search for topics: ${request.topics.join(", ")}`); - - try { - const websiteCollection = context.agentContext.websiteCollection; - if (!websiteCollection || websiteCollection.messages.length === 0) { - debug("No website collection available"); - return createEmptyResponse( - "No website data available for topic search", - startTime, - ); - } - - const searchRequest: SearchWebMemoriesRequest = { - query: request.topics.join(" OR "), - searchScope: request.searchScope || "all_indexed", - limit: request.maxResults || 10, - generateAnswer: false, - includeRelatedEntities: true, - exactMatch: false, - minScore: 0.25, - }; - - // Use direct topic search (calls knowpro searchByTopics) - const websites = await performTopicSearch( - searchRequest, - websiteCollection, - ); - - if (!websites || websites.length === 0) { - debug(`No results found for topics: ${request.topics.join(", ")}`); - return createEmptyResponse( - `No websites found containing topics: ${request.topics.join(", ")}`, - startTime, - ); - } - - debug( - `Topic search found ${websites.length} results for topics: ${request.topics.join(", ")}`, - ); - - let websiteResults = convertToWebsiteResults(websites); - const knowledgeResult = await extractKnowledgeFromResults(websites); - - // Associate insights with individual results - websiteResults = associateInsightsWithResults( - websiteResults, - websites, - knowledgeResult.topicMap, - knowledgeResult.entityMap, - ); - - return { - websites: websiteResults, - summary: { - totalFound: websiteResults.length, - searchTime: Date.now() - startTime, - strategies: ["topic-direct"], - confidence: websiteResults.length > 0 ? 0.9 : 0, - }, - answer: - websiteResults.length > 0 - ? `Found ${websiteResults.length} websites containing the requested topics.` - : `No websites found containing topics: ${request.topics.join(", ")}`, - answerType: websiteResults.length > 0 ? "direct" : "noAnswer", - answerSources: [], - queryIntent: "discovery", - relatedEntities: knowledgeResult.entities, - suggestedFollowups: [], - topTopics: knowledgeResult.topics, - }; - } catch (error) { - console.error("Error in topic search:", error); - return createErrorResponse( - error instanceof Error ? error.message : "Topic search failed", - startTime, - ); - } -} - -// Hybrid search function - combines multiple strategies -export async function hybridSearch( - request: { - query: string; - url?: string; - maxResults?: number; - searchScope?: "current_page" | "all_indexed"; - includeMetadata?: boolean; - combineStrategies?: boolean; - }, - context: SessionContext, -): Promise { - const startTime = Date.now(); - debug(`Starting hybrid search for query: ${request.query}`); - - try { - // Extract potential entities and topics from query - const queryWords = request.query - .toLowerCase() - .split(/\s+/) - .filter((word) => word.length > 2); - const potentialEntities = queryWords.filter((word) => - /^[A-Z]/.test( - request.query - .split(" ") - .find((w) => w.toLowerCase() === word) || "", - ), - ); - const potentialTopics = queryWords; - - const websiteCollection = context.agentContext.websiteCollection; - if (!websiteCollection || websiteCollection.messages.length === 0) { - debug("No website collection available"); - return createEmptyResponse( - "No website data available for hybrid search", - startTime, - ); - } - - // Run multiple search strategies in parallel using direct methods - const [textSearchPromise, entitySearchPromise, topicSearchPromise] = [ - // Standard text search - searchWebMemories( - { - query: request.query, - searchScope: request.searchScope || "all_indexed", - limit: Math.ceil((request.maxResults || 10) * 0.6), - generateAnswer: true, - includeRelatedEntities: true, - enableAdvancedSearch: true, - minScore: 0.4, - }, - context, - ), - - // Direct entity-based search if we detected potential entities - potentialEntities.length > 0 - ? (async () => { - try { - const entityResults = - await websiteCollection.searchByEntities( - potentialEntities, - ); - return entityResults - ? { - websites: convertToWebsiteResults( - entityResults - .map((r) => r.toWebsite()) - .slice( - 0, - Math.ceil( - (request.maxResults || 10) * - 0.3, - ), - ), - ), - summary: { strategies: ["entity-direct"] }, - } - : null; - } catch (error) { - debug("Entity search failed in hybrid:", error); - return null; - } - })() - : Promise.resolve(null), - - // Direct topic-based search - potentialTopics.length > 0 - ? (async () => { - try { - const topicResults = - await websiteCollection.searchByTopics( - potentialTopics.slice(0, 3), - ); - return topicResults - ? { - websites: convertToWebsiteResults( - topicResults - .map((r) => r.toWebsite()) - .slice( - 0, - Math.ceil( - (request.maxResults || 10) * - 0.3, - ), - ), - ), - summary: { strategies: ["topic-direct"] }, - } - : null; - } catch (error) { - debug("Topic search failed in hybrid:", error); - return null; - } - })() - : Promise.resolve(null), - ]; - - const [textResult, entityResult, topicResult] = await Promise.all([ - textSearchPromise, - entitySearchPromise, - topicSearchPromise, - ]); - - // Combine and deduplicate results - const allWebsites = new Map(); - const strategies = ["hybrid"]; - - // Add text search results (highest priority) - textResult.websites.forEach((website) => { - allWebsites.set(website.url, { - ...website, - relevanceScore: website.relevanceScore * 1.0, // Full weight - }); - }); - strategies.push(...textResult.summary.strategies); - - // Add entity search results (medium priority) - if (entityResult && entityResult.websites) { - entityResult.websites.forEach((website) => { - const existing = allWebsites.get(website.url); - if (existing) { - // Boost score for multi-strategy matches - existing.relevanceScore = Math.min( - 1.0, - existing.relevanceScore + 0.2, - ); - } else { - allWebsites.set(website.url, { - ...website, - relevanceScore: website.relevanceScore * 0.8, // Slightly lower weight - }); - } - }); - if (entityResult.summary && entityResult.summary.strategies) { - strategies.push(...entityResult.summary.strategies); - } - } - - // Add topic search results (lower priority) - if (topicResult && topicResult.websites) { - topicResult.websites.forEach((website) => { - const existing = allWebsites.get(website.url); - if (existing) { - // Boost score for multi-strategy matches - existing.relevanceScore = Math.min( - 1.0, - existing.relevanceScore + 0.15, - ); - } else { - allWebsites.set(website.url, { - ...website, - relevanceScore: website.relevanceScore * 0.7, // Lower weight - }); - } - }); - if (topicResult.summary && topicResult.summary.strategies) { - strategies.push(...topicResult.summary.strategies); - } - } - - // Sort by combined relevance score and limit results - const combinedWebsites = Array.from(allWebsites.values()) - .sort((a, b) => b.relevanceScore - a.relevanceScore) - .slice(0, request.maxResults || 10); - - debug( - `Hybrid search completed: ${combinedWebsites.length} results from ${strategies.length} strategies`, - ); - - // Use the best answer from text search, as it's most likely to be relevant - const finalAnswer = - textResult.answer || - "Results found from multiple search strategies."; - const finalSources = textResult.answerSources || []; - - return { - websites: combinedWebsites, - summary: { - totalFound: combinedWebsites.length, - searchTime: Date.now() - startTime, - strategies: Array.from(new Set(strategies)), - confidence: Math.min( - 1.0, - combinedWebsites.length > 0 ? 0.8 : 0.2, - ), - }, - answer: finalAnswer, - answerType: textResult.answerType || "synthesized", - answerSources: finalSources, - queryIntent: textResult.queryIntent || "discovery", - relatedEntities: textResult.relatedEntities || [], - suggestedFollowups: textResult.suggestedFollowups || [], - topTopics: textResult.topTopics || [], - }; - } catch (error) { - console.error("Error in hybridSearch:", error); - return createErrorResponse( - error instanceof Error ? error.message : "Hybrid search failed", - startTime, - ); - } -} -export function generateWebSearchHtml( - searchResponse: SearchWebMemoriesResponse, - summary: string, -): string { - let html = `
`; - - // Add summary header - html += `
${summary}
`; - - // Add answer if available - if (searchResponse.answer && searchResponse.answerType !== "noAnswer") { - html += `
-
Answer:
-
${searchResponse.answer}
-
`; - } - - // Add main results as ordered list - if (searchResponse.websites.length > 0) { - html += `
Search Results:
`; - html += `
    `; - - const topResults = searchResponse.websites.slice(0, 10); - topResults.forEach((site: any, index: number) => { - html += `
  1. -
    -
    -
    ${escapeHtml(site.title)}
    - -
    - ${site.lastVisited ? ` • Visited: ${new Date(site.lastVisited).toLocaleDateString()}` : ""} -
    -
    -
    -
  2. `; - }); - - html += `
`; - } - - // Add related entities if available - if ( - searchResponse.relatedEntities && - searchResponse.relatedEntities.length > 0 - ) { - html += ``; - } - - // Add topics if available - if (searchResponse.topTopics && searchResponse.topTopics.length > 0) { - html += `
-
Top Topics:
-
`; - const topTopics = searchResponse.topTopics.slice(0, 5); - topTopics.forEach((topic: string) => { - html += `${escapeHtml(topic)}`; - }); - html += `
`; - } - - // Add follow-up suggestions if available - if ( - searchResponse.suggestedFollowups && - searchResponse.suggestedFollowups.length > 0 - ) { - html += `
-
Suggested follow-ups:
-
    `; - searchResponse.suggestedFollowups.forEach((followup: string) => { - html += `
  • ${escapeHtml(followup)}
  • `; - }); - html += `
`; - } - - html += `
`; - - // Add CSS styles for better presentation - html += ` - `; - - return html; -} -export function generateWebSearchMarkdown( - searchResponse: SearchWebMemoriesResponse, - query: string, -): string { - let content = `Found ${searchResponse.websites.length} result(s) in ${searchResponse.summary.searchTime}ms\n\n`; - - // Add answer if available - if (searchResponse.answer && searchResponse.answerType !== "noAnswer") { - content += `** Answer:**${searchResponse.answer}\n\n`; - } - - // Add main results (limit to top 10) - if (searchResponse.websites.length > 0) { - content += `**Top Results:**\n\n`; - const topResults = searchResponse.websites.slice(0, 10); - - topResults.forEach((site: any, index: number) => { - content += `${index + 1}. ${site.title}\n`; - content += `([link](${site.url}))\n`; - - if (site.lastVisited) { - content += ` • Last visited: ${new Date(site.lastVisited).toLocaleDateString()}`; - } - content += `\n\n`; - }); - } - - // Add related entities if available - if ( - searchResponse.relatedEntities && - searchResponse.relatedEntities.length > 0 - ) { - content += `**Related Entities:**\n\n`; - const topEntities = searchResponse.relatedEntities.slice(0, 5); - topEntities.forEach((entity: any) => { - content += `- ${entity.name}\n`; - }); - content += `\n`; - } - - // Add topics if available - if (searchResponse.topTopics && searchResponse.topTopics.length > 0) { - content += `**Top Topics**\n\n`; - const topTopics = searchResponse.topTopics.slice(0, 5); - topTopics.forEach((topic: string) => { - content += `- ${topic}\n`; - }); - content += `\n`; - } - - // Add follow-up suggestions if available - if ( - searchResponse.suggestedFollowups && - searchResponse.suggestedFollowups.length > 0 - ) { - content += `**Suggested Follow-ups:**\n\n`; - searchResponse.suggestedFollowups.forEach((followup: string) => { - content += `- ${followup}\n`; - }); - } - - return content; -} - -export function escapeHtml(unsafe: string): string { - return unsafe - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} diff --git a/ts/packages/agents/browser/src/agent/websiteMemory.mts b/ts/packages/agents/browser/src/agent/websiteMemory.mts index 9974a2cb72..be0ab122bf 100644 --- a/ts/packages/agents/browser/src/agent/websiteMemory.mts +++ b/ts/packages/agents/browser/src/agent/websiteMemory.mts @@ -16,7 +16,7 @@ import { BrowserActionContext } from "./browserActions.mjs"; import { searchWebMemories, SearchWebMemoriesRequest, -} from "./searchWebMemories.mjs"; +} from "./durableWebSearch.mjs"; import * as website from "@typeagent/website-memory"; import * as kpLib from "@typeagent/knowledge-processor"; import { openai as ai } from "@typeagent/aiclient"; @@ -30,7 +30,13 @@ import { ImportStateManager, ImportState, } from "./import/importStateManager.mjs"; -import * as path from "path"; + +function createWebsiteKnowledgeModel(role: string) { + return ai.createChatModel(ai.GPT_5_6_LUNA, undefined, undefined, [ + "website-knowledge", + role, + ]); +} function logStructuredProgress( current: number, @@ -137,12 +143,6 @@ export async function resolveURLWithHistory( ): Promise { debug(`Attempting to resolve '${site}' using website visit history`); - const websiteCollection = context.agentContext.websiteCollection; - if (!websiteCollection || websiteCollection.messages.length === 0) { - debug("No website collection available or empty"); - return undefined; - } - try { // Create SessionContext wrapper for searchWebMemories // Use minimal required fields - searchWebMemories only needs agentContext @@ -262,6 +262,9 @@ export async function importWebsiteDataFromSession( type: "websiteImport" as const, ...(parameters.url && { url: parameters.url }), }; + const importId = importContext.importId; + let importState: ImportState | undefined; + let persistedDuringExtraction = false; try { const { @@ -277,7 +280,7 @@ export async function importWebsiteDataFromSession( logStructuredProgress( 0, - 0, + limit ?? 0, `Preparing ${type} import from ${source}`, "initializing", importContext, @@ -361,17 +364,8 @@ export async function importWebsiteDataFromSession( // Create AI model for intelligent analysis if AI mode is enabled if (extractionMode !== "basic") { try { - const apiSettings = ai.azureApiSettingsFromEnv( - ai.ModelType.Chat, - undefined, - undefined, // Use default model - ); - const chatModel = ai.createChatModel( - apiSettings, - undefined, - undefined, - ["website-analysis"], - ); + const chatModel = + createWebsiteKnowledgeModel("bookmark-import"); // Create knowledge extractor for ContentExtractor importOptions.knowledgeExtractor = @@ -414,6 +408,20 @@ export async function importWebsiteDataFromSession( ); if (metadataWebsites.length > 0) { + importState = { + importId, + totalWebsites: metadataWebsites.length, + processedWebsites: 0, + lastSavePoint: 0, + failedUrls: [], + startTime: Date.now(), + lastProgressTime: Date.now(), + extractionMode, + source, + type, + filePath, + }; + await ImportStateManager.saveImportState(importState); logStructuredProgress( 0, metadataWebsites.length, @@ -525,6 +533,7 @@ export async function importWebsiteDataFromSession( const batchProcessor = new website.BatchProcessor( extractor, ); + let persistedCount = 0; const extractionProgressCallback = ( progress: BatchProgress, @@ -548,20 +557,37 @@ export async function importWebsiteDataFromSession( { processingMode: "batch", progressCallback: extractionProgressCallback, + itemCompleteCallback: async (result, index) => { + const metaSite = metadataWebsites[index]; + const completedWebsite: any = { + ...metaSite, + knowledge: result.knowledge, + textChunks: result.pageContent?.mainContent + ? [result.pageContent.mainContent] + : metaSite.textChunks || [], + }; + websites[index] = completedWebsite; + await ingestWebsitesIntoMemoryService( + [completedWebsite], + extractionMode, + context.agentContext, + importContext, + index, + metadataWebsites.length, + ); + persistedDuringExtraction = true; + persistedCount++; + importState!.processedWebsites = persistedCount; + importState!.lastSavePoint = persistedCount; + importState!.lastProgressTime = Date.now(); + await ImportStateManager.saveImportState( + importState!, + ); + }, }, ); - // Build complete website objects from extraction results - websites = metadataWebsites.map((metaSite, index) => { - const result = extractionResults[index]; - return { - ...metaSite, - knowledge: result?.knowledge, - textChunks: result?.pageContent?.mainContent - ? [result.pageContent.mainContent] - : metaSite.textChunks || [], - }; - }); + websites = websites.filter((item) => item !== undefined); logStructuredProgress( extractionResults.length, @@ -586,37 +612,34 @@ export async function importWebsiteDataFromSession( } } - if (!context.agentContext.websiteCollection) { - context.agentContext.websiteCollection = - new website.WebsiteCollection(); - } - - //Set up periodic persistence - const importId = importContext.importId; - const chunkSize = Math.min(50, Math.ceil(websites.length * 0.2)); + // Set up periodic durable-ingestion checkpoints. + const pendingWebsites = persistedDuringExtraction ? [] : websites; + const chunkSize = Math.min(50, Math.ceil(pendingWebsites.length * 0.2)); const savePoints = ImportStateManager.calculateSavePoints( websites.length, ); let currentSavePointIndex = 0; // Initialize import state - const importState: ImportState = { - importId, - totalWebsites: websites.length, - processedWebsites: 0, - lastSavePoint: 0, - failedUrls: [], - startTime: Date.now(), - lastProgressTime: Date.now(), - extractionMode, - source, - type, - filePath, - }; - await ImportStateManager.saveImportState(importState); + if (importState === undefined) { + importState = { + importId, + totalWebsites: websites.length, + processedWebsites: 0, + lastSavePoint: 0, + failedUrls: [], + startTime: Date.now(), + lastProgressTime: Date.now(), + extractionMode, + source, + type, + filePath, + }; + await ImportStateManager.saveImportState(importState); + } - for (let i = 0; i < websites.length; i += chunkSize) { - const chunk = websites.slice(i, i + chunkSize); + for (let i = 0; i < pendingWebsites.length; i += chunkSize) { + const chunk = pendingWebsites.slice(i, i + chunkSize); const chunkIndex = Math.floor(i / chunkSize) + 1; const totalChunks = Math.ceil(websites.length / chunkSize); const processedCount = i + chunk.length; @@ -624,8 +647,8 @@ export async function importWebsiteDataFromSession( logStructuredProgress( processedCount, websites.length, - `Building knowledge graph (chunk ${chunkIndex}/${totalChunks})`, - "graph-building", + `Persisting durable memory (chunk ${chunkIndex}/${totalChunks})`, + "persisting", importContext, undefined, // summary undefined, // itemDetails @@ -645,40 +668,6 @@ export async function importWebsiteDataFromSession( websites.length, ); - context.agentContext.websiteCollection.addWebsites(chunk); - - try { - await context.agentContext.websiteCollection.addToIndex(); - } catch (error) { - debug( - `Incremental indexing failed, falling back to full rebuild: ${error}`, - ); - await context.agentContext.websiteCollection.buildIndex(); - } - - await context.agentContext.websiteCollection.updateGraphIncremental( - chunk, - ); - - try { - const topicsCount = chunk.filter( - (site) => site.knowledge?.topics?.length > 0, - ).length; - if (topicsCount > 0) { - await context.agentContext.websiteCollection.updateHierarchicalTopics( - chunk, - ); - debug( - `Updated hierarchical topics for ${topicsCount} websites in chunk ${chunkIndex}/${totalChunks}`, - ); - } - } catch (error) { - console.warn( - "Failed to update hierarchical topics during import:", - error, - ); - } - // Check if we should save progress if ( currentSavePointIndex < savePoints.length && @@ -699,22 +688,6 @@ export async function importWebsiteDataFromSession( ); try { - // Save WebsiteCollection to backup location - if (context.agentContext.index?.path) { - const backupPath = - ImportStateManager.getCollectionBackupPath( - importId, - processedCount, - ); - await context.agentContext.websiteCollection.writeToFile( - path.dirname(backupPath), - path.basename(backupPath, ".json"), - ); - debug( - `Saved website collection backup to ${backupPath}`, - ); - } - // Update import state importState.processedWebsites = processedCount; importState.lastSavePoint = processedCount; @@ -745,29 +718,14 @@ export async function importWebsiteDataFromSession( } } - // Entity processing is now handled by the website-memory package integration debug(`Website import completed for ${websites.length} websites`); - // Final save and cleanup + // Durable ingestion has already persisted every completed chunk. try { - if (context.agentContext.index?.path) { - await context.agentContext.websiteCollection.writeToFile( - context.agentContext.index.path, - "index", - ); - debug( - `Saved website collection to ${context.agentContext.index.path}`, - ); - } else { - debug("No index path available, website data not persisted"); - } - - // Clean up import state and backups await ImportStateManager.deleteImportState(importId); - await ImportStateManager.cleanupOldBackups(importId); - debug(`Cleaned up import state and backups for ${importId}`); + debug(`Cleaned up import state for ${importId}`); } catch (error) { - debug(`Failed to save website collection or cleanup: ${error}`); + debug(`Failed to clean up import state: ${error}`); } // Calculate knowledge statistics for the completion event @@ -890,40 +848,6 @@ export async function importHtmlFolderFromSession( // Initialize import options for folder processing const importOptions: any = {}; - // For AI-enabled modes, validate AI availability before starting import - if (extractionMode !== "basic") { - try { - // Create and validate the knowledge extractor (same logic as BrowserKnowledgeExtractor) - const apiSettings = ai.azureApiSettingsFromEnv( - ai.ModelType.Chat, - ); - const languageModel = ai.createChatModel(apiSettings); - const knowledgeExtractor = - kpLib.conversation.createKnowledgeExtractor(languageModel); - - // Validate that the knowledge extractor works by testing extraction - const testResult = await knowledgeExtractor.extract( - "test content for validation", - ); - if (!testResult) { - throw new Error("Knowledge extractor validation failed"); - } - - // Store the validated knowledge extractor in import options - importOptions.knowledgeExtractor = knowledgeExtractor; - } catch (error) { - if (error instanceof AIModelRequiredError) { - throw new Error( - `Cannot import HTML folder with ${extractionMode} mode: ${error.message}`, - ); - } else { - throw new Error( - `AI model initialization failed for ${extractionMode} mode: ${(error as Error).message}. Please check AI model configuration or use 'basic' mode.`, - ); - } - } - } - // Validate folder path first const validation = await validateHtmlFolder(folderPath, options); if (!validation.valid) { @@ -954,10 +878,11 @@ export async function importHtmlFolderFromSession( importContext, ); - // Ensure we have a website collection - if (!context.agentContext.websiteCollection) { - context.agentContext.websiteCollection = - new website.WebsiteCollection(); + if (extractionMode !== "basic") { + const languageModel = + createWebsiteKnowledgeModel("html-folder-import"); + importOptions.knowledgeExtractor = + kpLib.conversation.createKnowledgeExtractor(languageModel); } // Process files in batches for better performance and progress reporting @@ -1126,8 +1051,8 @@ export async function importHtmlFolderFromSession( logStructuredProgress( i + chunk.length, websites.length, - `Building knowledge graph (chunk ${chunkIndex}/${totalChunks})`, - "graph-building", + `Persisting durable memory (chunk ${chunkIndex}/${totalChunks})`, + "persisting", importContext, ); @@ -1139,67 +1064,9 @@ export async function importHtmlFolderFromSession( i, websites.length, ); - - context.agentContext.websiteCollection.addWebsites(chunk); - - try { - await context.agentContext.websiteCollection.addToIndex(); - } catch (error) { - debug( - `Incremental indexing failed, falling back to full rebuild: ${error}`, - ); - await context.agentContext.websiteCollection.buildIndex(); - } - - await context.agentContext.websiteCollection.updateGraphIncremental( - chunk, - ); - - try { - const topicsCount = chunk.filter( - (site) => site.knowledge?.topics?.length > 0, - ).length; - if (topicsCount > 0) { - await context.agentContext.websiteCollection.updateHierarchicalTopics( - chunk, - ); - debug( - `Updated hierarchical topics for ${topicsCount} websites in chunk ${chunkIndex}/${totalChunks}`, - ); - } - } catch (error) { - console.warn( - "Failed to update hierarchical topics during HTML folder import:", - error, - ); - } } - // Entity processing is now handled by the website-memory package integration debug(`HTML file import completed for ${websites.length} files`); - - try { - if (context.agentContext.index?.path) { - await context.agentContext.websiteCollection.writeToFile( - context.agentContext.index.path, - "index", - ); - debug( - `Saved website collection with ${successCount} new files to ${context.agentContext.index.path}`, - ); - } else { - debug( - "No index path available, HTML folder data not persisted", - ); - } - } catch (error) { - debug(`Failed to save website collection: ${error}`); - errors.push({ - type: "persistence", - message: `Failed to save data: ${(error as Error).message}`, - timestamp: Date.now(), - }); - } } const duration = Date.now() - startTime; @@ -1265,6 +1132,7 @@ export async function importHtmlFolderFromSession( summary: summaryStats, }; } catch (error: any) { + logStructuredProgress(0, 0, error.message, "error", importContext); return { success: false, importId: parameters.importId, @@ -1441,9 +1309,15 @@ export async function getWebsiteStats( action: TypeAgentAction, ) { try { - const websiteCollection = - context.sessionContext.agentContext.websiteCollection; - if (!websiteCollection || websiteCollection.messages.length === 0) { + const memory = context.sessionContext.agentContext.browserMemoryService; + if (memory === undefined) { + return createActionResult( + "Durable browser memory is not available.", + true, + ); + } + const sources = await memory.listSources(); + if (sources.length === 0) { return createActionResult( "No website data available. Please import website data first.", true, @@ -1451,27 +1325,26 @@ export async function getWebsiteStats( } const { groupBy = "domain", limit = 10 } = action.parameters || {}; - const websites = websiteCollection.messages.getAll(); const stats: { [key: string]: number } = {}; - const totalCount = websites.length; + const totalCount = sources.length; - for (const site of websites) { - const metadata = site.metadata as website.WebsiteDocPartMeta; + for (const source of sources) { + const metadata = source.metadata ?? {}; let key: string; switch (groupBy) { case "domain": - key = metadata.domain || "unknown"; + key = String(metadata.domain || "unknown"); break; case "pageType": - key = metadata.pageType || "general"; + key = String(metadata.pageType || "general"); break; case "source": - key = metadata.websiteSource; + key = String(metadata.source || "unknown"); break; default: - key = metadata.domain || "unknown"; + key = String(metadata.domain || "unknown"); } stats[key] = (stats[key] || 0) + 1; @@ -1493,10 +1366,15 @@ export async function getWebsiteStats( // Add some additional stats if (groupBy !== "source") { const sourceCounts = { bookmark: 0, history: 0, reading_list: 0 }; - for (const site of websites) { - sourceCounts[ - (site.metadata as website.WebsiteDocPartMeta).websiteSource - ]++; + for (const item of sources) { + const source = item.metadata?.source; + if ( + source === "bookmark" || + source === "history" || + source === "reading_list" + ) { + sourceCounts[source]++; + } } resultText += `\nBy Source:\n`; for (const [source, count] of Object.entries(sourceCounts)) { diff --git a/ts/packages/agents/browser/test/browserInitOptions.test.ts b/ts/packages/agents/browser/test/browserInitOptions.test.ts index f6da2ece4e..6eeead049b 100644 --- a/ts/packages/agents/browser/test/browserInitOptions.test.ts +++ b/ts/packages/agents/browser/test/browserInitOptions.test.ts @@ -1,8 +1,12 @@ // 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 { + createBrowserControlRpcFacade, + type BrowserControl, +} from "@typeagent/browser-control-rpc/types"; +import { createMemoryServiceRpcFacade } from "@typeagent/memory-service/rpc"; +import type { MemoryService } from "@typeagent/memory-service"; import { normalizeBrowserAgentInitOptions } from "../src/agent/browserActions.mjs"; describe("normalizeBrowserAgentInitOptions", () => { @@ -29,4 +33,29 @@ describe("normalizeBrowserAgentInitOptions", () => { test("uses external browser control when no options are supplied", () => { expect(normalizeBrowserAgentInitOptions(undefined)).toEqual({}); }); + + test("creates plain facades that preserve method receivers", async () => { + const browserControl = Object.create({ + getPageUrl() { + return Promise.resolve(this.url); + }, + }) as BrowserControl & { url: string }; + browserControl.url = "https://example.com"; + const memoryService = Object.create({ + listCorpora() { + return Promise.resolve(this.corpora); + }, + }) as MemoryService & { corpora: [] }; + memoryService.corpora = []; + + const browserFacade = createBrowserControlRpcFacade(browserControl); + const memoryFacade = createMemoryServiceRpcFacade(memoryService); + + expect(Object.getPrototypeOf(browserFacade)).toBe(Object.prototype); + expect(Object.getPrototypeOf(memoryFacade)).toBe(Object.prototype); + await expect(browserFacade.getPageUrl()).resolves.toBe( + "https://example.com", + ); + await expect(memoryFacade.listCorpora()).resolves.toEqual([]); + }); }); diff --git a/ts/packages/agents/browser/test/browserMemoryService.test.ts b/ts/packages/agents/browser/test/browserMemoryService.test.ts index 3439e1bbd2..b4d57c2f4b 100644 --- a/ts/packages/agents/browser/test/browserMemoryService.test.ts +++ b/ts/packages/agents/browser/test/browserMemoryService.test.ts @@ -20,6 +20,7 @@ function createClient(): jest.Mocked { documentCount: 0, })), listCorpora: jest.fn(async () => []), + clearCorpus: jest.fn(async () => 0), listSources: jest.fn(async () => []), getSource: jest.fn(), ingestDocument: jest.fn(async () => ({ @@ -29,7 +30,17 @@ function createClient(): jest.Mocked { state: "accepted", statusUri: "typeagent-memory://jobs/job-1", })), - getJob: jest.fn(), + getJob: 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: [], + })), cancelJob: jest.fn(), search: jest.fn(async (request) => ({ query: request.query, @@ -88,29 +99,26 @@ describe("BrowserMemoryService", () => { .sourceId as string; expect(firstSourceId).toMatch(/^web:[a-f0-9]{64}$/); expect(secondSourceId).toBe(firstSourceId); - expect(client.waitForJob).toHaveBeenCalledTimes(2); + expect(client.getJob).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?.({ + client.getJob.mockResolvedValue({ + jobId: "job-1", + corpusId: "browser-corpus", + sourceId: "source-1", + revisionId: "revision-1", + state: "complete", + progress: { 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: [], - }; + }, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + warnings: [], }); await new BrowserMemoryService(client).ingest( @@ -144,6 +152,15 @@ describe("BrowserMemoryService", () => { expect(client.getKnowledgeGraph).toHaveBeenCalledWith("browser-corpus"); }); + test("clears the durable browser corpus", async () => { + const client = createClient(); + client.clearCorpus.mockResolvedValue(3); + + await expect(new BrowserMemoryService(client).clear()).resolves.toBe(3); + + expect(client.clearCorpus).toHaveBeenCalledWith("browser-corpus"); + }); + test("translates URL, metadata, and date filters to source IDs", async () => { const client = createClient(); const matchingSource: MemorySource = { diff --git a/ts/packages/agents/browser/test/search/queryEnhancement.test.ts b/ts/packages/agents/browser/test/search/queryEnhancement.test.ts deleted file mode 100644 index daa17b2ded..0000000000 --- a/ts/packages/agents/browser/test/search/queryEnhancement.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { QueryEnhancementAdapter } from "../../src/agent/search/queryEnhancementAdapter.mjs"; - -describe("QueryEnhancementAdapter - Target Queries", () => { - let adapter: QueryEnhancementAdapter; - - beforeEach(() => { - adapter = new QueryEnhancementAdapter(); - }); - - describe("Target Query Enhancement", () => { - const testCases = [ - { - query: "most recently bookmarked github repo", - description: - "Should optimize query and apply github + bookmark filters", - }, - { - query: "summarize car reviews last week", - description: "Should optimize query and apply temporal filter", - }, - { - query: "most often visited news site", - description: "Should optimize query for frequency ranking", - }, - { - query: "earliest transformers article bookmarked", - description: - "Should optimize query and apply earliest + bookmark filters", - }, - { - query: "car review I read last month", - description: "Should optimize query and apply temporal filter", - }, - ]; - - testCases.forEach(({ query, description }) => { - it(`should enhance: "${query}"`, async () => { - const request = { - query, - limit: 20, - enableAdvancedSearch: true, - generateAnswer: true, - }; - - // Test that enhancement doesn't throw errors - const enhanced = await adapter.enhanceSearchRequest( - request, - {}, - ); - - expect(enhanced).toBeTruthy(); - expect(enhanced.query).toBeTruthy(); - expect(typeof enhanced.query).toBe("string"); - - // Should have metadata with analysis - const analysis = (enhanced as any).metadata?.analysis; - if (analysis) { - expect(analysis.intent).toBeTruthy(); - expect(analysis.intent.type).toBeTruthy(); - expect(analysis.confidence).toBeGreaterThan(0); - } - - console.log(`Enhanced "${query}":`); - console.log(` Original: "${request.query}"`); - console.log(` Optimized: "${enhanced.query}"`); - if (enhanced.domain) - console.log(` Domain filter: ${enhanced.domain}`); - if (enhanced.source) - console.log(` Source filter: ${enhanced.source}`); - if (enhanced.dateFrom) - console.log( - ` Date range: ${enhanced.dateFrom} to ${enhanced.dateTo}`, - ); - if (analysis) { - console.log( - ` Intent: ${analysis.intent.type} (confidence: ${analysis.confidence})`, - ); - if (analysis.ranking) - console.log( - ` Ranking: ${analysis.ranking.primaryFactor} ${analysis.ranking.direction}`, - ); - } - console.log(""); - }); - }); - }); - - describe("Simple Queries", () => { - const simpleQueries = [ - "machine learning", - "react tutorial", - "python documentation", - ]; - - simpleQueries.forEach((query) => { - it(`should handle simple query: "${query}"`, async () => { - const request = { query, limit: 20 }; - - // Should not throw even for simple queries - const enhanced = await adapter.enhanceSearchRequest( - request, - {}, - ); - - expect(enhanced).toBeTruthy(); - expect(enhanced.query).toBeTruthy(); - - console.log(`Simple query "${query}" -> "${enhanced.query}"`); - }); - }); - }); -}); diff --git a/ts/packages/agents/browserControlRpc/src/browserControl.ts b/ts/packages/agents/browserControlRpc/src/browserControl.ts index 469b3b66c7..1b2f772ad1 100644 --- a/ts/packages/agents/browserControlRpc/src/browserControl.ts +++ b/ts/packages/agents/browserControlRpc/src/browserControl.ts @@ -91,6 +91,52 @@ export type BrowserControlCallFunctions = { export type BrowserControl = BrowserControlInvokeFunctions & BrowserControlCallFunctions; +export function createBrowserControlRpcFacade( + browserControl: BrowserControl, +): BrowserControl { + return { + openWebPage: (...args) => browserControl.openWebPage(...args), + closeWebPage: (...args) => browserControl.closeWebPage(...args), + closeAllWebPages: (...args) => browserControl.closeAllWebPages(...args), + goForward: (...args) => browserControl.goForward(...args), + goBack: (...args) => browserControl.goBack(...args), + reload: (...args) => browserControl.reload(...args), + getPageUrl: (...args) => browserControl.getPageUrl(...args), + scrollUp: (...args) => browserControl.scrollUp(...args), + scrollDown: (...args) => browserControl.scrollDown(...args), + zoomIn: (...args) => browserControl.zoomIn(...args), + zoomOut: (...args) => browserControl.zoomOut(...args), + zoomReset: (...args) => browserControl.zoomReset(...args), + followLinkByText: (...args) => browserControl.followLinkByText(...args), + followLinkByPosition: (...args) => + browserControl.followLinkByPosition(...args), + closeWindow: (...args) => browserControl.closeWindow(...args), + search: (...args) => browserControl.search(...args), + switchTabs: (...args) => browserControl.switchTabs(...args), + readPageContent: (...args) => browserControl.readPageContent(...args), + stopReadPageContent: (...args) => + browserControl.stopReadPageContent(...args), + captureScreenshot: (...args) => + browserControl.captureScreenshot(...args), + getPageTextContent: (...args) => + browserControl.getPageTextContent(...args), + getAutoIndexSetting: (...args) => + browserControl.getAutoIndexSetting(...args), + getBrowserSettings: (...args) => + browserControl.getBrowserSettings(...args), + getHtmlFragments: (...args) => browserControl.getHtmlFragments(...args), + clickOn: (...args) => browserControl.clickOn(...args), + setDropdown: (...args) => browserControl.setDropdown(...args), + enterTextIn: (...args) => browserControl.enterTextIn(...args), + awaitPageLoad: (...args) => browserControl.awaitPageLoad(...args), + awaitPageInteraction: (...args) => + browserControl.awaitPageInteraction(...args), + downloadImage: (...args) => browserControl.downloadImage(...args), + runBrowserAction: (...args) => browserControl.runBrowserAction(...args), + setAgentStatus: (...args) => browserControl.setAgentStatus(...args), + }; +} + export type SearchProvider = { name: string; url: string; 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 13bf205465..f915b1e980 100644 --- a/ts/packages/agents/browserExtension/src/extension/interfaces/websiteImport.types.ts +++ b/ts/packages/agents/browserExtension/src/extension/interfaces/websiteImport.types.ts @@ -168,8 +168,11 @@ export type ErrorCallback = (error: ImportError) => void; // Chrome extension message types export interface ImportWebsiteDataMessage { type: "importWebsiteDataWithProgress"; - parameters: ImportOptions; - importId: string; + parameters: ImportOptions & { + importId: string; + totalItems?: number; + progressCallback?: boolean; + }; } export interface ImportHtmlFolderMessage { diff --git a/ts/packages/agents/browserExtension/src/extension/offscreen/contentProcessor.ts b/ts/packages/agents/browserExtension/src/extension/offscreen/contentProcessor.ts index c8fb653ffc..a3746191c1 100644 --- a/ts/packages/agents/browserExtension/src/extension/offscreen/contentProcessor.ts +++ b/ts/packages/agents/browserExtension/src/extension/offscreen/contentProcessor.ts @@ -24,6 +24,8 @@ import { export class OffscreenContentProcessor { private readonly maxLoadTime: number = 45000; private currentlyProcessing: boolean = false; + private activeMessageId: string | undefined; + private activeController: AbortController | undefined; private processedCount: number = 0; private readonly logElement: HTMLElement; private readonly statusElement: HTMLElement; @@ -101,6 +103,22 @@ export class OffscreenContentProcessor { }); break; + case "cancel": { + const cancelled = + message.targetMessageId === this.activeMessageId; + if (cancelled) { + this.activeController?.abort( + new Error("Offscreen operation cancelled"), + ); + } + sendResponse({ + success: true, + data: { cancelled }, + messageId: message.messageId || "", + }); + break; + } + default: this.log( "warn", @@ -130,6 +148,8 @@ export class OffscreenContentProcessor { } this.currentlyProcessing = true; + this.activeMessageId = message.messageId; + this.activeController = new AbortController(); this.updateUI("processing", `Downloading: ${message.url}`); const startTime = Date.now(); @@ -137,6 +157,7 @@ export class OffscreenContentProcessor { const result = await this.processUrl( message.url!, (message.options as DownloadOptions) || {}, + this.activeController, ); this.processedCount++; @@ -153,6 +174,8 @@ export class OffscreenContentProcessor { }; } finally { this.currentlyProcessing = false; + this.activeMessageId = undefined; + this.activeController = undefined; } } @@ -196,16 +219,20 @@ export class OffscreenContentProcessor { /** * Process URL by using fetch + DOM parsing (cross-origin compatible) */ - async processUrl(url: string, options: DownloadOptions): Promise { + async processUrl( + url: string, + options: DownloadOptions, + controller: AbortController = new AbortController(), + ): Promise { const loadStartTime = Date.now(); + let timeoutId: ReturnType | undefined; this.log("info", `Starting URL processing: ${url}`); try { this.currentUrlElement.textContent = url; this.log("info", `Fetching content from ${url}...`); - const controller = new AbortController(); - const timeoutId = setTimeout( + timeoutId = setTimeout( () => controller.abort(), options.timeout || this.maxLoadTime, ); @@ -218,8 +245,6 @@ export class OffscreenContentProcessor { }, }); - clearTimeout(timeoutId); - if (!response.ok) { throw new Error( `HTTP ${response.status}: ${response.statusText}`, @@ -264,6 +289,11 @@ export class OffscreenContentProcessor { throw new Error( `URL processing failed: ${error?.message || "Unknown error"}`, ); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + this.currentUrlElement.textContent = ""; } } diff --git a/ts/packages/agents/browserExtension/src/extension/offscreen/types.ts b/ts/packages/agents/browserExtension/src/extension/offscreen/types.ts index abc94a7e19..373b8d6c3e 100644 --- a/ts/packages/agents/browserExtension/src/extension/offscreen/types.ts +++ b/ts/packages/agents/browserExtension/src/extension/offscreen/types.ts @@ -146,12 +146,13 @@ export interface ServiceWorkerMessage { * Messages for offscreen document communication */ export interface OffscreenMessage { - type: "downloadContent" | "processHtmlContent" | "ping"; + type: "downloadContent" | "processHtmlContent" | "cancel" | "ping"; url?: string; htmlContent?: string; filePath?: string; options?: DownloadOptions | ProcessingOptions; messageId?: string; + targetMessageId?: string; } /** diff --git a/ts/packages/agents/browserExtension/src/extension/serviceWorker/chromeRpcServer.ts b/ts/packages/agents/browserExtension/src/extension/serviceWorker/chromeRpcServer.ts index 089f0192a6..835e7f42ff 100644 --- a/ts/packages/agents/browserExtension/src/extension/serviceWorker/chromeRpcServer.ts +++ b/ts/packages/agents/browserExtension/src/extension/serviceWorker/chromeRpcServer.ts @@ -24,12 +24,14 @@ export function createChromeRpcServer< callHandlers?: CallHandlers, ): { adapter: ChannelAdapter; rpc: ReturnType } { const adapter = createChannelAdapter((message: any) => { - chrome.runtime.sendMessage({ type: "rpc", message }).catch(() => {}); + chrome.runtime + .sendMessage({ type: "rpc", target: "view", message }) + .catch(() => {}); }); chrome.runtime.onMessage.addListener( (msg: any, _sender: chrome.runtime.MessageSender) => { - if (msg.type === "rpc") { + if (msg.type === "rpc" && msg.target === "serviceWorker") { adapter.notifyMessage(msg.message); } }, diff --git a/ts/packages/agents/browserExtension/src/extension/serviceWorker/contentDownloader.ts b/ts/packages/agents/browserExtension/src/extension/serviceWorker/contentDownloader.ts index b23605d216..29b42be46e 100644 --- a/ts/packages/agents/browserExtension/src/extension/serviceWorker/contentDownloader.ts +++ b/ts/packages/agents/browserExtension/src/extension/serviceWorker/contentDownloader.ts @@ -17,6 +17,8 @@ import { */ export class BrowserContentDownloader implements ContentDownloadAdapter { private offscreenCreated: boolean = false; + private offscreenReadyPromise: Promise | undefined; + private operationQueue: Promise = Promise.resolve(); private readonly maxRetries: number = 3; private readonly defaultTimeout: number = 5000; private readonly minTimeout: number = 1000; @@ -43,7 +45,9 @@ export class BrowserContentDownloader implements ContentDownloadAdapter { const startTime = Date.now(); try { - return await this.downloadUsingBrowser(url, options); + return await this.enqueueOffscreenOperation(() => + this.downloadUsingBrowser(url, options), + ); } catch (error: any) { // Try fallback if enabled if (options.fallbackToFetch) { @@ -124,9 +128,12 @@ export class BrowserContentDownloader implements ContentDownloadAdapter { error?.message || "Unknown error", ); - if (attempt >= this.maxRetries) { + if ( + attempt >= this.maxRetries || + !this.isRetryableError(error) + ) { throw new Error( - `Browser download failed after ${this.maxRetries} attempts: ${error?.message || "Unknown error"}`, + `Browser download failed after ${attempt} attempt${attempt === 1 ? "" : "s"}: ${error?.message || "Unknown error"}`, ); } @@ -138,6 +145,21 @@ export class BrowserContentDownloader implements ContentDownloadAdapter { throw new Error("Max retries exceeded"); } + private isRetryableError(error: unknown): boolean { + const message = + error instanceof Error ? error.message : String(error ?? ""); + const status = /HTTP\s+(\d{3})/i.exec(message)?.[1]; + if (status !== undefined) { + const statusCode = Number(status); + return ( + statusCode === 408 || statusCode === 429 || statusCode >= 500 + ); + } + return !/invalid url|authentication failed|unauthorized|forbidden|content too large/i.test( + message, + ); + } + /** * Fallback to standard fetch method */ @@ -219,37 +241,41 @@ export class BrowserContentDownloader implements ContentDownloadAdapter { * Ensure offscreen document is created and ready */ private async ensureOffscreenDocument(): Promise { - if (!this.offscreenCreated) { - try { - // Check if offscreen document already exists - const existingContexts = await ( - chrome.runtime as any - ).getContexts({ - contextTypes: ["OFFSCREEN_DOCUMENT"], - }); - - if (existingContexts.length === 0) { - await (chrome as any).offscreen.createDocument({ - url: "offscreen/offscreen.html", - reasons: ["DOM_PARSER"] as any, - justification: - "Process HTML content with DOM access for enhanced import functionality", - }); - - // Wait a moment for the document to initialize - await this.delay(1000); - } + if (this.offscreenCreated) { + return; + } + this.offscreenReadyPromise ??= this.createAndVerifyOffscreenDocument(); + try { + await this.offscreenReadyPromise; + } catch (error) { + this.offscreenReadyPromise = undefined; + throw error; + } + } - this.offscreenCreated = true; + private async createAndVerifyOffscreenDocument(): Promise { + try { + const existingContexts = await (chrome.runtime as any).getContexts({ + contextTypes: ["OFFSCREEN_DOCUMENT"], + }); - // Test communication with offscreen document - await this.pingOffscreen(); - } catch (error: any) { - this.offscreenCreated = false; - throw new Error( - `Failed to create offscreen document: ${error?.message || "Offscreen API not available"}`, - ); + if (existingContexts.length === 0) { + await (chrome as any).offscreen.createDocument({ + url: "offscreen/offscreen.html", + reasons: ["DOM_PARSER"] as any, + justification: + "Process HTML content with DOM access for enhanced import functionality", + }); + await this.delay(1000); } + + await this.pingOffscreen(); + this.offscreenCreated = true; + } catch (error: any) { + this.offscreenCreated = false; + throw new Error( + `Failed to create offscreen document: ${error?.message || "Offscreen API not available"}`, + ); } } @@ -263,8 +289,12 @@ export class BrowserContentDownloader implements ContentDownloadAdapter { const messageId = `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; return new Promise((resolve, reject) => { + let settled = false; const timeoutId = setTimeout(() => { - reject(new Error("Offscreen communication timeout")); + settled = true; + void this.cancelOffscreen(messageId).finally(() => { + reject(new Error("Offscreen communication timeout")); + }); }, this.sanitizeTimeout(timeout)); chrome.runtime @@ -274,8 +304,18 @@ export class BrowserContentDownloader implements ContentDownloadAdapter { messageId, }) .then((response: MessageResponse) => { + if (settled) { + return; + } + settled = true; clearTimeout(timeoutId); - if (response) { + if (response?.messageId !== messageId) { + reject( + new Error( + `Unexpected offscreen response '${response?.messageId || "missing"}' for '${messageId}'`, + ), + ); + } else if (response) { resolve(response); } else { reject( @@ -284,12 +324,38 @@ export class BrowserContentDownloader implements ContentDownloadAdapter { } }) .catch((error) => { + if (settled) { + return; + } + settled = true; clearTimeout(timeoutId); reject(error); }); }); } + private async cancelOffscreen(targetMessageId: string): Promise { + const messageId = `cancel_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + const response = (await chrome.runtime.sendMessage({ + type: "cancel", + target: "offscreen", + messageId, + targetMessageId, + })) as MessageResponse; + if (response?.messageId !== messageId || response.success !== true) { + await this.resetOffscreenDocument(); + } + } + + private async resetOffscreenDocument(): Promise { + try { + await (chrome as any).offscreen.closeDocument(); + } finally { + this.offscreenCreated = false; + this.offscreenReadyPromise = undefined; + } + } + /** * Test offscreen document connectivity */ @@ -342,16 +408,17 @@ export class BrowserContentDownloader implements ContentDownloadAdapter { options: ProcessingOptions = {}, ): Promise { try { - await this.ensureOffscreenDocument(); - - const result = await this.sendToOffscreen( - { - type: "processHtmlContent", - htmlContent, - options, - }, - 30000, - ); // Fixed timeout instead of using options.timeout + const result = await this.enqueueOffscreenOperation(async () => { + await this.ensureOffscreenDocument(); + return this.sendToOffscreen( + { + type: "processHtmlContent", + htmlContent, + options, + }, + 30000, + ); + }); if (result.success) { return result.data; @@ -375,6 +442,7 @@ export class BrowserContentDownloader implements ContentDownloadAdapter { try { await (chrome as any).offscreen.closeDocument(); this.offscreenCreated = false; + this.offscreenReadyPromise = undefined; } catch (error: any) { console.warn( "Failed to close offscreen document:", @@ -412,6 +480,17 @@ export class BrowserContentDownloader implements ContentDownloadAdapter { private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + + private enqueueOffscreenOperation( + operation: () => Promise, + ): Promise { + const result = this.operationQueue.then(operation, operation); + this.operationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } } /** diff --git a/ts/packages/agents/browserExtension/src/extension/serviceWorker/messageHandlers.ts b/ts/packages/agents/browserExtension/src/extension/serviceWorker/messageHandlers.ts index 6d8da8b192..39c7a71747 100644 --- a/ts/packages/agents/browserExtension/src/extension/serviceWorker/messageHandlers.ts +++ b/ts/packages/agents/browserExtension/src/extension/serviceWorker/messageHandlers.ts @@ -5,6 +5,7 @@ import { getTabHTMLFragments, CompressionMode } from "./capture"; import { sendActionToAgent } from "./websocket"; import { BrowserContentDownloader } from "./contentDownloader.js"; import type { KnowledgeExtractionProgress } from "../interfaces/knowledgeExtraction.types"; +import type { ImportWebsiteDataMessage } from "../interfaces/websiteImport.types"; import { broadcastEvent } from "./extensionEventHelpers"; // Store active extraction callbacks @@ -32,9 +33,10 @@ export function handleKnowledgeExtractionProgress( } // Website Library Panel handlers -export async function handleImportWebsiteDataWithProgress(message: any) { - const importId = message.importId; - const totalItems = message.totalItems || 0; +export async function handleImportWebsiteDataWithProgress( + message: ImportWebsiteDataMessage, +) { + const { importId, totalItems = 0 } = message.parameters; try { // Send initial progress update @@ -46,8 +48,6 @@ export async function handleImportWebsiteDataWithProgress(message: any) { errors: [], }); - const startTime = Date.now(); - const result = await sendActionToAgent({ actionName: "importWebsiteDataWithProgress", parameters: { @@ -65,18 +65,26 @@ export async function handleImportWebsiteDataWithProgress(message: any) { }, }); - // Send completion progress - sendProgressToUI(importId, { - importId, - phase: "complete", - totalItems: totalItems, - processedItems: totalItems, - errors: [], - }); + if (result.error || result.success === false) { + const error = result.error || "Website import failed"; + sendProgressToUI(importId, { + importId, + phase: "error", + totalItems, + processedItems: result.itemCount || 0, + errors: [ + { + type: "processing", + message: error, + timestamp: Date.now(), + }, + ], + }); + } return { - success: !result.error, - itemCount: result.itemCount || totalItems, + success: !result.error && result.success !== false, + itemCount: result.itemCount || 0, error: result.error, }; } catch (error) { @@ -119,21 +127,10 @@ export function sendProgressToUI(importId: string, progress: any) { ...progress, }; - // Send to all connected library panels via runtime messaging - try { - chrome.runtime - .sendMessage({ - type: "importProgress", - importId, - progress: structuredProgress, - }) - .catch((error) => { - // Handle case where no listeners are available - console.log("No listeners for progress update:", error); - }); - } catch (error) { - console.error("Failed to send progress to UI:", error); - } + broadcastEvent("importProgress", { + importId, + progress: structuredProgress, + }); } export async function handleClearWebsiteLibrary() { @@ -196,19 +193,12 @@ export async function handleImportHtmlFolder(message: any) { }); return { - success: !result.error, - itemCount: result.websiteCount || 0, + success: result.success !== false && !result.error, + itemCount: result.itemCount || 0, importId: importId, duration: result.duration || 0, errors: result.errors || [], - summary: { - totalProcessed: result.websiteCount || 0, - successfullyImported: result.websiteCount || 0, - knowledgeExtracted: result.knowledgeCount || 0, - entitiesFound: result.entityCount || 0, - topicsIdentified: result.topicCount || 0, - actionsDetected: result.actionCount || 0, - }, + summary: result.summary, }; } catch (error) { console.error("Folder import error:", error); diff --git a/ts/packages/agents/browserExtension/src/extension/views/chromeRpcClient.ts b/ts/packages/agents/browserExtension/src/extension/views/chromeRpcClient.ts index e3c25cc099..8077027944 100644 --- a/ts/packages/agents/browserExtension/src/extension/views/chromeRpcClient.ts +++ b/ts/packages/agents/browserExtension/src/extension/views/chromeRpcClient.ts @@ -26,12 +26,14 @@ export function createChromeRpcClient< callHandlers?: CallHandlers, ): { adapter: ChannelAdapter; rpc: ReturnType } { const adapter = createChannelAdapter((message: any) => { - chrome.runtime.sendMessage({ type: "rpc", message }).catch(() => {}); + chrome.runtime + .sendMessage({ type: "rpc", target: "serviceWorker", message }) + .catch(() => {}); }); chrome.runtime.onMessage.addListener( (msg: any, _sender: chrome.runtime.MessageSender) => { - if (msg.type === "rpc") { + if (msg.type === "rpc" && msg.target === "view") { adapter.notifyMessage(msg.message); } }, diff --git a/ts/packages/agents/browserExtension/src/extension/views/extensionServiceBase.ts b/ts/packages/agents/browserExtension/src/extension/views/extensionServiceBase.ts index a5aeacca94..3dc8cc933f 100644 --- a/ts/packages/agents/browserExtension/src/extension/views/extensionServiceBase.ts +++ b/ts/packages/agents/browserExtension/src/extension/views/extensionServiceBase.ts @@ -584,7 +584,7 @@ export abstract class ExtensionServiceBase { parameters: { ...options, // TODO: remove "type" from this dictionary. That will remove the need to wrap these values in a "parameters" object importId, - totalItems: 0, + totalItems: options.limit ?? 0, progressCallback: true, }, }); diff --git a/ts/packages/agents/browserExtension/src/extension/views/knowledgeLibrary.html b/ts/packages/agents/browserExtension/src/extension/views/knowledgeLibrary.html index 9922fe45eb..ce261c6eea 100644 --- a/ts/packages/agents/browserExtension/src/extension/views/knowledgeLibrary.html +++ b/ts/packages/agents/browserExtension/src/extension/views/knowledgeLibrary.html @@ -697,7 +697,7 @@