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

315 lines (313 loc) 9.54 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import Database from "better-sqlite3"; import { mkdirSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import { randomUUID } from "crypto"; function classifyErrorText(text) { const lower = text.toLowerCase(); if (lower.includes("lint") || lower.includes("eslint") || lower.includes("prettier")) return "lint_failure"; if (lower.includes("test") && (lower.includes("fail") || lower.includes("error"))) return "test_failure"; if (lower.includes("timeout") || lower.includes("timed out")) return "timeout"; if (lower.includes("conflict") || lower.includes("merge")) return "git_conflict"; if (lower.includes("429") || lower.includes("rate limit")) return "rate_limit"; if (lower.includes("permission") || lower.includes("EACCES") || lower.includes("not found")) return "permission_or_missing"; if (lower.includes("build") && lower.includes("error")) return "build_failure"; return null; } function getTracesDbPath() { return join(homedir(), ".stackmemory", "conductor", "traces.db"); } function openTracesDb(dbPath) { const path = dbPath ?? getTracesDbPath(); mkdirSync(join(path, ".."), { recursive: true }); const db = new Database(path); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); db.exec(` CREATE TABLE IF NOT EXISTS conductor_traces ( id INTEGER PRIMARY KEY AUTOINCREMENT, issue_id TEXT NOT NULL, session_id TEXT NOT NULL, attempt INTEGER NOT NULL, turn_number INTEGER NOT NULL, timestamp INTEGER NOT NULL, phase TEXT, tool_names TEXT, tool_count INTEGER DEFAULT 0, files_modified INTEGER DEFAULT 0, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, cache_creation_tokens INTEGER DEFAULT 0, cache_read_tokens INTEGER DEFAULT 0, message_preview TEXT, event_json TEXT NOT NULL, UNIQUE(session_id, turn_number) ); CREATE INDEX IF NOT EXISTS idx_traces_issue ON conductor_traces(issue_id, attempt); CREATE INDEX IF NOT EXISTS idx_traces_session ON conductor_traces(session_id); CREATE INDEX IF NOT EXISTS idx_traces_phase ON conductor_traces(phase); CREATE INDEX IF NOT EXISTS idx_traces_timestamp ON conductor_traces(timestamp DESC); `); return db; } function withDb(db, fn) { const ownDb = db ?? openTracesDb(); try { return fn(ownDb); } finally { if (!db) ownDb.close(); } } class TraceCollector { db; ownsDb; sessionId; issueId; attempt; turnCounter = 0; insertStmt; constructor(opts) { this.issueId = opts.issueId; this.attempt = opts.attempt; this.sessionId = `${opts.issueId}-${opts.attempt}-${randomUUID().slice(0, 8)}`; this.ownsDb = !opts.db; this.db = opts.db ?? openTracesDb(); this.insertStmt = this.db.prepare(` INSERT INTO conductor_traces ( issue_id, session_id, attempt, turn_number, timestamp, phase, tool_names, tool_count, files_modified, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, message_preview, event_json ) VALUES ( @issue_id, @session_id, @attempt, @turn_number, @timestamp, @phase, @tool_names, @tool_count, @files_modified, @input_tokens, @output_tokens, @cache_creation_tokens, @cache_read_tokens, @message_preview, @event_json ) `); } get session() { return this.sessionId; } /** * Record a turn using pre-extracted data from the orchestrator's stream parser. * Avoids re-iterating content blocks — the caller already did that work. */ recordTurn(turnData, phase, eventJson) { this.insertStmt.run({ issue_id: this.issueId, session_id: this.sessionId, attempt: this.attempt, turn_number: this.turnCounter++, timestamp: Date.now(), phase: phase ?? null, tool_names: turnData.toolNames.length > 0 ? JSON.stringify(turnData.toolNames) : null, tool_count: turnData.toolCount, files_modified: turnData.filesModified, input_tokens: turnData.inputTokens, output_tokens: turnData.outputTokens, cache_creation_tokens: turnData.cacheCreationTokens, cache_read_tokens: turnData.cacheReadTokens, message_preview: turnData.textPreview, event_json: eventJson }); } /** Record a result event (final output) */ recordResult(event) { const resultText = typeof event.result === "string" ? event.result : JSON.stringify(event.result); this.insertStmt.run({ issue_id: this.issueId, session_id: this.sessionId, attempt: this.attempt, turn_number: this.turnCounter++, timestamp: Date.now(), phase: "result", tool_names: null, tool_count: 0, files_modified: 0, input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, message_preview: (resultText || "").slice(0, 500), event_json: JSON.stringify(event).slice(0, 5e4) }); } /** Close the DB connection only if we own it */ close() { if (!this.ownsDb) return; try { this.db.close(); } catch { } } } function stringifyEventTruncated(event) { return JSON.stringify(event, (_key, value) => { if (value && typeof value === "object" && !Array.isArray(value)) { const obj = value; if (obj.type === "tool_use" && obj.input) { const inputStr = JSON.stringify(obj.input); if (inputStr.length > 2e3) { return { ...obj, input: { _truncated: true, length: inputStr.length } }; } } if (obj.type === "tool_result" && obj.content) { const contentStr = typeof obj.content === "string" ? obj.content : JSON.stringify(obj.content); if (contentStr.length > 2e3) { return { ...obj, content: `[truncated: ${contentStr.length} chars]` }; } } } return value; }); } function listSessions(issueId, db) { return withDb(db, (d) => { const rows = d.prepare( ` SELECT issue_id, session_id, attempt, COUNT(*) as total_turns, SUM(tool_count) as total_tool_calls, SUM(files_modified) as total_files_modified, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens, GROUP_CONCAT(DISTINCT phase) as phases, MIN(timestamp) as started_at, MAX(timestamp) as ended_at FROM conductor_traces WHERE issue_id = ? GROUP BY session_id ORDER BY started_at DESC ` ).all(issueId); return rows.map((r) => ({ issue_id: r.issue_id, session_id: r.session_id, attempt: r.attempt, total_turns: r.total_turns, total_tool_calls: r.total_tool_calls || 0, total_files_modified: r.total_files_modified || 0, total_input_tokens: r.total_input_tokens || 0, total_output_tokens: r.total_output_tokens || 0, phases: (r.phases || "").split(",").filter(Boolean), started_at: r.started_at, ended_at: r.ended_at, duration_ms: r.ended_at - r.started_at })); }); } function getSessionTurns(sessionId, db) { return withDb( db, (d) => d.prepare( ` SELECT * FROM conductor_traces WHERE session_id = ? ORDER BY turn_number ASC ` ).all(sessionId) ); } function getPhaseBreakdown(sessionId, db) { return withDb( db, (d) => d.prepare( ` SELECT phase, COUNT(*) as turns, SUM(tool_count) as tool_calls, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens FROM conductor_traces WHERE session_id = ? AND phase IS NOT NULL GROUP BY phase ORDER BY MIN(turn_number) ASC ` ).all(sessionId) ); } function getToolFrequencies(issueId, db) { return withDb( db, (d) => d.prepare( ` SELECT j.value as tool_name, COUNT(*) as count FROM conductor_traces, json_each(tool_names) j WHERE issue_id = ? AND tool_names IS NOT NULL GROUP BY j.value ORDER BY count DESC ` ).all(issueId) ); } function getFailureTurns(issueId, tailCount = 5, db) { return withDb( db, (d) => d.prepare( ` SELECT t.* FROM conductor_traces t INNER JOIN ( SELECT session_id, MAX(turn_number) as max_turn FROM conductor_traces WHERE issue_id = ? GROUP BY session_id ) latest ON t.session_id = latest.session_id AND t.turn_number > latest.max_turn - ? WHERE t.issue_id = ? ORDER BY t.session_id, t.turn_number ASC ` ).all(issueId, tailCount, issueId) ); } function getTraceStats(db) { return withDb( db, (d) => d.prepare( ` SELECT COUNT(DISTINCT session_id) as total_sessions, COUNT(*) as total_turns, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens, COUNT(DISTINCT issue_id) as issues_traced FROM conductor_traces ` ).get() ); } export { TraceCollector, classifyErrorText, getFailureTurns, getPhaseBreakdown, getSessionTurns, getToolFrequencies, getTraceStats, getTracesDbPath, listSessions, openTracesDb, stringifyEventTruncated };