diff --git a/assistant/openapi.yaml b/assistant/openapi.yaml index a6b753972e7..6bd22147959 100644 --- a/assistant/openapi.yaml +++ b/assistant/openapi.yaml @@ -11697,6 +11697,48 @@ paths: type: object properties: {} additionalProperties: false + /v1/memory/v3/tree: + post: + operationId: memory_v3_tree_post + summary: Return a serializable view of the memory v3 tree DAG (read-only) + description: + Returns the v3 tree root id plus every node and its ordered child refs (page:/node:) as a JSON-serializable + projection of the in-memory TreeIndex. Read-only; the CLI uses it to print an indented tree with shared-DAG + re-entries marked. + tags: + - memory + responses: + "200": + description: Successful response + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: {} + additionalProperties: false + /v1/memory/v3/validate: + post: + operationId: memory_v3_validate_post + summary: Validate the memory v3 tree structure (read-only) + description: + Read-only structural validation of the hand-authored v3 tree DAG. Reports dangling child refs, orphan + pages, cycles, stale compositional indexes, and unknown edge targets. Writes nothing and runs no LLM — operators + dry-run it while the v2 → v3 migration is in flight. + tags: + - memory + responses: + "200": + description: Successful response + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: {} + additionalProperties: false /v1/messages: get: operationId: messages_get diff --git a/assistant/src/cli/commands/__tests__/memory-v3-render.test.ts b/assistant/src/cli/commands/__tests__/memory-v3-render.test.ts new file mode 100644 index 00000000000..343c921bc84 --- /dev/null +++ b/assistant/src/cli/commands/__tests__/memory-v3-render.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; + +import type { + MemoryV3TreeResult, + MemoryV3ValidateResult, +} from "../../../runtime/routes/memory-v3-routes.js"; +import { + renderTree, + renderValidationReport, + reportHasDefects, +} from "../memory-v3-render.js"; + +function cleanReport(): MemoryV3ValidateResult { + return { + danglingChildRefs: [], + danglingChildRefCount: 0, + orphanPages: [], + orphanPageCount: 0, + cycles: [], + cycleCount: 0, + staleIndex: [], + staleIndexCount: 0, + unknownEdgeTargets: [], + unknownEdgeTargetCount: 0, + }; +} + +describe("memory v3 — renderValidationReport", () => { + test("renders 'none' for every empty category", () => { + const out = renderValidationReport(cleanReport()); + expect(out).toContain("Memory v3 Tree Validation"); + expect(out).toContain("Dangling child refs: none"); + expect(out).toContain("Orphan pages: none"); + expect(out).toContain("Cycles: none"); + expect(out).toContain("Stale index: none"); + expect(out).toContain("Unknown edge targets: none"); + }); + + test("renders counts and offending ids for each defect category", () => { + const report: MemoryV3ValidateResult = { + danglingChildRefs: [{ node: "people", ref: "ghost", kind: "node" }], + danglingChildRefCount: 1, + orphanPages: ["stray-page"], + orphanPageCount: 1, + cycles: [{ from: "a", to: "b" }], + cycleCount: 1, + staleIndex: [ + { node: "root", child: "people", nodeMtimeMs: 1, childMtimeMs: 2 }, + ], + staleIndexCount: 1, + unknownEdgeTargets: [{ from: "p1", to: "missing" }], + unknownEdgeTargetCount: 1, + }; + const out = renderValidationReport(report); + expect(out).toContain("Dangling child refs: 1"); + expect(out).toContain("people → node:ghost"); + expect(out).toContain("Orphan pages: 1"); + expect(out).toContain("- stray-page"); + expect(out).toContain("Cycles: 1"); + expect(out).toContain("a → b"); + expect(out).toContain("Stale index: 1"); + expect(out).toContain("root (older than child people)"); + expect(out).toContain("Unknown edge targets: 1"); + expect(out).toContain("p1 → missing"); + }); +}); + +describe("memory v3 — reportHasDefects", () => { + test("false for a clean report", () => { + expect(reportHasDefects(cleanReport())).toBe(false); + }); + + test("true when any single category is non-empty", () => { + const report = cleanReport(); + report.orphanPageCount = 1; + report.orphanPages = ["x"]; + expect(reportHasDefects(report)).toBe(true); + }); +}); + +describe("memory v3 — renderTree", () => { + test("renders an indented tree descending node and page children", () => { + const view: MemoryV3TreeResult = { + root: "_root", + nodes: [ + { + id: "_root", + children: [ + { kind: "node", ref: "people" }, + { kind: "page", ref: "overview" }, + ], + }, + { + id: "people", + children: [{ kind: "page", ref: "alice" }], + }, + ], + }; + const out = renderTree(view); + expect(out).toBe( + ["node:_root", " node:people", " page:alice", " page:overview"].join( + "\n", + ), + ); + }); + + test("marks a shared DAG sub-node as a re-entry rather than re-expanding", () => { + const view: MemoryV3TreeResult = { + root: "_root", + nodes: [ + { + id: "_root", + children: [ + { kind: "node", ref: "a" }, + { kind: "node", ref: "b" }, + ], + }, + { id: "a", children: [{ kind: "node", ref: "shared" }] }, + { id: "b", children: [{ kind: "node", ref: "shared" }] }, + { id: "shared", children: [{ kind: "page", ref: "leaf" }] }, + ], + }; + const out = renderTree(view); + // First reach under `a` expands; second reach under `b` is a marked re-entry. + expect(out).toContain(" node:a\n node:shared\n page:leaf"); + expect(out).toContain("node:shared (↑ already shown)"); + // The leaf page is expanded exactly once. + expect(out.match(/page:leaf/g)?.length).toBe(1); + }); + + test("bounds output on a cycle instead of looping forever", () => { + const view: MemoryV3TreeResult = { + root: "_root", + nodes: [ + { id: "_root", children: [{ kind: "node", ref: "a" }] }, + { id: "a", children: [{ kind: "node", ref: "_root" }] }, + ], + }; + const out = renderTree(view); + expect(out).toContain("node:_root (↑ already shown)"); + }); + + test("flags a child ref whose target node is missing", () => { + const view: MemoryV3TreeResult = { + root: "_root", + nodes: [{ id: "_root", children: [{ kind: "node", ref: "ghost" }] }], + }; + const out = renderTree(view); + expect(out).toContain("node:ghost (missing)"); + }); + + test("lists nodes unreachable from the root", () => { + const view: MemoryV3TreeResult = { + root: "_root", + nodes: [ + { id: "_root", children: [] }, + { id: "floating", children: [] }, + ], + }; + const out = renderTree(view); + expect(out).toContain("Unreachable nodes (1):"); + expect(out).toContain("- node:floating"); + }); +}); diff --git a/assistant/src/cli/commands/memory-v3-render.ts b/assistant/src/cli/commands/memory-v3-render.ts new file mode 100644 index 00000000000..9cbb7a2f7b1 --- /dev/null +++ b/assistant/src/cli/commands/memory-v3-render.ts @@ -0,0 +1,133 @@ +/** + * Text rendering for `assistant memory v3 validate` and `... tree`. + * + * Both functions are pure presentation: they take the daemon route's response + * shape and return a terminal-ready string. They live CLI-side (mirroring + * `memory-v2-compare-render.ts`) and import only the response *types* from the + * daemon route — `cli/no-daemon-internals` permits type-only imports but + * forbids pulling in daemon runtime modules. + */ + +import type { + MemoryV3TreeResult, + MemoryV3ValidateResult, +} from "../../runtime/routes/memory-v3-routes.js"; + +/** + * Render a {@link MemoryV3ValidateResult} into a counts summary plus the + * offending ids for each non-empty category. Categories with zero entries + * print `none` so a clean tree reads at a glance. + */ +export function renderValidationReport(report: MemoryV3ValidateResult): string { + const lines: string[] = [ + "Memory v3 Tree Validation", + "=========================", + `Dangling child refs: ${report.danglingChildRefCount || "none"}`, + ]; + for (const d of report.danglingChildRefs) { + lines.push(` - ${d.node} → ${d.kind}:${d.ref}`); + } + + lines.push(`Orphan pages: ${report.orphanPageCount || "none"}`); + for (const slug of report.orphanPages) { + lines.push(` - ${slug}`); + } + + lines.push(`Cycles: ${report.cycleCount || "none"}`); + for (const c of report.cycles) { + lines.push(` - ${c.from} → ${c.to}`); + } + + lines.push(`Stale index: ${report.staleIndexCount || "none"}`); + for (const s of report.staleIndex) { + lines.push(` - ${s.node} (older than child ${s.child})`); + } + + lines.push( + `Unknown edge targets: ${report.unknownEdgeTargetCount || "none"}`, + ); + for (const e of report.unknownEdgeTargets) { + lines.push(` - ${e.from} → ${e.to}`); + } + + return lines.join("\n"); +} + +/** + * Whether the validation report has any defect in any category. The CLI uses + * this to set a non-zero exit code so `validate` is scriptable as a check. + */ +export function reportHasDefects(report: MemoryV3ValidateResult): boolean { + return ( + report.danglingChildRefCount > 0 || + report.orphanPageCount > 0 || + report.cycleCount > 0 || + report.staleIndexCount > 0 || + report.unknownEdgeTargetCount > 0 + ); +} + +/** + * Render a {@link MemoryV3TreeResult} as an indented tree rooted at `view.root`, + * descending `node:` children depth-first. A node reached more than once + * (shared DAG sub-node) is printed once with a `(↑ …)` re-entry marker rather + * than re-expanded, which also bounds output when the structure contains a + * cycle. `page:` children are printed as leaves under their parent node. + */ +export function renderTree(view: MemoryV3TreeResult): string { + const childrenById = new Map(); + for (const node of view.nodes) { + childrenById.set(node.id, node); + } + + const lines: string[] = []; + const expanded = new Set(); + + const walk = (nodeId: string, depth: number): void => { + const indent = " ".repeat(depth); + const node = childrenById.get(nodeId); + + if (!node) { + lines.push(`${indent}node:${nodeId} (missing)`); + return; + } + + if (expanded.has(nodeId)) { + // Shared DAG sub-node (or a cycle's back-edge): print the reference but + // do not re-expand, so output stays finite and the re-entry is visible. + lines.push(`${indent}node:${nodeId} (↑ already shown)`); + return; + } + expanded.add(nodeId); + lines.push(`${indent}node:${nodeId}`); + + for (const child of node.children) { + if (child.kind === "page") { + lines.push(`${" ".repeat(depth + 1)}page:${child.ref}`); + } else { + walk(child.ref, depth + 1); + } + } + }; + + walk(view.root, 0); + + if (lines.length === 0) { + lines.push("(empty tree)"); + } + + // Surface nodes that exist on disk but were never reached from the root — + // they would otherwise be invisible in a root-anchored print. + const unreached = view.nodes + .map((n) => n.id) + .filter((id) => !expanded.has(id)) + .sort(); + if (unreached.length > 0) { + lines.push("", `Unreachable nodes (${unreached.length}):`); + for (const id of unreached) { + lines.push(` - node:${id}`); + } + } + + return lines.join("\n"); +} diff --git a/assistant/src/cli/commands/memory-v3.ts b/assistant/src/cli/commands/memory-v3.ts new file mode 100644 index 00000000000..fd629bd5287 --- /dev/null +++ b/assistant/src/cli/commands/memory-v3.ts @@ -0,0 +1,161 @@ +/** + * `assistant memory v3` CLI subgroup. + * + * Operator-facing read-only inspection of the v3 memory tree — the DAG overlay + * the v2 → v3 data-migration hand-authors over the flat concept pages. + * + * Subcommands: + * + * - `validate` — print a structural health report (dangling refs, orphan + * pages, cycles, stale indexes, unknown edge targets). Exits non-zero when + * any defect is found so it is scriptable as a check. + * - `tree` — print the tree as an indented outline rooted at the tree root, + * marking shared-DAG re-entries. + * + * Both are read-only: they mutate nothing and run no LLM. `--json` emits the + * raw daemon payload for either subcommand. + */ + +import type { Command } from "commander"; + +import { cliIpcCall } from "../../ipc/cli-client.js"; +import type { + MemoryV3TreeResult, + MemoryV3ValidateResult, +} from "../../runtime/routes/memory-v3-routes.js"; +import { registerCommand } from "../lib/register-command.js"; +import { log } from "../logger.js"; +import { + renderTree, + renderValidationReport, + reportHasDefects, +} from "./memory-v3-render.js"; + +export function registerMemoryV3Command(program: Command): void { + // Reuse an existing `memory` parent if a sibling registrar (e.g. v2) + // attached it first; otherwise create one. Keeps registration order between + // sibling memory registrars unconstrained. + const memory = + program.commands.find((c) => c.name() === "memory") ?? + program + .command("memory") + .description("Manage the memory subsystem (concept-page model)"); + + registerCommand(memory, { + name: "v3", + transport: "ipc", + description: "Memory v3 subsystem operations (tree-DAG overlay)", + build: (v3) => { + v3.addHelpText( + "after", + ` +The v3 memory subsystem layers a hand-authored DAG of tree nodes over the +flat v2 concept pages. Each node lives under /workspace/memory/v3/tree/ and +its frontmatter 'children' list references sub-nodes (node:) and leaf +concept pages (page:). The structure is authored by the v2 → v3 +data-migration, so these subcommands are read-only inspection only — they +mutate nothing and run no LLM. + +Examples: + $ assistant memory v3 validate + $ assistant memory v3 tree + $ assistant memory v3 tree --json | jq '.nodes | length'`, + ); + + // ── validate ────────────────────────────────────────────────────────── + + v3.command("validate") + .description( + "Print a structural health report of the v3 tree (read-only)", + ) + .option("--json", "Emit raw JSON instead of a formatted report") + .addHelpText( + "after", + ` +Walks the hand-authored v3 tree DAG and reports: + - Dangling child refs (node:/page: targets that do not exist) + - Orphan pages (concept pages not reachable from the tree root) + - Cycles (back-edges in the node:/node: adjacency) + - Stale indexes (a node older than a child it composes) + - Unknown edge targets (page edges: pointing at a missing slug) + +Read-only — mutates nothing. Exits non-zero if any defect is reported, so it +is usable as a pre-flight check while the v2 → v3 migration is in flight. + +Examples: + $ assistant memory v3 validate + $ assistant memory v3 validate --json | jq '.orphanPageCount'`, + ) + .action(async (opts: { json?: boolean }) => { + const result = await cliIpcCall( + "memory_v3_validate", + { body: {} }, + ); + + if (!result.ok) { + log.error(result.error ?? "Failed to validate memory v3 tree"); + process.exitCode = 1; + return; + } + + const report = result.result!; + + if (opts.json === true) { + log.info(JSON.stringify(report, null, 2)); + } else { + log.info(renderValidationReport(report)); + } + + if (reportHasDefects(report)) { + process.exitCode = 1; + } + }); + + // ── tree ────────────────────────────────────────────────────────────── + + v3.command("tree") + .description( + "Print the v3 tree as an indented outline from the root (read-only)", + ) + .option("--json", "Emit raw JSON instead of a formatted tree") + .addHelpText( + "after", + ` +Descends the v3 tree depth-first from its root node, printing one line per +node:/page: ref with indentation by depth. A node reached more than once +(shared DAG sub-node or a cycle back-edge) is printed once with a re-entry +marker rather than re-expanded, so output is finite. Nodes that exist on disk +but are unreachable from the root are listed separately. + +Read-only — mutates nothing. + +Examples: + $ assistant memory v3 tree + $ assistant memory v3 tree --json | jq '.root'`, + ) + .action(async (opts: { json?: boolean }) => { + const result = await cliIpcCall( + "memory_v3_tree", + { + body: {}, + }, + ); + + if (!result.ok) { + log.error(result.error ?? "Failed to read memory v3 tree"); + process.exitCode = 1; + return; + } + + const view = result.result!; + + if (opts.json === true) { + log.info(JSON.stringify(view, null, 2)); + return; + } + + log.info(renderTree(view)); + }); + }, + }); +} diff --git a/assistant/src/cli/program.ts b/assistant/src/cli/program.ts index 4a8cab87e00..1e55e5a0b48 100644 --- a/assistant/src/cli/program.ts +++ b/assistant/src/cli/program.ts @@ -34,6 +34,7 @@ import { registerInferenceCommand } from "./commands/inference.js"; import { registerKeysCommand } from "./commands/keys.js"; import { registerMcpCommand } from "./commands/mcp.js"; import { registerMemoryV2Command } from "./commands/memory-v2.js"; +import { registerMemoryV3Command } from "./commands/memory-v3.js"; import { registerNotificationsCommand } from "./commands/notifications.js"; import { registerOAuthCommand } from "./commands/oauth/index.js"; import { registerPendingCommand } from "./commands/pending.js"; @@ -129,6 +130,7 @@ Examples: registerKeysCommand(program); registerMcpCommand(program); registerMemoryV2Command(program); + registerMemoryV3Command(program); registerNotificationsCommand(program); registerOAuthCommand(program); registerPendingCommand(program); diff --git a/assistant/src/runtime/routes/index.ts b/assistant/src/runtime/routes/index.ts index 0c0069fbc03..8f49c11d54c 100644 --- a/assistant/src/runtime/routes/index.ts +++ b/assistant/src/runtime/routes/index.ts @@ -90,6 +90,7 @@ import { ROUTES as LOG_EXPORT_ROUTES } from "./log-export-routes.js"; import { ROUTES as MCP_AUTH_ROUTES } from "./mcp-auth-routes.js"; import { ROUTES as MEMORY_ITEM_ROUTES } from "./memory-item-routes.js"; import { ROUTES as MEMORY_V2_ROUTES } from "./memory-v2-routes.js"; +import { ROUTES as MEMORY_V3_ROUTES } from "./memory-v3-routes.js"; import { ROUTES as MIGRATION_ROLLBACK_ROUTES } from "./migration-rollback-routes.js"; import { ROUTES as MIGRATION_ROUTES } from "./migration-routes.js"; import { ROUTES as NOTIFICATION_ROUTES } from "./notification-routes.js"; @@ -216,6 +217,7 @@ export const ROUTES: RouteDefinition[] = [ ...LLM_CALL_SITES_ROUTES, ...MEMORY_ITEM_ROUTES, ...MEMORY_V2_ROUTES, + ...MEMORY_V3_ROUTES, ...MIGRATION_ROLLBACK_ROUTES, ...MIGRATION_ROUTES, ...NOTIFICATION_ROUTES, diff --git a/assistant/src/runtime/routes/memory-v3-routes.ts b/assistant/src/runtime/routes/memory-v3-routes.ts new file mode 100644 index 00000000000..f6e8b2cef06 --- /dev/null +++ b/assistant/src/runtime/routes/memory-v3-routes.ts @@ -0,0 +1,117 @@ +/** + * Memory v3 route definitions — read-only diagnostics over the hand-authored + * v3 tree DAG. + * + * Two operations, both side-effect-free (no LLM, no writes): + * + * - `memory_v3_validate` — returns the {@link TreeValidationReport} from + * `validateTree(workspaceDir)` (orphan pages, cycles, dangling refs, + * stale-index, unknown edge targets). + * - `memory_v3_tree` — returns a JSON-serializable view of + * `getTreeIndex(workspaceDir)`: the root id, every node id, and each + * node's ordered child refs. `TreeIndex` is Map-based, so the handler + * flattens it into arrays/objects the wire protocol can carry. + * + * The v3 tree is authored by the v2 → v3 data-migration; these routes are the + * on-demand inspection surface operators run while that migration is in flight. + * They are NOT invoked on any turn. + */ + +import { z } from "zod"; + +import { getTreeIndex } from "../../memory/v3/tree-index.js"; +import type { TreeValidationReport } from "../../memory/v3/validate.js"; +import { validateTree } from "../../memory/v3/validate.js"; +import { getWorkspaceDir } from "../../util/platform.js"; +import type { RouteDefinition, RouteHandlerArgs } from "./types.js"; + +// ── Validate ──────────────────────────────────────────────────────────── + +const MemoryV3ValidateParams = z.object({}).strict(); + +/** + * Wire shape for `memory_v3_validate`. Identical to the daemon-internal + * {@link TreeValidationReport} — every field is already serializable, so the + * route forwards it verbatim. Re-exported as its own type so the CLI can + * import it without reaching into the validator module. + */ +export type MemoryV3ValidateResult = TreeValidationReport; + +async function handleValidate({ + body = {}, +}: RouteHandlerArgs): Promise { + // Read-only structural validation of the v3 tree. Like the v2 validate + // route, it is intentionally ungated: operators dry-run it while the + // v2 → v3 migration is mid-flight, well before any v3 flag flips. + MemoryV3ValidateParams.parse(body); + return validateTree(getWorkspaceDir()); +} + +// ── Tree ──────────────────────────────────────────────────────────────── + +const MemoryV3TreeParams = z.object({}).strict(); + +/** One node in the serialized tree view: its id and ordered child refs. */ +export interface MemoryV3TreeNodeView { + id: string; + children: Array<{ kind: "node" | "page"; ref: string }>; +} + +/** + * JSON-serializable projection of the {@link TreeIndex}. `TreeIndex` keys its + * adjacency by `Map`, which doesn't survive JSON, so the handler flattens it: + * `root` is the entry-point node id and `nodes` is every node with its ordered + * child refs. The CLI renderer walks `nodes`/`root` to print an indented tree, + * marking shared-DAG re-entries. + */ +export interface MemoryV3TreeResult { + root: string; + nodes: MemoryV3TreeNodeView[]; +} + +async function handleTree({ + body = {}, +}: RouteHandlerArgs): Promise { + MemoryV3TreeParams.parse(body); + + const tree = await getTreeIndex(getWorkspaceDir()); + + const nodes: MemoryV3TreeNodeView[] = [...tree.nodes.keys()] + .sort() + .map((id) => ({ + id, + children: (tree.childrenByNode.get(id) ?? []).map((child) => ({ + kind: child.kind, + ref: child.ref, + })), + })); + + return { root: tree.root, nodes }; +} + +// ── Route definitions ─────────────────────────────────────────────────── + +export const ROUTES: RouteDefinition[] = [ + { + operationId: "memory_v3_validate", + method: "POST", + endpoint: "memory/v3/validate", + handler: handleValidate, + summary: "Validate the memory v3 tree structure (read-only)", + description: + "Read-only structural validation of the hand-authored v3 tree DAG. Reports dangling child refs, orphan pages, cycles, stale compositional indexes, and unknown edge targets. Writes nothing and runs no LLM — operators dry-run it while the v2 → v3 migration is in flight.", + tags: ["memory"], + requestBody: MemoryV3ValidateParams, + }, + { + operationId: "memory_v3_tree", + method: "POST", + endpoint: "memory/v3/tree", + handler: handleTree, + summary: "Return a serializable view of the memory v3 tree DAG (read-only)", + description: + "Returns the v3 tree root id plus every node and its ordered child refs (page:/node:) as a JSON-serializable projection of the in-memory TreeIndex. Read-only; the CLI uses it to print an indented tree with shared-DAG re-entries marked.", + tags: ["memory"], + requestBody: MemoryV3TreeParams, + }, +]; diff --git a/gateway/src/risk/command-registry/commands/assistant.ts b/gateway/src/risk/command-registry/commands/assistant.ts index ad7dc18497c..090ed1d8220 100644 --- a/gateway/src/risk/command-registry/commands/assistant.ts +++ b/gateway/src/risk/command-registry/commands/assistant.ts @@ -157,6 +157,9 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [ "memory v2 reembed-skills", "memory v2 activation", "memory v2 validate", + "memory v3", + "memory v3 validate", + "memory v3 tree", "notifications", "notifications send", "notifications list", @@ -482,6 +485,16 @@ const riskOverrides: AssistantRiskOverride[] = [ risk: "low", reason: "Read-only diagnostic walk over concept pages and edges", }, + { + path: "memory v3 validate", + risk: "low", + reason: "Read-only structural validation of the v3 tree DAG", + }, + { + path: "memory v3 tree", + risk: "low", + reason: "Read-only print of the v3 tree DAG structure", + }, { path: "notifications send", risk: "low" }, { path: "oauth request",