UNPKG

open-memex

Version:

Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.

137 lines (124 loc) • 4.73 kB
import fs from "node:fs"; import { db } from "./db.ts"; import { readMemoryFile, iterInRepoMemoryFiles, iterMemoryFiles, } from "./markdown.ts"; import { similarity, NEAR_DUP_THRESHOLD } from "./lifecycle.ts"; import { projectRoot } from "../paths.ts"; import { loadConfig } from "../config.ts"; // Memory health audit (D57). Read-only: reports, never mutates. // Checks, per open question "retrieval explainability & memory health": // - near-duplicate pairs among active memories (same dedup yardstick // as write-time: Jaccard >= 0.8) that were never superseded; // - stale actives: untouched for STALE_DAYS, still in the retrieval / // injection pool; // - broken supersede chains: superseded_by pointing at a missing id; // - personal files inside the in-repo memory dir (iron-rule breach: // personal memory must never land in a shared repo); // - index/file drift after sync (should be zero; reported if not). export const STALE_DAYS = 90; interface AuditRow { id: string; scope_key: string; status: string; updated_at: number; superseded_by: string | null; content: string; file_path: string; } export interface DuplicatePair { a: string; b: string; scopeKey: string; score: number; } export interface AuditReport { totalMemories: number; duplicatePairs: DuplicatePair[]; stale: { id: string; scopeKey: string; ageDays: number }[]; brokenChains: { id: string; scopeKey: string; missingTarget: string }[]; personalInRepo: string[]; drift: { filesNotIndexed: number; rowsMissingFiles: number }; } export function runAudit(scopeKeys: string[]): AuditReport { const d = db(); const rows = d .prepare( `SELECT id, scope_key, status, updated_at, superseded_by, content, file_path FROM memories`, ) .all() as AuditRow[]; const inScope = rows.filter((r) => scopeKeys.includes(r.scope_key)); const ids = new Set(rows.map((r) => r.id)); // Near-duplicate pairs: active only, same scope. O(n^2) per scope with a // length pre-filter; local stores are small enough for this to be fine. const pairs: DuplicatePair[] = []; const byScope = new Map<string, AuditRow[]>(); for (const r of inScope) { if (r.status !== "active") continue; const arr = byScope.get(r.scope_key) ?? []; arr.push(r); byScope.set(r.scope_key, arr); } for (const [scopeKey, arr] of byScope) { for (let i = 0; i < arr.length; i++) { for (let j = i + 1; j < arr.length; j++) { const a = arr[i]!; const b = arr[j]!; const la = a.content.length; const lb = b.content.length; if (Math.min(la, lb) / Math.max(la, lb) < 0.5) continue; const score = similarity(a.content, b.content); if (score >= NEAR_DUP_THRESHOLD) { pairs.push({ a: a.id, b: b.id, scopeKey, score }); } } } } pairs.sort((x, y) => y.score - x.score); const now = Date.now(); const stale = inScope .filter((r) => r.status === "active") .map((r) => ({ id: r.id, scopeKey: r.scope_key, ageDays: Math.floor((now - r.updated_at) / 86_400_000) })) .filter((s) => s.ageDays >= STALE_DAYS) .sort((x, y) => y.ageDays - x.ageDays); const brokenChains = inScope .filter((r) => r.superseded_by && !ids.has(r.superseded_by)) .map((r) => ({ id: r.id, scopeKey: r.scope_key, missingTarget: r.superseded_by! })); // Personal files must never sit in the in-repo dir. Read-only: this only // parses frontmatter of files already in the repo tree. const personalInRepo: string[] = []; try { const root = projectRoot(); const memoryDir = loadConfig().memoryDir; for (const fp of iterInRepoMemoryFiles(root, memoryDir)) { const mf = readMemoryFile(fp); if (mf && mf.fm.scope === "personal") personalInRepo.push(fp); } } catch { // No project root (e.g. personal-only usage): nothing to scan. } // Drift: index rows whose file vanished, and files on disk the index // never picked up. syncScope normally heals both before audit runs. const indexedPaths = new Set(rows.map((r) => r.file_path)); let rowsMissingFiles = 0; for (const r of inScope) { if (!r.file_path || !fs.existsSync(r.file_path)) rowsMissingFiles++; } let filesNotIndexed = 0; for (const key of scopeKeys) { for (const fp of iterMemoryFiles(key)) { if (!indexedPaths.has(fp)) filesNotIndexed++; } } return { totalMemories: inScope.length, duplicatePairs: pairs, stale, brokenChains, personalInRepo, drift: { filesNotIndexed, rowsMissingFiles }, }; }