/** * pi-loom: Auto-capture — extract implicit memories from tool results * * Mirrors context-mode's extractEvent pattern (src/session/extract.ts): * watches pi.on("tool_result") and produces memory entries for * significant events without requiring explicit loom_store() calls. * * Design constraints: * - NO LLM calls (fast, cheap, never blocks the tool response) * - Pure pattern matching on tool name + input shape * - Rate-limited & deduped by the caller in index.ts * - File edits at very low importance (0.3) — one edit is noise, * but accumulated edits on the same directory create signal * * Phase 3 (ESR integration): auto-detect entity co-occurrence edges. * When two ESR entities appear within the same tool event, a weak * REFERENCE edge is suggested for graph traversal. */ import { dirname } from "node:path"; /** Normalized tool result event from Pi's tool_result hook. */ export interface ToolResultEvent { toolName: string; input: Record; output?: string; isError: boolean; } /** A memory entry to auto-store. */ export interface CapturedMemory { content: string; entity_id?: string; importance: number; tags: string[]; } /** Auto-detected entity edge from co-occurrence. */ export interface AutoEdge { source_entity: string; target_entity: string; relation_type: string; memory_id?: string; confidence: number; } /** Wraps the event + extracted memories for two-layer storage. */ export interface CaptureResult { memories: CapturedMemory[]; edges: AutoEdge[]; rawPayload: Record; } // ═══════════════════════════════════════════════════════════════ // Tool name normalisation — Pi lowercases tool names // ═══════════════════════════════════════════════════════════════ const TOOL_MAP: Record = { bash: "Bash", read: "Read", write: "Write", edit: "Edit", grep: "Grep", glob: "Glob", }; function canonicalName(raw: string): string { return TOOL_MAP[raw.toLowerCase()] ?? raw; } // ═══════════════════════════════════════════════════════════════ // Extractors — one per tool category // ═══════════════════════════════════════════════════════════════ /** File edits: track Write/Edit tool calls + Bash patch/sed. * Low importance — one file edit alone is not a "memory". * But multiple edits on the same directory accumulate signal. */ function extractFileEdits(e: ToolResultEvent): CapturedMemory[] { const name = canonicalName(e.toolName); // Write / Edit tools — single file if (name === "Write" || name === "Edit") { const filePath = String(e.input.file_path ?? e.input.path ?? ""); if (!filePath) return []; const action = name === "Write" ? "wrote" : "edited"; // Use directory prefix for dedup aggregation: // "File wrote src/capture.ts" and "File wrote src/index.ts" // will be dedup-hashed together in the caller const dir = dirname(filePath); return [ { content: `File ${action}: ${filePath}`, importance: 0.2, // Very low — single edit is noise, patterns emerge via RecMem tags: ["file", action, `dir:${dir}`, "auto-captured"], }, ]; } // Bash — apply_patch / sed if (name === "Bash") { const cmd = String(e.input.command ?? ""); if (/\bgit\s+apply\b|\bpatch\b/.test(cmd)) { return [{ content: `Applied patch`, importance: 0.4, tags: ["file", "patch", "auto-captured"] }]; } const sedMatch = cmd.match(/\bsed\s+-i[^;|&]+/); if (sedMatch) { return [ { content: `sed edit: ${sedMatch[0].slice(0, 80)}`, importance: 0.3, tags: ["file", "sed", "auto-captured"] }, ]; } } return []; } /** Significant file reads: instruction files, configs, memory dirs */ function extractSignificantReads(e: ToolResultEvent): CapturedMemory[] { if (canonicalName(e.toolName) !== "Read") return []; const filePath = String(e.input.file_path ?? e.input.path ?? ""); if (!filePath) return []; const isSignificant = /CLAUDE\.md$/i.test(filePath) || /AGENTS\.md$/i.test(filePath) || /\.esr/i.test(filePath) || /\.loom/i.test(filePath) || /packages\.json$/i.test(filePath) || /tsconfig\.json$/i.test(filePath) || /dockerfile/i.test(filePath); if (!isSignificant) return []; return [ { content: `Read config: ${filePath}`, importance: 0.4, tags: ["file", "read", "config", "auto-captured"], }, ]; } /** Git operations detected from Bash commands */ const GIT_PATTERNS: Array<{ pattern: RegExp; op: string; importance: number }> = [ { pattern: /\bgit\s+commit\b/, op: "commit", importance: 0.8 }, { pattern: /\bgit\s+merge\b/, op: "merge", importance: 0.8 }, { pattern: /\bgit\s+rebase\b/, op: "rebase", importance: 0.7 }, { pattern: /\bgit\s+checkout\s+(-b\s+)?(\S+)/, op: "branch", importance: 0.7 }, { pattern: /\bgit\s+push\b/, op: "push", importance: 0.7 }, { pattern: /\bgit\s+stash\b/, op: "stash", importance: 0.6 }, { pattern: /\bgit\s+tag\b/, op: "tag", importance: 0.7 }, { pattern: /\bgit\s+clone\b/, op: "clone", importance: 0.7 }, { pattern: /\bgit\s+cherry-pick\b/, op: "cherry-pick", importance: 0.7 }, ]; function extractGit(e: ToolResultEvent): CapturedMemory[] { if (canonicalName(e.toolName) !== "Bash") return []; const cmd = String(e.input.command ?? ""); if (!cmd) return []; if (e.isError) return []; for (const gp of GIT_PATTERNS) { if (gp.pattern.test(cmd)) { // Try to extract commit message let content = `Git ${gp.op}`; if (gp.op === "commit") { const msgMatch = cmd.match(/\bgit\s+commit\s+.*-m\s+["'](.+?)["']/); if (msgMatch) content += `: ${msgMatch[1]}`; } if (gp.op === "branch") { const branchMatch = cmd.match(/\bgit\s+checkout\s+(-b\s+)?(\S+)/); if (branchMatch) content += ` → ${branchMatch[2]}`; } return [ { content, importance: gp.importance, tags: ["git", gp.op, "auto-captured"], }, ]; } } return []; } /** Significant shell operations */ const ENV_PATTERNS: Array<{ pattern: RegExp; label: string; importance: number }> = [ { pattern: /\bnpm\s+(install|ci)\b/, label: "npm install", importance: 0.5 }, { pattern: /\bpip\s+install\b/, label: "pip install", importance: 0.5 }, { pattern: /\bbun\s+(install|add)\b/, label: "bun install", importance: 0.5 }, { pattern: /\bcargo\s+(install|add|build)\b/, label: "cargo", importance: 0.5 }, { pattern: /\bdocker\s+(build|compose|run)\b/, label: "docker", importance: 0.6 }, { pattern: /\bkubectl\s+(apply|create|delete)\b/, label: "kubectl", importance: 0.7 }, { pattern: /\bmake\s+(build|test|deploy|install)\b/, label: "make", importance: 0.5 }, ]; function extractEnv(e: ToolResultEvent): CapturedMemory[] { if (canonicalName(e.toolName) !== "Bash") return []; const cmd = String(e.input.command ?? ""); if (!cmd) return []; for (const ep of ENV_PATTERNS) { if (ep.pattern.test(cmd)) { return [ { content: `Ran: ${ep.label}`, importance: ep.importance, tags: ["env", "setup", "auto-captured"], }, ]; } } return []; } /** Tool errors — high-signal events worth remembering */ function extractErrors(e: ToolResultEvent): CapturedMemory[] { if (!e.isError) return []; const name = canonicalName(e.toolName); if (!name) return []; const snippet = (e.output ?? "").slice(0, 200).replace(/\n/g, " | "); return [ { content: `Error in ${name}: ${snippet}`, importance: 0.75, tags: ["error", name.toLowerCase(), "auto-captured"], }, ]; } /** ESR tool operations */ function extractESR(e: ToolResultEvent): CapturedMemory[] { const name = canonicalName(e.toolName); if (!name.startsWith("esr_")) return []; if (e.isError) return []; // esr_promote_task → task lifecycle if (name === "esr_promote_task") { const entityId = String(e.input.entity_id ?? ""); const newState = String(e.input.new_state ?? ""); if (!entityId) return []; return [ { content: `ESR task ${entityId} → ${newState}`, entity_id: entityId, importance: newState === "stable" ? 0.8 : 0.6, tags: ["esr", "task", newState, `task-${newState}`, "auto-captured"], }, ]; } // esr_create_entity → new entities worth tracking if (name === "esr_create_entity") { const entityId = String(e.input.entity_id ?? ""); const role = String(e.input.role ?? ""); if (!entityId) return []; const tags = ["esr", "entity-created", role.toLowerCase(), "auto-captured"]; // Mark Task entities for task-lifecycle tracking if (role === "Task") tags.push("task-started"); return [ { content: `ESR entity created: ${entityId} (${role})`, entity_id: entityId, importance: role === "Task" ? 0.7 : 0.5, tags, }, ]; } // esr_link_entities → explicit relation (capture as edge source) if (name === "esr_link_entities") { const sourceEntity = String(e.input.source_entity ?? ""); const targetEntity = String(e.input.target_entity ?? ""); const relationType = String(e.input.relation_type ?? ""); if (!sourceEntity || !targetEntity) return []; return [ { content: `ESR link: ${sourceEntity} → ${relationType} → ${targetEntity}`, entity_id: sourceEntity, importance: 0.65, tags: ["esr", "entity-link", relationType.toLowerCase(), "auto-captured"], }, ]; } // esr_evaluate → evaluation results if (name === "esr_evaluate") { const entityId = String(e.input.entity_id ?? ""); const evaluator = String(e.input.evaluator ?? ""); if (!entityId) return []; return [ { content: `ESR evaluation by ${evaluator} on ${entityId}`, entity_id: entityId, importance: 0.7, tags: ["esr", "evaluation", "auto-captured"], }, ]; } // esr_apply_constraint → path-conditioned constraint (v1.0) if (name === "esr_apply_constraint") { const entityId = String(e.input.entity_id ?? ""); const desc = String(e.input.constraint_description ?? "").slice(0, 200); if (!entityId) return []; return [ { content: `ESR constraint applied to ${entityId}: ${desc}`, entity_id: entityId, importance: 0.7, tags: ["esr", "constraint", "auto-captured"], }, ]; } return []; } /** Loom tool calls — track own state */ function extractLoom(e: ToolResultEvent): CapturedMemory[] { const name = canonicalName(e.toolName); if (!name.startsWith("loom_")) return []; if (e.isError) return []; // Don't capture our own store operations (avoid recursion) if (name === "loom_store") return []; // Track dream engine runs if (name === "loom_dream") return [ { content: `Dream engine run completed`, importance: 0.6, tags: ["loom", "dream", "auto-captured"], }, ]; // Track extraction runs if (name === "loom_extract") return [ { content: `Observation extraction completed`, importance: 0.5, tags: ["loom", "extract", "auto-captured"], }, ]; return []; } // ═══════════════════════════════════════════════════════════════ // Read-only filter — skip tools that never mutate state // ═══════════════════════════════════════════════════════════════ const READ_ONLY_TOOLS = new Set([ "esr_get_context", "esr_get_closure_status", "esr_list_closure_gaps", "esr_list_tasks", "esr_mem_recall", "esr_mem_timeline", "esr_mem_journal", "esr_detect_pack", "esr_list_packs", "loom_recall", "loom_status", "loom_insights", "loom_stats", "read", ]); // ═══════════════════════════════════════════════════════════════ // Main extractor // ═══════════════════════════════════════════════════════════════ const EXTRACTORS = [ extractErrors, // Must run first — errors are highest priority extractESR, // ESR operations have entity anchoring extractGit, // Git operations are high signal extractFileEdits, // File edits provide context extractSignificantReads, // Config reads extractEnv, // Environment setup extractLoom, // Loom's own operations ]; // ═══════════════════════════════════════════════════════════════ // Auto entity-edge detection (Phase 3: ESR deep integration) // ═══════════════════════════════════════════════════════════════ /** * Detect entity co-occurrence edges from captured memories. * * Three detection strategies: * 1. Co-occurrence: two entity_ids in same tool event → REFERENCE edge (c=0.3) * 2. ESR task lifecycle: task-started + task-stable across events → RESOLVES edge (c=0.4) * 3. Entity + file directory: entity actioned on files → MODIFIES edge (c=0.25) * * Confidence starts low — repeated co-occurrence bumps it via * store.linkEntities() which accumulates max confidence. */ function detectEntityEdges(memories: CapturedMemory[]): AutoEdge[] { const edges: AutoEdge[] = []; // Collect entity IDs from memories const entityIds = [...new Set(memories.map((m) => m.entity_id).filter((e): e is string => !!e))]; // Strategy 1: Pairwise co-occurrence → REFERENCE for (let i = 0; i < entityIds.length; i++) { for (let j = i + 1; j < entityIds.length; j++) { const a = entityIds[i], b = entityIds[j]; edges.push({ source_entity: a, target_entity: b, relation_type: "REFERENCE", confidence: 0.3, }); } } // Strategy 2: ESR task lifecycle — task-started + (task-stable|task-completed) // within the same entity → creates a self-referencing RESOLVES chain for timeline const startedEntities = new Set( memories .filter((m) => m.tags.includes("task-started")) .map((m) => m.entity_id!) .filter(Boolean), ); const stableEntities = new Set( memories .filter((m) => m.tags.includes("task-stable") || m.tags.includes("task-completed")) .map((m) => m.entity_id!) .filter(Boolean), ); // Cross-event task linking (different entities, same session event) for (const startId of startedEntities) { for (const stableId of stableEntities) { if (startId !== stableId) { edges.push({ source_entity: startId, target_entity: stableId, relation_type: "RESOLVES", confidence: 0.4, }); } } } // Strategy 3: Entity + file directory → MODIFIES const dirTags = memories .filter((m) => m.tags.some((t) => t.startsWith("dir:"))) .map((m) => { const dirTag = m.tags.find((t) => t.startsWith("dir:"))!; return dirTag.slice(4); // strip "dir:" prefix }); if (dirTags.length > 0 && entityIds.length > 0) { for (const dir of [...new Set(dirTags)]) { for (const eid of entityIds) { edges.push({ source_entity: eid, target_entity: `dir:${dir}`, relation_type: "MODIFIES", confidence: 0.25, }); } } } // Strategy 4: ESR explicit link event → higher-confidence DEPENDS_ON/USES/RELATES_TO const linkMems = memories.filter((m) => m.tags.includes("entity-link")); for (const lm of linkMems) { // Extract relation from content: "ESR link: A → type → B" const match = lm.content.match(/ESR link: (\S+) → (\S+) → (\S+)/); if (match) { edges.push({ source_entity: match[1], target_entity: match[3], relation_type: match[2], confidence: 0.65, }); } } return edges; } /** * Extract implicit memories from a tool result event + build raw payload. * Pure function — no side effects, no LLM calls. * * Returns captured memories AND auto-detected entity edges for ESR integration. */ export function captureToolResult(event: ToolResultEvent): CaptureResult { // Skip read-only tools — no state change, no value for auto-capture if (READ_ONLY_TOOLS.has(event.toolName)) { return { memories: [], edges: [], rawPayload: { toolName: event.toolName, input: event.input, output: event.output?.slice(0, 500), isError: event.isError, }, }; } const all: CapturedMemory[] = []; const seen = new Set(); for (const extractor of EXTRACTORS) { try { const results = extractor(event); for (const r of results) { const dedupKey = r.content.slice(0, 40); if (!seen.has(dedupKey)) { seen.add(dedupKey); all.push(r); } } } catch (err) { console.error("[pi-loom] capture extractor error:", err instanceof Error ? err.message : err); } } all.sort((a, b) => b.importance - a.importance); const topMemories = all.slice(0, 3); const edges = detectEntityEdges(topMemories); return { memories: topMemories, edges, rawPayload: { toolName: event.toolName, input: event.input, output: event.output?.slice(0, 500), isError: event.isError, }, }; } // Backward compat alias export const extractMemoriesFromToolResult = (event: ToolResultEvent): CapturedMemory[] => captureToolResult(event).memories;