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

647 lines (638 loc) 21.3 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 { 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 };