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

411 lines (410 loc) 13.5 kB
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, writeFileSync, readFileSync, readdirSync, watch } from "fs"; import { join } from "path"; import { logger } from "../monitoring/logger.js"; import { frameLifecycleHooks } from "../context/frame-lifecycle-hooks.js"; import { WikiCompiler } from "../wiki/wiki-compiler.js"; class ObsidianVaultAdapter { config; dirs; watcher = null; ingestCallback = null; seenRawFiles = /* @__PURE__ */ new Set(); unregisterCreate = null; unregisterClose = null; wikiCompiler = null; constructor(config) { this.config = { vaultPath: config.vaultPath, subdir: config.subdir ?? "stackmemory", watchRaw: config.watchRaw ?? true, autoIndex: config.autoIndex ?? true, includeEvents: config.includeEvents ?? false }; const root = join(this.config.vaultPath, this.config.subdir); this.dirs = { root, frames: join(root, "frames"), raw: join(root, "raw"), sessions: join(root, "sessions"), wiki: join(root, "wiki") }; } /** Initialize vault directory structure and register lifecycle hooks */ async initialize() { for (const dir of Object.values(this.dirs)) { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } const rawReadme = join(this.dirs.raw, "README.md"); if (!existsSync(rawReadme)) { writeFileSync( rawReadme, [ "# Raw Ingest", "", "Drop .md files here (e.g., from Obsidian Web Clipper).", "StackMemory will auto-ingest them as frames.", "", "## Setup", "", "1. Install [Obsidian Web Clipper](https://obsidian.md/clipper)", "2. Set the clip destination to this folder", "3. StackMemory watches for new files and ingests them" ].join("\n") ); } this.wikiCompiler = new WikiCompiler({ wikiDir: this.dirs.wiki }); await this.wikiCompiler.initialize(); this.unregisterCreate = frameLifecycleHooks.onFrameCreated( "obsidian-vault", async (frame) => { await this.writeFrame(frame); } ); this.unregisterClose = frameLifecycleHooks.onFrameClosed( "obsidian-vault", async (data) => { await this.writeFrame(data.frame, data.events, data.anchors); if (this.config.autoIndex) { await this.updateIndex(); } } ); if (this.config.watchRaw) { this.startWatching(); } if (this.config.autoIndex) { await this.updateIndex(); } logger.info("Obsidian vault adapter initialized", { vault: this.config.vaultPath, subdir: this.config.subdir }); } /** Set callback for when raw files are ingested */ onIngest(callback) { this.ingestCallback = callback; } /** Write a frame as a .md file */ async writeFrame(frame, events, anchors) { const filename = this.frameFilename(frame); const filepath = join(this.dirs.frames, filename); const content = this.serializeFrame(frame, events, anchors); writeFileSync(filepath, content); logger.debug("Wrote frame to vault", { frameId: frame.frame_id, file: filename }); return filepath; } /** Write a session summary */ async writeSessionSummary(sessionId, summary, frames) { const filename = `session-${sessionId.slice(0, 12)}.md`; const filepath = join(this.dirs.sessions, filename); const frameLinks = frames.map( (f) => `- [[${this.frameFilename(f).replace(".md", "")}|${f.name}]] (${f.type})` ).join("\n"); const content = [ "---", `session_id: "${sessionId}"`, `created: ${(/* @__PURE__ */ new Date()).toISOString()}`, `frame_count: ${frames.length}`, "---", "", `# Session ${sessionId.slice(0, 12)}`, "", summary, "", "## Frames", "", frameLinks ].join("\n"); writeFileSync(filepath, content); return filepath; } /** Update the auto-maintained index.md */ async updateIndex() { const framesDir = this.dirs.frames; if (!existsSync(framesDir)) return; const files = readdirSync(framesDir).filter((f) => f.endsWith(".md")).sort().reverse(); const frameLinks = files.slice(0, 100).map((f) => { const name = f.replace(".md", ""); return `- [[frames/${name}]]`; }); const typeCounts = {}; for (const f of files) { const match = f.match(/^(\w+)-/); if (match) { typeCounts[match[1]] = (typeCounts[match[1]] || 0) + 1; } } const typeStats = Object.entries(typeCounts).map(([type, count]) => `| ${type} | ${count} |`).join("\n"); const sessionsDir = this.dirs.sessions; const sessionFiles = existsSync(sessionsDir) ? readdirSync(sessionsDir).filter( (f) => f.endsWith(".md") && f !== "README.md" ) : []; const sessionLinks = sessionFiles.slice(0, 20).map((f) => `- [[sessions/${f.replace(".md", "")}]]`); const content = [ "---", `updated: ${(/* @__PURE__ */ new Date()).toISOString()}`, `total_frames: ${files.length}`, "---", "", "# StackMemory Index", "", `> Auto-maintained by StackMemory. ${files.length} frames indexed.`, "", "## Frame Types", "", "| Type | Count |", "|------|-------|", typeStats, "", "## Recent Frames", "", ...frameLinks.slice(0, 30), files.length > 30 ? ` _...and ${files.length - 30} more_` : "", "", sessionLinks.length > 0 ? "## Sessions\n" : "", ...sessionLinks, "", "## Raw Ingest", "", "[[raw/README|Drop web clipper files in raw/]]" ].join("\n"); writeFileSync(join(this.dirs.root, "index.md"), content); } /** Serialize a Frame to Obsidian-flavored markdown */ serializeFrame(frame, events, anchors) { const lines = []; lines.push("---"); lines.push(`frame_id: "${frame.frame_id}"`); lines.push(`type: "${frame.type}"`); lines.push(`name: "${this.escapeYaml(frame.name)}"`); lines.push(`state: "${frame.state}"`); lines.push(`depth: ${frame.depth}`); lines.push(`project_id: "${frame.project_id}"`); lines.push(`run_id: "${frame.run_id}"`); lines.push(`created_at: ${frame.created_at}`); if (frame.closed_at) lines.push(`closed_at: ${frame.closed_at}`); if (frame.parent_frame_id) lines.push(`parent_frame_id: "${frame.parent_frame_id}"`); lines.push(`tags: [stackmemory, ${frame.type}]`); lines.push("---"); lines.push(""); lines.push(`# ${frame.name}`); lines.push(""); if (frame.parent_frame_id) { lines.push( `Parent: [[${frame.type}-${frame.parent_frame_id.slice(0, 8)}]]` ); lines.push(""); } lines.push( `**Type:** \`${frame.type}\` | **State:** \`${frame.state}\` | **Depth:** ${frame.depth}` ); lines.push(`**Created:** ${new Date(frame.created_at).toISOString()}`); if (frame.closed_at) { const duration = Math.round((frame.closed_at - frame.created_at) / 1e3); lines.push( `**Closed:** ${new Date(frame.closed_at).toISOString()} (${duration}s)` ); } lines.push(""); if (frame.digest_text) { lines.push("## Digest"); lines.push(""); lines.push(frame.digest_text); lines.push(""); } if (frame.inputs && Object.keys(frame.inputs).length > 0) { lines.push("## Inputs"); lines.push(""); lines.push("```json"); lines.push(JSON.stringify(frame.inputs, null, 2)); lines.push("```"); lines.push(""); } if (frame.outputs && Object.keys(frame.outputs).length > 0) { lines.push("## Outputs"); lines.push(""); lines.push("```json"); lines.push(JSON.stringify(frame.outputs, null, 2)); lines.push("```"); lines.push(""); } if (anchors && anchors.length > 0) { lines.push("## Anchors"); lines.push(""); for (const anchor of anchors) { lines.push(`### ${anchor.type}: ${this.escapeYaml(anchor.content)}`); lines.push(""); } } if (this.config.includeEvents && events && events.length > 0) { lines.push("## Events"); lines.push(""); for (const event of events.slice(-20)) { const ts = new Date(event.ts).toISOString().substring(11, 19); lines.push( `- \`${ts}\` **${event.event_type}** ${this.summarizePayload(event.payload)}` ); } lines.push(""); } return lines.join("\n"); } /** Generate a filename for a frame */ frameFilename(frame) { const id = frame.frame_id.slice(0, 8); const name = frame.name.replace(/[^a-zA-Z0-9-_]/g, "-").replace(/-+/g, "-").slice(0, 40); return `${frame.type}-${id}-${name}.md`; } /** Escape special YAML characters */ escapeYaml(s) { return s.replace(/"/g, '\\"').replace(/\n/g, " "); } /** Summarize event payload for compact display */ summarizePayload(payload) { const str = JSON.stringify(payload); return str.length > 120 ? str.slice(0, 117) + "..." : str; } // ── Raw File Watcher ── /** Watch raw/ directory for new .md files from web clipper */ startWatching() { if (!existsSync(this.dirs.raw)) return; for (const f of readdirSync(this.dirs.raw)) { this.seenRawFiles.add(f); } this.watcher = watch(this.dirs.raw, async (eventType, filename) => { if (!filename || !filename.endsWith(".md")) return; if (filename === "README.md") return; if (this.seenRawFiles.has(filename)) return; const filepath = join(this.dirs.raw, filename); if (!existsSync(filepath)) return; await new Promise((resolve) => setTimeout(resolve, 1e3)); if (!existsSync(filepath)) return; this.seenRawFiles.add(filename); logger.info("Raw file detected for ingest", { filename }); try { const content = readFileSync(filepath, "utf-8"); const metadata = this.parseClipperMetadata(content); if (this.ingestCallback) { await this.ingestCallback(filename, content, metadata); } if (this.wikiCompiler) { try { await this.wikiCompiler.compileRawIngest( filename, content, metadata ); } catch (err) { logger.debug("Wiki compile on raw ingest failed", { error: err.message }); } } logger.info("Ingested raw file", { filename, source: metadata.source || "unknown" }); } catch (err) { logger.error("Failed to ingest raw file", { filename, error: err.message }); } }); logger.info("Watching raw/ for web clipper files", { dir: this.dirs.raw }); } /** Parse Obsidian Web Clipper YAML frontmatter */ parseClipperMetadata(content) { const metadata = {}; if (!content.startsWith("---")) return metadata; const end = content.indexOf("---", 3); if (end === -1) return metadata; const yaml = content.slice(3, end).trim(); for (const line of yaml.split("\n")) { const match = line.match(/^(\w+):\s*(.+)/); if (match) { metadata[match[1]] = match[2].replace(/^["']|["']$/g, ""); } } return metadata; } /** Stop watching and clean up */ async cleanup() { if (this.watcher) { this.watcher.close(); this.watcher = null; } if (this.unregisterCreate) this.unregisterCreate(); if (this.unregisterClose) this.unregisterClose(); logger.info("Obsidian vault adapter cleaned up"); } } let _instance = null; async function initObsidianVault() { if (_instance) return _instance; try { const configPath = join(process.cwd(), ".stackmemory", "config.yaml"); if (!existsSync(configPath)) return null; const content = readFileSync(configPath, "utf-8"); const vaultMatch = content.match( /obsidian:\s*\n\s+vaultPath:\s*["']?([^\n"']+)/ ); if (!vaultMatch) return null; const vaultPath = vaultMatch[1].trim(); if (!vaultPath || !existsSync(vaultPath)) { logger.warn("Obsidian vaultPath configured but directory not found", { vaultPath }); return null; } const subdirMatch = content.match( /obsidian:\s*\n(?:\s+\w+:.*\n)*\s+subdir:\s*["']?([^\n"']+)/ ); const watchRawMatch = content.match( /obsidian:\s*\n(?:\s+\w+:.*\n)*\s+watchRaw:\s*(true|false)/ ); const autoIndexMatch = content.match( /obsidian:\s*\n(?:\s+\w+:.*\n)*\s+autoIndex:\s*(true|false)/ ); _instance = new ObsidianVaultAdapter({ vaultPath, subdir: subdirMatch?.[1]?.trim(), watchRaw: watchRawMatch ? watchRawMatch[1] === "true" : void 0, autoIndex: autoIndexMatch ? autoIndexMatch[1] === "true" : void 0 }); await _instance.initialize(); logger.info("Obsidian vault adapter auto-initialized", { vaultPath }); return _instance; } catch (err) { logger.debug("Obsidian vault adapter not initialized", { error: err.message }); return null; } } function getObsidianVault() { return _instance; } export { ObsidianVaultAdapter, getObsidianVault, initObsidianVault };