Skip to content

Commit 92e075d

Browse files
committed
fix: dreamer.inject_docs=false now actually omits project docs
When the <project-docs> block moved from the system prompt into the m[0] baseline, the dreamer.inject_docs gate stayed behind on the retired system-prompt path: the flag was still computed and passed to the system-prompt handler, which never used it, while the m[0] compose read ARCHITECTURE.md and STRUCTURE.md unconditionally on both harnesses. The flag silently did nothing. Thread the flag through OpenCode and Pi m[0] injection instead, with flag-only semantics independent of dreamer runnable state (docs exist on disk and are hand-authored regardless of whether the dreamer maintains them). Every docs read site routes through one gated helper per harness that returns the rendered block and the docs hash together as the stable empty pair when disabled, so execute, defer, fallback, and snapshot-marker paths all agree byte-for-byte. Flipping the flag mid-session does not force a materialization on its own; like docs content edits, it folds in on the next natural hard bust. The dead system-prompt plumbing is removed and the schema description now says what the flag actually gates. Fixes #210
1 parent 1c31e17 commit 92e075d

17 files changed

Lines changed: 233 additions & 65 deletions

assets/magic-context.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,7 @@
969969
},
970970
"inject_docs": {
971971
"default": true,
972-
"description": "Inject ARCHITECTURE.md and STRUCTURE.md into system prompt",
972+
"description": "Inject ARCHITECTURE.md and STRUCTURE.md into the m[0] `<project-docs>` block (default true)",
973973
"type": "boolean"
974974
},
975975
"thinking_level": {

packages/docs/src/content/docs/reference/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ Off-hours maintenance (Dreamer) and on-demand prompt augmentation (Sidekick).
199199
| `dreamer.tasks.refresh-primers.fallback_models` | string \\| string[] || Per-task fallback chain (inherits dreamer.fallback_models) |
200200
| `dreamer.tasks.refresh-primers.thinking_level` | `"off"` \\| `"minimal"` \\| `"low"` \\| `"medium"` \\| `"high"` \\| `"xhigh"` || Pi only: per-task thinking level |
201201
| `dreamer.tasks.refresh-primers.timeout_minutes` | number (5–) | `20` | Minutes allowed for this task before it is aborted |
202-
| `dreamer.inject_docs` | boolean | `true` | Inject ARCHITECTURE.md and STRUCTURE.md into system prompt |
202+
| `dreamer.inject_docs` | boolean | `true` | Inject ARCHITECTURE.md and STRUCTURE.md into the m[0] `<project-docs>` block (default true) |
203203
| `dreamer.thinking_level` | `"off"` \\| `"minimal"` \\| `"low"` \\| `"medium"` \\| `"high"` \\| `"xhigh"` || Pi only: default thinking level for dreamer subagent invocations. See historian.thinking_level. |
204204
| `sidekick` | object || Optional sidekick agent configuration for session-start memory retrieval |
205205
| `sidekick.model` | string || Primary model ID (e.g. 'claude-sonnet-4-6') |

packages/pi-plugin/src/context-handler.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -682,8 +682,10 @@ export interface PiHeuristicsOptions {
682682
/** <session-history> injection config — writes compartments+facts+memories into message[0]. */
683683
export interface PiInjectionOptions {
684684
/** When false (config `memory.enabled=false`), project memories are NOT read
685-
* or rendered into m[0]/m[1]. Docs still render. */
685+
* or rendered into m[0]/m[1]. Docs are controlled by injectDocs. */
686686
memoryEnabled?: boolean;
687+
/** Defaults true. When false, m[0] omits the <project-docs> block and docs hash. */
688+
injectDocs?: boolean;
687689
injectionBudgetTokens: number;
688690
temporalAwareness?: boolean;
689691
}
@@ -3297,8 +3299,10 @@ interface RunPipelineArgs {
32973299
/** Memory-injection config — when omitted, no <session-history> injection runs. */
32983300
injection?: {
32993301
/** When false (config `memory.enabled=false`), project memories are NOT
3300-
* read or rendered into m[0]/m[1]. Docs still render. */
3302+
* read or rendered into m[0]/m[1]. Docs are controlled by injectDocs. */
33013303
memoryEnabled?: boolean;
3304+
/** Defaults true. When false, m[0] omits the <project-docs> block and docs hash. */
3305+
injectDocs?: boolean;
33023306
injectionBudgetTokens: number;
33033307
/** v2 decay-render history budget (~60K), distinct from the memory
33043308
* injection budget. Drives compartment tier demotion in renderM0Pi. */
@@ -4257,17 +4261,18 @@ async function runPipeline(args: RunPipelineArgs): Promise<RunPipelineResult> {
42574261
// NOTE: do NOT clear the m[0]/m[1] cache on a cache-busting pass. A new
42584262
// compartment is an m[1] DELTA (SOFT), not an m[0] re-materialization
42594263
// (HARD) — clearing forced mustMaterializePi to first_render and folded
4260-
// m[0] every history-refresh pass, defeating the whole m[0]/m[1] split
4261-
// (parity with the OpenCode max_compartment_seq removal). injectM0M1Pi
4262-
// now keeps cached m[0] and soft-refreshes m[1] with the new compartment;
4263-
// HARD triggers (model/system/ttl/epoch/docs/upgrade/mutation) still
4264+
// m[0] every history-refresh pass, defeating the whole m[0]/m[1] split.
4265+
// This matches OpenCode's rule that a new compartment sequence alone is not
4266+
// a HARD trigger. injectM0M1Pi now keeps cached m[0] and soft-refreshes m[1];
4267+
// HARD triggers (model/system/ttl/epoch/upgrade/mutation) still
42644268
// re-materialize inside mustMaterializePi when genuinely needed.
42654269
injectionResult = injectM0M1Pi(
42664270
{
42674271
sessionId: args.sessionId,
42684272
projectIdentity: args.projectIdentity,
42694273
projectDirectory: args.projectDirectory,
42704274
memoryEnabled: args.injection.memoryEnabled,
4275+
injectDocs: args.injection.injectDocs,
42714276
injectionBudgetTokens: args.injection.injectionBudgetTokens,
42724277
historyBudgetTokens: args.injection.historyBudgetTokens,
42734278
hardSignals: piHardSignals,

packages/pi-plugin/src/index.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
657657
},
658658
injection: {
659659
memoryEnabled: cfg.memory.enabled,
660+
injectDocs: cfg.dreamer?.inject_docs !== false,
660661
injectionBudgetTokens: cfg.memory.injection_budget_tokens,
661662
temporalAwareness: cfg.temporal_awareness === true,
662663
},
@@ -1092,16 +1093,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
10921093
const effectiveDreamerRunnable = switchedProject
10931094
? isDreamerRunnable(effectiveConfig)
10941095
: isDreamerRunnable(config);
1095-
const injectDocs = switchedProject
1096-
? effectiveDreamerRunnable &&
1097-
(effectiveConfig.dreamer?.inject_docs ?? true)
1098-
: isDreamerRunnable(config) && (config.dreamer?.inject_docs ?? true);
10991096
const block = buildMagicContextBlock({
11001097
db,
11011098
cwd: currentProject.projectDir,
11021099
sessionId,
11031100
memoryEnabled: effectiveConfig.memory.enabled,
1104-
injectDocs,
11051101
includeGuidance: true,
11061102
protectedTags: effectiveConfig.protected_tags,
11071103
ctxReduceEnabled: effectiveConfig.ctx_reduce_enabled,

packages/pi-plugin/src/inject-compartments-pi.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "@magic-context/core/features/magic-context/memory/storage-memory";
1212
import {
1313
getCompartments,
14+
getOrCreateSessionMeta,
1415
queueMemoryMutation,
1516
setProjectState,
1617
} from "@magic-context/core/features/magic-context/storage";
@@ -385,6 +386,70 @@ describe("injectM0M1Pi", () => {
385386
}
386387
});
387388

389+
it("gates project docs block and hash with injectDocs=false", () => {
390+
const db = createTestDb();
391+
const cwd = mkdtempSync(join(tmpdir(), "pi-m0m1-docs-gate-"));
392+
try {
393+
writeFileSync(
394+
join(cwd, "ARCHITECTURE.md"),
395+
"# PI_FLAG_OFF_ARCH_DOCS\nArchitecture bytes must stay out.\n",
396+
);
397+
writeFileSync(
398+
join(cwd, "STRUCTURE.md"),
399+
"# PI_FLAG_OFF_STRUCTURE_DOCS\nStructure bytes must stay out.\n",
400+
);
401+
const state = { ...piState("ses-pi-docs-off", cwd), injectDocs: false };
402+
403+
const first = [userMessage("hello", 10)];
404+
const firstResult = injectM0M1Pi(state, db, first as never);
405+
const firstM0 = textOf(first[0] as never);
406+
const firstM1 = textOf(first[1] as never);
407+
408+
expect(firstResult.m0Materialized).toBe(true);
409+
expect(firstM0).not.toContain("<project-docs>");
410+
expect(firstM0).not.toContain("PI_FLAG_OFF_ARCH_DOCS");
411+
expect(firstM0).not.toContain("PI_FLAG_OFF_STRUCTURE_DOCS");
412+
expect(
413+
getOrCreateSessionMeta(db, state.sessionId).cachedM0ProjectDocsHash,
414+
).toBe("");
415+
expect(mustMaterializePi(state, db)).toEqual({
416+
value: false,
417+
reason: null,
418+
});
419+
420+
const second = [userMessage("hello again", 11)];
421+
const secondResult = injectM0M1Pi(
422+
state,
423+
db,
424+
second as never,
425+
undefined,
426+
false,
427+
);
428+
429+
expect(secondResult.m0Materialized).toBe(false);
430+
expect(textOf(second[0] as never)).toBe(firstM0);
431+
expect(textOf(second[1] as never)).toBe(firstM1);
432+
433+
const enabledState = piState("ses-pi-docs-on", cwd);
434+
const enabled = [userMessage("hello docs", 12)];
435+
injectM0M1Pi(enabledState, db, enabled as never);
436+
expect(textOf(enabled[0] as never)).toContain("<project-docs>");
437+
expect(textOf(enabled[0] as never)).toContain("PI_FLAG_OFF_ARCH_DOCS");
438+
expect(textOf(enabled[0] as never)).toContain(
439+
"PI_FLAG_OFF_STRUCTURE_DOCS",
440+
);
441+
expect(
442+
mustMaterializePi({ ...enabledState, injectDocs: false }, db),
443+
).toEqual({
444+
value: false,
445+
reason: null,
446+
});
447+
} finally {
448+
rmSync(cwd, { recursive: true, force: true });
449+
closeQuietly(db);
450+
}
451+
});
452+
388453
it("replays byte-stable cached m[0]/m[1] for identical state", () => {
389454
const db = createTestDb();
390455
const cwd = mkdtempSync(join(tmpdir(), "pi-m0m1-stable-"));

packages/pi-plugin/src/inject-compartments-pi.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -452,8 +452,10 @@ const EMPTY_MAX_COMPARTMENT_SEQ = -1;
452452

453453
type PiCompartment = ReturnType<typeof getCompartments>[number];
454454

455+
type PiProjectDocsRender = ReturnType<typeof readProjectDocsCanonical>;
456+
455457
interface FrozenM0Inputs {
456-
docs: ReturnType<typeof readProjectDocsCanonical>;
458+
docs: PiProjectDocsRender;
457459
markers: PiM0SnapshotMarkers;
458460
compartments: PiCompartment[];
459461
memories: Memory[];
@@ -497,9 +499,10 @@ export interface PiM0M1State {
497499
/** When false, project memories are NOT read or rendered into m[0]/m[1]
498500
* (config `memory.enabled=false`). Mirrors OpenCode, which passes
499501
* `projectPath: undefined` in that case so every memory read short-circuits.
500-
* Docs still render (they key off projectDirectory, not memory).
501-
* Unset/true keeps memory on. */
502+
* Docs are controlled independently by injectDocs. Unset/true keeps memory on. */
502503
memoryEnabled?: boolean;
504+
/** Defaults true. When false, m[0] omits the <project-docs> block and docs hash. */
505+
injectDocs?: boolean;
503506
/** Memory-block trim budget (~4K). Bounds the <project-memory> block. */
504507
injectionBudgetTokens?: number;
505508
/** v2 decay-render history budget (~60K). Drives compartment tier demotion.
@@ -513,11 +516,22 @@ export interface PiM0M1State {
513516
hardSignals?: PiM0HardSignals;
514517
}
515518

519+
const EMPTY_PI_PROJECT_DOCS: PiProjectDocsRender = {
520+
renderedBlock: "",
521+
canonicalHash: "",
522+
};
523+
524+
function readProjectDocsForPiM0(state: PiM0M1State): PiProjectDocsRender {
525+
return state.injectDocs !== false
526+
? readProjectDocsCanonical(state.projectDirectory)
527+
: EMPTY_PI_PROJECT_DOCS;
528+
}
529+
516530
/**
517531
* The project path used for MEMORY reads only. Returns undefined when
518532
* `memory.enabled=false`, so every memory read short-circuits to its empty
519-
* value (mirrors OpenCode passing `projectPath: undefined`). Docs + key-files
520-
* use `projectDirectory` directly and are unaffected.
533+
* value (mirrors OpenCode passing `projectPath: undefined`). Project docs use
534+
* the independent injectDocs flag.
521535
*/
522536
function memoryProjectPath(state: PiM0M1State): string | undefined {
523537
return state.memoryEnabled === false ? undefined : state.projectIdentity;
@@ -846,8 +860,7 @@ function readCurrentMarkersFromCompartments(
846860
: null,
847861
projectUserProfileVersion: globalState?.projectUserProfileVersion ?? 0,
848862
projectDocsHash:
849-
projectDocsHash ??
850-
readProjectDocsCanonical(state.projectDirectory).canonicalHash,
863+
projectDocsHash ?? readProjectDocsForPiM0(state).canonicalHash,
851864
sessionFactsVersion: getSessionFactsVersion(db, state.sessionId),
852865
materializedAt: Date.now(),
853866
// Dynamic upgrade state (parity with OpenCode getUpgradeState): suffix
@@ -992,7 +1005,7 @@ function renderUserProfileBlock(
9921005
export function renderM0Pi(
9931006
state: PiM0M1State,
9941007
db: ContextDatabase,
995-
projectDocs = readProjectDocsCanonical(state.projectDirectory).renderedBlock,
1008+
projectDocs = readProjectDocsForPiM0(state).renderedBlock,
9961009
decayPressureMultiplier = 1,
9971010
// Atomic-snapshot override: when materializeM0Pi reads markers + memories in
9981011
// one transaction, it passes the SAME memory set here so the rendered m[0]
@@ -1169,7 +1182,7 @@ export class PiMaterializeContentionError extends Error {
11691182
function readFrozenM0InputsPi(
11701183
state: PiM0M1State,
11711184
db: ContextDatabase,
1172-
docs = readProjectDocsCanonical(state.projectDirectory),
1185+
docs = readProjectDocsForPiM0(state),
11731186
memoryCutoff?: number,
11741187
): FrozenM0Inputs {
11751188
// Read every render source and its corresponding watermark as one short DB
@@ -1254,7 +1267,7 @@ function renderFreshM0PiNonPersisted(
12541267
snapshotMarkers: PiM0SnapshotMarkers;
12551268
renderedMemoryIds: number[];
12561269
} {
1257-
const docs = readProjectDocsCanonical(state.projectDirectory);
1270+
const docs = readProjectDocsForPiM0(state);
12581271
const cachedMaterializedAt =
12591272
getOrCreateSessionMeta(db, state.sessionId).cachedM0MaterializedAt ?? 0;
12601273
const frozen = readFrozenM0InputsPi(state, db, docs, cachedMaterializedAt);
@@ -1317,7 +1330,7 @@ export function materializeM0Pi(
13171330
} {
13181331
// Phase 1 (no lock): read markers + render. Rendering can be slow, so we do
13191332
// it OUTSIDE the write lock to keep the BEGIN IMMEDIATE critical section tiny.
1320-
const docs = readProjectDocsCanonical(state.projectDirectory);
1333+
const docs = readProjectDocsForPiM0(state);
13211334
const foldMaterializedAt = Date.now();
13221335
const frozen = readFrozenM0InputsPi(state, db, docs, foldMaterializedAt);
13231336
const snapshotMarkers = frozen.markers;
@@ -1368,9 +1381,7 @@ export function materializeM0Pi(
13681381
attempts += 1;
13691382
}
13701383
const m0Bytes = Buffer.from(m0, "utf8");
1371-
const phase3ProjectDocsHash = readProjectDocsCanonical(
1372-
state.projectDirectory,
1373-
).canonicalHash;
1384+
const phase3ProjectDocsHash = readProjectDocsForPiM0(state).canonicalHash;
13741385

13751386
// Phase 2 + 3 (locked): re-read markers under BEGIN IMMEDIATE; if anything
13761387
// changed since Phase 1, the rendered bytes are stale — roll back and let the

packages/pi-plugin/src/signal-peek-drain.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,9 +327,11 @@ describe("source contract: peek-then-drain in before_agent_start (system prompt)
327327
expect(code).not.toContain("^todo.*write");
328328
});
329329

330-
test("project-docs injection is gated on dreamer.disable", () => {
331-
expect(code).toContain("isDreamerRunnable(config) &&");
332-
expect(code).toContain("(config.dreamer?.inject_docs ?? true)");
330+
test("project-docs m0 injection uses the flag independent of dreamer.disable", () => {
331+
expect(code).toContain("injectDocs: cfg.dreamer?.inject_docs !== false");
332+
expect(code).not.toContain(
333+
"isDreamerRunnable(config) && (config.dreamer?.inject_docs",
334+
);
333335
});
334336

335337
test("hash-change path remains eager for all three refresh sets", () => {

packages/pi-plugin/src/system-prompt.test.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ describe("buildMagicContextBlock v2 system-prompt parity", () => {
2424
cwd: tempDir("pi-guidance-"),
2525
sessionId: "ses-guidance",
2626
memoryEnabled: true,
27-
injectDocs: true,
2827
includeGuidance: true,
2928
});
3029

@@ -51,7 +50,6 @@ describe("buildMagicContextBlock v2 system-prompt parity", () => {
5150
cwd,
5251
sessionId: "ses-v2-system",
5352
memoryEnabled: true,
54-
injectDocs: true,
5553
includeGuidance: true,
5654
userMemoriesEnabled: true,
5755
});
@@ -75,7 +73,6 @@ describe("buildMagicContextBlock v2 system-prompt parity", () => {
7573
cwd,
7674
sessionId: "ses-no-guidance",
7775
memoryEnabled: true,
78-
injectDocs: true,
7976
includeGuidance: false,
8077
userMemoriesEnabled: true,
8178
pinKeyFilesEnabled: true,
@@ -95,7 +92,6 @@ describe("buildMagicContextBlock v2 system-prompt parity", () => {
9592
cwd: tempDir("pi-guidance-dedup-"),
9693
sessionId: "ses-guidance-dedup",
9794
memoryEnabled: false,
98-
injectDocs: false,
9995
includeGuidance: true,
10096
existingSystemPrompt: "base\n## Magic Context\nalready present",
10197
});
@@ -114,7 +110,6 @@ describe("buildMagicContextBlock v2 system-prompt parity", () => {
114110
cwd: tempDir("pi-noreduce-"),
115111
sessionId: "ses-noreduce",
116112
memoryEnabled: false,
117-
injectDocs: false,
118113
includeGuidance: true,
119114
ctxReduceEnabled: false,
120115
});
@@ -135,15 +130,13 @@ describe("buildMagicContextBlock v2 system-prompt parity", () => {
135130
cwd: tempDir("pi-language-baseline-"),
136131
sessionId: "ses-language-baseline",
137132
memoryEnabled: true,
138-
injectDocs: false,
139133
includeGuidance: true,
140134
});
141135
const unset = buildMagicContextBlock({
142136
db,
143137
cwd: tempDir("pi-language-unset-"),
144138
sessionId: "ses-language-unset",
145139
memoryEnabled: true,
146-
injectDocs: false,
147140
includeGuidance: true,
148141
language: " ",
149142
});
@@ -152,7 +145,6 @@ describe("buildMagicContextBlock v2 system-prompt parity", () => {
152145
cwd: tempDir("pi-language-set-"),
153146
sessionId: "ses-language-set",
154147
memoryEnabled: true,
155-
injectDocs: false,
156148
includeGuidance: true,
157149
language: "es",
158150
});

packages/pi-plugin/src/system-prompt.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ export interface BuildMagicContextBlockOptions {
3535
sessionId?: string;
3636
/** Reserved for compatibility; project memories now live in m[0]/m[1]. */
3737
memoryEnabled: boolean;
38-
/** Reserved for compatibility; project docs now live in m[0]. */
39-
injectDocs: boolean;
4038
memoryBudgetChars?: number;
4139
/** When true (default), emit the `## Magic Context` guidance section. */
4240
includeGuidance?: boolean;

packages/plugin/src/config/schema/magic-context.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,9 @@ export const DreamerConfigSchema = AgentOverrideConfigSchema.merge(
171171
inject_docs: z
172172
.boolean()
173173
.default(true)
174-
.describe("Inject ARCHITECTURE.md and STRUCTURE.md into system prompt"),
174+
.describe(
175+
"Inject ARCHITECTURE.md and STRUCTURE.md into the m[0] `<project-docs>` block (default true)",
176+
),
175177
thinking_level: PiThinkingLevelSchema.describe(
176178
"Pi only: default thinking level for dreamer subagent invocations. See historian.thinking_level.",
177179
),

0 commit comments

Comments
 (0)