/** * pi-loom: LoomMemoryProvider — ESR memory-bridge adapter * * Implements ESRMemoryProvider from pi-esr's memory-bridge, allowing * pi-esr to use pi-loom as its memory backend. This means: * - esr_mem_store → writes to pi-loom's SQLite store * - esr_mem_recall → searches pi-loom's FTS5 index * - esr_attach_memory_ref → stores refs in pi-loom's entity_edges * * The provider wraps LoomStore directly (no HTTP/MCP layer needed * when running in-process), giving zero-latency memory access. */ import type { ESRMemoryEntityQuery, ESRMemoryJournalEntry, ESRMemoryJournalQuery, ESRMemoryProvider, ESRMemoryRecord, ESRMemoryRef, ESRMemorySearchInput, ESRMemoryStoreInput, ESRMemoryTimelineEntry, } from "./esr-types.js"; import type { LoomStore, MemRow } from "./store.js"; import { parseTags } from "./store.js"; /** * Adapts pi-loom's LoomStore to pi-esr's ESRMemoryProvider interface. */ export class LoomMemoryProvider implements ESRMemoryProvider { readonly name = "pi-loom"; constructor(private loomStore: LoomStore) {} async isAvailable(): Promise { try { this.loomStore.stats(); return true; } catch { return false; } } async store(input: ESRMemoryStoreInput): Promise { const mem = this.loomStore.store({ content: input.content, entity_id: input.entityId, importance: 0.6, tags: ["esr", input.kind, "memory-ref"], }); return { ref_id: mem.id, provider: this.name, entity_id: input.entityId, kind: input.kind, title: input.title ?? input.content.slice(0, 80), created_at: mem.created_at, metadata: input.metadata, }; } async search(input: ESRMemorySearchInput): Promise { const limit = input.limit ?? 20; let results: MemRow[]; if (input.entityId && input.query) { // Hybrid: entity-scoped search results = this.loomStore.searchHybrid({ query: input.query, entity_id: input.entityId, limit, compact: true, }); } else if (input.entityId) { results = this.loomStore.recallByEntity(input.entityId, limit); } else if (input.query) { results = this.loomStore.searchHybrid({ query: input.query, limit, compact: true, }); } else { results = this.loomStore.recallActive(limit); } return results.map((r) => this._memToRef(r)); } async listByEntity(input: ESRMemoryEntityQuery): Promise { const results = this.loomStore.recallByEntity(input.entityId, input.limit ?? 20); return results.map((r) => this._memToRef(r)); } async timeline(input: ESRMemoryEntityQuery): Promise { const results = this.loomStore.recallByEntity(input.entityId, input.limit ?? 50); return results.map((r) => ({ ref: this._memToRef(r), content: r.content, })); } async count(entityId?: string): Promise { if (entityId) { return this.loomStore.countByEntity(entityId); } return this.loomStore.stats().active; } async recordJournal(entityId: string, transition: string, _metadata?: Record): Promise { // Create an episode as a journal entry proxy this.loomStore.createEpisode({ session_id: `esr_${Date.now().toString(36)}`, summary: transition, entity_id: entityId, }); } async getJournal(input: ESRMemoryJournalQuery): Promise { const episodes = this.loomStore.getTimeline(input.entityId, input.limit ?? 30); return episodes.map((e) => ({ entity_id: e.entity_id ?? "", transition: e.summary ?? "", created_at: e.created_at, })); } async getAllJournal(limit?: number): Promise { const sessions = this.loomStore.recentSessionSummaries(limit ?? 30); return sessions.map((s) => ({ entity_id: s.session_id, transition: s.summary?.slice(0, 200) ?? "", created_at: s.created_at, })); } async fetch(refs: ESRMemoryRef[]): Promise { return refs .map((ref) => { const mem = this.loomStore.get(ref.ref_id); if (!mem) return null; return { ref, content: mem.content }; }) .filter((r): r is ESRMemoryRecord => r !== null); } async render(refs: ESRMemoryRef[]): Promise { const records = await this.fetch(refs); if (records.length === 0) return "(no memories)"; return records .map((r) => `[${r.ref.entity_id}] ${r.ref.created_at?.slice(0, 16) ?? ""} [${r.ref.kind}] ${r.content}`) .join("\n"); } // ── Private helpers ────────────────────────────────────── private _memToRef(mem: MemRow): ESRMemoryRef { const tags = parseTags(mem.tags); const kind = tags.includes("error") ? "incident" : tags.includes("decision") ? "decision" : tags.includes("summary") ? "summary" : "note"; return { ref_id: mem.id, provider: this.name, entity_id: mem.entity_id ?? "", kind, title: mem.fact_summary ?? mem.content.slice(0, 80), created_at: mem.created_at, metadata: { importance: mem.importance, tags, }, }; } }