/** * Read-only Markdown projections over MemoryNode rows. * * SQLite remains the source of truth; these files are deterministic views * for human inspection and lightweight agent handoff. */ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { LoomStore, MemoryEdgeRow, MemRow, VisibilityFilter } from "./store.js"; import { getDbDir, parseTags } from "./store.js"; export interface MarkdownViewFile { name: string; path: string; count: number; } export interface MarkdownViewsResult { dir: string; files: MarkdownViewFile[]; } export interface MarkdownViewsOptions { scope_type?: string; scope_id?: string; visibility?: VisibilityFilter; limit?: number; } interface ViewSpec { name: string; title: string; rows: (store: LoomStore, limit: number, opts: MarkdownViewsOptions) => MemRow[]; } const VIEW_SPECS: ViewSpec[] = [ { name: "procedures.md", title: "Procedures", rows: (store, limit, opts) => store.recallByKind("procedure", limit, opts.scope_type, opts.scope_id, opts.visibility), }, { name: "handoffs.md", title: "Handoffs", rows: (store, limit, opts) => store.recallByKind("handoff", limit, opts.scope_type, opts.scope_id, opts.visibility), }, { name: "decisions.md", title: "Decisions", rows: (store, limit, opts) => { const byKind = store.recallByKind("decision", limit, opts.scope_type, opts.scope_id, opts.visibility); const byTag = filterScope(store.recallByTags(["decision", "architecture", "principle"], limit, opts.visibility), opts); return uniqueRows([...byKind, ...byTag]).slice(0, limit); }, }, { name: "profiles.md", title: "Profiles", rows: (store, limit, opts) => store.recallByKind("profile", limit, opts.scope_type, opts.scope_id, opts.visibility), }, { name: "insights.md", title: "Insights", rows: (store, limit, opts) => store.recallByKind("insight", limit, opts.scope_type, opts.scope_id, opts.visibility), }, ]; export function exportMarkdownViews(store: LoomStore, opts: MarkdownViewsOptions = {}): MarkdownViewsResult { const dir = join(getDbDir(), "views"); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const limit = typeof opts.limit === "number" && opts.limit > 0 ? Math.floor(opts.limit) : 50; const files = VIEW_SPECS.map((spec) => { const rows = spec.rows(store, limit, opts); const path = join(dir, spec.name); writeAtomic(path, renderView(spec.title, rows, opts)); return { name: spec.name, path, count: rows.length }; }); const edges = filterEdges(store, store.listMemoryEdges(limit), opts); const edgesPath = join(dir, "edges.md"); writeAtomic(edgesPath, renderEdges(edges, store, opts)); files.push({ name: "edges.md", path: edgesPath, count: edges.length }); const indexPath = join(dir, "index.md"); writeAtomic(indexPath, renderIndex(files, opts, limit)); files.unshift({ name: "index.md", path: indexPath, count: files.reduce((sum, file) => sum + file.count, 0), }); return { dir, files }; } function filterEdges(store: LoomStore, edges: MemoryEdgeRow[], opts: MarkdownViewsOptions): MemoryEdgeRow[] { return edges.filter((edge) => { const source = store.get(edge.source_id); const target = store.get(edge.target_id); if (!source || !target) return false; if (!inVisibility(source, opts.visibility) || !inVisibility(target, opts.visibility)) return false; if (!opts.scope_type && !opts.scope_id) return true; return inScope(source, opts) || inScope(target, opts); }); } function filterScope(rows: MemRow[], opts: MarkdownViewsOptions): MemRow[] { return rows.filter((row) => { return inScope(row, opts); }); } function inScope(row: MemRow, opts: MarkdownViewsOptions): boolean { if (opts.scope_type && row.scope_type !== opts.scope_type) return false; if (opts.scope_id && row.scope_id && row.scope_id !== opts.scope_id) return false; return true; } function inVisibility(row: MemRow, visibility?: VisibilityFilter): boolean { if (visibility === "private") return row.visibility === "private"; if (visibility === "shared") return row.visibility === "shared"; return row.visibility !== "private"; } function uniqueRows(rows: MemRow[]): MemRow[] { const seen = new Set(); return rows.filter((row) => { if (seen.has(row.id)) return false; seen.add(row.id); return true; }); } function renderView(title: string, rows: MemRow[], opts: MarkdownViewsOptions): string { const lines = [`# ${title}`, ""]; if (opts.scope_type || opts.scope_id) { lines.push(`scope: ${opts.scope_type ?? "*"}${opts.scope_id ? `/${opts.scope_id}` : ""}`, ""); } if (rows.length === 0) { lines.push("_No active memories._", ""); return lines.join("\n"); } for (const row of rows) { const summary = (row.fact_summary || firstLine(row.content)).trim(); lines.push(`## ${row.id.slice(0, 8)}${summary ? ` - ${summary}` : ""}`); lines.push(`- id: ${row.id}`); lines.push(`- kind: ${row.kind ?? "memory"}`); lines.push(`- scope: ${row.scope_type ?? "repo"}${row.scope_id ? `/${row.scope_id}` : ""}`); if (row.entity_id) lines.push(`- entity: ${row.entity_id}`); lines.push(`- confidence: ${formatNumber(row.confidence ?? 1)}`); lines.push(`- importance: ${formatNumber(row.importance)}`); const tags = parseTags(row.tags); if (tags.length > 0) lines.push(`- tags: ${tags.join(", ")}`); lines.push(""); lines.push(row.content.trim()); lines.push(""); } return `${lines.join("\n").trimEnd()}\n`; } function renderIndex(files: MarkdownViewFile[], opts: MarkdownViewsOptions, limit: number): string { const lines = ["# Memory Views", "", "source: sqlite", `limit: ${limit}`]; if (opts.scope_type || opts.scope_id) { lines.push(`scope: ${opts.scope_type ?? "*"}${opts.scope_id ? `/${opts.scope_id}` : ""}`); } lines.push("", "| View | Count |", "|------|------:|"); for (const file of files) { lines.push(`| [${file.name}](./${file.name}) | ${file.count} |`); } return `${lines.join("\n")}\n`; } function renderEdges(edges: MemoryEdgeRow[], store: LoomStore, opts: MarkdownViewsOptions): string { const lines = ["# Memory Edges", ""]; if (opts.scope_type || opts.scope_id) { lines.push(`scope: ${opts.scope_type ?? "*"}${opts.scope_id ? `/${opts.scope_id}` : ""}`, ""); } if (edges.length === 0) { lines.push("_No memory edges._", ""); return lines.join("\n"); } lines.push("| Source | Relation | Target | Confidence |", "|--------|----------|--------|-----------:|"); for (const edge of edges) { const source = store.get(edge.source_id); const target = store.get(edge.target_id); lines.push( `| ${edge.source_id.slice(0, 8)} ${source ? escapeCell(firstLine(source.content).slice(0, 48)) : ""} | ` + `${edge.relation} | ` + `${edge.target_id.slice(0, 8)} ${target ? escapeCell(firstLine(target.content).slice(0, 48)) : ""} | ` + `${formatNumber(edge.confidence)} |`, ); } return `${lines.join("\n")}\n`; } function firstLine(content: string): string { return content.replace(/\s+/g, " ").slice(0, 96); } function escapeCell(value: string): string { return value.replace(/\|/g, "\\|"); } function formatNumber(value: number): string { return Number.isFinite(value) ? value.toFixed(2) : "0.00"; } function writeAtomic(path: string, content: string): void { const tmp = `${path}.tmp`; writeFileSync(tmp, content, "utf8"); renameSync(tmp, path); }