@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
JavaScript
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 (
, , , , ,
, , , ,
, , , ,
,
)
`);
}
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
};