@vellumai/assistant 0.10.3-dev.202606282033.2a8a8de → 0.10.3-dev.202606282228.b3d6565

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.
package/openapi.yaml CHANGED
@@ -8659,8 +8659,6 @@ paths:
8659
8659
  type: number
8660
8660
  live:
8661
8661
  type: boolean
8662
- shadow:
8663
- type: boolean
8664
8662
  selections:
8665
8663
  type: array
8666
8664
  items:
@@ -8690,7 +8688,6 @@ paths:
8690
8688
  required:
8691
8689
  - turn
8692
8690
  - live
8693
- - shadow
8694
8691
  - selections
8695
8692
  - injectedText
8696
8693
  additionalProperties: false
@@ -18266,8 +18263,6 @@ paths:
18266
18263
  type: number
18267
18264
  live:
18268
18265
  type: boolean
18269
- shadow:
18270
- type: boolean
18271
18266
  selections:
18272
18267
  type: array
18273
18268
  items:
@@ -18297,7 +18292,6 @@ paths:
18297
18292
  required:
18298
18293
  - turn
18299
18294
  - live
18300
- - shadow
18301
18295
  - selections
18302
18296
  - injectedText
18303
18297
  additionalProperties: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.3-dev.202606282033.2a8a8de",
3
+ "version": "0.10.3-dev.202606282228.b3d6565",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,141 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { elevatePointerConversationToGuardian } from "../daemon/pointer-conversation-trust.js";
4
+ import {
5
+ INTERNAL_GUARDIAN_TRUST_CONTEXT,
6
+ type TrustContext,
7
+ } from "../daemon/trust-context.js";
8
+ import { resolveCapabilities } from "../runtime/capabilities.js";
9
+
10
+ /**
11
+ * Fake conversation that mirrors `Conversation.loadFromDb`'s trust gate: a
12
+ * memory-capable (guardian) trust class sees the full guardian history; any
13
+ * other class sees an empty (filtered) history. `ensureActorScopedHistory`
14
+ * re-applies that gate against the current trust context, exactly like the real
15
+ * reload path.
16
+ */
17
+ class FakeConversation {
18
+ trustContext: TrustContext | undefined;
19
+ visibleHistory: string[] = [];
20
+ setTrustContextCalls: Array<TrustContext | null> = [];
21
+ ensureCalls = 0;
22
+ private readonly guardianHistory: string[];
23
+ private readonly processing: boolean;
24
+
25
+ constructor(opts: {
26
+ trustContext?: TrustContext;
27
+ guardianHistory?: string[];
28
+ processing?: boolean;
29
+ }) {
30
+ this.trustContext = opts.trustContext;
31
+ this.guardianHistory = opts.guardianHistory ?? ["m1", "m2", "m3"];
32
+ this.processing = opts.processing ?? false;
33
+ this.applyHistoryForTrust();
34
+ }
35
+
36
+ private applyHistoryForTrust(): void {
37
+ this.visibleHistory = resolveCapabilities(this.trustContext?.trustClass)
38
+ .canAccessMemory
39
+ ? [...this.guardianHistory]
40
+ : [];
41
+ }
42
+
43
+ isProcessing(): boolean {
44
+ return this.processing;
45
+ }
46
+
47
+ setTrustContext(ctx: TrustContext | null): void {
48
+ this.setTrustContextCalls.push(ctx);
49
+ this.trustContext = ctx ?? undefined;
50
+ }
51
+
52
+ async ensureActorScopedHistory(): Promise<void> {
53
+ this.ensureCalls++;
54
+ this.applyHistoryForTrust();
55
+ }
56
+ }
57
+
58
+ const CONTACT_CONTEXT: TrustContext = {
59
+ sourceChannel: "vellum",
60
+ trustClass: "unverified_contact",
61
+ };
62
+
63
+ const GUARDIAN_CONTEXT: TrustContext = {
64
+ sourceChannel: "vellum",
65
+ trustClass: "guardian",
66
+ };
67
+
68
+ describe("elevatePointerConversationToGuardian", () => {
69
+ test("elevates a cold trust-less conversation and rehydrates guardian history", async () => {
70
+ const conv = new FakeConversation({ trustContext: undefined });
71
+ // A cold (trust-less) load filters guardian history to empty.
72
+ expect(conv.visibleHistory).toEqual([]);
73
+
74
+ const restore = await elevatePointerConversationToGuardian(conv);
75
+
76
+ expect(conv.trustContext).toBe(INTERNAL_GUARDIAN_TRUST_CONTEXT);
77
+ expect(conv.ensureCalls).toBe(1);
78
+ // History is rehydrated rather than shipped empty.
79
+ expect(conv.visibleHistory).toEqual(["m1", "m2", "m3"]);
80
+
81
+ restore();
82
+ expect(conv.trustContext).toBeUndefined();
83
+ });
84
+
85
+ test("restores the prior non-memory trust context after the turn", async () => {
86
+ const conv = new FakeConversation({ trustContext: CONTACT_CONTEXT });
87
+ expect(conv.visibleHistory).toEqual([]);
88
+
89
+ const restore = await elevatePointerConversationToGuardian(conv);
90
+ expect(conv.trustContext).toBe(INTERNAL_GUARDIAN_TRUST_CONTEXT);
91
+ expect(conv.visibleHistory).toEqual(["m1", "m2", "m3"]);
92
+
93
+ restore();
94
+ expect(conv.trustContext).toBe(CONTACT_CONTEXT);
95
+ });
96
+
97
+ test("no-ops for a conversation that already has memory access", async () => {
98
+ const conv = new FakeConversation({ trustContext: GUARDIAN_CONTEXT });
99
+ expect(conv.visibleHistory).toEqual(["m1", "m2", "m3"]);
100
+
101
+ const restore = await elevatePointerConversationToGuardian(conv);
102
+
103
+ // No elevation, no redundant reload.
104
+ expect(conv.setTrustContextCalls).toEqual([]);
105
+ expect(conv.ensureCalls).toBe(0);
106
+ expect(conv.trustContext).toBe(GUARDIAN_CONTEXT);
107
+
108
+ restore();
109
+ expect(conv.setTrustContextCalls).toEqual([]);
110
+ expect(conv.trustContext).toBe(GUARDIAN_CONTEXT);
111
+ });
112
+
113
+ test("does not mutate trust while the conversation is processing", async () => {
114
+ const conv = new FakeConversation({
115
+ trustContext: undefined,
116
+ processing: true,
117
+ });
118
+
119
+ const restore = await elevatePointerConversationToGuardian(conv);
120
+
121
+ expect(conv.setTrustContextCalls).toEqual([]);
122
+ expect(conv.ensureCalls).toBe(0);
123
+ expect(conv.trustContext).toBeUndefined();
124
+
125
+ restore();
126
+ expect(conv.setTrustContextCalls).toEqual([]);
127
+ });
128
+
129
+ test("restore leaves trust alone when a later turn replaced it", async () => {
130
+ const conv = new FakeConversation({ trustContext: undefined });
131
+ const restore = await elevatePointerConversationToGuardian(conv);
132
+ expect(conv.trustContext).toBe(INTERNAL_GUARDIAN_TRUST_CONTEXT);
133
+
134
+ // Simulate a new turn legitimately replacing the trust context mid-flight.
135
+ conv.setTrustContext(GUARDIAN_CONTEXT);
136
+ restore();
137
+
138
+ // The restorer must not clobber the new turn's context.
139
+ expect(conv.trustContext).toBe(GUARDIAN_CONTEXT);
140
+ });
141
+ });
@@ -9,11 +9,10 @@
9
9
  *
