@vellumai/assistant 0.7.3 → 0.8.0

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 (169) hide show
  1. package/ARCHITECTURE.md +29 -28
  2. package/Dockerfile +1 -0
  3. package/__tests__/permissions/gateway-threshold-reader.test.ts +236 -9
  4. package/bun.lock +3 -0
  5. package/knip.json +1 -0
  6. package/node_modules/@vellumai/ipc-server-utils/bun.lock +24 -0
  7. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  8. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +6 -0
  9. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +430 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +221 -0
  11. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  12. package/openapi.yaml +22 -4
  13. package/package.json +3 -1
  14. package/src/__tests__/annotate-risk-options.test.ts +291 -0
  15. package/src/__tests__/approval-cascade.test.ts +8 -16
  16. package/src/__tests__/approval-routes-http.test.ts +6 -0
  17. package/src/__tests__/auto-analysis-end-to-end.test.ts +12 -25
  18. package/src/__tests__/call-constants.test.ts +10 -1
  19. package/src/__tests__/call-controller.test.ts +127 -0
  20. package/src/__tests__/cli-memory-v2-reembed-skills.test.ts +58 -28
  21. package/src/__tests__/config-loader-platform-defaults.test.ts +284 -1
  22. package/src/__tests__/context-search-memory-source.test.ts +3 -26
  23. package/src/__tests__/context-search-pkb-source.test.ts +12 -6
  24. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -6
  25. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  26. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -1
  27. package/src/__tests__/conversation-agent-loop.test.ts +3 -3
  28. package/src/__tests__/conversation-confirmation-signals.test.ts +5 -13
  29. package/src/__tests__/conversation-init.benchmark.test.ts +1 -1
  30. package/src/__tests__/conversation-process-callsite.test.ts +1 -6
  31. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -6
  32. package/src/__tests__/conversation-runtime-assembly.test.ts +15 -6
  33. package/src/__tests__/conversation-slash-unknown.test.ts +1 -6
  34. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +170 -9
  35. package/src/__tests__/conversation-surfaces-data-persist.test.ts +73 -1
  36. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +59 -0
  37. package/src/__tests__/conversation-workspace-injection.test.ts +1 -7
  38. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -7
  39. package/src/__tests__/filing-service.test.ts +2 -19
  40. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +10 -26
  41. package/src/__tests__/injector-chain.test.ts +24 -16
  42. package/src/__tests__/injector-pkb-v2-silenced.test.ts +10 -7
  43. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +154 -67
  44. package/src/__tests__/notification-decision-fallback.test.ts +91 -0
  45. package/src/__tests__/notification-decision-strategy.test.ts +22 -0
  46. package/src/__tests__/oauth-cli.test.ts +121 -0
  47. package/src/__tests__/relay-server.test.ts +46 -2
  48. package/src/__tests__/secret-prompt-log-hygiene.test.ts +7 -5
  49. package/src/__tests__/secret-prompter-channel-fallback.test.ts +7 -5
  50. package/src/__tests__/secret-response-routing.test.ts +7 -5
  51. package/src/__tests__/server-history-render.test.ts +82 -0
  52. package/src/__tests__/skill-include-graph.test.ts +31 -0
  53. package/src/__tests__/skill-load-tool.test.ts +44 -16
  54. package/src/__tests__/skills.test.ts +39 -0
  55. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -42
  56. package/src/__tests__/tool-executor.test.ts +155 -0
  57. package/src/__tests__/voice-session-bridge.test.ts +3 -0
  58. package/src/__tests__/workspace-migration-069-seed-onboarding-threads.test.ts +120 -0
  59. package/src/__tests__/workspace-migration-071-remove-safe-storage-release-note.test.ts +206 -0
  60. package/src/__tests__/workspace-migration-safe-storage-limits-release.test.ts +15 -27
  61. package/src/agent/loop.ts +11 -0
  62. package/src/approvals/guardian-decision-primitive.ts +0 -13
  63. package/src/approvals/guardian-request-resolvers.ts +4 -32
  64. package/src/calls/call-constants.ts +5 -8
  65. package/src/calls/call-controller.ts +130 -67
  66. package/src/calls/relay-server.ts +7 -1
  67. package/src/calls/voice-session-bridge.ts +1 -1
  68. package/src/cli/commands/memory-v2.ts +7 -7
  69. package/src/cli/commands/oauth/__tests__/connect.test.ts +0 -254
  70. package/src/cli/commands/oauth/connect.ts +10 -52
  71. package/src/config/bundled-skills/app-builder/SKILL.md +1 -3
  72. package/src/config/feature-flag-registry.json +1 -17
  73. package/src/config/loader.ts +72 -19
  74. package/src/config/schemas/memory-v2.ts +1 -1
  75. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +32 -0
  76. package/src/daemon/conversation-agent-loop-handlers.ts +32 -0
  77. package/src/daemon/conversation-agent-loop.ts +13 -10
  78. package/src/daemon/conversation-lifecycle.ts +22 -8
  79. package/src/daemon/conversation-surfaces.ts +16 -14
  80. package/src/daemon/conversation-tool-setup.ts +9 -5
  81. package/src/daemon/conversation.ts +1 -1
  82. package/src/daemon/handlers/shared.ts +26 -0
  83. package/src/daemon/host-bash-proxy.ts +1 -1
  84. package/src/daemon/host-browser-proxy.ts +1 -1
  85. package/src/daemon/host-cu-proxy.ts +1 -1
  86. package/src/daemon/host-file-proxy.ts +1 -1
  87. package/src/daemon/host-transfer-proxy.ts +2 -2
  88. package/src/daemon/lifecycle.ts +88 -73
  89. package/src/daemon/memory-v2-startup.ts +55 -14
  90. package/src/daemon/message-types/messages.ts +19 -1
  91. package/src/documents/document-store.ts +35 -1
  92. package/src/filing/filing-service.ts +2 -3
  93. package/src/heartbeat/heartbeat-service.ts +1 -1
  94. package/src/ipc/assistant-server.ts +93 -36
  95. package/src/ipc/skill-server.ts +99 -42
  96. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +10 -57
  97. package/src/memory/context-search/sources/memory-v2.ts +1 -17
  98. package/src/memory/context-search/sources/memory.ts +2 -2
  99. package/src/memory/context-search/sources/pkb.ts +2 -3
  100. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +104 -61
  101. package/src/memory/graph/__tests__/handle-remember-v2.test.ts +11 -26
  102. package/src/memory/graph/conversation-graph-memory.ts +32 -9
  103. package/src/memory/graph/graph-search.test.ts +6 -5
  104. package/src/memory/graph/graph-search.ts +3 -4
  105. package/src/memory/graph/retriever.test.ts +12 -7
  106. package/src/memory/graph/retriever.ts +4 -5
  107. package/src/memory/graph/tool-handlers.ts +3 -4
  108. package/src/memory/graph/tools.ts +4 -4
  109. package/src/memory/indexer.ts +1 -2
  110. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +116 -0
  111. package/src/memory/jobs/embed-concept-page.ts +223 -87
  112. package/src/memory/jobs-worker.ts +8 -4
  113. package/src/memory/pkb/pkb-search.test.ts +6 -5
  114. package/src/memory/pkb/pkb-search.ts +4 -5
  115. package/src/memory/qdrant-client.ts +3 -0
  116. package/src/memory/search/semantic.ts +4 -5
  117. package/src/memory/v2/__tests__/activation.test.ts +35 -5
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +21 -32
  119. package/src/memory/v2/__tests__/injection.test.ts +140 -23
  120. package/src/memory/v2/__tests__/qdrant.test.ts +310 -9
  121. package/src/memory/v2/__tests__/sim.test.ts +118 -7
  122. package/src/memory/v2/__tests__/static-context.test.ts +1 -13
  123. package/src/memory/v2/__tests__/sweep-job.test.ts +19 -33
  124. package/src/memory/v2/consolidation-job.ts +7 -8
  125. package/src/memory/v2/injection.ts +32 -12
  126. package/src/memory/v2/page-store.ts +39 -0
  127. package/src/memory/v2/prompts/consolidation.ts +5 -0
  128. package/src/memory/v2/qdrant.ts +209 -48
  129. package/src/memory/v2/sim.ts +67 -26
  130. package/src/memory/v2/static-context.ts +4 -8
  131. package/src/memory/v2/sweep-job.ts +5 -6
  132. package/src/memory/v2/types.ts +7 -0
  133. package/src/notifications/copy-composer.ts +46 -12
  134. package/src/notifications/decision-engine.ts +46 -0
  135. package/src/permissions/gateway-threshold-reader.ts +116 -8
  136. package/src/permissions/prompter.ts +86 -96
  137. package/src/permissions/secret-prompter.ts +31 -31
  138. package/src/plugins/defaults/injectors.ts +1 -2
  139. package/src/proactive-artifact/job.test.ts +51 -4
  140. package/src/proactive-artifact/job.ts +16 -2
  141. package/src/proactive-artifact/message-copy.ts +18 -1
  142. package/src/prompts/templates/SOUL.md +13 -28
  143. package/src/runtime/auth/route-policy.ts +1 -0
  144. package/src/runtime/channel-approvals.ts +3 -2
  145. package/src/runtime/guardian-reply-router.ts +0 -10
  146. package/src/runtime/pending-interactions.ts +19 -15
  147. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +147 -0
  148. package/src/runtime/routes/approval-routes.ts +7 -3
  149. package/src/runtime/routes/consolidation-routes.ts +8 -9
  150. package/src/runtime/routes/conversation-query-routes.ts +44 -1
  151. package/src/runtime/routes/debug-bash-routes.ts +2 -0
  152. package/src/runtime/routes/filing-routes.ts +2 -3
  153. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +0 -3
  154. package/src/runtime/routes/memory-item-routes.test.ts +3 -9
  155. package/src/runtime/routes/memory-item-routes.ts +5 -6
  156. package/src/runtime/routes/memory-v2-routes.ts +103 -17
  157. package/src/skills/include-graph.ts +35 -13
  158. package/src/tools/document/document-tool.ts +20 -0
  159. package/src/tools/executor.ts +18 -2
  160. package/src/tools/memory/register.test.ts +7 -5
  161. package/src/tools/permission-checker.ts +15 -0
  162. package/src/tools/skills/load.ts +24 -20
  163. package/src/tools/tool-name-aliases.ts +19 -0
  164. package/src/tools/types.ts +19 -1
  165. package/src/workspace/migrations/067-release-notes-safe-storage-limits.ts +4 -62
  166. package/src/workspace/migrations/069-seed-onboarding-threads.ts +28 -0
  167. package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +31 -0
  168. package/src/workspace/migrations/071-remove-safe-storage-release-note.ts +111 -0
  169. package/src/workspace/migrations/registry.ts +6 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Tests for the memory v2 route handlers in `memory-v2-routes.ts`.
