// mem-search / mem-timeline / mem-get tools — register on ExtensionAPI. import { Type } from "typebox"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { getByIds, search, timeline } from "../search/index.js"; import type { FullObservation } from "../types.js"; export function registerMemTools(pi: ExtensionAPI): void { pi.registerTool({ name: "mem_search", label: "Memory Search", description: "Search past observations across all sessions. Returns a compact index (id, type, title, time) — use mem_get with the ids to fetch full details. Layer 1 of 3.", parameters: Type.Object({ query: Type.String({ description: "Search keywords" }), limit: Type.Optional(Type.Number({ description: "Max results (default 10)" })), project: Type.Optional( Type.String({ description: "Optional project path filter; defaults to current cwd", }), ), }), execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => { const project = (params.project as string | undefined) ?? ctx.cwd; const limit = (params.limit as number | undefined) ?? 10; const result = search(params.query as string, project, limit); if (result.hits.length === 0) { return { content: [ { type: "text", text: `No matching observations for "${result.query}" (scanned ${result.totalCandidates} candidates).`, }, ], details: { hits: [], totalCandidates: result.totalCandidates }, }; } const lines = result.hits.map((h, i) => { const ts = new Date(h.createdAt).toISOString().slice(0, 16).replace("T", " "); const sub = h.subtitle ? ` — ${h.subtitle}` : ""; return `${i + 1}. [${h.id}] [${h.type}] ${h.title}${sub} _(${ts})_`; }); const text = `Found ${result.hits.length} matches (of ${result.totalCandidates} candidates) for "${result.query}":\n\n${lines.join("\n")}\n\nUse mem_get with the IDs to fetch full details.`; return { content: [{ type: "text", text }], details: { hits: result.hits, totalCandidates: result.totalCandidates }, }; }, }); pi.registerTool({ name: "mem_timeline", label: "Memory Timeline", description: "Get observations around a specific observation id, within a time window. Layer 2 of 3.", parameters: Type.Object({ id: Type.Number({ description: "Observation id to center the timeline on" }), windowMinutes: Type.Optional( Type.Number({ description: "Time window in minutes (default 30, i.e. ±30min)", default: 30, }), ), }), execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => { const windowMs = ((params.windowMinutes as number | undefined) ?? 30) * 60_000; const items = timeline(params.id as number, windowMs); if (items.length === 0) { return { content: [{ type: "text", text: `No timeline context found for observation ${params.id}.` }], details: { items: [] }, }; } const lines = items.map((o) => { const ts = new Date(o.createdAt).toISOString().slice(0, 16).replace("T", " "); const marker = o.id === params.id ? "→" : " "; return `${marker} [${o.id}] [${o.type}] ${ts} ${o.title}`; }); return { content: [ { type: "text", text: `Timeline around observation ${params.id} (±${(params.windowMinutes as number | undefined) ?? 30}min):\n\n${lines.join("\n")}`, }, ], details: { items }, }; }, }); pi.registerTool({ name: "mem_get", label: "Memory Get", description: "Fetch full observation details by IDs. Use after mem_search to expand only relevant results. Layer 3 of 3.", parameters: Type.Object({ ids: Type.Array(Type.Number(), { description: "Observation IDs to fetch (from mem_search results)", }), }), execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => { const ids = params.ids as number[]; const items = getByIds(ids); if (items.length === 0) { return { content: [{ type: "text", text: "No observations found for the given IDs." }], details: { items: [] as FullObservation[] }, }; } const blocks = items.map(formatObservationBlock); return { content: [{ type: "text", text: blocks.join("\n\n---\n\n") }], details: { items }, }; }, }); } function formatObservationBlock(o: FullObservation): string { const ts = new Date(o.createdAt).toISOString().slice(0, 16).replace("T", " "); const lines: string[] = []; lines.push(`#${o.id} [${o.type}] ${o.title}`); lines.push(`(${ts} · session ${o.sessionId.slice(0, 12)} · project ${o.project})`); if (o.subtitle) lines.push(o.subtitle); if (o.facts.length > 0) { lines.push(""); lines.push("Facts:"); for (const f of o.facts) lines.push(` - ${f}`); } if (o.narrative) { lines.push(""); lines.push("Narrative:"); lines.push(o.narrative); } if (o.concepts.length > 0) { lines.push(""); lines.push(`Concepts: ${o.concepts.join(", ")}`); } if (o.filesRead.length > 0) { lines.push(`Read: ${o.filesRead.join(", ")}`); } if (o.filesModified.length > 0) { lines.push(`Modified: ${o.filesModified.join(", ")}`); } return lines.join("\n"); }