@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
399 lines (397 loc) • 13.6 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 { existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { randomUUID, createHash } from "node:crypto";
import BetterSqlite3 from "better-sqlite3";
import { logger } from "../../../core/monitoring/logger.js";
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY, type TEXT NOT NULL, content TEXT NOT NULL,
embedding BLOB, actor TEXT, confidence REAL NOT NULL DEFAULT 0.0,
version INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS edges (
id TEXT PRIMARY KEY, from_node TEXT NOT NULL, to_node TEXT NOT NULL,
rel_type TEXT NOT NULL, confidence REAL NOT NULL DEFAULT 1.0,
version INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sources (
id TEXT PRIMARY KEY, system TEXT NOT NULL, external_id TEXT NOT NULL,
raw_payload TEXT NOT NULL, hash TEXT NOT NULL, fetched_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS source_edges (
id TEXT PRIMARY KEY, node_id TEXT NOT NULL, source_id TEXT NOT NULL,
system TEXT NOT NULL, external_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS contradictions (
id TEXT PRIMARY KEY, node_a TEXT NOT NULL, node_b TEXT NOT NULL,
conflict_score REAL NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
resolved_by TEXT, resolved_at INTEGER
);
CREATE TABLE IF NOT EXISTS stale_flags (
id TEXT PRIMARY KEY, node_id TEXT NOT NULL, triggered_by_source TEXT NOT NULL,
flagged_at INTEGER NOT NULL, resolved_at INTEGER
);
CREATE TABLE IF NOT EXISTS rejection_log (
id TEXT PRIMARY KEY, suggestion_node TEXT NOT NULL, override_node TEXT,
reasoning TEXT, reasoning_resolved INTEGER NOT NULL DEFAULT 0,
actor TEXT, created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS review_queue (
id TEXT PRIMARY KEY, source_id TEXT NOT NULL, candidate_content TEXT NOT NULL,
confidence REAL NOT NULL, queue_reason TEXT NOT NULL,
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, resolved_at INTEGER
);
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type);
CREATE INDEX IF NOT EXISTS idx_nodes_created ON nodes(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_contradictions_status ON contradictions(status);
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, unixepoch() * 1000);
`;
class ProvenantHandlers {
constructor(deps) {
this.deps = deps;
this.dbPath = join(deps.projectDir, ".provenant", "graph.db");
}
dbPath;
openDb() {
mkdirSync(dirname(this.dbPath), { recursive: true });
const db = new BetterSqlite3(this.dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
try {
db.prepare("SELECT 1 FROM schema_version").get();
} catch {
db.exec(SCHEMA_SQL);
}
return db;
}
async handleSearch(args) {
try {
const { query, actor, since, limit = 10 } = args;
if (!query) throw new Error("query is required");
const db = this.openDb();
try {
const stopwords = /* @__PURE__ */ new Set([
"the",
"a",
"an",
"is",
"was",
"were",
"are",
"we",
"our",
"this",
"that",
"it",
"to",
"of",
"in",
"for",
"on",
"with",
"did",
"do",
"not",
"why",
"how",
"what",
"when",
"and",
"or",
"but"
]);
const keywords = query.toLowerCase().replace(/[^\w\s]/g, "").split(/\s+/).filter((w) => w.length > 2 && !stopwords.has(w));
let sql;
const params = [];
if (keywords.length === 0) {
sql = "SELECT * FROM nodes ORDER BY created_at DESC LIMIT ?";
params.push(limit);
} else {
const scoreParts = keywords.map(
() => "(CASE WHEN LOWER(content) LIKE ? THEN 1 ELSE 0 END)"
);
for (const kw of keywords) params.push(`%${kw}%`);
sql = `SELECT *, (${scoreParts.join(" + ")}) as match_score FROM nodes WHERE (${scoreParts.join(" + ")}) > 0`;
for (const kw of keywords) params.push(`%${kw}%`);
if (actor) {
sql += " AND actor = ?";
params.push(actor);
}
if (since) {
sql += " AND created_at >= ?";
params.push(new Date(since).getTime());
}
sql += " ORDER BY match_score DESC, created_at DESC LIMIT ?";
params.push(limit);
}
const nodes = db.prepare(sql).all(...params);
const results = nodes.map((n) => ({
id: n.id.slice(0, 8),
type: n.type,
content: n.content.slice(0, 200),
actor: n.actor,
confidence: n.confidence,
created: new Date(n.created_at).toISOString()
}));
logger.info("Provenant search", { query, resultCount: results.length });
return {
content: [
{
type: "text",
text: results.length === 0 ? `No decisions found for: "${query}"` : results.map(
(r) => `[${r.id}] (${r.confidence.toFixed(2)}) ${r.content}${r.actor ? ` \u2014 ${r.actor}` : ""}`
).join("\n\n")
}
],
metadata: { query, results }
};
} finally {
db.close();
}
} catch (error) {
logger.error(
"Provenant search error",
error instanceof Error ? error : new Error(String(error))
);
throw error;
}
}
async handleLog(args) {
try {
const { content, actor, reasoning } = args;
if (!content) throw new Error("content is required");
const db = this.openDb();
try {
const now = Date.now();
const hash = createHash("sha256").update(content).digest("hex");
const externalId = `manual-${now}`;
let confidence = 0.6;
if (reasoning) confidence += 0.15;
if (actor) confidence += 0.1;
const sourceId = randomUUID();
db.prepare(
"INSERT INTO sources (id, system, external_id, raw_payload, hash, fetched_at) VALUES (?, ?, ?, ?, ?, ?)"
).run(
sourceId,
"manual",
externalId,
JSON.stringify({ content, actor, reasoning }),
hash,
now
);
const nodeId = randomUUID();
db.prepare(
"INSERT INTO nodes (id, type, content, embedding, actor, confidence, version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
nodeId,
"decision",
content,
null,
actor ?? null,
confidence,
1,
now,
now
);
db.prepare(
"INSERT INTO source_edges (id, node_id, source_id, system, external_id) VALUES (?, ?, ?, ?, ?)"
).run(randomUUID(), nodeId, sourceId, "manual", externalId);
logger.info("Provenant decision logged", { nodeId, confidence });
return {
content: [
{
type: "text",
text: `Logged decision: ${nodeId.slice(0, 8)} (confidence: ${confidence.toFixed(2)})`
}
],
metadata: { nodeId, confidence }
};
} finally {
db.close();
}
} catch (error) {
logger.error(
"Provenant log error",
error instanceof Error ? error : new Error(String(error))
);
throw error;
}
}
async handleStatus(_args) {
try {
if (!existsSync(this.dbPath)) {
return {
content: [
{
type: "text",
text: "No Provenant database found. Use provenant_log to create one."
}
]
};
}
const db = this.openDb();
try {
const count = (sql) => db.prepare(sql).get().c;
const s = {
nodeCount: count("SELECT COUNT(*) as c FROM nodes"),
edgeCount: count("SELECT COUNT(*) as c FROM edges"),
pendingQueue: count(
"SELECT COUNT(*) as c FROM review_queue WHERE resolved_at IS NULL"
),
unresolvedContradictions: count(
"SELECT COUNT(*) as c FROM contradictions WHERE status = 'pending'"
),
unresolvedStaleFlags: count(
"SELECT COUNT(*) as c FROM stale_flags WHERE resolved_at IS NULL"
),
unresolvedRejections: count(
"SELECT COUNT(*) as c FROM rejection_log WHERE reasoning_resolved = 0"
)
};
return {
content: [
{
type: "text",
text: `Nodes: ${s.nodeCount}
Edges: ${s.edgeCount}
Review queue: ${s.pendingQueue}
Contradictions: ${s.unresolvedContradictions}
Stale flags: ${s.unresolvedStaleFlags}
Rejections needing reasoning: ${s.unresolvedRejections}`
}
],
metadata: s
};
} finally {
db.close();
}
} catch (error) {
logger.error(
"Provenant status error",
error instanceof Error ? error : new Error(String(error))
);
throw error;
}
}
async handleContradictions(_args) {
try {
if (!existsSync(this.dbPath)) {
return { content: [{ type: "text", text: "No Provenant database." }] };
}
const db = this.openDb();
try {
const contras = db.prepare("SELECT * FROM contradictions WHERE status = 'pending'").all();
if (contras.length === 0) {
return {
content: [{ type: "text", text: "No open contradictions." }]
};
}
const lines = contras.map((c) => {
const a = db.prepare("SELECT content FROM nodes WHERE id = ?").get(c.node_a);
const b = db.prepare("SELECT content FROM nodes WHERE id = ?").get(c.node_b);
return `${c.node_a.slice(0, 8)} \u2194 ${c.node_b.slice(0, 8)} (score: ${c.conflict_score.toFixed(2)})
A: ${a?.content?.slice(0, 100) ?? "?"}
B: ${b?.content?.slice(0, 100) ?? "?"}`;
});
return {
content: [
{
type: "text",
text: `${contras.length} open contradictions:
${lines.join("\n\n")}`
}
],
metadata: { count: contras.length }
};
} finally {
db.close();
}
} catch (error) {
logger.error(
"Provenant contradictions error",
error instanceof Error ? error : new Error(String(error))
);
throw error;
}
}
async handleResolve(args) {
try {
const { node_a, node_b, winner, dismiss } = args;
if (!node_a || !node_b) throw new Error("node_a and node_b are required");
if (!winner && !dismiss) throw new Error("Specify winner or dismiss");
const db = this.openDb();
try {
const contras = db.prepare("SELECT * FROM contradictions WHERE status = 'pending'").all();
const contradiction = contras.find(
(c) => c.node_a.startsWith(node_a) && c.node_b.startsWith(node_b) || c.node_a.startsWith(node_b) && c.node_b.startsWith(node_a)
);
if (!contradiction) {
throw new Error(
`No pending contradiction between ${node_a} and ${node_b}`
);
}
if (dismiss) {
db.prepare(
"UPDATE contradictions SET status = ?, resolved_by = ?, resolved_at = ? WHERE id = ?"
).run("dismissed", "human", Date.now(), contradiction.id);
return {
content: [
{
type: "text",
text: `Dismissed contradiction ${contradiction.id.slice(0, 8)}`
}
]
};
}
const winnerNode = [contradiction.node_a, contradiction.node_b].find(
(id) => id.startsWith(winner)
);
if (!winnerNode) {
throw new Error(
`Winner must match one of: ${contradiction.node_a.slice(0, 8)}, ${contradiction.node_b.slice(0, 8)}`
);
}
const loserNode = winnerNode === contradiction.node_a ? contradiction.node_b : contradiction.node_a;
db.prepare(
"UPDATE contradictions SET status = ?, resolved_by = ?, resolved_at = ? WHERE id = ?"
).run("resolved", winnerNode, Date.now(), contradiction.id);
db.prepare(
"INSERT INTO edges (id, from_node, to_node, rel_type, confidence, version, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
).run(
randomUUID(),
winnerNode,
loserNode,
"supersedes",
1,
1,
Date.now()
);
return {
content: [
{
type: "text",
text: `Resolved: ${winnerNode.slice(0, 8)} supersedes ${loserNode.slice(0, 8)}`
}
],
metadata: { winner: winnerNode, loser: loserNode }
};
} finally {
db.close();
}
} catch (error) {
logger.error(
"Provenant resolve error",
error instanceof Error ? error : new Error(String(error))
);
throw error;
}
}
}
export {
ProvenantHandlers
};