import { completeSimple } from "@earendil-works/pi-ai"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { parseFrontmatter, } from "@earendil-works/pi-coding-agent"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { SharedDeps } from "./agent.js"; const MEMORY_FLUSH_SYSTEM_PROMPT = `You decide whether a completed conversation turn contains durable information worth saving to persistent memory. Be conservative. Most turns should produce no memory updates. Only save: - user preferences, working style, or stable personal context - feedback about what to do or avoid in future interactions - durable project facts, decisions, or constraints - durable references to external systems the assistant will need later Do not save: - ephemeral task state - summaries of the just-finished conversation - code facts or file paths that can be rediscovered from the repository - speculative, weak, or ambiguous inferences When there is nothing clearly worth saving, return shouldWrite=false. Return strict JSON with this shape: { "shouldWrite": boolean, "memories": [ { "action": "create" | "update", "slug": string, "name": string, "description": string, "type": "user" | "feedback" | "project" | "reference", "body": string, "indexHook": string, "why": string } ] } Rules: - slug must be lowercase kebab-case and must not include .md - description must be one specific line - indexHook must be a short one-line hook suitable for INDEX.md - for feedback and project memories, body must include **Why:** and **How to apply:** - if updating an existing memory, rewrite the full file content, not a patch - output JSON only`; const HEADER = `# Memory index One-line pointers to every memory file. Always loaded into context — keep it tight. Format: \`- [Title](file.md) — one-line hook\``; interface MemoryFile { slug: string; path: string; content: string; } interface MemoryFlushResult { shouldWrite: boolean; memories: ProposedMemory[]; } interface ProposedMemory { action: "create" | "update"; slug: string; name: string; description: string; type: "user" | "feedback" | "project" | "reference"; body: string; indexHook: string; why: string; } function isRootMemoryFile(name: string): boolean { return name.endsWith(".md") && name !== "INDEX.md"; } function isDurableType(value: string): value is ProposedMemory["type"] { return value === "user" || value === "feedback" || value === "project" || value === "reference"; } function isValidSlug(slug: string): boolean { return /^[a-z0-9][a-z0-9-]{0,62}$/.test(slug); } function formatMemoryFile(memory: ProposedMemory): string { return `--- name: ${memory.name} description: ${memory.description} type: ${memory.type} --- ${memory.body.trim()} `; } async function readRootMemories(memoryDir: string): Promise { await mkdir(memoryDir, { recursive: true }); const names = await readdir(memoryDir); const files = names.filter(isRootMemoryFile).sort(); return Promise.all( files.map(async (name) => { const path = join(memoryDir, name); return { slug: name.slice(0, -3), path, content: await readFile(path, "utf8"), }; }) ); } async function readMemoryContext(memoryDir: string): Promise<{ indexText: string; memories: MemoryFile[]; }> { const indexPath = join(memoryDir, "INDEX.md"); let indexText = ""; try { indexText = await readFile(indexPath, "utf8"); } catch (err: any) { if (err?.code !== "ENOENT") throw err; } return { indexText, memories: await readRootMemories(memoryDir), }; } function formatMemoryContext(indexText: string, memories: MemoryFile[]): string { const files = memories.length === 0 ? "(none)" : memories .map((memory) => `## ${memory.slug}.md\n\n${memory.content.trim()}`) .join("\n\n"); return [ "", indexText.trim() || "(empty)", "", "", "", files, "", ].join("\n"); } function contentToText(content: unknown): string { if (typeof content === "string") return content.trim(); if (!Array.isArray(content)) return ""; return content .map((part) => { if (!part || typeof part !== "object") return ""; const value = part as Record; if (value.type === "text") return String(value.text ?? ""); if (value.type === "thinking") return ""; if (value.type === "image") return "[image]"; if (value.type === "toolCall") { return `[tool call: ${String(value.name ?? "unknown")}]`; } return ""; }) .filter(Boolean) .join("\n") .trim(); } function formatTranscript(turnMessages: AgentMessage[]): string { const lines = turnMessages .map((message) => { switch (message.role) { case "user": return `User: ${contentToText(message.content)}`; case "assistant": return `Assistant: ${contentToText(message.content)}`; case "toolResult": return `Tool ${message.toolName}: ${contentToText(message.content)}`; case "custom": return `Custom(${message.customType}): ${contentToText(message.content)}`; case "bashExecution": return `Bash(${message.command}): ${message.output}`; default: return ""; } }) .filter(Boolean) .join("\n\n") .trim(); return lines || "(empty)"; } function extractJsonObject(raw: string): string { const start = raw.indexOf("{"); const end = raw.lastIndexOf("}"); if (start === -1 || end === -1 || end < start) { throw new Error("memory flush response did not contain JSON"); } return raw.slice(start, end + 1); } function parseFlushResult(raw: string): MemoryFlushResult { const parsed = JSON.parse(extractJsonObject(raw)) as Record; const shouldWrite = parsed.shouldWrite === true; const items = Array.isArray(parsed.memories) ? parsed.memories : []; const memories: ProposedMemory[] = []; for (const item of items) { if (!item || typeof item !== "object") continue; const candidate = item as Record; const action = candidate.action === "update" ? "update" : candidate.action === "create" ? "create" : null; const slug = String(candidate.slug ?? "").trim(); const name = String(candidate.name ?? "").trim(); const description = String(candidate.description ?? "").trim(); const type = String(candidate.type ?? "").trim(); const body = String(candidate.body ?? "").trim(); const indexHook = String(candidate.indexHook ?? "").trim(); const why = String(candidate.why ?? "").trim(); if (!action || !isValidSlug(slug) || !name || !description || !isDurableType(type) || !body || !indexHook) { continue; } if ((type === "feedback" || type === "project") && (!body.includes("**Why:**") || !body.includes("**How to apply:**"))) { continue; } memories.push({ action, slug, name, description, type, body, indexHook, why }); } return { shouldWrite: shouldWrite && memories.length > 0, memories, }; } function ensureIndexEntry(indexText: string, memory: ProposedMemory): string { const lines = (indexText.trim() ? indexText.trim().split("\n") : HEADER.split("\n")).filter(Boolean); const entry = `- [${memory.name}](${memory.slug}.md) — ${memory.indexHook}`; const next: string[] = []; let replaced = false; for (const line of lines) { if (line.startsWith(`- [`) && line.includes(`](${memory.slug}.md)`)) { next.push(entry); replaced = true; } else { next.push(line); } } if (!replaced) { next.push(entry); } return next.join("\n") + "\n"; } function validateExistingType(content: string, expected: ProposedMemory["type"]): boolean { try { const { frontmatter } = parseFrontmatter>(content); return String(frontmatter.type ?? "") === expected; } catch { return false; } } export async function flushMemoryForTurn( deps: SharedDeps, turnMessages: AgentMessage[] ): Promise { const transcriptMessages = turnMessages.filter( (message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult" || message.role === "custom" || message.role === "bashExecution" ); if (transcriptMessages.length === 0) return; const memoryDir = join(deps.cwd, "memory"); const { indexText, memories } = await readMemoryContext(memoryDir); const auth = await deps.modelRegistry.getApiKeyAndHeaders(deps.model); if (!auth.ok) { throw new Error(`memory flush auth failed: ${auth.error}`); } const prompt = [ "Review this completed conversation turn and decide whether any durable memory should be written.", "", "", formatTranscript(transcriptMessages), "", "", formatMemoryContext(indexText, memories), ].join("\n"); const response = await completeSimple( deps.model, { systemPrompt: MEMORY_FLUSH_SYSTEM_PROMPT, messages: [ { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now(), }, ], }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 1800, } ); if (response.stopReason === "aborted") return; if (response.stopReason === "error") { throw new Error(response.errorMessage || "memory flush model call failed"); } const raw = response.content .filter((part): part is { type: "text"; text: string } => part.type === "text") .map((part) => part.text) .join("\n") .trim(); if (!raw) return; const decision = parseFlushResult(raw); if (!decision.shouldWrite) return; let nextIndex = indexText; const existingBySlug = new Map(memories.map((memory) => [memory.slug, memory])); for (const memory of decision.memories) { const existing = existingBySlug.get(memory.slug); if (memory.action === "update" && !existing) continue; if (existing && !validateExistingType(existing.content, memory.type)) continue; const path = join(memoryDir, `${memory.slug}.md`); await writeFile(path, formatMemoryFile(memory), "utf8"); nextIndex = ensureIndexEntry(nextIndex, memory); existingBySlug.set(memory.slug, { slug: memory.slug, path, content: formatMemoryFile(memory), }); } await writeFile(join(memoryDir, "INDEX.md"), nextIndex || `${HEADER}\n`, "utf8"); }