@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
JavaScript
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
};