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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/workbench/views/AiChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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<AiSurfaceService | null>(
() => (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]
)

Expand Down
76 changes: 76 additions & 0 deletions apps/web/src/workbench/views/ai-graph-retriever.test.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
186 changes: 186 additions & 0 deletions apps/web/src/workbench/views/ai-graph-retriever.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
deleted: boolean
}

/** The minimal NodeStore surface the retriever reads. */
export interface GraphRetrieverStore {
get(id: string): Promise<GraphRetrieverNode | null>
list(options?: { limit?: number }): Promise<GraphRetrieverNode[]>
}

/** Resolve the relation-valued property names for a schema. */
export type RelationFieldsLookup = (schemaId: string) => Promise<readonly string[]>

export interface GraphContextRetrieverOptions {
/** Relation-field resolver; defaults to the global client schema registry. */
relationFieldsOf?: RelationFieldsLookup
/** Override the retrieval budget. */
budget?: Partial<RetrievalBudget>
}

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<string, readonly string[]>()
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<EntryHit[]> {
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<NodeText | null> {
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 }))
}
}
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/src/ai-surface/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ export { AiSurfaceService, createAiSurfaceService } from './service'
export { XNET_AGENT_SKILL_MD } from './skill'
export { flattenRowForTsv, toTsv } from './format'
export type {
AiContextRetriever,
AiDatabaseMutationApplyResult,
AiPageMarkdownApplyAdapter,
AiPageMarkdownApplyAdapterInput,
AiPageMarkdownApplyAdapterResult,
AiPageMarkdownApplyResult,
AiPageMarkdownRollbackResult,
AiResourceContent,
AiRetrievedNode,
AiSearchOptions,
AiSearchResult,
AiSurfaceLimits,
Expand Down
2 changes: 2 additions & 0 deletions packages/plugins/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,9 @@ export type {
AiToolDefinition,
AiExtraTool,
AiValidationResult,
AiContextRetriever,
AiResourceContent,
AiRetrievedNode,
AiSearchOptions,
AiSearchResult,
AiSurfaceLimits,
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions site/src/data/changelog/2026-06-21-assistant-graph-context.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading