/** * 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 { sanitizeLine } from "../../lib/format.ts"; import { reuseText, toolDetails } from "../../lib/tui/chrome.ts"; import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts"; import { paint } from "../../lib/tui/tokens.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 }; return { content: [{ type: "text" as const, text: formatResultText(full) }], details: full, structuredContent: toStructuredContent(full), ...(error === undefined ? {} : { isError: true as const }), }; } 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 text = reuseCallText(context.lastComponent); const body = formatCallText( args as { action?: unknown; text?: unknown; to?: unknown; scope?: unknown }, context.argsComplete, ); text.setText(toolCallTitle(theme, `${TOOL_NAME} `) + paint(theme, "meta", body)); return text; }, renderResult(result, { expanded }, theme, context) { const text = reuseText(context.lastComponent); const details = toolDetails(result.details); if (!details) { text.setText(rejectionLine(theme, result, expanded)); return text; } if (details.error) { text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`)); return text; } text.setText(formatResultText(details, expanded)); return text; }, }); }