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

407 lines (401 loc) 14.5 kB
#!/usr/bin/env node import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { Command } from "commander"; import chalk from "chalk"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; function parseFrontmatter(content) { const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) { throw new Error("No YAML frontmatter found (expected --- delimiters)"); } const yamlBlock = match[1]; const body = match[2]; const fm = {}; let currentKey = null; let currentArray = null; for (const line of yamlBlock.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; if (trimmed.startsWith("- ") && currentKey && currentArray !== null) { currentArray.push(trimmed.slice(2).trim()); continue; } if (currentKey && currentArray !== null) { fm[currentKey] = currentArray; currentArray = null; currentKey = null; } const kvMatch = trimmed.match(/^(\w[\w-]*):\s*(.*)$/); if (!kvMatch) continue; const key = kvMatch[1]; const val = kvMatch[2].trim(); if (val === "" || val === "[]") { currentKey = key; currentArray = []; } else if (val.startsWith("[") && val.endsWith("]")) { fm[key] = val.slice(1, -1).split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")); } else { fm[key] = val.replace(/^['"]|['"]$/g, ""); } } if (currentKey && currentArray !== null) { fm[currentKey] = currentArray; } return { frontmatter: { name: fm["name"] || "unknown", version: fm["version"] || "0.0.0", domain: fm["domain"] || "general", expires: fm["expires"] || "", activates_on: fm["activates_on"] || [], sources: fm["sources"] || [], context7: fm["context7"] }, body }; } function getSkillDirs() { const dirs = []; const globalDir = path.join(os.homedir(), ".claude", "skills", "knowledge"); if (fs.existsSync(globalDir)) { dirs.push({ dir: globalDir, scope: "global" }); } const projectDir = path.join(process.cwd(), ".claude", "skills", "knowledge"); if (fs.existsSync(projectDir)) { dirs.push({ dir: projectDir, scope: "project" }); } return dirs; } function discoverSkills() { const skills = []; const now = /* @__PURE__ */ new Date(); for (const { dir, scope } of getSkillDirs()) { const files = fs.readdirSync(dir).filter((f) => f.endsWith(".skill.md")); for (const file of files) { const filePath = path.join(dir, file); try { const content = fs.readFileSync(filePath, "utf-8"); const { frontmatter, body } = parseFrontmatter(content); const stale = frontmatter.expires ? new Date(frontmatter.expires) < now : false; skills.push({ path: filePath, scope, frontmatter, body, stale }); } catch { } } } return skills; } function matchSkills(prompt, skills) { const words = new Set(prompt.toLowerCase().split(/\s+/)); const results = []; for (const skill of skills) { const matched = []; for (const keyword of skill.frontmatter.activates_on) { const kw = keyword.toLowerCase(); if (words.has(kw) || prompt.toLowerCase().includes(kw)) { matched.push(keyword); } } if (matched.length > 0) { const ratio = matched.length / skill.frontmatter.activates_on.length; const scopeBoost = skill.scope === "project" ? 0.1 : 0; const stalepenalty = skill.stale ? -0.2 : 0; results.push({ skill, score: Math.min(1, ratio + scopeBoost + stalepenalty), matchedKeywords: matched }); } } return results.sort((a, b) => b.score - a.score); } function createSkillCommand() { const cmd = new Command("skill").description( "Manage knowledge skills (.skill.md) \u2014 domain expertise for AI agents" ); cmd.command("list").description("List all discovered knowledge skills").option("--stale", "Show only stale (expired) skills").option("--json", "Output as JSON").action((options) => { let skills = discoverSkills(); if (options.stale) { skills = skills.filter((s) => s.stale); } if (options.json) { const out = skills.map((s) => ({ name: s.frontmatter.name, domain: s.frontmatter.domain, version: s.frontmatter.version, scope: s.scope, expires: s.frontmatter.expires, stale: s.stale, keywords: s.frontmatter.activates_on.length, path: s.path })); console.log(JSON.stringify(out, null, 2)); return; } if (skills.length === 0) { console.log(chalk.yellow("No knowledge skills found.")); console.log(` Global: ~/.claude/skills/knowledge/*.skill.md`); console.log(` Project: .claude/skills/knowledge/*.skill.md`); return; } console.log(chalk.bold(` Knowledge Skills (${skills.length}) `)); for (const s of skills) { const status = s.stale ? chalk.red("expired") : chalk.green("active"); const scope = s.scope === "global" ? chalk.dim("global") : chalk.cyan("project"); const keywords = chalk.dim( `[${s.frontmatter.activates_on.slice(0, 5).join(", ")}${s.frontmatter.activates_on.length > 5 ? "..." : ""}]` ); console.log( ` ${chalk.bold(s.frontmatter.name.padEnd(20))} ${s.frontmatter.domain.padEnd(12)} ${s.frontmatter.version.padEnd(14)} ${scope.padEnd(18)} ${status} ${keywords}` ); } const staleCount = skills.filter((s) => s.stale).length; if (staleCount > 0) { console.log( chalk.yellow( ` ${staleCount} skill(s) expired \u2014 run ${chalk.bold("stackmemory skill refresh")} to update` ) ); } console.log(); }); cmd.command("match <prompt>").description("Find skills matching a prompt (keyword match)").option("-n, --limit <n>", "Max results", "5").option("--json", "Output as JSON").action((prompt, options) => { const skills = discoverSkills(); const matches = matchSkills(prompt, skills).slice( 0, parseInt(options.limit, 10) ); if (options.json) { const out = matches.map((m) => ({ name: m.skill.frontmatter.name, domain: m.skill.frontmatter.domain, score: Math.round(m.score * 100), stale: m.skill.stale, matchedKeywords: m.matchedKeywords, path: m.skill.path })); console.log(JSON.stringify(out, null, 2)); return; } if (matches.length === 0) { console.log(chalk.yellow(`No skills match: "${prompt}"`)); return; } console.log(chalk.bold(` Matches for: "${prompt}" `)); for (const m of matches) { const pct = Math.round(m.score * 100); const bar = pct >= 50 ? chalk.green(`${pct}%`) : chalk.yellow(`${pct}%`); const staleTag = m.skill.stale ? chalk.red(" [stale]") : ""; console.log( ` ${bar.padEnd(16)} ${chalk.bold(m.skill.frontmatter.name.padEnd(20))} ${chalk.dim(m.matchedKeywords.join(", "))}${staleTag}` ); } console.log(); }); cmd.command("show <name>").description("Display a skill's full content").action((name) => { const skills = discoverSkills(); const skill = skills.find((s) => s.frontmatter.name === name); if (!skill) { console.error(chalk.red(`Skill not found: ${name}`)); console.log( `Available: ${skills.map((s) => s.frontmatter.name).join(", ")}` ); process.exit(1); } const status = skill.stale ? chalk.red("EXPIRED") : chalk.green("active"); console.log( chalk.bold(` ${skill.frontmatter.name}`) + ` (${skill.frontmatter.domain}) ${status}` ); console.log( chalk.dim( ` v${skill.frontmatter.version} | expires ${skill.frontmatter.expires} | ${skill.scope}` ) ); console.log(chalk.dim(` ${skill.path}`)); if (skill.frontmatter.context7) { console.log(chalk.dim(` context7: ${skill.frontmatter.context7}`)); } console.log( chalk.dim(` keywords: ${skill.frontmatter.activates_on.join(", ")}`) ); console.log(chalk.dim(` sources:`)); for (const src of skill.frontmatter.sources) { console.log(chalk.dim(` - ${src}`)); } console.log(); console.log(skill.body); }); cmd.command("add <source>").description("Add a knowledge skill from a local file or URL").option( "--global", "Install to ~/.claude/skills/knowledge/ (default: project)" ).option("--name <name>", "Override skill name").action( async (source, options) => { let content = ""; if (source.startsWith("http://") || source.startsWith("https://")) { try { const res = await fetch(source); if (!res.ok) throw new Error(`HTTP ${res.status}`); content = await res.text(); } catch (err) { console.error(chalk.red(`Failed to fetch: ${source}`)); console.error(err.message); process.exit(1); return; } } else { const resolved = path.resolve(source); if (!fs.existsSync(resolved)) { console.error(chalk.red(`File not found: ${resolved}`)); process.exit(1); return; } content = fs.readFileSync(resolved, "utf-8"); } let parsed; try { parsed = parseFrontmatter(content); } catch (err) { console.error( chalk.red(`Invalid skill file: ${err.message}`) ); process.exit(1); return; } if (!parsed) return; const skillName = options.name ?? parsed.frontmatter.name; const targetDir = options.global ? path.join(os.homedir(), ".claude", "skills", "knowledge") : path.join(process.cwd(), ".claude", "skills", "knowledge"); fs.mkdirSync(targetDir, { recursive: true }); const targetPath = path.join(targetDir, `${skillName}.skill.md`); const exists = fs.existsSync(targetPath); fs.writeFileSync(targetPath, content, "utf-8"); const action = exists ? "Updated" : "Added"; const scope = options.global ? "global" : "project"; console.log( chalk.green(`${action} ${chalk.bold(skillName)} (${scope})`) ); console.log(chalk.dim(` ${targetPath}`)); console.log( chalk.dim( ` domain: ${parsed.frontmatter.domain} | keywords: ${parsed.frontmatter.activates_on.length} | expires: ${parsed.frontmatter.expires}` ) ); } ); cmd.command("refresh [name]").description("Check freshness and show stale skills that need updating").option("--fetch", "Attempt to fetch source URLs and show diff summary").action(async (name, options) => { let skills = discoverSkills(); if (name) { skills = skills.filter((s) => s.frontmatter.name === name); if (skills.length === 0) { console.error(chalk.red(`Skill not found: ${name}`)); process.exit(1); } } const stale = skills.filter((s) => s.stale); const fresh = skills.filter((s) => !s.stale); console.log(chalk.bold(` Skill Freshness Report `)); console.log( ` ${chalk.green(String(fresh.length))} active ${chalk.red(String(stale.length))} expired ${skills.length} total ` ); if (stale.length === 0) { console.log(chalk.green(" All skills are fresh.")); console.log(); return; } for (const s of stale) { const daysPast = Math.floor( (Date.now() - new Date(s.frontmatter.expires).getTime()) / 864e5 ); console.log( ` ${chalk.red("expired")} ${chalk.bold(s.frontmatter.name.padEnd(20))} ${chalk.dim(`${daysPast}d ago`)} ${s.scope}` ); if (s.frontmatter.context7) { console.log( chalk.dim(` context7: ${s.frontmatter.context7}`) ); } for (const src of s.frontmatter.sources) { console.log(chalk.dim(` source: ${src}`)); } if (options.fetch && s.frontmatter.sources.length > 0) { const url = s.frontmatter.sources[0]; try { const res = await fetch(url, { method: "HEAD" }); console.log( res.ok ? chalk.green(` ${url} \u2014 reachable (${res.status})`) : chalk.yellow(` ${url} \u2014 ${res.status}`) ); } catch { console.log(chalk.red(` ${url} \u2014 unreachable`)); } } } console.log( chalk.yellow( ` To update: edit the .skill.md file and bump the version + expires date.` ) ); if (!options.fetch) { console.log( chalk.dim(` Run with --fetch to check source URL reachability.`) ); } console.log(); }); cmd.command("init <name>").description("Scaffold a new .skill.md file").option("--global", "Create in ~/.claude/skills/knowledge/").option("--domain <domain>", "Skill domain", "general").action((name, options) => { const targetDir = options.global ? path.join(os.homedir(), ".claude", "skills", "knowledge") : path.join(process.cwd(), ".claude", "skills", "knowledge"); fs.mkdirSync(targetDir, { recursive: true }); const targetPath = path.join(targetDir, `${name}.skill.md`); if (fs.existsSync(targetPath)) { console.error(chalk.red(`Already exists: ${targetPath}`)); process.exit(1); } const sixMonths = /* @__PURE__ */ new Date(); sixMonths.setMonth(sixMonths.getMonth() + 6); const expiresDate = sixMonths.toISOString().slice(0, 10); const template = `--- name: ${name} version: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10).replace(/-/g, ".")} domain: ${options.domain} expires: ${expiresDate} activates_on: [${name}] sources: - https://docs.example.com context7: --- # ${name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, " ")} ## Current SDK & Versions - TODO: add current versions ## Preferred Patterns - TODO: document the right way to do things ## Gotchas - TODO: things LLMs consistently get wrong ## Entry Points - TODO: canonical doc URLs `; fs.writeFileSync(targetPath, template, "utf-8"); console.log(chalk.green(`Created ${chalk.bold(name)} skill`)); console.log(chalk.dim(` ${targetPath}`)); console.log( chalk.dim( ` Edit the file to add domain knowledge, then update activates_on keywords.` ) ); }); return cmd; } export { createSkillCommand };