chore: add memory review queue

This commit is contained in:
2026-04-29 21:55:50 +03:00
parent 4fa9561a8d
commit c3397cd47c
5 changed files with 239 additions and 4 deletions
+1
View File
@@ -83,3 +83,4 @@ comparison-report/
.agent-memory/raw/
.agent-memory/state/
.agent-memory/reports/
.agent-memory/review/
+203 -3
View File
@@ -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) => {
+23 -1
View File
@@ -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
+6
View File
@@ -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`.
+6
View File
@@ -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.