task continuity: TASKS.md ledger, post-compaction recovery, entity dedup, credential scanning

Add task ledger (TASKS.md) parsing and stale-task archival for maintaining
agent task state across context compactions. Post-compaction recovery injects
memory_recall + TASKS.md read steps after auto-compaction. Sleep cycle gains
entity dedup (Phase 1d) and credential scanning. Memory flush now extracts
active task checkpoints. Compaction instructions prioritize active tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tarun Sukhani
2026-02-14 11:12:22 +08:00
parent 4d54736b98
commit a170e25494
20 changed files with 2267 additions and 44 deletions

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_COMPACTION_INSTRUCTIONS } from "./compact.js";
describe("DEFAULT_COMPACTION_INSTRUCTIONS", () => {
it("contains priority ordering with numbered items", () => {
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("1.");
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("2.");
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("3.");
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("4.");
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("5.");
});
it("prioritizes active tasks first", () => {
const taskLine = DEFAULT_COMPACTION_INSTRUCTIONS.indexOf("active or in-progress tasks");
const decisionsLine = DEFAULT_COMPACTION_INSTRUCTIONS.indexOf("Key decisions");
expect(taskLine).toBeLessThan(decisionsLine);
expect(taskLine).toBeGreaterThan(-1);
});
it("mentions TASKS.md for task ledger continuity", () => {
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("TASKS.md");
});
it("includes de-prioritization guidance", () => {
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("De-prioritize");
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("casual conversation");
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("completed tasks");
});
it("mentions exact values needed to resume work", () => {
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("file paths");
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("URLs");
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("IDs");
});
it("includes tool state preservation", () => {
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("Tool state");
expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("browser sessions");
});
});
describe("compaction instructions merging", () => {
it("custom instructions are appended to defaults", () => {
const customInstructions = "Also remember to include user preferences.";
const merged = `${DEFAULT_COMPACTION_INSTRUCTIONS}\n\n${customInstructions}`;
// Defaults come first
expect(merged.indexOf("When summarizing")).toBeLessThan(merged.indexOf(customInstructions));
// Custom instructions are present
expect(merged).toContain(customInstructions);
// Defaults are not lost
expect(merged).toContain("active or in-progress tasks");
});
it("when no custom instructions, defaults are used alone", () => {
// Simulate the compaction path where customInstructions is undefined
const resolve = (custom?: string) =>
custom ? `${DEFAULT_COMPACTION_INSTRUCTIONS}\n\n${custom}` : DEFAULT_COMPACTION_INSTRUCTIONS;
const result = resolve(undefined);
expect(result).toBe(DEFAULT_COMPACTION_INSTRUCTIONS);
expect(result).not.toContain("\n\nundefined");
});
});

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { buildAgentSystemPrompt } from "../system-prompt.js";
describe("Task Ledger section", () => {
it("includes the Task Ledger section in full prompt mode", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
expect(prompt).toContain("## Task Ledger (TASKS.md)");
});
it("describes the task format with required fields", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
expect(prompt).toContain("**Status:**");
expect(prompt).toContain("**Started:**");
expect(prompt).toContain("**Updated:**");
expect(prompt).toContain("**Current Step:**");
});
it("mentions stale task archival by sleep cycle", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
expect(prompt).toContain("sleep cycle");
expect(prompt).toContain(">24h");
});
it("omits the section in minimal (subagent) prompt mode", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
promptMode: "minimal",
});
expect(prompt).not.toContain("## Task Ledger (TASKS.md)");
});
it("omits the section in none prompt mode", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
promptMode: "none",
});
expect(prompt).not.toContain("## Task Ledger (TASKS.md)");
});
});
describe("Post-Compaction Recovery", () => {
it("does NOT include a static recovery section (handled by framework injection)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
});
// Recovery instructions are injected dynamically via post-compaction-recovery.ts,
// not baked into the system prompt (avoids wasting tokens on every turn).
expect(prompt).not.toContain("## Post-Compaction Recovery");
});
});

