/** * Memory Lint Engine — Content Quality Checker * * A read-only quality checker for memory content. Separate from data-checkup.ts * which checks infrastructure integrity (vector dims, tier distribution, etc.), * Memory Lint checks *content* quality: * * 1. Contradictions — memories that say opposite things about the same topic * 2. Duplicates — near-identical memories by vector cosine similarity * 3. Stale — memories never or rarely accessed and old enough to review * 4. Orphans — memories with missing scope or broken consolidation links * 5. Weak lessons — workflow patterns lacking any action verbs (inspired by * KarryViber/Orb memory-lint heuristic — a "lesson" should prescribe an * action, not describe a phenomenon) * * Produces a health score (0-100) and a human-readable report. */ import type { MemoryEntry, MemoryStore } from "./store.js"; import { cosineSimilarity } from "./multi-vector.js"; import { parseEvolution, isActiveMemory } from "./memory-evolution.js"; import { deriveUsageStatus, isUsageSignalActive } from "./usage-tracker.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type LintSeverity = "info" | "warning" | "error"; export interface LintFinding { check: string; // e.g., "contradiction", "duplicate", "stale", "orphan", "weakLesson" severity: LintSeverity; detail: string; memoryIds: string[]; } export interface MemoryLintReport { findings: LintFinding[]; healthScore: number; // 0-100 totalScanned: number; /** True if the scan hit the 10000-entry cap (older entries were not analyzed). */ scanLimited: boolean; timestamp: string; summary: { contradictions: number; duplicates: number; staleMemories: number; orphans: number; weakLessons: number; /** P0 B-1: cold 记忆数(被反复 surface 却从未被引用)。 */ coldMemories: number; }; } export interface LintDeps { store: Pick; scope?: string; verbose?: boolean; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Maximum entries to compare per scope+category group (performance guard). */ const MAX_ENTRIES_PER_GROUP = 100; /** Cosine similarity threshold for duplicate detection. */ const DUPLICATE_THRESHOLD = 0.92; /** * Minimum vector similarity to even consider two entries as potential contradictions. * Real contradictions are about the SAME topic but say opposite things, * so they should have moderate semantic similarity. */ const CONTRADICTION_SIMILARITY_FLOOR = 0.45; /** * Categories where contradictions are meaningful. * Append-only categories (events, cases) naturally contain "opposite" entries * from different points in time — those are not contradictions. */ const CONTRADICTION_CATEGORIES = new Set(["profile", "preferences", "entities", "patterns"]); /** Entries not accessed in this many days are candidates for staleness. */ const STALE_DAYS = 90; const STALE_MS = STALE_DAYS * 24 * 60 * 60 * 1000; /** * Categories where the action-verb lesson-quality heuristic applies. * * Workflow `patterns` are meant to be reusable "do it this way next time" * prescriptions. A pattern whose text contains no imperative/action verb is * usually too abstract for the agent to reuse and is flagged as a weak lesson. * * We intentionally do NOT apply this to `cases` (problem/solution pairs whose * text may be descriptive), `events` (append-only log), `profile`/`preferences` * (static facts), or `entities` (noun-centric). * * Inspired by KarryViber/Orb `lib/holographic/memory-lint.py`. */ const LESSON_CATEGORIES = new Set(["patterns"]); /** * Regex matching action/imperative verbs used to judge whether a workflow * pattern is actionable. Bilingual: English word-bounded; Chinese substring * (CJK has no word boundaries). * * Intentionally wide — false-negatives here are benign (a weak-lesson flag is * an `info` hint, not an error). We only want to catch patterns that are pure * observations/descriptions with no prescriptive action at all. */ const ACTION_VERBS = /\b(check|verify|validate|ensure|use|avoid|implement|run|call|set|add|remove|update|refactor|test|document|commit|push|rollback|escalate|stop|retry|install|configure|create|delete|write|read|spawn|enable|disable|init|start|restart|launch|store|fetch|query|log|match|replace|generate|prefer|require|invoke|route|build|deploy|review|fix|parse|apply|assert|include|exclude|skip|catch|throw|wrap|mock|mount|import|export|inject|extract|prepend|append|return|raise|cache|clear|reset|refresh|merge|split|rename|move|copy|scan|patrol|trigger|schedule|observe|record|emit|publish|subscribe|notify|confirm|choose|select|filter|sort|group|reduce|map|forward|resolve|reject)\b|使用|避免|检查|验证|确认|添加|删除|修改|改为|回滚|停止|重试|安装|配置|运行|调用|启用|禁用|生成|初始化|启动|重启|存储|获取|查询|记录|匹配|替换|创建|写入|读取|强制|禁止|必须|应当|保留|抛出|捕获|重构|部署|审查|修复|解析|应用|断言|包含|排除|跳过|合并|拆分|重命名|移动|复制|刷新|重置|清理|缓存|注入|提取|追加|遍历|订阅|发布|通知|选择|筛选|排序|触发|观察/iu; // --------------------------------------------------------------------------- // Contradiction Detection (inline copy from consolidation-engine.ts) // --------------------------------------------------------------------------- /** * Heuristic contradiction detection between two memory texts. * Checks for negation patterns and requires at least one shared significant * term to reduce false positives. */ function detectContradiction(textA: string, textB: string): boolean { const a = textA.toLowerCase(); const b = textB.toLowerCase(); const negationPairs: [RegExp, RegExp][] = [ [/\bnot\b/, /\b(?:always|must|should|is|are|was|were)\b/], [/\bnever\b/, /\b(?:always|every|each)\b/], [/\bdisable/, /\benable/], [/不要|不用|别/, /必须|一定|总是/], [/从不/, /每次|总是|一直/], ]; for (const [negRe, posRe] of negationPairs) { if ((negRe.test(a) && posRe.test(b)) || (negRe.test(b) && posRe.test(a))) { // Require at least one shared significant term to reduce false positives const wordsA = new Set(a.split(/\s+/).filter(w => w.length > 3)); const wordsB = new Set(b.split(/\s+/).filter(w => w.length > 3)); for (const w of wordsA) { if (wordsB.has(w)) return true; } } } return false; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Truncate text for display in findings. */ function truncate(text: string, maxLen = 60): string { if (text.length <= maxLen) return text; return text.slice(0, maxLen - 3) + "..."; } /** * Group entries by scope + category key. * Within each group, sort by importance descending and cap at MAX_ENTRIES_PER_GROUP. */ function groupEntries(entries: MemoryEntry[]): Map { const groups = new Map(); for (const entry of entries) { const key = `${entry.scope ?? ""}::${entry.category}`; if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(entry); } // Sort each group by importance descending, cap at limit for (const [key, group] of groups) { group.sort((a, b) => b.importance - a.importance); if (group.length > MAX_ENTRIES_PER_GROUP) { groups.set(key, group.slice(0, MAX_ENTRIES_PER_GROUP)); } } return groups; } // --------------------------------------------------------------------------- // Check 1: Contradictions // --------------------------------------------------------------------------- function findContradictions(entries: MemoryEntry[]): LintFinding[] { const findings: LintFinding[] = []; // Only check merge-type categories where contradictions are meaningful const eligible = entries.filter(e => CONTRADICTION_CATEGORIES.has(e.category)); const groups = groupEntries(eligible); for (const [, group] of groups) { if (group.length < 2) continue; for (let i = 0; i < group.length; i++) { for (let j = i + 1; j < group.length; j++) { // Pre-filter: entries must be about the same topic (moderate vector similarity) const sim = cosineSimilarity(group[i].vector, group[j].vector); if (sim < CONTRADICTION_SIMILARITY_FLOOR) continue; if (detectContradiction(group[i].text, group[j].text)) { findings.push({ check: "contradiction", severity: "warning", detail: `"${truncate(group[i].text)}" vs "${truncate(group[j].text)}"`, memoryIds: [group[i].id, group[j].id], }); } } } } return findings; } // --------------------------------------------------------------------------- // Check 2: Duplicates // --------------------------------------------------------------------------- function findDuplicates(entries: MemoryEntry[]): LintFinding[] { const findings: LintFinding[] = []; const groups = groupEntries(entries); for (const [, group] of groups) { if (group.length < 2) continue; for (let i = 0; i < group.length; i++) { for (let j = i + 1; j < group.length; j++) { const sim = cosineSimilarity(group[i].vector, group[j].vector); if (sim >= DUPLICATE_THRESHOLD) { findings.push({ check: "duplicate", severity: "warning", detail: `"${truncate(group[i].text)}" -- ${(sim * 100).toFixed(1)}% similar [${truncate(group[j].text)}]`, memoryIds: [group[i].id, group[j].id], }); } } } } return findings; } // --------------------------------------------------------------------------- // Check 3: Stale Memories // --------------------------------------------------------------------------- function findStaleMemories(entries: MemoryEntry[]): LintFinding[] { const findings: LintFinding[] = []; const now = Date.now(); for (const entry of entries) { const evo = parseEvolution(entry.metadata, entry.timestamp); // Stale = entry itself is old enough AND (lastAccessedAt is null or > 90 days ago) AND accessCount <= 1 const entryAge = now - entry.timestamp; if (entryAge < STALE_MS) continue; // too new to be considered stale const lastAccess = evo.lastAccessedAt; const isOldAccess = lastAccess === null || (now - lastAccess > STALE_MS); if (isOldAccess && evo.accessCount <= 1) { const ageDays = Math.floor((now - entry.timestamp) / (24 * 60 * 60 * 1000)); findings.push({ check: "stale", severity: "info", detail: `${ageDays}d old, ${evo.accessCount} access(es): "${truncate(entry.text, 50)}"`, memoryIds: [entry.id], }); } } return findings; } // --------------------------------------------------------------------------- // Check 4: Orphans // --------------------------------------------------------------------------- function findOrphans(entries: MemoryEntry[]): LintFinding[] { const findings: LintFinding[] = []; // Build set of all loaded IDs for consolidation link checking const allIds = new Set(entries.map(e => e.id)); for (const entry of entries) { // Missing or schema scope if (!entry.scope || entry.scope.trim() === "" || entry.scope === "__schema__") { findings.push({ check: "orphan", severity: "info", detail: `Missing/empty scope: "${truncate(entry.text, 50)}"`, memoryIds: [entry.id], }); continue; } // Broken consolidation link const evo = parseEvolution(entry.metadata, entry.timestamp); if (evo.consolidatedInto && !allIds.has(evo.consolidatedInto)) { findings.push({ check: "orphan", severity: "warning", detail: `Broken consolidation link -> ${evo.consolidatedInto.slice(0, 12)}...: "${truncate(entry.text, 40)}"`, memoryIds: [entry.id], }); } } return findings; } // --------------------------------------------------------------------------- // Check 5: Weak Lessons (action-verb heuristic) // --------------------------------------------------------------------------- /** * Identify "weak lessons" — workflow `patterns` whose text contains no * imperative/action verb. Such entries are usually descriptive rather than * prescriptive, so the agent cannot act on them next time. * * Severity is `info` (not warning/error): this is a quality hint, not a bug. * Low penalty (see computeHealthScore) reflects that. * * Inspired by KarryViber/Orb `lib/holographic/memory-lint.py`. */ function findWeakLessons(entries: MemoryEntry[]): LintFinding[] { const findings: LintFinding[] = []; for (const entry of entries) { if (!LESSON_CATEGORIES.has(entry.category)) continue; // Empty/whitespace text is caught elsewhere; skip to avoid double-flagging. if (!entry.text || entry.text.trim() === "") continue; if (ACTION_VERBS.test(entry.text)) continue; findings.push({ check: "weakLesson", severity: "info", detail: `Pattern without action verbs (too abstract to reuse): "${truncate(entry.text, 60)}"`, memoryIds: [entry.id], }); } return findings; } /** * Check 6 (P0 B-1 观察): cold memories — 被反复 surface(accessCount ≥ 6)却 * 从未被 reconstruction [src:ID] 引用的记忆,召回位污染信号。 * * 聚合为单条 finding(cold 可能有几千条,逐条 finding 会淹没报告); * memoryIds 取 injection 最高的前 20 条供抽查。severity 永远是 info: * B-1 是纯观察阶段,降权/归档要等观察数据校准后的 B-2。 */ function findColdMemories(entries: MemoryEntry[]): { findings: LintFinding[]; coldCount: number } { // use 信号未采集(reconstruction flag 关)→ cold 全是假阳性,跳过且不扣健康分。 if (!isUsageSignalActive()) { return { findings: [], coldCount: 0 }; } const cold: Array<{ id: string; injection: number }> = []; for (const entry of entries) { if (deriveUsageStatus(entry) !== "cold") continue; let injection = 0; try { const meta = JSON.parse(entry.metadata || "{}") as Record; injection = typeof meta.accessCount === "number" ? meta.accessCount : 0; } catch { /* keep 0 */ } cold.push({ id: entry.id, injection }); } if (cold.length === 0) return { findings: [], coldCount: 0 }; const pct = entries.length > 0 ? ((cold.length / entries.length) * 100).toFixed(1) : "0"; const topIds = cold .sort((a, b) => b.injection - a.injection) .slice(0, 20) .map(c => c.id); return { coldCount: cold.length, findings: [{ check: "coldMemory", severity: "info", detail: `${cold.length} cold memories (${pct}% of scanned): repeatedly surfaced (accessCount >= 6) but never cited by reconstruction. Top-20 by injection in memoryIds. Observation only (B-1) — no action taken.`, memoryIds: topIds, }], }; } // --------------------------------------------------------------------------- // Health Score // --------------------------------------------------------------------------- /** * Compute a 0-100 health score from finding counts. * * Weights: * - Contradictions: -10 each (most dangerous, conflicting guidance) * - Duplicates: -5 each (waste and confusion risk) * - Stale: -0.5 each (minor, many are expected) * - Orphans: -3 each (moderate, broken references) * - Weak lessons: -2 each (pattern lacks action verbs, reuse-unfriendly) * - Cold memories (P0 B-1): 按占比而非条数 — 超过 10% 的部分每个百分点 -1, * 封顶 -10。cold 是反复 surface 零引用的召回污染信号,量大但单条危害小, * 按条扣会瞬间扣穿。纯观察反馈,不触发任何动作。 * * `weakLessons`/`coldMemories` are optional on the input type for backward * compatibility — callers constructing `summary` without them still get a * correct score. */ export function computeHealthScore( summary: { contradictions: number; duplicates: number; staleMemories: number; orphans: number; weakLessons?: number; coldMemories?: number; }, total: number, ): number { const coldPct = total > 0 ? ((summary.coldMemories ?? 0) / total) * 100 : 0; const coldPenalty = Math.min(10, Math.max(0, Math.floor(coldPct - 10))); const penalty = summary.contradictions * 10 + summary.duplicates * 5 + Math.floor(summary.staleMemories * 0.5) + summary.orphans * 3 + (summary.weakLessons ?? 0) * 2 + coldPenalty; return Math.max(0, Math.min(100, 100 - penalty)); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- /** * Run all memory lint checks and produce a report. * * @param deps.store - Memory store (only `list` is needed) * @param deps.scope - Optional scope filter; undefined = all scopes * @param deps.verbose - Reserved for future use */ export async function runMemoryLint(deps: LintDeps): Promise { const scopeFilter = deps.scope ? [deps.scope] : undefined; const SCAN_LIMIT = 10000; const allEntries = await deps.store.list(scopeFilter, undefined, SCAN_LIMIT, 0); const scanLimited = allEntries.length >= SCAN_LIMIT; // Filter to active entries only const activeEntries = allEntries.filter(e => isActiveMemory(e.metadata)); // list() 为性能不返回向量(vector: []);矛盾/去重检查靠向量算相似度,否则恒为 0、 // 静默失效并给出虚假 all-clear。补回真实向量再检查(stale/orphan/weakLesson 不依赖向量)。 const vectorMap = await deps.store.getVectors(activeEntries.map(e => e.id)); const entries = activeEntries.map(e => ({ ...e, vector: vectorMap.get(e.id) ?? [] })); // Run all checks const contradictions = findContradictions(entries); const duplicates = findDuplicates(entries); const stale = findStaleMemories(entries); const orphans = findOrphans(entries); const weakLessons = findWeakLessons(entries); const coldResult = findColdMemories(entries); const findings = [...contradictions, ...duplicates, ...stale, ...orphans, ...weakLessons, ...coldResult.findings]; const summary = { contradictions: contradictions.length, duplicates: duplicates.length, staleMemories: stale.length, orphans: orphans.length, weakLessons: weakLessons.length, coldMemories: coldResult.coldCount, }; return { findings, healthScore: computeHealthScore(summary, entries.length), totalScanned: entries.length, scanLimited, timestamp: new Date().toISOString(), summary, }; } // --------------------------------------------------------------------------- // Formatter // --------------------------------------------------------------------------- /** Format a lint report as human-readable text. */ export function formatMemoryLintReport(report: MemoryLintReport): string { const lines: string[] = []; // All clear case if (report.findings.length === 0) { lines.push("Memory Lint: All Clear!"); lines.push(`Scanned: ${report.totalScanned} active memories${report.scanLimited ? " (达扫描上限 10000,更早记忆未分析)" : ""}`); lines.push(`Health Score: ${report.healthScore}/100`); return lines.join("\n"); } // Header lines.push(`Memory Lint Report (${report.timestamp})`); lines.push("=".repeat(44)); lines.push(`Scanned: ${report.totalScanned} active memories${report.scanLimited ? " (达扫描上限 10000,更早记忆未分析)" : ""}`); lines.push(""); // Contradictions if (report.summary.contradictions > 0) { const items = report.findings.filter(f => f.check === "contradiction"); lines.push(`Contradictions (${items.length}):`); for (const f of items) { lines.push(` - ${f.detail} [${f.memoryIds.map(id => id.slice(0, 8)).join(", ")}]`); } lines.push(""); } // Duplicates if (report.summary.duplicates > 0) { const items = report.findings.filter(f => f.check === "duplicate"); lines.push(`Duplicates (${items.length}):`); for (const f of items) { lines.push(` - ${f.detail} [${f.memoryIds.map(id => id.slice(0, 8)).join(", ")}]`); } lines.push(""); } // Stale if (report.summary.staleMemories > 0) { const items = report.findings.filter(f => f.check === "stale"); lines.push(`Stale (${items.length}):`); if (items.length <= 5) { for (const f of items) { lines.push(` - ${f.detail}`); } } else { // Summarize when many lines.push(` - ${items.length} memories not accessed in ${STALE_DAYS}+ days`); } lines.push(""); } // Orphans if (report.summary.orphans > 0) { const items = report.findings.filter(f => f.check === "orphan"); const missingScope = items.filter(f => f.severity === "info"); const brokenLinks = items.filter(f => f.severity === "warning"); lines.push(`Orphans (${items.length}):`); if (missingScope.length > 0) { lines.push(` - ${missingScope.length} memor${missingScope.length === 1 ? "y" : "ies"} with missing scope`); } if (brokenLinks.length > 0) { for (const f of brokenLinks) { lines.push(` - ${f.detail} [${f.memoryIds.map(id => id.slice(0, 8)).join(", ")}]`); } } lines.push(""); } // Weak Lessons if (report.summary.weakLessons > 0) { const items = report.findings.filter(f => f.check === "weakLesson"); lines.push(`Weak Lessons (${items.length}):`); if (items.length <= 5) { for (const f of items) { lines.push(` - ${f.detail} [${f.memoryIds.map(id => id.slice(0, 8)).join(", ")}]`); } } else { lines.push(` - ${items.length} patterns without action verbs (consider rewriting or promoting)`); } lines.push(""); } // Cold Memories (P0 B-1 observation) if (report.summary.coldMemories > 0) { const item = report.findings.find(f => f.check === "coldMemory"); lines.push(`Cold Memories (${report.summary.coldMemories}):`); if (item) { lines.push(` - ${item.detail}`); lines.push(` - top by injection: ${item.memoryIds.map(id => id.slice(0, 8)).join(", ")}`); } lines.push(""); } // Health score lines.push(`Health Score: ${report.healthScore}/100`); return lines.join("\n"); }