@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
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,
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
};