10
10
  * Returned as part of `LlmContextResponse` — see `./llm-context-response.ts`.
11
11
  *
12
- * `live` / `shadow` reflect the CURRENT `memory-v3-live` / `memory-v3-shadow`
13
- * flag state, not the per-turn history (the selection rows don't record which
14
- * mode was active when they were written). When `live` is true the rendered
15
- * `injectedText` reflects the live selection; when only `shadow` is true it is
16
- * the block that WOULD have been injected.
12
+ * `live` reflects the CURRENT `memory.v3.live` config state, not the per-turn
13
+ * history (the selection rows don't record whether v3 was live when they were
14
+ * written). The rendered `injectedText` is the `<memory>` block for the logged
15
+ * selection.
17
16
  *
18
17
  * `injectedText` is inspector-only, not a verbatim record of the live cards +
19
18
  * spotlight. It re-renders each selection's matched section — resolved from the
@@ -58,7 +57,6 @@ export type MemoryV3SelectionRow = z.infer<typeof MemoryV3SelectionRowSchema>;
58
57
  export const MemoryV3SelectionLogSchema = z.object({
59
58
  turn: z.number(),
60
59
  live: z.boolean(),
61
- shadow: z.boolean(),
62
60
  selections: z.array(MemoryV3SelectionRowSchema),
63
61
  injectedText: z.string(),
64
62
  });
