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.
This commit is contained in:
syntaxbullet
2026-07-23 20:35:08 +02:00
parent 212af9301e
commit 91e5f44cc8
13 changed files with 1333 additions and 4 deletions

View File

@@ -0,0 +1,50 @@
export class MemoryTurnAccess {
constructor() {
this.reset();
}
reset() {
this.searched = false;
this.searchResultIds = new Set();
this.readIds = new Set();
}
recordSearch(ids) {
this.searched = true;
for (const id of ids) this.searchResultIds.add(id);
}
requireSearchedId(id) {
if (!this.searched) {
throw new Error("Memory workflow blocked: call memory_search in this user turn before reading or mutating memory.");
}
if (!this.searchResultIds.has(id)) {
throw new Error(`Memory workflow blocked: search for the exact concept '${id}' in this user turn first.`);
}
}
recordRead(ids) {
for (const id of ids) {
this.requireSearchedId(id);
this.readIds.add(id);
}
}
requireWrite(id, exists) {
if (!this.searched) {
throw new Error("Memory workflow blocked: call memory_search for duplicates in this user turn before writing.");
}
if (!exists) return;
this.requireSearchedId(id);
if (!this.readIds.has(id)) {
throw new Error(`Memory workflow blocked: read existing concept '${id}' in this user turn before updating it.`);
}
}
requireForget(id) {
this.requireSearchedId(id);
if (!this.readIds.has(id)) {
throw new Error(`Memory workflow blocked: read concept '${id}' in this user turn before forgetting it.`);
}
}
}

184
extensions/memory/index.ts Normal file
View File

