/** * Memex Extension for Pi * * Persistent Zettelkasten memory for AI coding agents. * Wraps the `memex` CLI as Pi custom tools so the LLM can recall prior knowledge * at task start and save insights at task end. * * Prerequisites: npm install -g @touchskyer/memex * * Tools registered: * memex_recall – retrieve memory index or search (call at task start) * memex_retro – save an atomic insight card (call at task end) * memex_search – full-text search cards * memex_read – read a specific card by slug * memex_write – write/update a card * memex_links – show link graph stats * memex_archive – archive a card * memex_organize – analyze card network health * * Session lifecycle: * before_agent_start – injects recall reminder on first turn * agent_end – injects retro reminder if not yet done * session_compact – resets recall state so reminder re-injects post-compaction * resources_discover – exposes bundled skills (memex-recall, memex-retro, etc.) * * Commands: * /memex – show memex status and card count * /memex-serve – open visual timeline UI * /memex-sync – sync cards via git */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const baseDir = dirname(fileURLToPath(import.meta.url)); const skillsDir = join(baseDir, "..", "skills"); /** Run a memex CLI command and return stdout. Uses spawn to support stdin. */ function memex( args: string[], stdin?: string, ): Promise<{ stdout: string; stderr: string; ok: boolean }> { return new Promise((resolve) => { const child = spawn("memex", args, { timeout: 30_000 }); let stdout = ""; let stderr = ""; child.stdout.on("data", (data: Buffer) => { stdout += data.toString(); }); child.stderr.on("data", (data: Buffer) => { stderr += data.toString(); }); child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { resolve({ stdout: "", stderr: "memex CLI not found. Install it with: npm install -g @touchskyer/memex", ok: false, }); } else { resolve({ stdout: stdout.trim(), stderr: err.message, ok: false }); } }); child.on("close", (code: number | null) => { resolve({ stdout: stdout.trim(), stderr: stderr.trim(), ok: code === 0 }); }); if (stdin !== undefined) { child.stdin.write(stdin); child.stdin.end(); } }); } function textResult(text: string, isError = false) { return { content: [{ type: "text" as const, text }], details: {}, ...(isError ? { isError: true } : {}), }; } function appendStderrWarning(text: string, stderr: string): string { return stderr ? `${text}\n\n${stderr}` : text; } // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- export default function memexExtension(pi: ExtensionAPI) { // Track whether recall/retro have been done this session let recallDone = false; let retroDone = false; // ----------------------------------------------------------------------- // Resource discovery — expose bundled skills to Pi // ----------------------------------------------------------------------- pi.on("resources_discover", async () => { if (!existsSync(skillsDir)) return; return { skillPaths: [skillsDir], }; }); // ----------------------------------------------------------------------- // Session lifecycle // ----------------------------------------------------------------------- pi.on("session_start", async (_event, _ctx) => { recallDone = false; retroDone = false; }); // After compaction the recall reminder message is summarized away. // Reset recallDone so before_agent_start re-injects the reminder on // the next user prompt. pi.on("session_compact", async (_event, _ctx) => { recallDone = false; }); // Inject recall reminder at the start of each agent turn (until recall is done) pi.on("before_agent_start", async (_event, _ctx) => { if (recallDone) return; return { message: { customType: "memex-recall-reminder", content: [ "## Memex Memory System Active", "", "You have a Zettelkasten memory system available via the `memex_recall` tool.", "**BEFORE starting work**, call `memex_recall` (with no query) to load your keyword index.", "Then read relevant cards with `memex_read` and search with `memex_search`.", "Never include actual secrets, credentials, tokens, or exact secret file contents in memex query/body arguments; use abstract descriptions or redacted examples.", "", "**AFTER completing the task**, if you learned something non-obvious,", "call `memex_retro` to save atomic insight cards for future sessions.", "", "Memory recall guardrails: max 3 link hops, max 20 cards read.", ].join("\n"), display: false, }, }; }); // After each agent response, remind the LLM to call memex_retro if it // hasn't done so yet. Uses "nextTurn" delivery so it doesn't interrupt // the current response — the reminder appears on the next user prompt. pi.on("agent_end", async (_event, _ctx) => { if (retroDone || !recallDone) return; pi.sendMessage( { customType: "memex-retro-reminder", content: [ "**Memex reminder:** If you learned something non-obvious in this task,", "call `memex_retro` to save an atomic insight card before finishing.", ].join(" "), display: false, }, { deliverAs: "nextTurn", }, ); }); // ----------------------------------------------------------------------- // Tool: memex_recall // ----------------------------------------------------------------------- pi.registerTool({ name: "memex_recall", label: "Memex Recall", description: "IMPORTANT: Call at the START of every task. Retrieves your persistent Zettelkasten memory — knowledge cards from previous sessions. Returns the keyword index (if exists) or card list. USAGE: Call with NO query first to get the index. Only use query when you need to find specific cards — pass 1-3 short keywords, NOT sentences or task summaries. Keyword search uses AND logic (every token must appear in the same card). For natural-language search, use memex_search with semantic=true instead. Never include actual secrets, credentials, tokens, or exact secret file contents in query.", parameters: Type.Object({ query: Type.Optional( Type.String({ description: "1-3 short keywords (AND logic — every token must appear). Do NOT pass sentences or task summaries. Omit for task-start recall. Examples: 'pptx migration', 'auth gotcha'. Do not include raw secrets.", }), ), }), async execute(_toolCallId, params) { recallDone = true; const { query } = params as { query?: string }; if (query) { const res = await memex(["search", query, "--limit", "10"]); if (!res.ok) return textResult(res.stderr, true); return textResult(appendStderrWarning(res.stdout || "No cards found.", res.stderr)); } // Try index first, then fall back to listing all cards const indexRes = await memex(["read", "index"]); if (indexRes.ok && indexRes.stdout) { return textResult(indexRes.stdout); } const listRes = await memex(["search"]); if (!listRes.ok) return textResult(listRes.stderr, true); return textResult( listRes.stdout || "No cards yet. This is a fresh memory.", ); }, }); // ----------------------------------------------------------------------- // Tool: memex_retro // ----------------------------------------------------------------------- pi.registerTool({ name: "memex_retro", label: "Memex Retro", description: "IMPORTANT: Call at the END of every task to save what you learned. Write one atomic insight per card with [[wikilinks]] to related cards. Only save non-obvious learnings. Never save actual secrets, credentials, tokens, or exact secret file contents. Handles frontmatter automatically.", parameters: Type.Object({ slug: Type.String({ description: "Card slug in kebab-case (e.g. 'jwt-revocation-pattern')", }), title: Type.String({ description: "Card title (≤60 chars, noun phrase not sentence)", }), body: Type.String({ description: "Card body in markdown with [[wikilinks]] explaining relationships", }), category: Type.Optional( Type.String({ description: "Category (e.g. frontend, architecture, devops, bugfix)", }), ), }), async execute(_toolCallId, params) { const { slug, title, body, category } = params as { slug: string; title: string; body: string; category?: string; }; const today = new Date().toISOString().split("T")[0]; const frontmatter: Record = { title, created: today, source: "pi", }; if (category) frontmatter.category = category; const yaml = Object.entries(frontmatter) .map(([k, v]) => `${k}: ${v}`) .join("\n"); const content = `---\n${yaml}\n---\n\n${body}`; const res = await memex(["write", slug], content); if (!res.ok) return textResult(res.stderr, true); retroDone = true; return textResult(appendStderrWarning(`Card '${slug}' saved successfully.`, res.stderr)); }, }); // ----------------------------------------------------------------------- // Tool: memex_search // ----------------------------------------------------------------------- pi.registerTool({ name: "memex_search", label: "Memex Search", description: "Full-text search memory cards. Omit query to list all cards. Never include actual secrets, credentials, tokens, or exact secret file contents in query.", parameters: Type.Object({ query: Type.Optional(Type.String({ description: "Search keyword" })), limit: Type.Optional( Type.Number({ description: "Max results (default 10)" }), ), semantic: Type.Optional( Type.Boolean({ description: "Use embedding-based semantic search" }), ), }), async execute(_toolCallId, params) { const { query, limit, semantic } = params as { query?: string; limit?: number; semantic?: boolean }; const args = ["search"]; if (query) args.push(query); if (limit) args.push("--limit", String(limit)); if (semantic) args.push("--semantic"); const res = await memex(args); if (!res.ok) return textResult(res.stderr, true); return textResult(appendStderrWarning(res.stdout || "No cards found.", res.stderr)); }, }); // ----------------------------------------------------------------------- // Tool: memex_read // ----------------------------------------------------------------------- pi.registerTool({ name: "memex_read", label: "Memex Read", description: "Read a specific memory card by its slug.", parameters: Type.Object({ slug: Type.String({ description: "Card slug (e.g. 'my-card-name')" }), }), async execute(_toolCallId, params) { const { slug } = params as { slug: string }; const res = await memex(["read", slug]); if (!res.ok) return textResult(res.stderr, true); return textResult(res.stdout); }, }); // ----------------------------------------------------------------------- // Tool: memex_write // ----------------------------------------------------------------------- pi.registerTool({ name: "memex_write", label: "Memex Write", description: "Write or update a memory card. Content should include YAML frontmatter + markdown body. Never write actual secrets, credentials, tokens, or exact secret file contents.", parameters: Type.Object({ slug: Type.String({ description: "Card slug in kebab-case", }), content: Type.String({ description: "Full card content: YAML frontmatter (---) + markdown body with [[wikilinks]]", }), }), async execute(_toolCallId, params) { const { slug, content } = params as { slug: string; content: string }; const res = await memex(["write", slug], content); if (!res.ok) return textResult(res.stderr, true); return textResult(appendStderrWarning(`Card '${slug}' written successfully.`, res.stderr)); }, }); // ----------------------------------------------------------------------- // Tool: memex_links // ----------------------------------------------------------------------- pi.registerTool({ name: "memex_links", label: "Memex Links", description: "Show link graph stats. Omit slug for global stats, or specify a slug for that card's links.", parameters: Type.Object({ slug: Type.Optional( Type.String({ description: "Card slug (omit for global stats)", }), ), }), async execute(_toolCallId, params) { const { slug } = params as { slug?: string }; const args = ["links"]; if (slug) args.push(slug); const res = await memex(args); if (!res.ok) return textResult(res.stderr, true); return textResult(res.stdout || "No link data."); }, }); // ----------------------------------------------------------------------- // Tool: memex_archive // ----------------------------------------------------------------------- pi.registerTool({ name: "memex_archive", label: "Memex Archive", description: "Archive a card (move to archive). Use for outdated or superseded cards.", parameters: Type.Object({ slug: Type.String({ description: "Card slug to archive" }), }), async execute(_toolCallId, params) { const { slug } = params as { slug: string }; const res = await memex(["archive", slug]); if (!res.ok) return textResult(res.stderr, true); return textResult(`Card '${slug}' archived.`); }, }); // ----------------------------------------------------------------------- // Tool: memex_organize // ----------------------------------------------------------------------- pi.registerTool({ name: "memex_organize", label: "Memex Organize", description: "Analyze the card network health: orphans, hubs, unresolved links, and contradictions. Call periodically.", parameters: Type.Object({ since: Type.Optional( Type.String({ description: "Only check cards modified since this date (YYYY-MM-DD). Omit for full scan.", }), ), }), async execute(_toolCallId, params) { const { since } = params as { since?: string }; const args = ["organize"]; if (since) args.push("--since", since); const res = await memex(args); if (!res.ok) return textResult(res.stderr, true); return textResult(res.stdout || "No cards found."); }, }); // ----------------------------------------------------------------------- // Commands // ----------------------------------------------------------------------- pi.registerCommand("memex", { description: "Show memex status and card count", handler: async (_args, ctx) => { const res = await memex(["search"]); if (!res.ok) { ctx.ui.notify( "Memex CLI not found. Run: npm install -g @touchskyer/memex", "error", ); return; } const lines = res.stdout.split("\n").filter((l) => l.trim()); ctx.ui.notify(`Memex: ${lines.length} cards found`, "info"); }, }); pi.registerCommand("memex-serve", { description: "Open memex visual timeline UI", handler: async (_args, ctx) => { const res = await memex(["serve"]); if (!res.ok) { ctx.ui.notify(res.stderr || "Failed to start memex serve", "error"); return; } ctx.ui.notify("Memex timeline opened at localhost:3939", "info"); }, }); pi.registerCommand("memex-sync", { description: "Sync memex cards via git", handler: async (_args, ctx) => { const res = await memex(["sync"]); if (!res.ok) { ctx.ui.notify(res.stderr || "Sync failed", "error"); return; } ctx.ui.notify(res.stdout || "Memex cards synced", "success"); }, }); }