/** * pi-loom: Shared Tool Handlers * * Tool logic shared across pi-loom tools. Called by index.ts (Pi extension). * Pure store operations return synchronously; LLM-calling tools need ctx. */ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { consolidateMemories } from "./consolidate.js"; import { buildLoomContext } from "./context.js"; import { DEFAULT_DREAM_CONFIG, runDreamEngine } from "./dream.js"; import { embedText } from "./embed.js"; import { extractFacts } from "./extract.js"; import { generateMermaidGraph, listSessionRefs, offloadText, retrieveOffload } from "./offloader.js"; import type { LoomStore, MemRow, VisibilityFilter } from "./store.js"; import { exportMarkdownViews } from "./views.js"; export interface ToolResult { [key: string]: unknown; content: { type: "text"; text: string }[]; details: Record; } function readVisibility(params: Record): VisibilityFilter | undefined { const visibility = params.visibility; return visibility === "private" || visibility === "project" || visibility === "shared" ? visibility : undefined; } function matchesVisibility(mem: MemRow, visibility?: VisibilityFilter): boolean { if (visibility === "private") return mem.visibility === "private"; if (visibility === "shared") return mem.visibility === "shared"; return mem.visibility !== "private"; } // ═══════════════════════════════════════════════════════════════ // Pure handlers (no ctx needed) // ═══════════════════════════════════════════════════════════════ export async function handleLoomStore( store: LoomStore, params: Record, ctx?: ExtensionContext | null, ): Promise { if (typeof params.content !== "string") { return { content: [{ type: "text", text: "ERROR: content required" }], details: { error: "content required" } }; } const mem = store.store({ content: params.content, fact_summary: typeof params.fact_summary === "string" ? params.fact_summary : undefined, entity_id: params.entity_id as string | undefined, kind: typeof params.kind === "string" ? params.kind : undefined, scope_type: typeof params.scope_type === "string" ? params.scope_type : undefined, scope_id: typeof params.scope_id === "string" ? params.scope_id : undefined, confidence: typeof params.confidence === "number" ? params.confidence : undefined, visibility: typeof params.visibility === "string" ? params.visibility : undefined, importance: typeof params.importance === "number" ? params.importance : 0.5, expire_at: params.expire_at as string | undefined, tags: Array.isArray(params.tags) ? params.tags : undefined, }); let extra = ""; if (params.auto_extract && mem.importance >= 0.6) { // v2: Skip low-content memories — avoid burning LLM on 1-liner file edits const contentLen = ((params.content as string) || "").length; if (contentLen >= 80) { try { const { extractFacts } = await import("./extract.js"); const result = await extractFacts(store, [mem], ctx ?? null); if (result.fact_count > 0) { extra = ` +${result.fact_count} facts extracted`; } } catch { /* best-effort */ } } else { extra = " (auto-extract skipped: content too short)"; } } if (params.auto_link_entities !== false && !mem.entity_id) { try { const linked = store.extractEntityIds(mem.id); if (linked.length > 0) extra += ` +${linked.length} entities linked`; } catch { /* best-effort */ } } // Trust verification for high-importance memories (TrustMem-inspired) let trustNote = ""; if (mem.importance >= 0.7) { try { const trust = store.checkTrust(mem.id); if (trust.conflicts.length > 0) { trustNote = ` ⚠️ ${trust.conflicts.length} conflict(s) found`; } if (trust.warnings.length > 0) { trustNote += ` ${trust.warnings.join("; ")}`; } } catch { /* best-effort */ } } return { content: [ { type: "text", text: `Stored #${mem.id}${mem.entity_id ? ` → ${mem.entity_id}` : ""} importance=${mem.importance.toFixed(2)}${extra}${trustNote}`, }, ], details: { action: "loom_store", id: mem.id, entity_id: mem.entity_id, kind: mem.kind, scope_type: mem.scope_type, scope_id: mem.scope_id, importance: mem.importance, status: mem.status, }, }; } export async function handleLoomRecall(store: LoomStore, params: Record): Promise { const limit = typeof params.limit === "number" ? params.limit : 20; const compact = Boolean(params.compact); const view = typeof params.view === "string" ? params.view : undefined; const visibility = readVisibility(params); let results: MemRow[]; const query = typeof params.query === "string" ? params.query : undefined; if (view === "procedures") { results = store.recallByKind( "procedure", limit, typeof params.scope_type === "string" ? params.scope_type : undefined, typeof params.scope_id === "string" ? params.scope_id : undefined, visibility, ); } else if (view === "handoffs") { results = store.recallByKind( "handoff", limit, typeof params.scope_type === "string" ? params.scope_type : undefined, typeof params.scope_id === "string" ? params.scope_id : undefined, visibility, ); } else if (view === "profiles") { results = store.recallByKind( "profile", limit, typeof params.scope_type === "string" ? params.scope_type : undefined, typeof params.scope_id === "string" ? params.scope_id : undefined, visibility, ); } else if (view === "insights") { results = store.recallByKind( "insight", limit, typeof params.scope_type === "string" ? params.scope_type : undefined, typeof params.scope_id === "string" ? params.scope_id : undefined, visibility, ); } else if (view === "decisions") { const byKind = store.recallByKind( "decision", limit, typeof params.scope_type === "string" ? params.scope_type : undefined, typeof params.scope_id === "string" ? params.scope_id : undefined, visibility, ); const byTag = store.recallByTags(["decision", "architecture", "principle"], limit, visibility); const seen = new Set(); results = [...byKind, ...byTag].filter((m) => { if (seen.has(m.id)) return false; seen.add(m.id); return true; }).slice(0, limit); } else if (params.entity_id || query) { let queryEmbedding: number[] | undefined; if (query && store.vecLoaded) { const emb = await embedText(query); if (emb.length > 0) queryEmbedding = emb; } results = store.searchHybrid({ query, queryEmbedding, entity_id: params.entity_id as string | undefined, limit, compact, visibility, }); } else { results = store.recallActive(limit, visibility); } if (results.length === 0) { return { content: [{ type: "text", text: "No memories found." }], details: { action: "loom_recall", count: 0 } }; } const text = compact ? results.map((m) => m.content).join("\n---\n") : results .map( (m) => `[${m.id}] ${m.entity_id ? `entity=${m.entity_id} ` : ""}importance=${m.importance.toFixed(2)} status=${m.status}\n ${m.content}`, ) .join("\n\n"); return { content: [{ type: "text", text }], details: { action: "loom_recall", count: results.length, compact } }; } /** * Three-way hybrid search with configurable mode. * Modes: keyword (FTS5 only), semantic (vector only), hybrid (FTS5+vector+recency RRF), auto (default hybrid). */ export async function handleLoomSearch(store: LoomStore, params: Record): Promise { const limit = typeof params.limit === "number" ? params.limit : 20; const mode = (typeof params.mode === "string" ? params.mode : "hybrid") as "keyword" | "semantic" | "hybrid"; const query = typeof params.query === "string" ? params.query : undefined; const entityId = params.entity_id as string | undefined; const compact = Boolean(params.compact); const explain = Boolean(params.explain); const visibility = readVisibility(params); let results: MemRow[]; let ranked: ReturnType | undefined; if (mode === "keyword" || !store.vecLoaded) { // FTS5 only (or fallback when no vec) const searchParams = { query, entity_id: entityId, limit, compact, visibility, weights: { fts5: 0.55, vector: 0, recency: 0.45 }, }; ranked = explain ? store.searchHybridExplain(searchParams) : undefined; results = ranked ? ranked.map((r) => r.mem) : store.searchHybrid(searchParams); } else if (mode === "semantic" && query) { // Vector only — generate embedding first, then search const { embedText } = await import("./embed.js"); const embedding = await embedText(query); if (embedding.length > 0) { const searchParams = { query, queryEmbedding: embedding, entity_id: entityId, limit, compact, visibility, weights: { fts5: 0, vector: 0.8, recency: 0.2 }, }; ranked = explain ? store.searchHybridExplain(searchParams) : undefined; results = ranked ? ranked.map((r) => r.mem) : store.searchHybridWithEmbedding(searchParams); } else { // Embedding failed, fall back to FTS5 const searchParams = { query, entity_id: entityId, limit, compact, visibility, weights: { fts5: 0.55, vector: 0, recency: 0.45 }, }; ranked = explain ? store.searchHybridExplain(searchParams) : undefined; results = ranked ? ranked.map((r) => r.mem) : store.searchHybrid(searchParams); } } else { // Hybrid: FTS5 + vector + recency (default) if (query && store.vecLoaded) { const { embedText } = await import("./embed.js"); const embedding = await embedText(query); if (embedding.length > 0) { const searchParams = { query, queryEmbedding: embedding, entity_id: entityId, limit, compact, visibility, }; ranked = explain ? store.searchHybridExplain(searchParams) : undefined; results = ranked ? ranked.map((r) => r.mem) : store.searchHybridWithEmbedding(searchParams); } else { const searchParams = { query, entity_id: entityId, limit, compact, visibility }; ranked = explain ? store.searchHybridExplain(searchParams) : undefined; results = ranked ? ranked.map((r) => r.mem) : store.searchHybrid(searchParams); } } else { const searchParams = { query, entity_id: entityId, limit, compact, visibility }; ranked = explain ? store.searchHybridExplain(searchParams) : undefined; results = ranked ? ranked.map((r) => r.mem) : store.searchHybrid(searchParams); } } if (results.length === 0) { return { content: [{ type: "text", text: "No memories found." }], details: { action: "loom_search", count: 0, mode }, }; } const text = ranked ? ranked .map(({ mem, score, scores }) => { const breakdown = [ `score=${score.toFixed(3)}`, `fts5=${scores.fts5.toFixed(3)}`, `vec=${scores.vec.toFixed(3)}`, `recency=${scores.recency.toFixed(3)}`, `graph=${scores.graph.toFixed(3)}`, `access=${scores.access.toFixed(3)}`, `hop=${scores.hop === 99 ? "-" : scores.hop}`, ].join(" "); return `[${mem.id}] ${mem.entity_id ? `entity=${mem.entity_id} ` : ""}${breakdown}\n ${mem.content}`; }) .join("\n\n") : compact ? results.map((m) => m.content).join("\n---\n") : results .map( (m) => `[${m.id}] ${m.entity_id ? `entity=${m.entity_id} ` : ""}importance=${m.importance.toFixed(2)}\n ${m.content}`, ) .join("\n\n"); return { content: [{ type: "text", text }], details: { action: "loom_search", count: results.length, mode, compact, explain, scores: ranked?.map(({ mem, score, scores }) => ({ id: mem.id, score, ...scores })), }, }; } // ═══════════════════════════════════════════════════════════════ // Raw Event Audit + Session Summary (Phase 2.1) // ═══════════════════════════════════════════════════════════════ export function handleLoomAudit(store: LoomStore, params: Record): ToolResult { const sessionId = params.session_id as string | undefined; const eventType = params.event_type as string | undefined; const limit = typeof params.limit === "number" ? params.limit : 50; if (!sessionId) { const sessions = store.recentSessions(10); const text = sessions.length > 0 ? `Recent sessions:\n${sessions.map((s, i) => ` ${i + 1}. ${s} (${store.countRawEvents(s)} events)`).join("\n")}\n\nUse loom_audit with session_id to view events.` : "No sessions found."; return { content: [{ type: "text", text }], details: { action: "loom_audit", sessions } }; } const events = store.auditRawEvents({ session_id: sessionId, event_type: eventType, limit }); if (events.length === 0) { return { content: [{ type: "text", text: `No events for session ${sessionId}.` }], details: { action: "loom_audit", count: 0 }, }; } const text = events .map((e) => { const payload = JSON.parse(e.payload) as Record; const name = payload.toolName ?? e.event_type; const ts = e.created_at.slice(11, 19); return `[${ts}] ${name}: ${JSON.stringify(payload).slice(0, 200)}`; }) .join("\n"); return { content: [{ type: "text", text: `Session ${sessionId}: ${events.length} events\n\n${text}` }], details: { action: "loom_audit", session_id: sessionId, count: events.length }, }; } const SUMMARIZE_SYSTEM = `You are a session summarizer for an AI coding agent. Analyze the raw event log and extract structured insights. Output ONLY a JSON object: { "summary": "<1-2 sentence high-level summary of the session>", "decisions": ["decision 1", ...], "errors": ["error encountered and how it was resolved", ...], "changes": ["file edited and why", ...], "unfinished": ["task not completed and next step", ...] } Rules: - decisions: architecture, implementation, or strategy choices made - errors: tool errors or unexpected outcomes, with resolution if found - changes: files modified with the reason (extract from Write/Edit events) - unfinished: tasks started but not completed, with suggested next step - If a category has nothing, return empty array`; export async function handleLoomSummarizeSession( store: LoomStore, params: Record, _ctx: ExtensionContext | null, ): Promise { const sessionId = (params.session_id as string) || store.recentSessions(1)[0]; if (!sessionId) { return { content: [{ type: "text", text: "No sessions to summarize." }], details: { count: 0 } }; } const count = store.countRawEvents(sessionId); const rawText = store.getRawEventsAsText(sessionId, 200); if (rawText.length < 20) { return { content: [{ type: "text", text: `Session ${sessionId}: ${count} events, too few to summarize.` }], details: { count }, }; } let result: Record | null = null; const apiKey = process.env.DEEPSEEK_API_KEY || process.env.OPENAI_API_KEY || ""; const apiBase = process.env.OPENAI_API_BASE || "https://router.shengsuanyun.com/api/v1"; const model = process.env.EVAL_MODEL || "deepseek/deepseek-v4-flash"; try { const url = `${apiBase.replace(/\/$/, "")}/chat/completions`; const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model, max_tokens: 1024, temperature: 0.0, messages: [ { role: "system", content: SUMMARIZE_SYSTEM }, { role: "user", content: `## Session ${sessionId} (${count} events)\n\n${rawText.slice(0, 12000)}` }, ], }), }); if (resp.ok) { const data = (await resp.json()) as any; const txt = data.choices[0].message.content; const jsonMatch = txt.match(/\{[\s\S]*\}/); if (jsonMatch) result = JSON.parse(jsonMatch[0]) as Record; } } catch (err) { console.error("[pi-loom] Summarize LLM error:", err instanceof Error ? err.message : err); } if (!result) { return { content: [ { type: "text", text: `Session ${sessionId}: summarize failed (LLM error). ${count} raw events preserved.` }, ], details: { count }, }; } const sid = store.storeSessionSummary({ session_id: sessionId, summary: (result.summary as string) || "", decisions: result.decisions as string[] | undefined, errors: result.errors as string[] | undefined, changes: result.changes as string[] | undefined, unfinished: result.unfinished as string[] | undefined, }); const memIds: string[] = []; for (const [cat, imp] of [ ["decisions", 0.9], ["errors", 0.8], ["changes", 0.6], ["unfinished", 0.5], ] as Array<[string, number]>) { const items = result[cat] as string[] | undefined; if (items) { for (const item of items) { const mem = store.store({ content: item, entity_id: `session:${sessionId}`, importance: imp, tags: [cat, `session:${sessionId}`, "session-summary"], }); memIds.push(mem.id); } } } store.updateSummaryMemories(sid, memIds); const text = [ `Session ${sessionId}: ${count} events → ${memIds.length} memories`, `Summary: ${result.summary}`, ` decisions: ${(result.decisions as string[])?.length || 0}`, ` errors: ${(result.errors as string[])?.length || 0}`, ` changes: ${(result.changes as string[])?.length || 0}`, ` unfinished: ${(result.unfinished as string[])?.length || 0}`, ].join("\n"); return { content: [{ type: "text", text }], details: { action: "loom_summarize_session", session_id: sessionId, events: count, memories: memIds.length }, }; } export function handleLoomInsights(store: LoomStore, params: Record): ToolResult { const limit = typeof params.limit === "number" ? params.limit : 10; const visibility = readVisibility(params); const insights = params.entity_id ? store.getInsightsForEntity(params.entity_id as string, visibility).slice(0, limit) : store.getInsights(limit, visibility); if (insights.length === 0) { return { content: [{ type: "text", text: "No insights yet. Run loom_dream to generate some." }], details: { action: "loom_insights", count: 0 }, }; } const text = insights .map((i, idx) => { const derivation: Array<{ id: string }> = i.derivation ? JSON.parse(i.derivation) : []; const supporting = derivation.length > 0 ? ` (from ${derivation.length} memories)` : ""; return `[${i.id}] #${idx + 1} (confidence=${i.importance.toFixed(2)})${supporting}\n ${i.content}`; }) .join("\n\n"); return { content: [{ type: "text", text }], details: { action: "loom_insights", count: insights.length } }; } export function handleLoomManageInsight(store: LoomStore, params: Record): ToolResult { const action = params.action as string; if (action === "delete") return handleLoomDeleteInsight(store, params); return handleLoomUpdateInsight(store, params); } function handleLoomUpdateInsight(store: LoomStore, params: Record): ToolResult { if (typeof params.insight_id !== "string") { return { content: [{ type: "text", text: "ERROR: insight_id required" }], details: {} }; } const ok = store.updateInsight(params.insight_id, { content: params.content as string | undefined, confidence: typeof params.confidence === "number" ? params.confidence : undefined, entity_id: params.entity_id as string | undefined, }); if (!ok) return { content: [{ type: "text", text: `Not found: ${params.insight_id}` }], details: {} }; const updated = store.getInsight(params.insight_id); return { content: [ { type: "text", text: `Updated ${params.insight_id}(c=${updated?.importance.toFixed(2)}): ${updated?.content}` }, ], details: { insight_id: params.insight_id }, }; } function handleLoomDeleteInsight(store: LoomStore, params: Record): ToolResult { if (typeof params.insight_id !== "string") { return { content: [{ type: "text", text: "ERROR: insight_id required" }], details: {} }; } const ok = store.deleteInsight(params.insight_id); if (!ok) return { content: [{ type: "text", text: `Not found: ${params.insight_id}` }], details: {} }; return { content: [{ type: "text", text: `Deleted ${params.insight_id}${params.reason ? ` — ${params.reason}` : ""}` }], details: { insight_id: params.insight_id }, }; } // ═══════════════════════════════════════════════════════════════ // Entity Graph Operations (Phase 1.2) // ═══════════════════════════════════════════════════════════════ export function handleLoomLink(store: LoomStore, params: Record): ToolResult { const source = params.source_entity as string | undefined; const target = params.target_entity as string | undefined; const rtype = params.relation_type as string | undefined; if (!source || !target || !rtype) { return { content: [{ type: "text", text: "ERROR: source_entity, target_entity, relation_type required." }], details: {}, }; } const eid = store.linkEntities({ source_entity: source, target_entity: target, relation_type: rtype, memory_id: params.memory_id as string | undefined, confidence: typeof params.confidence === "number" ? params.confidence : 0.5, }); return { content: [{ type: "text", text: `Linked ${source} → ${rtype} → ${target} (edge #${eid.slice(0, 8)})` }], details: { action: "loom_link", edge_id: eid, source, target, relation_type: rtype }, }; } export function handleLoomRelated(store: LoomStore, params: Record): ToolResult { const entityId = params.entity_id as string; if (!entityId) { return { content: [{ type: "text", text: "ERROR: entity_id required." }], details: {} }; } const related = store.getRelatedEntities(entityId); if (related.length === 0) { return { content: [{ type: "text", text: `No relations for ${entityId}. Use loom_link to create edges.` }], details: { count: 0 }, }; } const text = `Relations for ${entityId}:\n${related.map((r) => ` ${r.entity} (${r.relation_type}, c=${r.confidence.toFixed(2)}, ${r.direction})`).join("\n")}`; return { content: [{ type: "text", text }], details: { action: "loom_related", entity_id: entityId, related } }; } export function handleLoomGraph(store: LoomStore, params: Record): ToolResult { const entityId = params.entity_id as string; if (!entityId) { return { content: [{ type: "text", text: "ERROR: entity_id required." }], details: {} }; } const visited = store.traverseGraph([entityId], typeof params.hops === "number" ? params.hops : 2); const text = `Graph from ${entityId} (${visited.length} entities):\n${visited .map((e) => { const eps = store.getTimeline(e, 1); return ` ${e}${eps.length > 0 ? ` (${eps.length}+ episodes)` : ""}`; }) .join("\n")}`; return { content: [{ type: "text", text }], details: { action: "loom_graph", entity_id: entityId, visited, hops: params.hops || 2 }, }; } export function handleLoomStats(store: LoomStore): ToolResult { const s = store.stats(); return { content: [ { type: "text", text: `active=${s.active} expired=${s.expired} archived=${s.archived} ` + `insight=${s.insight} insights=${s.totalInsights} degraded=${s.degraded} ` + `expiring_soon=${s.expiringSoon} extended_by_recall=${s.extendedByRecall}`, }, ], details: { action: "loom_stats", ...s }, }; } // ═══════════════════════════════════════════════════════════════ // Progressive Disclosure (Phase 3: drill-down from symbolic index) // ═══════════════════════════════════════════════════════════════ export function handleLoomDetail(store: LoomStore, params: Record): ToolResult { const id = params.memory_id as string | undefined; if (!id) { return { content: [ { type: "text", text: "ERROR: memory_id required. Use the #mem_xxx IDs from the [PI_LOOM] context index." }, ], details: {}, }; } // Support short IDs (first 8 chars) by prefix matching let mem = store.get(id); const visibility = readVisibility(params); if (mem && !matchesVisibility(mem, visibility)) { mem = undefined; } if (!mem && id.length >= 6 && !id.includes("-")) { const rows = store.recallActive(200, visibility); mem = rows.find((r) => r.id.startsWith(id)) ?? undefined; } if (!mem) { return { content: [{ type: "text", text: `Memory #${id} not found (may be expired or archived).` }], details: { action: "loom_detail", found: false }, }; } const tags = (() => { try { return JSON.parse(mem.tags || "[]"); } catch { return []; } })(); const text = [ `## Memory #${mem.id}`, `Entity: ${mem.entity_id || "(none)"}`, `Importance: ${mem.importance.toFixed(2)} | Created: ${mem.created_at}`, `Tags: ${tags.join(", ") || "(none)"}`, `Recalls: ${(mem as any).recall_count || 0}`, "", mem.content, ].join("\n"); return { content: [{ type: "text", text }], details: { action: "loom_detail", id: mem.id, entity_id: mem.entity_id }, }; } export function handleLoomTimeline(store: LoomStore, params: Record): ToolResult { const entityId = params.entity_id as string | undefined; const limit = typeof params.limit === "number" ? params.limit : 20; if (!entityId) { // List entities with most memories const visibility = readVisibility(params); const recent = store.recallActive(100, visibility); 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, 10); const text = top.length > 0 ? `Top entities:\n${top.map(([e, c]) => ` ${e} (${c} memories)`).join("\n")}\n\nUse loom_timeline --entity_id for detail.` : "No entities found."; return { content: [{ type: "text", text }], details: { action: "loom_timeline", entities: top.length } }; } const mems = store.recallByEntity(entityId, limit * 3, readVisibility(params)); if (mems.length === 0) { return { content: [{ type: "text", text: `No memories for entity ${entityId}.` }], details: { action: "loom_timeline", count: 0 }, }; } // Sort by date, newest first mems.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); const text = mems .slice(0, limit) .map((m) => { const tags = (() => { try { return JSON.parse(m.tags || "[]"); } catch { return []; } })(); const prefix = tags.includes("decision") ? "[D]" : tags.includes("error") ? "[E]" : tags.includes("insight") ? "[I]" : "[M]"; const date = m.created_at.slice(0, 10); const content = (m.fact_summary || m.content).replace(/\n/g, " ").slice(0, 120); return `${prefix} ${date} | ${content} | #${m.id.slice(0, 8)}`; }) .join("\n"); // Also include session summaries for this entity const summaries = store.recentSessionSummaries(5); const relevantSummaries = summaries.filter((s) => s.summary?.toLowerCase().includes(entityId.toLowerCase())); const summaryText = relevantSummaries.length > 0 ? "\n\n## Session Summaries\n" + relevantSummaries.map((s) => ` ${s.created_at.slice(0, 10)}: ${s.summary.slice(0, 120)}`).join("\n") : ""; return { content: [{ type: "text", text: `Timeline for ${entityId} (${mems.length} memories):\n\n${text}${summaryText}` }], details: { action: "loom_timeline", entity_id: entityId, count: mems.length }, }; } export function handleLoomEpisode(store: LoomStore, params: Record): ToolResult { const sessionId = params.session_id as string | undefined; const _limit = typeof params.limit === "number" ? params.limit : 20; if (!sessionId) { // List recent summaries const summaries = store.recentSessionSummaries(5); if (summaries.length === 0) { return { content: [{ type: "text", text: "No session summaries yet. Run loom_summarize_session first." }], details: { count: 0 }, }; } const text = `Recent sessions:\n${summaries .map((s) => ` ${s.session_id} | ${s.created_at.slice(0, 10)} — ${s.summary.slice(0, 100)}`) .join("\n")}\n\nUse loom_episode --session_id for full detail.`; return { content: [{ type: "text", text }], details: { action: "loom_episode", count: summaries.length } }; } // Get full session summary const summary = store.getSessionSummary(sessionId); if (!summary) { return { content: [{ type: "text", text: `Session ${sessionId} not found.` }], details: { action: "loom_episode", found: false }, }; } const decisions = (() => { try { return JSON.parse(summary.decisions || "[]"); } catch { return []; } })(); const errors = (() => { try { return JSON.parse(summary.errors || "[]"); } catch { return []; } })(); const changes = (() => { try { return JSON.parse(summary.changes || "[]"); } catch { return []; } })(); const unfinished = (() => { try { return JSON.parse(summary.unfinished || "[]"); } catch { return []; } })(); const text = [ `## Session: ${sessionId}`, `Date: ${summary.created_at.slice(0, 10)}`, `Summary: ${summary.summary}`, "", decisions.length > 0 ? `### Decisions (${decisions.length})\n${decisions.map((d: string) => `- ${d}`).join("\n")}` : "", errors.length > 0 ? `### Errors (${errors.length})\n${errors.map((e: string) => `- ${e}`).join("\n")}` : "", changes.length > 0 ? `### Changes (${changes.length})\n${changes.map((c: string) => `- ${c}`).join("\n")}` : "", unfinished.length > 0 ? `### Unfinished (${unfinished.length})\n${unfinished.map((u: string) => `- ${u}`).join("\n")}` : "", ] .filter(Boolean) .join("\n\n"); return { content: [{ type: "text", text }], details: { action: "loom_episode", session_id: sessionId, decisions: decisions.length, errors: errors.length }, }; } // ═══════════════════════════════════════════════════════════════ // Async handlers (need ctx for model resolution) // ═══════════════════════════════════════════════════════════════ export async function handleLoomDream( store: LoomStore, params: Record, ctx: ExtensionContext | null, ): Promise { const config = { ...DEFAULT_DREAM_CONFIG }; if (typeof params.model === "string") { const [provider = "openai", modelId] = params.model.split("/"); config.modelProvider = provider; config.modelId = modelId; } const result = await runDreamEngine(store, ctx, { ...config, samplerRoundCount: typeof params.per_round === "number" ? params.per_round : config.samplerRoundCount, samplerRounds: typeof params.rounds === "number" ? params.rounds : config.samplerRounds, conflictPairs: 5, entityId: typeof params.entity_id === "string" ? params.entity_id : undefined, visibility: readVisibility(params), }); if (result.insight_count === 0) { return { content: [{ type: "text", text: `Dream: sampled ${result.sampled_count} memories, no insights.` }], details: { action: "loom_dream", ...result }, }; } const text = [ `Dream: ${result.insight_count} insights from ${result.sampled_count} memories:`, ...result.insights.map((i, idx) => `[${i.id}] ${idx + 1}. (c=${i.importance.toFixed(2)}) ${i.content}`), ].join("\n\n"); return { content: [{ type: "text", text }], details: { action: "loom_dream", ...result } }; } export async function handleLoomExtract( store: LoomStore, params: Record, ctx: ExtensionContext | null, ): Promise { const limit = typeof params.limit === "number" ? params.limit : 20; const memories = resolveMemories(store, params, limit); if (memories.length === 0) { return { content: [{ type: "text", text: "No memories to extract from." }], details: { count: 0 } }; } const config = buildModelConfig(params); const result = await extractFacts(store, memories, ctx, config); return { content: [{ type: "text", text: `${result.fact_count} facts from ${result.memory_count} memories.` }], details: { action: "loom_extract", ...result }, }; } // ═══════════════════════════════════════════════════════════════ // Phase 3: Recurrence-based Consolidation // ═══════════════════════════════════════════════════════════════ export async function handleLoomConsolidate( store: LoomStore, _params: Record, ctx: ExtensionContext | null, ): Promise { const candidates = store.findConsolidationCandidates({ limit: 5 }); if (candidates.length === 0) { return { content: [ { type: "text", text: "No consolidation candidates found (need hit_count >= 3 on subconscious memories)." }, ], details: { action: "loom_consolidate", candidates: 0, consolidated: 0 }, }; } let consolidated = 0; for (const candidate of candidates) { const similar = store.findSimilarByEmbedding(candidate, 0.85, 5); if (similar.length < 2) continue; const sources = [candidate, ...similar.map((s) => s.mem)]; const result = await consolidateMemories(store, sources, ctx); if (result) consolidated++; } return { content: [ { type: "text", text: `Consolidated ${consolidated} groups from ${candidates.length} candidates (${candidates.map((c) => c.content.slice(0, 30)).join(", ")})`, }, ], details: { action: "loom_consolidate", candidates: candidates.length, consolidated }, }; } // ═══════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════ function resolveMemories(store: LoomStore, params: Record, limit: number): MemRow[] { if (Array.isArray(params.memory_ids) && params.memory_ids.length > 0) { const visibility = readVisibility(params); return params.memory_ids .map((id: string) => store.get(id)) .filter((m): m is MemRow => m != null && matchesVisibility(m, visibility)); } if (params.entity_id) return store.recallByEntity(params.entity_id as string, limit, readVisibility(params)); return store.recallActive(limit, readVisibility(params)); } export function handleLoomStatus(store: LoomStore): ToolResult { const stats = store.stats(); const recent = store.recallActive(8); const insights = store.getInsights(1); const lines: string[] = [ `active=${stats.active} expired=${stats.expired} archived=${stats.archived} insights=${stats.insight}`, ]; // v1.1: health check — surface evidence quality issues try { const degraded = stats.degraded; if (degraded > 0) { lines.push(`⚠️ ${degraded} degraded consolidations — keys lost in abstraction`); } } catch { /* optional */ } // Last 3 high-signal memories const signal = recent.filter((m) => m.importance >= 0.7).slice(0, 3); for (const m of signal) { const prefix = m.tags?.includes("error") ? "❌" : m.tags?.includes("decision") ? "📋" : "📌"; const summary = (m.fact_summary || m.content).replace(/\n/g, " ").slice(0, 80); lines.push(`${prefix} ${summary}`); } if (insights.length > 0) { lines.push(`💡 ${insights[0].fact_summary || insights[0].content.slice(0, 80)}`); } return { content: [{ type: "text", text: lines.join("\n") }], details: { stats }, }; } export function handleLoomContext(store: LoomStore, params: Record = {}): ToolResult { const ctx = buildLoomContext(store, { scope_type: typeof params.scope_type === "string" ? params.scope_type : undefined, scope_id: typeof params.scope_id === "string" ? params.scope_id : undefined, visibility: readVisibility(params), maxTokens: typeof params.max_tokens === "number" ? params.max_tokens : undefined, }); if (!ctx) { return { content: [{ type: "text", text: "[PI_LOOM] (no memories)" }], details: { empty: true } }; } return { content: [{ type: "text", text: ctx }], details: { action: "loom_context", scope_type: params.scope_type, scope_id: params.scope_id, }, }; } export function handleLoomProfile(store: LoomStore, params: Record): ToolResult { const limit = typeof params.limit === "number" ? params.limit : 10; const visibility = readVisibility(params); if (params.entity_id) { const profiles = store.getProfiles(params.entity_id as string, limit, visibility); if (profiles.length === 0) { return { content: [{ type: "text", text: `No profiles for ${params.entity_id}. Run loom_dream to generate one.` }], details: { count: 0 }, }; } const text = profiles .map((p) => `[${p.id}] (c=${p.importance.toFixed(2)}) ${p.created_at.slice(0, 10)}\n ${p.content}`) .join("\n\n"); return { content: [{ type: "text", text }], details: { action: "loom_profile", entity_id: params.entity_id, count: profiles.length }, }; } const all = store.getAllProfiles(limit, visibility); if (all.length === 0) { return { content: [{ type: "text", text: "No profiles yet. Profiles are generated by Dream Engine from entity facts." }], details: { count: 0 }, }; } const text = all.map((p) => `[P] ${p.entity_id}: ${p.content.slice(0, 120)} (#${p.id.slice(0, 6)})`).join("\n"); return { content: [{ type: "text", text }], details: { action: "loom_profile", count: all.length } }; } /** One-call session setup: returns status + context. ~600 tokens total. */ export function handleLoomSetup(store: LoomStore, params: Record = {}): ToolResult { const health = store.healthCheck(); const status = handleLoomStatus(store); const context = handleLoomContext(store, params); const healthLines = [ "[PI_LOOM_SETUP]", `db=${health.dbPath}`, `memories=${health.activeMemories} fts=${health.ftsSynced ? "ok" : `drift:${health.ftsRows}/${health.activeMemories}`}`, `vec=${health.vecLoaded ? "loaded" : "disabled"} embeddings=${health.embeddingRows}/${health.activeMemories} (${(health.embeddingCoverage * 100).toFixed(1)}%)`, `raw_events=${health.rawEvents} entity_edges=${health.entityEdges}`, `config=embed:${health.hasEmbedConfig || health.hasLocalEmbedConfig ? "ok" : "fallback"} dream:${health.hasDreamModel ? "set" : "auto"} fact:${health.hasFactModel ? "set" : "auto"}`, ]; const text = `${healthLines.join("\n")}\n\n${status.content[0].text}\n\n${context.content[0].text}`; return { content: [{ type: "text", text }], details: { action: "loom_setup", health, status_lines: status.content[0].text.split("\n").length, context_chars: context.content[0].text.length, }, }; } export function handleLoomReview(store: LoomStore, params: Record): ToolResult { const result = store.review({ scope_type: typeof params.scope_type === "string" ? params.scope_type : undefined, scope_id: typeof params.scope_id === "string" ? params.scope_id : undefined, visibility: readVisibility(params), limit: typeof params.limit === "number" ? params.limit : undefined, }); if (result.proposals.length === 0) { return { content: [{ type: "text", text: "No review proposals." }], details: { action: "loom_review", ...result }, }; } const text = result.proposals .map( (p, i) => `${i + 1}. ${p.action} c=${p.confidence.toFixed(2)} ${p.memory_ids.map((id) => `#${id.slice(0, 6)}`).join(" ")}\n ${p.reason}`, ) .join("\n"); return { content: [{ type: "text", text }], details: { action: "loom_review", ...result }, }; } export function handleLoomApply(store: LoomStore, params: Record): ToolResult { const action = typeof params.action === "string" ? params.action : ""; const allowed = new Set(["merge", "supersede", "promote_to_procedure", "archive", "contradicts"]); if (!allowed.has(action)) { return { content: [{ type: "text", text: "ERROR: action must be merge, supersede, promote_to_procedure, archive, or contradicts." }], details: {} }; } const memoryIds = Array.isArray(params.memory_ids) ? params.memory_ids.filter((id): id is string => typeof id === "string") : []; if (memoryIds.length === 0) { return { content: [{ type: "text", text: "ERROR: memory_ids required." }], details: {} }; } const visibility = readVisibility(params); const hiddenIds = memoryIds.filter((id) => { const mem = store.get(id); return mem != null && !matchesVisibility(mem, visibility); }); if (hiddenIds.length > 0) { return { content: [{ type: "text", text: "ERROR: memory_ids include memories outside the requested visibility boundary." }], details: { action: "loom_apply", error: "visibility boundary", hidden_count: hiddenIds.length }, }; } const result = store.applyReviewProposal({ action: action as "merge" | "supersede" | "promote_to_procedure" | "archive" | "contradicts", memory_ids: memoryIds, }); const text = `Applied ${result.action}: ` + `created=${result.created_ids.length} archived=${result.archived_ids.length} edges=${result.edge_ids.length}`; return { content: [{ type: "text", text }], details: { tool: "loom_apply", ...result }, }; } export function handleLoomViews(store: LoomStore, params: Record = {}): ToolResult { const result = exportMarkdownViews(store, { scope_type: typeof params.scope_type === "string" ? params.scope_type : undefined, scope_id: typeof params.scope_id === "string" ? params.scope_id : undefined, visibility: readVisibility(params), limit: typeof params.limit === "number" ? params.limit : undefined, }); const text = [ `Exported Markdown views to ${result.dir}`, ...result.files.map((file) => `- ${file.name}: ${file.count}`), ].join("\n"); return { content: [{ type: "text", text }], details: { action: "loom_views", ...result }, }; } /** * Query-time evidence distillation (DeferMem-inspired). * High-recall search → compact, query-conditioned evidence summary. * Unlike loom_search (returns raw memories), loom_evidence synthesizes * a 1-3 sentence answer grounded in retrieved facts. */ export async function handleLoomEvidence( store: LoomStore, params: Record, ctx?: ExtensionContext | null, ): Promise { const query = typeof params.query === "string" ? params.query : ""; if (!query) { return { content: [{ type: "text", text: "ERROR: query required." }], details: {} }; } // Broad recall — high recall, get more candidates than usual let queryEmbedding: number[] | undefined; if (store.vecLoaded) { const emb = await embedText(query); if (emb.length > 0) queryEmbedding = emb; } const candidates = store.searchHybrid({ query, queryEmbedding, entity_id: params.entity_id as string | undefined, visibility: readVisibility(params), limit: 15, compact: true, }); if (candidates.length === 0) { return { content: [{ type: "text", text: "No relevant memories found." }], details: { count: 0 } }; } // Build evidence lines with fact summaries, skip file-edit noise const evidence = candidates .filter((c) => { if (c.importance < 0.4) return false; const t = (() => { try { return JSON.parse(c.tags || "[]"); } catch { return []; } })(); if (t.includes("file") && (t.includes("edited") || t.includes("wrote")) && c.importance < 0.8) return false; return true; }) .slice(0, 8) .map((c, i) => { const text = (c.fact_summary || c.content).replace(/\n/g, " ").slice(0, 150); return `[E${i + 1}] ${text} (source: #${c.id.slice(0, 6)})`; }); // If model available, synthesize; otherwise return raw evidence let synthesis = ""; try { const model = await import("./model.js"); const resolved = model.resolveModelForLLM(ctx ?? null, {}, "PI_FACT_MODEL", "deepseek/deepseek-v3.1"); if (resolved) { const auth = ctx ? await (ctx as any).modelRegistry.getApiKeyAndHeaders(resolved) : model.resolveMcpAuth(resolved); if (auth?.ok && auth.apiKey) { const prompt = [ "Given the query and retrieved memory evidence, produce a concise 1-3 sentence answer.", "Be specific. Cite source IDs in parentheses. If evidence is insufficient, say so.", "", `## Query: ${query}`, "", `## Evidence (${evidence.length} items):`, evidence.join("\n"), "", 'Output JSON: {"answer": "1-3 sentence synthesis", "sources": ["#mem_a", ...]}', ].join("\n"); type EvJSON = { answer: string; sources: string[] }; const parsed = await model.callLLMForJSON(resolved, prompt, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 512, }); if (parsed?.[0]?.answer) { synthesis = parsed[0].answer; } } } } catch { /* synthesis optional — raw evidence is still useful */ } const text = synthesis ? `${synthesis}\n\n_Sources_: ${evidence .map((e) => e.match(/#\w+/)?.[0] ?? "") .filter(Boolean) .join(", ")}` : evidence.join("\n"); return { content: [{ type: "text", text }], details: { action: "loom_evidence", candidates: candidates.length, synthesized: !!synthesis }, }; } function buildModelConfig(params: Record): { modelProvider?: string; modelId?: string } { const config: { modelProvider?: string; modelId?: string } = {}; if (typeof params.model === "string") { const [provider = "openai", modelId] = params.model.split("/"); config.modelProvider = modelId ? provider : "openai"; config.modelId = modelId || provider; } return config; } // ═══════════════════════════════════════════════════════════════ // v1.0: Path-conditioned constraint checker // ═══════════════════════════════════════════════════════════════ export function handleLoomCheckPath(store: LoomStore, params: Record): ToolResult { const violations = store.checkPathConstraints({ entity_id: params.entity_id as string | undefined, session_id: params.session_id as string | undefined, }); if (violations.length === 0) { return { content: [{ type: "text", text: "No path-conditioned constraint violations." }], details: { action: "loom_check_path", violations: 0 }, }; } // v1.1: auto-store block-level violations as high-importance memories // so they persist across sessions and appear in future retrievals let autoStored = 0; for (const v of violations) { try { store.store({ content: `Constraint violated: ${v.description} — ${v.violation_detail}`, entity_id: v.entity_id, importance: v.enforcement === "block" ? 0.85 : 0.6, tags: ["constraint-violation", v.enforcement, `entity:${v.entity_id}`], provenance: "auto_captured", }); autoStored++; } catch { /* best-effort */ } } const text = violations .map((v) => `[${v.enforcement.toUpperCase()}] ${v.entity_id}: ${v.description}\n → ${v.violation_detail}`) .join("\n\n"); return { content: [{ type: "text", text }], details: { action: "loom_check_path", violations: violations.length, worst: violations.some((v) => v.enforcement === "block") ? "block" : "warn", auto_stored: autoStored, }, }; } /** * Store a path-conditioned constraint. Called by agents that want to * set up runtime guardrails against raw_events. */ export function handleLoomConstrain(store: LoomStore, params: Record): ToolResult { if (typeof params.entity_id !== "string" || typeof params.description !== "string") { return { content: [{ type: "text", text: "ERROR: entity_id and description required." }], details: {} }; } const c = store.storeConstraint({ entity_id: params.entity_id, description: params.description, path_condition: params.path_condition as string | undefined, enforcement: params.enforcement as "warn" | "block" | "log" | undefined, }); const condText = c.path_condition ? ` (${c.path_condition})` : ""; return { content: [ { type: "text", text: `Constraint #${c.id.slice(0, 8)}: ${c.enforcement}${condText} — ${c.description}` }, ], details: { action: "loom_constrain", constraint_id: c.id, entity_id: c.entity_id }, }; } // ═══════════════════════════════════════════════════════════════ // Context Offloading — symbolic short-term memory (Mermaid) // ═══════════════════════════════════════════════════════════════ export function handleLoomOffload(store: LoomStore, params: Record): ToolResult { if (typeof params.content !== "string") { return { content: [{ type: "text", text: "ERROR: content required" }], details: {} }; } const sessionId = (params.session_id as string) || "default"; const label = (params.label as string) || "offload"; const maxInline = typeof params.max_inline === "number" ? params.max_inline : 500; const result = offloadText(params.content, sessionId, label, maxInline); store.store({ content: `[REF:${result.nodeId}] ${label}: offloaded ${(result.originalBytes / 1024).toFixed(1)}KB`, entity_id: (params.entity_id as string) || `session:${sessionId}`, importance: 0.3, tags: ["offload", `session:${sessionId}`, label], provenance: "auto_captured", }); return { content: [ { type: "text", text: `Offloaded ${(result.originalBytes / 1024).toFixed(1)}KB → ${result.nodeId}\n ${result.refString}`, }, ], details: { action: "loom_offload", node_id: result.nodeId, bytes: result.originalBytes }, }; } export function handleLoomOffloadRecall(params: Record): ToolResult { if (typeof params.node_id !== "string") { return { content: [{ type: "text", text: "ERROR: node_id required" }], details: {} }; } const content = retrieveOffload(params.node_id, params.session_id as string | undefined); if (!content) { return { content: [{ type: "text", text: `No content for node_id=${params.node_id}` }], details: { found: false } }; } const truncated = content.length > 8000 ? `${content.slice(0, 7997)}...` : content; return { content: [{ type: "text", text: truncated }], details: { action: "loom_offload_recall", node_id: params.node_id, total_chars: content.length, truncated: content.length > 8000, }, }; } export function handleLoomMermaid(_store: LoomStore, params: Record): ToolResult { const sessionId = (params.session_id as string) || "default"; const graph = generateMermaidGraph(sessionId); if (!graph) { return { content: [{ type: "text", text: `No refs for session ${sessionId}. Use loom_offload first.` }], details: { count: 0 }, }; } const refs = listSessionRefs(sessionId); return { content: [ { type: "text", text: `## Mermaid Task Graph (${refs.length} nodes)\n\n${graph}\n\nNodes: ${refs.map((r) => r.nodeId).join(", ")}`, }, ], details: { action: "loom_mermaid", session_id: sessionId, nodes: refs.length }, }; }