Files
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

185 lines
8.8 KiB
TypeScript

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);
},
});
}