@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.10-dev.202606100110.1d2c8c4",
3
+ "version": "0.8.10-dev.202606100317.c8b43c8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 { CONSOLIDATION_PROMPT, CUTOFF_PLACEHOLDER, resolveConsolidationPrompt } =
64
- await import("../prompts/consolidation.js");
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
- const bundledPrompt = (): string =>
69
- (CONSOLIDATION_PROMPT as string).replaceAll(CUTOFF_PLACEHOLDER, CUTOFF);
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("custom-prompt.md", CUTOFF);
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(`~/${filename}`, CUTOFF);
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("no-placeholder.md", CUTOFF);
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("custom-prompt.md", CUTOFF);
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
- ## 10. Review \`memory/core-pages.md\` — the curated core set
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. The prompt
448
- * treats the cutoff as opaque text — callers pass a `Mon D, h:mm AM/PM`
449
- * timestamp matching the `buffer.md` entry format so the agent compares
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(cutoff: string): string {
453
- return CONSOLIDATION_PROMPT.replaceAll(CUTOFF_PLACEHOLDER, cutoff);
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.replaceAll(CUTOFF_PLACEHOLDER, cutoff);
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 {