/** * Model-facing registration for the `memory` tool. * * One singleton tool with an action switch, like `job`: `add`, `update`, and * `forget` mutate one scope, `list` reads both. Validation failures come back as * an error result carrying the unchanged notes, so the model can retry without * corrupting a store. An `add` that duplicates an existing note is a no-op that * names the stored note. */ import type { JsonValue } from "@earendil-works/pi-ai"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { callLine, detailsOrLine } from "../../lib/tui/tool.ts"; import { failedResult, textResult } from "../../lib/tool-result.ts"; import { reuseText, toolDetails } from "../../lib/tui/chrome.ts"; import { messageOf } from "./errors.ts"; import { formatCallText, formatResultText, scopeLabel } from "./format.ts"; import type { MemoryRuntime } from "./runtime.ts"; import { MemoryParams, MemoryResult, normalizeAction, normalizeEntryText, normalizeScope, type MemoryArgs, } from "./schema.ts"; import type { MemoryDetails, MemoryScope } from "./types.ts"; export const TOOL_NAME = "memory"; /** JSON-safe mirror of the details for `structuredContent`. */ function toStructuredContent(details: MemoryDetails): Record { const content: Record = { action: details.action, scope: details.scope, project: details.project, global: details.global, changed: details.changed, }; if (details.entry !== undefined) content.entry = details.entry; if (details.to !== undefined) content.to = details.to; if (details.existing !== undefined) content.existing = details.existing; if (details.error !== undefined) content.error = details.error; return content; } /** * Build the tool result for one call. A rejected call carries the unchanged * notes and is marked `isError`; nothing was written. */ function memoryResult(details: MemoryDetails, error?: string) { const full: MemoryDetails = error === undefined ? details : { ...details, error }; const text = formatResultText(full); const structuredContent = toStructuredContent(full); return error === undefined ? textResult(text, full, structuredContent) : failedResult(text, full, structuredContent); } export function registerTools(pi: ExtensionAPI, runtime: MemoryRuntime): void { pi.registerTool({ name: TOOL_NAME, label: "Memory", description: [ "Save, change, remove, or list short durable notes that are recalled automatically in later sessions.", 'Notes are scoped "project" (the current repository, the default) or "global" (every project); stored notes', "are injected into context before each run. Adding a note that already exists, ignoring case and trailing", "punctuation, is a no-op.", ].join(" "), promptSnippet: "Save, change, or remove a durable note that is recalled in later sessions.", promptGuidelines: [ "Use memory for short, durable facts the user asks you to remember; keep one fact per call.", "Use action list only to see what is stored; the notes are already in context.", "To remove a note, pass its exact text with action forget; bulk clearing is not available.", "To change a note, use action update with its exact text and the replacement in to; do not forget and re-add.", ], parameters: MemoryParams, outputSchema: MemoryResult, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, executionMode: "sequential", async execute(_toolCallId, params, _signal, _onUpdate) { const args = params as MemoryArgs; let action: MemoryDetails["action"]; let scope: MemoryScope; try { action = normalizeAction(args.action); scope = normalizeScope(args.scope); } catch (error) { return memoryResult( { action: "list", scope: "project", ...runtime.entries(), changed: false }, messageOf(error), ); } try { if (action === "list") { return memoryResult({ action, scope, ...runtime.entries(), changed: false }); } const text = normalizeEntryText(args.text); if (action === "add") { const result = await runtime.add(scope, text); return memoryResult({ action, scope, ...runtime.entries(), changed: result.changed, entry: text, ...(result.existing === undefined ? {} : { existing: result.existing }), }); } if (action === "update") { const to = normalizeEntryText(args.to, "to"); if (text === to) { return memoryResult({ action, scope, ...runtime.entries(), changed: false, entry: text, to }); } const changed = await runtime.update(scope, text, to); if (!changed) { return memoryResult( { action, scope, ...runtime.entries(), changed: false, entry: text, to }, `No ${scopeLabel(scope)} memory note matches: ${text}`, ); } return memoryResult({ action, scope, ...runtime.entries(), changed, entry: text, to }); } const changed = await runtime.forget(scope, text); if (!changed) { return memoryResult( { action, scope, ...runtime.entries(), changed: false, entry: text }, `No ${scopeLabel(scope)} memory note matches: ${text}`, ); } return memoryResult({ action, scope, ...runtime.entries(), changed, entry: text }); } catch (error) { return memoryResult({ action, scope, ...runtime.entries(), changed: false }, messageOf(error)); } }, renderCall(args, theme, context) { const body = formatCallText( args as { action?: unknown; text?: unknown; to?: unknown; scope?: unknown }, context.argsComplete, ); return callLine(theme, context.lastComponent, TOOL_NAME, body); }, renderResult(result, { expanded }, theme, context) { const text = reuseText(context.lastComponent); const outcome = detailsOrLine(theme, result, expanded, toolDetails(result.details)); if ("line" in outcome) { text.setText(outcome.line); return text; } text.setText(formatResultText(outcome.details, expanded)); return text; }, }); }