@vellumai/assistant 0.10.3-dev.202606282033.2a8a8de → 0.10.3-dev.202606282228.b3d6565
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.yaml +0 -6
- package/package.json +1 -1
- package/src/__tests__/pointer-conversation-trust.test.ts +141 -0
- package/src/api/responses/memory-v3-selection-log.ts +4 -6
- package/src/config/feature-flag-registry.json +0 -8
- package/src/daemon/lifecycle.ts +11 -0
- package/src/daemon/pointer-conversation-trust.ts +66 -0
- 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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { elevatePointerConversationToGuardian } from "../daemon/pointer-conversation-trust.js";
|
|
4
|
+
import {
|
|
5
|
+
INTERNAL_GUARDIAN_TRUST_CONTEXT,
|
|
6
|
+
type TrustContext,
|
|
7
|
+
} from "../daemon/trust-context.js";
|
|
8
|
+
import { resolveCapabilities } from "../runtime/capabilities.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fake conversation that mirrors `Conversation.loadFromDb`'s trust gate: a
|
|
12
|
+
* memory-capable (guardian) trust class sees the full guardian history; any
|
|
13
|
+
* other class sees an empty (filtered) history. `ensureActorScopedHistory`
|
|
14
|
+
* re-applies that gate against the current trust context, exactly like the real
|
|
15
|
+
* reload path.
|
|
16
|
+
*/
|
|
17
|
+
class FakeConversation {
|
|
18
|
+
trustContext: TrustContext | undefined;
|
|
19
|
+
visibleHistory: string[] = [];
|
|
20
|
+
setTrustContextCalls: Array<TrustContext | null> = [];
|
|
21
|
+
ensureCalls = 0;
|
|
22
|
+
private readonly guardianHistory: string[];
|
|
23
|
+
private readonly processing: boolean;
|
|
24
|
+
|
|
25
|
+
constructor(opts: {
|
|
26
|
+
trustContext?: TrustContext;
|
|
27
|
+
guardianHistory?: string[];
|
|
28
|
+
processing?: boolean;
|
|
29
|
+
}) {
|
|
30
|
+
this.trustContext = opts.trustContext;
|
|
31
|
+
this.guardianHistory = opts.guardianHistory ?? ["m1", "m2", "m3"];
|
|
32
|
+
this.processing = opts.processing ?? false;
|
|
33
|
+
this.applyHistoryForTrust();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private applyHistoryForTrust(): void {
|
|
37
|
+
this.visibleHistory = resolveCapabilities(this.trustContext?.trustClass)
|
|
38
|
+
.canAccessMemory
|
|
39
|
+
? [...this.guardianHistory]
|
|
40
|
+
: [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
isProcessing(): boolean {
|
|
44
|
+
return this.processing;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setTrustContext(ctx: TrustContext | null): void {
|
|
48
|
+
this.setTrustContextCalls.push(ctx);
|
|
49
|
+
this.trustContext = ctx ?? undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async ensureActorScopedHistory(): Promise<void> {
|
|
53
|
+
this.ensureCalls++;
|
|
54
|
+
this.applyHistoryForTrust();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const CONTACT_CONTEXT: TrustContext = {
|
|
59
|
+
sourceChannel: "vellum",
|
|
60
|
+
trustClass: "unverified_contact",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const GUARDIAN_CONTEXT: TrustContext = {
|
|
64
|
+
sourceChannel: "vellum",
|
|
65
|
+
trustClass: "guardian",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe("elevatePointerConversationToGuardian", () => {
|
|
69
|
+
test("elevates a cold trust-less conversation and rehydrates guardian history", async () => {
|
|
70
|
+
const conv = new FakeConversation({ trustContext: undefined });
|
|
71
|
+
// A cold (trust-less) load filters guardian history to empty.
|
|
72
|
+
expect(conv.visibleHistory).toEqual([]);
|
|
73
|
+
|
|
74
|
+
const restore = await elevatePointerConversationToGuardian(conv);
|
|
75
|
+
|
|
76
|
+
expect(conv.trustContext).toBe(INTERNAL_GUARDIAN_TRUST_CONTEXT);
|
|
77
|
+
expect(conv.ensureCalls).toBe(1);
|
|
78
|
+
// History is rehydrated rather than shipped empty.
|
|
79
|
+
expect(conv.visibleHistory).toEqual(["m1", "m2", "m3"]);
|
|
80
|
+
|
|
81
|
+
restore();
|
|
82
|
+
expect(conv.trustContext).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("restores the prior non-memory trust context after the turn", async () => {
|
|
86
|
+
const conv = new FakeConversation({ trustContext: CONTACT_CONTEXT });
|
|
87
|
+
expect(conv.visibleHistory).toEqual([]);
|
|
88
|
+
|
|
89
|
+
const restore = await elevatePointerConversationToGuardian(conv);
|
|
90
|
+
expect(conv.trustContext).toBe(INTERNAL_GUARDIAN_TRUST_CONTEXT);
|
|
91
|
+
expect(conv.visibleHistory).toEqual(["m1", "m2", "m3"]);
|
|
92
|
+
|
|
93
|
+
restore();
|
|
94
|
+
expect(conv.trustContext).toBe(CONTACT_CONTEXT);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("no-ops for a conversation that already has memory access", async () => {
|
|
98
|
+
const conv = new FakeConversation({ trustContext: GUARDIAN_CONTEXT });
|
|
99
|
+
expect(conv.visibleHistory).toEqual(["m1", "m2", "m3"]);
|
|
100
|
+
|
|
101
|
+
const restore = await elevatePointerConversationToGuardian(conv);
|
|
102
|
+
|
|
103
|
+
// No elevation, no redundant reload.
|
|
104
|
+
expect(conv.setTrustContextCalls).toEqual([]);
|
|
105
|
+
expect(conv.ensureCalls).toBe(0);
|
|
106
|
+
expect(conv.trustContext).toBe(GUARDIAN_CONTEXT);
|
|
107
|
+
|
|
108
|
+
restore();
|
|
109
|
+
expect(conv.setTrustContextCalls).toEqual([]);
|
|
110
|
+
expect(conv.trustContext).toBe(GUARDIAN_CONTEXT);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("does not mutate trust while the conversation is processing", async () => {
|
|
114
|
+
const conv = new FakeConversation({
|
|
115
|
+
trustContext: undefined,
|
|
116
|
+
processing: true,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const restore = await elevatePointerConversationToGuardian(conv);
|
|
120
|
+
|
|
121
|
+
expect(conv.setTrustContextCalls).toEqual([]);
|
|
122
|
+
expect(conv.ensureCalls).toBe(0);
|
|
123
|
+
expect(conv.trustContext).toBeUndefined();
|
|
124
|
+
|
|
125
|
+
restore();
|
|
126
|
+
expect(conv.setTrustContextCalls).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("restore leaves trust alone when a later turn replaced it", async () => {
|
|
130
|
+
const conv = new FakeConversation({ trustContext: undefined });
|
|
131
|
+
const restore = await elevatePointerConversationToGuardian(conv);
|
|
132
|
+
expect(conv.trustContext).toBe(INTERNAL_GUARDIAN_TRUST_CONTEXT);
|
|
133
|
+
|
|
134
|
+
// Simulate a new turn legitimately replacing the trust context mid-flight.
|
|
135
|
+
conv.setTrustContext(GUARDIAN_CONTEXT);
|
|
136
|
+
restore();
|
|
137
|
+
|
|
138
|
+
// The restorer must not clobber the new turn's context.
|
|
139
|
+
expect(conv.trustContext).toBe(GUARDIAN_CONTEXT);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Returned as part of `LlmContextResponse` — see `./llm-context-response.ts`.
|
|
11
11
|
*
|
|
12
|
-
* `live`
|
|
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",
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -122,6 +122,7 @@ import {
|
|
|
122
122
|
rebuildBm25CorpusStatsAndReseedSkills,
|
|
123
123
|
} from "./memory-v2-startup.js";
|
|
124
124
|
import { startOrphanReaper } from "./orphan-reaper.js";
|
|
125
|
+
import { elevatePointerConversationToGuardian } from "./pointer-conversation-trust.js";
|
|
125
126
|
import { processMessage } from "./process-message.js";
|
|
126
127
|
import { runProfilerSweep } from "./profiler-run-store.js";
|
|
127
128
|
import {
|
|
@@ -1117,6 +1118,14 @@ export async function runDaemon(): Promise<void> {
|
|
|
1117
1118
|
const conversation =
|
|
1118
1119
|
await server.getConversationForMessages(conversationId);
|
|
1119
1120
|
|
|
1121
|
+
// Pointer turns are guardian-gated owner self-maintenance: elevate to
|
|
1122
|
+
// the internal guardian context and rehydrate history so a cold
|
|
1123
|
+
// (evicted) load doesn't filter guardian history to empty and ship a
|
|
1124
|
+
// cache-missing turn. `restoreTrustContext` undoes the elevation after
|
|
1125
|
+
// the turn. See pointer-conversation-trust.ts for the full rationale.
|
|
1126
|
+
const restoreTrustContext =
|
|
1127
|
+
await elevatePointerConversationToGuardian(conversation);
|
|
1128
|
+
|
|
1120
1129
|
// Constrain pointer generation to a tool-disabled path so call-
|
|
1121
1130
|
// status events cannot trigger unintended side-effect tools.
|
|
1122
1131
|
// Incrementing toolsDisabledDepth causes the resolveTools callback
|
|
@@ -1242,6 +1251,8 @@ export async function runDaemon(): Promise<void> {
|
|
|
1242
1251
|
} finally {
|
|
1243
1252
|
// Restore tool availability so subsequent turns aren't affected.
|
|
1244
1253
|
conversation.toolsDisabledDepth--;
|
|
1254
|
+
// Undo the temporary guardian elevation installed above.
|
|
1255
|
+
restoreTrustContext();
|
|
1245
1256
|
}
|
|
1246
1257
|
},
|
|
1247
1258
|
);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardian-trust elevation for call-status pointer turns.
|
|
3
|
+
*
|
|
4
|
+
* Call-status pointer messages are generated as owner self-maintenance turns:
|
|
5
|
+
* only guardian-gated audiences route through the daemon processor (see
|
|
6
|
+
* `resolvePointerAudienceTrust` in `calls/call-pointer-messages.ts`), so the
|
|
7
|
+
* generation should run under the internal guardian context.
|
|
8
|
+
*
|
|
9
|
+
* The subtlety is history: `Conversation.loadFromDb` filters the persisted
|
|
10
|
+
* history to non-guardian provenance whenever the active trust context cannot
|
|
11
|
+
* access memory. On a cold (evicted) load — common on a memory-pressured daemon
|
|
12
|
+
* that evicts idle conversations — a freshly rebuilt conversation has no trust
|
|
13
|
+
* context, so a guardian-authored conversation's entire history is filtered to
|
|
14
|
+
* empty. The pointer turn would then ship with only its freshly persisted
|
|
15
|
+
* instruction, and because dropping the prior history (and tools) shifts the
|
|
16
|
+
* cacheable prefix, the request misses the prompt cache completely.
|
|
17
|
+
*
|
|
18
|
+
* Elevating to the guardian context and rehydrating before the turn restores
|
|
19
|
+
* the full history and makes the turn's shape deterministic regardless of
|
|
20
|
+
* eviction state.
|
|
21
|
+
*/
|
|
22
|
+
import { resolveCapabilities } from "../runtime/capabilities.js";
|
|
23
|
+
import {
|
|
24
|
+
INTERNAL_GUARDIAN_TRUST_CONTEXT,
|
|
25
|
+
type TrustContext,
|
|
26
|
+
} from "./trust-context.js";
|
|
27
|
+
|
|
28
|
+
/** Minimal `Conversation` surface needed to elevate pointer trust. */
|
|
29
|
+
export interface GuardianElevatableConversation {
|
|
30
|
+
readonly trustContext?: TrustContext | undefined;
|
|
31
|
+
isProcessing(): boolean;
|
|
32
|
+
setTrustContext(ctx: TrustContext | null): void;
|
|
33
|
+
ensureActorScopedHistory(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Elevate a pointer conversation to the internal guardian context and rehydrate
|
|
38
|
+
* its history so guardian-authored history is not filtered to empty on a cold
|
|
39
|
+
* load. Returns a function that restores the prior trust context.
|
|
40
|
+
*
|
|
41
|
+
* No-ops (returning a no-op restorer) when the conversation is already
|
|
42
|
+
* memory-capable, or when it is mid-turn — mutating `trustContext` during an
|
|
43
|
+
* active loop would elevate that turn's actor trust (mirrors the warm-path guard
|
|
44
|
+
* in `conversation-store.ts`).
|
|
45
|
+
*/
|
|
46
|
+
export async function elevatePointerConversationToGuardian(
|
|
47
|
+
conversation: GuardianElevatableConversation,
|
|
48
|
+
): Promise<() => void> {
|
|
49
|
+
const priorTrustContext = conversation.trustContext;
|
|
50
|
+
const shouldElevate =
|
|
51
|
+
!conversation.isProcessing() &&
|
|
52
|
+
!resolveCapabilities(priorTrustContext?.trustClass).canAccessMemory;
|
|
53
|
+
if (!shouldElevate) return () => {};
|
|
54
|
+
|
|
55
|
+
conversation.setTrustContext(INTERNAL_GUARDIAN_TRUST_CONTEXT);
|
|
56
|
+
await conversation.ensureActorScopedHistory();
|
|
57
|
+
|
|
58
|
+
return () => {
|
|
59
|
+
// Undo only the elevation this call installed. If a new turn started at an
|
|
60
|
+
// await boundary and legitimately updated trustContext, the reference will
|
|
61
|
+
// differ and we leave it alone.
|
|
62
|
+
if (conversation.trustContext === INTERNAL_GUARDIAN_TRUST_CONTEXT) {
|
|
63
|
+
conversation.setTrustContext(priorTrustContext ?? null);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
|
-
import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
|
|
4
3
|
import { getConfig } from "../config/loader.js";
|
|
5
4
|
import { isMemoryV3Live } from "../config/memory-v3-gate.js";
|
|
6
5
|
import type { AssistantConfig } from "../config/types.js";
|
|
@@ -950,16 +949,13 @@ export function maybeEnqueueGraphMaintenanceJobs(
|
|
|
950
949
|
|
|
951
950
|
// v3 self-maintenance backstop. Orthogonal to the v1/v2 mutual exclusion
|
|
952
951
|
// above: it owns its own checkpoint and operates on the v3 topic tree, so it
|
|
953
|
-
// runs under either branch. Gated on the same
|
|
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
|
});
|