From 2d9c637854820f96f65b9d8e799fed925e09ecd3 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Sun, 21 Jun 2026 20:07:08 -0700 Subject: [PATCH] feat(web): wire graph-aware retrieval into the live AI chat (0211 Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assistant's context pack now uses @xnetjs/brain's retrieve() via the AiSurfaceService retrieveContext seam, instead of a flat keyword scan: - New ai-graph-retriever.ts: createGraphContextRetriever builds an AiContextRetriever over the local NodeStore — keyword entry search + bounded expansion along typed relations (resolved from the schema registry) + token budget + readable provenance paths. Deliberately model-free (no embedding download) so cold-start is untouched per 0204; the vector tier can swap in behind the same seam later. - AiChatPanel injects it into createAiSurfaceService({ retrieveContext }). - Export AiContextRetriever/AiRetrievedNode from the @xnetjs/plugins barrels. - 6 new tests; verified in-browser (entry + 1-hop neighbor with path label). Co-Authored-By: Claude Opus 4.8 --- apps/web/package.json | 1 + apps/web/src/workbench/views/AiChatPanel.tsx | 13 +- .../views/ai-graph-retriever.test.ts | 76 +++++++ .../src/workbench/views/ai-graph-retriever.ts | 186 ++++++++++++++++++ ...ECOND_BRAIN_GRAPHRAG_MEMORY_AND_TIERING.md | 24 +-- packages/plugins/src/ai-surface/index.ts | 2 + packages/plugins/src/index.ts | 2 + pnpm-lock.yaml | 3 + .../2026-06-21-assistant-graph-context.json | 12 ++ 9 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/workbench/views/ai-graph-retriever.test.ts create mode 100644 apps/web/src/workbench/views/ai-graph-retriever.ts create mode 100644 site/src/data/changelog/2026-06-21-assistant-graph-context.json diff --git a/apps/web/package.json b/apps/web/package.json index 5ea482e3d..26e262147 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@tanstack/react-router": "^1.57.0", "@tanstack/react-virtual": "^3.14.2", "@xnetjs/abuse": "workspace:*", + "@xnetjs/brain": "workspace:*", "@xnetjs/canvas": "workspace:*", "@xnetjs/charts": "workspace:*", "@xnetjs/comms": "workspace:*", diff --git a/apps/web/src/workbench/views/AiChatPanel.tsx b/apps/web/src/workbench/views/AiChatPanel.tsx index 9302b07fe..a741b5cf8 100644 --- a/apps/web/src/workbench/views/AiChatPanel.tsx +++ b/apps/web/src/workbench/views/AiChatPanel.tsx @@ -53,6 +53,7 @@ import { type ManagedModel } from './ai-chat-connector' import { AI_SYSTEM_PROMPT, formatContextMessages } from './ai-context' +import { createGraphContextRetriever } from './ai-graph-retriever' import { schemaRegistryApi } from './ai-schemas' /** Electron preload control channel for the local agent bridge (absent on web). */ @@ -118,7 +119,17 @@ export function AiChatPanel() { // user's own pages/databases/nodes for context (exploration 0192, Phase 1). const { store } = useNodeStore() const surface = useMemo( - () => (store ? createAiSurfaceService({ store, schemas: schemaRegistryApi() }) : null), + () => + store + ? createAiSurfaceService({ + store, + schemas: schemaRegistryApi(), + // Graph-aware, budgeted context retrieval (exploration 0211): the + // context pack now walks typed relations instead of a flat keyword + // scan. Keyword entry search keeps it model-free (no boot cost). + retrieveContext: createGraphContextRetriever(store) + }) + : null, [store] ) diff --git a/apps/web/src/workbench/views/ai-graph-retriever.test.ts b/apps/web/src/workbench/views/ai-graph-retriever.test.ts new file mode 100644 index 000000000..f31b24ae3 --- /dev/null +++ b/apps/web/src/workbench/views/ai-graph-retriever.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { + createGraphContextRetriever, + nodeTextParts, + type GraphRetrieverNode, + type GraphRetrieverStore +} from './ai-graph-retriever' + +function makeStore(nodes: GraphRetrieverNode[]): GraphRetrieverStore { + const byId = new Map(nodes.map((n) => [n.id, n])) + return { + async get(id) { + return byId.get(id) ?? null + }, + async list() { + return nodes + } + } +} + +const NODES: GraphRetrieverNode[] = [ + { + id: 'inv1', + schemaId: 'Inventory', + properties: { label: 'My inventory', items: ['item1'] }, + deleted: false + }, + { id: 'item1', schemaId: 'GameItem', properties: { name: 'Sword of testing' }, deleted: false }, + { id: 'other', schemaId: 'Page', properties: { title: 'unrelated note' }, deleted: false } +] + +const relationFieldsOf = async (schemaId: string) => (schemaId === 'Inventory' ? ['items'] : []) + +describe('nodeTextParts', () => { + it('uses the first text-bearing property as the title', () => { + expect(nodeTextParts(NODES[0])).toEqual({ title: 'My inventory', body: 'My inventory' }) + }) + + it('falls back to the node id when there is no text', () => { + expect(nodeTextParts({ id: 'x', schemaId: 'S', properties: {}, deleted: false }).title).toBe( + 'x' + ) + }) +}) + +describe('createGraphContextRetriever', () => { + it('returns keyword entry hits plus graph-expanded neighbors', async () => { + const retrieve = createGraphContextRetriever(makeStore(NODES), { relationFieldsOf }) + const results = await retrieve('inventory', { limit: 6 }) + const ids = results.map((r) => r.nodeId) + expect(ids).toContain('inv1') // keyword match on "My inventory" + expect(ids).toContain('item1') // 1-hop via the `items` relation + expect(ids).not.toContain('other') // no keyword match, not connected + }) + + it('attaches a readable provenance path to expanded nodes', async () => { + const retrieve = createGraphContextRetriever(makeStore(NODES), { relationFieldsOf }) + const results = await retrieve('inventory', { limit: 6 }) + const item = results.find((r) => r.nodeId === 'item1') + expect(item?.pathLabel).toContain('My inventory') + expect(item?.pathLabel).toContain('items') + }) + + it('returns only entry hits when the schema has no relations', async () => { + const retrieve = createGraphContextRetriever(makeStore(NODES), { + relationFieldsOf: async () => [] + }) + const results = await retrieve('inventory', { limit: 6 }) + expect(results.map((r) => r.nodeId)).toEqual(['inv1']) + }) + + it('returns nothing for an empty query', async () => { + const retrieve = createGraphContextRetriever(makeStore(NODES), { relationFieldsOf }) + expect(await retrieve(' ', { limit: 6 })).toEqual([]) + }) +}) diff --git a/apps/web/src/workbench/views/ai-graph-retriever.ts b/apps/web/src/workbench/views/ai-graph-retriever.ts new file mode 100644 index 000000000..ea1a6a404 --- /dev/null +++ b/apps/web/src/workbench/views/ai-graph-retriever.ts @@ -0,0 +1,186 @@ +/** + * Graph-aware context retriever for the AI chat (exploration 0211 — live wiring). + * + * This is the app-side glue that injects `@xnetjs/brain`'s `retrieve()` into the + * `AiSurfaceService` via its `retrieveContext` seam. Instead of the flat keyword + * scan the context pack used before, the assistant now gets a graph-walked, + * budgeted slice: keyword entry search over the local NodeStore, then bounded + * expansion along typed relations (resolved from the schema registry), with each + * hit carrying a readable provenance path. + * + * Deliberately uses **no embedding model** — entry search is keyword-only — so it + * adds zero boot weight and no heavy bundle dependency (the 0204 cold-start + * constraint). The vector tier can later swap in behind the same seam without + * touching this call site. + */ +import type { AiContextRetriever } from '@xnetjs/plugins' +import { + retrieve, + schemaRelationFields, + type EntryHit, + type GraphAccess, + type GraphEdge, + type NodeText, + type RetrievalBudget +} from '@xnetjs/brain' +import { schemaRegistry, type SchemaIRI } from '@xnetjs/data' + +/** The minimal node shape the retriever reads (a `NodeState` satisfies it). */ +export interface GraphRetrieverNode { + id: string + schemaId: string + properties: Record + deleted: boolean +} + +/** The minimal NodeStore surface the retriever reads. */ +export interface GraphRetrieverStore { + get(id: string): Promise + list(options?: { limit?: number }): Promise +} + +/** Resolve the relation-valued property names for a schema. */ +export type RelationFieldsLookup = (schemaId: string) => Promise + +export interface GraphContextRetrieverOptions { + /** Relation-field resolver; defaults to the global client schema registry. */ + relationFieldsOf?: RelationFieldsLookup + /** Override the retrieval budget. */ + budget?: Partial +} + +const TEXT_KEYS = [ + 'title', + 'name', + 'displayName', + 'label', + 'subject', + 'summary', + 'description', + 'text', + 'body', + 'content', + 'bio', + 'caption' +] as const + +const SCAN_LIMIT = 500 +const SNIPPET_MAX = 600 +const DEFAULT_BUDGET: RetrievalBudget = { + maxTokens: 24_000, + maxHops: 1, + maxEntries: 12, + maxNodes: 48 +} + +/** Title (first text-bearing property) + joined body of a node's text. */ +export function nodeTextParts(node: GraphRetrieverNode): { title: string; body: string } { + const parts: string[] = [] + for (const key of TEXT_KEYS) { + const value = node.properties[key] + if (typeof value === 'string' && value.trim().length > 0) parts.push(value.trim()) + } + return { title: parts[0]?.slice(0, 200) ?? node.id, body: parts.join('\n') } +} + +/** Default relation-field resolver backed by the global schema registry (memoized). */ +function registryRelationFields(): RelationFieldsLookup { + const cache = new Map() + return async (schemaId) => { + const cached = cache.get(schemaId) + if (cached) return cached + const defined = await schemaRegistry.get(schemaId as SchemaIRI) + const fields = defined ? schemaRelationFields(defined) : [] + cache.set(schemaId, fields) + return fields + } +} + +/** Keyword entry search: title-boosted substring match over the local store. */ +function keywordEntrySearch( + store: GraphRetrieverStore +): (query: string, k: number) => Promise { + return async (query, k) => { + const needle = query.trim().toLocaleLowerCase() + if (!needle) return [] + const nodes = await store.list({ limit: SCAN_LIMIT }) + const hits: EntryHit[] = [] + for (const node of nodes) { + if (node.deleted) continue + const { title, body } = nodeTextParts(node) + const idx = `${title}\n${body}`.toLocaleLowerCase().indexOf(needle) + if (idx === -1) continue + const titleMatch = title.toLocaleLowerCase().includes(needle) + hits.push({ + nodeId: node.id, + score: (titleMatch ? 10 : 1) + Math.max(0, 5 - idx / 100), + source: 'keyword' + }) + } + hits.sort((a, b) => b.score - a.score) + return hits.slice(0, k) + } +} + +/** Graph access that reads outbound relation edges, schema-resolved + memoized. */ +function schemaGraphAccess( + store: GraphRetrieverStore, + relationFieldsOf: RelationFieldsLookup +): GraphAccess { + return { + async neighbors(nodeId) { + const node = await store.get(nodeId) + if (!node || node.deleted) return [] + const edges: GraphEdge[] = [] + for (const field of await relationFieldsOf(node.schemaId)) { + const value = node.properties[field] + const targets = Array.isArray(value) ? value : [value] + for (const target of targets) { + if (typeof target === 'string' && target.length > 0) { + edges.push({ nodeId: target, relation: field, direction: 'outbound' }) + } + } + } + return edges + } + } +} + +/** Load a node's title/snippet for the retrieved context. */ +function nodeTextLoader(store: GraphRetrieverStore): (id: string) => Promise { + return async (id) => { + const node = await store.get(id) + if (!node || node.deleted) return null + const { title, body } = nodeTextParts(node) + return { + title, + snippet: body.replace(/\s+/g, ' ').trim().slice(0, SNIPPET_MAX), + schemaId: node.schemaId + } + } +} + +/** + * Build a graph-aware `AiContextRetriever` over the local NodeStore. Wire it into + * `createAiSurfaceService({ store, schemas, retrieveContext })`. + */ +export function createGraphContextRetriever( + store: GraphRetrieverStore, + options: GraphContextRetrieverOptions = {} +): AiContextRetriever { + const relationFieldsOf = options.relationFieldsOf ?? registryRelationFields() + const graph = schemaGraphAccess(store, relationFieldsOf) + const loadText = nodeTextLoader(store) + const entrySearch = keywordEntrySearch(store) + + return async (query, { limit }) => { + const budget: RetrievalBudget = { + ...DEFAULT_BUDGET, + maxEntries: Math.max(limit, 4), + maxNodes: Math.max(limit * 4, 24), + ...options.budget + } + const result = await retrieve(query, budget, { entrySearch, graph, loadText }) + return result.items.map((item) => ({ nodeId: item.nodeId, pathLabel: item.pathLabel })) + } +} diff --git a/docs/explorations/0211_[_]_AI_SECOND_BRAIN_GRAPHRAG_MEMORY_AND_TIERING.md b/docs/explorations/0211_[_]_AI_SECOND_BRAIN_GRAPHRAG_MEMORY_AND_TIERING.md index 4d8b7845f..d19d6b749 100644 --- a/docs/explorations/0211_[_]_AI_SECOND_BRAIN_GRAPHRAG_MEMORY_AND_TIERING.md +++ b/docs/explorations/0211_[_]_AI_SECOND_BRAIN_GRAPHRAG_MEMORY_AND_TIERING.md @@ -1,17 +1,19 @@ # AI Second Brain: GraphRAG Retrieval, Memory, and Data Tiering on the XNet Substrate > Exploration 0211 — 2026-06-21 -> Status: **Phases 1–2 implemented** (PR #228 + follow-up). The engine -> (`@xnetjs/brain`: retriever, embedding indexer, memory planner + apply, -> locality planner, schema/persistence helpers) and the `MemoryItem` schema all -> ship as tested packages, and `AiSurfaceService` now has an injected -> `retrieveContext` seam that drives its context-pack query path. Remaining and -> still-unchecked below: the **live injection in `apps/web`** (wiring a -> `SemanticSearch` + `@xnetjs/brain` into the AI surface, with embedding backfill -> + tier persistence on boot — deserves its own perf-aware PR per [0204]), an -> `xnet_graph_expand` MCP tool, the `data-bridge` query-path placement of the -> locality planner, `WorkingSetPrewarm` consumption, and a managed `/ai/embed` -> hub route. Filename stays `[_]` until the live app wiring lands. +> Status: **Phases 1–3 implemented and LIVE** (PRs #228, #230, + this one). The +> engine (`@xnetjs/brain`: retriever, embedding indexer, memory planner + apply, +> locality planner, schema/persistence helpers) and the `MemoryItem` schema ship +> as tested packages; `AiSurfaceService` has an injected `retrieveContext` seam; +> and the **`apps/web` AI chat now uses it live** — `createGraphContextRetriever` +> drives the chat's context pack with keyword entry search + bounded graph-walk + +> budgeting + readable provenance paths (`apps/web/src/workbench/views/ai-graph-retriever.ts`, +> verified in-browser). It is deliberately **model-free** (keyword entry search, +> no embedding download) to keep cold-start untouched per [0204]. Remaining +> enhancements (still `[_]`): swap the **vector tier** in behind the same seam +> (wake `@xnetjs/vectors` in the app + backfill/persist), an `xnet_graph_expand` +> MCP tool, the `data-bridge` query-path placement of the locality planner, +> `WorkingSetPrewarm` consumption, and a managed `/ai/embed` hub route. ## Problem Statement diff --git a/packages/plugins/src/ai-surface/index.ts b/packages/plugins/src/ai-surface/index.ts index 70d619df7..0caa3c3b9 100644 --- a/packages/plugins/src/ai-surface/index.ts +++ b/packages/plugins/src/ai-surface/index.ts @@ -48,6 +48,7 @@ export { AiSurfaceService, createAiSurfaceService } from './service' export { XNET_AGENT_SKILL_MD } from './skill' export { flattenRowForTsv, toTsv } from './format' export type { + AiContextRetriever, AiDatabaseMutationApplyResult, AiPageMarkdownApplyAdapter, AiPageMarkdownApplyAdapterInput, @@ -55,6 +56,7 @@ export type { AiPageMarkdownApplyResult, AiPageMarkdownRollbackResult, AiResourceContent, + AiRetrievedNode, AiSearchOptions, AiSearchResult, AiSurfaceLimits, diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index fb6bfb237..073ccb02c 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -535,7 +535,9 @@ export type { AiToolDefinition, AiExtraTool, AiValidationResult, + AiContextRetriever, AiResourceContent, + AiRetrievedNode, AiSearchOptions, AiSearchResult, AiSurfaceLimits, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b20f1bc80..23edbc063 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -394,6 +394,9 @@ importers: '@xnetjs/abuse': specifier: workspace:* version: link:../../packages/abuse + '@xnetjs/brain': + specifier: workspace:* + version: link:../../packages/brain '@xnetjs/canvas': specifier: workspace:* version: link:../../packages/canvas diff --git a/site/src/data/changelog/2026-06-21-assistant-graph-context.json b/site/src/data/changelog/2026-06-21-assistant-graph-context.json new file mode 100644 index 000000000..dceaec561 --- /dev/null +++ b/site/src/data/changelog/2026-06-21-assistant-graph-context.json @@ -0,0 +1,12 @@ +{ + "id": "2026-06-21-assistant-graph-context", + "date": "June 2026", + "title": "The assistant now follows your connections", + "summary": "When you chat with the assistant about your workspace, it now pulls in related items by walking the links between them — not just keyword matches — so multi-step questions get the connected context they need.", + "highlights": [ + "Context now walks typed relations from the best matches (graph-aware retrieval)", + "A token budget keeps it focused instead of dumping the whole workspace in", + "Runs entirely on-device — no embedding model download, no added startup cost" + ], + "tags": ["ai", "app"] +}