/** * pi-loom: GraphProvider — pluggable entity-graph interface * * Instead of maintaining its own entity_edges table that duplicates pi-esr's * entity graph, pi-loom delegates all graph operations through this interface. * * Default: SqliteGraphProvider wraps the existing entity_edges table (backward * compatible). Future: EsrGraphProvider queries pi-esr's graph natively. * * This is the strangler-fig step toward true loose coupling: * 1. Define the interface (this file) * 2. Inject via LoomStore constructor * 3. Eventually remove entity_edges table when ESR provider is stable */ /** A typed edge between two ESR entities. */ export interface EntityEdge { source_entity: string; target_entity: string; relation_type: string; confidence: number; } /** One side of an entity relation, as returned by getRelatedEntities. */ export interface RelatedEntity { entity: string; relation_type: string; confidence: number; direction: "out" | "in"; } /** Pluggable entity-graph provider. */ export interface GraphProvider { readonly name: string; /** Create or update a relation between two entities. Returns the edge id. */ linkEntities(params: { source_entity: string; target_entity: string; relation_type: string; memory_id?: string; episode_id?: string; confidence?: number; }): string; /** Get all entities related to entityId (both incoming and outgoing). */ getRelatedEntities(entityId: string): RelatedEntity[]; /** * BFS traversal from startEntities up to maxHops. * Returns all entity IDs reachable, including the seeds. */ traverseGraph(startEntities: string[], maxHops?: number): string[]; /** Return all edges for graph-boost scoring during hybrid search. */ getAllEdges(): EntityEdge[]; } // ═══════════════════════════════════════════════════════════════ // Default implementation: wraps the existing entity_edges table // ═══════════════════════════════════════════════════════════════ import type Database from "better-sqlite3"; export class SqliteGraphProvider implements GraphProvider { readonly name = "sqlite-entity-edges"; constructor(private db: Database.Database) {} linkEntities(params: { source_entity: string; target_entity: string; relation_type: string; memory_id?: string; episode_id?: string; confidence?: number; }): string { const existing = this.db .prepare( `SELECT edge_id, confidence FROM entity_edges WHERE source_entity = ? AND target_entity = ? AND relation_type = ? LIMIT 1`, ) .get(params.source_entity, params.target_entity, params.relation_type) as | { edge_id: string; confidence: number } | undefined; if (existing) { const newConf = Math.max(existing.confidence, params.confidence ?? 0.5); this.db.prepare("UPDATE entity_edges SET confidence = ? WHERE edge_id = ?").run(newConf, existing.edge_id); return existing.edge_id; } const id = `edge_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; this.db .prepare(` INSERT INTO entity_edges (edge_id, source_entity, target_entity, relation_type, memory_id, episode_id, confidence) VALUES (?, ?, ?, ?, ?, ?, ?) `) .run( id, params.source_entity, params.target_entity, params.relation_type, params.memory_id ?? null, params.episode_id ?? null, params.confidence ?? 0.5, ); return id; } getRelatedEntities(entityId: string): RelatedEntity[] { const out = ( this.db .prepare("SELECT target_entity as entity, relation_type, confidence FROM entity_edges WHERE source_entity = ?") .all(entityId) as Array<{ entity: string; relation_type: string; confidence: number }> ).map((r) => ({ ...r, direction: "out" as const })); const inp = ( this.db .prepare("SELECT source_entity as entity, relation_type, confidence FROM entity_edges WHERE target_entity = ?") .all(entityId) as Array<{ entity: string; relation_type: string; confidence: number }> ).map((r) => ({ ...r, direction: "in" as const })); return [...out, ...inp]; } traverseGraph(startEntities: string[], maxHops = 2): string[] { if (startEntities.length === 0) return []; const visited = new Set(startEntities); const frontier = [...startEntities]; const allEdges = this.db .prepare("SELECT source_entity, target_entity, relation_type FROM entity_edges") .all() as Array<{ source_entity: string; target_entity: string; relation_type: string }>; const adj = new Map(); for (const e of allEdges) { if (!adj.has(e.source_entity)) adj.set(e.source_entity, []); if (!adj.has(e.target_entity)) adj.set(e.target_entity, []); adj.get(e.source_entity)!.push(e.target_entity); adj.get(e.target_entity)!.push(e.source_entity); } for (let hop = 0; hop < maxHops; hop++) { const next: string[] = []; for (const eid of frontier) { for (const neighbor of adj.get(eid) || []) { if (!visited.has(neighbor)) { visited.add(neighbor); next.push(neighbor); } } } frontier.length = 0; frontier.push(...next); } return Array.from(visited); } getAllEdges(): EntityEdge[] { return this.db .prepare("SELECT source_entity, target_entity, relation_type, confidence FROM entity_edges") .all() as EntityEdge[]; } }