@vellumai/assistant 0.8.10-dev.202606100110.1d2c8c4 → 0.8.10-dev.202606100317.c8b43c8
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/package.json +1 -1
- package/src/memory/v2/__tests__/consolidation-job.test.ts +14 -0
- package/src/memory/v2/__tests__/consolidation-prompt-flag-gating-guard.test.ts +140 -0
- package/src/memory/v2/__tests__/prompts-consolidation.test.ts +85 -15
- package/src/memory/v2/consolidation-job.ts +9 -5
- package/src/memory/v2/prompts/consolidation.ts +71 -22
package/package.json
CHANGED
|
@@ -336,6 +336,20 @@ describe("memoryV2ConsolidateJob — non-empty buffer", () => {
|
|
|
336
336
|
expect(enqueuedJobs.map((j) => j.type)).toEqual(["memory_v2_reembed"]);
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
+
test("includes the core-pages curation section in the prompt only when a v3 flag is on", async () => {
|
|
340
|
+
// v2-only installs must not be instructed to curate memory/core-pages.md
|
|
341
|
+
// — the file feeds the v3 core lane and is inert without it.
|
|
342
|
+
v3FlagOn = false;
|
|
343
|
+
await memoryV2ConsolidateJob(makeJob(), CONFIG);
|
|
344
|
+
expect(runnerLastArgs?.prompt as string).not.toContain("core-pages");
|
|
345
|
+
|
|
346
|
+
v3FlagOn = true;
|
|
347
|
+
await memoryV2ConsolidateJob(makeJob(), CONFIG);
|
|
348
|
+
expect(runnerLastArgs?.prompt as string).toContain(
|
|
349
|
+
"## 10. Review `memory/core-pages.md`",
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
339
353
|
test("returns run_failed and skips follow-ups when the runner reports failure", async () => {
|
|
340
354
|
runnerImpl = async () => ({
|
|
341
355
|
conversationId: "conv-1",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard: the DEFAULT consolidation prompt must not leak flag-gated feature
|
|
3
|
+
* content to installs that don't have the feature enabled.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the consolidation prompt ships to every assistant with no
|
|
6
|
+
* `memory.v2.consolidation_prompt_path` override, and it is processed without
|
|
7
|
+
* any flag check at run time — so flag-gated instructions added to the shared
|
|
8
|
+
* template reach the entire default population at their next consolidation.
|
|
9
|
+
* That happened once: the memory-v3 `core-pages.md` curation step landed
|
|
10
|
+
* inline and instructed v2-only installs to curate a file nothing on their
|
|
11
|
+
* install reads, under premises ("kept in reach every turn", "the hot set")
|
|
12
|
+
* that are false without the v3 lanes. This is the same leak class
|
|
13
|
+
* `workspace-release-notes-feature-flag-guard.test.ts` blocks for UPDATES.md,
|
|
14
|
+
* via a path that guard doesn't cover.
|
|
15
|
+
*
|
|
16
|
+
* The rule this enforces: flag-gated content enters the template only via a
|
|
17
|
+
* `{{...}}` placeholder substituted by `renderConsolidationPrompt` under a
|
|
18
|
+
* `ConsolidationPromptOptions` gate. When you add a gated section, register
|
|
19
|
+
* it in GATED_SECTIONS below so the guard covers it.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { describe, expect, test } from "bun:test";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
CORE_PAGES_CONSOLIDATION_SECTION,
|
|
27
|
+
renderConsolidationPrompt,
|
|
28
|
+
} from "../prompts/consolidation.js";
|
|
29
|
+
|
|
30
|
+
type Registry = {
|
|
31
|
+
flags: Array<{
|
|
32
|
+
key: string;
|
|
33
|
+
scope: string;
|
|
34
|
+
defaultEnabled: boolean;
|
|
35
|
+
}>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every flag-gated section of the consolidation prompt, with a short
|
|
40
|
+
* distinctive marker that would survive light rewording of the section body.
|
|
41
|
+
* The guard asserts neither appears in the all-gates-off rendering.
|
|
42
|
+
*/
|
|
43
|
+
const GATED_SECTIONS: Array<{
|
|
44
|
+
name: string;
|
|
45
|
+
section: string;
|
|
46
|
+
marker: string;
|
|
47
|
+
}> = [
|
|
48
|
+
{
|
|
49
|
+
name: "memory-v3 core-pages curation (gate: memory-v3-shadow|live)",
|
|
50
|
+
section: CORE_PAGES_CONSOLIDATION_SECTION,
|
|
51
|
+
marker: "core-pages",
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** All `ConsolidationPromptOptions` gates forced off — the v2-only rendering. */
|
|
56
|
+
const ALL_GATES_OFF = { includeCorePagesSection: false };
|
|
57
|
+
|
|
58
|
+
// Registry helpers mirror workspace-release-notes-feature-flag-guard.test.ts.
|
|
59
|
+
function loadDefaultDisabledAssistantFlagKeys(): string[] {
|
|
60
|
+
const registryPath = join(
|
|
61
|
+
process.cwd(),
|
|
62
|
+
"..",
|
|
63
|
+
"meta",
|
|
64
|
+
"feature-flags",
|
|
65
|
+
"feature-flag-registry.json",
|
|
66
|
+
);
|
|
67
|
+
const registry = JSON.parse(readFileSync(registryPath, "utf-8")) as Registry;
|
|
68
|
+
return registry.flags
|
|
69
|
+
.filter((flag) => flag.scope === "assistant" && !flag.defaultEnabled)
|
|
70
|
+
.map((flag) => flag.key)
|
|
71
|
+
.sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function escapeRegExp(value: string): string {
|
|
75
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function featureFlagKeyPattern(key: string): RegExp {
|
|
79
|
+
const escaped = escapeRegExp(key);
|
|
80
|
+
if (key.includes("-")) {
|
|
81
|
+
return new RegExp(`(?:^|[^a-z0-9-])${escaped}(?:$|[^a-z0-9-])`, "i");
|
|
82
|
+
}
|
|
83
|
+
return new RegExp("[`'\"]" + escaped + "[`'\"]", "i");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("default consolidation prompt flag-gating guard", () => {
|
|
87
|
+
const rendered = renderConsolidationPrompt("Jan 1, 12:00 AM", ALL_GATES_OFF);
|
|
88
|
+
|
|
89
|
+
test("leaves no unsubstituted {{...}} placeholders", () => {
|
|
90
|
+
// A placeholder added to the template but not wired into
|
|
91
|
+
// renderConsolidationPrompt's substitutions would ship raw braces to
|
|
92
|
+
// every default-prompt install.
|
|
93
|
+
const residue = rendered.match(/\{\{[A-Z0-9_]+\}\}/g) ?? [];
|
|
94
|
+
expect(
|
|
95
|
+
residue,
|
|
96
|
+
"Unsubstituted placeholder(s) in the default consolidation prompt — " +
|
|
97
|
+
"wire them through renderConsolidationPrompt and gate them via " +
|
|
98
|
+
"ConsolidationPromptOptions.",
|
|
99
|
+
).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("contains no flag-gated section content when all gates are off", () => {
|
|
103
|
+
const violations: string[] = [];
|
|
104
|
+
for (const { name, section, marker } of GATED_SECTIONS) {
|
|
105
|
+
if (rendered.includes(section)) {
|
|
106
|
+
violations.push(`${name}: full section text present`);
|
|
107
|
+
}
|
|
108
|
+
if (rendered.includes(marker)) {
|
|
109
|
+
violations.push(`${name}: marker "${marker}" present`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const message = [
|
|
114
|
+
"The default consolidation prompt reaches every install with no prompt",
|
|
115
|
+
"override, regardless of feature flags. Flag-gated feature instructions",
|
|
116
|
+
"must enter via a {{...}} placeholder substituted under a",
|
|
117
|
+
"ConsolidationPromptOptions gate (see CORE_PAGES_PLACEHOLDER for the",
|
|
118
|
+
"pattern) — never inline in CONSOLIDATION_PROMPT.",
|
|
119
|
+
"",
|
|
120
|
+
"Violations:",
|
|
121
|
+
...violations.map((violation) => ` - ${violation}`),
|
|
122
|
+
].join("\n");
|
|
123
|
+
expect(violations, message).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("references no default-disabled assistant feature flags", () => {
|
|
127
|
+
const violations: string[] = [];
|
|
128
|
+
for (const key of loadDefaultDisabledAssistantFlagKeys()) {
|
|
129
|
+
if (featureFlagKeyPattern(key).test(rendered)) {
|
|
130
|
+
violations.push(`references default-disabled flag "${key}"`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
expect(
|
|
134
|
+
violations,
|
|
135
|
+
"The default consolidation prompt must not mention default-disabled " +
|
|
136
|
+
"assistant feature flags by name.\n" +
|
|
137
|
+
violations.map((violation) => ` - ${violation}`).join("\n"),
|
|
138
|
+
).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -60,13 +60,29 @@ afterAll(() => {
|
|
|
60
60
|
rmSync(tmpWorkspace, { recursive: true, force: true });
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
const {
|
|
64
|
-
|
|
63
|
+
const {
|
|
64
|
+
CONSOLIDATION_PROMPT,
|
|
65
|
+
CORE_PAGES_CONSOLIDATION_SECTION,
|
|
66
|
+
CORE_PAGES_PLACEHOLDER,
|
|
67
|
+
CUTOFF_PLACEHOLDER,
|
|
68
|
+
resolveConsolidationPrompt,
|
|
69
|
+
} = await import("../prompts/consolidation.js");
|
|
65
70
|
|
|
66
71
|
const CUTOFF = "2026-05-01T12:00:00.000Z";
|
|
67
72
|
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
/** Options for tests not exercising the v3 core-pages gate. */
|
|
74
|
+
const NO_CORE = { includeCorePagesSection: false };
|
|
75
|
+
const WITH_CORE = { includeCorePagesSection: true };
|
|
76
|
+
|
|
77
|
+
const bundledPrompt = (includeCorePagesSection = false): string =>
|
|
78
|
+
(CONSOLIDATION_PROMPT as string)
|
|
79
|
+
.replaceAll(CUTOFF_PLACEHOLDER, CUTOFF)
|
|
80
|
+
.replaceAll(
|
|
81
|
+
CORE_PAGES_PLACEHOLDER,
|
|
82
|
+
includeCorePagesSection
|
|
83
|
+
? (CORE_PAGES_CONSOLIDATION_SECTION as string)
|
|
84
|
+
: "",
|
|
85
|
+
);
|
|
70
86
|
|
|
71
87
|
beforeEach(() => {
|
|
72
88
|
warnCalls.length = 0;
|
|
@@ -88,7 +104,7 @@ afterEach(() => {
|
|
|
88
104
|
|
|
89
105
|
describe("resolveConsolidationPrompt — no override", () => {
|
|
90
106
|
test("returns the bundled prompt with {{CUTOFF}} substituted when overridePath is null", () => {
|
|
91
|
-
const result = resolveConsolidationPrompt(null, CUTOFF);
|
|
107
|
+
const result = resolveConsolidationPrompt(null, CUTOFF, NO_CORE);
|
|
92
108
|
expect(result).toContain("You are running memory consolidation");
|
|
93
109
|
expect(result).toContain(CUTOFF);
|
|
94
110
|
expect(result).not.toContain(CUTOFF_PLACEHOLDER);
|
|
@@ -96,12 +112,49 @@ describe("resolveConsolidationPrompt — no override", () => {
|
|
|
96
112
|
});
|
|
97
113
|
});
|
|
98
114
|
|
|
115
|
+
describe("resolveConsolidationPrompt — core-pages gate", () => {
|
|
116
|
+
test("omits the core-pages section (and any placeholder residue) when the v3 gate is off", () => {
|
|
117
|
+
// v2-only installs must not be told to curate a file nothing reads.
|
|
118
|
+
const result = resolveConsolidationPrompt(null, CUTOFF, NO_CORE);
|
|
119
|
+
expect(result).not.toContain("core-pages");
|
|
120
|
+
expect(result).not.toContain(CORE_PAGES_PLACEHOLDER);
|
|
121
|
+
// The section's slot collapses cleanly: §9 flows straight into the
|
|
122
|
+
// separator with no stray blank lines.
|
|
123
|
+
expect(result).toContain(
|
|
124
|
+
"never wholesale-clear.\n\n---\n\n# What NOT to do",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("includes the core-pages section exactly once, in place, when the v3 gate is on", () => {
|
|
129
|
+
const result = resolveConsolidationPrompt(null, CUTOFF, WITH_CORE);
|
|
130
|
+
expect(result).toContain("## 10. Review `memory/core-pages.md`");
|
|
131
|
+
expect(result.split("## 10. Review").length - 1).toBe(1);
|
|
132
|
+
expect(result).not.toContain(CORE_PAGES_PLACEHOLDER);
|
|
133
|
+
// Positioned between §9 and the don'ts, as authored.
|
|
134
|
+
const sectionAt = result.indexOf("## 10. Review");
|
|
135
|
+
expect(sectionAt).toBeGreaterThan(result.indexOf("## 9. Trim"));
|
|
136
|
+
expect(sectionAt).toBeLessThan(result.indexOf("# What NOT to do"));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("substitutes the placeholder in override files per the same gate", () => {
|
|
140
|
+
const path = join(tmpWorkspace, "custom-prompt.md");
|
|
141
|
+
writeFileSync(path, "Before\n{{CORE_PAGES_SECTION}}After {{CUTOFF}}\n");
|
|
142
|
+
|
|
143
|
+
const withCore = resolveConsolidationPrompt(path, CUTOFF, WITH_CORE);
|
|
144
|
+
expect(withCore).toContain("## 10. Review `memory/core-pages.md`");
|
|
145
|
+
expect(withCore).not.toContain(CORE_PAGES_PLACEHOLDER);
|
|
146
|
+
|
|
147
|
+
const withoutCore = resolveConsolidationPrompt(path, CUTOFF, NO_CORE);
|
|
148
|
+
expect(withoutCore).toBe(`Before\nAfter ${CUTOFF}\n`);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
99
152
|
describe("resolveConsolidationPrompt — with override", () => {
|
|
100
153
|
test("loads an absolute path verbatim and substitutes {{CUTOFF}}", () => {
|
|
101
154
|
const path = join(tmpWorkspace, "custom-prompt.md");
|
|
102
155
|
writeFileSync(path, "Custom prompt at {{CUTOFF}}\n");
|
|
103
156
|
|
|
104
|
-
const result = resolveConsolidationPrompt(path, CUTOFF);
|
|
157
|
+
const result = resolveConsolidationPrompt(path, CUTOFF, NO_CORE);
|
|
105
158
|
|
|
106
159
|
expect(result).toBe(`Custom prompt at ${CUTOFF}\n`);
|
|
107
160
|
expect(warnCalls).toHaveLength(0);
|
|
@@ -113,7 +166,11 @@ describe("resolveConsolidationPrompt — with override", () => {
|
|
|
113
166
|
"Workspace-relative {{CUTOFF}}\n",
|
|
114
167
|
);
|
|
115
168
|
|
|
116
|
-
const result = resolveConsolidationPrompt(
|
|
169
|
+
const result = resolveConsolidationPrompt(
|
|
170
|
+
"custom-prompt.md",
|
|
171
|
+
CUTOFF,
|
|
172
|
+
NO_CORE,
|
|
173
|
+
);
|
|
117
174
|
|
|
118
175
|
expect(result).toBe(`Workspace-relative ${CUTOFF}\n`);
|
|
119
176
|
expect(warnCalls).toHaveLength(0);
|
|
@@ -124,7 +181,11 @@ describe("resolveConsolidationPrompt — with override", () => {
|
|
|
124
181
|
const path = join(homedir(), filename);
|
|
125
182
|
writeFileSync(path, "Home dir {{CUTOFF}}\n");
|
|
126
183
|
try {
|
|
127
|
-
const result = resolveConsolidationPrompt(
|
|
184
|
+
const result = resolveConsolidationPrompt(
|
|
185
|
+
`~/${filename}`,
|
|
186
|
+
CUTOFF,
|
|
187
|
+
NO_CORE,
|
|
188
|
+
);
|
|
128
189
|
expect(result).toBe(`Home dir ${CUTOFF}\n`);
|
|
129
190
|
expect(warnCalls).toHaveLength(0);
|
|
130
191
|
} finally {
|
|
@@ -136,7 +197,11 @@ describe("resolveConsolidationPrompt — with override", () => {
|
|
|
136
197
|
const body = "No placeholder here. Just a plain prompt.\n";
|
|
137
198
|
writeFileSync(join(tmpWorkspace, "no-placeholder.md"), body);
|
|
138
199
|
|
|
139
|
-
const result = resolveConsolidationPrompt(
|
|
200
|
+
const result = resolveConsolidationPrompt(
|
|
201
|
+
"no-placeholder.md",
|
|
202
|
+
CUTOFF,
|
|
203
|
+
NO_CORE,
|
|
204
|
+
);
|
|
140
205
|
|
|
141
206
|
expect(result).toBe(body);
|
|
142
207
|
expect(warnCalls).toHaveLength(0);
|
|
@@ -148,7 +213,11 @@ describe("resolveConsolidationPrompt — with override", () => {
|
|
|
148
213
|
"{{CUTOFF}} ... {{CUTOFF}} ... {{CUTOFF}}",
|
|
149
214
|
);
|
|
150
215
|
|
|
151
|
-
const result = resolveConsolidationPrompt(
|
|
216
|
+
const result = resolveConsolidationPrompt(
|
|
217
|
+
"custom-prompt.md",
|
|
218
|
+
CUTOFF,
|
|
219
|
+
NO_CORE,
|
|
220
|
+
);
|
|
152
221
|
|
|
153
222
|
expect(result).toBe(`${CUTOFF} ... ${CUTOFF} ... ${CUTOFF}`);
|
|
154
223
|
expect(result).not.toContain(CUTOFF_PLACEHOLDER);
|
|
@@ -160,6 +229,7 @@ describe("resolveConsolidationPrompt — failure modes", () => {
|
|
|
160
229
|
const result = resolveConsolidationPrompt(
|
|
161
230
|
"/this/path/does/not/exist.md",
|
|
162
231
|
CUTOFF,
|
|
232
|
+
NO_CORE,
|
|
163
233
|
);
|
|
164
234
|
|
|
165
235
|
expect(result).toBe(bundledPrompt());
|
|
@@ -173,7 +243,7 @@ describe("resolveConsolidationPrompt — failure modes", () => {
|
|
|
173
243
|
const path = join(tmpWorkspace, "empty.md");
|
|
174
244
|
writeFileSync(path, "");
|
|
175
245
|
|
|
176
|
-
const result = resolveConsolidationPrompt(path, CUTOFF);
|
|
246
|
+
const result = resolveConsolidationPrompt(path, CUTOFF, NO_CORE);
|
|
177
247
|
|
|
178
248
|
expect(result).toBe(bundledPrompt());
|
|
179
249
|
expect(warnCalls).toHaveLength(1);
|
|
@@ -185,7 +255,7 @@ describe("resolveConsolidationPrompt — failure modes", () => {
|
|
|
185
255
|
const path = join(tmpWorkspace, "empty.md");
|
|
186
256
|
writeFileSync(path, " \n\n\t\n");
|
|
187
257
|
|
|
188
|
-
const result = resolveConsolidationPrompt(path, CUTOFF);
|
|
258
|
+
const result = resolveConsolidationPrompt(path, CUTOFF, NO_CORE);
|
|
189
259
|
|
|
190
260
|
expect(result).toBe(bundledPrompt());
|
|
191
261
|
expect(warnCalls).toHaveLength(1);
|
|
@@ -198,7 +268,7 @@ describe("resolveConsolidationPrompt — failure modes", () => {
|
|
|
198
268
|
// 1 MiB + 1 byte — just over the cap so we don't waste test memory.
|
|
199
269
|
writeFileSync(path, Buffer.alloc(1 * 1024 * 1024 + 1, 0x61));
|
|
200
270
|
|
|
201
|
-
const result = resolveConsolidationPrompt(path, CUTOFF);
|
|
271
|
+
const result = resolveConsolidationPrompt(path, CUTOFF, NO_CORE);
|
|
202
272
|
|
|
203
273
|
expect(result).toBe(bundledPrompt());
|
|
204
274
|
expect(warnCalls).toHaveLength(1);
|
|
@@ -213,7 +283,7 @@ describe("resolveConsolidationPrompt — failure modes", () => {
|
|
|
213
283
|
const link = join(tmpWorkspace, "link.md");
|
|
214
284
|
symlinkSync(target, link);
|
|
215
285
|
|
|
216
|
-
const result = resolveConsolidationPrompt(link, CUTOFF);
|
|
286
|
+
const result = resolveConsolidationPrompt(link, CUTOFF, NO_CORE);
|
|
217
287
|
|
|
218
288
|
expect(result).toBe(bundledPrompt());
|
|
219
289
|
expect(warnCalls).toHaveLength(1);
|
|
@@ -230,7 +300,7 @@ describe("resolveConsolidationPrompt — failure modes", () => {
|
|
|
230
300
|
return;
|
|
231
301
|
}
|
|
232
302
|
|
|
233
|
-
const result = resolveConsolidationPrompt(fifoPath, CUTOFF);
|
|
303
|
+
const result = resolveConsolidationPrompt(fifoPath, CUTOFF, NO_CORE);
|
|
234
304
|
|
|
235
305
|
expect(result).toBe(bundledPrompt());
|
|
236
306
|
expect(warnCalls).toHaveLength(1);
|
|
@@ -175,10 +175,17 @@ export async function memoryV2ConsolidateJob(
|
|
|
175
175
|
// the `memory.v2.consolidation_prompt_path` config override but bounds
|
|
176
176
|
// it to a regular file under 1 MiB before substitution so a stray path
|
|
177
177
|
// (or a `/dev/zero`-style pseudo-file) cannot exfiltrate megabytes of
|
|
178
|
-
// bytes through the wake hint.
|
|
178
|
+
// bytes through the wake hint. The core-pages curation section rides the
|
|
179
|
+
// same v3 gate as the maintenance follow-up: the file feeds the v3 core
|
|
180
|
+
// lane, so on a v2-only install the instruction would curate a file
|
|
181
|
+
// nothing reads.
|
|
182
|
+
const memoryV3Active =
|
|
183
|
+
isAssistantFeatureFlagEnabled(MEMORY_V3_SHADOW, config) ||
|
|
184
|
+
isAssistantFeatureFlagEnabled(MEMORY_V3_LIVE, config);
|
|
179
185
|
const prompt = resolveConsolidationPrompt(
|
|
180
186
|
config.memory.v2.consolidation_prompt_path,
|
|
181
187
|
cutoff,
|
|
188
|
+
{ includeCorePagesSection: memoryV3Active },
|
|
182
189
|
);
|
|
183
190
|
|
|
184
191
|
const runResult = await runBackgroundJob({
|
|
@@ -212,10 +219,7 @@ export async function memoryV2ConsolidateJob(
|
|
|
212
219
|
// is active, so it never fans out on v2-only installs.
|
|
213
220
|
const followUpJobIds: string[] = [];
|
|
214
221
|
const jobTypes: MemoryJobType[] = [...FOLLOW_UP_JOB_TYPES];
|
|
215
|
-
if (
|
|
216
|
-
isAssistantFeatureFlagEnabled(MEMORY_V3_SHADOW, config) ||
|
|
217
|
-
isAssistantFeatureFlagEnabled(MEMORY_V3_LIVE, config)
|
|
218
|
-
) {
|
|
222
|
+
if (memoryV3Active) {
|
|
219
223
|
jobTypes.push(V3_FOLLOW_UP_JOB_TYPE);
|
|
220
224
|
}
|
|
221
225
|
for (const jobType of jobTypes) {
|
|
@@ -30,6 +30,37 @@ const log = getLogger("memory-v2-consolidate-prompt");
|
|
|
30
30
|
/** Sentinel substituted with the cutoff timestamp at runtime. */
|
|
31
31
|
export const CUTOFF_PLACEHOLDER = "{{CUTOFF}}";
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Sentinel substituted with {@link CORE_PAGES_CONSOLIDATION_SECTION} (or the
|
|
35
|
+
* empty string) at runtime. The core-pages file only exists for the memory-v3
|
|
36
|
+
* retrieval lanes, so the section is included only when a v3 flag is enabled
|
|
37
|
+
* for the assistant — on a v2-only install the instruction would have the
|
|
38
|
+
* agent curate a file nothing reads, under premises ("kept in reach every
|
|
39
|
+
* turn", "the hot set") that are false there.
|
|
40
|
+
*
|
|
41
|
+
* New flag-gated sections must follow this placeholder pattern and be
|
|
42
|
+
* registered in consolidation-prompt-flag-gating-guard.test.ts, which blocks
|
|
43
|
+
* gated content from reaching the default (all-gates-off) rendering.
|
|
44
|
+
*/
|
|
45
|
+
export const CORE_PAGES_PLACEHOLDER = "{{CORE_PAGES_SECTION}}";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The flag-gated `memory/core-pages.md` curation step. Ends with a blank line
|
|
49
|
+
* so substituting it (or the empty string) ahead of the `---` separator keeps
|
|
50
|
+
* the surrounding template byte-stable either way.
|
|
51
|
+
*/
|
|
52
|
+
export const CORE_PAGES_CONSOLIDATION_SECTION = `## 10. Review \`memory/core-pages.md\` — the curated core set
|
|
53
|
+
|
|
54
|
+
\`memory/core-pages.md\` lists the pages retrieval keeps in reach EVERY turn, regardless of topic. It exists for one class of page: associative texture — registers, identity frames, calibration rules — pages with no lexical or semantic match to the message that neither search nor usage frequency can surface on their own. You are the only editor of this file; review it each pass.
|
|
55
|
+
|
|
56
|
+
- **Format:** one page per list line — \`- [[slug]]\` or \`- slug\`, optionally followed by an inline note: after a wikilink the note is free-form (\`- [[slug]] — why it belongs\`); after a bare slug introduce it with a dash (\`- slug — why it belongs\`). Headings, blank lines, and standalone prose lines are ignored, so annotate freely.
|
|
57
|
+
- **Keep it small** — on the order of a few dozen entries (~30–50). Every entry costs context budget every single turn.
|
|
58
|
+
- **Add** a page only when its content should always be in reach with no topical cue. If a search query would find it, it doesn't belong here.
|
|
59
|
+
- **Remove** entries made redundant by frequent use — recently-used pages stay warm automatically (the hot set), so a page that comes up all the time doesn't need a core slot.
|
|
60
|
+
- **Fix** entries whose page you renamed or deleted this pass (maintenance reports dangling entries, but never edits the file).
|
|
61
|
+
|
|
62
|
+
`;
|
|
63
|
+
|
|
33
64
|
/**
|
|
34
65
|
* Upper bound for the override file. Real consolidation prompts are kilobytes;
|
|
35
66
|
* 1 MiB is generous headroom while preventing a `settings.write` principal from
|
|
@@ -384,17 +415,7 @@ Scan namespace sizes. If any namespace has crossed ~12-15 articles with visible
|
|
|
384
415
|
- Rewrite to contain ONLY entries with timestamp ≥ \`${CUTOFF_PLACEHOLDER}\`.
|
|
385
416
|
- Smart removal — never wholesale-clear.
|
|
386
417
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
\`memory/core-pages.md\` lists the pages retrieval keeps in reach EVERY turn, regardless of topic. It exists for one class of page: associative texture — registers, identity frames, calibration rules — pages with no lexical or semantic match to the message that neither search nor usage frequency can surface on their own. You are the only editor of this file; review it each pass.
|
|
390
|
-
|
|
391
|
-
- **Format:** one page per list line — \`- [[slug]]\` or \`- slug\`, optionally followed by an inline note: after a wikilink the note is free-form (\`- [[slug]] — why it belongs\`); after a bare slug introduce it with a dash (\`- slug — why it belongs\`). Headings, blank lines, and standalone prose lines are ignored, so annotate freely.
|
|
392
|
-
- **Keep it small** — on the order of a few dozen entries (~30–50). Every entry costs context budget every single turn.
|
|
393
|
-
- **Add** a page only when its content should always be in reach with no topical cue. If a search query would find it, it doesn't belong here.
|
|
394
|
-
- **Remove** entries made redundant by frequent use — recently-used pages stay warm automatically (the hot set), so a page that comes up all the time doesn't need a core slot.
|
|
395
|
-
- **Fix** entries whose page you renamed or deleted this pass (maintenance reports dangling entries, but never edits the file).
|
|
396
|
-
|
|
397
|
-
---
|
|
418
|
+
${CORE_PAGES_PLACEHOLDER}---
|
|
398
419
|
|
|
399
420
|
# What NOT to do
|
|
400
421
|
|
|
@@ -443,14 +464,31 @@ For each article you touched:
|
|
|
443
464
|
|
|
444
465
|
This is the engine that decides who you are tomorrow. Be ORGANIZED. Care, judgment, voice. Your voice. Your wiki.`;
|
|
445
466
|
|
|
467
|
+
/** Flag-derived options threaded from the consolidation job. */
|
|
468
|
+
export interface ConsolidationPromptOptions {
|
|
469
|
+
/**
|
|
470
|
+
* Include the `memory/core-pages.md` curation step. True only when a
|
|
471
|
+
* memory-v3 flag (shadow or live) is enabled for the assistant — the file
|
|
472
|
+
* feeds the v3 core lane and is inert on v2-only installs.
|
|
473
|
+
*/
|
|
474
|
+
includeCorePagesSection: boolean;
|
|
475
|
+
}
|
|
476
|
+
|
|
446
477
|
/**
|
|
447
|
-
* Resolve `CONSOLIDATION_PROMPT` with `{{CUTOFF}}` substituted
|
|
448
|
-
*
|
|
449
|
-
*
|
|
478
|
+
* Resolve `CONSOLIDATION_PROMPT` with `{{CUTOFF}}` substituted and the
|
|
479
|
+
* flag-gated core-pages section included or elided. The prompt treats the
|
|
480
|
+
* cutoff as opaque text — callers pass a `Mon D, h:mm AM/PM` timestamp
|
|
481
|
+
* matching the `buffer.md` entry format so the agent compares
|
|
450
482
|
* like-with-like.
|
|
451
483
|
*/
|
|
452
|
-
export function renderConsolidationPrompt(
|
|
453
|
-
|
|
484
|
+
export function renderConsolidationPrompt(
|
|
485
|
+
cutoff: string,
|
|
486
|
+
options: ConsolidationPromptOptions,
|
|
487
|
+
): string {
|
|
488
|
+
return CONSOLIDATION_PROMPT.replaceAll(CUTOFF_PLACEHOLDER, cutoff).replaceAll(
|
|
489
|
+
CORE_PAGES_PLACEHOLDER,
|
|
490
|
+
options.includeCorePagesSection ? CORE_PAGES_CONSOLIDATION_SECTION : "",
|
|
491
|
+
);
|
|
454
492
|
}
|
|
455
493
|
|
|
456
494
|
/**
|
|
@@ -458,6 +496,11 @@ export function renderConsolidationPrompt(cutoff: string): string {
|
|
|
458
496
|
* referenced by `memory.v2.consolidation_prompt_path`, then substitute
|
|
459
497
|
* `{{CUTOFF}}`. Path-resolution rules are documented on the schema field.
|
|
460
498
|
*
|
|
499
|
+
* Override files get the same placeholder substitutions as the bundled
|
|
500
|
+
* template: `{{CUTOFF}}` always, and `{{CORE_PAGES_SECTION}}` per the same
|
|
501
|
+
* flag gate — so a prompt copied from the bundled source never leaks a raw
|
|
502
|
+
* placeholder, and a customized prompt can opt into the managed section.
|
|
503
|
+
*
|
|
461
504
|
* Failure handling is intentionally permissive — missing file, read error, or
|
|
462
505
|
* empty/whitespace-only body all log a warning and fall back to the bundled
|
|
463
506
|
* prompt. Consolidation must never break because of a bad override: the
|
|
@@ -466,8 +509,9 @@ export function renderConsolidationPrompt(cutoff: string): string {
|
|
|
466
509
|
export function resolveConsolidationPrompt(
|
|
467
510
|
overridePath: string | null,
|
|
468
511
|
cutoff: string,
|
|
512
|
+
options: ConsolidationPromptOptions,
|
|
469
513
|
): string {
|
|
470
|
-
if (overridePath === null) return renderConsolidationPrompt(cutoff);
|
|
514
|
+
if (overridePath === null) return renderConsolidationPrompt(cutoff, options);
|
|
471
515
|
|
|
472
516
|
const resolvedPath = resolveOverridePath(overridePath);
|
|
473
517
|
let contents: string;
|
|
@@ -483,7 +527,7 @@ export function resolveConsolidationPrompt(
|
|
|
483
527
|
},
|
|
484
528
|
"consolidation prompt override is not a regular file; using bundled prompt",
|
|
485
529
|
);
|
|
486
|
-
return renderConsolidationPrompt(cutoff);
|
|
530
|
+
return renderConsolidationPrompt(cutoff, options);
|
|
487
531
|
}
|
|
488
532
|
if (stat.size > MAX_PROMPT_BYTES) {
|
|
489
533
|
log.warn(
|
|
@@ -497,7 +541,7 @@ export function resolveConsolidationPrompt(
|
|
|
497
541
|
},
|
|
498
542
|
"consolidation prompt override exceeds size limit; using bundled prompt",
|
|
499
543
|
);
|
|
500
|
-
return renderConsolidationPrompt(cutoff);
|
|
544
|
+
return renderConsolidationPrompt(cutoff, options);
|
|
501
545
|
}
|
|
502
546
|
contents = readFileSync(resolvedPath, "utf-8");
|
|
503
547
|
} catch (err) {
|
|
@@ -506,7 +550,7 @@ export function resolveConsolidationPrompt(
|
|
|
506
550
|
{ configuredPath: overridePath, resolvedPath, code, fallback: "bundled" },
|
|
507
551
|
"consolidation prompt override unreadable; using bundled prompt",
|
|
508
552
|
);
|
|
509
|
-
return renderConsolidationPrompt(cutoff);
|
|
553
|
+
return renderConsolidationPrompt(cutoff, options);
|
|
510
554
|
}
|
|
511
555
|
|
|
512
556
|
if (contents.trim().length === 0) {
|
|
@@ -519,10 +563,15 @@ export function resolveConsolidationPrompt(
|
|
|
519
563
|
},
|
|
520
564
|
"consolidation prompt override is empty; using bundled prompt",
|
|
521
565
|
);
|
|
522
|
-
return renderConsolidationPrompt(cutoff);
|
|
566
|
+
return renderConsolidationPrompt(cutoff, options);
|
|
523
567
|
}
|
|
524
568
|
|
|
525
|
-
return contents
|
|
569
|
+
return contents
|
|
570
|
+
.replaceAll(CUTOFF_PLACEHOLDER, cutoff)
|
|
571
|
+
.replaceAll(
|
|
572
|
+
CORE_PAGES_PLACEHOLDER,
|
|
573
|
+
options.includeCorePagesSection ? CORE_PAGES_CONSOLIDATION_SECTION : "",
|
|
574
|
+
);
|
|
526
575
|
}
|
|
527
576
|
|
|
528
577
|
function resolveOverridePath(overridePath: string): string {
|