@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 +0 -6
- package/package.json +1 -1
- package/src/api/responses/memory-v3-selection-log.ts +4 -6
- package/src/config/feature-flag-registry.json +0 -8
- package/src/memory/jobs-worker.ts +2 -6
- package/src/memory/v2/__tests__/consolidation-job.test.ts +17 -20
- package/src/memory/v2/consolidation-job.ts +10 -21
- package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +2 -36
- package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +20 -28
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +3 -10
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +14 -15
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +36 -95
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +19 -46
- package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +1 -6
- package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +0 -4
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +9 -43
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
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Returned as part of `LlmContextResponse` — see `./llm-context-response.ts`.
|
|
11
11
|
*
|
|
12
|
-
* `live`
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
|
108
|
+
// ── v3 live gate mock ───────────────────────────────────────────────
|
|
109
109
|
//
|
|
110
|
-
//
|
|
111
|
-
// follow-up fan-out,
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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
|
|
121
|
-
// `isMemoryV3Live
|
|
122
|
-
//
|
|
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 —
|
|
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
|
|
715
|
-
//
|
|
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("
|
|
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-
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
266
|
-
//
|
|
267
|
-
// lane
|
|
268
|
-
//
|
|
269
|
-
//
|
|
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:
|
|
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
|
|
313
|
-
//
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
:
|
|
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
|
|
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
|
-
*
|
|
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 →
|
|
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
|
|
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
|
|
12
|
+
* → attribute selections to lane sources (the plugin's REAL
|
|
14
13
|
* `attributeSelections`, reading `result.lanes`)
|
|
15
|
-
* → write to `memory_v3_selections` (the
|
|
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
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
5
|
-
* -
|
|
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
|
-
* -
|
|
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
|
-
|
|
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
|
|
452
|
-
test("global memory disabled → observation is skipped
|
|
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
|
|
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("
|
|
473
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
688
|
-
await
|
|
689
|
-
await
|
|
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
|
|
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
|
-
|
|
713
|
-
await
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
752
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
845
|
-
)
|
|
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:
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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;
|
|
45
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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).
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
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.
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
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
|
-
//
|
|
400
|
-
//
|
|
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 —
|
|
2
|
+
* Memory v3 — config-gated live orchestration engine.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* `memory_v3_selections`.
|
|
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
|
-
*
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
}
|