UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

392 lines (348 loc) • 14.8 kB
import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, extname, join, resolve } from "node:path"; import { parse, stringify } from "yaml"; import { redactSecrets } from "./redact.ts"; export const MEMORY_SCHEMA = "kxm.memory.v1" as const; export const MEMORY_BRIEF_SCHEMA = "kxm.memory-brief.v1" as const; export const MEMORY_MARKER_START = "<!-- kxm:memory:start -->"; export const MEMORY_MARKER_END = "<!-- kxm:memory:end -->"; export type MemoryScope = "agent" | "project" | "run" | "operator"; export type MemoryAuthority = "instruction" | "evidence" | "promoted"; export type MemoryConfidence = "verified" | "probable" | "uncertain"; export type MemoryLifecycle = "active" | "deprecated" | "superseded"; const VALID_SCOPES = new Set<MemoryScope>(["agent", "project", "run", "operator"]); const VALID_AUTHORITIES = new Set<MemoryAuthority>(["instruction", "evidence", "promoted"]); const VALID_CONFIDENCES = new Set<MemoryConfidence>(["verified", "probable", "uncertain"]); const VALID_LIFECYCLES = new Set<MemoryLifecycle>(["active", "deprecated", "superseded"]); const BANNED_CONTROL_PLANE_FIELDS = new Set([ "permissions", "tools", "allow", "deny", "grants", "approval", "policy", "scopes", "credentials", "secrets", "token", "apiKey", "password", ]); export interface MemoryProvenance { sourceType: string; sourceRef?: string; runId?: string; timestamp?: string; } export interface MemoryRecord { schema: typeof MEMORY_SCHEMA; id: string; scope: MemoryScope; kind: string; summary: string; provenance: MemoryProvenance; authority: MemoryAuthority; confidence: MemoryConfidence; lifecycle: MemoryLifecycle; evidenceRefs: string[]; body?: string; } export interface MemoryBrief { schema: typeof MEMORY_BRIEF_SCHEMA; generatedAt: string; count: number; facts: MemoryRecord[]; } function parseFrontmatter(content: string): { frontmatter: string; body: string } { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!match) { throw new Error("memory file must contain YAML frontmatter enclosed in '---'"); } return { frontmatter: match[1] ?? "", body: match[2]?.trim() ?? "" }; } export function parseMemoryRecord(raw: string, filename = "memory.md"): MemoryRecord { const { frontmatter, body } = parseFrontmatter(raw); const data = parse(frontmatter) as Record<string, unknown> | null; if (!data || typeof data !== "object" || Array.isArray(data)) { throw new Error(`invalid YAML frontmatter in ${filename}`); } // 1. Control-plane rejection for (const field of BANNED_CONTROL_PLANE_FIELDS) { if (field in data) { throw new Error(`memory record ${filename} may not carry control-plane field '${field}'`); } } // 2. Schema check if (data.schema !== MEMORY_SCHEMA) { throw new Error(`memory record ${filename} has unsupported schema: expected ${MEMORY_SCHEMA}, got ${String(data.schema)}`); } // 3. ID validation const id = typeof data.id === "string" ? data.id.trim() : ""; if (!id || !/^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/i.test(id)) { throw new Error(`memory record ${filename} has invalid id: ${String(data.id)}`); } // 4. Scope validation const scope = data.scope as MemoryScope; if (!VALID_SCOPES.has(scope)) { throw new Error(`memory record ${filename} has invalid scope: must be one of agent, project, run, operator`); } // 5. Kind validation const kind = typeof data.kind === "string" ? data.kind.trim() : ""; if (!kind) { throw new Error(`memory record ${filename} is missing kind`); } // 6. Summary validation & secret redaction const rawSummary = typeof data.summary === "string" ? data.summary.trim() : ""; if (!rawSummary) { throw new Error(`memory record ${filename} is missing summary`); } const summary = redactSecrets(rawSummary); // 7. Provenance validation & secret redaction const rawProv = data.provenance as Record<string, unknown> | undefined; if (!rawProv || typeof rawProv !== "object" || typeof rawProv.sourceType !== "string" || !rawProv.sourceType.trim()) { throw new Error(`memory record ${filename} is missing provenance.sourceType`); } const provenance: MemoryProvenance = { sourceType: rawProv.sourceType.trim(), ...(typeof rawProv.sourceRef === "string" ? { sourceRef: redactSecrets(rawProv.sourceRef.trim()) } : {}), ...(typeof rawProv.runId === "string" ? { runId: rawProv.runId.trim() } : {}), ...(typeof rawProv.timestamp === "string" ? { timestamp: rawProv.timestamp.trim() } : {}), }; // 8. Authority validation const authority = data.authority as MemoryAuthority; if (!VALID_AUTHORITIES.has(authority)) { throw new Error(`memory record ${filename} has invalid authority: ${String(data.authority)}`); } // 9. Confidence validation const confidence = data.confidence as MemoryConfidence; if (!VALID_CONFIDENCES.has(confidence)) { throw new Error(`memory record ${filename} has invalid confidence: ${String(data.confidence)}`); } // 10. Lifecycle validation const lifecycle = data.lifecycle as MemoryLifecycle; if (!VALID_LIFECYCLES.has(lifecycle)) { throw new Error(`memory record ${filename} has invalid lifecycle: ${String(data.lifecycle)}`); } // 11. EvidenceRefs validation const rawRefs = Array.isArray(data.evidenceRefs) ? data.evidenceRefs : []; const evidenceRefs = rawRefs.map(String); return { schema: MEMORY_SCHEMA, id, scope, kind, summary, provenance, authority, confidence, lifecycle, evidenceRefs, ...(body ? { body } : {}), }; } export function formatMemoryRecord(record: MemoryRecord): string { const frontmatter: Record<string, unknown> = { schema: record.schema, id: record.id, scope: record.scope, kind: record.kind, summary: record.summary, provenance: record.provenance, authority: record.authority, confidence: record.confidence, lifecycle: record.lifecycle, evidenceRefs: record.evidenceRefs, }; const yamlText = stringify(frontmatter).trim(); const bodyText = record.body?.trim() ?? ""; return bodyText ? `---\n${yamlText}\n---\n\n${bodyText}\n` : `---\n${yamlText}\n---\n`; } export function memoryDirectories(repoRoot: string): { memoryDir: string; candidatesDir: string } { const root = resolve(repoRoot); const memoryDir = join(root, ".kxm", "memory"); const candidatesDir = join(memoryDir, "candidates"); return { memoryDir, candidatesDir }; } export function loadAuthoredMemory(repoRoot: string): MemoryRecord[] { const { memoryDir } = memoryDirectories(repoRoot); if (!existsSync(memoryDir)) return []; const records: MemoryRecord[] = []; const entries = readdirSync(memoryDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && extname(entry.name) === ".md") { const fullPath = join(memoryDir, entry.name); try { const text = readFileSync(fullPath, "utf8"); const record = parseMemoryRecord(text, entry.name); if (record.lifecycle === "active") { records.push(record); } } catch (error) { // Log or rethrow depending on strictness throw new Error(`failed to parse authored memory file ${entry.name}: ${error instanceof Error ? error.message : String(error)}`); } } } return records.sort((left, right) => left.id.localeCompare(right.id)); } export function loadCandidateMemory(repoRoot: string): MemoryRecord[] { const { candidatesDir } = memoryDirectories(repoRoot); if (!existsSync(candidatesDir)) return []; const records: MemoryRecord[] = []; const entries = readdirSync(candidatesDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && extname(entry.name) === ".md") { const fullPath = join(candidatesDir, entry.name); try { const text = readFileSync(fullPath, "utf8"); const record = parseMemoryRecord(text, entry.name); records.push(record); } catch (error) { throw new Error(`failed to parse candidate memory file ${entry.name}: ${error instanceof Error ? error.message : String(error)}`); } } } return records.sort((left, right) => left.id.localeCompare(right.id)); } export function createMemoryNote( repoRoot: string, summary: string, options: { scope?: MemoryScope | undefined; kind?: string | undefined; body?: string | undefined; sourceRef?: string | undefined; runId?: string | undefined; dryRun?: boolean | undefined; } = {}, ): { record: MemoryRecord; path: string } { const { candidatesDir } = memoryDirectories(repoRoot); const scope: MemoryScope = options.scope ?? "project"; if (!VALID_SCOPES.has(scope)) { throw new Error(`invalid memory note scope: ${scope}; must be one of agent, project, run, operator`); } const hex = randomUUID().replaceAll("-", "").slice(0, 12); const id = `cand_${scope}_${hex}`; const record: MemoryRecord = { schema: MEMORY_SCHEMA, id, scope, kind: options.kind ?? "learning", summary: redactSecrets(summary.trim()), provenance: { sourceType: "command", sourceRef: options.sourceRef ? redactSecrets(options.sourceRef) : "kxm memory note", ...(options.runId ? { runId: options.runId } : {}), timestamp: new Date().toISOString(), }, authority: "evidence", confidence: "probable", lifecycle: "active", evidenceRefs: [], ...(options.body ? { body: options.body.trim() } : {}), }; const targetPath = join(candidatesDir, `${id}.md`); if (options.dryRun) return { record, path: targetPath }; mkdirSync(candidatesDir, { recursive: true }); writeFileSync(targetPath, formatMemoryRecord(record), "utf8"); return { record, path: targetPath }; } export function generateMemoryBrief(repoRoot: string): MemoryBrief { const facts = loadAuthoredMemory(repoRoot); return { schema: MEMORY_BRIEF_SCHEMA, generatedAt: new Date().toISOString(), count: facts.length, facts, }; } export function formatMemoryBriefText(brief: MemoryBrief): string { if (brief.facts.length === 0) { return "No active project memory facts."; } const lines: string[] = [`Project memory (${brief.facts.length} active fact${brief.facts.length === 1 ? "" : "s"}):`, ""]; for (const fact of brief.facts) { lines.push(`• [${fact.scope}] [${fact.kind}] ${fact.summary} (${fact.id})`); } return lines.join("\n"); } export function formatHarnessMemoryBlock(records: MemoryRecord[]): string { const contentLines: string[] = [MEMORY_MARKER_START, "## Project memory (read-only projection)", ""]; if (records.length === 0) { contentLines.push("*No active project memory.*"); } else { for (const record of records) { contentLines.push(`- **[${record.scope}]** [${record.kind}] ${record.summary} (\`${record.id}\`)`); } } contentLines.push(MEMORY_MARKER_END); return contentLines.join("\n"); } /** Instruction files `kxm memory sync` projects memory into. Sync never creates one: * which harness a project uses is the project's choice, and a file KXM wrote would * carry KXM's words instead of the project's. */ const HARNESS_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"] as const; /** What is wrong with the memory markers in `text`, or undefined when it has exactly one * start marker followed by exactly one end marker, or neither. Any other shape has no single * block to replace: an orphan start would make the next sync delete the project's text up to * a later end, an end before its start would duplicate text, and a second block goes stale. */ function memoryMarkerProblem(text: string): string | undefined { const starts = text.split(MEMORY_MARKER_START).length - 1; const ends = text.split(MEMORY_MARKER_END).length - 1; if (starts > 1 || ends > 1) return `has ${starts} ${MEMORY_MARKER_START} and ${ends} ${MEMORY_MARKER_END} markers, not one block`; if (starts > ends) return `has ${MEMORY_MARKER_START} with no ${MEMORY_MARKER_END}`; if (ends > starts) return `has ${MEMORY_MARKER_END} with no ${MEMORY_MARKER_START}`; if (text.indexOf(MEMORY_MARKER_END) < text.indexOf(MEMORY_MARKER_START)) return `has ${MEMORY_MARKER_END} before ${MEMORY_MARKER_START}`; return undefined; } /** `original` with its memory block replaced, or with one appended when it has no markers. * Nothing outside the markers is rewritten. Markers must already pass `memoryMarkerProblem`. */ function withMemoryBlock(original: string, block: string): string { const startIdx = original.indexOf(MEMORY_MARKER_START); if (startIdx === -1) { const head = original.trimEnd(); return head ? `${head}\n\n${block}\n` : `${block}\n`; } const endIdx = original.indexOf(MEMORY_MARKER_END) + MEMORY_MARKER_END.length; return original.slice(0, startIdx) + block + original.slice(endIdx); } export function syncHarnessMemory( repoRoot: string, options: { dryRun?: boolean } = {}, ): { updated: string[]; unchanged: string[]; missing: string[] } { const root = resolve(repoRoot); const present = HARNESS_INSTRUCTION_FILES.filter((name) => existsSync(join(root, name))); const missing = HARNESS_INSTRUCTION_FILES.filter((name) => !present.includes(name)); if (present.length === 0) { throw new Error( `none of ${HARNESS_INSTRUCTION_FILES.join(", ")} exists in ${root}; sync updates the instruction files a project already has and does not create them`, ); } // Check every file before writing any: one malformed file leaves all of them untouched. const documents = present.map((name) => { const original = readFileSync(join(root, name), "utf8"); return { name, original, problem: memoryMarkerProblem(original) }; }); const malformed = documents.filter((doc) => doc.problem !== undefined); if (malformed.length > 0) { throw new Error( `${malformed.map((doc) => `${doc.name} ${doc.problem}`).join("; ")}; wrote no file. ` + `Keep exactly one ${MEMORY_MARKER_START} followed by one ${MEMORY_MARKER_END} in each file, or delete both so sync appends a fresh block`, ); } const block = formatHarnessMemoryBlock(loadAuthoredMemory(root)); const updated: string[] = []; const unchanged: string[] = []; for (const { name, original } of documents) { const next = withMemoryBlock(original, block); if (next === original) { unchanged.push(name); } else { if (!options.dryRun) writeFileSync(join(root, name), next, "utf8"); updated.push(name); } } return { updated, unchanged, missing }; }