import { Database } from 'bun:sqlite' /** * LearnedMemoryStore - Learned memory storage * DD ยง11.3. INV-2: single writer; outbox model. * * @module packages/runtime/src/knowledge/LearnedMemoryStore */ import { existsSync, mkdirSync } from 'fs' import { join } from 'path' export interface MemoryEntry { id: string memory_type: 'project_rule' | 'toolchain_rule' | 'skill_update' | 'debug_experience' summary: string content: string source_entity_type?: string source_entity_id?: string status: 'candidate' | 'promoted' | 'archived' | 'rejected' created_at: string updated_at: string metadata_json?: string } export class LearnedMemoryStore { private db: Database | null = null private db_path: string constructor(project_root: string) { this.db_path = join(project_root, '.air', 'local', 'learned-memory.db') } open(): void { const dir = join(this.db_path, '..') if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) this.db = new Database(this.db_path) this.db.exec('PRAGMA journal_mode = WAL') this.db.exec('PRAGMA synchronous = NORMAL') this.db.exec('PRAGMA foreign_keys = OFF') this.db.exec(` CREATE TABLE IF NOT EXISTS learned_memories ( id TEXT PRIMARY KEY, memory_type TEXT NOT NULL, summary TEXT NOT NULL, content TEXT NOT NULL, source_entity_type TEXT, source_entity_id TEXT, status TEXT DEFAULT 'candidate', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, metadata_json TEXT ); CREATE INDEX IF NOT EXISTS idx_memory_type ON learned_memories(memory_type); CREATE INDEX IF NOT EXISTS idx_memory_status ON learned_memories(status); `) } /** * Insert a memory entry. * INV-2: External write first, then emit memory.promoted via outbox. */ insert(entry: MemoryEntry): void { if (!this.db) throw new Error('Store not opened') const stmt = this.db.prepare(` INSERT INTO learned_memories (id, memory_type, summary, content, source_entity_type, source_entity_id, status, created_at, updated_at, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) stmt.run(entry.id, entry.memory_type, entry.summary, entry.content, entry.source_entity_type, entry.source_entity_id, entry.status, entry.created_at, entry.updated_at, entry.metadata_json) } lookup_by_type(memory_type: string): MemoryEntry[] { if (!this.db) return [] return this.db.prepare('SELECT * FROM learned_memories WHERE memory_type = ? AND status != ? ORDER BY created_at DESC').all(memory_type, 'archived') as MemoryEntry[] } update_status(id: string, status: 'candidate' | 'promoted' | 'archived' | 'rejected'): void { if (!this.db) return const updated_at = new Date().toISOString() this.db.prepare('UPDATE learned_memories SET status = ?, updated_at = ? WHERE id = ?').run(status, updated_at, id) } scan_stale(days_stale: number = 90): MemoryEntry[] { if (!this.db) return [] const cutoff = new Date(Date.now() - days_stale * 86400000).toISOString() return this.db.prepare('SELECT * FROM learned_memories WHERE status = ? AND updated_at < ?').all('promoted', cutoff) as MemoryEntry[] } }