/** * pi-loom/index.ts — Pi extension entry point * * Adds [PI_LOOM] context injection, auto-capture, RecMem consolidation, * and the Dream Engine. Collaborates with pi-esr for entity anchoring, * entity graph boost, and cross-layer memory refs. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { captureToolResult, type ToolResultEvent } from "./capture.js"; import { buildLoomContext } from "./context.js"; import { handleLoomApply, handleLoomCheckPath, handleLoomConsolidate, handleLoomConstrain, handleLoomDream, handleLoomExtract, handleLoomInsights, handleLoomManageInsight, handleLoomMermaid, handleLoomOffload, handleLoomOffloadRecall, handleLoomRecall, handleLoomReview, handleLoomStats, handleLoomStatus, handleLoomStore, handleLoomSummarizeSession, handleLoomViews, } from "./handlers.js"; import { LoomStore, openDb } from "./store.js"; export default function (pi: ExtensionAPI) { const db = openDb(); const store = new LoomStore(db); console.error(`[pi-loom] Started. stats=${JSON.stringify(store.stats())}`); // ═══════════════════════════════════════════════════════ // Skill registration — discover slash commands // ═══════════════════════════════════════════════════════ pi.on("resources_discover", (_event, _ctx) => { // Resolve skills directory relative to the extension source const skillsDir = new URL("../skills", import.meta.url).pathname; console.error(`[pi-loom] Registering skills from: ${skillsDir}`); return { skillPaths: [skillsDir] }; }); // ═══════════════════════════════════════════════════════ // Context cache — invalidate on state change // ═══════════════════════════════════════════════════════ let contextCache: { hash: string; text: string } | null = null; function invalidateCache() { contextCache = null; } // ═══════════════════════════════════════════════════════ // Session startup: no hard block. Loom context is auto-injected via // before_agent_start. Model calls loom_recall/loom_status as needed. // ═══════════════════════════════════════════════════════ const LOOM_TOOLS = new Set([ "loom_recall", "loom_status", "loom_store", "loom_dream", "loom_insights", "loom_extract", "loom_consolidate", "loom_stats", "loom_manage_insight", ]); let _loomRecalled = false; pi.on("session_start", () => { _loomRecalled = false; }); // ═══════════════════════════════════════════════════════ // Context injection: [PI_LOOM] block — dynamically sized // ═══════════════════════════════════════════════════════ pi.on("before_agent_start", async (event, ctx) => { const usage = ctx.getContextUsage(); const headroom = usage?.tokens != null && usage?.contextWindow != null ? usage.contextWindow - usage.tokens : Infinity; let factsCount = 5, keyCount = 4, recentCount = 3; if (headroom < 5000) { factsCount = 2; keyCount = 2; recentCount = 1; } else if (headroom < 20000) { factsCount = 3; keyCount = 3; recentCount = 2; } // Cache: skip rebuild when store state hasn't changed const snapshot = JSON.stringify(store.stats()); if (contextCache && contextCache.hash === snapshot) { if (!contextCache.text) return {}; // empty context, skip injection return { systemPrompt: `${event.systemPrompt}\n\n${contextCache.text}` }; } const loomCtx = buildLoomContext(store, { factsCount, keyCount, recentCount }); contextCache = { hash: snapshot, text: loomCtx }; if (!loomCtx) return {}; // empty store, skip injection entirely return { systemPrompt: `${event.systemPrompt}\n\n${loomCtx}` }; }); // ═══════════════════════════════════════════════════════ // Phase 3: RecMem — category-aware consolidation + session-shutdown batch // ═══════════════════════════════════════════════════════ let lastConsolidationTime = 0; const CONSOLIDATION_COOLDOWN_MS = 5 * 60_000; // 5 minutes between consolidations let mutationsSinceLastConsolidation = 0; let errorCountSinceLastConsolidation = 0; /** Priority ordering for consolidation: error > git/esr > edit > others by frequency. */ const CONSOLIDATION_PRIORITY: Record = { error: 0, git: 1, esr: 1, edit: 2 }; /** Shared consolidation runner: discover, sort, find candidates, consolidate. * @param simThreshold - embedding similarity threshold (0.88-0.90) * @param crossSessionOnly - only consolidate categories needing ≥2 sessions * @param logPrefix - prefix for console messages ("[pi-loom]" or "[pi-loom] Session-end:") * @returns count of consolidated batches */ async function runConsolidation(opts: { ctx: any; simThreshold: number; crossSessionOnly?: boolean; logPrefix?: string; allCategories?: boolean; }): Promise { const cross = opts.crossSessionOnly ?? false; const all = opts.allCategories ?? false; const prefix = opts.logPrefix ?? "[pi-loom]"; const sessionOpts = cross ? { minSessionDistinct: 2 } : {}; const activeTags = store.getActiveSubconsciousTags(cross ? 2 : 3, 10); const sorted = activeTags.sort((a, b) => { const pa = CONSOLIDATION_PRIORITY[a.tag] ?? 99; const pb = CONSOLIDATION_PRIORITY[b.tag] ?? 99; if (pa !== pb) return pa - pb; return b.count - a.count; }); let consolidated = 0; for (const { tag: cat } of sorted) { const isLongTail = cat === "edit" || cat === "env"; // long-tail categories: only when allCategories mode (session shutdown) if (isLongTail && !all) continue; const opts2 = isLongTail ? { category: cat, limit: 1, minSessionDistinct: 2 } : { category: cat, limit: cat === "error" ? 2 : 1, ...sessionOpts }; const candidates = store.findConsolidationCandidates(opts2); if (candidates.length === 0) continue; const similar = store.findSimilarByEmbedding(candidates[0], opts.simThreshold, 5); if (similar.length < 2) continue; const sources = [candidates[0], ...similar.map((s) => s.mem)]; const { consolidateMemories } = await import("./consolidate.js"); const result = await consolidateMemories(store, sources, opts.ctx); if (result) { const suffix = isLongTail ? " [cross-session]" : ""; console.error( `${prefix} Consolidated ${result.source_count} ${cat} memories → ${result.memory.id.slice(0, 6)} (${result.entity_label})${suffix}`, ); consolidated++; } } return consolidated; } pi.on("message_end", async (event, ctx) => { if (event.message.role !== "assistant") return; const now = Date.now(); if (now - lastConsolidationTime < CONSOLIDATION_COOLDOWN_MS) return; if (mutationsSinceLastConsolidation === 0) return; if (errorCountSinceLastConsolidation === 0) return; const consolidated = await runConsolidation({ ctx, simThreshold: 0.9 }); if (consolidated > 0) { lastConsolidationTime = now; mutationsSinceLastConsolidation = 0; errorCountSinceLastConsolidation = 0; } }); // ═══════════════════════════════════════════════════════ // Session & Auto-capture (Phase 2.1: two-layer) // ═══════════════════════════════════════════════════════ const SESSION_ID = `sess_${Date.now().toString(36)}`; console.error(`[pi-loom] Session started: ${SESSION_ID}`); const recentCaptures = new Map(); const CAPTURE_DEDUP_MS = 30_000; const MAX_AUTO_CAPTURES_PER_MINUTE = 20; let autoCaptureCount = 0; let autoCaptureWindowStart = Date.now(); // Stash tool args by toolCallId — turn_end toolResults don't carry input, // but tool_call events carry { toolCallId, toolName, input }. const pendingArgs = new Map }>(); pi.on("tool_call", (event, _ctx) => { // Track recall/status calls for capture purposes (no hard block). if (event.toolName === "loom_recall" || event.toolName === "loom_status") { _loomRecalled = true; return; } if (LOOM_TOOLS.has(event.toolName)) return; if (event.toolName.startsWith("esr_")) return; // Stash args for later pairing with tool result const toolCallId = (event as any).toolCallId; if (toolCallId && (event as any).input) { pendingArgs.set(toolCallId, { toolName: event.toolName, input: (event as any).input as Record, }); // Clean old entries (keep last 200) if (pendingArgs.size > 200) { const keys = [...pendingArgs.keys()]; for (const k of keys.slice(0, 50)) pendingArgs.delete(k); } } }); pi.on("turn_end", async (event, _ctx) => { const now = Date.now(); try { const toolResults = (event as any).toolResults ?? []; if (toolResults.length === 0) return; for (const tr of toolResults) { if (now - autoCaptureWindowStart > 60_000) { autoCaptureCount = 0; autoCaptureWindowStart = now; } // toolResult shape: { toolCallId, toolName, content, details, isError } // Pair with stashed args from tool_call event const toolCallId = tr.toolCallId as string | undefined; const stashed = toolCallId ? pendingArgs.get(toolCallId) : undefined; const input = stashed?.input ?? ((tr.input ?? {}) as Record); if (toolCallId) pendingArgs.delete(toolCallId); const rawContent = tr.content; let output: string | undefined; if (typeof rawContent === "string") { output = rawContent; } else if (Array.isArray(rawContent)) { output = rawContent .map((b: any) => b.text ?? b.data ?? "") .filter(Boolean) .join("\n"); } else if (rawContent != null) { output = JSON.stringify(rawContent); } const tEvent: ToolResultEvent = { toolName: tr.toolName ?? stashed?.toolName ?? "", input, output, isError: Boolean(tr.isError), }; if (!tEvent.toolName) continue; // Phase 2.1: Two-layer storage const result = captureToolResult(tEvent); // Layer 1: Raw event log — capture event_id for derivation const rawEventId = store.storeRawEvent({ session_id: SESSION_ID, event_type: "tool_result", payload: result.rawPayload, }); // Layer 2: Extracted memories for (const mem of result.memories) { const dedupKey = mem.content.slice(0, 60); const lastCapture = recentCaptures.get(dedupKey); if (lastCapture && now - lastCapture < CAPTURE_DEDUP_MS) continue; if (autoCaptureCount >= MAX_AUTO_CAPTURES_PER_MINUTE) break; const isHighSignal = mem.importance >= 0.4; if (isHighSignal) { store.store({ content: mem.content, entity_id: mem.entity_id, importance: mem.importance, tags: [...mem.tags, "auto-captured"], provenance: "auto_captured", derivation: rawEventId ? [{ id: rawEventId, type: "event", weight: 1.0 }] : [], expire_at: mem.importance < 0.5 ? new Date(Date.now() + 7 * 86400000).toISOString() : undefined, }); } else { store.storeSubconscious({ content: mem.content, entity_id: mem.entity_id, tags: [...mem.tags, "auto-captured"], session_id: SESSION_ID, }); store.bumpSimilarHits({ content: mem.content, entity_id: mem.entity_id, tags: mem.tags, session_id: SESSION_ID, }); mutationsSinceLastConsolidation++; if (mem.tags.includes("error")) errorCountSinceLastConsolidation++; } recentCaptures.set(dedupKey, now); autoCaptureCount++; } // Phase 3: auto-link entities for (const edge of result.edges) { try { store.linkEntities({ source_entity: edge.source_entity, target_entity: edge.target_entity, relation_type: edge.relation_type, confidence: edge.confidence, memory_id: edge.memory_id, }); } catch { /* best-effort */ } } } // end for each toolResult if (recentCaptures.size > 100) { const toDelete: string[] = []; for (const [k, ts] of recentCaptures) { if (now - ts > CAPTURE_DEDUP_MS * 3) toDelete.push(k); } for (const k of toDelete) recentCaptures.delete(k); } } catch (err) { console.error(`[pi-loom] auto-capture handler error:`, err instanceof Error ? err.message : err); } }); console.error(`[pi-loom] turn_end handler registered`); // ═══════════════════════════════════════════════════════ // Auto-expire + session-end batch consolidation + auto-dream // ═══════════════════════════════════════════════════════ /** Auto-trigger Dream Engine if enough memories have accumulated. */ async function maybeRunDream(ctx: any): Promise { const stats = store.stats(); if (stats.active >= 10) { try { const { runDreamEngine } = await import("./dream.js"); const result = await runDreamEngine(store, ctx, { samplerRoundCount: 8, samplerRounds: 1, conflictPairs: 3, }); if (result.insight_count > 0) { console.error( `[pi-loom] Auto-dream: ${result.insight_count} insights, ${result.suggested_edges} entity edges`, ); } } catch (err) { console.error("[pi-loom] Auto-dream error:", err instanceof Error ? err.message : err); } } } pi.on("session_shutdown", async () => { const expired = store.expireOverdue(); if (expired > 0) console.error(`[pi-loom] Expired ${expired} memories`); // Batch consolidation: at session end, process all categories. // Lower similarity threshold since we have more data at session end. try { const count = await runConsolidation({ ctx: null, simThreshold: 0.88, allCategories: true, logPrefix: "[pi-loom] Session-end:", }); if (count > 0) console.error(`[pi-loom] Session-end: ${count} consolidation batches`); } catch (_err) { // best-effort, don't block shutdown } // Auto-dream: run after consolidation if enough memories exist await maybeRunDream(null); // Phase 2.1: Auto-summarize session if env var is set and enough events exist if (process.env.RAW_EVENT_AUTO_SUMMARIZE && store.countRawEvents(SESSION_ID) >= 5) { console.error(`[pi-loom] Auto-summarizing session ${SESSION_ID}...`); try { await handleLoomSummarizeSession(store, { session_id: SESSION_ID }, null); console.error(`[pi-loom] Session summary stored.`); } catch (err) { console.error(`[pi-loom] Auto-summarize failed:`, err instanceof Error ? err.message : err); } } }); // ═══════════════════════════════════════════════════════ // Tools — delegate to shared handlers // ═══════════════════════════════════════════════════════ pi.registerTool({ name: "loom_store", label: "Loom Store", description: "Store a memory in pi-loom with importance, expiration, tags, and optional ESR entity anchoring.", promptSnippet: "Store a memory with importance, expiration, and entity anchoring", promptGuidelines: [ "Use loom_store to persist important observations, decisions, or facts for future sessions. Set importance 0.7-1.0 for key decisions, 0.3-0.6 for context. Anchor to ESR entities when relevant.", ], parameters: Type.Object({ content: Type.String({ description: "Memory content" }), fact_summary: Type.Optional( Type.String({ description: "LLM-extracted fact summary (short, 1-3 concise facts)" }), ), entity_id: Type.Optional(Type.String({ description: "ESR entity ID to anchor this memory to" })), kind: Type.Optional(Type.String({ description: "Memory kind, e.g. memory, decision, procedure, handoff" })), scope_type: Type.Optional(Type.String({ description: "Scope type: user, repo, task, session, or entity" })), scope_id: Type.Optional(Type.String({ description: "Scope identifier" })), confidence: Type.Optional(Type.Number({ description: "Evidence confidence 0.0-1.0" })), visibility: Type.Optional(Type.String({ description: "Visibility: private, project, or shared" })), importance: Type.Optional(Type.Number({ description: "Importance 0.0-1.0 (default 0.5)" })), expire_at: Type.Optional(Type.String({ description: "ISO 8601 expiration timestamp" })), tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for filtering" })), auto_extract: Type.Optional( Type.Boolean({ description: "Auto-extract atomic facts from this memory (requires importance >= 0.6)" }), ), auto_link_entities: Type.Optional( Type.Boolean({ description: "Auto-link known entity_ids found in content (default: true)" }), ), }), async execute(_id, params, _signal, _onUpdate, ctx) { const result = await handleLoomStore(store, params, ctx); invalidateCache(); return result; }, }); pi.registerTool({ name: "loom_recall", label: "Loom Recall", description: "Recall memories from pi-loom by entity, text search, or list all active.", promptSnippet: "Recall memories by entity, text search, or list active", parameters: Type.Object({ entity_id: Type.Optional(Type.String({ description: "Filter by ESR entity ID" })), query: Type.Optional(Type.String({ description: "Free-text search in memory content" })), view: Type.Optional(Type.String({ description: "Recall view: procedures, handoffs, profiles, insights, decisions" })), scope_type: Type.Optional(Type.String({ description: "Scope type for view recalls" })), scope_id: Type.Optional(Type.String({ description: "Scope identifier for view recalls" })), visibility: Type.Optional(Type.String({ description: "Visibility: private, project, or shared. Defaults to project/shared." })), limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })), compact: Type.Optional(Type.Boolean({ description: "Skip metadata, return content only (saves tokens)" })), }), async execute(_id, params) { return handleLoomRecall(store, params); }, }); pi.registerTool({ name: "loom_dream", label: "Loom Dream", description: "Run the Dream Engine to generate insights from stored memories. Uses weighted sampling, conflict detection, shuffling, and LLM-driven insight generation.", promptSnippet: "Run Dream Engine for memory insight generation", promptGuidelines: [ "Run loom_dream when 10+ active memories have accumulated or after completing a significant task batch.", "Optionally pass model='provider/model' to use a specific model; defaults to the current active model or PI_DREAM_MODEL env var.", ], parameters: Type.Object({ rounds: Type.Optional(Type.Number({ description: "Number of sampling rounds (default 2)" })), per_round: Type.Optional(Type.Number({ description: "Memories per round (default 10)" })), model: Type.Optional( Type.String({ description: "Provider/model for insight generation, e.g. 'openai/gpt-4.1' or 'deepseek/deepseek-v3.1'. Defaults to current active model. Also overridable via PI_DREAM_MODEL env var.", }), ), visibility: Type.Optional(Type.String({ description: "Visibility: private, project, or shared. Defaults to project/shared." })), }), async execute(_id, params, _signal, _onUpdate, ctx) { const result = await handleLoomDream(store, params, ctx); invalidateCache(); return result; }, }); pi.registerTool({ name: "loom_insights", label: "Loom Insights", description: "View generated insights/patterns from the Dream Engine, optionally filtered by entity.", promptSnippet: "View Dream Engine generated insights", parameters: Type.Object({ entity_id: Type.Optional(Type.String({ description: "Filter insights by ESR entity" })), visibility: Type.Optional(Type.String({ description: "Visibility: private, project, or shared. Defaults to project/shared." })), limit: Type.Optional(Type.Number({ description: "Max insights (default 10)" })), }), async execute(_id, params) { return handleLoomInsights(store, params); }, }); pi.registerTool({ name: "loom_manage_insight", label: "Loom Manage Insight", description: "Update or delete an insight. Use action='update' to refine content/confidence/entity, or action='delete' to remove.", promptSnippet: "Update or delete an insight", parameters: Type.Object({ action: Type.String({ description: "'update' (default) or 'delete'" }), insight_id: Type.String({ description: "Insight ID (from loom_insights output)" }), content: Type.Optional(Type.String({ description: "[update] Updated insight text" })), confidence: Type.Optional(Type.Number({ description: "[update] New confidence 0.0-1.0" })), entity_id: Type.Optional(Type.String({ description: "[update] Bind/unbind to ESR entity" })), reason: Type.Optional(Type.String({ description: "[delete] Why it's being removed" })), }), async execute(_id, params) { return handleLoomManageInsight(store, params); }, }); pi.registerTool({ name: "loom_extract", label: "Loom Extract", description: "Extract atomic facts from memories. Stores facts as searchable memories indexed by FTS5." + " Use after storing important memories to make knowledge queryable.", promptSnippet: "Extract atomic facts from memories", promptGuidelines: [ "Run loom_extract after storing important memories to extract atomic, searchable facts.", "Set PI_FACT_MODEL env var to override the fact extraction model.", ], parameters: Type.Object({ entity_id: Type.Optional(Type.String({ description: "Extract from memories with this entity_id" })), memory_ids: Type.Optional(Type.Array(Type.String(), { description: "Specific memory IDs to extract from" })), model: Type.Optional(Type.String({ description: "Provider/model for extraction" })), limit: Type.Optional(Type.Number({ description: "Max memories to process (default 20)" })), }), async execute(_id, params, _signal, _onUpdate, ctx) { return handleLoomExtract(store, params, ctx); }, }); pi.registerTool({ name: "loom_consolidate", label: "Loom Consolidate", description: "Consolidate subconscious memories that have hit the recurrence threshold (hit_count >= 3)." + " Runs the full pipeline: find candidates → verify similarity → LLM synthesis → store consolidated memory.", promptSnippet: "Consolidate recurring subconscious memories into long-term memories", promptGuidelines: [ "Run loom_consolidate after a session with repetitive patterns (debug loops, repeated edits, recurring errors).", "Set PI_CONSOLIDATE_MODEL env var to override the consolidation model.", ], parameters: Type.Object({}), async execute(_id, _params, _signal, _onUpdate, ctx) { const result = await handleLoomConsolidate(store, {}, ctx); invalidateCache(); return result; }, }); pi.registerTool({ name: "loom_stats", label: "Loom Stats", description: "View pi-loom memory statistics (counts by status, insights count).", promptSnippet: "Show pi-loom memory statistics", parameters: Type.Object({}), async execute() { return handleLoomStats(store); }, }); pi.registerTool({ name: "loom_review", label: "Loom Review", description: "Generate deterministic memory maintenance proposals without modifying memory.", promptSnippet: "Review memory for merge, supersede, and procedure-promotion proposals", parameters: Type.Object({ scope_type: Type.Optional(Type.String({ description: "Scope type to review" })), scope_id: Type.Optional(Type.String({ description: "Scope identifier to review" })), visibility: Type.Optional(Type.String({ description: "Visibility: private, project, or shared. Defaults to project/shared." })), limit: Type.Optional(Type.Number({ description: "Max proposals" })), }), async execute(_id, params) { return handleLoomReview(store, params); }, }); pi.registerTool({ name: "loom_apply", label: "Loom Apply", description: "Explicitly apply a memory review proposal after inspecting loom_review output.", promptSnippet: "Apply a reviewed memory maintenance proposal", parameters: Type.Object({ action: Type.String({ description: "merge, supersede, promote_to_procedure, archive, or contradicts" }), memory_ids: Type.Array(Type.String(), { description: "Memory IDs from loom_review output" }), visibility: Type.Optional(Type.String({ description: "Visibility: private, project, or shared. Defaults to project/shared." })), }), async execute(_id, params) { return handleLoomApply(store, params); }, }); pi.registerTool({ name: "loom_views", label: "Loom Views", description: "Export read-only Markdown projections from MemoryNode rows for inspection and handoff.", promptSnippet: "Export Loom Markdown memory views", parameters: Type.Object({ scope_type: Type.Optional(Type.String({ description: "Scope type to export" })), scope_id: Type.Optional(Type.String({ description: "Scope identifier to export" })), visibility: Type.Optional(Type.String({ description: "Visibility: private, project, or shared. Defaults to project/shared." })), limit: Type.Optional(Type.Number({ description: "Max memories per view" })), }), async execute(_id, params) { return handleLoomViews(store, params); }, }); pi.registerTool({ name: "loom_status", label: "Loom Status", description: "Lightweight session status check (~50 tokens). Returns active task count, last 3 high-signal memories, and latest insight. Call this at session start before any tool — cheaper than full loom_recall.", promptSnippet: "Lightweight session status — active tasks, recent decisions, latest insight", promptGuidelines: [ "Call loom_status at session start (after esr_get_context) — cheaper than full loom_recall.", "Use this to quickly assess state before deciding if full loom_recall is needed.", ], parameters: Type.Object({}), async execute() { return handleLoomStatus(store); }, }); // v1.0: Path-conditioned constraint tools pi.registerTool({ name: "loom_check_path", label: "Loom Check Path", description: "Check path-conditioned constraints against raw_events. Returns violations for runtime guardrails (e.g. 3+ bash errors in 5 min). Zero LLM cost.", promptSnippet: "Check runtime guardrail violations from event log", promptGuidelines: [ "Call before risky operations (git push, rm, API calls) to check if any path-conditioned constraints are violated.", "Returns list of violations with enforcement level (warn/block/log).", ], parameters: Type.Object({ entity_id: Type.Optional(Type.String()), }), async execute(params: any) { return handleLoomCheckPath(store, params); }, }); pi.registerTool({ name: "loom_constrain", label: "Loom Constrain", description: "Create a path-conditioned constraint. path_condition format: 'tool:bash error>=3,window=300' to watch raw_events. Set null for static-only.", promptSnippet: "Create a runtime guardrail watching event log patterns", promptGuidelines: [ "Use to set up runtime guardrails: 'tool:bash error>=3,window=300' = 3+ bash errors in 5 min triggers violation.", "enforcement: 'warn' (default), 'block', or 'log'.", ], parameters: Type.Object({ entity_id: Type.String(), description: Type.String(), path_condition: Type.Optional(Type.String()), enforcement: Type.Optional(Type.String()), }), async execute(params: any) { return handleLoomConstrain(store, params); }, }); pi.registerTool({ name: "loom_offload", label: "loom_offload", description: "Offload large text to external file, returning a symbolic [REF:node_id] reference.", parameters: Type.Object({ content: Type.String(), session_id: Type.Optional(Type.String()), label: Type.Optional(Type.String()), max_inline: Type.Optional(Type.Number()), entity_id: Type.Optional(Type.String()), }), async execute(params: any) { return handleLoomOffload(store, params); }, }); pi.registerTool({ name: "loom_offload_recall", label: "loom_offload_recall", description: "Retrieve offloaded content by node_id.", parameters: Type.Object({ node_id: Type.String(), session_id: Type.Optional(Type.String()) }), async execute(params: any) { return handleLoomOffloadRecall(params); }, }); pi.registerTool({ name: "loom_mermaid", label: "loom_mermaid", description: "Generate a Mermaid task graph from offloaded session refs.", parameters: Type.Object({ session_id: Type.Optional(Type.String()) }), async execute(params: any) { return handleLoomMermaid(store, params); }, }); // ═══════════════════════════════════════════════════════ // Slash commands — direct /loom, /loom-stats (no LLM) // ═══════════════════════════════════════════════════════ pi.registerCommand("loom", { description: "Show pi-loom memory: recall, timeline, or recent", handler: async (args, ctx) => { const query = args.trim(); let lines: string[]; if (query) { const result = await handleLoomRecall(store, { query, limit: 15, compact: false }); lines = result.content .map((c) => c.text) .join("\n") .split("\n"); } else { const stats = store.stats(); const recent = store.recallActive(10); lines = [`pi-loom — ${stats.active} active, ${stats.expired} expired, ${stats.totalInsights} insights`, ""]; if (recent.length > 0) { lines.push("Recent memories:"); for (const m of recent.slice(0, 5)) { const tags = (() => { try { return JSON.parse(m.tags || "[]"); } catch { return []; } })(); const prefix = tags.includes("error") ? "[E]" : tags.includes("decision") ? "[D]" : tags.includes("consolidated") ? "[C]" : "[M]"; const summary = (m.fact_summary || m.content).replace(/\n/g, " ").slice(0, 100); lines.push(` ${prefix} ${m.created_at?.slice(0, 10) ?? ""} | ${summary}`); } lines.push(""); } lines.push("/loom — search memories"); lines.push("/loom-stats — memory statistics"); } if (ctx.mode === "tui" && ctx.hasUI) { // Dynamic import pi-tui only in TUI mode (not in loom's own node_modules) const { matchesKey, truncateToWidth } = await import( "../../pi-esr/node_modules/@earendil-works/pi-tui/dist/index.js" ); await ctx.ui.custom((_tui, theme, _kb, done) => { const headerText = theme.fg("accent", theme.bold(" pi-loom Memory ")); const headerLine = `${theme.fg("borderMuted", "═══")}${headerText}${theme.fg("borderMuted", "═".repeat(15))}`; class LoomView { private text = [headerLine, ...lines, "", theme.fg("dim", "Press Escape to close"), ""]; invalidate(): void {} handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done(); } render(width: number): string[] { return this.text.map((line) => truncateToWidth(line, width)); } } return new LoomView(); }); } else { ctx.ui.notify(lines.join("\n"), "info"); } }, }); pi.registerCommand("loom-stats", { description: "Show pi-loom memory statistics", handler: async (_args, ctx) => { const stats = store.stats(); const lines = [ `pi-loom Memory Statistics`, `========================`, `Active: ${stats.active}`, `Expired: ${stats.expired}`, `Archived: ${stats.archived}`, `Insights: ${stats.totalInsights}`, ]; // Top entities by memory count const recent = store.recallActive(100); const byEntity = new Map(); for (const m of recent) { if (m.entity_id) { byEntity.set(m.entity_id, (byEntity.get(m.entity_id) || 0) + 1); } } const top = Array.from(byEntity.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 8); if (top.length > 0) { lines.push(""); lines.push("Top entities:"); for (const [eid, cnt] of top) { lines.push(` ${eid}: ${cnt} memories`); } } if (ctx.mode === "tui" && ctx.hasUI) { const { matchesKey, truncateToWidth } = await import( "../../pi-esr/node_modules/@earendil-works/pi-tui/dist/index.js" ); await ctx.ui.custom((_tui, theme, _kb, done) => { const headerText = theme.fg("accent", theme.bold(" pi-loom Stats ")); const headerLine = `${theme.fg("borderMuted", "═══")}${headerText}${theme.fg("borderMuted", "═".repeat(15))}`; class StatsView { private text = [headerLine, ...lines, "", theme.fg("dim", "Press Escape to close"), ""]; invalidate(): void {} handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done(); } render(width: number): string[] { return this.text.map((line) => truncateToWidth(line, width)); } } return new StatsView(); }); } else { ctx.ui.notify(lines.join("\n"), "info"); } }, }); }