/** * KHAELOR * File: src/memory/store.ts * Description: Project memory store — .khaelor/MEMORY.md with event-anchored provenance comments (v2 design §5). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ /** Relative path of the project memory file. */ export const MEMORY_FILE = ".khaelor/MEMORY.md"; export type MemoryConfidence = "high" | "medium" | "low"; export interface MemoryProvenance { session: string; /** Anchor into the session log — the tool_use id of the remember call. */ tool: string; confidence: MemoryConfidence; /** ISO date (YYYY-MM-DD). */ date: string; } export interface MemoryEntry { section: string; text: string; provenance: MemoryProvenance | null; } const HEADER = "# Project memory\n\n> Maintained by KHAELOR. Every entry is anchored to the session/event that produced it.\n"; const PROVENANCE_RE = //; /** Canonical section title casing: "conventions" → "Conventions". */ function sectionTitle(section: string): string { const trimmed = section.trim(); if (trimmed.length === 0) return "Notes"; return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); } /** Parse MEMORY.md into entries. Tolerant: unknown lines are ignored. */ export function parseMemory(content: string): MemoryEntry[] { const entries: MemoryEntry[] = []; let section = "Notes"; let currentText: string[] = []; let currentProvenance: MemoryProvenance | null = null; const flush = (): void => { const text = currentText.join("\n").trim(); if (text.length > 0) entries.push({ section, text, provenance: currentProvenance }); currentText = []; currentProvenance = null; }; for (const line of content.split("\n")) { const heading = /^##\s+(.+)$/.exec(line); if (heading !== null) { flush(); section = (heading[1] as string).trim(); continue; } const provenance = PROVENANCE_RE.exec(line); if (provenance !== null) { currentProvenance = { session: provenance[1] as string, tool: provenance[2] as string, confidence: provenance[3] as MemoryConfidence, date: provenance[4] as string, }; flush(); continue; } if (/^-\s+/.test(line) && currentText.length > 0) flush(); if (line.trim().length > 0 && !line.startsWith("#") && !line.startsWith(">")) { currentText.push(line); } } flush(); return entries; } function formatProvenance(p: MemoryProvenance): string { return ` `; } /** Append one entry to existing MEMORY.md content, creating the section when needed. */ export function appendMemoryEntry( existing: string | null, entry: { section: string; text: string; provenance: MemoryProvenance }, ): string { const title = sectionTitle(entry.section); const bullet = entry.text.startsWith("- ") ? entry.text : `- ${entry.text}`; const block = `${bullet}\n${formatProvenance(entry.provenance)}\n`; const base = existing !== null && existing.trim().length > 0 ? existing : HEADER; const lines = base.split("\n"); const headingLine = `## ${title}`; const headingIndex = lines.findIndex((line) => line.trim() === headingLine); if (headingIndex === -1) { const trimmed = base.replace(/\n+$/, ""); return `${trimmed}\n\n${headingLine}\n${block}`; } // Insert at the end of the section (before the next heading or EOF). let insertAt = lines.length; for (let i = headingIndex + 1; i < lines.length; i++) { if (/^##\s+/.test(lines[i] as string)) { insertAt = i; break; } } while (insertAt > headingIndex + 1 && (lines[insertAt - 1] as string).trim().length === 0) { insertAt -= 1; } const out = [...lines.slice(0, insertAt), ...block.replace(/\n$/, "").split("\n"), ...lines.slice(insertAt)]; return out.join("\n"); } /** * Hygiene report: low-confidence entries are purge candidates the agent may * propose to drop during /compact (v2 §5.4). */ export function purgeCandidates(entries: readonly MemoryEntry[]): MemoryEntry[] { return entries.filter((entry) => entry.provenance?.confidence === "low"); }