@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
647 lines (638 loc) • 21.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 Database from "better-sqlite3";
import { v4 as uuidv4 } from "uuid";
import * as path from "path";
import * as fs from "fs";
import { logger } from "../monitoring/logger.js";
const SCHEMA_VERSION = 1;
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
summary TEXT,
category TEXT NOT NULL,
priority TEXT NOT NULL DEFAULT 'medium',
tags TEXT NOT NULL DEFAULT '[]',
tool TEXT,
project TEXT,
language TEXT,
framework TEXT,
validated_count INTEGER NOT NULL DEFAULT 0,
last_validated TEXT,
source TEXT NOT NULL,
session_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expires_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_skills_category ON skills(category);
CREATE INDEX IF NOT EXISTS idx_skills_priority ON skills(priority);
CREATE INDEX IF NOT EXISTS idx_skills_tool ON skills(tool);
CREATE INDEX IF NOT EXISTS idx_skills_created ON skills(created_at);
CREATE TABLE IF NOT EXISTS skill_rules (
name TEXT PRIMARY KEY,
description TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 5,
triggers TEXT NOT NULL DEFAULT '{}',
exclude_patterns TEXT NOT NULL DEFAULT '[]',
related_skills TEXT NOT NULL DEFAULT '[]',
suggestion TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS directory_mappings (
directory TEXT PRIMARY KEY,
skill_name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS matcher_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
min_confidence_score INTEGER NOT NULL DEFAULT 3,
show_match_reasons INTEGER NOT NULL DEFAULT 1,
max_skills_to_show INTEGER NOT NULL DEFAULT 5,
scoring TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS journal_entries (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
context_file TEXT,
context_tool TEXT,
context_command TEXT,
outcome TEXT,
promoted_to_skill_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_journal_session ON journal_entries(session_id);
CREATE INDEX IF NOT EXISTS idx_journal_type ON journal_entries(type);
CREATE TABLE IF NOT EXISTS session_summaries (
session_id TEXT PRIMARY KEY,
started_at TEXT NOT NULL,
ended_at TEXT,
entries_count INTEGER NOT NULL DEFAULT 0,
corrections_count INTEGER NOT NULL DEFAULT 0,
decisions_count INTEGER NOT NULL DEFAULT 0,
key_learnings TEXT NOT NULL DEFAULT '[]',
promoted_skill_ids TEXT NOT NULL DEFAULT '[]'
);
`;
function getDefaultDbPath() {
const home = process.env["HOME"] || process.env["USERPROFILE"] || "/tmp";
return path.join(home, ".stackmemory", "skills.db");
}
function priorityScore(priority) {
const scores = {
critical: 1e3,
high: 100,
medium: 10,
low: 1
};
return scores[priority] ?? 10;
}
function rowToSkill(row) {
return {
id: row["id"],
content: row["content"],
summary: row["summary"] || void 0,
category: row["category"],
priority: row["priority"],
tags: JSON.parse(row["tags"] || "[]"),
tool: row["tool"] || void 0,
project: row["project"] || void 0,
language: row["language"] || void 0,
framework: row["framework"] || void 0,
validatedCount: row["validated_count"] || 0,
lastValidated: row["last_validated"] || void 0,
source: row["source"],
sessionId: row["session_id"] || void 0,
createdAt: row["created_at"],
updatedAt: row["updated_at"],
expiresAt: row["expires_at"] || void 0
};
}
function rowToJournalEntry(row) {
const context = row["context_file"] || row["context_tool"] || row["context_command"] ? {
file: row["context_file"] || void 0,
tool: row["context_tool"] || void 0,
command: row["context_command"] || void 0
} : void 0;
return {
id: row["id"],
sessionId: row["session_id"],
type: row["type"],
title: row["title"],
content: row["content"],
context,
outcome: row["outcome"] || void 0,
createdAt: row["created_at"],
promotedToSkillId: row["promoted_to_skill_id"] || void 0
};
}
class SkillRegistry {
db;
dbPath;
constructor(dbPath) {
this.dbPath = dbPath || getDefaultDbPath();
const dir = path.dirname(this.dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
this.db = new Database(this.dbPath);
this.db.pragma("journal_mode = WAL");
this.db.pragma("busy_timeout = 5000");
this.db.pragma("foreign_keys = ON");
this.initSchema();
}
initSchema() {
const versionRow = (() => {
try {
return this.db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
).get();
} catch {
return void 0;
}
})();
if (!versionRow) {
this.db.exec(SCHEMA_SQL);
this.db.prepare("INSERT OR REPLACE INTO schema_version (version) VALUES (?)").run(SCHEMA_VERSION);
logger.debug("SkillRegistry: created schema v" + SCHEMA_VERSION);
}
}
// ============================================================
// SKILL CRUD
// ============================================================
createSkill(input) {
const now = (/* @__PURE__ */ new Date()).toISOString();
const id = uuidv4();
this.db.prepare(
`INSERT INTO skills (id, content, summary, category, priority, tags, tool, project, language, framework,
validated_count, source, session_id, created_at, updated_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)`
).run(
id,
input.content,
input.summary ?? null,
input.category,
input.priority ?? "medium",
JSON.stringify(input.tags ?? []),
input.tool ?? null,
input.project ?? null,
input.language ?? null,
input.framework ?? null,
input.source,
input.sessionId ?? null,
now,
now,
input.expiresAt ?? null
);
const skill = this.getSkill(id);
if (!skill) throw new Error(`Skill not found after creation: ${id}`);
return skill;
}
getSkill(id) {
const row = this.db.prepare("SELECT * FROM skills WHERE id = ?").get(id);
return row ? rowToSkill(row) : void 0;
}
updateSkill(input) {
const existing = this.getSkill(input.id);
if (!existing) return void 0;
const now = (/* @__PURE__ */ new Date()).toISOString();
const updates = ["updated_at = ?"];
const params = [now];
if (input.content !== void 0) {
updates.push("content = ?");
params.push(input.content);
}
if (input.summary !== void 0) {
updates.push("summary = ?");
params.push(input.summary);
}
if (input.category !== void 0) {
updates.push("category = ?");
params.push(input.category);
}
if (input.priority !== void 0) {
updates.push("priority = ?");
params.push(input.priority);
}
if (input.tags !== void 0) {
updates.push("tags = ?");
params.push(JSON.stringify(input.tags));
}
if (input.tool !== void 0) {
updates.push("tool = ?");
params.push(input.tool);
}
params.push(input.id);
this.db.prepare(`UPDATE skills SET ${updates.join(", ")} WHERE id = ?`).run(...params);
return this.getSkill(input.id);
}
validateSkill(id) {
const skill = this.getSkill(id);
if (!skill) return void 0;
const now = (/* @__PURE__ */ new Date()).toISOString();
this.db.prepare(
"UPDATE skills SET validated_count = validated_count + 1, last_validated = ?, updated_at = ? WHERE id = ?"
).run(now, now, id);
return this.getSkill(id);
}
deleteSkill(id) {
const result = this.db.prepare("DELETE FROM skills WHERE id = ?").run(id);
return result.changes > 0;
}
querySkills(query) {
const conditions = [];
const params = [];
if (query.categories?.length) {
conditions.push(
`category IN (${query.categories.map(() => "?").join(",")})`
);
params.push(...query.categories);
}
if (query.priorities?.length) {
conditions.push(
`priority IN (${query.priorities.map(() => "?").join(",")})`
);
params.push(...query.priorities);
}
if (query.tool) {
conditions.push("tool = ?");
params.push(query.tool);
}
if (query.language) {
conditions.push("language = ?");
params.push(query.language);
}
if (query.framework) {
conditions.push("framework = ?");
params.push(query.framework);
}
if (query.minValidatedCount !== void 0) {
conditions.push("validated_count >= ?");
params.push(query.minValidatedCount);
}
const where = conditions.length ? "WHERE " + conditions.join(" AND ") : "";
const sortColMap = {
priority: "priority",
validatedCount: "validated_count",
createdAt: "created_at",
updatedAt: "updated_at"
};
const sortCol = sortColMap[query.sortBy ?? "priority"] ?? "priority";
const sortDir = query.sortOrder === "asc" ? "ASC" : "DESC";
const sql = `SELECT * FROM skills ${where} ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`;
params.push(query.limit ?? 50, query.offset ?? 0);
const rows = this.db.prepare(sql).all(...params);
let skills = rows.map(rowToSkill);
if (sortCol === "priority") {
skills.sort((a, b) => {
const diff = priorityScore(b.priority) - priorityScore(a.priority);
return sortDir === "DESC" ? diff : -diff;
});
}
if (query.tags?.length) {
const tags = query.tags;
skills = skills.filter((s) => tags.some((t) => s.tags.includes(t)));
}
return skills;
}
getRelevantSkills(context) {
const skills = [];
const seenIds = /* @__PURE__ */ new Set();
const critical = this.db.prepare("SELECT * FROM skills WHERE priority = 'critical'").all();
for (const row of critical) {
const skill = rowToSkill(row);
if (!seenIds.has(skill.id)) {
skills.push(skill);
seenIds.add(skill.id);
}
}
if (context.tool) {
const toolRows = this.db.prepare(
"SELECT * FROM skills WHERE tool = ? ORDER BY validated_count DESC LIMIT 20"
).all(context.tool);
for (const row of toolRows) {
const skill = rowToSkill(row);
if (!seenIds.has(skill.id)) {
skills.push(skill);
seenIds.add(skill.id);
}
}
}
const validated = this.db.prepare("SELECT * FROM skills ORDER BY validated_count DESC LIMIT 10").all();
for (const row of validated) {
const skill = rowToSkill(row);
if (!seenIds.has(skill.id)) {
skills.push(skill);
seenIds.add(skill.id);
}
}
return skills.slice(0, 50);
}
// ============================================================
// SKILL RULES CRUD
// ============================================================
upsertRule(name, rule) {
const now = (/* @__PURE__ */ new Date()).toISOString();
this.db.prepare(
`INSERT INTO skill_rules (name, description, priority, triggers, exclude_patterns, related_skills, suggestion, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
description = excluded.description,
priority = excluded.priority,
triggers = excluded.triggers,
exclude_patterns = excluded.exclude_patterns,
related_skills = excluded.related_skills,
suggestion = excluded.suggestion,
updated_at = excluded.updated_at`
).run(
name,
rule.description,
rule.priority,
JSON.stringify(rule.triggers),
JSON.stringify(rule.excludePatterns ?? []),
JSON.stringify(rule.relatedSkills ?? []),
rule.suggestion ?? null,
now,
now
);
}
getRule(name) {
const row = this.db.prepare("SELECT * FROM skill_rules WHERE name = ?").get(name);
if (!row) return void 0;
return {
description: row["description"],
priority: row["priority"],
triggers: JSON.parse(row["triggers"]),
excludePatterns: JSON.parse(row["exclude_patterns"]),
relatedSkills: JSON.parse(row["related_skills"]),
suggestion: row["suggestion"] || void 0
};
}
getAllRules() {
const rows = this.db.prepare("SELECT * FROM skill_rules").all();
const result = {};
for (const row of rows) {
result[row["name"]] = {
description: row["description"],
priority: row["priority"],
triggers: JSON.parse(row["triggers"]),
excludePatterns: JSON.parse(row["exclude_patterns"]),
relatedSkills: JSON.parse(row["related_skills"]),
suggestion: row["suggestion"] || void 0
};
}
return result;
}
deleteRule(name) {
return this.db.prepare("DELETE FROM skill_rules WHERE name = ?").run(name).changes > 0;
}
// ============================================================
// DIRECTORY MAPPINGS
// ============================================================
setDirectoryMapping(directory, skillName) {
this.db.prepare(
"INSERT OR REPLACE INTO directory_mappings (directory, skill_name) VALUES (?, ?)"
).run(directory, skillName);
}
getDirectoryMappings() {
const rows = this.db.prepare("SELECT * FROM directory_mappings").all();
const result = {};
for (const row of rows) {
result[row["directory"]] = row["skill_name"];
}
return result;
}
// ============================================================
// MATCHER CONFIG
// ============================================================
getMatcherConfig() {
const row = this.db.prepare("SELECT * FROM matcher_config WHERE id = 1").get();
if (!row) {
return {
config: {
minConfidenceScore: 3,
showMatchReasons: true,
maxSkillsToShow: 5
},
scoring: {
keyword: 2,
keywordPattern: 3,
pathPattern: 4,
directoryMatch: 5,
intentPattern: 4,
contentPattern: 3,
contextPattern: 2
}
};
}
return {
config: {
minConfidenceScore: row["min_confidence_score"],
showMatchReasons: !!row["show_match_reasons"],
maxSkillsToShow: row["max_skills_to_show"]
},
scoring: JSON.parse(row["scoring"])
};
}
setMatcherConfig(config, scoring) {
this.db.prepare(
`INSERT OR REPLACE INTO matcher_config (id, min_confidence_score, show_match_reasons, max_skills_to_show, scoring)
VALUES (1, ?, ?, ?, ?)`
).run(
config.minConfidenceScore,
config.showMatchReasons ? 1 : 0,
config.maxSkillsToShow,
JSON.stringify(scoring)
);
}
// ============================================================
// JOURNAL
// ============================================================
createJournalEntry(sessionId, type, title, content, context) {
const id = uuidv4();
const now = (/* @__PURE__ */ new Date()).toISOString();
this.db.prepare(
`INSERT INTO journal_entries (id, session_id, type, title, content, context_file, context_tool, context_command, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
sessionId,
type,
title,
content,
context?.file ?? null,
context?.tool ?? null,
context?.command ?? null,
now
);
return {
id,
sessionId,
type,
title,
content,
context,
createdAt: now
};
}
getSessionJournal(sessionId) {
const rows = this.db.prepare(
"SELECT * FROM journal_entries WHERE session_id = ? ORDER BY created_at DESC"
).all(sessionId);
return rows.map(rowToJournalEntry);
}
promoteToSkill(entryId, category, priority = "medium") {
const row = this.db.prepare("SELECT * FROM journal_entries WHERE id = ?").get(entryId);
if (!row) return void 0;
const entry = rowToJournalEntry(row);
const skill = this.createSkill({
content: entry.content,
summary: entry.title,
category,
priority,
tags: [],
tool: entry.context?.tool,
source: "observation",
sessionId: entry.sessionId
});
this.db.prepare(
"UPDATE journal_entries SET promoted_to_skill_id = ? WHERE id = ?"
).run(skill.id, entryId);
return skill;
}
// ============================================================
// SESSION MANAGEMENT
// ============================================================
startSession(sessionId) {
const now = (/* @__PURE__ */ new Date()).toISOString();
this.db.prepare(
`INSERT OR REPLACE INTO session_summaries (session_id, started_at, entries_count, corrections_count, decisions_count)
VALUES (?, ?, 0, 0, 0)`
).run(sessionId, now);
}
endSession(sessionId) {
const row = this.db.prepare("SELECT * FROM session_summaries WHERE session_id = ?").get(sessionId);
if (!row) return void 0;
const now = (/* @__PURE__ */ new Date()).toISOString();
const entries = this.getSessionJournal(sessionId);
const corrections = entries.filter((e) => e.type === "correction").length;
const decisions = entries.filter((e) => e.type === "decision").length;
const keyLearnings = entries.filter((e) => e.type === "correction" || e.type === "resolution").slice(0, 5).map((e) => e.title);
const promotedSkillIds = entries.filter((e) => e.promotedToSkillId != null).map((e) => e.promotedToSkillId);
this.db.prepare(
`UPDATE session_summaries SET
ended_at = ?, entries_count = ?, corrections_count = ?,
decisions_count = ?, key_learnings = ?, promoted_skill_ids = ?
WHERE session_id = ?`
).run(
now,
entries.length,
corrections,
decisions,
JSON.stringify(keyLearnings),
JSON.stringify(promotedSkillIds),
sessionId
);
return {
sessionId,
startedAt: row["started_at"],
endedAt: now,
entriesCount: entries.length,
correctionsCount: corrections,
decisionsCount: decisions,
keyLearnings,
promotedSkillIds
};
}
getSessionSummary(sessionId) {
const row = this.db.prepare("SELECT * FROM session_summaries WHERE session_id = ?").get(sessionId);
if (!row) return void 0;
return {
sessionId: row["session_id"],
startedAt: row["started_at"],
endedAt: row["ended_at"] || void 0,
entriesCount: row["entries_count"] || 0,
correctionsCount: row["corrections_count"] || 0,
decisionsCount: row["decisions_count"] || 0,
keyLearnings: JSON.parse(row["key_learnings"] || "[]"),
promotedSkillIds: JSON.parse(
row["promoted_skill_ids"] || "[]"
)
};
}
// ============================================================
// METRICS
// ============================================================
getMetrics() {
const skillsTotal = this.db.prepare("SELECT COUNT(*) as c FROM skills").get().c;
const catRows = this.db.prepare("SELECT category, COUNT(*) as c FROM skills GROUP BY category").all();
const skillsByCategory = {};
for (const row of catRows) {
skillsByCategory[row.category] = row.c;
}
const rulesTotal = this.db.prepare("SELECT COUNT(*) as c FROM skill_rules").get().c;
const journalEntriesTotal = this.db.prepare("SELECT COUNT(*) as c FROM journal_entries").get().c;
const sessionsTotal = this.db.prepare("SELECT COUNT(*) as c FROM session_summaries").get().c;
return {
skillsTotal,
skillsByCategory,
rulesTotal,
journalEntriesTotal,
sessionsTotal
};
}
// ============================================================
// SEED FROM RULES JSON
// ============================================================
seedFromRulesJson(rulesFile) {
const tx = this.db.transaction(() => {
this.setMatcherConfig(rulesFile.config, rulesFile.scoring);
for (const [dir, skill] of Object.entries(
rulesFile.directoryMappings || {}
)) {
this.setDirectoryMapping(dir, skill);
}
for (const [name, rule] of Object.entries(rulesFile.skills)) {
this.upsertRule(name, rule);
}
});
tx();
logger.info("SkillRegistry: seeded from rules JSON", {
rules: Object.keys(rulesFile.skills).length,
mappings: Object.keys(rulesFile.directoryMappings || {}).length
});
}
// ============================================================
// LIFECYCLE
// ============================================================
close() {
this.db.close();
}
}
let registryInstance;
function getSkillRegistry(dbPath) {
if (!registryInstance) {
registryInstance = new SkillRegistry(dbPath);
}
return registryInstance;
}
function resetSkillRegistry() {
if (registryInstance) {
registryInstance.close();
registryInstance = void 0;
}
}
export {
SkillRegistry,
getSkillRegistry,
resetSkillRegistry
};