@@ -0,0 +1,184 @@
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { MemoryTurnAccess } from "./access.mjs";
import { MemoryStore } from "./store.mjs";
const DETAIL_TOOLS = ["memory_read", "memory_write", "memory_forget"];
const EXPLICIT_MEMORY_INTENT = /\b(?:forget|forgot|memories|memory|recall|remember|remembered)\b|\b(?:last time|previous conversation)\b/i;
const extensionInstanceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const memoryRoot = join(process.env.PI_CODING_AGENT_DIR ?? extensionInstanceRoot, "memory");
const store = new MemoryStore(memoryRoot);
function textResult(text: string, details: unknown = {}) {
return { content: [{ type: "text" as const, text }], details };
}
export default function memoryExtension(pi: ExtensionAPI) {
const turnAccess = new MemoryTurnAccess();
let preparedInput: string | undefined;
function resetTurnAccess() {
turnAccess.reset();
const compactTools = pi.getActiveTools().filter((name) => !DETAIL_TOOLS.includes(name));
pi.setActiveTools([...new Set([...compactTools, "memory_search"])]);
}
function enableDetailTools() {
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...DETAIL_TOOLS])]);
}
function prepareTurn(text: string) {
resetTurnAccess();
// Explicit memory requests reveal the gated tools early so the model knows the
// capability exists. The workflow gate still requires search/read in order.
if (EXPLICIT_MEMORY_INTENT.test(text)) enableDetailTools();
}
pi.registerTool({
name: "memory_search",
label: "Search Memory",
description: "Required first step for every recall, remember, update, or forget request. Search durable memory by keywords; executing this tool enables detailed memory tools even when no matches are found.",
promptSnippet: "First step for every recall, remember, update, or forget request; enables detailed memory tools",
promptGuidelines: [
"Always call memory_search first for recall, remember, update, or forget requests; it enables the detailed memory tools even when no match exists.",
],
parameters: Type.Object({
query: Type.String({ description: "Keywords for the person, project, preference, decision, or fact" }),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "Maximum candidates (default 8)" })),
}),
async execute(_toolCallId, params) {
enableDetailTools();
const result = await store.search(params.query, params.limit ?? 8);
turnAccess.recordSearch(result.results.map((item) => item.id));
if (!result.results.length) {
return textResult(`No memory candidates found for: ${params.query}\nDetailed memory tools are now enabled.`, result);
}
const lines = result.results.map((item) => {
const tags = item.tags.length ? ` [${item.tags.join(", ")}]` : "";
const description = item.description ? `${item.description}` : "";
return `- ${item.id} | ${item.type} | ${item.timestamp ?? "timestamp unavailable"}${tags}${description}`;
});
if (result.warnings.length) lines.push(`\nWarnings: ${result.warnings.length} invalid concept(s); use /memory-check --repair.`);
return textResult(lines.join("\n"), result);
},
});
pi.registerTool({
name: "memory_read",
label: "Read Memory",
description: "Load full bodies for up to 10 concept IDs returned by memory_search in the current user turn. Output is capped at 45,000 characters.",
parameters: Type.Object({
ids: Type.Array(Type.String({ description: "Bundle-relative concept ID without .md" }), { minItems: 1, maxItems: 10 }),
}),
async execute(_toolCallId, params) {
const normalizedIds = params.ids.map((id) => store.normalizeId(id));
for (const id of normalizedIds) turnAccess.requireSearchedId(id);
const result = await store.read(normalizedIds);
turnAccess.recordRead(result.documents.map((document) => document.id));
return textResult(result.text, result);
},
});
pi.registerTool({
name: "memory_write",
label: "Write Memory",
description: "Create or replace one atomic durable concept after searching in the current user turn; existing concepts must also be read first. Supply semantic fields and Markdown body only; storage mechanics are deterministic.",
parameters: Type.Object({
id: Type.String({ description: "Stable lowercase bundle-relative ID, e.g. people/alice; no .md" }),
type: Type.String({ description: "Short descriptive type, e.g. Person, Preference, Project, Decision" }),
title: Type.Optional(Type.String({ description: "Human-readable title" })),
description: Type.Optional(Type.String({ description: "One-sentence retrieval summary" })),
tags: Type.Optional(Type.Array(Type.String(), { maxItems: 20 })),
body: Type.String({ description: "Structured Markdown body without YAML frontmatter or a timestamp" }),
}),
async execute(_toolCallId, params) {
const id = store.normalizeId(params.id);
turnAccess.requireWrite(id, await store.exists(id));
const result = await store.upsert({ ...params, id });
return textResult(`${result.action}: ${result.id} (${result.timestamp})${result.attempts > 1 ? " after verified retry" : ""}`, result);
},
});
pi.registerTool({
name: "memory_forget",
label: "Forget Memory",
description: "Permanently delete one durable concept after searching and reading it in the current user turn. Confirmation must exactly match the concept ID.",
parameters: Type.Object({
id: Type.String({ description: "Concept ID to delete" }),
confirm: Type.String({ description: "Repeat the exact concept ID to confirm permanent deletion" }),
}),
async execute(_toolCallId, params) {
const id = store.normalizeId(params.id);
turnAccess.requireForget(id);
const result = await store.forget({ ...params, id });
return textResult(`deleted: ${result.id}`, result);
},
});
pi.on("input", (event) => {
// Do not revoke access while an earlier turn is still executing. Queued or
// steering input is prepared when its own before_agent_start event arrives.
if (event.streamingBehavior !== undefined) return;
prepareTurn(event.text);
preparedInput = event.text;
});
pi.on("before_agent_start", async (event) => {
if (preparedInput !== event.prompt) prepareTurn(event.prompt);
preparedInput = undefined;
try {
const hints = await store.hints(event.prompt, 2);
if (hints.length === 0) return;
const lines = hints.map((hint) => {
const summary = (hint.description || hint.title).replace(/\s+/g, " ").slice(0, 180);
return `- ${hint.id}${summary}`;
});
return {
// This is rebuilt for each user turn and is never appended to session history.
systemPrompt: `${event.systemPrompt}\n\nEphemeral long-term memory candidates (metadata only):\n${lines.join("\n")}\nTreat these only as retrieval hints. If relevant, call memory_search and then memory_read; otherwise ignore them.`,
};
} catch (error) {
console.warn(`Memory hint retrieval failed: ${error instanceof Error ? error.message : String(error)}`);
}
});
pi.on("session_start", async (_event, ctx) => {
try {
const report = await store.initialize();
resetTurnAccess();
if (report.repaired > 0 && ctx.hasUI) {
ctx.ui.notify(`Memory repaired ${report.repaired} concept(s); run /memory-check for details.`, "warning");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Memory initialization failed: ${message}`);
if (ctx.hasUI) ctx.ui.notify(`Memory initialization failed: ${message}`, "error");
}
});
pi.registerCommand("memory-check", {
description: "Validate memory; pass --repair for deterministic repair and regenerated indexes",
handler: async (args, ctx) => {
try {
const report = await store.audit({ repair: args.trim() === "--repair" });
const summary = `${report.valid ? "valid" : "invalid"}: ${report.concepts} concepts, ${report.issues.length} issues, ${report.repaired} repaired`;
if (ctx.hasUI) ctx.ui.notify(summary, report.valid ? "info" : "warning");
else console.log(summary);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (ctx.hasUI) ctx.ui.notify(message, "error");
else console.error(message);
}
},
});
pi.registerCommand("memory-path", {
description: "Show the OKF memory bundle path",
handler: async (_args, ctx) => {
if (ctx.hasUI) ctx.ui.notify(memoryRoot, "info");
else console.log(memoryRoot);
},
});
}

28
extensions/memory/package-lock.json generated Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "kimiko-memory-extension",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kimiko-memory-extension",
"dependencies": {
"yaml": "^2.8.1"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "kimiko-memory-extension",
"private": true,
"type": "module",
"scripts": {
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"yaml": "^2.8.1"
}
}

695
extensions/memory/store.mjs Normal file
View File

@@ -0,0 +1,695 @@
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 };

View File

@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { MemoryTurnAccess } from "../access.mjs";
test("requires current-turn search and read before updating an existing concept", () => {
const access = new MemoryTurnAccess();
assert.throws(() => access.requireWrite("projects/atlas", true), /memory_search/);
access.recordSearch(["projects/atlas"]);
assert.throws(() => access.requireWrite("projects/atlas", true), /read existing concept/);
access.recordRead(["projects/atlas"]);
assert.doesNotThrow(() => access.requireWrite("projects/atlas", true));
});
test("new concepts require duplicate search but cannot be read unless returned", () => {
const access = new MemoryTurnAccess();
access.recordSearch([]);
assert.doesNotThrow(() => access.requireWrite("projects/new-project", false));
assert.throws(() => access.requireSearchedId("projects/new-project"), /exact concept/);
});
test("turn reset removes prior authorization", () => {
const access = new MemoryTurnAccess();
access.recordSearch(["people/alice"]);
access.recordRead(["people/alice"]);
assert.doesNotThrow(() => access.requireForget("people/alice"));
access.reset();
assert.throws(() => access.requireForget("people/alice"), /this user turn/);
});

View File

@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { MemoryStore, parseFrontmatter } from "../store.mjs";
async function fixture(options = {}) {
const root = await mkdtemp(join(tmpdir(), "kimiko-memory-test-"));
const store = new MemoryStore(root, options);
await store.initialize();
return { root, store };
}
async function cleanup(root) {
await rm(root, { recursive: true, force: true });
}
test("writes valid concepts, preserves timestamps on no-op, and updates generated files", async () => {
const dates = [new Date("2026-07-22T09:30:00.000Z"), new Date("2026-07-23T10:00:00.000Z")];
let dateIndex = 0;
const { root, store } = await fixture({ clock: () => dates[Math.min(dateIndex, dates.length - 1)] });
try {
const created = await store.upsert({
id: "people/alice",
type: "Person",
title: "Alice",
description: "A test person.",
tags: ["test", "test"],
body: "# Facts\n\n- Likes deterministic systems.",
});
assert.equal(created.action, "created");
assert.equal(created.timestamp, dates[0].toISOString());
dateIndex = 1;
const unchanged = await store.upsert({
id: "people/alice",
type: "Person",
title: "Alice",
description: "A test person.",
tags: ["test"],
body: "# Facts\n\n- Likes deterministic systems.",
});
assert.equal(unchanged.action, "unchanged");
assert.equal(unchanged.timestamp, dates[0].toISOString());
const parsed = parseFrontmatter(await readFile(join(root, "people", "alice.md"), "utf8"));
assert.equal(parsed.metadata.type, "Person");
assert.equal(parsed.metadata.timestamp, dates[0].toISOString());
assert.deepEqual(parsed.metadata.tags, ["test"]);
const rootIndex = await readFile(join(root, "index.md"), "utf8");
const peopleIndex = await readFile(join(root, "people", "index.md"), "utf8");
const log = await readFile(join(root, "log.md"), "utf8");
assert.match(rootIndex, /okf_version: "?0\.1"?/);
assert.match(rootIndex, /\[People\]\(people\/\)/);
assert.match(peopleIndex, /\[Alice\]\(alice\.md\) - A test person\./);
assert.match(log, /\*\*Creation\*\*/);
} finally {
await cleanup(root);
}
});
test("verified writes retry once after deterministic verification failure", async () => {
let injected = false;
const { root, store } = await fixture({
clock: () => new Date("2026-07-22T09:30:00.000Z"),
afterWriteAttempt: async ({ path, attempt, kind }) => {
if (!injected && kind === "concept" && attempt === 1 && path.endsWith("retry.md")) {
injected = true;
await writeFile(path, "broken", "utf8");
}
},
});
try {
const result = await store.upsert({ id: "retry", type: "Reference", body: "# Retried" });
assert.equal(result.attempts, 2);
assert.equal(parseFrontmatter(await readFile(join(root, "retry.md"), "utf8")).metadata.type, "Reference");
} finally {
await cleanup(root);
}
});
test("audit repairs missing and malformed frontmatter without discarding bodies", async () => {
const { root, store } = await fixture({ clock: () => new Date("2026-07-22T09:30:00.000Z") });
try {
const missingPath = join(root, "missing.md");
const malformedPath = join(root, "malformed.md");
await writeFile(missingPath, "# Original\n\nKeep this body.\n", "utf8");
await writeFile(malformedPath, "---\ntype: [broken\n---\n\n# Also Original\n", "utf8");
const mtime = new Date("2026-01-02T03:04:05.000Z");
await utimes(missingPath, mtime, mtime);
const report = await store.audit({ repair: true });
assert.equal(report.repaired, 2);
assert.equal(report.valid, true);
const missing = parseFrontmatter(await readFile(missingPath, "utf8"));
const malformed = parseFrontmatter(await readFile(malformedPath, "utf8"));
assert.equal(missing.metadata.type, "Recovered Concept");
assert.equal(missing.metadata.timestamp, mtime.toISOString());
assert.match(missing.body, /Keep this body/);
assert.match(malformed.body, /Also Original/);
assert.ok(malformed.metadata.repair_backup);
assert.ok((await readdir(join(root, ".recovery"))).length >= 1);
} finally {
await cleanup(root);
}
});
test("search is metadata-first and forget requires an exact confirmation", async () => {
const { root, store } = await fixture({ clock: () => new Date("2026-07-22T09:30:00.000Z") });
try {
await store.upsert({
id: "projects/atlas",
type: "Project",
title: "Atlas",
description: "Infrastructure provisioning platform.",
tags: ["platform"],
body: "# Secret Detail\n\nBody-only retrieval token: zephyrquartz.",
});
const found = await store.search("infrastructure atlas", 5);
assert.equal(found.results[0].id, "projects/atlas");
assert.equal("body" in found.results[0], false);
assert.equal((await store.hints("What did we decide about Atlas?"))[0].id, "projects/atlas");
assert.deepEqual(await store.hints("What did we discuss about this project?"), []);
assert.deepEqual(await store.hints("zephyrquartz"), []);
await assert.rejects(() => store.forget({ id: "projects/atlas", confirm: "atlas" }), /confirm must exactly match/);
assert.equal((await store.forget({ id: "projects/atlas", confirm: "projects/atlas" })).action, "deleted");
await assert.rejects(() => store.read(["projects/atlas"]), /not found/);
const log = await readFile(join(root, "log.md"), "utf8");
assert.doesNotMatch(log, /projects\/atlas|Atlas/);
assert.match(log, /\*\*Deletion\*\*: Removed a concept/);
assert.doesNotMatch(await readFile(join(root, "projects", "index.md"), "utf8"), /Atlas/);
} finally {
await cleanup(root);
}
});
test("rejects unsafe or reserved concept ids", async () => {
const { root, store } = await fixture();
try {
for (const id of ["../escape", "/absolute", "People/Alice", "index", "notes/log", "a//b"]) {
assert.throws(() => store.normalizeId(id));
}
} finally {
await cleanup(root);
}
});