@vellumai/assistant 0.10.3-dev.202606282129.2e2ccad → 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.202606282129.2e2ccad",
3
+ "version": "0.10.3-dev.202606282228.b3d6565",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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",
@@ -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
  });
@@ -1,6 +1,5 @@
1
1
  import { afterEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
- import { setOverridesForTesting } from "../../../../__tests__/feature-flag-test-helpers.js";
4
3
  import type { AssistantConfig } from "../../../../config/types.js";
5
4
  import { EmbeddingBackendUnavailableError } from "../../../../memory/embedding-backend.js";
6
5
  import { EmbeddingBillingBlockError } from "../../../../memory/embedding-billing-breaker.js";
@@ -17,12 +16,8 @@ import {
17
16
  import { buildSectionIndex } from "../sections.js";
18
17
  import type { Section, SectionIndex, Slug } from "../types.js";
19
18
 
20
- const FLAG_SHADOW = "memory-v3-shadow";
21
-
22
- // The shadow flag resolver ignores the passed config and reads the override
23
- // cache; the config arg only satisfies the signature. Shadow is driven via
24
- // `setOverridesForTesting`; v3-live (now config-gated) is driven through the
25
- // `isMemoryV3Live` mock slot below.
19
+ // v3-live (config-gated) is driven through the `isMemoryV3Live` mock slot
20
+ // below; the config arg only satisfies the signature.
26
21
  const CONFIG = {} as AssistantConfig;
27
22
 
28
23
  let memoryV3LiveSlot = false;
@@ -50,7 +45,6 @@ const JOB = { id: "job-1", type: "memory_v3_maintain" } as unknown as MemoryJob;
50
45
 
51
46
  describe("maintainJob", () => {
52
47
  afterEach(() => {
53
- setOverridesForTesting({});
54
48
  memoryV3LiveSlot = false;
55
49
  });
56
50
 
@@ -103,8 +97,7 @@ describe("maintainJob", () => {
103
97
  return { deps: { ...base, ...overrides }, calls };
104
98
  }
105
99
 
106
- test("no-op when both v3 flags are off", async () => {
107
- setOverridesForTesting({ [FLAG_SHADOW]: false });
100
+ test("no-op when v3 is disabled", async () => {
108
101
  const { deps: d, calls } = deps({
109
102
  selectChangedPages: async () => ["page-a"],
110
103
  });
@@ -114,8 +107,8 @@ describe("maintainJob", () => {
114
107
  expect(calls.invalidate).toBe(0);
115
108
  });
116
109
 
117
- test("re-chunks + re-embeds changed pages and invalidates lanes (shadow on)", async () => {
118
- setOverridesForTesting({ [FLAG_SHADOW]: true });
110
+ test("re-chunks + re-embeds changed pages and invalidates lanes (live on)", async () => {
111
+ memoryV3LiveSlot = true;
119
112
  const { deps: d, calls } = deps({
120
113
  selectChangedPages: async () => ["page-a", "page-b"],
121
114
  });
@@ -148,7 +141,7 @@ describe("maintainJob", () => {
148
141
  });
149
142
 
150
143
  test("skips the dense store entirely when no pages changed", async () => {
151
- setOverridesForTesting({ [FLAG_SHADOW]: true });
144
+ memoryV3LiveSlot = true;
152
145
  const { deps: d, calls } = deps({ selectChangedPages: async () => [] });
153
146
  const outcome = await maintainJob(JOB, CONFIG, d);
154
147
  expect(outcome.reembedded).toBe(0);
@@ -161,7 +154,7 @@ describe("maintainJob", () => {
161
154
  });
162
155
 
163
156
  test("a single failing page is contained; other pages still re-embed", async () => {
164
- setOverridesForTesting({ [FLAG_SHADOW]: true });
157
+ memoryV3LiveSlot = true;
165
158
  const { deps: d, calls } = deps({
166
159
  selectChangedPages: async () => ["page-ok", "page-bad", "page-ok-2"],
167
160
  upsertSections: async (_config, sections) => {
@@ -190,7 +183,7 @@ describe("maintainJob", () => {
190
183
  });
191
184
 
192
185
  test("a thrown re-embed stage does not abort lane invalidation", async () => {
193
- setOverridesForTesting({ [FLAG_SHADOW]: true });
186
+ memoryV3LiveSlot = true;
194
187
  const { deps: d, calls } = deps({
195
188
  selectChangedPages: async () => {
196
189
  throw new Error("select boom");
@@ -204,7 +197,7 @@ describe("maintainJob", () => {
204
197
  });
205
198
 
206
199
  test("advances the high-water mark after a successful re-embed pass", async () => {
207
- setOverridesForTesting({ [FLAG_SHADOW]: true });
200
+ memoryV3LiveSlot = true;
208
201
  const { deps: d, calls } = deps({
209
202
  selectChangedPages: async () => ["page-a"],
210
203
  });
@@ -213,7 +206,7 @@ describe("maintainJob", () => {
213
206
  });
214
207
 
215
208
  test("does not advance the high-water mark when selection throws", async () => {
216
- setOverridesForTesting({ [FLAG_SHADOW]: true });
209
+ memoryV3LiveSlot = true;
217
210
  const { deps: d, calls } = deps({
218
211
  selectChangedPages: async () => {
219
212
  throw new Error("select boom");
@@ -225,7 +218,7 @@ describe("maintainJob", () => {
225
218
  });
226
219
 
227
220
  test("prunes sections for an article absent from the page index", async () => {
228
- setOverridesForTesting({ [FLAG_SHADOW]: true });
221
+ memoryV3LiveSlot = true;
229
222
  // The dense store holds points for a live page, a deleted page, and a
230
223
  // synthetic capability row; the page index holds only the live + synthetic
231
224
  // slugs. Only the deleted page's sections are pruned. `selectChangedPages`
@@ -250,7 +243,7 @@ describe("maintainJob", () => {
250
243
  });
251
244
 
252
245
  test("prunes nothing when every stored article is still in the index", async () => {
253
- setOverridesForTesting({ [FLAG_SHADOW]: true });
246
+ memoryV3LiveSlot = true;
254
247
  const { deps: d, calls } = deps({
255
248
  listSectionArticles: async () => ["page-a", "page-b"],
256
249
  listIndexedSlugs: async () => ["page-a", "page-b", "page-c"],
@@ -262,7 +255,7 @@ describe("maintainJob", () => {
262
255
  });
263
256
 
264
257
  test("a single failing prune delete is contained; other deletions proceed", async () => {
265
- setOverridesForTesting({ [FLAG_SHADOW]: true });
258
+ memoryV3LiveSlot = true;
266
259
  const { deps: d, calls } = deps({
267
260
  listSectionArticles: async () => ["gone-1", "gone-bad", "gone-2"],
268
261
  listIndexedSlugs: async () => [],
@@ -284,7 +277,7 @@ describe("maintainJob", () => {
284
277
  });
285
278
 
286
279
  test("reports dangling core entries without mutating anything", async () => {
287
- setOverridesForTesting({ [FLAG_SHADOW]: true });
280
+ memoryV3LiveSlot = true;
288
281
  // The core file lists a live page, a renamed/deleted page, and a synthetic
289
282
  // capability slug; only the missing page is reported. The stage is
290
283
  // report-only: no deletes, no upserts, and the maintainer-owned file is
@@ -308,7 +301,7 @@ describe("maintainJob", () => {
308
301
  });
309
302
 
310
303
  test("reports nothing when every core entry is still in the index", async () => {
311
- setOverridesForTesting({ [FLAG_SHADOW]: true });
304
+ memoryV3LiveSlot = true;
312
305
  const { deps: d } = deps({
313
306
  loadCoreSet: () => ["page-a", "page-b"],
314
307
  listIndexedSlugs: async () => ["page-a", "page-b", "page-c"],
@@ -318,7 +311,7 @@ describe("maintainJob", () => {
318
311
  });
319
312
 
320
313
  test("an empty core set skips validation entirely", async () => {
321
- setOverridesForTesting({ [FLAG_SHADOW]: true });
314
+ memoryV3LiveSlot = true;
322
315
  let indexReads = 0;
323
316
  const { deps: d } = deps({
324
317
  loadCoreSet: () => [],
@@ -335,7 +328,7 @@ describe("maintainJob", () => {
335
328
  });
336
329
 
337
330
  test("a thrown core-validation stage is contained and does not abort lane invalidation", async () => {
338
- setOverridesForTesting({ [FLAG_SHADOW]: true });
331
+ memoryV3LiveSlot = true;
339
332
  const { deps: d, calls } = deps({
340
333
  loadCoreSet: () => {
341
334
  throw new Error("core boom");
@@ -350,7 +343,7 @@ describe("maintainJob", () => {
350
343
  });
351
344
 
352
345
  test("a thrown prune stage is contained and does not abort lane invalidation", async () => {
353
- setOverridesForTesting({ [FLAG_SHADOW]: true });
346
+ memoryV3LiveSlot = true;
354
347
  const { deps: d, calls } = deps({
355
348
  listSectionArticles: async () => {
356
349
  throw new Error("scroll boom");
@@ -366,7 +359,7 @@ describe("maintainJob", () => {
366
359
  });
367
360
 
368
361
  test("reconciles capability rows missing from the section store", async () => {
369
- setOverridesForTesting({ [FLAG_SHADOW]: true });
362
+ memoryV3LiveSlot = true;
370
363
  // The index lists a real page and three capability rows; the store already
371
364
  // holds one of the caps. The change-delta excludes capability rows, so
372
365
  // without this stage a skill enabled after the one-time backfill never
@@ -397,7 +390,7 @@ describe("maintainJob", () => {
397
390
  });
398
391
 
399
392
  test("skips a cold capability row (empty body) without deleting its points", async () => {
400
- setOverridesForTesting({ [FLAG_SHADOW]: true });
393
+ memoryV3LiveSlot = true;
401
394
  const { deps: d, calls } = deps({
402
395
  listIndexedSlugs: async () => ["skills/cold"],
403
396
  listSectionArticles: async () => [],
@@ -459,7 +452,6 @@ describe("computeChangedPages", () => {
459
452
 
460
453
  describe("backfillAllSections", () => {
461
454
  afterEach(() => {
462
- setOverridesForTesting({});
463
455
  memoryV3LiveSlot = false;
464
456
  });
465
457
 
@@ -11,7 +11,7 @@
11
11
  * - the fork fallback: a turn inherited from a fork resolves to the parent's
12
12
  * rows via the message's `forkSourceMessageId` back-pointer;
13
13
  * - source/pinned/section mapping and the rendered `<memory>` block;
14
- * - `live` / `shadow` reflect the flag resolver.
14
+ * - `live` reflects the config gate.
15
15
  *
16
16
  * `mock.module` is process-global and leaks into sibling files in a
17
17
  * `bun test <dir>` run, so every stub DELEGATES to the real implementation
@@ -37,7 +37,6 @@ const realPageContent = { ...(await import("../page-content.js")) };
37
37
 
38
38
  let storeMockActive = false;
39
39
  let liveEnabled = false;
40
- let shadowEnabled = false;
41
40
 
42
41
  let testSqlite: Database;
43
42
  let testDb = makeDb();
@@ -101,9 +100,7 @@ mock.module("../../../../config/assistant-feature-flags.js", () => ({
101
100
  storeMockActive
102
101
  ? key === "memory-v3-live"
103
102
  ? liveEnabled
104
- : key === "memory-v3-shadow"
105
- ? shadowEnabled
106
- : false
103
+ : false
107
104
  : realFlags.isAssistantFeatureFlagEnabled(
108
105
  key as Parameters<typeof realFlags.isAssistantFeatureFlagEnabled>[0],
109
106
  config as Parameters<
@@ -152,7 +149,6 @@ const {
152
149
  beforeEach(() => {
153
150
  storeMockActive = true;
154
151
  liveEnabled = false;
155
- shadowEnabled = false;
156
152
  testDb = makeDb();
157
153
  });
158
154
 
@@ -222,18 +218,15 @@ describe("getMemoryV3SelectionForInspector", () => {
222
218
  expect(log?.injectedText).toContain("body for domain-b/page-2");
223
219
  });
224
220
 
225
- test("live/shadow reflect the flag resolver", async () => {
221
+ test("live reflects the config gate", async () => {
226
222
  seed("conv-5", 1, [{ slug: "domain-a/page-1", source: "needle" }]);
227
223
 
228
224
  const off = await getMemoryV3SelectionForInspector("conv-5", 1);
229
225
  expect(off?.live).toBe(false);
230
- expect(off?.shadow).toBe(false);
231
226
 
232
227
  liveEnabled = true;
233
- shadowEnabled = true;
234
228
  const on = await getMemoryV3SelectionForInspector("conv-5", 1);
235
229
  expect(on?.live).toBe(true);
236
- expect(on?.shadow).toBe(true);
237
230
  });
238
231
  });
239
232
 
@@ -1,28 +1,27 @@
1
1
  /**
2
- * Shadow-mode end-to-end integration test for the memory-v3 section-lane
3
- * pipeline.
2
+ * End-to-end integration test for the memory-v3 section-lane pipeline.
4
3
  *
5
- * SCOPE / ALTITUDE. A full daemon-assembly run (plugin registry → flag read →
4
+ * SCOPE / ALTITUDE. A full daemon-assembly run (plugin registry → config read →
6
5
  * runtime assembly → provider call → DB write) is too heavy and too
7
- * mock-fragile for a unit test. Instead this composes the REAL shadow units
6
+ * mock-fragile for a unit test. Instead this composes the REAL engine units
8
7
  * with a mocked select provider, a stubbed dense lane, an in-memory selections
9
8
  * DB, and synthetic fixtures, driving them over a MULTI-TURN sequence:
10
9
  *
11
10
  * orchestrate (cache-ordered pool: core + hot stable prefix, then
12
11
  * needle ∪ dense ∪ edge finder candidates → ONE selectPool call)
13
- * → attribute selections to lane sources (the shadow plugin's REAL
12
+ * → attribute selections to lane sources (the plugin's REAL
14
13
  * `attributeSelections`, reading `result.lanes`)
15
- * → write to `memory_v3_selections` (the shadow plugin's REAL
14
+ * → write to `memory_v3_selections` (the plugin's REAL
16
15
  * `writeSelections`)
17
16
  * → summarizeSelections (the offline A/B readout)
18
17
  *
19
- * This is exactly the side-effect contract shadow mode observes each turn: the
18
+ * This is exactly the selection contract the engine records each turn: the
20
19
  * candidate pool is the cache-ordered union of the lanes — synthetic capability
21
20
  * pages are indexed like any other page, so they enter through the needle lane
22
21
  * rather than being always-added — a SINGLE select runs per turn, the result is
23
- * this turn's selections only, and each selection is
24
- * logged tagged with its lane source. None of this changes live injection —
25
- * shadow mode is observation-only; cutover is the `memory-v3-live` flag flip.
22
+ * this turn's selections only, and each selection is logged tagged with its
23
+ * lane source. Selection logging runs on the live path
24
+ * (`memory.v3.live`); injection is exercised in the injector tests.
26
25
  *
27
26
  * Slugs are generic placeholders (`page-a`, `topic-x`, `page-b`, …) — this is a
28
27
  * public repo.
@@ -342,7 +341,7 @@ afterAll(() => {
342
341
  // their content — not by always being appended.
343
342
  // ---------------------------------------------------------------------------
344
343
 
345
- describe("memory-v3 shadow integration — candidate pool", () => {
344
+ describe("memory-v3 integration — candidate pool", () => {
346
345
  test("pool unions needle ∪ dense ∪ edge; one select per turn", async () => {
347
346
  const lanes = await buildLanes();
348
347
  // "apple" hits page-a (needle). Dense returns page-b. page-a links to
@@ -372,7 +371,7 @@ describe("memory-v3 shadow integration — candidate pool", () => {
372
371
  // source tags. Selections are current-turn only — nothing carries.
373
372
  // ---------------------------------------------------------------------------
374
373
 
375
- describe("memory-v3 shadow integration — core + hot stable prefix", () => {
374
+ describe("memory-v3 integration — core + hot stable prefix", () => {
376
375
  test("core and hot head the pool every turn and log lane-correct sources", async () => {
377
376
  const lanes = await buildLanes();
378
377
  const prefix = { core: ["topic-x"], hot: ["page-b"] };
@@ -422,7 +421,7 @@ describe("memory-v3 shadow integration — core + hot stable prefix", () => {
422
421
  // matched section and is attributed `needle` — exactly like a concept page.
423
422
  // ---------------------------------------------------------------------------
424
423
 
425
- describe("memory-v3 shadow integration — lane-source attribution", () => {
424
+ describe("memory-v3 integration — lane-source attribution", () => {
426
425
  test("a needle-ranked capability selection is logged with the needle source", async () => {
427
426
  const lanes = await buildLanes();
428
427
  // "durian" matches the capability page's content section, so selecting it
@@ -437,11 +436,11 @@ describe("memory-v3 shadow integration — lane-source attribution", () => {
437
436
  });
438
437
 
439
438
  // ---------------------------------------------------------------------------
440
- // A/B readout: summarizeSelections aggregates the logged shadow run by lane
439
+ // A/B readout: summarizeSelections aggregates the logged run by lane
441
440
  // source, and reports turn count + distinct-slug selection footprint.
442
441
  // ---------------------------------------------------------------------------
443
442
 
444
- describe("memory-v3 shadow integration — selection-log readout", () => {
443
+ describe("memory-v3 integration — selection-log readout", () => {
445
444
  test("summarizeSelections aggregates a multi-turn run by source", async () => {
446
445
  const lanes = await buildLanes();
447
446
  const prefix = { hot: ["topic-x"] };
@@ -1,12 +1,10 @@
1
1
  /**
2
2
  * Tests for `shadow-plugin.ts` (section-lane pipeline).
3
3
  *
4
- * The v3 plugin is flag-gated. These tests assert:
5
- * - both flags OFF orchestrate is never called and no DB rows are written;
6
- * - either flag ON → orchestrate runs and selection rows land in
4
+ * These tests assert the v3 orchestration engine and the live injector:
5
+ * - {@link observeTurn} runs orchestration and selection rows land in
7
6
  * `memory_v3_selections` with the new lane source tags;
8
- * - shadow-only (live off) the injector returns `null` (never mutates the
9
- * turn) but still logs;
7
+ * - {@link observeTurn} is skipped when global memory is disabled;
10
8
  * - live on → the injector returns the rendered `<memory>` block;
11
9
  * - an empty selection under live → `null`;
12
10
  * - lazy-init runs the lane builders only once across multiple turns, and
@@ -79,7 +77,6 @@ let shadowMockActive = false;
79
77
  // ─── mutable test state, read by the mocks below ────────────────────────────
80
78
 
81
79
  let liveEnabled = false;
82
- let shadowEnabled = false;
83
80
  let memoryEnabled = true;
84
81
  let messages: Array<{ role: string; content: string }> = [];
85
82
 
@@ -177,11 +174,7 @@ const FAKE_SECTION_INDEX: SectionIndex = {
177
174
 
178
175
  mock.module("../../../../config/assistant-feature-flags.js", () => ({
179
176
  isAssistantFeatureFlagEnabled: (key: string) =>
180
- key === "memory-v3-live"
181
- ? liveEnabled
182
- : key === "memory-v3-shadow"
183
- ? shadowEnabled
184
- : false,
177
+ key === "memory-v3-live" ? liveEnabled : false,
185
178
  }));
186
179
 
187
180
  mock.module("../../../../config/loader.js", () => ({
@@ -383,7 +376,7 @@ mock.module("../orchestrate.js", () => ({
383
376
 
384
377
  // Import AFTER mocks so the plugin binds to them.
385
378
  const {
386
- runShadowObservation,
379
+ observeTurn,
387
380
  resetShadowLanesForTests,
388
381
  invalidateLanes,
389
382
  attributeSelections,
@@ -407,7 +400,6 @@ function readRows() {
407
400
  beforeEach(() => {
408
401
  shadowMockActive = true;
409
402
  liveEnabled = false;
410
- shadowEnabled = false;
411
403
  memoryEnabled = true;
412
404
  messages = [
413
405
  {
@@ -448,30 +440,19 @@ async function produce(conversationId: string, turnIndex: number) {
448
440
  return block;
449
441
  }
450
442
 
451
- describe("memory-v3 shadow plugin", () => {
452
- test("global memory disabled → observation is skipped even when v3 flags are on", async () => {
453
- shadowEnabled = true;
454
- liveEnabled = true;
443
+ describe("memory-v3 engine", () => {
444
+ test("global memory disabled → observation is skipped", async () => {
455
445
  memoryEnabled = false;
456
446
 
457
- await runShadowObservation("conv-1", 0);
458
-
459
- expect(orchestrateSpy).not.toHaveBeenCalled();
460
- expect(sectionBuilds).toBe(0);
461
- expect(readRows()).toHaveLength(0);
462
- });
447
+ await observeTurn("conv-1", 0);
463
448
 
464
- test("shadow flag OFF → orchestrate not called, no DB writes", async () => {
465
- shadowEnabled = false;
466
- await runShadowObservation("conv-1", 0);
467
449
  expect(orchestrateSpy).not.toHaveBeenCalled();
468
450
  expect(sectionBuilds).toBe(0);
469
451
  expect(readRows()).toHaveLength(0);
470
452
  });
471
453
 
472
- test("shadow flag ON orchestrate runs and rows are written with per-lane sources", async () => {
473
- shadowEnabled = true;
474
- await runShadowObservation("conv-1", 2);
454
+ test("observeTurn runs orchestration and writes rows with per-lane sources", async () => {
455
+ await observeTurn("conv-1", 2);
475
456
 
476
457
  expect(orchestrateSpy).toHaveBeenCalledTimes(1);
477
458
  const rows = readRows();
@@ -499,7 +480,6 @@ describe("memory-v3 shadow plugin", () => {
499
480
  });
500
481
 
501
482
  test("the turn carries the tail of the previous assistant reply for the reply-query pass", async () => {
502
- shadowEnabled = true;
503
483
  messages = [
504
484
  {
505
485
  role: "user",
@@ -516,7 +496,7 @@ describe("memory-v3 shadow plugin", () => {
516
496
  content: JSON.stringify([{ type: "text", text: "and now this" }]),
517
497
  },
518
498
  ];
519
- await runShadowObservation("conv-1", 1);
499
+ await observeTurn("conv-1", 1);
520
500
 
521
501
  const turn = (
522
502
  orchestrateSpy.mock.calls as unknown as unknown[][]
@@ -528,8 +508,7 @@ describe("memory-v3 shadow plugin", () => {
528
508
  });
529
509
 
530
510
  test("a conversation-opening turn has no previous assistant message", async () => {
531
- shadowEnabled = true;
532
- await runShadowObservation("conv-1", 0);
511
+ await observeTurn("conv-1", 0);
533
512
 
534
513
  const turn = (
535
514
  orchestrateSpy.mock.calls as unknown as unknown[][]
@@ -562,8 +541,7 @@ describe("memory-v3 shadow plugin", () => {
562
541
  });
563
542
 
564
543
  test("the turn passed to orchestrate carries the latest user message", async () => {
565
- shadowEnabled = true;
566
- await runShadowObservation("conv-1", 0);
544
+ await observeTurn("conv-1", 0);
567
545
  const turn = (
568
546
  orchestrateSpy.mock.calls as unknown as unknown[][]
569
547
  )[0]![0] as {
@@ -577,8 +555,7 @@ describe("memory-v3 shadow plugin", () => {
577
555
  });
578
556
 
579
557
  test("orchestrate receives the lane deps", async () => {
580
- shadowEnabled = true;
581
- await runShadowObservation("conv-1", 0);
558
+ await observeTurn("conv-1", 0);
582
559
  const deps = (
583
560
  orchestrateSpy.mock.calls as unknown as unknown[][]
584
561
  )[0]![1] as {
@@ -596,7 +573,6 @@ describe("memory-v3 shadow plugin", () => {
596
573
  });
597
574
 
598
575
  test("initLanes filters core to existing pages and excludes core from the hot set", async () => {
599
- shadowEnabled = true;
600
576
  // The core file lists a live page and a dangling slug; the hot set returns
601
577
  // a live page and a deleted one (selection rows can outlive their pages).
602
578
  coreSetSlugs = ["page-1", "missing-page"];
@@ -604,7 +580,7 @@ describe("memory-v3 shadow plugin", () => {
604
580
  { slug: "page-2", score: 2 },
605
581
  { slug: "gone-page", score: 1 },
606
582
  ];
607
- await runShadowObservation("conv-1", 0);
583
+ await observeTurn("conv-1", 0);
608
584
 
609
585
  const deps = (
610
586
  orchestrateSpy.mock.calls as unknown as unknown[][]
@@ -619,27 +595,16 @@ describe("memory-v3 shadow plugin", () => {
619
595
  expect(hotSetOpts?.halfLifeMs).toBe(14 * 24 * 60 * 60 * 1000);
620
596
  });
621
597
 
622
- test("both flags OFF → produce returns null, no orchestrate, no writes", async () => {
598
+ test("live off → produce returns null, no orchestrate, no writes", async () => {
623
599
  liveEnabled = false;
624
- shadowEnabled = false;
625
600
  const block = await produce("conv-1", 0);
626
601
  expect(block).toBeNull();
627
602
  expect(orchestrateSpy).not.toHaveBeenCalled();
628
603
  expect(readRows()).toHaveLength(0);
629
604
  });
630
605
 
631
- test("shadow-only (live off) → produce returns null but still logs", async () => {
632
- liveEnabled = false;
633
- shadowEnabled = true;
634
- const block = await produce("conv-1", 0);
635
- expect(block).toBeNull();
636
- expect(orchestrateSpy).toHaveBeenCalledTimes(1);
637
- expect(readRows().length).toBeGreaterThan(0);
638
- });
639
-
640
606
  test("live on → produce returns the net-new CARD block and logs", async () => {
641
607
  liveEnabled = true;
642
- shadowEnabled = false;
643
608
  const block = await produce("conv-1", 0);
644
609
  expect(block).not.toBeNull();
645
610
  expect(block!.placement).toBe("after-memory-prefix");
@@ -659,7 +624,6 @@ describe("memory-v3 shadow plugin", () => {
659
624
 
660
625
  test("live on → a later turn re-selecting the same pages renders an EMPTY block (net-new dedup)", async () => {
661
626
  liveEnabled = true;
662
- shadowEnabled = false;
663
627
  const first = await produce("conv-1", 0);
664
628
  expect(first!.text.length).toBeGreaterThan(0);
665
629
  // Same orchestrate fixture on the next turn → zero net-new cards. The
@@ -671,7 +635,6 @@ describe("memory-v3 shadow plugin", () => {
671
635
 
672
636
  test("live on but empty selection → produce returns null", async () => {
673
637
  liveEnabled = true;
674
- shadowEnabled = false;
675
638
  orchestrateSpy.mockImplementationOnce(async () => ({
676
639
  selections: [],
677
640
  matchedSections: new Map(),
@@ -684,10 +647,9 @@ describe("memory-v3 shadow plugin", () => {
684
647
  });
685
648
 
686
649
  test("lazy-init runs the lane builders only once across turns", async () => {
687
- shadowEnabled = true;
688
- await runShadowObservation("conv-1", 0);
689
- await runShadowObservation("conv-1", 1);
690
- await runShadowObservation("conv-1", 2);
650
+ await observeTurn("conv-1", 0);
651
+ await observeTurn("conv-1", 1);
652
+ await observeTurn("conv-1", 2);
691
653
  expect(sectionBuilds).toBe(1);
692
654
  expect(needleBuilds).toBe(1);
693
655
  expect(edgeBuilds).toBe(1);
@@ -696,12 +658,11 @@ describe("memory-v3 shadow plugin", () => {
696
658
  });
697
659
 
698
660
  test("a Qdrant section-collection failure does not disable the in-memory lanes", async () => {
699
- shadowEnabled = true;
700
661
  ensureCollectionThrows = true;
701
662
  // initLanes still builds the needle + edge lanes and orchestrate runs — a
702
663
  // Qdrant outage degrades only the dense lane, it does not take down all of v3
703
664
  // (and does not poison the memoized lanes by rejecting init).
704
- await runShadowObservation("conv-1", 0);
665
+ await observeTurn("conv-1", 0);
705
666
  expect(needleBuilds).toBe(1);
706
667
  expect(edgeBuilds).toBe(1);
707
668
  expect(ensureCollectionCalls).toBe(1);
@@ -709,69 +670,64 @@ describe("memory-v3 shadow plugin", () => {
709
670
  });
710
671
 
711
672
  test("invalidateLanes forces a one-time rebuild on the next turn", async () => {
712
- shadowEnabled = true;
713
- await runShadowObservation("conv-1", 0);
714
- await runShadowObservation("conv-1", 1);
673
+ await observeTurn("conv-1", 0);
674
+ await observeTurn("conv-1", 1);
715
675
  expect(sectionBuilds).toBe(1);
716
676
  expect(needleBuilds).toBe(1);
717
677
 
718
678
  invalidateLanes();
719
679
 
720
- await runShadowObservation("conv-1", 2);
680
+ await observeTurn("conv-1", 2);
721
681
  expect(sectionBuilds).toBe(2);
722
682
  expect(needleBuilds).toBe(2);
723
683
  expect(edgeBuilds).toBe(2);
724
684
 
725
685
  // ...and the rebuild is memoized again — no further builds until the next
726
686
  // invalidation.
727
- await runShadowObservation("conv-1", 3);
687
+ await observeTurn("conv-1", 3);
728
688
  expect(sectionBuilds).toBe(2);
729
689
  expect(needleBuilds).toBe(2);
730
690
  });
731
691
 
732
692
  test("resetShadowLanesForTests invalidates like invalidateLanes", async () => {
733
- shadowEnabled = true;
734
- await runShadowObservation("conv-1", 0);
693
+ await observeTurn("conv-1", 0);
735
694
  expect(sectionBuilds).toBe(1);
736
695
 
737
696
  resetShadowLanesForTests();
738
697
 
739
- await runShadowObservation("conv-1", 1);
698
+ await observeTurn("conv-1", 1);
740
699
  expect(sectionBuilds).toBe(2);
741
700
  });
742
701
 
743
702
  test("concurrent first turns after invalidation share a single build", async () => {
744
- shadowEnabled = true;
745
- await runShadowObservation("conv-1", 0);
703
+ await observeTurn("conv-1", 0);
746
704
  expect(sectionBuilds).toBe(1);
747
705
 
748
706
  invalidateLanes();
749
707
 
750
708
  await Promise.all([
751
- runShadowObservation("conv-1", 1),
752
- runShadowObservation("conv-1", 2),
709
+ observeTurn("conv-1", 1),
710
+ observeTurn("conv-1", 2),
753
711
  ]);
754
712
  expect(sectionBuilds).toBe(2);
755
713
  expect(needleBuilds).toBe(2);
756
714
  });
757
715
 
758
716
  test("no user message → no orchestrate, no writes", async () => {
759
- shadowEnabled = true;
760
717
  messages = [
761
718
  {
762
719
  role: "assistant",
763
720
  content: JSON.stringify([{ type: "text", text: "hi" }]),
764
721
  },
765
722
  ];
766
- await runShadowObservation("conv-1", 0);
723
+ await observeTurn("conv-1", 0);
767
724
  expect(orchestrateSpy).not.toHaveBeenCalled();
768
725
  expect(readRows()).toHaveLength(0);
769
726
  });
770
727
 
771
728
  describe("initLanes feeds synthetic capability pages into the section index", () => {
772
729
  test("the pageBody resolver returns capability content for a synthetic slug and disk body otherwise", async () => {
773
- shadowEnabled = true;
774
- await runShadowObservation("conv-1", 0);
730
+ await observeTurn("conv-1", 0);
775
731
 
776
732
  // `initLanes` ran the real pageBody-building closure and handed it to
777
733
  // `buildSectionIndex`; the stub captured it.
@@ -787,8 +743,7 @@ describe("memory-v3 shadow plugin", () => {
787
743
  });
788
744
 
789
745
  test("a synthetic slug's capability content yields a section the needle ranks", async () => {
790
- shadowEnabled = true;
791
- await runShadowObservation("conv-1", 0);
746
+ await observeTurn("conv-1", 0);
792
747
  const pageBody = capturedPageBody!;
793
748
 
794
749
  // Feed the captured resolver through the REAL section builder + needle:
@@ -819,35 +774,21 @@ describe("memory-v3 infrastructure-failure handling", () => {
819
774
 
820
775
  test("LIVE injector logs and degrades to no v3 block on an infra failure", async () => {
821
776
  liveEnabled = true;
822
- shadowEnabled = false;
823
777
  throwInfra();
824
778
 
825
779
  expect(await produce("conv-infra-live", 0)).toBeNull();
826
780
  });
827
781
 
828
- test("SHADOW injector swallows an infra failure (v2 fallback) no throw, no block", async () => {
829
- liveEnabled = false;
830
- shadowEnabled = true;
831
- throwInfra();
832
-
833
- // Shadow mode: v2 retrieval still ran this turn, so the v3 injector returns
834
- // null.
835
- expect(await produce("conv-infra-shadow", 0)).toBeNull();
836
- });
837
-
838
- test("runShadowObservation never throws on an infra failure (fire-and-forget)", async () => {
839
- liveEnabled = false;
840
- shadowEnabled = true;
782
+ test("observeTurn rethrows an infra failure so the injector can degrade distinctly", async () => {
841
783
  throwInfra();
842
784
 
843
- await expect(
844
- runShadowObservation("conv-infra-obs", 0),
845
- ).resolves.toBeUndefined();
785
+ await expect(observeTurn("conv-infra-obs", 0)).rejects.toBeInstanceOf(
786
+ MemoryV3RetrievalUnavailableError,
787
+ );
846
788
  });
847
789
 
848
790
  test("LIVE injector stays NON-fatal on a non-infra error (degrades to no v3 block)", async () => {
849
791
  liveEnabled = true;
850
- shadowEnabled = false;
851
792
  orchestrateSpy.mockImplementationOnce(async () => {
852
793
  throw new Error("some unexpected non-infra bug");
853
794
  });
@@ -24,11 +24,10 @@
24
24
  * all-repeat turn returns an EMPTY-TEXT block: assembly attaches nothing,
25
25
  * but the block's presence still keys v2 suppression (v3 ran and owns the
26
26
  * `<memory>` layer this turn). A `null` return (failure / empty selection /
27
- * every net-new card rendered empty) attaches no v3 block: in shadow mode
28
- * (`memory-v3-live` off) v2 still ran this turn, so its block stays as the
29
- * fallback; under `memory-v3-live` the user-prompt-submit hook skips v2
30
- * retrieval entirely, so a null return leaves the turn with no NEW injected
31
- * memory (prior turns' frozen cards still ride history).
27
+ * every net-new card rendered empty) attaches no v3 block: under
28
+ * `memory-v3-live` the user-prompt-submit hook skips v2 retrieval entirely,
29
+ * so a null return leaves the turn with no NEW injected memory (prior turns'
30
+ * frozen cards still ride history).
32
31
  *
33
32
  * - {@link memoryV3SpotlightInjector} (id `memory-v3-spotlight`,
34
33
  * `after-memory-prefix`): the EPHEMERAL layer. Renders the top `spotlight.n`
@@ -41,9 +40,8 @@
41
40
  * block id only); it is never persisted to metadata, so the frozen card
42
41
  * prefix it follows stays byte-stable and cached regardless.
43
42
  *
44
- * Gating: `memory.v3.live` (config) attaches blocks; the `memory-v3-shadow`
45
- * flag (live off) logs what WOULD inject (net-new slugs + bytes + spotlight
46
- * refs) and attaches nothing; both off → no orchestration.
43
+ * Gating: `memory.v3.live` (config) runs orchestration and attaches blocks;
44
+ * with it off, no orchestration runs and nothing is attached.
47
45
  *
48
46
  * Both injectors apply the same personal-memory trust gate as v2
49
47
  * ({@link isPersonalMemoryAllowed}): an untrusted remote actor's turn
@@ -60,7 +58,6 @@
60
58
  * The next compaction clears the store and resets both layers.
61
59
  */
62
60
 
63
- import { isAssistantFeatureFlagEnabled } from "../../../config/assistant-feature-flags.js";
64
61
  import { getConfig } from "../../../config/loader.js";
65
62
  import { isMemoryV3Live } from "../../../config/memory-v3-gate.js";
66
63
  import {
@@ -90,7 +87,7 @@ import {
90
87
  renderSpotlightInner,
91
88
  type SpotlightEntry,
92
89
  } from "./render-injection.js";
93
- import { MEMORY_V3_SHADOW, observeTurn } from "./shadow-plugin.js";
90
+ import { observeTurn } from "./shadow-plugin.js";
94
91
  import {
95
92
  MEMORY_V3_BLOCK_ID,
96
93
  MEMORY_V3_COMMIT_META_KEY,
@@ -261,8 +258,7 @@ export const memoryV3Injector: Injector = {
261
258
  const config = getConfig();
262
259
  if (config.memory.enabled === false) return null;
263
260
  const live = isMemoryV3Live(config);
264
- const shadow = isAssistantFeatureFlagEnabled(MEMORY_V3_SHADOW, config);
265
- if (!live && !shadow) return null;
261
+ if (!live) return null;
266
262
  if (!isPersonalMemoryAllowed(ctx.trust)) return null;
267
263
 
268
264
  let observed: OrchestrateResult | null;
@@ -275,18 +271,17 @@ export const memoryV3Injector: Injector = {
275
271
  {
276
272
  err: err.message,
277
273
  conversationId: ctx.conversationId,
278
- mode: live ? "live" : "shadow",
274
+ mode: "live",
279
275
  },
280
276
  "memory-v3 selection failed; skipping v3 memory for this turn",
281
277
  );
282
278
  }
283
279
  return null;
284
280
  }
285
- // Empty selection → return null (attach nothing). Returning null (vs an
286
- // empty block) preserves the shadow-mode v2 fallback: v2 suppression keys
287
- // off BOTH the flag AND a produced block, so in shadow a turn with nothing
288
- // selected ships v2's memory. Under `memory-v3-live` the hook skipped v2
289
- // retrieval, so the turn simply gets no injected memory.
281
+ // Empty selection → return null (attach nothing). The user-prompt-submit
282
+ // hook skipped v2 retrieval under live, so a turn with nothing selected
283
+ // simply gets no v3 `<memory>` block (prior turns' frozen cards still ride
284
+ // history).
290
285
  if (!observed || observed.selections.length === 0) return null;
291
286
  // `const` so the non-null narrowing survives capture in the `commit`
292
287
  // closure below (a `let` would re-widen to `OrchestrateResult | null`).
@@ -307,12 +302,10 @@ export const memoryV3Injector: Injector = {
307
302
  if (card.trim().length > 0) cards.push({ slug, card });
308
303
  }
309
304
  // Every net-new card rendered empty: return null rather than an
310
- // empty-text block. Returning null preserves the shadow-mode v2 fallback
311
- // (an empty block would key v2 suppression yet show nothing); under
312
- // `memory-v3-live` there is no v2 block, so the turn simply gets no new
313
- // memory. Distinct from the all-repeat case (empty `netNew`), where the
314
- // empty block correctly keeps v2 suppressed because the cards already
315
- // ride history.
305
+ // empty-text block. Under live there is no v2 block, so the turn simply
306
+ // gets no new memory. Distinct from the all-repeat case (empty `netNew`),
307
+ // where the empty block correctly keeps v2 suppressed because the cards
308
+ // already ride history.
316
309
  if (netNew.length > 0 && cards.length === 0) return null;
317
310
  const entries = cards.map(({ slug, card }) => ({
318
311
  slug,
@@ -325,25 +318,6 @@ export const memoryV3Injector: Injector = {
325
318
  bytes: isCapabilitySlug(slug) ? 0 : cardBytes(card),
326
319
  }));
327
320
 
328
- if (!live) {
329
- // Shadow mode: log what WOULD inject, attach and record nothing.
330
- const spotlightRefs = computeSpotlightEntries(
331
- result,
332
- config.memory.v3.spotlight.n,
333
- ).map((e) => `${e.slug}§${e.title}`);
334
- log.info(
335
- {
336
- conversationId: ctx.conversationId,
337
- turnIndex: ctx.turnIndex,
338
- netNew: entries,
339
- netNewBytes: entries.reduce((sum, e) => sum + e.bytes, 0),
340
- spotlightRefs,
341
- },
342
- "memory-v3 shadow: would inject net-new cards + spotlight",
343
- );
344
- return null;
345
- }
346
-
347
321
  // The everInjected store write and the prune-valve schedule are
348
322
  // DEFERRED to this commit callback, invoked by runtime assembly at the
349
323
  // point where attachment is guaranteed (the turn's tail is a user
@@ -396,9 +370,8 @@ export const memoryV3SpotlightInjector: Injector = {
396
370
  async produce(ctx: TurnContext): Promise<InjectionBlock | null> {
397
371
  const config = getConfig();
398
372
  if (config.memory.enabled === false) return null;
399
- // Live-only: shadow mode logs spotlight refs from the cards injector and
400
- // must keep the turn untouched (no ring state either, so a later
401
- // live-flag flip starts from a clean window).
373
+ // The spotlight rides the live `<memory>` layer; with `memory.v3.live` off
374
+ // it produces nothing and keeps no ring state.
402
375
  if (!isMemoryV3Live(config)) return null;
403
376
  if (!isPersonalMemoryAllowed(ctx.trust)) return null;
404
377
 
@@ -51,7 +51,6 @@
51
51
  * and `invalidateLanes` without process-global module mocks.
52
52
  */
53
53
 
54
- import { isAssistantFeatureFlagEnabled } from "../../../config/assistant-feature-flags.js";
55
54
  import { isMemoryV3Live } from "../../../config/memory-v3-gate.js";
56
55
  import type { AssistantConfig } from "../../../config/types.js";
57
56
  import {
@@ -80,8 +79,6 @@ import { buildSectionIndex as realBuildSectionIndex } from "./sections.js";
80
79
  import { invalidateLanes as realInvalidateLanes } from "./shadow-plugin.js";
81
80
  import type { Slug } from "./types.js";
82
81
 
83
- const MEMORY_V3_SHADOW = "memory-v3-shadow" as const;
84
-
85
82
  /**
86
83
  * Durable checkpoint holding the epoch-ms high-water mark of the last successful
87
84
  * re-embed pass. Pages whose mtime is past this mark are re-chunked + re-embedded
@@ -656,9 +653,7 @@ export async function maintainJob(
656
653
  failures: [],
657
654
  };
658
655
 
659
- const enabled =
660
- isAssistantFeatureFlagEnabled(MEMORY_V3_SHADOW, config) ||
661
- isMemoryV3Live(config);
656
+ const enabled = isMemoryV3Live(config);
662
657
  if (!enabled) {
663
658
  outcome.disabled = true;
664
659
  return outcome;
@@ -17,7 +17,6 @@
17
17
  */
18
18
 
19
19
  import type { MemoryV3SelectionLog } from "../../../api/responses/memory-v3-selection-log.js";
20
- import { isAssistantFeatureFlagEnabled } from "../../../config/assistant-feature-flags.js";
21
20
  import { getConfig } from "../../../config/loader.js";
22
21
  import { isMemoryV3Live } from "../../../config/memory-v3-gate.js";
23
22
  import { getDb, getSqliteFrom } from "../../../memory/db-connection.js";
@@ -35,8 +34,6 @@ import {
35
34
  type Slug,
36
35
  } from "./types.js";
37
36
 
38
- const MEMORY_V3_SHADOW = "memory-v3-shadow" as const;
39
-
40
37
  interface SelectionRow {
41
38
  turn: number;
42
39
  slug: string;
@@ -180,7 +177,6 @@ async function buildSelectionLog(
180
177
  return {
181
178
  turn: rows[0]!.turn,
182
179
  live: isMemoryV3Live(config),
183
- shadow: isAssistantFeatureFlagEnabled(MEMORY_V3_SHADOW, config),
184
180
  selections,
185
181
  injectedText,
186
182
  };
@@ -1,20 +1,12 @@
1
1
  /**
2
- * Memory v3 — flag-gated shadow/live orchestration engine.
2
+ * Memory v3 — config-gated live orchestration engine.
3
3
  *
4
- * Runs the v3 orchestrator each turn and records its selection set to
5
- * `memory_v3_selections`. Two flags gate behavior:
4
+ * When `memory.v3.live` is set, runs the v3 orchestrator each turn and records
5
+ * its selection set to `memory_v3_selections`. The injector (`memoryV3Injector`
6
+ * in `./injector.ts`) renders this turn's selections into a `<memory>` block
7
+ * and returns it at v2's dynamic-memory placement (`after-memory-prefix`).
6
8
  *
7
- * - `memory-v3-shadow` (live OFF): observation-only. {@link observeTurn}
8
- * orchestrates and logs the selection set; no injection is produced, so v2
9
- * injection is bit-for-bit identical — the only difference is the
10
- * side-effect telemetry write.
11
- * - `memory-v3-live`: live injection. The injector (`memoryV3Injector` in
12
- * `./injector.ts`) additionally renders this turn's selections into a
13
- * `<memory>` block and returns it at v2's dynamic-memory placement
14
- * (`after-memory-prefix`). Selections are still logged.
15
- * - both OFF: orchestration is skipped entirely.
16
- *
17
- * On each turn (either flag on):
9
+ * On each live turn:
18
10
  * 1. Lazy-init the v3 lanes ONCE across the whole process (section index,
19
11
  * section-grain BM25 needle, dense lane config, link-graph edge graph,
20
12
  * curated core set, frecency hot set), memoizing the init promise so
@@ -23,15 +15,13 @@
23
15
  * 3. Run {@link orchestrate} and record its selection set to
24
16
  * `memory_v3_selections` with a best-effort lane attribution.
25
17
  *
26
- * {@link observeTurn} wraps everything after the flag read in try/catch — any
27
- * failure is logged and swallowed so it can never affect the live turn. The
28
- * injector treats a `null`/empty result as "no v3 injection", so v2 memory
29
- * remains the fallback rather than dropping all memory.
18
+ * {@link observeTurn} wraps everything in try/catch — any failure is logged and
19
+ * swallowed so it can never affect the live turn. The injector treats a
20
+ * `null`/empty result as "no v3 injection".
30
21
  */
31
22
 
32
23
  import { existsSync, readFileSync } from "node:fs";
33
24
 
34
- import { isAssistantFeatureFlagEnabled } from "../../../config/assistant-feature-flags.js";
35
25
  import { getConfig } from "../../../config/loader.js";
36
26
  import type { AssistantConfig } from "../../../config/schema.js";
37
27
  import { loadSkillCatalog } from "../../../config/skills.js";
@@ -71,8 +61,6 @@ import {
71
61
  type Slug,
72
62
  } from "./types.js";
73
63
 
74
- export const MEMORY_V3_SHADOW = "memory-v3-shadow" as const;
75
-
76
64
  const log = getLogger("memory-v3-shadow");
77
65
 
78
66
  /** How many recent messages to fold into the shadow `recentContext` string. */
@@ -619,25 +607,3 @@ export async function observeTurn(
619
607
  return null;
620
608
  }
621
609
  }
622
-
623
- /**
624
- * Test-facing shadow wrapper: gate on the shadow flag, then run v3
625
- * orchestration for one turn and log the selection set. Production injection
626
- * goes through `produce()` → {@link observeTurn} directly (which checks both
627
- * flags); this wrapper exists so tests can drive shadow observation in
628
- * isolation. Never throws — all failures are logged and swallowed so the live
629
- * turn is unaffected.
630
- */
631
- export async function runShadowObservation(
632
- conversationId: string,
633
- turnIndex: number,
634
- ): Promise<void> {
635
- const config = getConfig();
636
- if (config.memory.enabled === false) return;
637
- if (!isAssistantFeatureFlagEnabled(MEMORY_V3_SHADOW, config)) return;
638
- try {
639
- await observeTurn(conversationId, turnIndex);
640
- } catch {
641
- // Shadow observation is fire-and-forget and must never fail a turn.
642
- }
643
- }