diff --git a/ts/packages/agents/browser/README.md b/ts/packages/agents/browser/README.md index 04f8c29b80..d435be9ac5 100644 --- a/ts/packages/agents/browser/README.md +++ b/ts/packages/agents/browser/README.md @@ -22,6 +22,19 @@ puppeteer helpers). ## Architecture +### Scoped graphs and Memory Hub navigation + +Memory Hub Explore supports entity, topic and maintained hierarchy graphs with +document/event filters, bounded generation-aware paging, coverage diagnostics and +exact evidence drilldown. Graph preparation happens separately from reads; hosts +without the scoped capability report it explicitly. Retained originals and event +bodies remain subject to current eligibility and suppression checks. + +Views use paginated cards and a separate detail reader, with build receipts in a +flyout. Grouped activity, Inbox navigation and responsive detail panels reuse +summary inventories rather than fetching every view's full evidence. These +navigation features do not depend on checkpoint or integrated-answer APIs. + ### Schema-defined questions The Memory hub's Questions workspace is a complete projection of versioned diff --git a/ts/packages/agents/browser/package.json b/ts/packages/agents/browser/package.json index 82a351e13a..00f4e9b15e 100644 --- a/ts/packages/agents/browser/package.json +++ b/ts/packages/agents/browser/package.json @@ -41,7 +41,7 @@ "test:local": "jest && npm run test:crossword && npm run test:views:gateway && npm run test:views:client", "test:pdf": "node --test test/pdf*.spec.mjs test/pdfExtraction/*.spec.mjs test/memoryImportHandlers.spec.mjs test/agentConnectionBoundary.spec.mjs", "test:views:client": "jest --runInBand --testMatch \"**/views/client/**/*.test.ts\"", - "test:views:gateway": "node --test test/viewGateway.test.mjs test/viewDraftGateway.test.mjs test/viewLifecycle.test.mjs test/memoryHub.test.mjs test/memoryHubQuery.test.mjs test/memoryHubSourceDependencies.test.mjs test/memoryHubCapture.test.mjs test/browserCaptureRpc.test.mjs test/memoryHubRunbookImports.test.mjs test/memoryHubWebAnalytics.test.mjs test/schemaQuestionRpc.test.mjs test/knowledgeRpc.test.mjs", + "test:views:gateway": "node --test test/viewGateway.test.mjs test/viewDraftGateway.test.mjs test/viewLifecycle.test.mjs test/memoryHub.test.mjs test/memoryHubQuery.test.mjs test/memoryHubGraph.test.mjs test/memoryHubScopedKnowledge.test.mjs test/memoryHubSourceDependencies.test.mjs test/memoryHubCapture.test.mjs test/browserCaptureRpc.test.mjs test/memoryHubRunbookImports.test.mjs test/memoryHubWebAnalytics.test.mjs test/schemaQuestionRpc.test.mjs test/knowledgeRpc.test.mjs", "tsc:agent": "tsc -p src/agent/tsconfig.json", "tsc:puppeteer": "tsc -p src/puppeteer/tsconfig.json", "tsc:views:client:typecheck": "tsc -p src/views/client/pdf/tsconfig.json && tsc -p src/views/client/library/tsconfig.json", diff --git a/ts/packages/agents/browser/src/agent/agentServiceHandlers.mts b/ts/packages/agents/browser/src/agent/agentServiceHandlers.mts index 64d3e1d4e0..c53206aa4e 100644 --- a/ts/packages/agents/browser/src/agent/agentServiceHandlers.mts +++ b/ts/packages/agents/browser/src/agent/agentServiceHandlers.mts @@ -17,6 +17,7 @@ import { createAutomationViewFunctions } from "./automationViewHandlers.mjs"; import { createMemoryHubFunctions } from "./memoryHub.mjs"; import { createMemoryHubQueryFunctions } from "./memoryHubQuery.mjs"; import { createMemoryHubExploreFunctions } from "./memoryHubExplore.mjs"; +import { createMemoryHubGraphFunctions } from "./memoryHubGraph.mjs"; import { createMemoryHubKnowledgeFunctions } from "./memoryHubKnowledge.mjs"; import { createMemoryHubSynthesizer } from "./memoryHubAnswer.mjs"; import { createMemoryHubCaptureFunctions } from "./memoryHubCapture.mjs"; @@ -48,6 +49,7 @@ export function createViewServiceDomain( createMemoryHubSynthesizer(), ), ...createMemoryHubExploreFunctions(service), + ...createMemoryHubGraphFunctions(service), ...createMemoryHubKnowledgeFunctions(service), ...createMemoryHubSourceOriginalFunctions(service), ...createMemoryHubViewUsageFunctions(service), diff --git a/ts/packages/agents/browser/src/agent/knowledge/utils/graphologyLayoutEngine.mts b/ts/packages/agents/browser/src/agent/knowledge/utils/graphologyLayoutEngine.mts index 73af68438a..5153cb379a 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/utils/graphologyLayoutEngine.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/utils/graphologyLayoutEngine.mts @@ -58,6 +58,7 @@ export interface GraphologyLayoutOptions { noverlapIterations?: number; targetViewportSize?: number; skipEdgeFiltering?: boolean; + preserveIsolatedNodes?: boolean; } const DEFAULT_OPTIONS: Required = { @@ -68,6 +69,7 @@ const DEFAULT_OPTIONS: Required = { noverlapIterations: 1000, targetViewportSize: 2000, skipEdgeFiltering: false, + preserveIsolatedNodes: false, }; const COMMUNITY_COLORS = [ @@ -230,7 +232,7 @@ export function buildGraphologyGraph( } } - if (isolatedNodes.length > 0) { + if (!opts.preserveIsolatedNodes && isolatedNodes.length > 0) { debug(`Removing ${isolatedNodes.length} isolated nodes (no edges)`); for (const node of isolatedNodes) { graph.dropNode(node); diff --git a/ts/packages/agents/browser/src/agent/memoryHubExplore.mts b/ts/packages/agents/browser/src/agent/memoryHubExplore.mts index 6e9870a38c..2d8eea32d2 100644 --- a/ts/packages/agents/browser/src/agent/memoryHubExplore.mts +++ b/ts/packages/agents/browser/src/agent/memoryHubExplore.mts @@ -2,7 +2,6 @@ // Licensed under the MIT License. import { createHash } from "node:crypto"; -import { conversationCorpusName } from "@typeagent/memory-service"; import type { MemoryHubExploreResult, MemoryHubFunctions, @@ -14,6 +13,80 @@ import { timed, type MemoryHubReadService, } from "./memoryHubQuery.mjs"; +import { readScopedKnowledge } from "./memoryHubScopedKnowledge.mjs"; + +async function scopedOverview( + service: MemoryHubReadService, + corpusId: string, + result: MemoryHubExploreResult, +) { + const [entities, topics] = await Promise.all([ + readScopedKnowledge(service, corpusId, "entities"), + readScopedKnowledge(service, corpusId, "topics"), + ]); + if (!entities || !topics) + throw new Error("Scoped knowledge capability is unavailable."); + for (const inventory of [entities, topics]) + for (const reason of inventory.first.coverage.reasons) + result.errors.push({ + corpusId, + operation: "graph", + message: reason, + }); + result.entities.push( + ...entities.items.map((item) => ({ + id: item.id, + name: item.title, + types: entities.nodes.find((node) => node.id === item.id)!.types, + mentionCount: item.mentions ?? 0, + sources: item.sources, + ...(item.graphSupport ? { graphSupport: item.graphSupport } : {}), + })), + ); + result.topics.push( + ...topics.items.map((item) => ({ + id: item.id, + name: item.title, + mentionCount: item.mentions ?? 0, + sources: item.sources, + ...(item.graphSupport ? { graphSupport: item.graphSupport } : {}), + })), + ); + const names = new Map(entities.nodes.map((node) => [node.id, node.label])); + result.relationships.push( + ...entities.edges.map((edge) => ({ + id: edge.id, + fromId: edge.source, + toId: edge.target, + type: edge.type, + fromName: names.get(edge.source) ?? edge.source, + toName: names.get(edge.target) ?? edge.target, + count: edge.count, + sources: [], + graphSupport: { + corpusId, + generation: entities.first.generation, + edgeId: edge.id, + }, + })), + ); + result.counts.entities += entities.first.counts.nodes; + result.counts.topics += topics.first.counts.nodes; + result.counts.relationships += entities.first.counts.edges; + result.counts.events = + (result.counts.events ?? 0) + entities.first.coverage.events.eligible; + result.sourceInventory = "documents"; + const sources = await timed( + service.listSourcesPage({ corpusId, pageSize: 6 }), + ); + (result.contributingSources ??= []).push( + ...sources.items.map((source) => ({ + corpusId, + sourceId: source.sourceId, + ...(source.title ? { title: source.title } : {}), + })), + ); +} export function identity(kind: string, corpusId: string, name: string): string { return JSON.stringify([kind, corpusId, name]); @@ -66,13 +139,16 @@ async function explore( await Promise.all([ (async () => { try { - if (corpus.name === conversationCorpusName) - throw new Error( - "Conversation event graphs are unavailable in the corpus graph API; use Search to inspect turn evidence.", - ); + if (service.queryKnowledgeGraph) { + await scopedOverview(service, corpus.corpusId, result); + return; + } const graph = await timed( service.getKnowledgeGraph(corpus.corpusId), ); + result.counts.entities += graph.entities.length; + result.counts.topics += graph.topics.length; + result.counts.relationships += graph.relationships.length; result.entities.push( ...graph.entities.map((entity) => ({ id: identity( @@ -144,16 +220,18 @@ async function explore( })(), ]); }); - result.counts.entities = result.entities.length; - result.counts.topics = result.topics.length; - result.counts.relationships = result.relationships.length; const contributors = new Map( [...result.entities, ...result.topics, ...result.relationships] .flatMap((item) => item.sources) .map((source) => [JSON.stringify(source), source]), ); - result.contributingSourceCount = contributors.size; - result.contributingSources = [...contributors.values()] + result.contributingSourceCount = result.sourceInventory + ? result.counts.sources + : contributors.size; + result.contributingSources = [ + ...(result.contributingSources ?? []), + ...contributors.values(), + ] .sort( (a, b) => a.corpusId.localeCompare(b.corpusId) || @@ -161,30 +239,35 @@ async function explore( ) .slice(0, maxNodes); await Promise.all( - result.contributingSources.slice(0, 6).map(async (source) => { - try { - const metadata = await timed( - service.getSource(source.corpusId, source.sourceId), - ); - if (!metadata) - throw new Error( - "Contributing source is no longer available.", + result.contributingSources + .filter((source) => !source.title) + .slice(0, 6) + .map(async (source) => { + try { + const metadata = await timed( + service.getSource(source.corpusId, source.sourceId), ); - source.title = metadata.title; - } catch (error) { - result.errors.push({ - corpusId: source.corpusId, - operation: "sources", - message: - error instanceof Error ? error.message : String(error), - }); - } - }), + if (!metadata) + throw new Error( + "Contributing source is no longer available.", + ); + source.title = metadata.title; + } catch (error) { + result.errors.push({ + corpusId: source.corpusId, + operation: "sources", + message: + error instanceof Error + ? error.message + : String(error), + }); + } + }), ); result.entities.sort( (a, b) => b.mentionCount - a.mentionCount || a.id.localeCompare(b.id), ); - result.omittedEntities = Math.max(0, result.entities.length - maxNodes); + result.omittedEntities = Math.max(0, result.counts.entities - maxNodes); result.entities = result.entities.slice(0, maxNodes); result.relationships.sort( (a, b) => b.count - a.count || a.id.localeCompare(b.id), diff --git a/ts/packages/agents/browser/src/agent/memoryHubGraph.mts b/ts/packages/agents/browser/src/agent/memoryHubGraph.mts new file mode 100644 index 0000000000..07fa39667e --- /dev/null +++ b/ts/packages/agents/browser/src/agent/memoryHubGraph.mts @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + MemoryGraphNode, + MemoryGraphEdge, + MemoryGraphQueryRequest, + MemoryService, +} from "@typeagent/memory-service"; +import type { + MemoryHubGraphFunctions, + MemoryHubGraphRequest, + MemoryHubGraphResult, +} from "@typeagent/browser-control-rpc/viewRpc"; +import { mapMemoryHubCorpora } from "./memoryHub.mjs"; +import { queryCorpora, timed } from "./memoryHubQuery.mjs"; +import { + buildGraphologyGraph, + convertToCytoscapeElements, +} from "./knowledge/utils/graphologyLayoutEngine.mjs"; + +function graphBudget(value: number | undefined, fallback: number): number { + const limit = value ?? fallback; + if (!Number.isInteger(limit) || limit < 1 || limit > fallback) + throw new Error(`Graph limit must be between 1 and ${fallback}.`); + return limit; +} + +function layout( + nodes: MemoryGraphNode[], + edges: MemoryGraphEdge[], +): MemoryHubGraphResult["presetLayout"] { + if (!nodes.length) return { elements: [] }; + const graph = buildGraphologyGraph( + nodes.map((node) => ({ + id: node.id, + name: node.label, + type: node.kind, + count: node.mentionCount, + })), + edges.map((edge) => ({ + from: edge.source, + to: edge.target, + type: edge.type, + strength: edge.count, + })), + { + nodeLimit: 250, + forceAtlas2Iterations: 20, + noverlapIterations: 20, + skipEdgeFiltering: true, + preserveIsolatedNodes: true, + }, + ); + const positions = new Map( + convertToCytoscapeElements( + graph, + Math.max(250, Math.sqrt(nodes.length) * 50), + ) + .filter((element) => element.data.source === undefined) + .map((element) => [element.data.id, element]), + ); + return { + elements: [ + ...nodes.map((node) => { + const placed = positions.get(node.id); + const position = placed?.position; + return { + data: { + ...node, + name: node.label, + label: node.label, + nodeType: node.kind, + type: node.types?.[0] ?? node.kind, + size: 35, + important: true, + color: placed?.data.color ?? "#5e81ac", + }, + ...(position === undefined ? {} : { position }), + }; + }), + ...edges.map((edge) => ({ + data: { ...edge, label: edge.label ?? edge.type }, + })), + ], + }; +} + +function boundGraphResponse(result: MemoryHubGraphResult) { + const largestFirst = result.corpora + .map((corpus) => ({ + corpusId: corpus.corpusId, + bytes: Buffer.byteLength(JSON.stringify(corpus)), + })) + .sort((a, b) => b.bytes - a.bytes); + for (const corpus of largestFirst) { + if ( + Buffer.byteLength( + JSON.stringify({ success: true, data: result }), + ) <= + 512 * 1024 + ) + break; + const removed = result.corpora.find( + (item) => item.corpusId === corpus.corpusId, + )!; + const ids = new Set([ + ...removed.graph.nodes.map((node) => node.id), + ...removed.graph.edges.map((edge) => edge.id), + ]); + result.corpora = result.corpora.filter((item) => item !== removed); + result.presetLayout.elements = result.presetLayout.elements.filter( + (element) => !ids.has(element.data.id), + ); + result.errors.push({ + corpusId: corpus.corpusId, + message: + "Graph exceeds the combined 512 KiB response budget. Narrow the search or evidence filters.", + }); + } +} + +export function createMemoryHubGraphFunctions( + getService: () => MemoryService, +): MemoryHubGraphFunctions { + async function graph( + request: MemoryHubGraphRequest, + ): Promise { + if ( + !request.corpusId && + (request.continuationToken || request.expectedGeneration) + ) + throw new Error( + "Graph paging and generation checks require one selected collection.", + ); + const service = getService(); + const maxNodes = graphBudget(request.maxNodes, 250); + const maxEdges = graphBudget(request.maxEdges, 500); + const corpora = await queryCorpora(service, request.corpusId); + const selected = corpora.slice(0, Math.min(maxNodes, maxEdges)); + const result: MemoryHubGraphResult = { + corpora: [], + errors: [], + omittedCorpora: corpora.length - selected.length, + presetLayout: { elements: [] }, + }; + const query = service.queryKnowledgeGraph; + if (!query) + throw new Error("This memory host does not support scoped graphs."); + await mapMemoryHubCorpora(selected, async (corpus) => { + try { + const input: MemoryGraphQueryRequest = { + ...request, + corpusId: corpus.corpusId, + maxNodes: Math.floor(maxNodes / selected.length), + maxEdges: Math.max( + 1, + Math.floor(maxEdges / selected.length), + ), + }; + result.corpora.push({ + corpusId: corpus.corpusId, + corpusName: corpus.name, + graph: await timed(query.call(service, input)), + }); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + result.errors.push({ + corpusId: corpus.corpusId, + message: + message.length > 512 + ? `${message.slice(0, 500)} [truncated]` + : message, + }); + } + }); + result.corpora.sort((a, b) => a.corpusId.localeCompare(b.corpusId)); + result.errors.sort((a, b) => a.corpusId.localeCompare(b.corpusId)); + result.presetLayout = layout( + result.corpora.flatMap((item) => item.graph.nodes), + result.corpora.flatMap((item) => item.graph.edges), + ); + boundGraphResponse(result); + result.errors.sort((a, b) => a.corpusId.localeCompare(b.corpusId)); + return result; + } + + return { + memoryHubGraph: graph, + async memoryHubGraphSupport(request) { + const service = getService(); + await queryCorpora(service, request.corpusId); + if (!service.queryGraphSupport) + throw new Error("Graph evidence paging is unsupported."); + return timed(service.queryGraphSupport(request)); + }, + async memoryHubGraphEvent(request) { + const service = getService(); + await queryCorpora(service, request.corpusId); + const event = await timed( + service.getEvent(request.corpusId, request.eventId), + ); + if (!event) + throw new Error("Event evidence is no longer available."); + const content = JSON.stringify(event, null, 2); + const offset = request.offset ?? 0; + if ( + !Number.isSafeInteger(offset) || + offset < 0 || + offset > content.length + ) + throw new Error("Invalid event evidence offset."); + const next = Math.min(content.length, offset + 12_000); + return { + title: event.eventType, + content: content.slice(offset, next), + offset, + totalChars: content.length, + ...(next < content.length ? { nextOffset: next } : {}), + }; + }, + }; +} diff --git a/ts/packages/agents/browser/src/agent/memoryHubKnowledge.mts b/ts/packages/agents/browser/src/agent/memoryHubKnowledge.mts index 865a8d4d12..127f920e7e 100644 --- a/ts/packages/agents/browser/src/agent/memoryHubKnowledge.mts +++ b/ts/packages/agents/browser/src/agent/memoryHubKnowledge.mts @@ -1,10 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { - conversationCorpusName, - type MemoryKnowledgeGraph, -} from "@typeagent/memory-service"; +import type { MemoryKnowledgeGraph } from "@typeagent/memory-service"; import type { MemoryHubFunctions, MemoryHubKnowledgeItem, @@ -19,6 +16,35 @@ import { timed, type MemoryHubReadService, } from "./memoryHubQuery.mjs"; +import { readScopedKnowledge } from "./memoryHubScopedKnowledge.mjs"; + +async function documentItems( + service: MemoryHubReadService, + corpusId: string, + corpusName: string, +): Promise { + const items: MemoryHubKnowledgeItem[] = []; + let continuationToken: string | undefined; + do { + const page = await timed( + service.listSourcesPage({ + corpusId, + pageSize: 100, + ...(continuationToken ? { continuationToken } : {}), + }), + ); + items.push( + ...page.items.map((source) => ({ + id: identity("source", corpusId, source.sourceId), + title: source.title ?? source.sourceId, + subtitle: `${corpusName} · ${source.sourceType}`, + sources: [{ corpusId, sourceId: source.sourceId }], + })), + ); + continuationToken = page.nextContinuationToken; + } while (continuationToken); + return items; +} function derivedItems( corpusId: string, @@ -109,10 +135,43 @@ export function createMemoryHubKnowledgeFunctions( const errors: MemoryHubKnowledgePage["errors"] = []; await mapMemoryHubCorpora(corpora, async (corpus) => { try { - if (corpus.name === conversationCorpusName) - throw new Error( - "Conversation knowledge collections are unavailable; use Search for turn evidence.", + if (service.queryKnowledgeGraph) { + if (request.kind === "sources") { + items.push( + ...(await documentItems( + service, + corpus.corpusId, + corpus.name, + )), + ); + return; + } + const inventory = await readScopedKnowledge( + service, + corpus.corpusId, + request.kind, + true, ); + if (!inventory) + throw new Error( + "Scoped knowledge capability is unavailable.", + ); + items.push( + ...inventory.items.map((item) => ({ + ...item, + subtitle: [item.subtitle, corpus.name] + .filter(Boolean) + .join(" · "), + })), + ); + for (const reason of inventory.first.coverage.reasons) + errors.push({ + corpusId: corpus.corpusId, + operation: "graph", + message: reason, + }); + return; + } const graph = await timed( service.getKnowledgeGraph(corpus.corpusId), ); diff --git a/ts/packages/agents/browser/src/agent/memoryHubScopedKnowledge.mts b/ts/packages/agents/browser/src/agent/memoryHubScopedKnowledge.mts new file mode 100644 index 0000000000..727825ffd8 --- /dev/null +++ b/ts/packages/agents/browser/src/agent/memoryHubScopedKnowledge.mts @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + MemoryGraphQueryRequest, + MemoryGraphQueryResult, + MemoryService, +} from "@typeagent/memory-service"; +import type { + MemoryHubKnowledgeItem, + MemoryHubKnowledgeKind, +} from "@typeagent/browser-control-rpc/viewRpc"; +import { timed } from "./memoryHubQuerySupport.mjs"; + +export async function readScopedKnowledge( + service: MemoryService, + corpusId: string, + kind: Exclude, + allPages = false, + search?: string, +) { + if (!service.queryKnowledgeGraph) return undefined; + const query = service.queryKnowledgeGraph.bind(service); + const request: MemoryGraphQueryRequest = { + corpusId, + kind: kind === "topics" ? "topics" : "entities", + ...(kind === "relationships" ? { pageBy: "edges" as const } : {}), + maxNodes: 250, + maxEdges: 500, + ...(search ? { search } : {}), + }; + const pages: MemoryGraphQueryResult[] = []; + do { + const page = await timed(query(request)); + if (pages[0] && page.generation !== pages[0].generation) + throw new Error( + "Graph generation changed while loading inventory. Refresh.", + ); + pages.push(page); + if (!allPages || !page.nextContinuationToken) break; + request.continuationToken = page.nextContinuationToken; + request.expectedGeneration = page.generation; + } while (true); + const first = pages[0]!; + const nodes = new Map( + pages.flatMap((page) => page.nodes).map((node) => [node.id, node]), + ); + const edges = new Map( + pages.flatMap((page) => page.edges).map((edge) => [edge.id, edge]), + ); + const items: MemoryHubKnowledgeItem[] = + kind === "relationships" + ? [...edges.values()].map((edge) => ({ + id: edge.id, + title: `${nodes.get(edge.source)?.label ?? edge.source} → ${edge.type} → ${nodes.get(edge.target)?.label ?? edge.target}`, + mentions: edge.count, + sources: [], + graphSupport: { + corpusId, + generation: first.generation, + edgeId: edge.id, + }, + })) + : [...nodes.values()].map((node) => ({ + id: node.id, + title: node.label, + subtitle: node.types.join(" · "), + mentions: node.mentionCount, + sources: [], + graphSupport: { + corpusId, + generation: first.generation, + nodeId: node.id, + }, + })); + return { + first, + nodes: [...nodes.values()], + edges: [...edges.values()], + items, + }; +} diff --git a/ts/packages/agents/browser/src/agent/memoryViews.mts b/ts/packages/agents/browser/src/agent/memoryViews.mts index 14ada136be..4712d80f08 100644 --- a/ts/packages/agents/browser/src/agent/memoryViews.mts +++ b/ts/packages/agents/browser/src/agent/memoryViews.mts @@ -9,6 +9,9 @@ import type { import { createMemoryServiceRpcFacade } from "@typeagent/memory-service/rpc"; import type { MemoryAcquisitionProgressService } from "@typeagent/memory-client"; import { + inventoryPageRequestSchema, + viewSummaryRequestSchema, + viewPublicationBatchRequestSchema, knowledgeCorpusRequestSchema, knowledgeDiscoveryUpdateSchema, knowledgeCheckpointRequestSchema, @@ -26,6 +29,9 @@ type ViewMethods = Pick< | "memoryQueryTopics" | "memoryRebuildTopics" | "memoryListViews" + | "memoryListViewSummaries" + | "memoryListViewBuildSummaries" + | "memoryGetViewPublications" | "memoryListQuestions" | "memoryListQuestionSummaries" | "memoryGetQuestion" @@ -129,6 +135,18 @@ export function createMemoryViewFunctions( memoryQueryTopics: (request) => views().queryTopics(request), memoryRebuildTopics: ({ corpusId }) => views().rebuildTopics(corpusId), memoryListViews: ({ corpusId }) => views().listViews(corpusId), + memoryListViewSummaries: (request) => { + viewSummaryRequestSchema.parse(request); + return required("listViewSummaries")(request); + }, + memoryListViewBuildSummaries: (request) => { + inventoryPageRequestSchema.parse(request); + return required("listViewBuildSummaries")(request); + }, + memoryGetViewPublications: (request) => { + viewPublicationBatchRequestSchema.parse(request); + return required("getViewPublications")(request); + }, memoryListQuestions: ({ corpusId }) => required("listQuestions")(corpusId), memoryListQuestionSummaries: (request) => diff --git a/ts/packages/agents/browser/src/agent/viewService.mts b/ts/packages/agents/browser/src/agent/viewService.mts index 2f265f4d10..0845d9c999 100644 --- a/ts/packages/agents/browser/src/agent/viewService.mts +++ b/ts/packages/agents/browser/src/agent/viewService.mts @@ -168,6 +168,12 @@ export function createViewServiceHost( context.agentContext.viewPortRegistration = context.registerPort("view", message.port); context.agentContext.localHostPort = message.port; + const baseUrl = `http://localhost:${message.port}`; + // code-lint-allow no-console: Startup URLs must be visible without DEBUG. + console.log( + `Browser views listening on port ${message.port}: ${baseUrl}\n` + + `Memory Hub: ${baseUrl}/library/memoryHub.html`, + ); cleanup(); resolve(child); } catch (error) { diff --git a/ts/packages/agents/browser/src/views/client/library/entityGraphVisualizer.ts b/ts/packages/agents/browser/src/views/client/library/entityGraphVisualizer.ts index ceddee7ec7..7c0a47f823 100644 --- a/ts/packages/agents/browser/src/views/client/library/entityGraphVisualizer.ts +++ b/ts/packages/agents/browser/src/views/client/library/entityGraphVisualizer.ts @@ -6,6 +6,7 @@ import cytoscape from "cytoscape"; import { escapeWebText } from "./memoryHubWebExploreRoot"; interface EntityData { + id?: string; name: string; type: string; confidence: number; @@ -37,6 +38,7 @@ export class EntityGraphVisualizer { protected cy: any = null; private container: HTMLElement; private entityClickCallback: ((entity: EntityData) => void) | null = null; + private edgeClickCallback: ((edgeId: string) => void) | undefined; // View mode and data management private viewMode: ViewMode = "global"; @@ -837,6 +839,7 @@ export class EntityGraphVisualizer { instance.on("tap", "node", (evt: any) => { const node = evt.target; const entityData: EntityData = { + id: node.id(), name: node.data("name"), type: node.data("type"), confidence: node.data("confidence"), @@ -875,7 +878,8 @@ export class EntityGraphVisualizer { this.cy.on("tap", "edge", (evt: any) => { const edge = evt.target; this.highlightEdgePath(edge); - this.showEdgeDetails(edge, evt.renderedPosition); + if (this.edgeClickCallback) this.edgeClickCallback(edge.id()); + else this.showEdgeDetails(edge, evt.renderedPosition); }); // Edge hover @@ -1629,6 +1633,10 @@ export class EntityGraphVisualizer { this.entityClickCallback = callback; } + onEdgeClick(callback: (edgeId: string) => void): void { + this.edgeClickCallback = callback; + } + /** * Focus on a specific entity */ diff --git a/ts/packages/agents/browser/src/views/client/library/memoryHub.css b/ts/packages/agents/browser/src/views/client/library/memoryHub.css index 19699092a5..e3c7081386 100644 --- a/ts/packages/agents/browser/src/views/client/library/memoryHub.css +++ b/ts/packages/agents/browser/src/views/client/library/memoryHub.css @@ -14,146 +14,6 @@ body[data-memory-hub] { --accent-soft-hover: color-mix(in srgb, var(--primary) 16%, var(--surface)); } -#page-questions .hub-qhead, -#page-questions .hub-qfilters, -#page-questions .hub-row-actions, -#page-questions .hub-qmeta, -#page-questions .hub-qdetail-head { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} -#page-questions .hub-qfilters { - margin-bottom: 12px; -} -#page-questions .hub-qfilters input[type="search"] { - flex: 1 1 180px; - max-width: 320px; - min-height: 30px; -} -#page-questions .hub-rows { - display: grid; - gap: 2px; - margin-top: 6px; -} -#page-questions .hub-grow { - flex: 1 1 auto; - min-width: 0; -} -#page-questions .hub-muted, -#page-questions .hub-qmeta { - color: var(--muted); - font-size: 13px; -} -#page-questions .hub-notice { - margin-bottom: 12px; - padding: 8px 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface-alt); -} -#page-questions .hub-split { - display: grid; - grid-template-columns: minmax(280px, 2fr) minmax(0, 3fr); - gap: 16px; - align-items: start; -} -#page-questions .hub-qlist { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -} -#page-questions .hub-qrow { - padding: 8px 10px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); - cursor: pointer; -} -#page-questions .hub-qrow:hover { - background: var(--surface-alt); -} -#page-questions .hub-qrow.selected { - border-color: var(--primary); - background: var(--accent-soft-hover); -} -#page-questions .hub-qrow > button { - display: block; - width: 100%; - padding: 0; - border: 0; - background: none; - color: inherit; - font: inherit; - font-weight: 600; - text-align: left; - cursor: pointer; -} -#page-questions .hub-qmeta { - margin-top: 4px; -} -#page-questions .hub-qmeta small { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -#page-questions .hub-qdetail { - position: sticky; - top: 8px; - max-height: calc(100vh - 24px); - min-width: 0; - overflow: auto; - padding: 0 4px 16px; -} -#page-questions .hub-qdetail:empty::before { - content: "Select a question to see its answers."; - color: var(--muted); -} -#page-questions .hub-qdetail-head h3 { - flex: 1 1 100%; - margin: 0; - font-size: 16px; -} -#page-questions .hub-qdetail details { - margin: 10px 0; -} -#page-questions summary { - cursor: pointer; - font-weight: 600; -} -#page-questions .hub-fields { - display: grid; - grid-template-columns: max-content 1fr; - gap: 2px 12px; - margin: 0 0 6px; -} -#page-questions .hub-fields dt { - color: var(--muted); - font-size: 13px; -} -#page-questions .hub-fields dd { - margin: 0; -} -#page-questions .hub-panel { - margin-top: 20px; - padding: 10px 14px; - border: 1px solid var(--border); - border-radius: 8px; -} -@media (max-width: 900px) { - #page-questions .hub-split { - grid-template-columns: minmax(0, 1fr); - } - #page-questions .hub-qdetail { - position: static; - max-height: none; - } -} - [hidden] { display: none !important; } @@ -225,6 +85,103 @@ body[data-memory-hub] { .hub-view-section { margin: 20px 0; } +.hub-view-document .markdown-preview { + min-height: 0; + padding: 0; + border: 0; + background: none; +} +.hub-view-document h2 { + margin: 0 0 6px; + font-size: 20px; +} +.hub-view-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + margin-bottom: 12px; + color: var(--muted); + font-size: 13px; +} +.hub-view-section > h3 { + margin: 20px 0 8px; + font-size: 15px; +} +.hub-view-document > details { + margin: 12px 0; +} +.hub-view-document summary { + cursor: pointer; + font-weight: 600; +} +.hub-view-document details > .hub-row-actions { + padding: 2px 0; +} +.hub-related { + margin-top: 20px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.hub-related h3 { + margin: 0 0 6px; + font-size: 13px; + color: var(--muted); +} +.hub-related ul { + margin: 0; + padding: 0; + list-style: none; +} +.hub-related button { + padding: 3px 0; + border: 0; + background: none; + color: var(--primary); + font: inherit; + text-align: left; + cursor: pointer; +} +.hub-related button:hover { + text-decoration: underline; +} +.hub-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} +.hub-view-more-actions > summary { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 6px; + list-style: none; + cursor: pointer; +} +.hub-view-more-actions > summary::-webkit-details-marker { + display: none; +} +.hub-view-more-actions > summary:hover { + background: var(--surface-alt); +} +.hub-draft-views:has(.hub-view-document) > h3, +.hub-draft-views:has(.hub-view-document) > p, +.hub-draft-views:has(.hub-view-document) > .hub-view-build-receipt { + display: none; +} +.hub-draft-views:has(.hub-view-document) .hub-view-actions { + max-width: 900px; + margin: 0 auto 8px; +} +.hub-view-lead { + font-size: 1.05em; + line-height: 1.5; + margin: 12px 0 20px; +} .hub-view-section > details { margin-top: 12px; padding: 10px; @@ -512,6 +469,16 @@ body[data-memory-hub] { .hub-chip:empty { display: none; } +.hub-chip.bad { + border-color: transparent; + background: var(--danger-soft, #fdf1ec); + color: var(--danger-text, #a4380d); +} +.hub-chip.good { + border-color: transparent; + background: var(--ok-soft, #eef8f1); + color: var(--ok-text, #1b6b3a); +} .hub-chip.warn { border-color: transparent; background: var(--warn-soft); @@ -1254,3 +1221,258 @@ textarea:focus-visible { .phase2-search-insights .knowledge-cards { max-height: none; } + +/* Questions: list on the left, selected question beside it */ +.hub-qhead { + flex-wrap: wrap; +} +.hub-qfilters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} +.hub-qfilters input[type="search"] { + flex: 1 1 180px; + max-width: 320px; + min-height: 30px; +} +.hub-rows { + display: grid; + gap: 2px; + margin-top: 6px; +} +.hub-rows .hub-row-actions { + display: flex; + align-items: center; + width: 100%; + margin: 0; +} +.hub-grow { + flex: 1 1 auto; + min-width: 0; +} +.hub-muted { + color: var(--muted); + font-size: 13px; +} +.hub-notice { + margin-bottom: 12px; + padding: 8px 12px; + border-radius: 8px; + background: var(--warn-soft); + color: var(--warn-text); +} +.hub-notice p { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 2px 0; + font-size: 13px; +} +.hub-notice i { + margin-right: 8px; +} +.hub-split { + display: grid; + grid-template-columns: minmax(280px, 2fr) minmax(0, 3fr); + gap: 16px; + align-items: start; +} +.hub-qlist { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.hub-qrow { + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + cursor: pointer; +} +.hub-qrow:hover { + background: var(--surface-alt); +} +.hub-qrow.selected { + border-color: var(--primary); + background: var(--accent-soft-hover); +} +.hub-qrow > button { + display: block; + width: 100%; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 600; + text-align: left; + cursor: pointer; +} +.hub-qmeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 4px; + color: var(--muted); +} +.hub-qmeta small { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hub-qdetail { + position: sticky; + top: 8px; + max-height: calc(100vh - 24px); + min-width: 0; + overflow: auto; + padding: 0 4px 16px; +} +.hub-qdetail:empty::before { + content: "Select a question to see its answers."; + color: var(--muted); +} +.hub-qdetail-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.hub-qdetail-head h3 { + flex: 1 1 100%; + margin: 0; + font-size: 16px; +} +.hub-qdetail-head .hub-row-actions { + margin-left: auto; +} +.hub-qdetail details { + margin: 10px 0; +} +.hub-qdetail summary { + cursor: pointer; + font-weight: 600; +} +.hub-fields { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 12px; + margin: 0 0 6px; +} +.hub-fields dt { + color: var(--muted); + font-size: 13px; +} +.hub-fields dd { + margin: 0; +} +.hub-panel { + margin-top: 20px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 8px; +} +.hub-panel > summary { + cursor: pointer; + font-weight: 600; +} +@media (max-width: 900px) { + .hub-split { + grid-template-columns: minmax(0, 1fr); + } + .hub-qdetail { + position: static; + max-height: none; + } +} +.hub-qdetail .card .hub-row-actions { + display: flex; + align-items: center; + width: 100%; +} +.hub-view-document h2:focus { + outline: none; +} +.hub-related button { + min-height: 0; + padding: 2px 0; + line-height: 1.4; +} +.hub-related li { + margin: 0; +} +.hub-view-card { + cursor: pointer; +} +.hub-view-card:hover { + background: var(--surface-alt); +} +.hub-view-card-title { + margin: 0 0 6px; + font-size: 15px; +} +.hub-view-card-title button { + display: block; + width: 100%; + min-height: 0; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 700; + text-align: left; + cursor: pointer; +} +.hub-view-card .hub-muted { + margin: 6px 0 0; +} +.hub-view-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} +.hub-view-toolbar input[type="search"] { + flex: 1 1 180px; + max-width: 320px; + min-height: 32px; +} +.hub-view-controls { + display: flex; + align-items: center; + gap: 6px; +} +.hub-flyout .hub-view-build-receipt { + margin: 0; +} +.hub-flyout pre { + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.hub-result { + margin: 10px 0; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; +} +.hub-result > p { + margin: 6px 0; +} +.hub-result .hub-grow { + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/ts/packages/agents/browser/src/views/client/library/memoryHub.html b/ts/packages/agents/browser/src/views/client/library/memoryHub.html index 6eaa5e2bc8..f6c8cc7720 100644 --- a/ts/packages/agents/browser/src/views/client/library/memoryHub.html +++ b/ts/packages/agents/browser/src/views/client/library/memoryHub.html @@ -115,6 +115,7 @@

Inbox

+
+ -