SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
4.2 KB · 128 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/memory/store.ts4 * Description: Project memory store — .khaelor/MEMORY.md with event-anchored provenance comments (v2 design §5).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910/** Relative path of the project memory file. */11export const MEMORY_FILE = ".khaelor/MEMORY.md";1213export type MemoryConfidence = "high" | "medium" | "low";1415export interface MemoryProvenance {16  session: string;17  /** Anchor into the session log — the tool_use id of the remember call. */18  tool: string;19  confidence: MemoryConfidence;20  /** ISO date (YYYY-MM-DD). */21  date: string;22}2324export interface MemoryEntry {25  section: string;26  text: string;27  provenance: MemoryProvenance | null;28}2930const HEADER = "# Project memory\n\n> Maintained by KHAELOR. Every entry is anchored to the session/event that produced it.\n";3132const PROVENANCE_RE =33  /<!--\s*khaelor:\s*session=(\S+)\s+tool=(\S+)\s+confidence=(high|medium|low)\s+date=(\S+)\s*-->/;3435/** Canonical section title casing: "conventions" → "Conventions". */36function sectionTitle(section: string): string {37  const trimmed = section.trim();38  if (trimmed.length === 0) return "Notes";39  return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);40}4142/** Parse MEMORY.md into entries. Tolerant: unknown lines are ignored. */43export function parseMemory(content: string): MemoryEntry[] {44  const entries: MemoryEntry[] = [];45  let section = "Notes";46  let currentText: string[] = [];47  let currentProvenance: MemoryProvenance | null = null;4849  const flush = (): void => {50    const text = currentText.join("\n").trim();51    if (text.length > 0) entries.push({ section, text, provenance: currentProvenance });52    currentText = [];53    currentProvenance = null;54  };5556  for (const line of content.split("\n")) {57    const heading = /^##\s+(.+)$/.exec(line);58    if (heading !== null) {59      flush();60      section = (heading[1] as string).trim();61      continue;62    }63    const provenance = PROVENANCE_RE.exec(line);64    if (provenance !== null) {65      currentProvenance = {66        session: provenance[1] as string,67        tool: provenance[2] as string,68        confidence: provenance[3] as MemoryConfidence,69        date: provenance[4] as string,70      };71      flush();72      continue;73    }74    if (/^-\s+/.test(line) && currentText.length > 0) flush();75    if (line.trim().length > 0 && !line.startsWith("#") && !line.startsWith(">")) {76      currentText.push(line);77    }78  }79  flush();80  return entries;81}8283function formatProvenance(p: MemoryProvenance): string {84  return `  <!-- khaelor: session=${p.session} tool=${p.tool} confidence=${p.confidence} date=${p.date} -->`;85}8687/** Append one entry to existing MEMORY.md content, creating the section when needed. */88export function appendMemoryEntry(89  existing: string | null,90  entry: { section: string; text: string; provenance: MemoryProvenance },91): string {92  const title = sectionTitle(entry.section);93  const bullet = entry.text.startsWith("- ") ? entry.text : `- ${entry.text}`;94  const block = `${bullet}\n${formatProvenance(entry.provenance)}\n`;9596  const base = existing !== null && existing.trim().length > 0 ? existing : HEADER;97  const lines = base.split("\n");98  const headingLine = `## ${title}`;99  const headingIndex = lines.findIndex((line) => line.trim() === headingLine);100101  if (headingIndex === -1) {102    const trimmed = base.replace(/\n+$/, "");103    return `${trimmed}\n\n${headingLine}\n${block}`;104  }105106  // Insert at the end of the section (before the next heading or EOF).107  let insertAt = lines.length;108  for (let i = headingIndex + 1; i < lines.length; i++) {109    if (/^##\s+/.test(lines[i] as string)) {110      insertAt = i;111      break;112    }113  }114  while (insertAt > headingIndex + 1 && (lines[insertAt - 1] as string).trim().length === 0) {115    insertAt -= 1;116  }117  const out = [...lines.slice(0, insertAt), ...block.replace(/\n$/, "").split("\n"), ...lines.slice(insertAt)];118  return out.join("\n");119}120121/**122 * Hygiene report: low-confidence entries are purge candidates the agent may123 * propose to drop during /compact (v2 §5.4).124 */125export function purgeCandidates(entries: readonly MemoryEntry[]): MemoryEntry[] {126  return entries.filter((entry) => entry.provenance?.confidence === "low");127}128