@@ -362,14 +362,6 @@
362
362
  "description": "Expose the developer-only Memory Router Playground tab in macOS Settings and the /assistant/memory-router-playground web page for dry-running v4 router config overrides against the live page index. Dev-only; default off.",
363
363
  "defaultEnabled": false
364
364
  },
365
- {
366
- "id": "memory-v3-shadow",
367
- "scope": "assistant",
368
- "key": "memory-v3-shadow",
369
- "label": "Memory v3 Shadow",
370
- "description": "When on, runs the new memory-v3 topic-tree retrieval alongside v2 in shadow mode: logs selections to memory_v3_selections, does not modify injected context. Off by default.",
371
- "defaultEnabled": false
372
- },
373
365
  {
374
366
  "id": "self-intro-greeting",
375
367
  "scope": "both",
@@ -122,6 +122,7 @@ import {
122
122
  rebuildBm25CorpusStatsAndReseedSkills,
123
123
  } from "./memory-v2-startup.js";
124
124
  import { startOrphanReaper } from "./orphan-reaper.js";
125
+ import { elevatePointerConversationToGuardian } from "./pointer-conversation-trust.js";
125
126
  import { processMessage } from "./process-message.js";
126
127
  import { runProfilerSweep } from "./profiler-run-store.js";
127
128
  import {
@@ -1117,6 +1118,14 @@ export async function runDaemon(): Promise<void> {
1117
1118
  const conversation =
1118
1119
  await server.getConversationForMessages(conversationId);
1119
1120
 
1121
+ // Pointer turns are guardian-gated owner self-maintenance: elevate to
1122
+ // the internal guardian context and rehydrate history so a cold
1123
+ // (evicted) load doesn't filter guardian history to empty and ship a
1124
+ // cache-missing turn. `restoreTrustContext` undoes the elevation after
1125
+ // the turn. See pointer-conversation-trust.ts for the full rationale.
1126
+ const restoreTrustContext =
1127
+ await elevatePointerConversationToGuardian(conversation);
1128
+
1120
1129
  // Constrain pointer generation to a tool-disabled path so call-
1121
1130
  // status events cannot trigger unintended side-effect tools.
1122
1131
  // Incrementing toolsDisabledDepth causes the resolveTools callback
@@ -1242,6 +1251,8 @@ export async function runDaemon(): Promise<void> {
1242
1251
  } finally {
1243
1252
  // Restore tool availability so subsequent turns aren't affected.
1244
1253
  conversation.toolsDisabledDepth--;
1254
+ // Undo the temporary guardian elevation installed above.
1255
+ restoreTrustContext();
1245
1256
  }
1246
1257
  },
1247
1258
  );
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Guardian-trust elevation for call-status pointer turns.
3
+ *
4
+ * Call-status pointer messages are generated as owner self-maintenance turns:
5
+ * only guardian-gated audiences route through the daemon processor (see
6
+ * `resolvePointerAudienceTrust` in `calls/call-pointer-messages.ts`), so the
7
+ * generation should run under the internal guardian context.
8
+ *
9
+ * The subtlety is history: `Conversation.loadFromDb` filters the persisted
10
+ * history to non-guardian provenance whenever the active trust context cannot
11
+ * access memory. On a cold (evicted) load — common on a memory-pressured daemon
12
+ * that evicts idle conversations — a freshly rebuilt conversation has no trust
13
+ * context, so a guardian-authored conversation's entire history is filtered to
14
+ * empty. The pointer turn would then ship with only its freshly persisted
15
+ * instruction, and because dropping the prior history (and tools) shifts the
16
+ * cacheable prefix, the request misses the prompt cache completely.
17
+ *
18
+ * Elevating to the guardian context and rehydrating before the turn restores
19
+ * the full history and makes the turn's shape deterministic regardless of
20
+ * eviction state.
21
+ */
22
+ import { resolveCapabilities } from "../runtime/capabilities.js";
23
+ import {
24
+ INTERNAL_GUARDIAN_TRUST_CONTEXT,
25
+ type TrustContext,
26
+ } from "./trust-context.js";
27
+
28
+ /** Minimal `Conversation` surface needed to elevate pointer trust. */
29
+ export interface GuardianElevatableConversation {
30
+ readonly trustContext?: TrustContext | undefined;
31
+ isProcessing(): boolean;
32
+ setTrustContext(ctx: TrustContext | null): void;
33
+ ensureActorScopedHistory(): Promise<void>;
34
+ }
35
+
36
+ /**
37
+ * Elevate a pointer conversation to the internal guardian context and rehydrate
38
+ * its history so guardian-authored history is not filtered to empty on a cold
39
+ * load. Returns a function that restores the prior trust context.
40
+ *
41
+ * No-ops (returning a no-op restorer) when the conversation is already
42
+ * memory-capable, or when it is mid-turn — mutating `trustContext` during an
43
+ * active loop would elevate that turn's actor trust (mirrors the warm-path guard
44
+ * in `conversation-store.ts`).
45
+ */
46
+ export async function elevatePointerConversationToGuardian(
47
+ conversation: GuardianElevatableConversation,
48
+ ): Promise<() => void> {
49
+ const priorTrustContext = conversation.trustContext;
50
+ const shouldElevate =
51
+ !conversation.isProcessing() &&
52
+ !resolveCapabilities(priorTrustContext?.trustClass).canAccessMemory;
53
+ if (!shouldElevate) return () => {};
54
+
55
+ conversation.setTrustContext(INTERNAL_GUARDIAN_TRUST_CONTEXT);
56
+ await conversation.ensureActorScopedHistory();
57
+
58
+ return () => {
59
+ // Undo only the elevation this call installed. If a new turn started at an
60
+ // await boundary and legitimately updated trustContext, the reference will
61
+ // differ and we leave it alone.
62
+ if (conversation.trustContext === INTERNAL_GUARDIAN_TRUST_CONTEXT) {
63
+ conversation.setTrustContext(priorTrustContext ?? null);
64
+ }
65
+ };
66
+ }
@@ -1,6 +1,5 @@
1
1
  import { join } from "node:path";
2
2
 
3
- import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
4
3
  import { getConfig } from "../config/loader.js";
5
4
  import { isMemoryV3Live } from "../config/memory-v3-gate.js";
6
5
  import type { AssistantConfig } from "../config/types.js";
@@ -950,16 +949,13 @@ export function maybeEnqueueGraphMaintenanceJobs(
950
949
 
951
950
  // v3 self-maintenance backstop. Orthogonal to the v1/v2 mutual exclusion
952
951
  // above: it owns its own checkpoint and operates on the v3 topic tree, so it
953
- // runs under either branch. Gated on the same flags that gate the v3 plugin
952
+ // runs under either branch. Gated on the same config that gates the v3 plugin
954
953
  // so it stays inert when v3 is off. The post-consolidation follow-up in
955
954
  // `consolidation-job.ts` remains the primary trigger; this interval only
956
955
  // self-heals when that follow-up is missed (failed enqueue). The job handler
957
956
  // itself no-ops when v3 is off, so
958
957
  // this guard is belt-and-suspenders that also avoids a wasted enqueue.
959
- if (
960
- isAssistantFeatureFlagEnabled("memory-v3-shadow", config) ||
961
- isMemoryV3Live(config)
962
- ) {
958
+ if (isMemoryV3Live(config)) {
963
959
  schedule.push({
964
960
  key: GRAPH_MAINTENANCE_CHECKPOINTS.memoryV3Maintain,
965
961
  intervalMs: GRAPH_V3_MAINTAIN_INTERVAL_MS,
@@ -105,22 +105,22 @@ mock.module("../../jobs-store.js", () => ({
105
105
  isMemoryEnabled: () => true,
106
106
  }));
107
107
 
108
- // ── v3 follow-up flag mock ──────────────────────────────────────────
108
+ // ── v3 live gate mock ───────────────────────────────────────────────
109
109
  //
110
- // A v3 flag being on appends `memory_v3_maintain` to the post-consolidation
111
- // follow-up fan-out, and the LIVE flag (alone) selects the v3 article-shape
112
- // prompt. `v3FlagOn` toggles all flags at once for the existing tests;
113
- // `flagStates` overrides individual keys for the shadow-vs-live tests.
110
+ // `memory.v3.live` being on appends `memory_v3_maintain` to the
111
+ // post-consolidation follow-up fan-out, selects the v3 article-shape prompt,
112
+ // and includes the core-pages curation section. `v3FlagOn` toggles the gate
113
+ // for the existing on/off tests; `flagStates["memory-v3-live"]` overrides it
114
+ // for the article-shape tests.
114
115
  let v3FlagOn = false;
115
116
  let flagStates: Record<string, boolean> = {};
116
117
  mock.module("../../../config/assistant-feature-flags.js", () => ({
117
118
  isAssistantFeatureFlagEnabled: (key: string) => flagStates[key] ?? v3FlagOn,
118
119
  }));
119
120
 
120
- // The v3-live gate moved to config (`config.memory.v3.live`), read via
121
- // `isMemoryV3Live`. Mirror the flag mock so the shadow-vs-live tests keep
122
- // driving live through `flagStates["memory-v3-live"]` (and `v3FlagOn` toggles
123
- // it alongside shadow for the existing on/off tests).
121
+ // The v3-live gate lives in config (`config.memory.v3.live`), read via
122
+ // `isMemoryV3Live`; drive it through `flagStates["memory-v3-live"]` (and
123
+ // `v3FlagOn` for the existing on/off tests).
124
124
  mock.module("../../../config/memory-v3-gate.js", () => ({
125
125
  isMemoryV3Live: () => flagStates["memory-v3-live"] ?? v3FlagOn,
126
126
  }));
@@ -709,18 +709,15 @@ describe("CONSOLIDATION_PROMPT", () => {
709
709
  });
710
710
  });
711
711
 
712
- describe("article-shape selection — live flag only, never shadow", () => {
712
+ describe("article-shape selection — keyed on the live gate", () => {
713
713
  // The v3 article shape drops the `summary:` field that v2 injection
714
- // depends on. Under SHADOW, live prompts are still served by v2, so
715
- // consolidation must keep producing v2-shaped pages only the LIVE flag
716
- // may select the v3 template. Collapsing this distinction (e.g. keying the
717
- // shape on shadow||live) would corrupt the corpus of every shadow install
718
- // before its flip.
714
+ // depends on, so only a live (`memory.v3.live`) install may select the v3
715
+ // template a v2-only install must keep producing `summary:`-bearing pages.
719
716
  const V3_MARKER = "The lead IS the card";
720
717
 
721
- test("shadow on, live off → v2 article shape", async () => {
718
+ test("v3 off → v2 article shape", async () => {
722
719
  writeFileSync(bufferPath(), "- [Apr 27, 9:00 AM] Alice prefers VS Code.\n");
723
- flagStates = { "memory-v3-shadow": true, "memory-v3-live": false };
720
+ flagStates = { "memory-v3-live": false };
724
721
 
725
722
  await memoryV2ConsolidateJob(makeJob(), CONFIG);
726
723
 
@@ -728,13 +725,13 @@ describe("article-shape selection — live flag only, never shadow", () => {
728
725
  const prompt = runnerLastArgs?.prompt as string;
729
726
  expect(prompt).not.toContain(V3_MARKER);
730
727
  expect(prompt).toContain("The `summary` field is required");
731
- // §10 still rides the shadow flag.
732
- expect(prompt).toContain("memory/core-pages.md");
728
+ // §10 (core-pages curation) is omitted on a v2-only install.
729
+ expect(prompt).not.toContain("memory/core-pages.md");
733
730
  });
734
731
 
735
732
  test("live on → v3 article shape", async () => {
736
733
  writeFileSync(bufferPath(), "- [Apr 27, 9:00 AM] Alice prefers VS Code.\n");
737
- flagStates = { "memory-v3-shadow": false, "memory-v3-live": true };
734
+ flagStates = { "memory-v3-live": true };
738
735
 
739
736
  await memoryV2ConsolidateJob(makeJob(), CONFIG);
740
737
 
@@ -64,7 +64,6 @@ import {
64
64
  } from "node:fs";
65
65
  import { dirname, join } from "node:path";
66
66
 
67
- import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
68
67
  import { isMemoryV3Live } from "../../config/memory-v3-gate.js";
69
68
  import type { AssistantConfig } from "../../config/types.js";
70
69
  import { runBackgroundJob } from "../../runtime/background-job-runner.js";
@@ -85,12 +84,6 @@ const log = getLogger("memory-v2-consolidate");
85
84
  /** Stable identifier surfaced in `runBackgroundJob` logs and notifications. */
86
85
  const JOB_NAME = "memory.consolidate";
87
86
 
88
- /**
89
- * v3 plugin flags. Either being on enqueues `memory_v3_maintain` as a
90
- * post-consolidation follow-up. These gate the v3 plugin itself.
91
- */
92
- const MEMORY_V3_SHADOW = "memory-v3-shadow" as const;
93
-
94
87
  /**
95
88
  * Hard timeout for the consolidation run. Consolidation reads the buffer,
96
89
  * rewrites several files, and re-encodes essentials/threads — generous
@@ -127,7 +120,7 @@ const STALE_LOCK_TTL_MS = 4 * CONSOLIDATION_TIMEOUT_MS;
127
120
  */
128
121
  const FOLLOW_UP_JOB_TYPES: readonly MemoryJobType[] = ["memory_v2_reembed"];
129
122
 
130
- /** Follow-up enqueued only when a v3 flag is on. */
123
+ /** Follow-up enqueued only when v3 is live. */
131
124
  const V3_FOLLOW_UP_JOB_TYPE: MemoryJobType = "memory_v3_maintain";
132
125
 
133
126
  /**
@@ -262,21 +255,17 @@ export async function memoryV2ConsolidateJob(
262
255
  // the `memory.v2.consolidation_prompt_path` config override but bounds
263
256
  // it to a regular file under 1 MiB before substitution so a stray path
264
257
  // (or a `/dev/zero`-style pseudo-file) cannot exfiltrate megabytes of
265
- // bytes through the wake hint. The core-pages curation section rides the
266
- // same v3 gate as the maintenance follow-up: the file feeds the v3 core
267
- // lane, so on a v2-only install the instruction would curate a file
268
- // nothing reads.
269
- // The article SHAPE is keyed on the live flag alone: under shadow, live
270
- // prompts are still assembled by v2's injection model, so consolidation
271
- // must keep producing `summary:`-bearing fragment pages until the flip.
258
+ // bytes through the wake hint. The core-pages curation section and the
259
+ // article SHAPE both ride the single `memory.v3.live` gate: the core-pages
260
+ // file feeds the v3 core lane (inert on a v2-only install), and the v3
261
+ // article shape drops the `summary:` field v2 injection depends on, so a
262
+ // v2-only install must keep producing `summary:`-bearing fragment pages.
272
263
  const memoryV3Live = isMemoryV3Live(config);
273
- const memoryV3Active =
274
- isAssistantFeatureFlagEnabled(MEMORY_V3_SHADOW, config) || memoryV3Live;
275
264
  const prompt = resolveConsolidationPrompt(
276
265
  config.memory.v2.consolidation_prompt_path,
277
266
  cutoff,
278
267
  {
279
- includeCorePagesSection: memoryV3Active,
268
+ includeCorePagesSection: memoryV3Live,
280
269
  articleShape: memoryV3Live ? "v3" : "v2",
281
270
  },
282
271
  );
@@ -309,11 +298,11 @@ export async function memoryV2ConsolidateJob(
309
298
 
310
299
  // Step 5: enqueue follow-up jobs. Enqueueing now keeps the dispatch
311
300
  // wiring exercised end-to-end so PR 21 only has to swap in the handler
312
- // bodies. v3 maintenance is appended only while a v3 path (shadow or live)
313
- // is active, so it never fans out on v2-only installs.
301
+ // bodies. v3 maintenance is appended only while v3 is live, so it never
302
+ // fans out on v2-only installs.
314
303
  const followUpJobIds: string[] = [];
315
304
  const jobTypes: MemoryJobType[] = [...FOLLOW_UP_JOB_TYPES];
316
- if (memoryV3Active) {
305
+ if (memoryV3Live) {
317
306
  jobTypes.push(V3_FOLLOW_UP_JOB_TYPE);
318
307
  }
319
308
  for (const jobType of jobTypes) {
@@ -12,8 +12,6 @@
12
12
  * - fork dedup: a conversation whose everInjected record was seeded from
13
13
  * inherited blocks does not re-render those slugs;
14
14
  * - prune round-trip: a pruned slug that is re-selected re-injects;
15
- * - shadow mode: attaches nothing and records nothing, but logs the
16
- * would-inject set;
17
15
  * - spotlight: current-window entries only, re-rendered (never accumulated),
18
16
  * bounded by `n × (windowTurns + 1)`, live-only, absent from the
19
17
  * persistent card layer.
@@ -58,7 +56,6 @@ let injectionMockActive = false;
58
56
  // ─── mutable test state ──────────────────────────────────────────────────────
59
57
 
60
58
  let liveEnabled = false;
61
- let shadowEnabled = false;
62
59
  let memoryEnabled = true;
63
60
  let spotlightConfig = { n: 6, windowTurns: 2 };
64
61
  /** `null` disables the prune valve (the default for tests not exercising it —
@@ -163,11 +160,7 @@ mock.module("../../../../config/assistant-feature-flags.js", () => ({
163
160
  config,
164
161
  );
165
162
  }
166
- return key === "memory-v3-live"
167
- ? liveEnabled
168
- : key === "memory-v3-shadow"
169
- ? shadowEnabled
170
- : false;
163
+ return key === "memory-v3-live" ? liveEnabled : false;
171
164
  },
172
165
  }));
173
166
 
@@ -311,7 +304,6 @@ beforeEach(async () => {
311
304
  await flushPruneValveForTests();
312
305
  injectionMockActive = true;
313
306
  liveEnabled = false;
314
- shadowEnabled = false;
315
307
  memoryEnabled = true;
316
308
  spotlightConfig = { n: 6, windowTurns: 2 };
317
309
  pruneConfig = null;
@@ -552,31 +544,6 @@ describe("memoryV3Injector — frozen net-new cards", () => {
552
544
  expect(getActiveSlugs("conv-1")).toEqual(new Set());
553
545
  });
554
546
 
555
- test("shadow mode (live off) attaches nothing, records nothing, logs the would-inject set", async () => {
556
- shadowEnabled = true;
557
- turnResults.set(
558
- 0,
559
- result(
560
- ["page-a"],
561
- [["page-a", section("page-a", "Heading", "section text")]],
562
- ),
563
- );
564
-
565
- expect(await produceCards("conv-1", 0)).toBeNull();
566
- expect(await produceSpotlight("conv-1", 0)).toBeNull();
567
- expect(getActiveSlugs("conv-1")).toEqual(new Set());
568
- const shadowLog = logCalls.find((c) =>
569
- c.msg.includes("memory-v3 shadow: would inject"),
570
- );
571
- expect(shadowLog).toBeDefined();
572
- const data = shadowLog!.data as {
573
- netNew: Array<{ slug: string; bytes: number }>;
574
- spotlightRefs: string[];
575
- };
576
- expect(data.netNew.map((e) => e.slug)).toEqual(["page-a"]);
577
- expect(data.spotlightRefs).toEqual(["page-a§Heading"]);
578
- });
579
-
580
547
  test("the persistent card block never contains the spotlight wrapper", async () => {
581
548
  liveEnabled = true;
582
549
  turnResults.set(
@@ -709,8 +676,7 @@ describe("memoryV3SpotlightInjector — ephemeral section spotlight", () => {
709
676
  expect(await produceSpotlight("conv-1", 0)).toBeNull();
710
677
  });
711
678
 
712
- test("live off → null even when shadow is on", async () => {
713
- shadowEnabled = true;
679
+ test("live off → null", async () => {
714
680
  turnResults.set(0, result(["page-a"], [["page-a", sectionA]]));
715
681
  expect(await produceSpotlight("conv-1", 0)).toBeNull();
716
682
  });