/** * pi-loom Context Injection — Symbolic Index + Progressive Disclosure * * Phase 3: Instead of raw text blocks, inject a compact symbolic index. * Each line is ~60-100 tokens with type prefix, date, entity, and memory_id. * Agent drills down via MCP: loom_detail, loom_timeline, loom_episode. * * Format: * [PI_LOOM] * ## Context * [D] 2026-06-15 | Arch: use sqlite-vec for search | loom-arch | #mem_a1b2c3 * [E] 2026-06-14 | CI: type error in handlers.ts → fixed | loom-bug | #mem_d4e5f6 * [M] 2026-06-14 | Modified src/store.ts: added embedding column | #mem_g7h8i9 * [I] Dream: "FTS5-only misses 40% of semantic queries" | #insight_j0k1 * * Prefix legend: * [H] Handoff — task/session transfer state * [P] Procedure/Profile — reusable playbooks or entity portraits * [D] Decision — architecture, implementation, strategy choices * [E] Error — tool errors, unexpected outcomes * [M] Memory — general facts, changes, observations * [I] Insight — Dream Engine discoveries * * Token budget: ~500-600 tokens. Falls back to minimal format when tight. */ import type { DerivationLink, LoomStore, MemoryEdgeRow, MemRow, VisibilityFilter } from "./store.js"; import { memoryEdgeExpansionWeight } from "./memory-edges.js"; import { parseTags } from "./store.js"; const MAX_LINE = 56; // chars per line (tighter for v2 compact) function classify(mem: MemRow, tags: string[]): "D" | "E" | "H" | "I" | "P" | "M" { if (mem.kind === "handoff") return "H"; if (mem.kind === "procedure") return "P"; for (const t of tags) { if ( t === "decision" || t === "architecture" || t === "principle" || (t === "session-summary" && tags.includes("decisions")) ) return "D"; if (t === "error" || t === "bug-fix") return "E"; if (t === "insight" || t === "dream") return "I"; } return "M"; } function fmtDate(iso: string): string { return iso.slice(5, 10); // "06-15" (skip year, saves 3 chars) } function fmtLine(mem: MemRow): string { const tags = parseTags(mem.tags); const prefix = classify(mem, tags); const date = fmtDate(mem.created_at); const text = (mem.fact_summary || mem.content).replace(/\n/g, " ").slice(0, MAX_LINE).trim(); const mid = `#${mem.id.slice(0, 6)}`; // v1.1: evidence quality marker const q = evidenceMarker(mem); return `[${prefix}] ${date} ${text} ${mid}${q}`; } /** v1.1: Evidence quality marker — makes derivation info visible in [PI_LOOM]. */ function evidenceMarker(mem: MemRow): string { if (!mem.derivation || mem.derivation === "[]") return ""; try { const d: DerivationLink[] = JSON.parse(mem.derivation); let mark = ""; if (d.some((l) => l.type === "event")) mark += "·e"; if (mem.provenance === "consolidated_pattern" && d.length >= 3) mark += "·c"; const t = parseTags(mem.tags); if (t.includes("consolidation-degraded")) mark += "·↓"; return mark; } catch { return ""; } } function fmtInsight(i: { id: string; content: string; importance: number; entity_id: string | null }): string { const text = i.content.replace(/\n/g, " ").slice(0, MAX_LINE).trim(); const mid = `#${i.id.slice(0, 6)}`; return `[I] ${text} c${i.importance.toFixed(1)} ${mid}`; } function inScope(mem: MemRow, opts?: { scope_type?: string; scope_id?: string }): boolean { if (!opts?.scope_type && !opts?.scope_id) return true; if (mem.scope_type === "user") return true; if (!mem.scope_id) return true; if (opts.scope_type && mem.scope_type !== opts.scope_type) return false; if (opts.scope_id && mem.scope_id !== opts.scope_id) return false; return true; } function inVisibility(mem: MemRow, visibility?: VisibilityFilter): boolean { if (visibility === "private") return mem.visibility === "private"; if (visibility === "shared") return mem.visibility === "shared"; return mem.visibility !== "private"; } function relatedContextRows( store: LoomStore, seedIds: Set, emittedIds: Set, opts?: { scope_type?: string; scope_id?: string; visibility?: VisibilityFilter }, ): MemRow[] { const byId = new Map(); for (const seedId of seedIds) { for (const edge of store.getMemoryEdges(seedId)) { if (memoryEdgeExpansionWeight(edge.relation) <= 0 || edge.confidence < 0.5) continue; const relatedId = edge.source_id === seedId ? edge.target_id : edge.source_id; if (emittedIds.has(relatedId) || byId.has(relatedId)) continue; const mem = store.get(relatedId); if (!mem || mem.status !== "active" || !inScope(mem, opts) || !inVisibility(mem, opts?.visibility)) continue; byId.set(relatedId, { mem, edge }); } } return [...byId.values()] .sort( (a, b) => memoryEdgeExpansionWeight(b.edge.relation) * b.edge.confidence - memoryEdgeExpansionWeight(a.edge.relation) * a.edge.confidence || b.mem.importance - a.mem.importance, ) .map((entry) => entry.mem); } /** * Build symbolic index for context injection. * Returns "" when store has no data — caller should skip injection. * * Budget controls (TDAM-inspired): * - maxCharsPerMemory: truncate individual lines to N chars (default: unlimited) * - maxTotalChars: hard cap on total output characters (default: from PI_LOOM_MAX_TOTAL_CHARS or unlimited) * - maxTokens: soft cap on estimated tokens (existing) * * Priority order: handoffs → procedures → profiles → insights → decisions/errors → recent high-importance. * When budget is tight, lower-priority sections are trimmed or dropped entirely. */ export function buildLoomContext( store: LoomStore, opts?: { factsCount?: number; keyCount?: number; recentCount?: number; maxTokens?: number; /** Optional scope for task/repo-aware context planning. */ scope_type?: string; scope_id?: string; /** Visibility boundary. Defaults to project/shared, excluding private. */ visibility?: VisibilityFilter; /** Max characters per memory line. Lines exceeding this are truncated with "…". */ maxCharsPerMemory?: number; /** Hard cap: total output characters. If exceeded, lowest-priority lines are dropped. */ maxTotalChars?: number; }, ): string { const maxTokens = opts?.maxTokens ?? 350; const maxCharsPerMem = opts?.maxCharsPerMemory ?? 0; // 0 = unlimited const maxTotalChars = opts?.maxTotalChars ?? (typeof process !== "undefined" && process.env.PI_LOOM_MAX_TOTAL_CHARS ? parseInt(process.env.PI_LOOM_MAX_TOTAL_CHARS, 10) : 0); // 0 = unlimited const lines: string[] = ["[PI_LOOM]"]; const emittedIds = new Set(); const seedIds = new Set(); let estTokens = 5; // header let totalChars = "[PI_LOOM]".length; // char-level tracking // ── Session context ── const headerLine = " Recent memories auto-injected below. Use loom_recall() to search deeper."; lines.push("[PI_LOOM]", headerLine, ""); estTokens += 15; totalChars += "[PI_LOOM]".length + headerLine.length + 3; // +3 for newlines // ── Helper: apply budget controls to a candidate line ── const budgetFit = (line: string, priority: "high" | "medium" | "low" = "medium"): boolean => { let finalLine = line; // Per-memory char limit if (maxCharsPerMem > 0 && finalLine.length > maxCharsPerMem) { finalLine = `${finalLine.slice(0, maxCharsPerMem - 1)}…`; } const lineTokens = Math.ceil(finalLine.length / 4); const lineChars = finalLine.length + 1; // +1 for newline // Hard char cap: always accept high-priority, may reject lower if (maxTotalChars > 0) { if (priority === "high") { // High-priority always fits (even if it pushes over the cap) } else if (totalChars + lineChars > maxTotalChars) { return false; } } // Soft token cap if (estTokens + lineTokens > maxTokens - (priority === "high" ? 10 : 20)) { return false; } lines.push(finalLine); estTokens += lineTokens; totalChars += lineChars; return true; }; const emitMem = (mem: MemRow, priority: "high" | "medium" | "low" = "medium", suffix = ""): boolean => { const ok = budgetFit(`${fmtLine(mem)}${suffix}`, priority); if (ok) { emittedIds.add(mem.id); if (priority !== "low") seedIds.add(mem.id); } return ok; }; // ── Handoffs (task/session state transfer) ── const handoffs = store.recallByKind("handoff", 2, opts?.scope_type, opts?.scope_id, opts?.visibility); for (const h of handoffs) { if (!emitMem(h, "high")) break; } // ── Procedures (reusable coding playbooks) ── const procedures = store.recallByKind("procedure", 3, opts?.scope_type, opts?.scope_id, opts?.visibility); for (const p of procedures) { if (!emitMem(p, "high")) break; } // ── Profiles (aggregated entity portraits, TriMem-inspired) ── const profiles = store.getAllProfiles(2, opts?.visibility).filter((p) => inScope(p, opts)); for (const p of profiles) { const text = p.content.replace(/\n/g, " ").slice(0, MAX_LINE).trim(); const line = `[P] ${p.entity_id}: ${text} #${p.id.slice(0, 6)}`; if (!budgetFit(line, "high")) break; emittedIds.add(p.id); seedIds.add(p.id); } // ── Insights (distilled knowledge) ── const insights = store.getInsights(2, opts?.visibility).filter((i) => inScope(i as MemRow, opts)); for (const i of insights) { const line = fmtInsight(i); if (!budgetFit(line, "high")) break; emittedIds.add(i.id); seedIds.add(i.id); } // ── Priority: decisions & errors (high-signal) ── // v1.0: exclude dream_insight from fact/index lines (they're output-only, shown via [I]/[P] sections) const ctxProvenanceFilter = (m: MemRow) => m.provenance !== "dream_insight"; const priorityTags = ["decision", "architecture", "principle", "error", "session-summary"]; const priorityMems = store .recallByTags(priorityTags, 8, opts?.visibility) .filter(ctxProvenanceFilter) .filter((m) => inScope(m, opts)); const priorityIds = new Set(priorityMems.map((m) => m.id)); // v2: Merge same-entity memories into a single line (saves ~60 tokens on 10-entity clusters) const mergedMap = new Map(); for (const m of priorityMems) { const eid = m.entity_id || ""; if (!eid) { // No entity → emit individually if (!emitMem(m, "medium")) break; continue; } const existing = mergedMap.get(eid); if (existing) { existing.count++; if (m.importance > existing.best.importance) existing.best = m; } else { mergedMap.set(eid, { best: m, count: 1 }); } } for (const [, { best, count }] of mergedMap) { if (!emitMem(best, "medium", count > 1 ? ` +${count - 1}` : "")) break; } // ── One-hop MemoryEdges: reuse existing graph without adding a subsystem ── for (const related of relatedContextRows(store, seedIds, emittedIds, opts).filter(ctxProvenanceFilter).slice(0, 3)) { if (!emitMem(related, "medium", " ↔")) break; } // ── Recent: fill remaining budget, only high importance ── const allActive = store.recallActive(20, opts?.visibility).filter((m) => inScope(m, opts)); const nonPriority = allActive.filter((m) => { if (priorityIds.has(m.id)) return false; if (m.importance < 0.6) return false; if (!ctxProvenanceFilter(m)) return false; // v2: Skip low-signal noise: auto-captured file edits, dump events const t = parseTags(m.tags); if (t.includes("file") && (t.includes("edited") || t.includes("wrote")) && m.importance < 0.8) return false; if (t.includes("dump") && m.importance < 0.7) return false; return true; }); nonPriority.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); for (const m of nonPriority) { if (emittedIds.has(m.id)) continue; if (!emitMem(m, "low")) break; } // ── Stats footer ── const stats = store.stats(); if (stats.active >= 8) { const degraded = stats.degraded > 0 ? ` · ${stats.degraded} degraded` : ""; const footer = `${stats.active} active${degraded}`; budgetFit(footer, "low"); } // Empty check: skip if no memories were added (only header + boilerplate) if (lines.length <= 4) return ""; // Budget summary at end when constraints are active if (maxTotalChars > 0 && totalChars >= maxTotalChars * 0.8) { lines.push(`(budget: ${totalChars}/${maxTotalChars} chars)`); } return lines.join("\n"); }