/** * pi-loom: Sub-store modules — decomposed from LoomStore * * Each sub-store owns a single table or closely-related table group. * They take a better-sqlite3 Database in the constructor, so they can * be used standalone (testing, migration) or composed into LoomStore. */ import type Database from "better-sqlite3"; import type { ConstraintRow, EpisodeRow, RawEventRow, SessionSummaryRow } from "./store.js"; // Local genId — same algorithm as store.ts function genId(): string { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } // ═══════════════════════════════════════════════════════════════ // RawEventStore — raw_events table (audit log, zero LLM cost) // ═══════════════════════════════════════════════════════════════ export class RawEventStore { constructor(private db: Database.Database) {} store(params: { session_id: string; event_type: string; payload: Record }): string { const id = genId(); this.db .prepare(` INSERT INTO raw_events (id, session_id, event_type, payload) VALUES (?, ?, ?, ?) `) .run(id, params.session_id, params.event_type, JSON.stringify(params.payload)); return id; } audit(params: { session_id?: string; event_type?: string; limit?: number; offset?: number }): RawEventRow[] { const limit = params.limit ?? 50; const offset = params.offset ?? 0; let sql = "SELECT * FROM raw_events WHERE 1=1"; const args: (string | number)[] = []; if (params.session_id) { sql += " AND session_id = ?"; args.push(params.session_id); } if (params.event_type) { sql += " AND event_type = ?"; args.push(params.event_type); } sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?"; args.push(limit, offset); return this.db.prepare(sql).all(...args) as RawEventRow[]; } recentSessions(limit = 10): string[] { return ( this.db .prepare( "SELECT session_id, MAX(created_at) as max_ts FROM raw_events GROUP BY session_id ORDER BY max_ts DESC LIMIT ?", ) .all(limit) as Array<{ session_id: string }> ).map((r) => r.session_id); } count(sessionId: string): number { const row = this.db.prepare("SELECT COUNT(*) as cnt FROM raw_events WHERE session_id = ?").get(sessionId) as { cnt: number; }; return row.cnt; } getAsText(sessionId: string, maxEvents = 200): string { const rows = this.db .prepare( "SELECT event_type, payload, created_at FROM raw_events WHERE session_id = ? ORDER BY created_at ASC LIMIT ?", ) .all(sessionId, maxEvents) as Array<{ event_type: string; payload: string; created_at: string }>; const lines: string[] = []; for (const r of rows) { const payload = JSON.parse(r.payload) as Record; const ts = r.created_at.slice(11, 19); if (r.event_type === "tool_result") { const name = payload.toolName as string; const input = JSON.stringify(payload.input ?? {}).slice(0, 100); const output = typeof payload.output === "string" ? (payload.output as string).slice(0, 200) : ""; const isErr = payload.isError ? " [ERROR]" : ""; lines.push(`[${ts}] ${name}${isErr} | input: ${input}`); if (output) lines.push(` → ${output}`); } else if (r.event_type === "user_message") { const content = String(payload.content ?? "").slice(0, 200); lines.push(`[${ts}] USER: ${content}`); } else { lines.push(`[${ts}] ${r.event_type}: ${JSON.stringify(payload).slice(0, 200)}`); } } return lines.join("\n"); } purge(): number { const TTL = process.env.RAW_EVENT_TTL_DAYS ? parseInt(process.env.RAW_EVENT_TTL_DAYS, 10) : 30; const result = this.db.prepare(`DELETE FROM raw_events WHERE created_at < datetime('now', '-${TTL} days')`).run(); return result.changes; } } // ═══════════════════════════════════════════════════════════════ // EpisodeStore — episodes + session_summaries tables // ═══════════════════════════════════════════════════════════════ export class EpisodeStore { constructor(private db: Database.Database) {} // ── Session summaries ───────────────────────────────── storeSummary(params: { session_id: string; summary: string; decisions?: string[]; errors?: string[]; changes?: string[]; unfinished?: string[]; memory_ids?: string[]; }): string { const id = genId(); this.db .prepare(` INSERT INTO session_summaries (id, session_id, summary, decisions, errors, changes, unfinished, memory_ids) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `) .run( id, params.session_id, params.summary, params.decisions ? JSON.stringify(params.decisions) : null, params.errors ? JSON.stringify(params.errors) : null, params.changes ? JSON.stringify(params.changes) : null, params.unfinished ? JSON.stringify(params.unfinished) : null, params.memory_ids ? JSON.stringify(params.memory_ids) : null, ); return id; } getSummary(sessionId: string): SessionSummaryRow | undefined { return this.db .prepare("SELECT * FROM session_summaries WHERE session_id = ? ORDER BY created_at DESC LIMIT 1") .get(sessionId) as SessionSummaryRow | undefined; } recentSummaries(limit = 10): SessionSummaryRow[] { return this.db .prepare("SELECT * FROM session_summaries ORDER BY created_at DESC LIMIT ?") .all(limit) as SessionSummaryRow[]; } updateSummaryMemories(sid: string, memIds: string[]): void { this.db.prepare("UPDATE session_summaries SET memory_ids = ? WHERE id = ?").run(JSON.stringify(memIds), sid); } // ── Episodes ──────────────────────────────────────── create(params: { session_id: string; summary?: string; entity_id?: string; token_count?: number }): string { const id = genId(); this.db .prepare(` INSERT INTO episodes (episode_id, session_id, summary, entity_id, token_count) VALUES (?, ?, ?, ?, ?) `) .run(id, params.session_id, params.summary ?? null, params.entity_id ?? null, params.token_count ?? null); return id; } timeline(entityId: string, limit = 20): EpisodeRow[] { return this.db .prepare("SELECT * FROM episodes WHERE entity_id = ? ORDER BY created_at DESC LIMIT ?") .all(entityId, limit) as EpisodeRow[]; } countForEntities(entityIds: string[]): Map { const result = new Map(); for (const eid of entityIds) { const row = this.db.prepare("SELECT COUNT(*) as cnt FROM episodes WHERE entity_id = ?").get(eid) as { cnt: number; }; if (row.cnt > 0) result.set(eid, row.cnt); } return result; } } // ═══════════════════════════════════════════════════════════════ // ConstraintStore — path-conditioned constraints table // ═══════════════════════════════════════════════════════════════ export class ConstraintStore { constructor(private db: Database.Database) {} store(params: { entity_id: string; description: string; path_condition?: string; enforcement?: "warn" | "block" | "log"; }): ConstraintRow { const id = `cons_${genId()}`; this.db .prepare( `INSERT INTO constraints (id, entity_id, description, path_condition, enforcement, state) VALUES (?, ?, ?, ?, ?, 'active')`, ) .run(id, params.entity_id, params.description, params.path_condition ?? null, params.enforcement ?? "warn"); return this.db.prepare("SELECT * FROM constraints WHERE id = ?").get(id) as ConstraintRow; } check(params?: { entity_id?: string; session_id?: string }): Array { const rows = params?.entity_id ? (this.db .prepare( "SELECT * FROM constraints WHERE entity_id = ? AND state != 'archived' AND path_condition IS NOT NULL", ) .all(params.entity_id) as ConstraintRow[]) : (this.db .prepare("SELECT * FROM constraints WHERE state != 'archived' AND path_condition IS NOT NULL") .all() as ConstraintRow[]); const violations: Array = []; for (const c of rows) { const result = this._eval(c); if (result.violated) { violations.push({ ...c, violation_detail: result.detail }); } this.db.prepare("UPDATE constraints SET last_checked_at = datetime('now') WHERE id = ?").run(c.id); } return violations; } list(entityId: string): ConstraintRow[] { return this.db .prepare("SELECT * FROM constraints WHERE entity_id = ? ORDER BY created_at DESC") .all(entityId) as ConstraintRow[]; } /** Parse and evaluate a single path_condition string. */ private _eval(c: ConstraintRow): { violated: boolean; detail: string } { if (!c.path_condition) return { violated: false, detail: "static" }; const parts = c.path_condition.split(","); const condPart = parts[0]; let windowSec = 300; for (const p of parts) { if (p.startsWith("window=")) windowSec = parseInt(p.slice(7), 10) || 300; } const match = condPart.match(/^(.+)\s*(>=|>|<|<=|==)\s*(\d+)$/); if (!match) return { violated: false, detail: `unparseable: ${c.path_condition}` }; const key = match[1].trim(); const op = match[2]; const threshold = parseInt(match[3], 10); const wantsError = key.endsWith(" error"); const prefix = wantsError ? key.slice(0, -6).trim() : key; // Use SQLite format (YYYY-MM-DD HH:MM:SS) for string comparison with datetime('now') const since = new Date(Date.now() - windowSec * 1000).toISOString().replace("T", " ").split(".")[0]; let count: number; if (wantsError) { count = ( this.db .prepare( `SELECT COUNT(*) as cnt FROM raw_events WHERE event_type LIKE ? AND created_at >= ? AND payload LIKE '%"isError":true%'`, ) .get(`${prefix}%`, since) as { cnt: number } ).cnt; } else { count = ( this.db .prepare( `SELECT COUNT(*) as cnt FROM raw_events WHERE event_type LIKE ? AND created_at >= ?`, ) .get(`${prefix}%`, since) as { cnt: number } ).cnt; } let violated = false; switch (op) { case ">=": violated = count >= threshold; break; case ">": violated = count > threshold; break; case "<=": violated = count <= threshold; break; case "<": violated = count < threshold; break; case "==": violated = count === threshold; break; } return { violated, detail: violated ? `${key} count=${count} ${op} ${threshold} (window=${windowSec}s) — VIOLATED` : `${key} count=${count} ${op} ${threshold} (window=${windowSec}s) — ok`, }; } }