|
|
|
@@ -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) => {
|
|
|
|
|