Something went wrong. Try again.
personal experiment with a claw
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359import { 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<MemoryFile[]> { 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 [ "<index>", indexText.trim() || "(empty)", "</index>", "", "<memory-files>", files, "</memory-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<string, unknown>; 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<string, unknown>; 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<string, unknown>; 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<Record<string, unknown>>(content); return String(frontmatter.type ?? "") === expected; } catch { return false; }}
export async function flushMemoryForTurn( deps: SharedDeps, turnMessages: AgentMessage[]): Promise<void> { 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.", "", "<conversation>", formatTranscript(transcriptMessages), "</conversation>", "", 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");}