diff --git a/plugin/pi/compaction-recovery.js b/plugin/pi/compaction-recovery.js index 8a69a789..3d79b2df 100644 --- a/plugin/pi/compaction-recovery.js +++ b/plugin/pi/compaction-recovery.js @@ -1,4 +1,9 @@ const SUMMARY_FIELD_PATHS = [ + // Current Pi session_compact event shape: + // { compactionEntry: CompactionEntry, fromExtension, reason, willRetry } + // where CompactionEntry.summary holds the summary text. Listed first so the + // documented shape wins over generic legacy fallbacks. + ["compactionEntry", "summary"], ["summary"], ["compactedSummary"], ["compacted_summary"], @@ -47,6 +52,11 @@ export function extractCompactedSummary(event) { return undefined; } +/** + * Manual fallback used when the compaction summary could NOT be archived to + * Engram during the session_compact handler (no summary extracted, no session, + * or the Engram write failed). Asks the agent to save it on the next turn. + */ export function recoveryInstruction(project) { return ( `CRITICAL INSTRUCTION FOR COMPACTED SUMMARY:\n` + @@ -57,8 +67,30 @@ export function recoveryInstruction(project) { ); } -export function buildRecoveryNotice(project, context) { - const instruction = recoveryInstruction(project); +/** + * Used when the compaction summary was already archived to Engram by + * gentle-engram. Still surfaces retrieved project context for continuity, but + * omits the manual `FIRST ACTION REQUIRED: mem_session_summary` instruction so + * the agent does not duplicate the save it just completed. + */ +function persistedAcknowledgement(project) { + return ( + `Compaction recovery summary was already saved to Engram (${project}) by gentle-engram. ` + + `No manual mem_session_summary call is needed for this compaction. ` + + `Call mem_context if you need additional recent project memory.` + ); +} + +/** + * Build the recovery notice injected before the next agent turn. + * + * @param {string} project Engram project name. + * @param {string|undefined} context Retrieved Engram project context, if any. + * @param {boolean} [persisted=false] True when the compaction summary was + * already archived to Engram; suppresses the manual save instruction. + */ +export function buildRecoveryNotice(project, context, persisted = false) { + const instruction = persisted ? persistedAcknowledgement(project) : recoveryInstruction(project); const trimmedContext = typeof context === "string" ? context.trim() : ""; return trimmedContext ? `${trimmedContext}\n\n${instruction}` : instruction; } diff --git a/plugin/pi/index.ts b/plugin/pi/index.ts index 33a5523d..4451cf50 100644 --- a/plugin/pi/index.ts +++ b/plugin/pi/index.ts @@ -887,8 +887,9 @@ export default function registerEngram(pi: ExtensionAPI) { if (sessionId) await ensureSessionBestEffort(sessionId); const summary = extractCompactedSummary(event); + let persistedSummary = false; if (sessionId && summary) { - await bestEffortEngramFetch("/observations", { + const saved = await bestEffortEngramFetch("/observations", { method: "POST", body: { session_id: sessionId, @@ -900,10 +901,14 @@ export default function registerEngram(pi: ExtensionAPI) { topic_key: "session/compaction-recovery", }, }); + // bestEffortEngramFetch returns null on a failed/unreachable write. Treat + // only a confirmed response as persisted so a failed archive still falls + // back to the manual FIRST ACTION REQUIRED save instruction. + persistedSummary = saved !== null; } const data = await bestEffortEngramFetch(`/context?project=${encodeURIComponent(project)}`); - pendingRecoveryNotice = buildRecoveryNotice(project, data?.context); + pendingRecoveryNotice = buildRecoveryNotice(project, data?.context, persistedSummary); }); pi.on("before_agent_start", async (event: AgentStartEvent, ctx: SessionContext) => { diff --git a/plugin/pi/test/compaction-recovery.test.mjs b/plugin/pi/test/compaction-recovery.test.mjs index f76c7770..e628264c 100644 --- a/plugin/pi/test/compaction-recovery.test.mjs +++ b/plugin/pi/test/compaction-recovery.test.mjs @@ -15,6 +15,26 @@ test("extractCompactedSummary supports top-level and nested summary fields", () assert.equal(extractCompactedSummary({ compaction: { content: "content summary" } }), "content summary"); }); +test("extractCompactedSummary reads the current Pi session_compact event shape", () => { + // Documented Pi event: { compactionEntry: CompactionEntry, ... } where + // CompactionEntry.summary holds the summary text. + assert.equal( + extractCompactedSummary({ compactionEntry: { summary: "native pi summary" } }), + "native pi summary", + ); + assert.equal( + extractCompactedSummary({ + compactionEntry: { summary: " extension summary " }, + fromExtension: true, + reason: "threshold", + willRetry: false, + }), + "extension summary", + ); + // Empty compactionEntry.summary is ignored (falls through / returns undefined). + assert.equal(extractCompactedSummary({ compactionEntry: { summary: " " } }), undefined); +}); + test("recoveryInstruction keeps manual FIRST ACTION REQUIRED fallback", () => { const notice = recoveryInstruction("engram"); assert.match(notice, /FIRST ACTION REQUIRED/); @@ -27,3 +47,15 @@ test("buildRecoveryNotice prefixes context when available", () => { assert.equal(buildRecoveryNotice("engram", "existing context").startsWith("existing context\n\nCRITICAL"), true); assert.equal(buildRecoveryNotice("engram", "").startsWith("CRITICAL"), true); }); + +test("buildRecoveryNotice drops the manual save instruction once persisted", () => { + // Default (persisted=false): manual save instruction is present. + const fallback = buildRecoveryNotice("engram", undefined); + assert.match(fallback, /FIRST ACTION REQUIRED/); + + // After a successful archive: no manual save instruction, context still surfaced. + const persisted = buildRecoveryNotice("engram", "recent project memory", true); + assert.doesNotMatch(persisted, /FIRST ACTION REQUIRED/); + assert.match(persisted, /recent project memory/); + assert.match(persisted, /already saved to Engram/); +});