@vellumai/assistant 0.8.9-staging.3 → 0.8.9-staging.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/openapi.yaml +16 -115
  2. package/package.json +1 -1
  3. package/src/__tests__/anthropic-provider.test.ts +23 -8
  4. package/src/__tests__/context-window-manager-compact-retry.test.ts +120 -14
  5. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  6. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  7. package/src/__tests__/llm-resolver.test.ts +73 -0
  8. package/src/__tests__/plugin-external-api.test.ts +68 -0
  9. package/src/__tests__/published-app-updater.test.ts +138 -0
  10. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  11. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  12. package/src/cli/commands/memory-v3.ts +57 -199
  13. package/src/config/call-site-defaults.ts +0 -1
  14. package/src/config/llm-resolver.ts +39 -7
  15. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  16. package/src/config/schemas/call-site-catalog.ts +0 -7
  17. package/src/config/schemas/llm.ts +17 -1
  18. package/src/config/schemas/memory-v3.ts +58 -8
  19. package/src/config/seed-inference-profiles.ts +18 -0
  20. package/src/daemon/conversation-agent-loop.ts +9 -17
  21. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  22. package/src/daemon/tool-side-effects.ts +15 -0
  23. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  24. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  25. package/src/memory/v2/consolidation-job.ts +2 -72
  26. package/src/memory/v2/types.ts +5 -0
  27. package/src/plugins/defaults/compaction/window-manager.ts +56 -0
  28. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  29. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  30. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  31. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  32. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  33. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  34. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  35. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  36. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  37. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  38. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  39. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  40. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  41. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  42. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  43. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  44. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  45. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  46. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  47. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  48. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  49. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  50. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  51. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  52. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  53. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  54. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  55. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  56. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  57. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  58. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  59. package/src/plugins/external-api.ts +114 -0
  60. package/src/providers/anthropic/client.ts +9 -10
  61. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  62. package/src/providers/inference/logit-bias.ts +55 -0
  63. package/src/providers/openai/chat-completions-provider.ts +19 -1
  64. package/src/providers/retry.ts +22 -0
  65. package/src/providers/types.ts +6 -0
  66. package/src/runtime/routes/app-management-routes.ts +3 -0
  67. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  68. package/src/services/published-app-updater.ts +30 -8
  69. package/src/tools/skills/load.ts +1 -1
  70. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  71. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  72. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  73. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  74. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  75. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  76. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  77. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  78. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  79. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  80. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  81. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  82. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  83. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  84. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  85. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  86. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  87. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  88. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  89. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  90. package/src/util/map-limit.ts +0 -27
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Tests that the auto-redeploy path deploys the app's *effective* HTML
3
+ * (dist/index.html for multifile apps) rather than the empty `htmlDefinition`.
4
+ */
5
+
6
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
7
+
8
+ import type { AppDefinition } from "../memory/app-store.js";
9
+
10
+ // ── Mocks ───────────────────────────────────────────────────────────────
11
+
12
+ let mockApp: AppDefinition | null = null;
13
+ let mockIsMultifile = false;
14
+ let mockEffectiveHtml = "";
15
+ // A directory that does not exist on disk, so the multifile dist-existence
16
+ // guard reads `false` from the real fs without mocking node:fs (which would
17
+ // leak across test files).
18
+ let mockAppDir = "/tmp/__vellum_test_nonexistent__/app-1";
19
+
20
+ mock.module("../memory/app-store.js", () => ({
21
+ getApp: () => mockApp,
22
+ getAppDirPath: () => mockAppDir,
23
+ isMultifileApp: () => mockIsMultifile,
24
+ resolveEffectiveAppHtml: () => mockEffectiveHtml,
25
+ }));
26
+
27
+ let mockPublishedPage: {
28
+ id: string;
29
+ projectSlug?: string;
30
+ htmlHash: string;
31
+ } | null = null;
32
+ const updatePublishedPageSpy = mock(() => {});
33
+
34
+ mock.module("../memory/published-pages-store.js", () => ({
35
+ getActivePublishedPageByAppId: () => mockPublishedPage,
36
+ updatePublishedPage: updatePublishedPageSpy,
37
+ }));
38
+
39
+ const deploySpy = mock(async (_args: { html: string; name: string }) => ({
40
+ deploymentId: "dep-1",
41
+ url: "https://example.vercel.app",
42
+ }));
43
+
44
+ mock.module("../services/vercel-deploy.js", () => ({
45
+ deployHtmlToVercel: deploySpy,
46
+ }));
47
+
48
+ // credentialBroker.serverUse invokes execute(token) and reports success.
49
+ mock.module("../tools/credentials/broker.js", () => ({
50
+ credentialBroker: {
51
+ serverUse: async ({
52
+ execute,
53
+ }: {
54
+ execute: (token: string) => Promise<unknown>;
55
+ }) => {
56
+ await execute("test-token");
57
+ return { success: true };
58
+ },
59
+ },
60
+ }));
61
+
62
+ const { updatePublishedAppDeployment } =
63
+ await import("../services/published-app-updater.js");
64
+
65
+ function makeApp(overrides: Partial<AppDefinition> = {}): AppDefinition {
66
+ return {
67
+ id: "app-1",
68
+ name: "App",
69
+ schemaJson: "{}",
70
+ htmlDefinition: "",
71
+ createdAt: 1,
72
+ updatedAt: 2,
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ // ── Tests ───────────────────────────────────────────────────────────────
78
+
79
+ describe("updatePublishedAppDeployment", () => {
80
+ beforeEach(() => {
81
+ mockApp = makeApp();
82
+ mockIsMultifile = false;
83
+ mockEffectiveHtml = "";
84
+ mockPublishedPage = { id: "pp-1", projectSlug: "slug", htmlHash: "old" };
85
+ mockAppDir = "/tmp/__vellum_test_nonexistent__/app-1";
86
+ deploySpy.mockClear();
87
+ updatePublishedPageSpy.mockClear();
88
+ });
89
+
90
+ test("deploys the resolved effective HTML, not the empty htmlDefinition", async () => {
91
+ // htmlDefinition is "" (as it is for every multifile app); the real
92
+ // content comes from resolveEffectiveAppHtml. isMultifile=false here keeps
93
+ // the dist guard out of the way so the assertion targets the html source.
94
+ mockApp = makeApp({ htmlDefinition: "" });
95
+ mockEffectiveHtml = "<html><body>real app</body></html>";
96
+
97
+ await updatePublishedAppDeployment("app-1");
98
+
99
+ expect(deploySpy).toHaveBeenCalledTimes(1);
100
+ expect(deploySpy.mock.calls[0][0].html).toBe(
101
+ "<html><body>real app</body></html>",
102
+ );
103
+ });
104
+
105
+ test("skips deploy when a multifile app has no compiled output", async () => {
106
+ mockIsMultifile = true;
107
+ mockApp = makeApp({ formatVersion: 2 });
108
+ mockEffectiveHtml = "<p>App compilation failed.</p>";
109
+ // mockAppDir does not exist → dist/index.html is absent.
110
+
111
+ await updatePublishedAppDeployment("app-1");
112
+
113
+ expect(deploySpy).not.toHaveBeenCalled();
114
+ });
115
+
116
+ test("skips deploy when content hash is unchanged", async () => {
117
+ const html = "<html>same</html>";
118
+ mockEffectiveHtml = html;
119
+ const { createHash } = await import("node:crypto");
120
+ mockPublishedPage = {
121
+ id: "pp-1",
122
+ htmlHash: createHash("sha256").update(html).digest("hex"),
123
+ };
124
+
125
+ await updatePublishedAppDeployment("app-1");
126
+
127
+ expect(deploySpy).not.toHaveBeenCalled();
128
+ });
129
+
130
+ test("skips deploy when the app has no active published page", async () => {
131
+ mockPublishedPage = null;
132
+ mockEffectiveHtml = "<html>x</html>";
133
+
134
+ await updatePublishedAppDeployment("app-1");
135
+
136
+ expect(deploySpy).not.toHaveBeenCalled();
137
+ });
138
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Wire contract for the memory v3 selection set surfaced in the inspector's
3
3
  * Memory tab. Mirrors the return value of `getMemoryV3SelectionForInspector`
4
- * in `assistant/src/memory/v3/selection-log-store.ts`.
4
+ * in `assistant/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts`.
5
5
  *
6
6
  * Canonical wire-contract source. Assistant code imports the types directly
7
7
  * from this file via relative paths; external consumers (web client, gateway,
@@ -12,19 +12,27 @@
12
12
  * `live` / `shadow` reflect the CURRENT `memory-v3-live` / `memory-v3-shadow`
13
13
  * flag state, not the per-turn history (the selection rows don't record which
14
14
  * mode was active when they were written). When `live` is true the rendered
15
- * `injectedText` block was actually injected into the turn; when only `shadow`
16
- * is true it is the block that WOULD have been injected. For stable-flag
17
- * shadow validation these coincide; recording per-turn `injected` is a
18
- * documented follow-up.
15
+ * `injectedText` reflects the live selection; when only `shadow` is true it is
16
+ * the block that WOULD have been injected.
17
+ *
18
+ * `injectedText` is APPROXIMATE, not a verbatim record. It is re-rendered from
19
+ * the persisted selection rows, which store slug/source/pinned but NOT the
20
+ * matched-section identity. A page that live injection sent as a single matched
21
+ * section is re-rendered here as its FULL page (the section ordinal isn't
22
+ * persisted), so the text can differ from what was actually sent to the model.
23
+ * The selected slug set is exact — only the rendered text is full-page
24
+ * approximate. Persisting the per-turn injected section is a documented
25
+ * follow-up.
19
26
  */
20
27
 
21
28
  import { z } from "zod";
22
29
 
23
30
  /**
24
31
  * One selected page in the v3 set. `source` is the lane that surfaced it —
25
- * the daemon emits `l1+l2`, `core+l2`, `needle`, or `carry-forward` — but the
26
- * schema stays a permissive string so a new lane label doesn't break parsing
27
- * on the FE. `pinned` marks a slug carried forward across turns.
32
+ * the daemon emits `needle`, `dense`, `edge`, or `carry-forward` — but the
33
+ * schema stays a permissive string so a new lane label (or a historical
34
+ * pre-lane row) doesn't break parsing on the FE. `pinned` marks a slug carried
35
+ * forward across turns.
28
36
  */
29
37
  export const MemoryV3SelectionRowSchema = z.object({
30
38
  slug: z.string(),
@@ -36,8 +44,9 @@ export type MemoryV3SelectionRow = z.infer<typeof MemoryV3SelectionRowSchema>;
36
44
 
37
45
  /**
38
46
  * Memory v3 selection log shape. `injectedText` is the rendered
39
- * `<memory>…</memory>` block for the selection (actual when `live`, would-be
40
- * when `shadow`-only).
47
+ * `<memory>…</memory>` block for the selection — APPROXIMATE: re-rendered from
48
+ * the persisted rows, with full pages where live injection used a matched
49
+ * section (the section identity isn't persisted). See the file header.
41
50
  */
42
51
  export const MemoryV3SelectionLogSchema = z.object({
43
52
  turn: z.number(),
@@ -1,245 +1,226 @@
1
1
  /**
2
- * Tests for the `memory v3` CLI command group's daemon-side handlers.
2
+ * Tests for the `assistant memory v3` CLI subgroup (section-lane model).
3
3
  *
4
- * The CLI subcommands are thin IPC shells over the handlers in
5
- * `runtime/routes/memory-v3-routes.ts`; the logic worth testing lives there.
6
- * We exercise the handlers directly against a temp workspace + data dir
7
- * injected via their `deps` parameter. Generic taxonomy only
8
- * (domain-a/topic-x, etc.).
9
- *
10
- * Source-of-truth note: `health` and `set-core` build the leaf tree from each
11
- * concept page's `leaves:` frontmatter (via the page index) and union it over
12
- * `assignments.json`, matching the consolidation-injected health block. The
13
- * fixture below writes concept pages with `leaves:` frontmatter as the
14
- * authoritative membership and keeps a *stale* `assignments.json` to prove the
15
- * handlers read the frontmatter, not the stale assignments. The seeded
16
- * skill/CLI-command catalogs are mocked empty so `allSlugs` (derived from the
17
- * page index) is deterministic.
4
+ * Validates:
5
+ * - Subcommand registration (rebuild-index, backfill-sections) under
6
+ * `memory v3`.
7
+ * - Each subcommand maps to the right `cliIpcCall` method.
8
+ * - `backfill-sections` passes a long IPC timeout (the one-time full-corpus
9
+ * embed easily outlasts the default 60s), while `rebuild-index` does not.
10
+ * - IPC error paths return a non-zero exit code without throwing.
18
11
  */
19
12
 
20
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
21
- import { tmpdir } from "node:os";
22
- import { join } from "node:path";
23
- import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
24
-
25
- import * as cliCommandStore from "../../../memory/v2/cli-command-store.js";
26
- import { invalidatePageIndex } from "../../../memory/v2/page-index.js";
27
- import * as skillStore from "../../../memory/v2/skill-store.js";
28
- import {
29
- handleMemoryV3Health,
30
- handleMemoryV3Reconcile,
31
- handleMemoryV3SetCore,
32
- UnknownLeafError,
33
- } from "../../../runtime/routes/memory-v3-routes.js";
34
-
35
- // ---------------------------------------------------------------------------
36
- // Temp data-dir fixture
37
- // ---------------------------------------------------------------------------
38
-
39
- let workspaceDir: string;
40
- let dataDir: string;
41
-
42
- /** Write a leaf markdown file with the given frontmatter under leaves/. */
43
- async function writeLeaf(
44
- relPath: string,
45
- frontmatter: { path: string; in_core: boolean; id?: string },
46
- body = "description",
47
- ): Promise<void> {
48
- const file = join(dataDir, "leaves", `${relPath}.md`);
49
- await mkdir(join(file, ".."), { recursive: true });
50
- const fm = [
51
- "---",
52
- `path: ${frontmatter.path}`,
53
- `in_core: ${frontmatter.in_core}`,
54
- ...(frontmatter.id ? [`id: ${frontmatter.id}`] : []),
55
- "---",
56
- body,
57
- "",
58
- ].join("\n");
59
- await writeFile(file, fm);
13
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
14
+
15
+ import { Command } from "commander";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Mock state
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** The last `cliIpcCall` invocation captured for assertions. */
22
+ let lastIpcCall: {
23
+ method: string;
24
+
25
+ params?: any;
26
+ options?: { timeoutMs?: number; signal?: AbortSignal };
27
+ } | null = null;
28
+
29
+ /** The result that cliIpcCall will return. */
30
+ let mockIpcResult: {
31
+ ok: boolean;
32
+ result?: unknown;
33
+ error?: string;
34
+ } = { ok: true, result: { articles: 3, sections: 12, failures: 0 } };
35
+
36
+ /** Captured log output for assertion. */
37
+ let logOutput: string[] = [];
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Mocks
41
+ // ---------------------------------------------------------------------------
42
+
43
+ mock.module("../../../ipc/cli-client.js", () => ({
44
+ cliIpcCall: async (
45
+ method: string,
46
+ params?: any,
47
+ options?: { timeoutMs?: number; signal?: AbortSignal },
48
+ ) => {
49
+ lastIpcCall = { method, params, options };
50
+ return mockIpcResult;
51
+ },
52
+ }));
53
+
54
+ const capture = (...args: unknown[]) => {
55
+ logOutput.push(args.map(String).join(" "));
56
+ };
57
+ const fakeLogger = {
58
+ info: capture,
59
+ warn: capture,
60
+ error: capture,
61
+ debug: () => {},
62
+ };
63
+ mock.module("../../../util/logger.js", () => ({
64
+ getLogger: () => fakeLogger,
65
+ getCliLogger: () => fakeLogger,
66
+ }));
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Import modules under test (after mocks)
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const { registerMemoryV3Command } = await import("../memory-v3.js");
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Test helpers
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function buildProgram(): Command {
79
+ const program = new Command();
80
+ program.exitOverride();
81
+ program.configureOutput({
82
+ writeErr: () => {},
83
+ writeOut: () => {},
84
+ });
85
+ registerMemoryV3Command(program);
86
+ return program;
60
87
  }
61
88
 
62
- /** Write a concept page with `leaves:` frontmatter under memory/concepts/. */
63
- async function writePage(slug: string, leaves: string[]): Promise<void> {
64
- const file = join(workspaceDir, "memory", "concepts", `${slug}.md`);
65
- await mkdir(join(file, ".."), { recursive: true });
66
- const fm = [
67
- "---",
68
- `summary: ${slug} summary`,
69
- "leaves:",
70
- ...leaves.map((l) => ` - ${l}`),
71
- "---",
72
- `${slug} body`,
73
- "",
74
- ].join("\n");
75
- await writeFile(file, fm);
89
+ async function runCommand(
90
+ args: string[],
91
+ ): Promise<{ stdout: string; exitCode: number }> {
92
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
93
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
94
+ const stdoutChunks: string[] = [];
95
+
96
+ process.stdout.write = ((chunk: unknown) => {
97
+ stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk));
98
+ return true;
99
+ }) as typeof process.stdout.write;
100
+
101
+ process.stderr.write = (() => true) as typeof process.stderr.write;
102
+
103
+ process.exitCode = 0;
104
+
105
+ try {
106
+ const program = buildProgram();
107
+ await program.parseAsync(["node", "assistant", ...args]);
108
+ } catch {
109
+ if (process.exitCode === 0) process.exitCode = 1;
110
+ } finally {
111
+ process.stdout.write = originalStdoutWrite;
112
+ process.stderr.write = originalStderrWrite;
113
+ }
114
+
115
+ const exitCode = process.exitCode ?? 0;
116
+ process.exitCode = 0;
117
+
118
+ return { exitCode, stdout: stdoutChunks.join("") };
76
119
  }
77
120
 
78
- beforeEach(async () => {
79
- workspaceDir = await mkdtemp(join(tmpdir(), "mem-v3-cli-ws-"));
80
- dataDir = join(workspaceDir, "memory", "v3", "data");
81
- await mkdir(dataDir, { recursive: true });
82
-
83
- // Keep the page index deterministic regardless of the on-disk seeded
84
- // catalogs, and clear any module-cached index from a prior test.
85
- spyOn(skillStore, "listSkillEntries").mockReturnValue([]);
86
- spyOn(cliCommandStore, "listCliCommandEntries").mockReturnValue([]);
87
- invalidatePageIndex();
88
-
89
- // A small generic tree: two leaves under domain-a, one under domain-b.
90
- await writeLeaf("domain-a/topic-x", {
91
- path: "domain-a/topic-x",
92
- in_core: true,
93
- id: "leaf-x",
94
- });
95
- await writeLeaf("domain-a/topic-y", {
96
- path: "domain-a/topic-y",
97
- in_core: false,
98
- id: "leaf-y",
99
- });
100
- await writeLeaf("domain-b/topic-z", {
101
- path: "domain-b/topic-z",
102
- in_core: false,
103
- id: "leaf-z",
104
- });
121
+ // ---------------------------------------------------------------------------
122
+ // Setup
123
+ // ---------------------------------------------------------------------------
105
124
 
106
- // Authoritative membership lives in each page's `leaves:` frontmatter.
107
- await writePage("page-1", ["domain-a/topic-x"]);
108
- await writePage("page-2", ["domain-a/topic-x", "domain-a/topic-y"]);
109
- await writePage("page-3", ["domain-b/topic-z"]);
110
-
111
- // STALE assignments.json: deliberately divergent from the frontmatter above.
112
- // It re-maps the SAME slugs that already carry `leaves:` frontmatter to the
113
- // WRONG leaves. `loadLeafTree`'s per-page precedence only lets frontmatter win
114
- // when it is non-empty, so for these slugs the frontmatter wins and
115
- // assignments.json contributes no membership — proving the handlers read the
116
- // page index, not these stale assignments (the regression Defect A guards
117
- // against). We deliberately avoid a slug that exists ONLY in assignments.json:
118
- // such a slug has no frontmatter, falls back to its assignments entry, and
119
- // would inflate the cost preview below with phantom membership.
120
- await writeFile(
121
- join(dataDir, "assignments.json"),
122
- JSON.stringify({
123
- "page-1": ["domain-a/bogus-leaf"],
124
- "page-2": ["domain-b/topic-z"],
125
- }),
126
- );
127
- await writeFile(
128
- join(dataDir, "core.json"),
129
- JSON.stringify({ alwaysOn: ["domain-a/topic-x"] }),
130
- );
125
+ beforeEach(() => {
126
+ lastIpcCall = null;
127
+ mockIpcResult = {
128
+ ok: true,
129
+ result: { articles: 3, sections: 12, failures: 0 },
130
+ };
131
+ logOutput = [];
132
+ process.exitCode = 0;
131
133
  });
132
134
 
133
- afterEach(async () => {
134
- invalidatePageIndex();
135
- await rm(workspaceDir, { recursive: true, force: true });
135
+ // ---------------------------------------------------------------------------
136
+ // Subcommand registration
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe("subcommand registration", () => {
140
+ test("registers v3 under memory with the expected subcommands", () => {
141
+ const program = buildProgram();
142
+ const memory = program.commands.find((c) => c.name() === "memory");
143
+ expect(memory).toBeDefined();
144
+ const v3 = memory!.commands.find((c) => c.name() === "v3");
145
+ expect(v3).toBeDefined();
146
+ const subcommandNames = v3!.commands.map((c) => c.name()).sort();
147
+ expect(subcommandNames).toEqual(["backfill-sections", "rebuild-index"]);
148
+ });
136
149
  });
137
150
 
138
151
  // ---------------------------------------------------------------------------
139
- // set-core
152
+ // rebuild-index
140
153
  // ---------------------------------------------------------------------------
141
154
 
142
- describe("handleMemoryV3SetCore", () => {
143
- test("rejects an unknown leaf and does not write", async () => {
144
- const call = handleMemoryV3SetCore(
145
- { add: ["domain-a/does-not-exist"], write: true },
146
- { dataDir, workspaceDir },
147
- );
148
- await expect(call).rejects.toThrow(/domain-a\/does-not-exist/);
149
- await expect(
150
- handleMemoryV3SetCore(
151
- { add: ["domain-a/does-not-exist"], write: true },
152
- { dataDir, workspaceDir },
153
- ),
154
- ).rejects.toBeInstanceOf(UnknownLeafError);
155
+ describe("memory v3 rebuild-index", () => {
156
+ test("sends memory_v3_rebuild_index with the default (no) timeout", async () => {
157
+ mockIpcResult = { ok: true, result: { invalidated: true } };
155
158
 
156
- // core.json must be untouched (still just the original entry).
157
- const core = JSON.parse(await readFile(join(dataDir, "core.json"), "utf8"));
158
- expect(core.alwaysOn).toEqual(["domain-a/topic-x"]);
159
- });
159
+ const { exitCode } = await runCommand(["memory", "v3", "rebuild-index"]);
160
160
 
161
- test("previews the always-on page count from frontmatter membership", async () => {
162
- // Adding topic-y to core: core = {topic-x, topic-y}. Membership comes from
163
- // page `leaves:` frontmatter — topic-x → {page-1, page-2}, topic-y →
164
- // {page-2} ⇒ unique always-on slugs {page-1, page-2} = 2. If the handler
165
- // read the stale assignments.json instead (page-1 → bogus-leaf, page-2 →
166
- // topic-z), topic-x/topic-y would have no members and this would be 0.
167
- const result = await handleMemoryV3SetCore(
168
- { add: ["domain-a/topic-y"] },
169
- { dataDir, workspaceDir },
170
- );
171
- expect(result.written).toBe(false);
172
- expect(result.nextCore).toEqual(["domain-a/topic-x", "domain-a/topic-y"]);
173
- expect(result.alwaysOnPageCount).toBe(2);
174
-
175
- // No write happened — core.json still holds only the original entry.
176
- const core = JSON.parse(await readFile(join(dataDir, "core.json"), "utf8"));
177
- expect(core.alwaysOn).toEqual(["domain-a/topic-x"]);
161
+ expect(exitCode).toBe(0);
162
+ expect(lastIpcCall!.method).toBe("memory_v3_rebuild_index");
163
+ expect(lastIpcCall!.params!.body).toEqual({});
164
+ // rebuild-index is fast; it relies on the default call timeout.
165
+ expect(lastIpcCall!.options).toBeUndefined();
178
166
  });
179
167
 
180
- test("writes core.json on --yes (write: true)", async () => {
181
- const result = await handleMemoryV3SetCore(
182
- { add: ["domain-b/topic-z"], write: true },
183
- { dataDir, workspaceDir },
184
- );
185
- expect(result.written).toBe(true);
186
- expect(result.nextCore).toEqual(["domain-a/topic-x", "domain-b/topic-z"]);
168
+ test("exits with code 1 on IPC failure", async () => {
169
+ mockIpcResult = { ok: false, error: "Daemon down" };
187
170
 
188
- const core = JSON.parse(await readFile(join(dataDir, "core.json"), "utf8"));
189
- expect(core.alwaysOn).toEqual(["domain-a/topic-x", "domain-b/topic-z"]);
190
- });
171
+ const { exitCode } = await runCommand(["memory", "v3", "rebuild-index"]);
191
172
 
192
- test("remove is idempotent for an absent entry", async () => {
193
- const result = await handleMemoryV3SetCore(
194
- { remove: ["domain-a/topic-y"], write: true },
195
- { dataDir, workspaceDir },
196
- );
197
- // topic-y was never in core; result is the original core unchanged.
198
- expect(result.nextCore).toEqual(["domain-a/topic-x"]);
173
+ expect(exitCode).toBe(1);
199
174
  });
200
175
  });
201
176
 
202
177
  // ---------------------------------------------------------------------------
203
- // health
178
+ // backfill-sections
204
179
  // ---------------------------------------------------------------------------
205
180
 
206
- describe("handleMemoryV3Health", () => {
207
- test("computes the slug universe from page frontmatter, not assignments.json", async () => {
208
- const result = await handleMemoryV3Health({ dataDir, workspaceDir });
209
- // domain-b/topic-z has a single member ⇒ a tiny leaf, so the report is
210
- // non-empty and renders the memory-v3 health header.
211
- expect(result.rendered).toContain("memory-v3 health:");
212
- expect(result.counts.tinyLeaves).toBeGreaterThanOrEqual(1);
213
- // Every page leaf ref (from frontmatter) resolves to a real leaf file ⇒ no
214
- // dangling refs. If assignments.json were the source, page-1's bogus-leaf
215
- // would dangle.
216
- expect(result.counts.danglingRefs).toBe(0);
217
- // The slug universe is the page-index pages (page-1..3), all assigned via
218
- // frontmatter, so nothing is unassigned.
219
- expect(result.counts.unassigned).toBe(0);
181
+ describe("memory v3 backfill-sections", () => {
182
+ test("sends memory_v3_backfill_sections with a long (30-min) IPC timeout", async () => {
183
+ mockIpcResult = {
184
+ ok: true,
185
+ result: { articles: 50, sections: 400, failures: 0 },
186
+ };
187
+
188
+ const { exitCode } = await runCommand([
189
+ "memory",
190
+ "v3",
191
+ "backfill-sections",
192
+ ]);
193
+
194
+ expect(exitCode).toBe(0);
195
+ expect(lastIpcCall!.method).toBe("memory_v3_backfill_sections");
196
+ expect(lastIpcCall!.params!.body).toEqual({});
197
+ // The one-time full-corpus embed is long-running, so the CLI must override
198
+ // the default 60s timeout with a generous ceiling (30 min).
199
+ expect(lastIpcCall!.options!.timeoutMs).toBe(30 * 60 * 1000);
220
200
  });
221
201
 
222
- test("flags a page frontmatter ref with no backing leaf as dangling", async () => {
223
- await writePage("page-4", ["domain-a/missing-leaf"]);
224
- invalidatePageIndex();
225
- const result = await handleMemoryV3Health({ dataDir, workspaceDir });
226
- expect(result.counts.danglingRefs).toBeGreaterThanOrEqual(1);
202
+ test("summarizes embedded sections on success", async () => {
203
+ mockIpcResult = {
204
+ ok: true,
205
+ result: { articles: 7, sections: 42, failures: 0 },
206
+ };
207
+
208
+ await runCommand(["memory", "v3", "backfill-sections"]);
209
+
210
+ expect(
211
+ logOutput.some((line) => line.includes("42") && line.includes("7")),
212
+ ).toBe(true);
227
213
  });
228
- });
229
214
 
230
- // ---------------------------------------------------------------------------
231
- // reconcile
232
- // ---------------------------------------------------------------------------
215
+ test("exits with code 1 on IPC failure", async () => {
216
+ mockIpcResult = { ok: false, error: "Memory v3 not enabled" };
217
+
218
+ const { exitCode } = await runCommand([
219
+ "memory",
220
+ "v3",
221
+ "backfill-sections",
222
+ ]);
233
223
 
234
- describe("handleMemoryV3Reconcile", () => {
235
- test("no-op reconcile against an unchanged tree returns empty diffs", async () => {
236
- // v1: prevLeaves is derived from the current tree, so diffing
237
- // current-vs-current yields no renames/deletes — reconcile runs as a
238
- // convergence/prune pass. This still drives the full reconcileTree path
239
- // (snapshot → apply → validate → invalidate).
240
- const result = await handleMemoryV3Reconcile({ dataDir, workspaceDir });
241
- expect(result.renames).toEqual([]);
242
- expect(result.deleted).toEqual([]);
243
- expect(result.prunedCore).toEqual([]);
224
+ expect(exitCode).toBe(1);
244
225
  });
245
226
  });