diff --git a/.gitignore b/.gitignore index e793cfd7..dc89cfda 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ comparison-report/ .agent-memory/raw/ .agent-memory/state/ .agent-memory/reports/ +.agent-memory/review/ diff --git a/.pi/extensions/agent-memory.ts b/.pi/extensions/agent-memory.ts index c3b98165..c6898b65 100644 --- a/.pi/extensions/agent-memory.ts +++ b/.pi/extensions/agent-memory.ts @@ -1,5 +1,5 @@ -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, appendFileSync } from "node:fs"; -import { dirname, join, relative } from "node:path"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, appendFileSync } from "node:fs"; +import { basename, dirname, join, relative } from "node:path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; @@ -25,12 +25,17 @@ const MEMORY_ROOT = "docs/agent-memory"; const RAW_ROOT = ".agent-memory/raw"; const REPORT_ROOT = ".agent-memory/reports"; const STATE_ROOT = ".agent-memory/state"; +const REVIEW_ROOT = ".agent-memory/review"; +const PENDING_REVIEW_ROOT = `${REVIEW_ROOT}/pending`; +const APPROVED_REVIEW_ROOT = `${REVIEW_ROOT}/approved`; +const DISCARDED_REVIEW_ROOT = `${REVIEW_ROOT}/discarded`; const MAX_INJECT_CHARS = 9000; const MAX_RAW_TEXT_CHARS = 1600; const IDLE_TIMEOUT_MS = 5 * 60 * 1000; const isoDate = (date = new Date()) => date.toISOString().slice(0, 10); const isoTime = (date = new Date()) => date.toISOString(); +const compactStamp = (date = new Date()) => date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); const ensureDir = (path: string) => mkdirSync(path, { recursive: true }); @@ -92,6 +97,15 @@ const branchConversation = (entries: EntryLike[], maxMessages = 24) => { return messages.slice(-maxMessages); }; +const slugify = (value: string, fallback = "memory") => { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + return slug || fallback; +}; + const writeMetric = (cwd: string, metric: SessionMetric) => { const day = isoDate(); appendJsonl(join(cwd, RAW_ROOT, `${day}.jsonl`), metric); @@ -163,6 +177,108 @@ export default function agentMemoryExtension(pi: ExtensionAPI) { return row; }; + const latestConversation = (ctx: { sessionManager: { getBranch: () => EntryLike[] } }) => branchConversation(ctx.sessionManager.getBranch(), 8); + + const writeReviewCandidate = ( + ctx: { cwd: string; sessionManager: { getBranch: () => EntryLike[]; getSessionFile?: () => string | undefined } }, + source: "automatic" | "manual", + note = "", + ) => { + const conversation = latestConversation(ctx); + const latestUser = [...conversation].reverse().find((message) => message.role === "user")?.text || note || "memory candidate"; + const filename = `${compactStamp()}-${source}-${slugify(latestUser)}.md`; + const candidatePath = join(ctx.cwd, PENDING_REVIEW_ROOT, filename); + const rawPath = join(ctx.cwd, RAW_ROOT, `${isoDate()}.jsonl`); + const metrics = activeWorkSummary(); + const lines = [ + "---", + `status: pending`, + `source: ${source}`, + `created: ${isoTime()}`, + `raw_log: ${relative(ctx.cwd, rawPath)}`, + `session: ${ctx.sessionManager.getSessionFile?.() || ""}`, + "---", + "", + `# Memory Review Candidate: ${source}`, + "", + "## Review Decision", + "", + "- [ ] Approve for memory compilation", + "- [ ] Discard", + "- [ ] Needs manual editing before compile", + "", + "## Why This Was Captured", + "", + note || "Automatic capture after agent completion.", + "", + "## Suggested Durable Lessons", + "", + "- ", + "", + "## Errors And Fixes", + "", + "- Symptom:", + "- Cause:", + "- Fix:", + "- Evidence:", + "", + "## Prompt/Agent Evolution Candidates", + "", + "- Target:", + "- Proposed change:", + "- Evidence:", + "- Risk:", + "", + "## Recent Conversation Excerpt", + "", + ...conversation.map((message) => [`### ${message.role}`, "", message.text, ""].join("\n")), + "## Metrics Snapshot", + "", + `- Active user work time: ${formatMs(metrics.activeWorkMs as number)}`, + `- Pause-inclusive prompt gaps: ${formatMs(metrics.totalPauseInclusiveGapMs as number)}`, + `- Idle-excluded gap time: ${formatMs(metrics.totalIdleExcludedMs as number)}`, + `- Agent duration: ${formatMs(metrics.totalAgentDurationMs as number)}`, + `- Turn duration: ${formatMs(metrics.totalTurnDurationMs as number)}`, + `- Provider response header latency: ${formatMs(metrics.totalProviderHeaderLatencyMs as number)}`, + `- Tools started: ${metrics.toolsStarted}`, + `- Tool errors: ${metrics.toolsErrored}`, + "", + "## Next Commands", + "", + "```text", + `/memory-approve ${filename}`, + `/memory-discard ${filename}`, + `/memory-compile Review approved candidate ${filename}`, + "```", + "", + ]; + + ensureDir(dirname(candidatePath)); + writeFileSync(candidatePath, `${lines.join("\n")}\n`, "utf8"); + writeMetric(ctx.cwd, baseMetric(ctx, "memory_review_candidate", { + source, + candidatePath: relative(ctx.cwd, candidatePath), + note, + })); + return candidatePath; + }; + + const pendingCandidates = (cwd: string) => { + const dir = join(cwd, PENDING_REVIEW_ROOT); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((entry) => entry.endsWith(".md")) + .sort() + .map((entry) => join(dir, entry)); + }; + + const resolvePendingCandidate = (cwd: string, value: string) => { + const query = value.trim(); + const candidates = pendingCandidates(cwd); + if (!query) return candidates[candidates.length - 1]; + return candidates.find((candidate) => basename(candidate) === query || basename(candidate).includes(query)); + }; + const activeWorkMs = () => { if (!activeWorkStartedAt) return activeWorkAccumulatedMs; if (activeWorkPausedAt) return activeWorkAccumulatedMs; @@ -233,6 +349,9 @@ export default function agentMemoryExtension(pi: ExtensionAPI) { ensureDir(join(ctx.cwd, RAW_ROOT)); ensureDir(join(ctx.cwd, REPORT_ROOT)); ensureDir(join(ctx.cwd, STATE_ROOT)); + ensureDir(join(ctx.cwd, PENDING_REVIEW_ROOT)); + ensureDir(join(ctx.cwd, APPROVED_REVIEW_ROOT)); + ensureDir(join(ctx.cwd, DISCARDED_REVIEW_ROOT)); writeMetric(ctx.cwd, baseMetric(ctx, "session_start", { pid: process.pid })); }); @@ -350,6 +469,8 @@ export default function agentMemoryExtension(pi: ExtensionAPI) { })); captureSnapshot(ctx, "automatic agent_end snapshot"); + const candidatePath = writeReviewCandidate(ctx, "automatic", "Automatic capture after agent completion. Review before compiling into durable memory."); + if (ctx.hasUI) ctx.ui.notify(`Memory candidate ready for review: ${relative(ctx.cwd, candidatePath)}`, "info"); }); pi.on("session_before_compact", async (event, ctx) => { @@ -377,6 +498,7 @@ export default function agentMemoryExtension(pi: ExtensionAPI) { const message = [ `Memory index: ${existsSync(indexPath) ? relative(ctx.cwd, indexPath) : "missing"}`, `Raw metrics today: ${existsSync(rawPath) ? relative(ctx.cwd, rawPath) : "none yet"}`, + `Pending memory candidates: ${pendingCandidates(ctx.cwd).length}`, `Prompts observed this session: ${submittedPrompts.length}`, `Provider requests this turn: ${providerRequests}`, `Active user work time: ${formatMs(activeWorkMs())}`, @@ -390,9 +512,13 @@ export default function agentMemoryExtension(pi: ExtensionAPI) { description: "Capture a private raw snapshot for later memory compilation", handler: async (args, ctx) => { const row = captureSnapshot(ctx, args.trim() || "manual capture"); + const candidatePath = writeReviewCandidate(ctx, "manual", args.trim() || "manual capture"); const rawPath = join(ctx.cwd, RAW_ROOT, `${isoDate()}.jsonl`); pi.appendEntry("agent-memory-capture", row); - const message = `Captured private memory snapshot: ${relative(ctx.cwd, rawPath)}`; + const message = [ + `Captured private memory snapshot: ${relative(ctx.cwd, rawPath)}`, + `Review candidate: ${relative(ctx.cwd, candidatePath)}`, + ].join("\n"); showOrPrint(ctx, message, "success"); }, }); @@ -411,6 +537,80 @@ export default function agentMemoryExtension(pi: ExtensionAPI) { }, }); + pi.registerCommand("memory-review", { + description: "List pending memory candidates for review", + handler: async (_args, ctx) => { + const candidates = pendingCandidates(ctx.cwd); + const message = candidates.length + ? [`Pending memory candidates:`, ...candidates.slice(-20).map((candidate) => `- ${relative(ctx.cwd, candidate)}`)].join("\n") + : "No pending memory candidates."; + showOrPrint(ctx, message, "info"); + }, + }); + + pi.registerCommand("memory-show", { + description: "Show a pending memory candidate by filename fragment", + handler: async (args, ctx) => { + const candidate = resolvePendingCandidate(ctx.cwd, args); + if (!candidate) { + showOrPrint(ctx, "No matching pending memory candidate.", "warning"); + return; + } + const text = readIfExists(candidate, 12000); + showOrPrint(ctx, `${relative(ctx.cwd, candidate)}\n\n${text}`, "info"); + }, + }); + + pi.registerCommand("memory-approve", { + description: "Approve a pending memory candidate and launch memory compilation", + handler: async (args, ctx) => { + if (!ctx.isIdle()) { + showOrPrint(ctx, "Agent is busy. Approve after the current turn finishes.", "warning"); + return; + } + + const candidate = resolvePendingCandidate(ctx.cwd, args); + if (!candidate) { + showOrPrint(ctx, "No matching pending memory candidate.", "warning"); + return; + } + + const approvedPath = join(ctx.cwd, APPROVED_REVIEW_ROOT, basename(candidate)); + ensureDir(dirname(approvedPath)); + renameSync(candidate, approvedPath); + writeMetric(ctx.cwd, baseMetric(ctx, "memory_candidate_approved", { candidatePath: relative(ctx.cwd, approvedPath) })); + showOrPrint(ctx, `Approved memory candidate: ${relative(ctx.cwd, approvedPath)}`, "success"); + pi.sendUserMessage(`/pi-evolve Compile approved memory candidate ${relative(ctx.cwd, approvedPath)}. Update reviewed memory and propose prompt changes only if evidence is strong.`); + }, + }); + + pi.registerCommand("memory-discard", { + description: "Discard a pending memory candidate by filename fragment", + handler: async (args, ctx) => { + const candidate = resolvePendingCandidate(ctx.cwd, args); + if (!candidate) { + showOrPrint(ctx, "No matching pending memory candidate.", "warning"); + return; + } + + const discardedPath = join(ctx.cwd, DISCARDED_REVIEW_ROOT, basename(candidate)); + ensureDir(dirname(discardedPath)); + renameSync(candidate, discardedPath); + writeMetric(ctx.cwd, baseMetric(ctx, "memory_candidate_discarded", { candidatePath: relative(ctx.cwd, discardedPath) })); + showOrPrint(ctx, `Discarded memory candidate: ${relative(ctx.cwd, discardedPath)}`, "info"); + }, + }); + + pi.registerCommand("memory-clear", { + description: "Delete all pending memory candidates", + handler: async (_args, ctx) => { + const candidates = pendingCandidates(ctx.cwd); + for (const candidate of candidates) unlinkSync(candidate); + writeMetric(ctx.cwd, baseMetric(ctx, "memory_candidates_cleared", { count: candidates.length })); + showOrPrint(ctx, `Deleted ${candidates.length} pending memory candidates.`, "info"); + }, + }); + pi.registerCommand("prompt-start", { description: "Start explicit active user prompting/work timer", handler: async (args, ctx) => { diff --git a/docs/agent-memory/README.md b/docs/agent-memory/README.md index 5cbbe448..c2b61b97 100644 --- a/docs/agent-memory/README.md +++ b/docs/agent-memory/README.md @@ -61,10 +61,11 @@ It does four things automatically: - injects the reviewed memory index and prompt change log into each agent turn through `before_agent_start` - records private raw metrics and session snapshots under `.agent-memory/raw/` +- creates a reviewable pending memory candidate under `.agent-memory/review/pending/` after each completed agent turn - records provider header latency, agent duration, turn duration, tool counts, prompt submission gaps, and compaction/shutdown events - exposes memory and timing commands -Raw metrics are intentionally gitignored. They are evidence for later compilation, not reviewed memory. +Raw metrics and review candidates are intentionally gitignored. They are evidence for later compilation, not reviewed memory. The extension cannot infer true keystroke-level active typing time from Pi's current public extension events. It records explicit active work blocks plus automatic gap metrics: @@ -85,6 +86,11 @@ Inside Pi: /memory-status /memory-capture [note] /memory-compile [goal] +/memory-review +/memory-show [filename-fragment] +/memory-approve [filename-fragment] +/memory-discard [filename-fragment] +/memory-clear /prompt-start [label] /prompt-pause /prompt-resume @@ -97,6 +103,22 @@ Inside Pi: `/memory-compile` captures a private snapshot and then asks Pi to run `/pi-evolve` against the latest evidence. +After each agent turn, inspect pending candidates: + +```text +/memory-review +/memory-show +``` + +Approve the latest or matching candidate: + +```text +/memory-approve +/memory-approve 20260429T120000Z +``` + +Approval moves the candidate to `.agent-memory/review/approved/` and launches `/pi-evolve` so reviewed memory and prompt changes can be proposed. Discard candidates with `/memory-discard` or clear all pending candidates with `/memory-clear`. + Use `/prompt-start` when you begin an active prompting/planning block and `/prompt-stop` when you finish. Use `/prompt-pause` before leaving for an extended wait and `/prompt-resume` when you return. `/time-report` writes a private report under `.agent-memory/reports/`. ## Guardrails diff --git a/docs/agent-memory/log.md b/docs/agent-memory/log.md index 1e3d2f3e..0546bcd0 100644 --- a/docs/agent-memory/log.md +++ b/docs/agent-memory/log.md @@ -16,3 +16,9 @@ - Added explicit active prompting/work commands: `/prompt-start`, `/prompt-pause`, `/prompt-resume`, `/prompt-stop`, and `/time-report`. - Reports are written under `.agent-memory/reports/` and remain private/gitignored. - This measures explicit work blocks and idle-capped gaps, not hidden keystroke-level activity. + +## [2026-04-29] review | Automatic memory candidates + +- Added automatic pending memory candidates under `.agent-memory/review/pending/` after each completed agent turn. +- Added `/memory-review`, `/memory-show`, `/memory-approve`, `/memory-discard`, and `/memory-clear`. +- Approval moves a candidate to `.agent-memory/review/approved/` and launches `/pi-evolve`. diff --git a/docs/agent-memory/prompt-change-log.md b/docs/agent-memory/prompt-change-log.md index 84dfc21d..fd1eaff7 100644 --- a/docs/agent-memory/prompt-change-log.md +++ b/docs/agent-memory/prompt-change-log.md @@ -19,3 +19,9 @@ - Added `/prompt-start`, `/prompt-pause`, `/prompt-resume`, `/prompt-stop`, and `/time-report`. - Active user prompting/work time is measured by explicit work blocks with pause support. - Automatic metrics still record prompt gaps, idle-excluded gaps, agent duration, turn duration, provider header latency, and tool counts. + +## [2026-04-29] review | Automatic memory review queue + +- Added reviewable pending memory candidates after each agent turn. +- Added `/memory-review`, `/memory-show`, `/memory-approve`, `/memory-discard`, and `/memory-clear`. +- Durable memory and prompt changes still require approval; automatic capture does not write reviewed memory directly.