3
+ *
4
+ * Currently focused on `memory_v2_list_concept_pages`:
5
+ * - empty workspace → returns no pages
6
+ * - populated workspace → surfaces slug, bodyBytes, edgeCount, updatedAtMs
7
+ * - corrupt page on disk → logged-and-skipped, does not poison listing
8
+ */
9
+
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { mkdir, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
15
+
16
+ import { writePage } from "../../../memory/v2/page-store.js";
17
+ import type { ConceptPage } from "../../../memory/v2/types.js";
18
+ import type { MemoryV2ListConceptPagesResult } from "../memory-v2-routes.js";
19
+ import { ROUTES } from "../memory-v2-routes.js";
20
+ import type { RouteDefinition } from "../types.js";
21
+
22
+ // ─── Setup ─────────────────────────────────────────────────────────────────
23
+
24
+ let workspaceDir: string;
25
+ let origWorkspaceDir: string | undefined;
26
+
27
+ function findHandler(operationId: string): RouteDefinition["handler"] {
28
+ const route = ROUTES.find((r) => r.operationId === operationId);
29
+ if (!route) throw new Error(`Route ${operationId} not found`);
30
+ return route.handler;
31
+ }
32
+
33
+ beforeEach(() => {
34
+ workspaceDir = mkdtempSync(join(tmpdir(), "vellum-memv2-list-"));
35
+ origWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
36
+ process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
37
+ });
38
+
39
+ afterEach(() => {
40
+ if (origWorkspaceDir === undefined) {
41
+ delete process.env.VELLUM_WORKSPACE_DIR;
42
+ } else {
43
+ process.env.VELLUM_WORKSPACE_DIR = origWorkspaceDir;
44
+ }
45
+ try {
46
+ rmSync(workspaceDir, { recursive: true, force: true });
47
+ } catch {
48
+ // best-effort cleanup
49
+ }
50
+ });
51
+
52
+ // ─── Tests ─────────────────────────────────────────────────────────────────
53
+
54
+ describe("memory_v2_list_concept_pages handler", () => {
55
+ test("returns empty list for an empty workspace", async () => {
56
+ const handler = findHandler("memory_v2_list_concept_pages");
57
+ const result = (await handler({
58
+ body: {},
59
+ })) as MemoryV2ListConceptPagesResult;
60
+
61
+ expect(result).toEqual({ pages: [] });
62
+ });
63
+
64
+ test("returns slugs, body bytes, edge counts, and mtimes for populated workspace", async () => {
65
+ const before = Date.now();
66
+
67
+ const pages: ConceptPage[] = [
68
+ {
69
+ slug: "alice",
70
+ frontmatter: { edges: ["bob", "carol"], ref_files: [] },
71
+ body: "Alice prefers VS Code.\n",
72
+ },
73
+ {
74
+ slug: "bob",
75
+ frontmatter: { edges: [], ref_files: [] },
76
+ body: "Bob ships at end of day.\nLikes async standups.\n",
77
+ },
78
+ {
79
+ slug: "people/carol",
80
+ frontmatter: { edges: ["alice"], ref_files: [] },
81
+ body: "Carol leads the platform team.\n",
82
+ },
83
+ ];
84
+ for (const page of pages) {
85
+ await writePage(workspaceDir, page);
86
+ }
87
+
88
+ const handler = findHandler("memory_v2_list_concept_pages");
89
+ const result = (await handler({
90
+ body: {},
91
+ })) as MemoryV2ListConceptPagesResult;
92
+
93
+ expect(result.pages).toHaveLength(3);
94
+
95
+ const bySlug = new Map(result.pages.map((p) => [p.slug, p]));
96
+
97
+ const alice = bySlug.get("alice");
98
+ expect(alice).toBeDefined();
99
+ expect(alice!.bodyBytes).toBe(Buffer.byteLength(pages[0]!.body, "utf8"));
100
+ expect(alice!.edgeCount).toBe(2);
101
+ expect(alice!.updatedAtMs).toBeGreaterThanOrEqual(before);
102
+ // updatedAtMs must be an integer on the wire — Swift clients decode it as
103
+ // Int64 and a sub-millisecond float (which fs.Stats.mtimeMs returns by
104
+ // default) breaks JSONDecoder strict number parsing.
105
+ expect(Number.isInteger(alice!.updatedAtMs)).toBe(true);
106
+
107
+ const bob = bySlug.get("bob");
108
+ expect(bob).toBeDefined();
109
+ expect(bob!.bodyBytes).toBe(Buffer.byteLength(pages[1]!.body, "utf8"));
110
+ expect(bob!.edgeCount).toBe(0);
111
+ expect(bob!.updatedAtMs).toBeGreaterThanOrEqual(before);
112
+ expect(Number.isInteger(bob!.updatedAtMs)).toBe(true);
113
+
114
+ const carol = bySlug.get("people/carol");
115
+ expect(carol).toBeDefined();
116
+ expect(carol!.bodyBytes).toBe(Buffer.byteLength(pages[2]!.body, "utf8"));
117
+ expect(carol!.edgeCount).toBe(1);
118
+ expect(carol!.updatedAtMs).toBeGreaterThanOrEqual(before);
119
+ expect(Number.isInteger(carol!.updatedAtMs)).toBe(true);
120
+ });
121
+
122
+ test("tolerates a single corrupt page — returns valid pages and skips the broken one", async () => {
123
+ await writePage(workspaceDir, {
124
+ slug: "valid-page",
125
+ frontmatter: { edges: [], ref_files: [] },
126
+ body: "Body of the valid page.\n",
127
+ });
128
+
129
+ // A `.md` file with frontmatter that fails schema validation — `edges`
130
+ // must be a list of strings, not a single number — so `readPage` throws.
131
+ const conceptsDir = join(workspaceDir, "memory", "concepts");
132
+ await mkdir(conceptsDir, { recursive: true });
133
+ await writeFile(
134
+ join(conceptsDir, "broken.md"),
135
+ "---\nedges: 42\n---\nbroken body\n",
136
+ "utf-8",
137
+ );
138
+
139
+ const handler = findHandler("memory_v2_list_concept_pages");
140
+ const result = (await handler({
141
+ body: {},
142
+ })) as MemoryV2ListConceptPagesResult;
143
+
144
+ expect(result.pages).toHaveLength(1);
145
+ expect(result.pages.map((p) => p.slug)).toEqual(["valid-page"]);
146
+ });
147
+ });
@@ -62,8 +62,9 @@ function handleConfirm({ body }: RouteHandlerArgs) {
62
62
  throw new BadRequestError("decision must resolve to allow or deny");
63
63
  }
64
64
 
65
- // Validation passed — consume the pending interaction.
66
- const interaction = pendingInteractions.resolve(requestId)!;
65
+ // Validation passed. Use get() here — the prompter (or ACP directResolve path)
66
+ // owns deregistration via pendingInteractions.resolve().
67
+ const interaction = peeked;
67
68
 
68
69
  log.info(
69
70
  {
@@ -93,7 +94,9 @@ function handleConfirm({ body }: RouteHandlerArgs) {
93
94
  });
94
95
 
95
96
  // ACP permissions: resolve directly without a Conversation object.
97
+ // No PermissionPrompter involved, so the route owns deregistration.
96
98
  if (interaction.directResolve) {
99
+ pendingInteractions.resolve(requestId);
97
100
  interaction.directResolve(effectiveDecision as UserDecision);
98
101
  return { accepted: true };
99
102
  }
@@ -139,7 +142,8 @@ function handleSecret({ body }: RouteHandlerArgs) {
139
142
  throw new BadRequestError('delivery must be "store" or "transient_send"');
140
143
  }
141
144
 
142
- const interaction = pendingInteractions.resolve(requestId);
145
+ // Use get() — SecretPrompter.resolveSecret() owns deregistration.
146
+ const interaction = pendingInteractions.get(requestId);
143
147
  if (!interaction) {
144
148
  throw new NotFoundError("No pending interaction found for this requestId");
145
149
  }
@@ -8,13 +8,13 @@
8
8
  * only surface its config and provide an on-demand trigger for the Settings UI.
9
9
  *
10
10
  * `available` mirrors the filing route's `available` field: it reflects which
11
- * background memory job is active for this instance. When `memory-v2-enabled`
12
- * is off, consolidation returns `available: false` and the UI hides the row.
11
+ * background memory job is active for this instance. When
12
+ * `config.memory.v2.enabled` is false, consolidation returns
13
+ * `available: false` and the UI hides the row.
13
14
  */
14
15
 
15
16
  import { z } from "zod";
16
17
 
17
- import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
18
18
  import { getConfig } from "../../config/loader.js";
19
19
  import { getMemoryCheckpoint } from "../../memory/checkpoints.js";
20
20
  import {
@@ -26,7 +26,7 @@ import { BadRequestError } from "./errors.js";
26
26
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
27
27
 
28
28
  function isConsolidationAvailable(): boolean {
29
- return isAssistantFeatureFlagEnabled("memory-v2-enabled", getConfig());
29
+ return getConfig().memory.v2.enabled;
30
30
  }
31
31
 
32
32
  function consolidationIntervalMs(): number {
@@ -66,14 +66,13 @@ export const ROUTES: RouteDefinition[] = [
66
66
  success: z.boolean(),
67
67
  }),
68
68
  handler: async (_args: RouteHandlerArgs) => {
69
- const available = isConsolidationAvailable();
70
- const v2Config = getConfig().memory.v2;
69
+ const enabled = getConfig().memory.v2.enabled;
71
70
  const intervalMs = consolidationIntervalMs();
72
71
  const lastRunAt = readLastRunAt();
73
72
  const nextRunAt = lastRunAt != null ? lastRunAt + intervalMs : null;
74
73
  return {
75
- available,
76
- enabled: available && v2Config.enabled,
74
+ available: enabled,
75
+ enabled,
77
76
  intervalMs,
78
77
  nextRunAt,
79
78
  lastRunAt,
@@ -99,7 +98,7 @@ export const ROUTES: RouteDefinition[] = [
99
98
  handler: async (_args: RouteHandlerArgs) => {
100
99
  if (!isConsolidationAvailable()) {
101
100
  throw new BadRequestError(
102
- "Consolidation is not available (memory-v2-enabled is off)",
101
+ "Consolidation is not available (memory.v2.enabled is false)",
103
102
  );
104
103
  }
105
104
  // Coalesce: don't pile up duplicate jobs if the worker hasn't picked up
@@ -22,7 +22,9 @@ import { z } from "zod";
22
22
 
23
23
  import {
24
24
  deepMergeOverwrite,
25
+ fillContextDefaultsForMissingKeys,
25
26
  getConfig,
27
+ getDeploymentContextDefaults,
26
28
  invalidateConfigCache,
27
29
  loadRawConfig,
28
30
  saveRawConfig,
@@ -312,9 +314,50 @@ async function handleSetEmbeddingConfig({ body }: RouteHandlerArgs) {
312
314
  }
313
315
  }
314
316
 
317
+ /**
318
+ * Apply deployment-context defaults to a raw config payload before it goes
319
+ * out over the wire from `GET /v1/config`. The in-memory `loadConfig()`
320
+ * already layers these defaults for daemon-internal consumers; the GET
321
+ * response needs the same treatment so external clients (macOS, web, CLI)
322
+ * see the effective value rather than `undefined` when the daemon hasn't
323
+ * persisted an explicit choice yet. Without this, macOS's
324
+ * `loadServiceModes(config:)` short-circuits when `services.inference.mode`
325
+ * is missing and falls back to the SwiftUI `@Published` default of
326
+ * "your-own", which renders the wrong segment selection on freshly-hatched
327
+ * platform-managed assistants.
328
+ *
329
+ * Guards against `loadRawConfig()` handing us a value that is technically
330
+ * valid JSON but not a plain object (e.g. literal `null`, a number, or an
331
+ * array). `loadRawConfig` is typed `Record<string, unknown>` but `JSON.parse`
332
+ * itself doesn't enforce that — a malformed-but-parseable `config.json`
333
+ * would blow up `fillContextDefaultsForMissingKeys` on its `target[key]` /
334
+ * `fileConfig[key]` accesses, turning `GET /v1/config` into a 500 where it
335
+ * used to succeed (returning the malformed payload as-is). When `raw` is
336
+ * not a plain object, we return it unchanged.
337
+ *
338
+ * Exported for direct unit testing.
339
+ */
340
+ export function applyContextDefaultsToRawConfig(raw: unknown): unknown {
341
+ const contextDefaults = getDeploymentContextDefaults();
342
+ if (
343
+ Object.keys(contextDefaults).length === 0 ||
344
+ raw === null ||
345
+ typeof raw !== "object" ||
346
+ Array.isArray(raw)
347
+ ) {
348
+ return raw;
349
+ }
350
+ fillContextDefaultsForMissingKeys(
351
+ raw as Record<string, unknown>,
352
+ raw as Record<string, unknown>,
353
+ contextDefaults,
354
+ );
355
+ return raw;
356
+ }
357
+
315
358
  function handleGetConfig() {
316
359
  try {
317
- return loadRawConfig();
360
+ return applyContextDefaultsToRawConfig(loadRawConfig());
318
361
  } catch (err) {
319
362
  const message = err instanceof Error ? err.message : String(err);
320
363
  throw new InternalError(`Failed to read config: ${message}`);
@@ -15,6 +15,7 @@ import { spawn } from "node:child_process";
15
15
  import { z } from "zod";
16
16
 
17
17
  import { getIsContainerized } from "../../config/env-registry.js";
18
+ import { buildSanitizedEnv } from "../../tools/terminal/safe-env.js";
18
19
  import { getWorkspaceDir } from "../../util/platform.js";
19
20
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
20
21
 
@@ -92,6 +93,7 @@ function handleDebugBash({ body }: RouteHandlerArgs): Promise<DebugBashResult> {
92
93
  cwd: getWorkspaceDir(),
93
94
  stdio: ["ignore", "pipe", "pipe"],
94
95
  detached: true,
96
+ env: buildSanitizedEnv(),
95
97
  });
96
98
 
97
99
  const timer = setTimeout(() => {
@@ -2,14 +2,13 @@
2
2
  * Route handlers for filing management.
3
3
  *
4
4
  * `available` reflects whether the filing service is the active background
5
- * memory job for this instance. When the `memory-v2-enabled` flag is on,
5
+ * memory job for this instance. When `config.memory.v2.enabled` is true,
6
6
  * filing yields to the consolidation job (see consolidation-routes.ts) and
7
7
  * returns `available: false` so the UI can hide the row.
8
8
  */
9
9
 
10
10
  import { z } from "zod";
11
11
 
12
- import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
13
12
  import { getConfig } from "../../config/loader.js";
14
13
  import { FilingService } from "../../filing/filing-service.js";
15
14
  import { getLogger } from "../../util/logger.js";
@@ -19,7 +18,7 @@ import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
19
18
  const log = getLogger("filing-routes");
20
19
 
21
20
  function isFilingAvailable(): boolean {
22
- return !isAssistantFeatureFlagEnabled("memory-v2-enabled", getConfig());
21
+ return !getConfig().memory.v2.enabled;
23
22
  }
24
23
 
25
24
  // ---------------------------------------------------------------------------
@@ -174,9 +174,6 @@ export async function handleGuardianReplyIntercept(
174
174
  eventId,
175
175
  canonicalRouter: routerResult.type,
176
176
  requestId: routerResult.requestId,
177
- ...(routerResult.activatedContact
178
- ? { activatedContact: routerResult.activatedContact }
179
- : {}),
180
177
  }),
181
178
  skipApprovalInterception: false,
182
179
  };
@@ -6,13 +6,6 @@
6
6
  */
7
7
  import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
8
8
 
9
- import { _setOverridesForTesting } from "../../config/assistant-feature-flags.js";
10
-
11
- // This test exercises v1 memory CRUD routes. The `memory-v2-enabled` flag
12
- // (registry default `true`) flips memory routing to v2 — disable it here so
13
- // the v1 paths under test stay active.
14
- _setOverridesForTesting({ "memory-v2-enabled": false });
15
-
16
9
  mock.module("../../util/logger.js", () => ({
17
10
  getLogger: () =>
18
11
  new Proxy({} as Record<string, unknown>, {
@@ -20,7 +13,8 @@ mock.module("../../util/logger.js", () => ({
20
13
  }),
21
14
  }));
22
15
 
23
- // Stub config loader — returns a config with memory enabled by default
16
+ // Stub config loader — return a config with memory.v2.enabled=false so the
17
+ // v1 paths under test stay active.
24
18
  mock.module("../../config/loader.js", () => ({
25
19
  loadConfig: () => mockConfig,
26
20
  getConfig: () => mockConfig,
@@ -28,7 +22,7 @@ mock.module("../../config/loader.js", () => ({
28
22
  }));
29
23
 
30
24
  // ── Controllable mocks for semantic search ─────────────────────────────
31
- const mockConfig: unknown = {};
25
+ const mockConfig: unknown = { memory: { v2: { enabled: false } } };
32
26
 
33
27
  let mockBackendStatus: {
34
28
  enabled: boolean;
@@ -25,7 +25,6 @@ import {
25
25
  import { z } from "zod";
26
26
 
27
27
  import { getConfig } from "../../config/loader.js";
28
- import { isMemoryV2ReadActive } from "../../memory/context-search/sources/memory-v2.js";
29
28
  import { getDb } from "../../memory/db-connection.js";
30
29
  import {
31
30
  embedWithBackend,
@@ -176,11 +175,11 @@ async function searchNodesSemantic(
176
175
  ): Promise<{ ids: string[]; total: number } | null> {
177
176
  try {
178
177
  const config = getConfig();
179
- // v2 owns the read path when both gates are on. Fall back to SQL search
180
- // (the caller's `null` branch) instead of querying the v1 collection,
181
- // which is in active retirement and a corrupted sparse segment can
182
- // OOM-crash the shared Qdrant process.
183
- if (isMemoryV2ReadActive(config)) return null;
178
+ // v2 owns the read path when enabled. Fall back to SQL search (the
179
+ // caller's `null` branch) instead of querying the v1 collection, which
180
+ // is in active retirement and a corrupted sparse segment can OOM-crash
181
+ // the shared Qdrant process.
182
+ if (config.memory.v2.enabled) return null;
184
183
  const backendStatus = await getMemoryBackendStatus(config);
185
184
  if (!backendStatus.provider) return null;
186
185
 
@@ -4,9 +4,11 @@
4
4
  * Migrated from `ipc/routes/memory-v2-backfill.ts` and
5
5
  * `ipc/routes/memory-v2-validate.ts` into the shared ROUTES array.
6
6
  */
7
+ import { stat } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+
7
10
  import { z } from "zod";
8
11
 
9
- import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
10
12
  import { loadConfig } from "../../config/loader.js";
11
13
  import {
12
14
  applyCorrectionIfCalibrated,
@@ -32,6 +34,7 @@ import {
32
34
  validateEdgeTargets,
33
35
  } from "../../memory/v2/edge-index.js";
34
36
  import {
37
+ getConceptsDir,
35
38
  listPages,
36
39
  readPage,
37
40
  renderPageContent,
@@ -47,11 +50,37 @@ import {
47
50
  getConceptPageCorpusStats,
48
51
  rebuildConceptPageCorpusStats,
49
52
  } from "../../memory/v2/sparse-bm25.js";
53
+ import { getLogger } from "../../util/logger.js";
50
54
  import { getWorkspaceDir } from "../../util/platform.js";
51
55
  import { RouteError } from "./errors.js";
52
56
  import type { RouteDefinition } from "./types.js";
53
57
  import type { RouteHandlerArgs } from "./types.js";
54
58
 
59
+ const log = getLogger("memory-v2-routes");
60
+
61
+ /**
62
+ * Wire-format error code emitted when v2 routes reject a request because
63
+ * `memory.v2.enabled` is false. Exported so tests and the macOS client can
64
+ * reference the same string without drift.
65
+ */
66
+ export const MEMORY_V2_DISABLED_CODE = "MEMORY_V2_DISABLED";
67
+
68
+ /**
69
+ * Reject the request when memory v2 is not active. Returning 409 (rather
70
+ * than serving a partial response) keeps clients honest — the desktop
71
+ * Memories panel reads this code to render an explicit "disabled in
72
+ * config" empty state.
73
+ */
74
+ function requireMemoryV2Enabled(): void {
75
+ if (!loadConfig().memory.v2.enabled) {
76
+ throw new RouteError(
77
+ "Memory v2 is not enabled — set memory.v2.enabled to true to use this command.",
78
+ MEMORY_V2_DISABLED_CODE,
79
+ 409,
80
+ );
81
+ }
82
+ }
83
+
55
84
  // ── Backfill ────────────────────────────────────────────────────────────
56
85
 
57
86
  const MemoryV2BackfillParams = z
@@ -76,6 +105,7 @@ const OP_TO_JOB_TYPE: Record<MemoryV2BackfillOp, MemoryJobType> = {
76
105
  async function handleBackfill({
77
106
  body = {},
78
107
  }: RouteHandlerArgs): Promise<MemoryV2BackfillResult> {
108
+ requireMemoryV2Enabled();
79
109
  const { op, force } = MemoryV2BackfillParams.parse(body);
80
110
  const payload: Record<string, unknown> =
81
111
  op === "migrate" && force === true ? { force: true } : {};
@@ -102,6 +132,7 @@ export type MemoryV2ValidateResult = {
102
132
  async function handleValidate({
103
133
  body = {},
104
134
  }: RouteHandlerArgs): Promise<MemoryV2ValidateResult> {
135
+ requireMemoryV2Enabled();
105
136
  MemoryV2ValidateParams.parse(body);
106
137
 
107
138
  const workspaceDir = getWorkspaceDir();
@@ -158,6 +189,7 @@ export type MemoryV2GetConceptPageResult = {
158
189
  async function handleGetConceptPage({
159
190
  body = {},
160
191
  }: RouteHandlerArgs): Promise<MemoryV2GetConceptPageResult> {
192
+ requireMemoryV2Enabled();
161
193
  const { slug } = MemoryV2GetConceptPageParams.parse(body);
162
194
  const workspaceDir = getWorkspaceDir();
163
195
  let page;
@@ -180,6 +212,59 @@ async function handleGetConceptPage({
180
212
  return { slug, rendered: renderPageContent(page) };
181
213
  }
182
214
 
215
+ // ── List concept pages ──────────────────────────────────────────────────
216
+
217
+ const MemoryV2ListConceptPagesParams = z.object({}).strict();
218
+
219
+ export type MemoryV2ListConceptPagesResult = {
220
+ pages: Array<{
221
+ slug: string;
222
+ bodyBytes: number;
223
+ edgeCount: number;
224
+ updatedAtMs: number;
225
+ }>;
226
+ };
227
+
228
+ async function handleListConceptPages({
229
+ body = {},
230
+ }: RouteHandlerArgs): Promise<MemoryV2ListConceptPagesResult> {
231
+ requireMemoryV2Enabled();
232
+ MemoryV2ListConceptPagesParams.parse(body);
233
+
234
+ const workspaceDir = getWorkspaceDir();
235
+ const conceptsDir = getConceptsDir(workspaceDir);
236
+ const slugs = await listPages(workspaceDir);
237
+
238
+ const settled = await Promise.all(
239
+ slugs.map(async (slug) => {
240
+ try {
241
+ const page = await readPage(workspaceDir, slug);
242
+ if (!page) return null;
243
+ const stats = await stat(join(conceptsDir, `${slug}.md`));
244
+ return {
245
+ slug,
246
+ bodyBytes: Buffer.byteLength(page.body, "utf8"),
247
+ edgeCount: page.frontmatter.edges.length,
248
+ updatedAtMs: Math.floor(stats.mtimeMs),
249
+ };
250
+ } catch (err) {
251
+ // A single corrupt page (bad YAML, schema mismatch, etc.) shouldn't
252
+ // poison the whole listing — the validate route is the place to
253
+ // surface those; this one is read-only and best-effort.
254
+ log.warn(
255
+ `Skipping concept page '${slug}' in list-concept-pages: ${err instanceof Error ? err.message : String(err)}`,
256
+ );
257
+ return null;
258
+ }
259
+ }),
260
+ );
261
+ const pages = settled.filter(
262
+ (p): p is MemoryV2ListConceptPagesResult["pages"][number] => p !== null,
263
+ );
264
+
265
+ return { pages };
266
+ }
267
+
183
268
  // ── Rebuild BM25 corpus stats ───────────────────────────────────────────
184
269
 
185
270
  const MemoryV2RebuildCorpusStatsParams = z.object({}).strict();
@@ -194,6 +279,7 @@ export interface MemoryV2RebuildCorpusStatsResult {
194
279
  async function handleRebuildCorpusStats({
195
280
  body = {},
196
281
  }: RouteHandlerArgs): Promise<MemoryV2RebuildCorpusStatsResult> {
282
+ requireMemoryV2Enabled();
197
283
  MemoryV2RebuildCorpusStatsParams.parse(body);
198
284
  const workspaceDir = getWorkspaceDir();
199
285
  await rebuildConceptPageCorpusStats(workspaceDir);
@@ -225,23 +311,9 @@ export type MemoryV2ReembedSkillsResult = {
225
311
  async function handleReembedSkills({
226
312
  body = {},
227
313
  }: RouteHandlerArgs): Promise<MemoryV2ReembedSkillsResult> {
314
+ requireMemoryV2Enabled();
228
315
  MemoryV2ReembedSkillsParams.parse(body);
229
316
 
230
- // Gate the route on both the feature flag and the per-workspace config
231
- // toggle so the v2 skill collection never gets re-seeded against a
232
- // workspace whose v2 subsystem is intentionally off.
233
- const config = loadConfig();
234
- if (
235
- !isAssistantFeatureFlagEnabled("memory-v2-enabled", config) ||
236
- !config.memory.v2.enabled
237
- ) {
238
- throw new RouteError(
239
- "Memory v2 is not enabled — flip both the memory-v2-enabled feature flag and memory.v2.enabled to use this command.",
240
- "MEMORY_V2_DISABLED",
241
- 409,
242
- );
243
- }
244
-
245
317
  // Unlike the queued backfill jobs above, this is a CLI-driven sync
246
318
  // request: the operator wants the cache replaced before the next prompt
247
319
  // assembly, so we await the seed inline rather than enqueueing it.
@@ -416,6 +488,7 @@ async function scoreChannel(
416
488
  async function handleExplainSimilarity({
417
489
  body = {},
418
490
  }: RouteHandlerArgs): Promise<MemoryV2ExplainSimilarityResult> {
491
+ requireMemoryV2Enabled();
419
492
  const params = MemoryV2ExplainSimilarityParams.parse(body);
420
493
  const config = loadConfig();
421
494
  const { dense_weight: denseWeight, sparse_weight: sparseWeight } =
@@ -475,6 +548,7 @@ const MemoryV2ConceptFrequencyParams = z
475
548
  async function handleConceptFrequency({
476
549
  body = {},
477
550
  }: RouteHandlerArgs): Promise<ConceptFrequencyResponse> {
551
+ requireMemoryV2Enabled();
478
552
  const { conversationId, sinceMs } =
479
553
  MemoryV2ConceptFrequencyParams.parse(body);
480
554
  const workspaceDir = getWorkspaceDir();
@@ -517,6 +591,7 @@ export interface MemoryV2FitAnisotropyResult {
517
591
  async function handleFitAnisotropy({
518
592
  body = {},
519
593
  }: RouteHandlerArgs): Promise<MemoryV2FitAnisotropyResult> {
594
+ requireMemoryV2Enabled();
520
595
  const { k, sample } = MemoryV2FitAnisotropyParams.parse(body);
521
596
  const config = loadConfig();
522
597
 
@@ -603,6 +678,17 @@ export const ROUTES: RouteDefinition[] = [
603
678
  tags: ["memory"],
604
679
  requestBody: MemoryV2GetConceptPageParams,
605
680
  },
681
+ {
682
+ operationId: "memory_v2_list_concept_pages",
683
+ method: "POST",
684
+ endpoint: "memory/v2/list-concept-pages",
685
+ handler: handleListConceptPages,
686
+ summary: "List all memory v2 concept pages with metadata",
687
+ description:
688
+ "Returns slugs, body sizes, edge counts, and last-modified timestamps for every concept page on disk. Read-only; used by the desktop About → Memories surface to render a browse-able list.",
689
+ tags: ["memory"],
690
+ requestBody: MemoryV2ListConceptPagesParams,
691
+ },
606
692
  {
607
693
  operationId: "memory_v2_reembed_skills",
608
694
  method: "POST",
@@ -610,7 +696,7 @@ export const ROUTES: RouteDefinition[] = [
610
696
  handler: handleReembedSkills,
611
697
  summary: "Re-seed v2 skill entries from the current skill catalog",
612
698
  description:
613
- "Synchronously re-runs seedV2SkillEntries against the current skill catalog. Gated on memory-v2-enabled flag and config.memory.v2.enabled.",
699
+ "Synchronously re-runs seedV2SkillEntries against the current skill catalog. Gated on config.memory.v2.enabled.",
614
700
  tags: ["memory"],
615
701
  requestBody: MemoryV2ReembedSkillsParams,
616
702
  },