// pi-mem-cc — automatic observer-based memory for pi coding agent. // // Pipeline (mirrors claude-mem, adapted to pi's ExtensionAPI): // session_start → upsert session row // before_agent_start → query recent observations/summary → append to systemPrompt // tool_result → fire-and-forget compress into , persist // agent_end → fire-and-forget compress turn into , persist import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { upsertSession, countObservations, countSummaries, initStore } from "./store/db.js"; import { compressOne } from "./observer/compress.js"; import { summarizeTurn } from "./observer/summarize.js"; import { getRecentContext } from "./search/index.js"; import { buildInjectionMarkdown } from "./inject/context-builder.js"; import { registerMemTools } from "./tools/mem-tools.js"; // Module-level state for the current session. let currentSessionId: string | null = null; let currentProject: string | null = null; let lastUserPromptAt: number | null = null; export default function activate(pi: ExtensionAPI): void { // 0. Async init: open SQLite + ensure schema. Fire-and-forget but block // first observation writes until ready by awaiting inside handlers. void initStore(); // 1. Register memory tools (mem_search / mem_timeline / mem_get). registerMemTools(pi); // 2. Diagnostic command: /mem-status — show DB stats. pi.registerCommand("mem-status", { description: "Show pi-mem-cc memory statistics (observation count, summary count, db path).", handler: async (_args, ctx) => { const obs = countObservations(); const sum = countSummaries(); const lines = [ `pi-mem-cc status`, ` observations: ${obs}`, ` summaries: ${sum}`, ` current session: ${currentSessionId ?? "(none)"}`, ` current project: ${currentProject ?? "(none)"}`, ]; if (ctx.hasUI) { ctx.ui.notify(lines.join("\n"), "info"); } else { console.log(lines.join("\n")); } }, }); // 3. Session lifecycle. pi.on("session_start", async (event) => { // Best-effort session id resolution — pi exposes it via sessionManager in ctx, // but session_start event itself doesn't carry it. We'll fill in later when // before_agent_start fires (we read it from ctx.sessionManager there). currentSessionId = null; currentProject = null; lastUserPromptAt = null; // Touch the DB to ensure schema is created eagerly. if (event.reason === "startup" || event.reason === "resume" || event.reason === "fork") { // No-op; everything happens on first tool call. } }); // 4. Before agent start: inject recent memory into system prompt + record session. pi.on("before_agent_start", async (event, ctx) => { const sm = ctx.sessionManager; // Resolve current session id (best effort). const sessionId = resolveSessionId(sm); const project = ctx.cwd; if (!sessionId) return; currentSessionId = sessionId; currentProject = project; const now = Date.now(); upsertSession(sessionId, project, now); lastUserPromptAt = now; // Build injection from recent context in this project. const snapshot = getRecentContext(project, 10); const injection = buildInjectionMarkdown(snapshot); if (!injection) return; return { systemPrompt: event.systemPrompt + "\n\n" + injection, }; }); // 5. Tool result → fire-and-forget observation compression. pi.on("tool_result", async (event, ctx) => { if (!currentSessionId || !currentProject) return; // Don't block the agent loop — fire and forget. void runObservationSafely(ctx, { sessionId: currentSessionId, project: currentProject, toolName: event.toolName, toolInput: event.input, toolResult: extractToolResultText(event.content), isError: event.isError, cwd: ctx.cwd, observedAt: Date.now(), }); }); // 6. Agent end → fire-and-forget summary. pi.on("agent_end", async (event, ctx) => { if (!currentSessionId || !currentProject) return; void runSummarySafely(ctx, { sessionId: currentSessionId, project: currentProject, messages: event.messages, }); }); } function extractToolResultText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const part of content) { if (part && typeof part === "object") { const p = part as { type?: string; text?: string }; if (p.type === "text" && typeof p.text === "string") { parts.push(p.text); } } } return parts.join(""); } function resolveSessionId(sm: ExtensionContext["sessionManager"]): string | null { // sessionManager exposes the current session file path; use a stable hash. // We can't import the SessionManager type directly without circularity, // so probe duck-typed. const candidate = (sm as unknown as { getSessionFile?: () => string | null }).getSessionFile?.(); if (typeof candidate === "string" && candidate.length > 0) return candidate; const candidate2 = (sm as unknown as { getCurrentSessionId?: () => string | null }).getCurrentSessionId?.(); if (typeof candidate2 === "string" && candidate2.length > 0) return candidate2; // Fallback: a per-process pseudo id derived from cwd + last seen. if (lastUserPromptAt) return `proc-${lastUserPromptAt}`; return null; } async function runObservationSafely( ctx: ExtensionContext, input: Parameters[1], ): Promise { try { await compressOne(ctx, input); } catch (err) { console.warn("[pi-mem-cc] observation error:", err); } } async function runSummarySafely( ctx: ExtensionContext, input: Parameters[1], ): Promise { try { await summarizeTurn(ctx, input); } catch (err) { console.warn("[pi-mem-cc] summary error:", err); } }