diff --git a/hooks/auto-injection.mjs b/hooks/auto-injection.mjs
index e9eabfaf2..42238efb4 100644
--- a/hooks/auto-injection.mjs
+++ b/hooks/auto-injection.mjs
@@ -25,9 +25,12 @@ export function estimateTokens(text) {
/**
* Build auto-injection block from session events.
* @param {Array<{category: string, data: string}>} events
+ * @param {"compaction"|"active_memory"} source — REQUIRED (fail-safe: no
+ * default, so no caller can silently emit the wrong label). "compaction"
+ * only after a real compact; "active_memory" for routine per-turn injection.
* @returns {string} XML block or empty string
*/
-export function buildAutoInjection(events) {
+export function buildAutoInjection(events, source) {
// Single O(N) pass instead of 4× O(N) Array.filter() loops. UserPromptSubmit
// fires this on every prompt; with N up to 100 events the prior implementation
// walked the array 4 times per prompt — wasteful on macOS, painful on Windows
@@ -98,5 +101,13 @@ export function buildAutoInjection(events) {
}
if (parts.length === 0) return "";
- return `\n\n${parts.join("\n\n")}\n\n`;
+ // Fidelity line only for genuine compaction: tells the agent what happened
+ // and where the history lives. Routine per-turn injection gets NO extra
+ // line — the label alone carries the meaning, and an always-present line
+ // would leak past the 500-token content budget (the wrapper is unbudgeted)
+ // and add noise to every turn.
+ const fidelity = source === "compaction"
+ ? "Context was compacted; full history persists in the session transcript.\n\n"
+ : "";
+ return `\n\n${fidelity}${parts.join("\n\n")}\n\n`;
}
diff --git a/hooks/sessionstart.mjs b/hooks/sessionstart.mjs
index 8083ce39c..9ee4565dd 100755
--- a/hooks/sessionstart.mjs
+++ b/hooks/sessionstart.mjs
@@ -206,7 +206,7 @@ await runHook(async () => {
additionalContext += buildSessionDirective("compact", eventMeta, toolNamer);
// Auto-inject behavioral state on compaction (role, decisions, skills, intent)
- const autoInjection = buildAutoInjection(events);
+ const autoInjection = buildAutoInjection(events, "compaction");
if (autoInjection) {
additionalContext += "\n\n" + autoInjection;
}
diff --git a/src/adapters/opencode/plugin.ts b/src/adapters/opencode/plugin.ts
index 5459359cb..be8a3cbf9 100644
--- a/src/adapters/opencode/plugin.ts
+++ b/src/adapters/opencode/plugin.ts
@@ -650,7 +650,7 @@ async function createContextModePlugin(ctx: PluginContext) {
// hooks/auto-injection.mjs). Pushed as a separate context entry so
// OpenCode can fold it independently from the verbose snapshot.
try {
- const autoBlock: string = autoInjectionMod.buildAutoInjection(events);
+ const autoBlock: string = autoInjectionMod.buildAutoInjection(events, "compaction");
if (autoBlock && autoBlock.length > 0) {
output.context.push(autoBlock);
}
diff --git a/src/adapters/pi/extension.ts b/src/adapters/pi/extension.ts
index bd5d4d982..e78de2f9c 100644
--- a/src/adapters/pi/extension.ts
+++ b/src/adapters/pi/extension.ts
@@ -156,17 +156,27 @@ export let _mcpBridgeReady: Promise = Promise.resolve();
// Cached buildAutoInjection (500-token cap, prioritized).
let _buildAutoInjection:
- | ((events: Array<{ category: string; data: string }>) => string)
+ | ((
+ events: Array<{ category: string; data: string }>,
+ source: "compaction" | "active_memory",
+ ) => string)
| null
| undefined = undefined;
+// Set by session_compact; consumed by the next before_agent_start so the
+// FIRST post-compact injection is honestly labeled "compaction" (with the
+// fidelity line telling the agent where its history lives). Read-and-cleared
+// at the top of the handler so it can never strand true and mislabel a
+// later, unrelated turn.
+let _pendingCompactLabel = false;
+
// Pending context to inject via the 'context' hook (avoiding systemPrompt mutation
// which breaks prefix prompt cache on DeepSeek/Anthropic/OpenAI).
// See: https://github.com/mksglu/context-mode/issues/598
let _pendingContext = "";
async function getAutoInjection(
pluginRoot: string,
-): Promise<((events: Array<{ category: string; data: string }>) => string) | null> {
+): Promise<((events: Array<{ category: string; data: string }>, source: "compaction" | "active_memory") => string) | null> {
if (_buildAutoInjection !== undefined) return _buildAutoInjection;
try {
const mod = await import(
@@ -607,6 +617,13 @@ export default function piExtension(pi: any): void {
pi.on("before_agent_start", async (event: any, ctx: any) => {
try {
_pendingContext = ""; // Reset — will be filled below if events exist
+ // Consume any pending real-compaction label FIRST (fail-safe: even if
+ // this turn injects nothing, the flag can never leak into a later,
+ // unrelated turn and produce a false "compaction" signal).
+ const injectSource: "compaction" | "active_memory" = _pendingCompactLabel
+ ? "compaction"
+ : "active_memory";
+ _pendingCompactLabel = false;
// Lazily start and await the MCP bridge only when Pi is about to
// dispatch a real agent turn. This is the non-brittle #534/#809 guard:
// help/version/package/config CLI paths may load the extension, but they
@@ -690,6 +707,7 @@ export default function piExtension(pi: any): void {
category: String(e.category ?? ""),
data: String(e.data ?? ""),
})),
+ injectSource,
);
}
// Fallback (or if helper produced empty output): inline 500-token cap.
@@ -848,6 +866,7 @@ export default function piExtension(pi: any): void {
try {
if (!_sessionId) return;
db.incrementCompactCount(_sessionId);
+ _pendingCompactLabel = true;
} catch {
// best effort
}
@@ -863,6 +882,7 @@ export default function piExtension(pi: any): void {
_db = null;
_dbPath = "";
_sessionId = "";
+ _pendingCompactLabel = false;
} catch {
// best effort — never throw during shutdown
}
diff --git a/tests/auto-injection-label.test.ts b/tests/auto-injection-label.test.ts
new file mode 100644
index 000000000..812d8fb32
--- /dev/null
+++ b/tests/auto-injection-label.test.ts
@@ -0,0 +1,83 @@
+/**
+ * auto-injection source-label tests — fix: false `source="compaction"` label
+ *
+ * The wrapper was hardcoded to `source="compaction"` while the pi adapter
+ * injects the block on EVERY agent start, causing models to falsely believe
+ * their context was destroyed (false-loss cascade, 2026-07-24).
+ *
+ * Contract under test:
+ * - source is a REQUIRED parameter (fail-safe: no silent default)
+ * - routine per-turn injection is labeled "active_memory" with NO fidelity line
+ * - genuine post-compaction injection is labeled "compaction" WITH a fidelity
+ * line stating where the history lives
+ * - empty events still yield "" regardless of source
+ * - the block stays within the documented ~500-token content budget plus the
+ * (unbudgeted) wrapper/fidelity overhead
+ */
+
+import { describe, test, expect } from "vitest";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const PROJECT_ROOT = join(__dirname, "..");
+
+const {
+ buildAutoInjection,
+ estimateTokens,
+ // @ts-expect-error — plain .mjs hook, no type declarations
+} = await import(join(PROJECT_ROOT, "hooks", "auto-injection.mjs"));
+
+const EVENTS = [
+ { category: "decision", data: "use sqlite for session store" },
+ { category: "skills", data: "lint, test" },
+ { category: "intent", data: "fixing the false compaction label" },
+];
+
+describe("buildAutoInjection source label", () => {
+ test('routine injection is labeled "active_memory" with no fidelity line', () => {
+ const block = buildAutoInjection(EVENTS, "active_memory");
+ expect(block).toContain('');
+ expect(block).not.toContain('source="compaction"');
+ expect(block).not.toContain("compacted");
+ expect(block).not.toContain("transcript");
+ expect(block).toContain("use sqlite for session store");
+ });
+
+ test('post-compact injection is labeled "compaction" WITH fidelity line', () => {
+ const block = buildAutoInjection(EVENTS, "compaction");
+ expect(block).toContain('');
+ expect(block).toContain("Context was compacted");
+ expect(block).toContain("session transcript");
+ });
+
+ test("empty events yield empty string regardless of source", () => {
+ expect(buildAutoInjection([], "active_memory")).toBe("");
+ expect(buildAutoInjection([], "compaction")).toBe("");
+ });
+
+ test("source is required — no silent default label", () => {
+ // Calling without a source must not produce the reassuring
+ // "active_memory" label silently; the wrong usage is loud in output.
+ const block = buildAutoInjection(EVENTS, undefined);
+ expect(block).not.toContain('source="active_memory"');
+ expect(block).not.toContain('source="compaction"');
+ });
+
+ test("block stays within content budget + wrapper overhead", () => {
+ // The 500-token budget meters only content parts; the wrapper (~45 chars)
+ // and the compaction fidelity line are outside that accounting. Assert
+ // against the honest total: 500 + wrapper + fidelity. Small fixtures
+ // only — the P2-overflow path can exceed budget by design.
+ for (const source of ["active_memory", "compaction"]) {
+ const block = buildAutoInjection(EVENTS, source);
+ const wrapper = `\n\n\n\n`;
+ const fidelity =
+ source === "compaction"
+ ? "Context was compacted; full history persists in the session transcript."
+ : "";
+ const overhead = estimateTokens(wrapper + fidelity);
+ expect(estimateTokens(block)).toBeLessThanOrEqual(500 + overhead);
+ }
+ });
+});