View File

@@ -625,6 +625,38 @@ export function buildAgentSystemPrompt(params: {
);
}
// Task Ledger instructions (skip for subagent/none modes)
if (!isMinimal) {
lines.push(
"## Task Ledger (TASKS.md)",
"Maintain a TASKS.md file in the workspace root to track active work across compaction events.",
"Update it whenever you start, progress, or complete a task. Format:",
"",
"```markdown",
"# Active Tasks",
"",
"## TASK-001: <short title>",
"- **Status:** in_progress | awaiting_input | blocked | done",
"- **Started:** YYYY-MM-DD HH:MM",
"- **Updated:** YYYY-MM-DD HH:MM",
"- **Details:** What this task is about",
"- **Current Step:** What you're doing right now",
"- **Blocked On:** (if applicable) What's preventing progress",
"",
"# Completed",
"<!-- Move done tasks here with completion date -->",
"```",
"",
"Rules:",
"- Create TASKS.md on first task if it doesn't exist.",
"- Update **Updated** timestamp and **Current Step** as you make progress.",
"- Move tasks to Completed when done; include completion date.",
"- Keep IDs sequential (TASK-001, TASK-002, etc.).",
"- Stale tasks (>24h with no update) may be auto-archived by the sleep cycle.",
"",
);
}
// Skip heartbeats for subagent/none modes
if (!isMinimal) {
lines.push(

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
import {
DEFAULT_TASKS_FILENAME,
filterBootstrapFilesForSession,
loadWorkspaceBootstrapFiles,
} from "./workspace.js";
describe("TASKS.md bootstrap", () => {
it("DEFAULT_TASKS_FILENAME equals TASKS.md", () => {
expect(DEFAULT_TASKS_FILENAME).toBe("TASKS.md");
});
it("loadWorkspaceBootstrapFiles includes TASKS.md entry", async () => {
const tempDir = await makeTempWorkspace("openclaw-tasks-");
const files = await loadWorkspaceBootstrapFiles(tempDir);
const tasksEntry = files.find((f) => f.name === DEFAULT_TASKS_FILENAME);
expect(tasksEntry).toBeDefined();
});
it("loads TASKS.md content when the file exists", async () => {
const tempDir = await makeTempWorkspace("openclaw-tasks-");
await writeWorkspaceFile({ dir: tempDir, name: "TASKS.md", content: "- [ ] finish tests" });
const files = await loadWorkspaceBootstrapFiles(tempDir);
const tasksEntry = files.find((f) => f.name === DEFAULT_TASKS_FILENAME);
expect(tasksEntry).toBeDefined();
expect(tasksEntry!.missing).toBe(false);
expect(tasksEntry!.content).toBe("- [ ] finish tests");
});
it("marks TASKS.md as missing (not error) when the file does not exist", async () => {
const tempDir = await makeTempWorkspace("openclaw-tasks-");
const files = await loadWorkspaceBootstrapFiles(tempDir);
const tasksEntry = files.find((f) => f.name === DEFAULT_TASKS_FILENAME);
expect(tasksEntry).toBeDefined();
expect(tasksEntry!.missing).toBe(true);
expect(tasksEntry!.content).toBeUndefined();
});
it("TASKS.md is in SUBAGENT_BOOTSTRAP_ALLOWLIST (kept for subagent sessions)", () => {
const files = [
{
name: DEFAULT_TASKS_FILENAME as const,
path: "/tmp/TASKS.md",
missing: false,
content: "tasks",
},
{ name: "SOUL.md" as const, path: "/tmp/SOUL.md", missing: false, content: "soul" },
];
const filtered = filterBootstrapFilesForSession(files, "agent:main:subagent:test-123");
const tasksKept = filtered.find((f) => f.name === DEFAULT_TASKS_FILENAME);
expect(tasksKept).toBeDefined();
});
it("filterBootstrapFilesForSession drops non-allowlisted files for subagent sessions", () => {
const files = [
{
name: DEFAULT_TASKS_FILENAME as const,
path: "/tmp/TASKS.md",
missing: false,
content: "tasks",
},
{ name: "SOUL.md" as const, path: "/tmp/SOUL.md", missing: false, content: "soul" },
];
const filtered = filterBootstrapFilesForSession(files, "agent:main:subagent:test-123");
const soulKept = filtered.find((f) => f.name === "SOUL.md");
expect(soulKept).toBeUndefined();
});
});

View File

@@ -28,6 +28,7 @@ export const DEFAULT_USER_FILENAME = "USER.md";
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
export const DEFAULT_MEMORY_FILENAME = "MEMORY.md";
export const DEFAULT_TASKS_FILENAME = "TASKS.md";
export const DEFAULT_MEMORY_ALT_FILENAME = "memory.md";
const WORKSPACE_STATE_DIRNAME = ".openclaw";
const WORKSPACE_STATE_FILENAME = "workspace-state.json";
@@ -87,6 +88,7 @@ export type WorkspaceBootstrapFileName =
| typeof DEFAULT_HEARTBEAT_FILENAME
| typeof DEFAULT_BOOTSTRAP_FILENAME
| typeof DEFAULT_MEMORY_FILENAME
| typeof DEFAULT_TASKS_FILENAME
| typeof DEFAULT_MEMORY_ALT_FILENAME;
export type WorkspaceBootstrapFile = {
@@ -444,6 +446,10 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
name: DEFAULT_BOOTSTRAP_FILENAME,
filePath: path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME),
},
{
name: DEFAULT_TASKS_FILENAME,
filePath: path.join(resolvedDir, DEFAULT_TASKS_FILENAME),
},
];
entries.push(...(await resolveMemoryBootstrapEntries(resolvedDir)));
@@ -465,7 +471,11 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
return result;
}
const MINIMAL_BOOTSTRAP_ALLOWLIST = new Set([DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME]);
const SUBAGENT_BOOTSTRAP_ALLOWLIST = new Set([
DEFAULT_AGENTS_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_TASKS_FILENAME,
]);
export function filterBootstrapFilesForSession(
files: WorkspaceBootstrapFile[],
@@ -474,7 +484,7 @@ export function filterBootstrapFilesForSession(
if (!sessionKey || (!isSubagentSessionKey(sessionKey) && !isCronSessionKey(sessionKey))) {
return files;
}
return files.filter((file) => MINIMAL_BOOTSTRAP_ALLOWLIST.has(file.name));
return files.filter((file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name));
}
export async function loadExtraBootstrapFiles(

View File

@@ -23,6 +23,7 @@ import {
resolveMemoryFlushSettings,
shouldRunMemoryFlush,
} from "./memory-flush.js";
import { markNeedsPostCompactionRecovery } from "./post-compaction-recovery.js";
import { incrementCompactionCount } from "./session-updates.js";
export async function runMemoryFlushIfNeeded(params: {
@@ -179,6 +180,16 @@ export async function runMemoryFlushIfNeeded(params: {
if (typeof nextCount === "number") {
memoryFlushCompactionCount = nextCount;
}
// P3: Mark session for post-compaction recovery on the next turn.
// This path handles flush-triggered compaction (memory flush forces a compact).
// The main path in agent-runner.ts handles SDK auto-compaction.
// These are mutually exclusive; setting true is idempotent.
await markNeedsPostCompactionRecovery({
sessionEntry: activeSessionEntry,
sessionStore: activeSessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
});
}
if (params.storePath && params.sessionKey) {
try {

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_MEMORY_FLUSH_PROMPT,
DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT,
resolveMemoryFlushSettings,
} from "./memory-flush.js";
describe("memory flush task checkpoint", () => {
describe("DEFAULT_MEMORY_FLUSH_PROMPT", () => {
it("includes task state extraction language", () => {
expect(DEFAULT_MEMORY_FLUSH_PROMPT).toContain("active task");
expect(DEFAULT_MEMORY_FLUSH_PROMPT).toContain("task name");
expect(DEFAULT_MEMORY_FLUSH_PROMPT).toContain("current step");
expect(DEFAULT_MEMORY_FLUSH_PROMPT).toContain("pending actions");
});
it("instructs to use memory_store with core category and importance 1.0", () => {
expect(DEFAULT_MEMORY_FLUSH_PROMPT).toContain("memory_store");
expect(DEFAULT_MEMORY_FLUSH_PROMPT).toContain("category 'core'");
expect(DEFAULT_MEMORY_FLUSH_PROMPT).toContain("importance 1.0");
});
});
describe("DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT", () => {
it("includes CRITICAL instruction about active tasks", () => {
expect(DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT).toContain("CRITICAL");
expect(DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT).toContain("active task");
});
it("instructs to save task state with core category", () => {
expect(DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT).toContain("memory_store");
expect(DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT).toContain("category='core'");
expect(DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT).toContain("importance=1.0");
});
it("mentions task continuity after compaction", () => {
expect(DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT).toContain("task continuity after compaction");
});
});
describe("resolveMemoryFlushSettings", () => {
it("returns prompts containing task-related keywords by default", () => {
const settings = resolveMemoryFlushSettings();
expect(settings).not.toBeNull();
expect(settings?.prompt).toContain("active task");
expect(settings?.prompt).toContain("memory_store");
expect(settings?.systemPrompt).toContain("CRITICAL");
expect(settings?.systemPrompt).toContain("task continuity");
});
it("preserves task checkpoint language alongside existing content", () => {
const settings = resolveMemoryFlushSettings();
expect(settings).not.toBeNull();
// Original content still present
expect(settings?.prompt).toContain("Pre-compaction memory flush");
expect(settings?.prompt).toContain("durable memories");
// New task checkpoint content also present
expect(settings?.prompt).toContain("current step");
expect(settings?.prompt).toContain("pending actions");
});
});
});

View File

@@ -36,6 +36,7 @@ import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-utils.j
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
import { createFollowupRunner } from "./followup-runner.js";
import { markNeedsPostCompactionRecovery } from "./post-compaction-recovery.js";
import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js";
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js";
@@ -499,6 +500,16 @@ export async function runReplyAgent(params: {
lastCallUsage: runResult.meta.agentMeta?.lastCallUsage,
contextTokensUsed,
});
// P3: Mark session for post-compaction recovery on the next turn.
// This path handles SDK auto-compaction (during the agent run itself).
// The memory-flush path in agent-runner-memory.ts handles flush-triggered compaction.
// These are mutually exclusive for a given compaction event; setting true is idempotent.
await markNeedsPostCompactionRecovery({
sessionEntry: activeSessionEntry,
sessionStore: activeSessionStore,
sessionKey,
storePath,
});
if (verboseEnabled) {
const suffix = typeof count === "number" ? ` (count ${count})` : "";
finalPayloads = [{ text: `🧹 Auto-compaction complete${suffix}.` }, ...finalPayloads];

View File

@@ -41,6 +41,10 @@ import { runReplyAgent } from "./agent-runner.js";
import { applySessionHints } from "./body.js";
import { buildGroupChatContext, buildGroupIntro } from "./groups.js";
import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js";
import {
clearPostCompactionRecovery,
prependPostCompactionRecovery,
} from "./post-compaction-recovery.js";
import { resolveQueueSettings } from "./queue.js";
import { routeReply } from "./route-reply.js";
import { BARE_SESSION_RESET_PROMPT } from "./session-reset-prompt.js";
@@ -256,6 +260,18 @@ export async function runPreparedReply(
isNewSession,
prefixedBodyBase,
});
// P3: Prepend post-compaction recovery instructions if the previous turn
// triggered auto-compaction. This ensures the agent recalls task state from
// memory before responding to the user's next message.
prefixedBodyBase = prependPostCompactionRecovery(prefixedBodyBase, sessionEntry);
if (sessionEntry?.needsPostCompactionRecovery) {
await clearPostCompactionRecovery({
sessionEntry,
sessionStore,
sessionKey,
storePath,
});
}
prefixedBodyBase = appendUntrustedContext(prefixedBodyBase, sessionCtx.UntrustedContext);
const threadStarterBody = ctx.ThreadStarterBody?.trim();
const threadHistoryBody = ctx.ThreadHistoryBody?.trim();

View File

@@ -12,12 +12,14 @@ export const DEFAULT_MEMORY_FLUSH_PROMPT = [
"Pre-compaction memory flush.",
"Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed).",
"IMPORTANT: If the file already exists, APPEND new content only and do not overwrite existing entries.",
"If there is an active task in progress, save its state: task name, current step, pending actions, and any critical variables. Use memory_store with category 'core' and importance 1.0 for active task state.",
`If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`,
].join(" ");
export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [
"Pre-compaction memory flush turn.",
"The session is near auto-compaction; capture durable memories to disk.",
"CRITICAL: If there is an active task being worked on, you MUST save its current state (task name, step, pending actions, key variables) to memory_store with category='core' and importance=1.0. This ensures task continuity after compaction.",
`You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
].join(" ");

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
getPostCompactionRecoveryPrompt,
POST_COMPACTION_RECOVERY_PROMPT,
prependPostCompactionRecovery,
} from "./post-compaction-recovery.js";
describe("post-compaction-recovery", () => {
describe("POST_COMPACTION_RECOVERY_PROMPT", () => {
it("is defined and non-empty", () => {
expect(POST_COMPACTION_RECOVERY_PROMPT).toBeTruthy();
expect(POST_COMPACTION_RECOVERY_PROMPT.length).toBeGreaterThan(0);
});
it("stays under 200 tokens (rough estimate: <800 chars)", () => {
// A rough heuristic: 1 token ≈ 4 chars. 200 tokens ≈ 800 chars.
expect(POST_COMPACTION_RECOVERY_PROMPT.length).toBeLessThan(800);
});
it("includes memory_recall instruction", () => {
expect(POST_COMPACTION_RECOVERY_PROMPT).toContain("memory_recall");
});
it("includes TASKS.md instruction", () => {
expect(POST_COMPACTION_RECOVERY_PROMPT).toContain("TASKS.md");
});
it("includes Context Reset notification template", () => {
expect(POST_COMPACTION_RECOVERY_PROMPT).toContain("Context Reset");
});
});
describe("getPostCompactionRecoveryPrompt", () => {
it("returns null when entry is undefined", () => {
expect(getPostCompactionRecoveryPrompt(undefined)).toBeNull();
});
it("returns null when needsPostCompactionRecovery is false", () => {
const entry = {
sessionId: "test",
updatedAt: Date.now(),
needsPostCompactionRecovery: false,
};
expect(getPostCompactionRecoveryPrompt(entry)).toBeNull();
});
it("returns null when needsPostCompactionRecovery is not set", () => {
const entry = { sessionId: "test", updatedAt: Date.now() };
expect(getPostCompactionRecoveryPrompt(entry)).toBeNull();
});
it("returns the recovery prompt when needsPostCompactionRecovery is true", () => {
const entry = {
sessionId: "test",
updatedAt: Date.now(),
needsPostCompactionRecovery: true,
};
expect(getPostCompactionRecoveryPrompt(entry)).toBe(POST_COMPACTION_RECOVERY_PROMPT);
});
});
describe("prependPostCompactionRecovery", () => {
it("returns original body when no recovery needed", () => {
const body = "Hello, how are you?";
expect(prependPostCompactionRecovery(body, undefined)).toBe(body);
});
it("returns original body when flag is false", () => {
const body = "Hello, how are you?";
const entry = {
sessionId: "test",
updatedAt: Date.now(),
needsPostCompactionRecovery: false,
};
expect(prependPostCompactionRecovery(body, entry)).toBe(body);
});
it("prepends recovery prompt when flag is true", () => {
const body = "Hello, how are you?";
const entry = {
sessionId: "test",
updatedAt: Date.now(),
needsPostCompactionRecovery: true,
};
const result = prependPostCompactionRecovery(body, entry);
expect(result).toContain(POST_COMPACTION_RECOVERY_PROMPT);
expect(result).toContain(body);
expect(result.indexOf(POST_COMPACTION_RECOVERY_PROMPT)).toBeLessThan(result.indexOf(body));
});
it("separates recovery prompt from body with double newline", () => {
const body = "test message";
const entry = {
sessionId: "test",
updatedAt: Date.now(),
needsPostCompactionRecovery: true,
};
const result = prependPostCompactionRecovery(body, entry);
expect(result).toBe(`${POST_COMPACTION_RECOVERY_PROMPT}\n\n${body}`);
});
});
});

View File

@@ -0,0 +1,103 @@
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionStore } from "../../config/sessions.js";
/**
* Post-compaction recovery prompt injected into the next user message after
* auto-compaction completes. Instructs the agent to recall task state from
* memory and notify the user about the context reset.
*
* Kept under 200 tokens to minimize context overhead.
*/
export const POST_COMPACTION_RECOVERY_PROMPT = [
"[Post-compaction recovery — mandatory steps]",
"Context was just compacted. Before responding, you MUST:",
'1. Run memory_recall("active task") to check for saved task state.',
"2. Read TASKS.md if it exists in your workspace.",
"3. Compare recovered state against the compaction summary above.",
'4. Notify the user: "🔄 Context Reset — last task: [X], resuming from step [Y]" (or summarize what you recall).',
"Do NOT skip these steps. Proceed with the user's message after recovery.",
].join("\n");
/**
* Check whether the session needs post-compaction recovery and return the
* recovery prompt if so. Returns `null` when no recovery is needed.
*/
export function getPostCompactionRecoveryPrompt(entry?: SessionEntry): string | null {
if (!entry?.needsPostCompactionRecovery) {
return null;
}
return POST_COMPACTION_RECOVERY_PROMPT;
}
/**
* Prepend the post-compaction recovery prompt to the user's message body.
* Returns the original body unchanged if no recovery is needed.
*/
export function prependPostCompactionRecovery(body: string, entry?: SessionEntry): string {
const prompt = getPostCompactionRecoveryPrompt(entry);
if (!prompt) {
return body;
}
return `${prompt}\n\n${body}`;
}
/**
* Set or clear the post-compaction recovery flag on a session.
*/
async function setPostCompactionRecovery(
value: boolean,
params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
},
): Promise<void> {
const { sessionEntry, sessionStore, sessionKey, storePath } = params;
if (!sessionStore || !sessionKey) {
return;
}
const entry = sessionStore[sessionKey] ?? sessionEntry;
if (!entry) {
return;
}
sessionStore[sessionKey] = {
...entry,
needsPostCompactionRecovery: value,
};
if (storePath) {
await updateSessionStore(storePath, (store) => {
if (store[sessionKey]) {
store[sessionKey] = {
...store[sessionKey],
needsPostCompactionRecovery: value,
};
}
});
}
}
/**
* Mark a session as needing post-compaction recovery on the next turn.
*/
export async function markNeedsPostCompactionRecovery(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
}): Promise<void> {
return setPostCompactionRecovery(true, params);
}
/**
* Clear the post-compaction recovery flag after recovery instructions have
* been injected into the prompt.
*/
export async function clearPostCompactionRecovery(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
}): Promise<void> {
return setPostCompactionRecovery(false, params);
}

View File

@@ -82,6 +82,8 @@ export type SessionEntry = {
model?: string;
contextTokens?: number;
compactionCount?: number;
/** Set after auto-compaction; cleared after the next turn injects recovery instructions. */
needsPostCompactionRecovery?: boolean;
memoryFlushAt?: number;
memoryFlushCompactionCount?: number;
cliSessionIds?: Record<string, string>;