Files
kimiko-agent/extensions/memory/store.mjs
syntaxbullet 91e5f44cc8 feat(memory): add memory extension with core functionality and tests
- Implemented MemoryStore class for managing concepts with YAML frontmatter.
- Added methods for creating, reading, updating, and deleting concepts.
- Introduced locking mechanism for concurrent access.
- Developed search and hinting capabilities for concept retrieval.
- Created tests for access control and store functionality.
- Added long-term memory skill documentation and operational guidelines.
- Included package.json and package-lock.json for dependency management.
2026-07-23 20:35:08 +02:00

696 lines
28 KiB
JavaScript

import { open, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { basename, dirname, join, posix, relative, resolve, sep } from "node:path";
import YAML from "yaml";
const RESERVED = new Set(["index", "log"]);
const INDEX_START = "<!-- kimiko-memory:index:start -->";
const INDEX_END = "<!-- kimiko-memory:index:end -->";
const LOCAL_REQUIRED_KEYS = ["type", "timestamp"];
function sleep(ms) {
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
}
function normalizeText(value) {
return value.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
}
function normalizeBody(value) {
const normalized = normalizeText(value).trim();
return normalized ? `${normalized}\n` : "";
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function stableValue(value) {
if (Array.isArray(value)) return value.map(stableValue);
if (!isPlainObject(value)) return value;
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
}
function fingerprint(metadata, body) {
const meaningful = { ...metadata };
delete meaningful.timestamp;
return JSON.stringify({ metadata: stableValue(meaningful), body: normalizeBody(body) });
}
function validTimestamp(value) {
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)
&& Number.isFinite(Date.parse(value));
}
function parseFrontmatter(raw) {
const text = normalizeText(raw);
if (!text.startsWith("---\n")) {
throw new Error("missing YAML frontmatter");
}
const lines = text.split("\n");
let closing = -1;
for (let index = 1; index < lines.length; index += 1) {
if (lines[index] === "---" || lines[index] === "...") {
closing = index;
break;
}
}
if (closing < 0) throw new Error("unterminated YAML frontmatter");
let metadata;
try {
metadata = YAML.parse(lines.slice(1, closing).join("\n")) ?? {};
} catch (error) {
throw new Error(`invalid YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`);
}
if (!isPlainObject(metadata)) throw new Error("frontmatter must be a YAML mapping");
return { metadata, body: normalizeBody(lines.slice(closing + 1).join("\n")) };
}
function serializeConcept(metadata, body) {
const yaml = YAML.stringify(metadata, { lineWidth: 0 }).trimEnd();
return `---\n${yaml}\n---\n\n${normalizeBody(body)}`;
}
function extractRecoverableBody(raw) {
const text = normalizeText(raw);
if (!text.startsWith("---\n")) return normalizeBody(text);
const match = text.match(/^---\n[\s\S]*?\n(?:---|\.\.\.)\n?/);
if (!match) return normalizeBody(text);
return normalizeBody(text.slice(match[0].length));
}
function titleFromId(id) {
const leaf = id.split("/").at(-1) ?? id;
return leaf
.split(/[-_.]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function normalizeTags(tags) {
if (tags === undefined) return undefined;
const result = [];
const seen = new Set();
for (const raw of tags) {
const tag = raw.trim();
if (!tag || seen.has(tag)) continue;
if (tag.length > 64) throw new Error(`tag exceeds 64 characters: ${tag.slice(0, 20)}`);
seen.add(tag);
result.push(tag);
}
if (result.length > 20) throw new Error("at most 20 tags are allowed");
return result;
}
function assertShortSingleLine(name, value, max, required = false) {
if (value === undefined) {
if (required) throw new Error(`${name} is required`);
return;
}
if (!value.trim()) throw new Error(`${name} must not be empty`);
if (value.length > max) throw new Error(`${name} exceeds ${max} characters`);
if (/[\r\n]/.test(value)) throw new Error(`${name} must be one line`);
}
function escapeMarkdown(value) {
return value.replace(/([\\[\]])/g, "\\$1");
}
function replaceGeneratedSection(text, section) {
const start = text.indexOf(INDEX_START);
const end = text.indexOf(INDEX_END);
if (start >= 0 && end >= start) {
return `${text.slice(0, start).trimEnd()}\n\n${section}\n${text.slice(end + INDEX_END.length).trimStart()}`.trimEnd() + "\n";
}
return `${text.trimEnd()}\n\n${section}\n`;
}
const SEARCH_STOP_WORDS = new Set([
"about", "after", "again", "also", "been", "before", "being", "could", "does", "from", "have", "into",
"just", "know", "like", "more", "most", "much", "need", "only", "other", "should", "some", "such", "than",
"that", "their", "them", "then", "there", "these", "they", "this", "those", "through", "user", "very", "want",
"what", "when", "where", "which", "while", "with", "would", "your", "youre", "remember", "memory",
]);
const HINT_GENERIC_TERMS = new Set(["decision", "fact", "person", "preference", "project", "remember", "memory"]);
function tokenize(text) {
return [...text.toLocaleLowerCase().matchAll(/[\p{L}\p{N}]+/gu)]
.map((match) => match[0])
.filter((term) => term.length > 2 && !SEARCH_STOP_WORDS.has(term));
}
export class MemoryStore {
constructor(root, options = {}) {
this.root = resolve(root);
this.clock = options.clock ?? (() => new Date());
this.afterWriteAttempt = options.afterWriteAttempt;
this.lockPath = join(this.root, ".kimiko-memory.lock");
}
normalizeId(input) {
if (typeof input !== "string") throw new Error("concept id must be a string");
let id = input.trim().replace(/^@/, "");
if (id.endsWith(".md")) id = id.slice(0, -3);
if (!id || id.length > 240) throw new Error("concept id must contain 1-240 characters");
if (id.startsWith("/") || id.includes("\\") || id.split("/").some((part) => part === "" || part === "." || part === "..")) {
throw new Error("concept id must be a safe bundle-relative path");
}
if (posix.normalize(id) !== id || !id.split("/").every((part) => /^[a-z0-9][a-z0-9._-]*$/.test(part))) {
throw new Error("concept id segments may contain lowercase letters, numbers, dots, underscores, and hyphens");
}
if (RESERVED.has(id.split("/").at(-1))) throw new Error("index and log are reserved filenames");
return id;
}
pathForId(id) {
const normalized = this.normalizeId(id);
const path = resolve(this.root, ...normalized.split("/")) + ".md";
if (relative(this.root, path).startsWith(`..${sep}`)) throw new Error("concept path escapes bundle root");
return { id: normalized, path };
}
async initialize() {
await mkdir(this.root, { recursive: true });
return this.withLock(() => this.auditUnlocked({ repair: true }));
}
async withLock(task) {
await mkdir(this.root, { recursive: true });
let handle;
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
handle = await open(this.lockPath, "wx", 0o600);
await handle.writeFile(`${process.pid}\n${this.clock().toISOString()}\n`, "utf8");
break;
} catch (error) {
if (error?.code !== "EEXIST") throw error;
try {
const info = await stat(this.lockPath);
if (Date.now() - info.mtimeMs > 30_000) await unlink(this.lockPath);
} catch (statError) {
if (statError?.code !== "ENOENT") throw statError;
}
await sleep(Math.min(50 * (attempt + 1), 250));
}
}
if (!handle) throw new Error("memory bundle is locked by another process");
try {
return await task();
} finally {
await handle.close().catch(() => {});
await unlink(this.lockPath).catch(() => {});
}
}
async atomicWrite(path, content) {
await mkdir(dirname(path), { recursive: true });
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
const handle = await open(temporary, "wx", 0o600);
try {
await handle.writeFile(content, "utf8");
await handle.sync();
} finally {
await handle.close();
}
try {
await rename(temporary, path);
} catch (error) {
await rm(temporary, { force: true });
throw error;
}
}
async verifiedWrite(path, content, verify, kind = "file") {
let lastError;
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
await this.atomicWrite(path, content);
await this.afterWriteAttempt?.({ path, attempt, kind });
const persisted = await readFile(path, "utf8");
await verify(persisted);
return attempt;
} catch (error) {
lastError = error;
}
}
throw new Error(`${kind} verification failed after deterministic retry: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
}
async backup(path, raw) {
const rel = relative(this.root, path).split(sep).join("/");
const stamp = this.clock().toISOString().replace(/[:.]/g, "-");
const backupPath = join(this.root, ".recovery", `${rel}.${stamp}.bak`);
await mkdir(dirname(backupPath), { recursive: true });
await writeFile(backupPath, raw, { encoding: "utf8", mode: 0o600 });
return relative(this.root, backupPath).split(sep).join("/");
}
async conceptFiles(directory = this.root) {
const found = [];
let entries = [];
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return found;
throw error;
}
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
const path = join(directory, entry.name);
if (entry.isDirectory()) found.push(...await this.conceptFiles(path));
else if (entry.isFile() && entry.name.endsWith(".md") && !["index.md", "log.md"].includes(entry.name)) found.push(path);
}
return found.sort();
}
async bundleDirectories(directory = this.root) {
const found = [directory];
let entries = [];
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return found;
throw error;
}
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith(".")) {
found.push(...await this.bundleDirectories(join(directory, entry.name)));
}
}
return found;
}
async inspect(path) {
const raw = await readFile(path, "utf8");
const { metadata, body } = parseFrontmatter(raw);
const errors = [];
if (typeof metadata.type !== "string" || !metadata.type.trim()) errors.push("missing non-empty type");
if (!validTimestamp(metadata.timestamp)) errors.push("missing or invalid ISO-8601 UTC timestamp (local producer profile)");
return { raw, metadata, body, errors };
}
async search(query, limit = 8) {
const boundedLimit = Math.max(1, Math.min(20, limit));
const terms = [...new Set(tokenize(query))];
const normalizedQuery = query.trim().toLocaleLowerCase();
const results = [];
const warnings = [];
for (const path of await this.conceptFiles()) {
const id = relative(this.root, path).split(sep).join("/").slice(0, -3);
try {
const { metadata, body, errors } = await this.inspect(path);
if (errors.length) warnings.push(`${id}: ${errors.join(", ")}`);
const title = typeof metadata.title === "string" ? metadata.title : titleFromId(id);
const description = typeof metadata.description === "string" ? metadata.description : "";
const tags = Array.isArray(metadata.tags) ? metadata.tags.filter((tag) => typeof tag === "string") : [];
const weighted = [
["id", id, 8], ["title", title, 7], ["description", description, 5], ["tags", tags.join(" "), 5],
["type", String(metadata.type ?? ""), 3], ["body", body, 1],
];
const matchedFields = new Set();
const metadataTerms = new Set();
let score = terms.length === 0 ? 0 : terms.reduce((total, term) => total + weighted.reduce(
(subtotal, [field, value, weight]) => {
if (!String(value).toLocaleLowerCase().includes(term)) return subtotal;
matchedFields.add(String(field));
if (field !== "body") metadataTerms.add(term);
return subtotal + Number(weight);
}, 0,
), 0);
if (normalizedQuery && weighted.some(([, value]) => String(value).toLocaleLowerCase().includes(normalizedQuery))) score += 10;
if (terms.length > 0 && score === 0) continue;
results.push({
id,
type: String(metadata.type ?? "Unknown"),
title,
description,
tags,
timestamp: validTimestamp(metadata.timestamp) ? metadata.timestamp : undefined,
score,
matches: { fields: [...matchedFields], metadataTerms: [...metadataTerms] },
});
} catch (error) {
warnings.push(`${id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
results.sort((left, right) => right.score - left.score
|| String(right.timestamp ?? "").localeCompare(String(left.timestamp ?? ""))
|| left.id.localeCompare(right.id));
return { results: results.slice(0, boundedLimit), warnings };
}
async hints(prompt, limit = 2) {
const { results } = await this.search(prompt.slice(0, 2_000), 20);
return results.filter((result) => {
const distinctive = result.matches.metadataTerms.filter((term) => !HINT_GENERIC_TERMS.has(term));
if (distinctive.length === 0) return false;
const direct = result.matches.fields.some((field) => ["id", "title", "tags"].includes(field));
return direct || (distinctive.length >= 2 && result.matches.fields.includes("description"));
}).slice(0, Math.max(1, Math.min(3, limit)));
}
async exists(id) {
const { path } = this.pathForId(id);
try {
await stat(path);
return true;
} catch (error) {
if (error?.code === "ENOENT") return false;
throw error;
}
}
async read(ids, maxCharacters = 45_000) {
const unique = [...new Set(ids.map((id) => this.normalizeId(id)))];
if (unique.length > 10) throw new Error("at most 10 concepts may be read at once");
const documents = [];
let text = "";
let truncated = false;
for (const id of unique) {
const { path } = this.pathForId(id);
let inspected;
try {
inspected = await this.inspect(path);
} catch (error) {
if (error?.code === "ENOENT") throw new Error(`memory concept not found: ${id}`);
throw error;
}
if (inspected.errors.length) throw new Error(`${id} is invalid; run /memory-check --repair`);
const heading = `## ${id}\n\nType: ${inspected.metadata.type}\nTimestamp: ${inspected.metadata.timestamp}\n\n`;
const block = `${heading}${inspected.body}`;
if (text.length + block.length > maxCharacters) {
const remaining = Math.max(0, maxCharacters - text.length);
text += block.slice(0, remaining);
truncated = true;
break;
}
text += `${block}\n`;
documents.push({ id, metadata: inspected.metadata });
}
if (truncated) text += "\n[Memory output truncated; read fewer concept IDs.]";
return { text: text.trimEnd(), documents, truncated };
}
async upsert(input) {
return this.withLock(async () => {
const { id, path } = this.pathForId(input.id);
assertShortSingleLine("type", input.type, 100, true);
assertShortSingleLine("title", input.title, 200);
assertShortSingleLine("description", input.description, 500);
if (typeof input.body !== "string") throw new Error("body is required");
if (input.body.length > 100_000) throw new Error("body exceeds 100,000 characters");
const tags = normalizeTags(input.tags);
let existing;
let existingRaw;
try {
existingRaw = await readFile(path, "utf8");
existing = parseFrontmatter(existingRaw);
} catch (error) {
if (error?.code !== "ENOENT" && existingRaw !== undefined) {
await this.backup(path, existingRaw);
existing = { metadata: {}, body: extractRecoverableBody(existingRaw) };
} else if (error?.code !== "ENOENT") {
throw error;
}
}
const metadata = { ...(existing?.metadata ?? {}) };
metadata.type = input.type.trim();
if (input.title !== undefined) metadata.title = input.title.trim();
if (input.description !== undefined) metadata.description = input.description.trim();
if (tags !== undefined) metadata.tags = tags;
const body = normalizeBody(input.body);
const changed = !existing || fingerprint(metadata, body) !== fingerprint(existing.metadata, existing.body);
if (!changed && validTimestamp(existing.metadata.timestamp)) {
return { id, action: "unchanged", timestamp: existing.metadata.timestamp, attempts: 0 };
}
if (changed) metadata.timestamp = this.clock().toISOString();
else {
const info = await stat(path);
metadata.timestamp = info.mtime.toISOString();
}
const serialized = serializeConcept(metadata, body);
const expectedFingerprint = fingerprint(metadata, body);
const attempts = await this.verifiedWrite(path, serialized, (persisted) => {
const parsed = parseFrontmatter(persisted);
if (parsed.metadata.timestamp !== metadata.timestamp || fingerprint(parsed.metadata, parsed.body) !== expectedFingerprint) {
throw new Error("persisted concept differs from requested concept");
}
if (!LOCAL_REQUIRED_KEYS.every((key) => parsed.metadata[key])) throw new Error("persisted concept lacks required producer metadata");
}, "concept");
await this.updateIndexes();
await this.appendLog(changed && existing ? "Update" : existing ? "Repair" : "Creation", id, metadata.title ?? titleFromId(id));
return { id, action: existing ? (changed ? "updated" : "repaired") : "created", timestamp: metadata.timestamp, attempts };
});
}
async forget(input) {
return this.withLock(async () => {
const { id, path } = this.pathForId(input.id);
if (input.confirm !== id) throw new Error("confirm must exactly match the concept id");
let title = titleFromId(id);
try {
const inspected = await this.inspect(path);
if (typeof inspected.metadata.title === "string") title = inspected.metadata.title;
} catch (error) {
if (error?.code === "ENOENT") throw new Error(`memory concept not found: ${id}`);
}
await unlink(path);
const recoveryBase = join(this.root, ".recovery", `${id}.md`);
const recoveryDirectory = dirname(recoveryBase);
const recoveryPrefix = `${basename(recoveryBase)}.`;
let removedBackups = 0;
try {
for (const entry of await readdir(recoveryDirectory, { withFileTypes: true })) {
if (entry.isFile() && entry.name.startsWith(recoveryPrefix) && entry.name.endsWith(".bak")) {
await unlink(join(recoveryDirectory, entry.name));
removedBackups += 1;
}
}
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
await this.redactLogReferences(id);
await this.updateIndexes();
await this.appendLog("Deletion", id, title);
return { id, action: "deleted", removedBackups };
});
}
async updateIndexes() {
const files = await this.conceptFiles();
const directories = new Set(await this.bundleDirectories());
for (const file of files) {
let current = dirname(file);
while (current.startsWith(this.root)) {
directories.add(current);
if (current === this.root) break;
current = dirname(current);
}
}
for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
const immediateFiles = files.filter((file) => dirname(file) === directory);
const childDirs = [...new Set(files
.filter((file) => dirname(file) !== directory && relative(directory, file) && !relative(directory, file).startsWith(".."))
.map((file) => relative(directory, file).split(sep)[0]))].sort();
const lines = [INDEX_START, "_Generated inventory; text outside this section is preserved._"];
if (childDirs.length) {
lines.push("", "## Directories");
for (const child of childDirs) {
const count = files.filter((file) => relative(join(directory, child), file) && !relative(join(directory, child), file).startsWith("..")).length;
lines.push(`- [${escapeMarkdown(titleFromId(child))}](${child}/) - ${count} concept${count === 1 ? "" : "s"}.`);
}
}
if (immediateFiles.length) {
lines.push("", "## Concepts");
for (const file of immediateFiles) {
const id = relative(this.root, file).split(sep).join("/").slice(0, -3);
let title = titleFromId(id);
let description = "";
try {
const { metadata } = await this.inspect(file);
if (typeof metadata.title === "string") title = metadata.title;
if (typeof metadata.description === "string") description = metadata.description;
} catch {}
lines.push(`- [${escapeMarkdown(title)}](${basename(file)})${description ? ` - ${description}` : ""}`);
}
}
if (!childDirs.length && !immediateFiles.length) lines.push("", "_No concepts._");
lines.push(INDEX_END);
const section = lines.join("\n");
const indexPath = join(directory, "index.md");
let current = "";
try { current = await readFile(indexPath, "utf8"); } catch (error) { if (error?.code !== "ENOENT") throw error; }
if (directory === this.root) {
let body = current;
let rootMetadata = {};
if (current.startsWith("---\n")) {
try {
const parsed = parseFrontmatter(current);
rootMetadata = parsed.metadata;
body = parsed.body;
} catch {
await this.backup(indexPath, current);
body = current;
}
}
rootMetadata.okf_version = "0.1";
if (!/^#\s+/m.test(body)) body = `# Memory\n\n${body}`;
current = `---\n${YAML.stringify(rootMetadata, { lineWidth: 0 }).trimEnd()}\n---\n\n${body}`;
} else if (!/^#\s+/m.test(current)) {
current = `# ${titleFromId(basename(directory))}\n\n${current}`;
}
const next = replaceGeneratedSection(current, section);
if (normalizeText(current) !== next) {
await this.verifiedWrite(indexPath, next, (persisted) => {
if (!persisted.includes(INDEX_START) || !persisted.includes(INDEX_END) || !/^#\s+/m.test(persisted)) {
throw new Error("generated index markers or heading missing");
}
}, "index");
}
}
}
async ensureLog() {
const path = join(this.root, "log.md");
let current;
try {
current = normalizeText(await readFile(path, "utf8"));
} catch (error) {
if (error?.code !== "ENOENT") throw error;
current = "";
}
if (current.startsWith("# Update Log")) return;
if (current) await this.backup(path, current);
const next = `# Update Log\n${current ? `\n${current.trim()}\n` : ""}`;
await this.verifiedWrite(path, next, (persisted) => {
if (!persisted.startsWith("# Update Log")) throw new Error("log heading was not persisted");
}, "log repair");
}
async redactLogReferences(id) {
const path = join(this.root, "log.md");
let current;
try {
current = normalizeText(await readFile(path, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
const target = `](/${id}.md)`;
const next = current.split("\n").filter((line) => !line.includes(target)).join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
if (next !== current) {
await this.verifiedWrite(path, next, (persisted) => {
if (persisted.includes(target)) throw new Error("deleted concept remains in log");
}, "log redaction");
}
}
async appendLog(action, id, title) {
const path = join(this.root, "log.md");
let current = "# Update Log\n";
try { current = normalizeText(await readFile(path, "utf8")); } catch (error) { if (error?.code !== "ENOENT") throw error; }
if (!current.startsWith("# Update Log")) {
await this.backup(path, current);
current = `# Update Log\n\n${current.trim()}\n`;
}
const date = this.clock().toISOString().slice(0, 10);
const entry = action === "Deletion"
? "- **Deletion**: Removed a concept."
: `- **${action}**: ${action === "Creation" ? "Added" : "Changed"} [${escapeMarkdown(String(title))}](/${id}.md).`;
if (current.includes(entry)) return;
const heading = `## ${date}`;
let next;
const headingAt = current.indexOf(heading);
if (headingAt >= 0) {
const insertion = headingAt + heading.length;
next = `${current.slice(0, insertion)}\n\n${entry}${current.slice(insertion)}`;
} else {
const firstLineEnd = current.indexOf("\n");
next = `${current.slice(0, firstLineEnd + 1)}\n${heading}\n\n${entry}\n${current.slice(firstLineEnd + 1).trimStart()}`;
}
next = next.trimEnd() + "\n";
await this.verifiedWrite(path, next, (persisted) => {
if (!persisted.startsWith("# Update Log") || !persisted.includes(entry)) throw new Error("log entry was not persisted");
}, "log");
}
async audit(options = {}) {
return this.withLock(() => this.auditUnlocked(options));
}
async auditUnlocked({ repair = false } = {}) {
const issues = [];
let repaired = 0;
for (const path of await this.conceptFiles()) {
const id = relative(this.root, path).split(sep).join("/").slice(0, -3);
let raw;
try {
raw = await readFile(path, "utf8");
const parsed = parseFrontmatter(raw);
const errors = [];
if (typeof parsed.metadata.type !== "string" || !parsed.metadata.type.trim()) errors.push("missing type");
if (!validTimestamp(parsed.metadata.timestamp)) errors.push("missing/invalid timestamp");
if (!errors.length) continue;
issues.push({ id, errors, repaired: repair });
if (!repair) continue;
const info = await stat(path);
const metadata = { ...parsed.metadata };
if (typeof metadata.type !== "string" || !metadata.type.trim()) metadata.type = "Recovered Concept";
if (!validTimestamp(metadata.timestamp)) metadata.timestamp = info.mtime.toISOString();
const content = serializeConcept(metadata, parsed.body);
await this.verifiedWrite(path, content, (persisted) => {
const checked = parseFrontmatter(persisted);
if (!checked.metadata.type || !validTimestamp(checked.metadata.timestamp)) throw new Error("safe repair did not restore required metadata");
}, "concept repair");
repaired += 1;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
issues.push({ id, errors: [message], repaired: repair });
if (!repair || raw === undefined) continue;
const info = await stat(path);
const backup = await this.backup(path, raw);
const metadata = {
type: "Recovered Concept",
title: titleFromId(id),
description: "Recovered deterministically from an invalid concept document; review recommended.",
timestamp: info.mtime.toISOString(),
repair_backup: backup,
};
const content = serializeConcept(metadata, extractRecoverableBody(raw));
await this.verifiedWrite(path, content, (persisted) => {
const checked = parseFrontmatter(persisted);
if (!checked.metadata.type || !validTimestamp(checked.metadata.timestamp)) throw new Error("recovery did not restore required metadata");
}, "concept recovery");
repaired += 1;
}
}
if (repair) {
await this.updateIndexes();
await this.ensureLog();
}
const files = await this.conceptFiles();
return { concepts: files.length, issues, repaired, valid: issues.length === 0 || issues.every((issue) => issue.repaired) };
}
}
export { parseFrontmatter, serializeConcept, validTimestamp };