@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
1,012 lines (1,011 loc) • 34.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 {
existsSync,
mkdirSync,
writeFileSync,
readFileSync,
readdirSync
} from "fs";
import { join } from "path";
import { logger } from "../monitoring/logger.js";
class WikiCompiler {
config;
dirs;
constructor(config) {
this.config = {
wikiDir: config.wikiDir,
indexLimit: config.indexLimit ?? 200,
logLimit: config.logLimit ?? 500
};
this.dirs = {
root: this.config.wikiDir,
entities: join(this.config.wikiDir, "entities"),
concepts: join(this.config.wikiDir, "concepts"),
sources: join(this.config.wikiDir, "sources"),
synthesis: join(this.config.wikiDir, "synthesis")
};
}
/** Initialize wiki directory structure */
async initialize() {
for (const dir of Object.values(this.dirs)) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
const indexPath = join(this.dirs.root, "index.md");
if (!existsSync(indexPath)) {
writeFileSync(indexPath, this.generateEmptyIndex());
}
const logPath = join(this.dirs.root, "log.md");
if (!existsSync(logPath)) {
writeFileSync(
logPath,
[
"# Wiki Log",
"",
"> Chronological record of wiki operations. Parseable with grep.",
""
].join("\n")
);
}
logger.info("Wiki compiler initialized", { wikiDir: this.config.wikiDir });
}
/**
* Create the wiki from scratch using all available context.
* Generates entity pages, concept pages, source digests, and a synthesis overview.
*/
async create(ctx) {
const created = [];
const entitiesByName = this.groupBy(ctx.entities, (e) => e.entity_name);
for (const [name, states] of entitiesByName) {
const slug = this.slugify(name);
const path = `entities/${slug}.md`;
this.writeArticle(path, this.buildEntityArticle(name, states));
created.push(path);
}
const conceptGroups = this.extractConceptGroups(ctx.anchors);
for (const [concept, items] of conceptGroups) {
const slug = this.slugify(concept);
const path = `concepts/${slug}.md`;
this.writeArticle(path, this.buildConceptArticle(concept, items));
created.push(path);
}
for (const digest of ctx.digests) {
const slug = this.slugify(digest.frame_name);
const path = `sources/${slug}.md`;
this.writeArticle(path, this.buildSourceArticle(digest));
created.push(path);
}
if (ctx.digests.length > 0 || ctx.entities.length > 0) {
const overviewPath = "synthesis/overview.md";
this.writeArticle(
overviewPath,
this.buildSynthesisOverview(ctx.digests, ctx.entities, ctx.anchors)
);
created.push(overviewPath);
}
if (ctx.sessionDigest) {
const path = `sources/session-${this.slugify(ctx.sessionDigest.period)}.md`;
this.writeArticle(path, this.buildSessionSource(ctx.sessionDigest));
created.push(path);
}
this.updateIndex();
this.appendLog(
`## [${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}] create | full wiki`,
`Created ${created.length} articles from ${ctx.digests.length} digests, ${ctx.entities.length} entity states, ${ctx.anchors.length} anchors`
);
return {
created,
updated: [],
totalArticles: this.countArticles(),
compiledAt: Date.now()
};
}
/**
* Incrementally update the wiki with new context since last compile.
* Updates existing articles or creates new ones as needed.
*/
async update(ctx) {
const created = [];
const updated = [];
const entitiesByName = this.groupBy(ctx.entities, (e) => e.entity_name);
for (const [name, states] of entitiesByName) {
const slug = this.slugify(name);
const path = `entities/${slug}.md`;
const filepath = join(this.dirs.root, path);
if (existsSync(filepath)) {
const existing = readFileSync(filepath, "utf-8");
const merged = this.mergeEntityStates(existing, name, states);
if (merged !== existing) {
this.writeArticle(path, merged);
updated.push(path);
}
} else {
this.writeArticle(path, this.buildEntityArticle(name, states));
created.push(path);
}
}
const conceptGroups = this.extractConceptGroups(ctx.anchors);
for (const [concept, items] of conceptGroups) {
const slug = this.slugify(concept);
const path = `concepts/${slug}.md`;
const filepath = join(this.dirs.root, path);
if (existsSync(filepath)) {
const existing = readFileSync(filepath, "utf-8");
const merged = this.mergeConceptAnchors(existing, items);
if (merged !== existing) {
this.writeArticle(path, merged);
updated.push(path);
}
} else {
this.writeArticle(path, this.buildConceptArticle(concept, items));
created.push(path);
}
}
for (const digest of ctx.digests) {
const slug = this.slugify(digest.frame_name);
const path = `sources/${slug}.md`;
const filepath = join(this.dirs.root, path);
if (!existsSync(filepath)) {
this.writeArticle(path, this.buildSourceArticle(digest));
created.push(path);
}
}
if (created.length > 0 || updated.length > 0) {
const overviewPath = "synthesis/overview.md";
const allDigests = this.readExistingSources();
this.writeArticle(
overviewPath,
this.buildSynthesisFromExisting(allDigests, ctx.entities, ctx.anchors)
);
if (existsSync(join(this.dirs.root, overviewPath))) {
updated.push(overviewPath);
} else {
created.push(overviewPath);
}
}
if (ctx.sessionDigest) {
const path = `sources/session-${this.slugify(ctx.sessionDigest.period)}.md`;
this.writeArticle(path, this.buildSessionSource(ctx.sessionDigest));
const filepath = join(this.dirs.root, path);
if (existsSync(filepath)) updated.push(path);
else created.push(path);
}
if (created.length > 0 || updated.length > 0) {
this.updateIndex();
this.appendLog(
`## [${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}] update | incremental`,
`${created.length} created, ${updated.length} updated from ${ctx.digests.length} digests, ${ctx.entities.length} entity states`
);
}
return {
created,
updated,
totalArticles: this.countArticles(),
compiledAt: Date.now()
};
}
/**
* Compile a single raw ingest file into a source article.
*/
async compileRawIngest(filename, content, metadata) {
const created = [];
const title = metadata["title"] || filename.replace(".md", "");
const sourceSlug = this.slugify(title);
const sourcePath = `sources/${sourceSlug}.md`;
const lines = [
"---",
`title: "${this.escapeYaml(title)}"`,
`category: source`,
`source_file: "${filename}"`,
`created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
metadata["source"] ? `url: "${metadata["source"]}"` : "",
`tags: [source, raw-ingest]`,
"---",
"",
`# ${title}`,
"",
metadata["source"] ? `> Source: ${metadata["source"]}` : "",
"",
"## Summary",
"",
this.extractSummary(content),
"",
"## Raw Content",
"",
content.slice(0, 2e3),
content.length > 2e3 ? "\n_...truncated..._" : ""
].filter(Boolean);
this.writeArticle(sourcePath, lines.join("\n"));
created.push(sourcePath);
this.updateIndex();
this.appendLog(
`## [${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}] ingest | ${title}`,
`Raw file \`${filename}\` \u2014 1 source article created`
);
return {
created,
updated: [],
totalArticles: this.countArticles(),
compiledAt: Date.now()
};
}
/**
* Ingest a URL — fetch page content, convert to markdown, compile into wiki.
* Supports single pages and basic site crawling (follows internal links up to maxPages).
*/
async ingestUrl(url, opts) {
const maxPages = opts?.maxPages ?? 20;
const created = [];
const visited = /* @__PURE__ */ new Set();
const queue = [url];
let baseHost;
try {
baseHost = new URL(url).hostname;
} catch {
return {
created: [],
updated: [],
totalArticles: this.countArticles(),
compiledAt: Date.now()
};
}
while (queue.length > 0 && visited.size < maxPages) {
const pageUrl = queue.shift();
if (!pageUrl || visited.has(pageUrl)) continue;
visited.add(pageUrl);
try {
const { title, content, links } = await this.fetchPage(pageUrl);
if (!content || content.length < 50) continue;
const slug = this.slugify(title || this.urlToSlug(pageUrl));
const sourcePath = `sources/${slug}.md`;
const article = [
"---",
`title: "${this.escapeYaml(title || pageUrl)}"`,
`category: source`,
`url: "${pageUrl}"`,
`created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`tags: [source, web-ingest, ${baseHost}]`,
"---",
"",
`# ${title || pageUrl}`,
"",
`> Source: ${pageUrl}`,
"",
content.slice(0, 8e3),
content.length > 8e3 ? "\n\n_...truncated..._" : "",
""
].join("\n");
this.writeArticle(sourcePath, article);
created.push(sourcePath);
for (const link of links) {
try {
const parsed = new URL(link, pageUrl);
if (parsed.hostname === baseHost && !visited.has(parsed.href)) {
queue.push(parsed.href);
}
} catch {
}
}
} catch {
}
}
if (created.length > 0) {
this.updateIndex();
this.appendLog(
`## [${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}] ingest-url | ${baseHost}`,
`Crawled ${visited.size} pages from ${url} \u2014 ${created.length} articles created`
);
}
return {
created,
updated: [],
totalArticles: this.countArticles(),
compiledAt: Date.now()
};
}
/**
* Ingest a local file or directory into the wiki.
*/
async ingestPath(filePath) {
const created = [];
const { statSync } = await import("fs");
const stat = statSync(filePath);
const processFile = (fp) => {
if (!existsSync(fp)) return;
const content = readFileSync(fp, "utf-8");
const basename = fp.split("/").pop() ?? fp;
const ext = basename.split(".").pop() ?? "";
if (![
"md",
"txt",
"json",
"yaml",
"yml",
"toml",
"ts",
"js",
"py"
].includes(ext))
return;
const title = basename.replace(/\.[^.]+$/, "");
const slug = this.slugify(title);
const sourcePath = `sources/${slug}.md`;
const article = [
"---",
`title: "${this.escapeYaml(title)}"`,
`category: source`,
`source_file: "${fp}"`,
`created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`tags: [source, local-ingest]`,
"---",
"",
`# ${title}`,
"",
`> Source: \`${fp}\``,
"",
content.slice(0, 8e3),
content.length > 8e3 ? "\n\n_...truncated..._" : "",
""
].join("\n");
this.writeArticle(sourcePath, article);
created.push(sourcePath);
};
if (stat.isDirectory()) {
const entries = readdirSync(filePath, { recursive: true });
for (const entry of entries) {
processFile(join(filePath, entry));
}
} else {
processFile(filePath);
}
if (created.length > 0) {
this.updateIndex();
this.appendLog(
`## [${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}] ingest-path | ${filePath}`,
`${created.length} articles from local path`
);
}
return {
created,
updated: [],
totalArticles: this.countArticles(),
compiledAt: Date.now()
};
}
/** Fetch a web page and extract title, markdown content, and links */
async fetchPage(url) {
const res = await fetch(url, {
headers: { "User-Agent": "StackMemory-Wiki/1.0" },
signal: AbortSignal.timeout(1e4)
});
if (!res.ok) return { title: "", content: "", links: [] };
const html = await res.text();
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
const title = titleMatch?.[1]?.trim() ?? "";
const links = [];
const linkRe = /href="([^"]+)"/g;
let linkMatch;
while ((linkMatch = linkRe.exec(html)) !== null) {
const href = linkMatch[1] ?? "";
if (href && !href.startsWith("#") && !href.startsWith("javascript:") && !href.startsWith("mailto:")) {
links.push(href);
}
}
const content = this.htmlToMarkdown(html);
return { title, content, links };
}
/** Simple HTML to markdown conversion */
htmlToMarkdown(html) {
let text = html;
text = text.replace(
/<(script|style|nav|footer|header|aside)[^>]*>[\s\S]*?<\/\1>/gi,
""
);
const mainMatch = text.match(
/<(?:main|article)[^>]*>([\s\S]*?)<\/(?:main|article)>/i
);
if (mainMatch) text = mainMatch[1] ?? text;
text = text.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "\n# $1\n");
text = text.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "\n## $1\n");
text = text.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "\n### $1\n");
text = text.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, "\n#### $1\n");
text = text.replace(/<p[^>]*>/gi, "\n");
text = text.replace(/<\/p>/gi, "\n");
text = text.replace(/<br\s*\/?>/gi, "\n");
text = text.replace(/<li[^>]*>/gi, "- ");
text = text.replace(/<\/li>/gi, "\n");
text = text.replace(
/<(?:strong|b)[^>]*>([\s\S]*?)<\/(?:strong|b)>/gi,
"**$1**"
);
text = text.replace(/<(?:em|i)[^>]*>([\s\S]*?)<\/(?:em|i)>/gi, "*$1*");
text = text.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, "`$1`");
text = text.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, "\n```\n$1\n```\n");
text = text.replace(
/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi,
"[$2]($1)"
);
text = text.replace(/<[^>]+>/g, "");
text = text.replace(/&/g, "&");
text = text.replace(/</g, "<");
text = text.replace(/>/g, ">");
text = text.replace(/"/g, '"');
text = text.replace(/'/g, "'");
text = text.replace(/ /g, " ");
text = text.replace(/\n{3,}/g, "\n\n");
text = text.trim();
return text;
}
/** Convert URL path to a readable slug */
urlToSlug(url) {
try {
const parsed = new URL(url);
const path = parsed.pathname.replace(/^\/|\/$/g, "");
return path ? this.slugify(path.replace(/\//g, "-")) : this.slugify(parsed.hostname);
} catch {
return this.slugify(url);
}
}
/** Lint the wiki for health issues */
async lint() {
const allArticles = this.listAllArticles();
const orphans = [];
const brokenLinks = [];
const stale = [];
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1e3;
const inboundLinks = /* @__PURE__ */ new Map();
for (const article of allArticles) {
inboundLinks.set(article, 0);
}
for (const article of allArticles) {
const filepath = join(this.dirs.root, article);
if (!existsSync(filepath)) continue;
const content = readFileSync(filepath, "utf-8");
const links = this.extractWikiLinks(content);
for (const link of links) {
if (allArticles.includes(link)) {
inboundLinks.set(link, (inboundLinks.get(link) || 0) + 1);
} else {
brokenLinks.push({ source: article, target: link });
}
}
const updatedMatch = content.match(/updated:\s*(.+)/);
if (updatedMatch) {
const updatedAt = new Date((updatedMatch[1] ?? "").trim()).getTime();
if (updatedAt < thirtyDaysAgo) {
stale.push(article);
}
}
}
for (const [article, count] of inboundLinks) {
if (count === 0 && article !== "index.md" && article !== "log.md") {
orphans.push(article);
}
}
return { orphans, brokenLinks, stale, totalArticles: allArticles.length };
}
/** Search wiki articles by keyword */
search(query) {
const results = [];
const allArticles = this.listAllArticles();
const queryLower = query.toLowerCase();
for (const article of allArticles) {
const filepath = join(this.dirs.root, article);
if (!existsSync(filepath)) continue;
const content = readFileSync(filepath, "utf-8").toLowerCase();
const matches = content.split(queryLower).length - 1;
if (matches > 0) {
const titleMatch = content.match(/^#\s+(.+)/m);
results.push({
path: article,
title: titleMatch?.[1] || article,
matches
});
}
}
return results.sort((a, b) => b.matches - a.matches);
}
/** Get wiki status summary */
getStatus() {
const byCategory = {};
for (const [category, dir] of Object.entries(this.dirs)) {
if (category === "root") continue;
if (!existsSync(dir)) {
byCategory[category] = 0;
continue;
}
byCategory[category] = readdirSync(dir).filter(
(f) => f.endsWith(".md")
).length;
}
const logPath = join(this.dirs.root, "log.md");
let lastCompile = null;
if (existsSync(logPath)) {
const logContent = readFileSync(logPath, "utf-8");
const entries = logContent.match(/^## \[.+$/gm);
if (entries && entries.length > 0) {
lastCompile = entries[entries.length - 1] ?? null;
}
}
return { totalArticles: this.countArticles(), byCategory, lastCompile };
}
/** Get timestamp of last compile from log.md */
getLastCompileTime() {
const logPath = join(this.dirs.root, "log.md");
if (!existsSync(logPath)) return null;
const content = readFileSync(logPath, "utf-8");
const entries = content.match(/^## \[(\d{4}-\d{2}-\d{2})\]/gm);
if (!entries || entries.length === 0) return null;
const lastEntry = entries[entries.length - 1] ?? "";
const dateMatch = lastEntry.match(/\[(\d{4}-\d{2}-\d{2})\]/);
if (!dateMatch) return null;
return (/* @__PURE__ */ new Date((dateMatch[1] ?? "") + "T00:00:00Z")).getTime() / 1e3;
}
// ── Article builders ──
buildEntityArticle(name, states) {
const current = states.filter((s) => s.superseded_at === null);
const historical = states.filter((s) => s.superseded_at !== null);
const lines = [
"---",
`title: "${this.escapeYaml(name)}"`,
`category: entity`,
`created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`tags: [entity]`,
"---",
"",
`# ${name}`,
""
];
if (current.length > 0) {
lines.push("## Current State", "");
for (const s of current) {
lines.push(`- **${s.relation}**: ${s.value}`);
if (s.context) lines.push(` - _Context: ${s.context}_`);
}
lines.push("");
}
if (historical.length > 0) {
lines.push("## History", "");
for (const s of historical.slice(-20)) {
const from = new Date(s.valid_from * 1e3).toISOString().slice(0, 10);
const to = s.superseded_at ? new Date(s.superseded_at * 1e3).toISOString().slice(0, 10) : "current";
lines.push(`- ${from} \u2192 ${to}: **${s.relation}** = ${s.value}`);
}
lines.push("");
}
return lines.join("\n");
}
buildConceptArticle(concept, anchors) {
const lines = [
"---",
`title: "${this.escapeYaml(concept)}"`,
`category: concept`,
`created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`tags: [concept, ${concept.toLowerCase().replace(/\s+/g, "-")}]`,
"---",
"",
`# ${concept}`,
""
];
const byType = this.groupBy(anchors, (a) => a.type);
for (const [type, items] of byType) {
lines.push(`## ${type}s`, "");
for (const a of items) {
const date = new Date(a.created_at * 1e3).toISOString().slice(0, 10);
lines.push(
`- ${a.text}`,
` - _${date} \u2014 from [[sources/${this.slugify(a.frame_name)}]]_`
);
}
lines.push("");
}
return lines.join("\n");
}
buildSourceArticle(digest) {
return [
"---",
`title: "${this.escapeYaml(digest.frame_name)}"`,
`category: source`,
`frame_id: "${digest.frame_id}"`,
`frame_type: "${digest.frame_type}"`,
`created: ${new Date(digest.created_at * 1e3).toISOString()}`,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`tags: [source, ${digest.frame_type}]`,
"---",
"",
`# ${digest.frame_name}`,
"",
`> Frame \`${digest.frame_id.slice(0, 8)}\` | Type: ${digest.frame_type}`,
"",
"## Summary",
"",
digest.digest_text,
""
].join("\n");
}
buildSessionSource(session) {
return [
"---",
`title: "Session \u2014 ${session.period}"`,
`category: source`,
`period: "${session.period}"`,
`created: ${new Date(session.generatedAt).toISOString()}`,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`tags: [source, session]`,
"---",
"",
`# Session \u2014 ${session.period}`,
"",
session.content,
""
].join("\n");
}
buildSynthesisOverview(digests, entities, anchors) {
const uniqueEntities = new Set(entities.map((e) => e.entity_name));
const decisions = anchors.filter((a) => a.type === "DECISION");
const risks = anchors.filter((a) => a.type === "RISK");
const lines = [
"---",
`title: "Synthesis Overview"`,
`category: synthesis`,
`created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`tags: [synthesis, overview]`,
"---",
"",
"# Synthesis Overview",
"",
`> Auto-generated from ${digests.length} frame digests, ${uniqueEntities.size} entities, ${anchors.length} anchors.`,
""
];
if (uniqueEntities.size > 0) {
lines.push("## Key Entities", "");
for (const name of Array.from(uniqueEntities).slice(0, 30)) {
lines.push(`- [[entities/${this.slugify(name)}|${name}]]`);
}
lines.push("");
}
if (decisions.length > 0) {
lines.push("## Key Decisions", "");
for (const d of decisions.slice(-20)) {
const date = new Date(d.created_at * 1e3).toISOString().slice(0, 10);
lines.push(`- ${d.text} _(${date})_`);
}
lines.push("");
}
if (risks.length > 0) {
lines.push("## Open Risks", "");
for (const r of risks.slice(-10)) {
lines.push(`- ${r.text}`);
}
lines.push("");
}
if (digests.length > 0) {
lines.push("## Recent Activity", "");
for (const d of digests.slice(-15)) {
const date = new Date(d.created_at * 1e3).toISOString().slice(0, 10);
lines.push(
`- ${date}: [[sources/${this.slugify(d.frame_name)}|${d.frame_name}]] (${d.frame_type})`
);
}
lines.push("");
}
return lines.join("\n");
}
buildSynthesisFromExisting(existingSources, newEntities, newAnchors) {
const uniqueEntities = new Set(newEntities.map((e) => e.entity_name));
if (existsSync(this.dirs.entities)) {
for (const f of readdirSync(this.dirs.entities)) {
if (f.endsWith(".md")) {
uniqueEntities.add(f.replace(".md", "").replace(/-/g, " "));
}
}
}
const decisions = newAnchors.filter((a) => a.type === "DECISION");
const lines = [
"---",
`title: "Synthesis Overview"`,
`category: synthesis`,
`created: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`tags: [synthesis, overview]`,
"---",
"",
"# Synthesis Overview",
"",
`> ${existingSources.length} source articles, ${uniqueEntities.size} entities tracked.`,
""
];
if (uniqueEntities.size > 0) {
lines.push("## Key Entities", "");
for (const name of Array.from(uniqueEntities).slice(0, 30)) {
lines.push(`- [[entities/${this.slugify(name)}|${name}]]`);
}
lines.push("");
}
if (decisions.length > 0) {
lines.push("## Recent Decisions", "");
for (const d of decisions.slice(-10)) {
lines.push(`- ${d.text}`);
}
lines.push("");
}
if (existingSources.length > 0) {
lines.push("## Sources", "");
for (const s of existingSources.slice(-20)) {
lines.push(`- [[${s.path.replace(".md", "")}|${s.title}]]`);
}
lines.push("");
}
return lines.join("\n");
}
// ── Merge helpers (for update) ──
mergeEntityStates(existing, _name, newStates) {
let content = existing;
for (const s of newStates) {
const entry = `- **${s.relation}**: ${s.value}`;
if (content.includes(s.value)) continue;
content = content.replace(
/updated:\s*.+/,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`
);
if (s.superseded_at === null) {
const marker = "## Current State";
const idx = content.indexOf(marker);
if (idx !== -1) {
const afterMarker = content.indexOf("\n\n", idx + marker.length);
if (afterMarker !== -1) {
content = content.slice(0, afterMarker) + "\n" + entry + (s.context ? `
- _Context: ${s.context}_` : "") + content.slice(afterMarker);
}
}
} else {
const from = new Date(s.valid_from * 1e3).toISOString().slice(0, 10);
const to = new Date(s.superseded_at * 1e3).toISOString().slice(0, 10);
const histEntry = `- ${from} \u2192 ${to}: **${s.relation}** = ${s.value}`;
if (!content.includes("## History")) {
content = content.trimEnd() + "\n\n## History\n\n" + histEntry + "\n";
} else {
content = content.trimEnd() + "\n" + histEntry + "\n";
}
}
}
return content;
}
mergeConceptAnchors(existing, newAnchors) {
let content = existing;
for (const a of newAnchors) {
if (content.includes(a.text)) continue;
content = content.replace(
/updated:\s*.+/,
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`
);
const date = new Date(a.created_at * 1e3).toISOString().slice(0, 10);
const entry = `- ${a.text}
- _${date} \u2014 from [[sources/${this.slugify(a.frame_name)}]]_`;
const sectionHeader = `## ${a.type}s`;
if (content.includes(sectionHeader)) {
const idx = content.indexOf(sectionHeader);
const nextSection = content.indexOf(
"\n## ",
idx + sectionHeader.length
);
const insertAt = nextSection !== -1 ? nextSection : content.length;
content = content.slice(0, insertAt).trimEnd() + "\n" + entry + "\n" + content.slice(insertAt);
} else {
content = content.trimEnd() + "\n\n" + sectionHeader + "\n\n" + entry + "\n";
}
}
return content;
}
// ── Concept extraction ──
extractConceptGroups(anchors) {
const groups = /* @__PURE__ */ new Map();
const conceptMap = {
DECISION: "Decisions",
FACT: "Key Facts",
CONSTRAINT: "Constraints",
RISK: "Risks",
TODO: "Action Items",
INTERFACE_CONTRACT: "Interface Contracts"
};
for (const anchor of anchors) {
const concept = conceptMap[anchor.type] || "Notes";
const list = groups.get(concept) || [];
list.push(anchor);
groups.set(concept, list);
}
return groups;
}
// ── Reading helpers ──
readExistingSources() {
const sources = [];
if (!existsSync(this.dirs.sources)) return sources;
for (const f of readdirSync(this.dirs.sources)) {
if (!f.endsWith(".md")) continue;
const filepath = join(this.dirs.sources, f);
const content = readFileSync(filepath, "utf-8");
const titleMatch = content.match(/^title:\s*"?([^"\n]+)"?\s*$/m);
sources.push({
title: titleMatch?.[1] || f.replace(".md", ""),
path: `sources/${f}`
});
}
return sources;
}
// ── Shared helpers ──
writeArticle(articlePath, content) {
const filepath = join(this.dirs.root, articlePath);
const dir = join(filepath, "..");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(filepath, content);
}
updateIndex() {
const articles = this.listAllArticlesWithMeta();
const grouped = {
entities: [],
concepts: [],
sources: [],
synthesis: []
};
for (const a of articles) {
const category = a.path.split("/")[0] || "other";
const group = grouped[category];
if (group) group.push(a);
}
const lines = [
"---",
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
`total_articles: ${articles.length}`,
"---",
"",
"# Wiki Index",
"",
`> ${articles.length} articles. Auto-maintained by StackMemory Wiki Compiler.`,
""
];
for (const [category, items] of Object.entries(grouped)) {
if (items.length === 0) continue;
lines.push(
`## ${category.charAt(0).toUpperCase() + category.slice(1)}`,
""
);
for (const item of items.slice(0, this.config.indexLimit)) {
const link = item.path.replace(".md", "");
lines.push(`- [[${link}|${item.title}]] \u2014 ${item.summary}`);
}
if (items.length > this.config.indexLimit) {
lines.push(`_...and ${items.length - this.config.indexLimit} more_`);
}
lines.push("");
}
writeFileSync(join(this.dirs.root, "index.md"), lines.join("\n"));
}
appendLog(heading, detail) {
const logPath = join(this.dirs.root, "log.md");
if (!existsSync(logPath)) return;
let content = readFileSync(logPath, "utf-8");
content += `
${heading}
${detail}
`;
const entries = content.match(/^## \[.+[\s\S]*?(?=^## \[|\Z)/gm);
if (entries && entries.length > this.config.logLimit) {
const header = content.split(/^## \[/m)[0] ?? "";
const kept = entries.slice(-this.config.logLimit);
content = header + kept.join("");
}
writeFileSync(logPath, content);
}
listAllArticles() {
const articles = [];
for (const [category, dir] of Object.entries(this.dirs)) {
if (category === "root") continue;
if (!existsSync(dir)) continue;
for (const f of readdirSync(dir)) {
if (f.endsWith(".md")) articles.push(`${category}/${f}`);
}
}
return articles;
}
listAllArticlesWithMeta() {
const articles = [];
for (const [category, dir] of Object.entries(this.dirs)) {
if (category === "root") continue;
if (!existsSync(dir)) continue;
for (const f of readdirSync(dir)) {
if (!f.endsWith(".md")) continue;
const filepath = join(dir, f);
const content = readFileSync(filepath, "utf-8");
const titleMatch = content.match(/^title:\s*"?([^"\n]+)"?\s*$/m);
const title = titleMatch?.[1] || f.replace(".md", "");
const body = content.replace(/^---[\s\S]*?---/, "").trim();
const firstParagraph = body.split("\n").find((l) => l.trim() && !l.startsWith("#") && !l.startsWith(">"));
const summary = firstParagraph?.slice(0, 120) || "";
articles.push({ path: `${category}/${f}`, title, summary });
}
}
return articles;
}
countArticles() {
let count = 0;
for (const [category, dir] of Object.entries(this.dirs)) {
if (category === "root") continue;
if (!existsSync(dir)) continue;
count += readdirSync(dir).filter((f) => f.endsWith(".md")).length;
}
return count;
}
extractWikiLinks(content) {
const links = [];
const re = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
let match;
while ((match = re.exec(content)) !== null) {
const target = (match[1] ?? "").trim();
links.push(target.endsWith(".md") ? target : target + ".md");
}
return links;
}
extractSummary(content) {
const body = content.replace(/^---[\s\S]*?---/, "").trim();
const lines = body.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
return lines.slice(0, 3).join("\n").slice(0, 500) || "No summary available.";
}
slugify(text) {
return text.toLowerCase().replace(/[^a-z0-9-_\s]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").slice(0, 60).replace(/-$/, "");
}
escapeYaml(s) {
return s.replace(/"/g, '\\"').replace(/\n/g, " ");
}
groupBy(items, keyFn) {
const map = /* @__PURE__ */ new Map();
for (const item of items) {
const key = keyFn(item);
const list = map.get(key) || [];
list.push(item);
map.set(key, list);
}
return map;
}
generateEmptyIndex() {
return [
"---",
`updated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
"total_articles: 0",
"---",
"",
"# Wiki Index",
"",
"> No articles yet. Run `stackmemory wiki create` to generate wiki from context.",
"",
"## Entities",
"",
"_No entity pages yet._",
"",
"## Concepts",
"",
"_No concept pages yet._",
"",
"## Sources",
"",
"_No source summaries yet._",
"",
"## Synthesis",
"",
"_No synthesis articles yet._",
""
].join("\n");
}
}
export {
WikiCompiler
};