UNPKG

@stackmemoryai/stackmemory

Version:

Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a

613 lines (608 loc) 20.3 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { logger } from "../monitoring/logger.js"; import { DatabaseError, ErrorCode } from "../errors/index.js"; const DEFAULT_FRAME_LIMIT = 1e3; const DEFAULT_EVENT_LIMIT = 500; const DEFAULT_ANCHOR_LIMIT = 200; function safeJsonParse(json, fallback) { if (!json) return fallback; try { return JSON.parse(json); } catch { logger.warn("Failed to parse JSON, using fallback", { json: json.substring(0, 100) }); return fallback; } } class FrameDatabase { constructor(db) { this.db = db; } /** * Initialize database schema */ initSchema() { try { try { this.db.pragma("journal_mode = WAL"); } catch { } try { this.db.pragma("synchronous = NORMAL"); } catch { } try { this.db.pragma("foreign_keys = ON"); } catch { } this.db.exec(` CREATE TABLE IF NOT EXISTS frames ( frame_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, project_id TEXT NOT NULL, parent_frame_id TEXT, depth INTEGER NOT NULL DEFAULT 0, type TEXT NOT NULL, name TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'active', inputs TEXT DEFAULT '{}', outputs TEXT DEFAULT '{}', digest_text TEXT, digest_json TEXT DEFAULT '{}', created_at INTEGER NOT NULL DEFAULT (unixepoch()), closed_at INTEGER, retention_policy TEXT DEFAULT 'default', importance_score REAL DEFAULT 0.5, FOREIGN KEY (parent_frame_id) REFERENCES frames(frame_id) ); `); this.db.exec(` CREATE TABLE IF NOT EXISTS events ( event_id TEXT PRIMARY KEY, frame_id TEXT NOT NULL, run_id TEXT NOT NULL, seq INTEGER NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL DEFAULT '{}', ts INTEGER NOT NULL DEFAULT (unixepoch() * 1000), FOREIGN KEY (frame_id) REFERENCES frames(frame_id) ON DELETE CASCADE ); `); this.db.exec(` CREATE TABLE IF NOT EXISTS anchors ( anchor_id TEXT PRIMARY KEY, frame_id TEXT NOT NULL, project_id TEXT NOT NULL DEFAULT '', type TEXT NOT NULL, text TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 5, created_at INTEGER NOT NULL DEFAULT (unixepoch()), metadata TEXT DEFAULT '{}', FOREIGN KEY (frame_id) REFERENCES frames(frame_id) ON DELETE CASCADE ); `); try { this.db.exec( "ALTER TABLE frames ADD COLUMN retention_policy TEXT DEFAULT 'default'" ); } catch { } try { this.db.exec( "ALTER TABLE frames ADD COLUMN importance_score REAL DEFAULT 0.5" ); } catch { } this.db.exec(` CREATE INDEX IF NOT EXISTS idx_frames_run ON frames(run_id); CREATE INDEX IF NOT EXISTS idx_frames_project ON frames(project_id); CREATE INDEX IF NOT EXISTS idx_frames_parent ON frames(parent_frame_id); CREATE INDEX IF NOT EXISTS idx_frames_state ON frames(state); CREATE INDEX IF NOT EXISTS idx_frames_created ON frames(created_at DESC); CREATE INDEX IF NOT EXISTS idx_frames_project_state ON frames(project_id, state); CREATE INDEX IF NOT EXISTS idx_frames_project_created ON frames(project_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_frames_retention_created ON frames(retention_policy, created_at); CREATE INDEX IF NOT EXISTS idx_frames_gc_score ON frames(state, retention_policy, importance_score ASC, created_at ASC); CREATE INDEX IF NOT EXISTS idx_events_frame ON events(frame_id); CREATE INDEX IF NOT EXISTS idx_events_seq ON events(frame_id, seq); CREATE INDEX IF NOT EXISTS idx_anchors_frame ON anchors(frame_id); CREATE INDEX IF NOT EXISTS idx_anchors_frame_priority ON anchors(frame_id, priority DESC); `); this.db.exec(` CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, applied_at INTEGER DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS retrieval_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, query_text TEXT NOT NULL, strategy TEXT NOT NULL, results_count INTEGER NOT NULL, top_score REAL, latency_ms INTEGER NOT NULL, result_frame_ids TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) ); CREATE INDEX IF NOT EXISTS idx_retrieval_log_created ON retrieval_log(created_at); CREATE TABLE IF NOT EXISTS maintenance_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) ); CREATE TABLE IF NOT EXISTS project_registry ( project_id TEXT PRIMARY KEY, repo_path TEXT NOT NULL, display_name TEXT, db_path TEXT NOT NULL, is_active INTEGER DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), last_accessed INTEGER NOT NULL DEFAULT (unixepoch() * 1000) ); CREATE INDEX IF NOT EXISTS idx_project_registry_active ON project_registry(is_active); -- Set initial schema version if not exists INSERT OR IGNORE INTO schema_version (version) VALUES (1); `); try { this.db.exec( "ALTER TABLE frames ADD COLUMN access_count INTEGER DEFAULT 0" ); } catch { } try { this.db.exec("ALTER TABLE frames ADD COLUMN last_accessed INTEGER"); } catch { } this.db.exec(` CREATE TABLE IF NOT EXISTS frame_access_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, frame_id TEXT NOT NULL, accessed_at INTEGER NOT NULL DEFAULT (unixepoch()), FOREIGN KEY (frame_id) REFERENCES frames(frame_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_frame_access_log_frame ON frame_access_log(frame_id); `); this.db.exec(` CREATE TABLE IF NOT EXISTS entity_states ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, entity_name TEXT NOT NULL, relation TEXT NOT NULL, value TEXT NOT NULL, context TEXT, source_frame_id TEXT, valid_from INTEGER NOT NULL DEFAULT (unixepoch()), superseded_at INTEGER, FOREIGN KEY (source_frame_id) REFERENCES frames(frame_id) ); CREATE INDEX IF NOT EXISTS idx_entity_name ON entity_states(project_id, entity_name, relation); CREATE INDEX IF NOT EXISTS idx_entity_temporal ON entity_states(entity_name, valid_from DESC); `); this.db.exec(` CREATE TABLE IF NOT EXISTS cord_tasks ( task_id TEXT PRIMARY KEY, parent_id TEXT, project_id TEXT NOT NULL, run_id TEXT NOT NULL, goal TEXT NOT NULL, prompt TEXT NOT NULL DEFAULT '', result TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','completed','blocked','asked')), context_mode TEXT NOT NULL DEFAULT 'spawn' CHECK (context_mode IN ('spawn','fork','ask')), blocked_by TEXT NOT NULL DEFAULT '[]', depth INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()), completed_at INTEGER, FOREIGN KEY (parent_id) REFERENCES cord_tasks(task_id) ); CREATE INDEX IF NOT EXISTS idx_cord_tasks_project ON cord_tasks(project_id); CREATE INDEX IF NOT EXISTS idx_cord_tasks_parent ON cord_tasks(parent_id); CREATE INDEX IF NOT EXISTS idx_cord_tasks_status ON cord_tasks(status); CREATE INDEX IF NOT EXISTS idx_cord_tasks_project_status ON cord_tasks(project_id, status); `); logger.info("Frame database schema initialized"); } catch (error) { throw new DatabaseError( "Failed to initialize frame database schema", ErrorCode.DB_SCHEMA_ERROR, { operation: "initSchema" }, error instanceof Error ? error : void 0 ); } } /** * Insert new frame */ insertFrame(frame) { try { const stmt = this.db.prepare(` INSERT INTO frames (frame_id, run_id, project_id, parent_frame_id, depth, type, name, state, inputs, outputs, digest_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( frame.frame_id, frame.run_id, frame.project_id, frame.parent_frame_id || null, frame.depth, frame.type, frame.name, frame.state, JSON.stringify(frame.inputs), JSON.stringify(frame.outputs), JSON.stringify(frame.digest_json) ); if (result.changes === 0) { throw new DatabaseError( "Frame insertion failed - no rows affected", ErrorCode.DB_INSERT_FAILED, { frameId: frame.frame_id, operation: "insertFrame" } ); } const createdFrame = this.getFrame(frame.frame_id); if (!createdFrame) { throw new DatabaseError( "Failed to retrieve created frame", ErrorCode.DB_QUERY_FAILED, { frameId: frame.frame_id, operation: "insertFrame" } ); } return createdFrame; } catch (error) { throw new DatabaseError( `Failed to insert frame: ${frame.frame_id}`, ErrorCode.DB_INSERT_FAILED, { frameId: frame.frame_id, frameName: frame.name, operation: "insertFrame" }, error instanceof Error ? error : void 0 ); } } /** * Get frame by ID */ getFrame(frameId) { try { const row = this.db.prepare("SELECT * FROM frames WHERE frame_id = ?").get(frameId); if (!row) return void 0; return { ...row, parent_frame_id: row.parent_frame_id ?? void 0, inputs: safeJsonParse(row.inputs, {}), outputs: safeJsonParse(row.outputs, {}), digest_json: safeJsonParse( row.digest_json, {} ) }; } catch (error) { logger.warn(`Failed to get frame: ${frameId}`, { error }); return void 0; } } /** * Update frame state and outputs */ updateFrame(frameId, updates) { try { const setClauses = []; const values = []; if (updates.state !== void 0) { setClauses.push("state = ?"); values.push(updates.state); } if (updates.outputs !== void 0) { setClauses.push("outputs = ?"); values.push(JSON.stringify(updates.outputs)); } if (updates.digest_text !== void 0) { setClauses.push("digest_text = ?"); values.push(updates.digest_text); } if (updates.digest_json !== void 0) { setClauses.push("digest_json = ?"); values.push(JSON.stringify(updates.digest_json)); } if (updates.closed_at !== void 0) { setClauses.push("closed_at = ?"); values.push(updates.closed_at); } if (updates.parent_frame_id !== void 0) { setClauses.push("parent_frame_id = ?"); values.push(updates.parent_frame_id); } if (updates.depth !== void 0) { setClauses.push("depth = ?"); values.push(updates.depth); } if (setClauses.length === 0) { return; } values.push(frameId); const stmt = this.db.prepare(` UPDATE frames SET ${setClauses.join(", ")} WHERE frame_id = ? `); const result = stmt.run(...values); if (result.changes === 0) { throw new DatabaseError( `Frame not found: ${frameId}`, ErrorCode.DB_UPDATE_FAILED, { frameId, operation: "updateFrame" } ); } } catch (error) { throw new DatabaseError( `Failed to update frame: ${frameId}`, ErrorCode.DB_UPDATE_FAILED, { frameId, updates, operation: "updateFrame" }, error instanceof Error ? error : void 0 ); } } /** * Get frames by project and state */ getFramesByProject(projectId, state, limit = DEFAULT_FRAME_LIMIT) { try { const query = state ? "SELECT * FROM frames WHERE project_id = ? AND state = ? ORDER BY created_at LIMIT ?" : "SELECT * FROM frames WHERE project_id = ? ORDER BY created_at LIMIT ?"; const params = state ? [projectId, state, limit] : [projectId, limit]; const rows = this.db.prepare(query).all(...params); return rows.map((row) => ({ ...row, parent_frame_id: row.parent_frame_id ?? void 0, inputs: safeJsonParse(row.inputs, {}), outputs: safeJsonParse(row.outputs, {}), digest_json: safeJsonParse( row.digest_json, {} ) })); } catch (error) { throw new DatabaseError( `Failed to get frames for project: ${projectId}`, ErrorCode.DB_QUERY_FAILED, { projectId, state, operation: "getFramesByProject" }, error instanceof Error ? error : void 0 ); } } /** * Insert event */ insertEvent(event) { try { const stmt = this.db.prepare(` INSERT INTO events (event_id, frame_id, run_id, seq, event_type, payload) VALUES (?, ?, ?, ?, ?, ?) `); const result = stmt.run( event.event_id, event.frame_id, event.run_id, event.seq, event.event_type, JSON.stringify(event.payload) ); if (result.changes === 0) { throw new DatabaseError( "Event insertion failed - no rows affected", ErrorCode.DB_INSERT_FAILED, { eventId: event.event_id, frameId: event.frame_id, operation: "insertEvent" } ); } const createdEvent = this.db.prepare("SELECT * FROM events WHERE event_id = ?").get(event.event_id); return { ...createdEvent, payload: safeJsonParse( createdEvent.payload, {} ) }; } catch (error) { throw new DatabaseError( `Failed to insert event: ${event.event_id}`, ErrorCode.DB_INSERT_FAILED, { eventId: event.event_id, frameId: event.frame_id, operation: "insertEvent" }, error instanceof Error ? error : void 0 ); } } /** * Get events for a frame */ getFrameEvents(frameId, limit = DEFAULT_EVENT_LIMIT) { try { const query = "SELECT * FROM events WHERE frame_id = ? ORDER BY seq DESC LIMIT ?"; const params = [frameId, limit]; const rows = this.db.prepare(query).all(...params); return rows.map((row) => ({ ...row, payload: safeJsonParse(row.payload, {}) })); } catch (error) { throw new DatabaseError( `Failed to get events for frame: ${frameId}`, ErrorCode.DB_QUERY_FAILED, { frameId, limit, operation: "getFrameEvents" }, error instanceof Error ? error : void 0 ); } } /** * Get next event sequence number */ getNextEventSequence(frameId) { try { const result = this.db.prepare("SELECT MAX(seq) as max_seq FROM events WHERE frame_id = ?").get(frameId); return (result.max_seq || 0) + 1; } catch (error) { throw new DatabaseError( `Failed to get next event sequence for frame: ${frameId}`, ErrorCode.DB_QUERY_FAILED, { frameId, operation: "getNextEventSequence" }, error instanceof Error ? error : void 0 ); } } /** * Insert anchor */ insertAnchor(anchor) { try { const stmt = this.db.prepare(` INSERT INTO anchors (anchor_id, frame_id, project_id, type, text, priority, metadata) VALUES (?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run( anchor.anchor_id, anchor.frame_id, anchor.project_id || "", anchor.type, anchor.text, anchor.priority, JSON.stringify(anchor.metadata) ); if (result.changes === 0) { throw new DatabaseError( "Anchor insertion failed - no rows affected", ErrorCode.DB_INSERT_FAILED, { anchorId: anchor.anchor_id, frameId: anchor.frame_id, operation: "insertAnchor" } ); } const createdAnchor = this.db.prepare("SELECT * FROM anchors WHERE anchor_id = ?").get(anchor.anchor_id); return { ...createdAnchor, metadata: safeJsonParse( createdAnchor.metadata, {} ) }; } catch (error) { throw new DatabaseError( `Failed to insert anchor: ${anchor.anchor_id}`, ErrorCode.DB_INSERT_FAILED, { anchorId: anchor.anchor_id, frameId: anchor.frame_id, operation: "insertAnchor" }, error instanceof Error ? error : void 0 ); } } /** * Get anchors for a frame */ getFrameAnchors(frameId, limit = DEFAULT_ANCHOR_LIMIT) { try { const rows = this.db.prepare( "SELECT * FROM anchors WHERE frame_id = ? ORDER BY priority DESC, created_at ASC LIMIT ?" ).all(frameId, limit); return rows.map((row) => ({ ...row, metadata: safeJsonParse(row.metadata, {}) })); } catch (error) { throw new DatabaseError( `Failed to get anchors for frame: ${frameId}`, ErrorCode.DB_QUERY_FAILED, { frameId, operation: "getFrameAnchors" }, error instanceof Error ? error : void 0 ); } } /** * Delete frame and all related data */ deleteFrame(frameId) { try { this.db.prepare("DELETE FROM anchors WHERE frame_id = ?").run(frameId); this.db.prepare("DELETE FROM events WHERE frame_id = ?").run(frameId); this.db.prepare("DELETE FROM frames WHERE frame_id = ?").run(frameId); logger.info("Frame deleted", { frameId }); } catch (error) { throw new DatabaseError( `Failed to delete frame: ${frameId}`, ErrorCode.DB_DELETE_FAILED, { frameId, operation: "deleteFrame" }, error instanceof Error ? error : void 0 ); } } /** * Count frames by project and state (without loading all data) */ countFrames(projectId, state) { try { let query = "SELECT COUNT(*) as count FROM frames"; const params = []; if (projectId) { query += " WHERE project_id = ?"; params.push(projectId); if (state) { query += " AND state = ?"; params.push(state); } } else if (state) { query += " WHERE state = ?"; params.push(state); } const result = this.db.prepare(query).get(...params); return result.count; } catch (error) { logger.warn("Failed to count frames", { error, projectId, state }); return 0; } } /** * Get database statistics */ getStatistics() { try { const frameCount = this.db.prepare("SELECT COUNT(*) as count FROM frames").get(); const eventCount = this.db.prepare("SELECT COUNT(*) as count FROM events").get(); const anchorCount = this.db.prepare("SELECT COUNT(*) as count FROM anchors").get(); const activeFrames = this.db.prepare("SELECT COUNT(*) as count FROM frames WHERE state = 'active'").get(); return { totalFrames: frameCount.count, totalEvents: eventCount.count, totalAnchors: anchorCount.count, activeFrames: activeFrames.count }; } catch (error) { logger.warn("Failed to get database statistics", { error: error instanceof Error ? error.message : String(error) }); return {}; } } } export { DEFAULT_ANCHOR_LIMIT, DEFAULT_EVENT_LIMIT, DEFAULT_FRAME_LIMIT, FrameDatabase };