Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions ts/packages/agents/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ts/packages/agents/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions ts/packages/agents/browser/src/agent/agentServiceHandlers.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -48,6 +49,7 @@ export function createViewServiceDomain(
createMemoryHubSynthesizer(),
),
...createMemoryHubExploreFunctions(service),
...createMemoryHubGraphFunctions(service),
...createMemoryHubKnowledgeFunctions(service),
...createMemoryHubSourceOriginalFunctions(service),
...createMemoryHubViewUsageFunctions(service),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface GraphologyLayoutOptions {
noverlapIterations?: number;
targetViewportSize?: number;
skipEdgeFiltering?: boolean;
preserveIsolatedNodes?: boolean;
}

const DEFAULT_OPTIONS: Required<GraphologyLayoutOptions> = {
Expand All @@ -68,6 +69,7 @@ const DEFAULT_OPTIONS: Required<GraphologyLayoutOptions> = {
noverlapIterations: 1000,
targetViewportSize: 2000,
skipEdgeFiltering: false,
preserveIsolatedNodes: false,
};

const COMMUNITY_COLORS = [
Expand Down Expand Up @@ -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);
Expand Down
141 changes: 112 additions & 29 deletions ts/packages/agents/browser/src/agent/memoryHubExplore.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Licensed under the MIT License.

import { createHash } from "node:crypto";
import { conversationCorpusName } from "@typeagent/memory-service";
import type {
MemoryHubExploreResult,
MemoryHubFunctions,
Expand All @@ -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]);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -144,47 +220,54 @@ 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) ||
a.sourceId.localeCompare(b.sourceId),
)
.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),
Expand Down
Loading
Loading