From 76cce3a70c68df4e6be98d7e5b9d3fbe80048137 Mon Sep 17 00:00:00 2001 From: ZeR020 <88128532+ZeR020@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:25:01 +0000 Subject: [PATCH] feat(pi-extension): register native memory tools Normalize string tool_result content before file-context append so the original read text remains. Default tool project is the Git-root identity; all_projects drops project. --- packages/pi-extension/README.md | 2 +- packages/pi-extension/src/index.test.ts | 96 ++- packages/pi-extension/src/index.ts | 80 ++- packages/pi-extension/src/tools.test.ts | 46 ++ packages/pi-extension/src/tools.ts | 917 ++++++++++++++++++++++++ 5 files changed, 1136 insertions(+), 5 deletions(-) create mode 100644 packages/pi-extension/src/tools.test.ts create mode 100644 packages/pi-extension/src/tools.ts diff --git a/packages/pi-extension/README.md b/packages/pi-extension/README.md index ef97d72ec..c062ec99f 100644 --- a/packages/pi-extension/README.md +++ b/packages/pi-extension/README.md @@ -33,7 +33,7 @@ Build first so `dist/index.js` exists (`pnpm --filter @codemem/pi-extension buil - **Ingest** — captures pi session events (`session_start`/`session_shutdown`, user/assistant messages, tool calls/results) into codemem with `source: "pi"` via `POST /api/pi-hooks`, falling back to `codemem pi-hook-ingest` (spool when offline). - **Injection** — on `before_agent_start`, appends a `## codemem memories` block to the **turn-local** `systemPrompt` (never the persistent `message` channel). -- **Tools** — not registered in this package slice (follow-up). +- **Tools** — registers the 14 `memory_*` tools natively (HTTP preferred, CLI fallback). No `pi-mcp-adapter` needed. Skipped when `pi.tools_mode` is `mcp-adapter`. - **Compaction** — pi-only observe-only boundary: `session_before_compact` flushes extraction; never returns a custom compaction summary. - **Fork/resume** — re-keys stream identity on every `session_start`; durable cursors via `pi.appendEntry`. - **Cross-agent** — one shared, project-scoped store. Memories from OpenCode/Claude/Codex inject into pi and the reverse. diff --git a/packages/pi-extension/src/index.test.ts b/packages/pi-extension/src/index.test.ts index a0d03912c..6a294df70 100644 --- a/packages/pi-extension/src/index.test.ts +++ b/packages/pi-extension/src/index.test.ts @@ -1,3 +1,6 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { mapPiEventPayload } from "@codemem/core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CLI_PACK_TIMEOUT_MS } from "./client.js"; @@ -5,6 +8,7 @@ import type { PiExtensionConfig } from "./config.js"; import { defaultPiExtensionConfig } from "./config.js"; import codememPiExtension, { __setTestExecImpl, + expectedToolNames, formatPiInjectionBlock, stableMessageEntryId, } from "./index.js"; @@ -70,7 +74,7 @@ describe("extension factory lifecycle", () => { }); it("registers session + agent + tool handlers without starting daemons", () => { - const { pi, handlers } = createMockPi(); + const { pi, handlers, tools } = createMockPi(); codememPiExtension(pi as never); const expectedEvents = [ @@ -85,6 +89,13 @@ describe("extension factory lifecycle", () => { for (const name of expectedEvents) { expect(handlers.has(name), `missing handler for ${name}`).toBe(true); } + expect(tools.map((t) => t.name).toSorted()).toEqual(expectedToolNames().toSorted()); + }); + it("skips native tools when tools_mode is mcp-adapter", () => { + vi.stubEnv("CODEMEM_PI_TOOLS_MODE", "mcp-adapter"); + const { pi, tools } = createMockPi(); + codememPiExtension(pi as never); + expect(tools).toHaveLength(0); }); it("session_start re-keys state and persists cursor after ingest attempt", async () => { @@ -788,6 +799,89 @@ describe("injection (before_agent_start)", () => { expect(result?.content).toHaveLength(2); expect(String(result?.content?.[1]?.text ?? "")).toContain("## codemem memories"); }); + it("file-context appends (never replaces) string tool_result content", async () => { + __setTestExecImpl(async (args) => { + if (args[0] === "claude-hook-file-context") { + return { + stdout: JSON.stringify({ + hookSpecificOutput: { additionalContext: "per-file context" }, + }), + stderr: "", + }; + } + return { stdout: JSON.stringify({ inserted: 1, skipped: 0 }), stderr: "" }; + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("offline"); + }), + ); + + const { pi, handlers } = createMockPi(); + codememPiExtension(pi as never); + const ctx = createMockCtx(); + await handlers.get("session_start")?.[0]?.({ type: "session_start", reason: "startup" }, ctx); + + const result = (await handlers.get("tool_result")?.[0]?.( + { + type: "tool_result", + toolCallId: "tc-file-ctx-string", + toolName: "read", + input: { path: "src/a.ts" }, + content: "original file text", + isError: false, + }, + ctx, + )) as { content?: Array<{ type: string; text: string }> } | undefined; + + expect(result?.content).toHaveLength(2); + expect(String(result?.content?.[0]?.text ?? "")).toContain("original file text"); + expect(String(result?.content?.[1]?.text ?? "")).toContain("## codemem memories"); + }); + it("memory_search tool scopes to the git-root project from a nested cwd", async () => { + vi.stubEnv("CODEMEM_PROJECT", ""); + const tmpDir = mkdtempSync(join(tmpdir(), "codemem-pi-tool-scope-")); + const repoRoot = join(tmpDir, "scoped-repo"); + const nested = join(repoRoot, "packages", "core"); + mkdirSync(join(repoRoot, ".git"), { recursive: true }); + mkdirSync(nested, { recursive: true }); + + try { + const cliArgs: string[][] = []; + __setTestExecImpl(async (args) => { + cliArgs.push(args); + if (args[0] === "pi-hook-ingest") { + return { stdout: JSON.stringify({ inserted: 1, skipped: 0 }), stderr: "" }; + } + return { stdout: JSON.stringify({ items: [{ id: 1, title: "hit" }] }), stderr: "" }; + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("offline"); + }), + ); + + const { pi, handlers, tools } = createMockPi(); + codememPiExtension(pi as never); + const ctx = createMockCtx({ cwd: nested }); + await handlers.get("session_start")?.[0]?.({ type: "session_start", reason: "startup" }, ctx); + + const searchTool = tools.find((t) => t.name === "memory_search"); + expect(searchTool).toBeDefined(); + await searchTool?.execute("tc-scope-1", { query: "recall" }, ctx.signal); + + const searchCall = cliArgs.find((args) => args[0] === "search"); + expect(searchCall).toBeDefined(); + expect(searchCall).toContain("--project"); + const projectIndex = searchCall?.indexOf("--project") ?? -1; + expect(searchCall?.[projectIndex + 1]).toBe("scoped-repo"); + expect(searchCall?.[projectIndex + 1]).not.toBe("core"); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); describe("config defaults", () => { diff --git a/packages/pi-extension/src/index.ts b/packages/pi-extension/src/index.ts index 9b0096977..cfde0d5b0 100644 --- a/packages/pi-extension/src/index.ts +++ b/packages/pi-extension/src/index.ts @@ -11,11 +11,13 @@ * Surfaces: * - Ingest → POST /api/pi-hooks → CLI pi-hook-ingest * - Injection → before_agent_start systemPrompt append only (never message) - * - Native memory tools are not registered in this slice - + * - Tools → pi.registerTool × 14 when pi.tools_mode === "native" * - session_before_compact → flush signal only (never return compaction) */ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; import type { BeforeAgentStartEvent, ExtensionAPI, @@ -49,6 +51,7 @@ import { serializeToolOutput, stableMessageEntryId, } from "./payloads.js"; +import { registerMemoryTools } from "./tools.js"; import { createViewerRuntime, stopViewerTracking, type ViewerRuntime } from "./viewer.js"; /** Test-only CLI override. Null in production. */ @@ -179,6 +182,56 @@ async function safeIngest( } } +/** Resolve pi agent dir (~/.pi/agent), honoring PI_CODING_AGENT_DIR. */ +function resolvePiAgentDirForMcp(): string { + const fromEnv = process.env.PI_CODING_AGENT_DIR?.trim(); + if (fromEnv) { + if (fromEnv.startsWith("~/")) return join(homedir(), fromEnv.slice(2)); + return isAbsolute(fromEnv) ? fromEnv : join(homedir(), fromEnv); + } + return join(homedir(), ".pi", "agent"); +} + +function mcpJsonHasCodememEntry(path: string): boolean { + if (!existsSync(path)) return false; + try { + const raw = readFileSync(path, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return false; + const servers = (parsed as Record).mcpServers; + if (servers == null || typeof servers !== "object" || Array.isArray(servers)) return false; + return Object.hasOwn(servers as object, "codemem"); + } catch { + return false; + } +} + +/** + * D6: when native tools are registered but a codemem mcp.json entry also exists, + * warn once so the user can pick a single surface via pi.tools_mode. + */ +function warnDuplicateToolSurface(ctx: ExtensionContext, cwd: string): void { + try { + const candidates = [join(resolvePiAgentDirForMcp(), "mcp.json"), join(cwd, ".pi", "mcp.json")]; + const hit = candidates.some((p) => mcpJsonHasCodememEntry(p)); + if (!hit) return; + const msg = + 'codemem: both native tools and an mcp.json codemem entry are present. Pick one surface: set pi.tools_mode to "mcp-adapter" (or CODEMEM_PI_TOOLS_MODE=mcp-adapter) to use MCP only, or remove the codemem entry from mcp.json to keep native tools.'; + try { + const ui = (ctx as { ui?: { notify?: (m: string) => void } }).ui; + if (ui && typeof ui.notify === "function") { + ui.notify(msg); + return; + } + } catch { + // fall through to console + } + console.warn(msg); + } catch { + // best-effort; never break session_start + } +} + function resolveProject(cwd: string, envProject?: string | null): string | null { const fromEnv = envProject?.trim() || process.env.CODEMEM_PROJECT?.trim(); if (fromEnv) return fromEnv; @@ -192,6 +245,16 @@ function readPathFromToolInput(input: Record): string | null { } return null; } +/** + * Runtime tool_result content may arrive as an array (declared type) or as a + * plain string. Normalize to text blocks before appending file context so a + * string body is never dropped (maintainer blocker #2). + */ +function normalizeToolResultContent(content: unknown): Array<{ type: "text"; text: string }> { + if (typeof content === "string" && content) return [{ type: "text", text: content }]; + if (Array.isArray(content)) return content as Array<{ type: "text"; text: string }>; + return []; +} /** * Extension factory. Default export required by pi package loader. @@ -207,6 +270,11 @@ export default function codememPiExtension(pi: ExtensionAPI): void { execImpl: testExecImpl, }); + // Native tools only when not in mcp-adapter mode (D6). + if (config.toolsMode === "native") { + registerMemoryTools(pi, client); + } + // ---- session_start ---- pi.on("session_start", async (event: SessionStartEvent, ctx: ExtensionContext) => { const sessionId = ctx.sessionManager.getSessionId(); @@ -221,6 +289,11 @@ export default function codememPiExtension(pi: ExtensionAPI): void { state.seenEventKeys = loadCursorsFromSession(ctx, sessionId); client.rekey(sessionId, cwd, project); + // D6: warn once when native mode coexists with an mcp.json codemem entry. + if (config.toolsMode === "native") { + warnDuplicateToolSurface(ctx, cwd); + } + // Start viewer on first session need (not in factory). void client.ensureViewer(ctx.signal).catch(() => {}); @@ -349,7 +422,7 @@ export default function codememPiExtension(pi: ExtensionAPI): void { const block = fileCtx.preformatted ? fileCtx.text : formatPiInjectionBlock(fileCtx.text, 4_000); - const existing = Array.isArray(event.content) ? [...event.content] : []; + const existing = normalizeToolResultContent(event.content); return { content: [...existing, { type: "text" as const, text: `\n\n${block}` }], }; @@ -467,3 +540,4 @@ export { formatPiInjectionBlock, stableMessageEntryId, } from "./payloads.js"; +export { expectedToolNames, registerMemoryTools } from "./tools.js"; diff --git a/packages/pi-extension/src/tools.test.ts b/packages/pi-extension/src/tools.test.ts new file mode 100644 index 000000000..6b9866a24 --- /dev/null +++ b/packages/pi-extension/src/tools.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { buildDistillBody, parseCliJson } from "./tools.js"; + +describe("parseCliJson", () => { + it("parses a nested top-level object (codex review: lastIndexOf picked the inner object)", () => { + expect(parseCliJson('{"items":[{"id":1}]}')).toEqual({ items: [{ id: 1 }] }); + }); + + it("parses a top-level array", () => { + expect(parseCliJson('[{"id":1}]')).toEqual([{ id: 1 }]); + }); + + it("parses JSON that follows CLI log lines", () => { + expect(parseCliJson('info: starting\n{"id":2}')).toEqual({ id: 2 }); + }); + + it("returns the raw string when nothing parses", () => { + expect(parseCliJson("just a log line")).toBe("just a log line"); + }); + + it("returns null for empty stdout", () => { + expect(parseCliJson(" \n ")).toBeNull(); + }); +}); + +describe("buildDistillBody", () => { + it("omits project when all_projects is true (would otherwise 400: mutually exclusive)", () => { + const body = buildDistillBody({ all_projects: true, limit: 5 }, "codemem"); + expect("project" in body).toBe(false); + }); + + it("omits project when caller explicitly projects and also asks all_projects", () => { + const body = buildDistillBody({ all_projects: true, project: "codemem" }, "other"); + expect("project" in body).toBe(false); + }); + + it("fills client project when caller did not supply one", () => { + const body = buildDistillBody({ limit: 5 }, "codemem"); + expect(body.project).toBe("codemem"); + }); + + it("keeps explicit caller project", () => { + const body = buildDistillBody({ project: "mine" }, "codemem"); + expect(body.project).toBe("mine"); + }); +}); diff --git a/packages/pi-extension/src/tools.ts b/packages/pi-extension/src/tools.ts new file mode 100644 index 000000000..a6974f43b --- /dev/null +++ b/packages/pi-extension/src/tools.ts @@ -0,0 +1,917 @@ +/** + * Native memory_* tool registration for pi. + * HTTP preferred via PiCodememClient; CLI fallback for every tool. + * Errors return as tool results (never throw through pi's loop). + */ + +import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { errorResult, jsonResult, type PiCodememClient, type ToolResultContent } from "./client.js"; +import { MEMORY_LEARN_PAYLOAD } from "./learn.js"; + +/** Loose tool def — TypeBox Static inference is intentionally erased at the boundary. */ +type AnyToolDef = ToolDefinition; + +const filterProps = { + kind: Type.Optional(Type.String({ description: "Filter by memory kind" })), + project: Type.Optional( + Type.String({ description: "Filter by project scope (matches sessions.project)" }), + ), +}; + +const memoryKind = Type.Union([ + Type.Literal("discovery"), + Type.Literal("change"), + Type.Literal("feature"), + Type.Literal("bugfix"), + Type.Literal("refactor"), + Type.Literal("decision"), + Type.Literal("exploration"), +]); + +function asRecord(value: unknown): Record { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function projectOrClient( + params: Record, + client: PiCodememClient, +): string | undefined { + if (typeof params.project === "string" && params.project.trim()) return params.project.trim(); + return client.project ?? undefined; +} + +async function withToolError( + label: string, + fn: () => Promise, +): Promise { + try { + return await fn(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return errorResult(`codemem ${label} failed: ${msg}`); + } +} + +async function httpOrCli( + _client: PiCodememClient, + _signal: AbortSignal | undefined, + http: () => Promise, + cli: () => Promise, +): Promise { + try { + const httpResult = await http(); + if (httpResult) return httpResult; + } catch { + // fall through to CLI + } + return cli(); +} + +/** + * Parse CLI stdout that may contain log lines before a JSON payload. + * Scans candidate JSON value starts in document order and returns the first + * suffix that parses — handling nested top-level objects/arrays correctly + * (lastIndexOf picks inner objects and breaks on `{"items":[{"id":1}]}`). + */ +export function parseCliJson(stdout: string): unknown { + const trimmed = stdout.trim(); + if (!trimmed) return null; + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i]; + if (ch !== "{" && ch !== "[") continue; + try { + return JSON.parse(trimmed.slice(i)); + } catch { + // not a parseable suffix — keep scanning + } + } + return trimmed; +} + +function paramsOf(params: unknown): Record { + return params != null && typeof params === "object" && !Array.isArray(params) + ? (params as Record) + : {}; +} + +/** + * Build a memory_distill_candidates request body. `all_projects` and `project` + * are mutually exclusive server-side (memory-tools.ts / MCP distill guard), so + * client/session project must NOT be attached when all_projects is requested. + */ +export function buildDistillBody( + params: Record, + clientProject?: string, +): Record { + const body = { ...(params as Record) }; + if (params.all_projects === true || params.all_projects === "true") { + delete body.project; + return body; + } + if (typeof body.project !== "string" || !body.project.trim()) { + if (clientProject) body.project = clientProject; + } + return body; +} + +export function registerMemoryTools(pi: ExtensionAPI, client: PiCodememClient): string[] { + const registered: string[] = []; + + const register = (def: AnyToolDef) => { + pi.registerTool(def); + registered.push(def.name); + }; + + // Helper: build a tool def without fighting TypeBox Static inference at the boundary. + const tool = (def: { + name: string; + label: string; + description: string; + parameters: unknown; + execute: ( + toolCallId: string, + params: unknown, + signal: AbortSignal | undefined, + ) => Promise; + }): AnyToolDef => def as unknown as AnyToolDef; + + // ---- memory_search ---- + register( + tool({ + name: "memory_search", + label: "Memory Search", + description: "Search memories by text query. Returns full body text for each match.", + parameters: Type.Object({ + query: Type.String({ description: "Search query" }), + limit: Type.Optional( + Type.Integer({ minimum: 1, maximum: 50, default: 5, description: "Max results" }), + ), + ...filterProps, + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_search", async () => { + const query = String(params.query ?? ""); + const limit = Number(params.limit ?? 5); + const project = projectOrClient(params as Record, client); + const kind = typeof params.kind === "string" ? params.kind : undefined; + + return httpOrCli( + client, + signal, + async () => { + // Full-body search: search_index + expand(include_observations). + const index = await client.httpJson("GET", "/api/memories/search_index", { + query: { query, limit, project, kind }, + signal, + }); + if (!index.ok) return null; + const items = asRecord(index.data).items; + if (!Array.isArray(items) || items.length === 0) { + return jsonResult({ items: [] }); + } + const ids = items + .map((item) => asRecord(item).id) + .filter((id): id is number => typeof id === "number"); + if (ids.length === 0) return jsonResult({ items }); + const expanded = await client.httpJson("POST", "/api/memories/expand", { + body: { + ids, + depth_before: 0, + depth_after: 0, + include_observations: true, + project, + kind, + }, + signal, + }); + if (expanded.ok) { + const obs = asRecord(expanded.data).observations; + if (Array.isArray(obs) && obs.length > 0) { + return jsonResult({ items: obs }); + } + const anchors = asRecord(expanded.data).anchors; + if (Array.isArray(anchors)) return jsonResult({ items: anchors }); + } + return jsonResult({ items }); + }, + async () => { + const args = ["search", query, "--json", "-n", String(limit)]; + if (project) args.push("--project", project); + if (kind) args.push("--kind", kind); + const { stdout } = await client.execCodemem(args, { signal }); + return jsonResult(parseCliJson(stdout) ?? { items: [] }); + }, + ); + }); + }, + }), + ); + + // ---- memory_search_index ---- + register( + tool({ + name: "memory_search_index", + label: "Memory Search Index", + description: + "Search memories by text query. Returns compact index entries (no body) for browsing.", + parameters: Type.Object({ + query: Type.String({ description: "Search query" }), + limit: Type.Optional( + Type.Integer({ minimum: 1, maximum: 50, default: 8, description: "Max results" }), + ), + ...filterProps, + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_search_index", async () => { + const query = String(params.query ?? ""); + const limit = Number(params.limit ?? 8); + const project = projectOrClient(params as Record, client); + const kind = typeof params.kind === "string" ? params.kind : undefined; + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("GET", "/api/memories/search_index", { + query: { query, limit, project, kind }, + signal, + }); + if (!res.ok) return null; + return jsonResult(res.data); + }, + async () => { + const args = ["search", query, "--json", "-n", String(limit)]; + if (project) args.push("--project", project); + if (kind) args.push("--kind", kind); + const { stdout } = await client.execCodemem(args, { signal }); + const parsed = parseCliJson(stdout); + // Compact: drop body fields if present. + const record = asRecord(parsed); + const items = Array.isArray(record.items) + ? record.items + : Array.isArray(parsed) + ? parsed + : []; + const compact = items.map((item) => { + const row = asRecord(item); + return { + id: row.id, + kind: row.kind, + title: row.title, + score: row.score, + created_at: row.created_at, + session_id: row.session_id, + metadata: row.metadata, + }; + }); + return jsonResult({ items: compact }); + }, + ); + }); + }, + }), + ); + + // ---- memory_explain ---- + register( + tool({ + name: "memory_explain", + label: "Memory Explain", + description: "Explain why memories match a query or set of IDs (ranking diagnostics).", + parameters: Type.Object({ + query: Type.Optional(Type.String({ description: "Search query" })), + ids: Type.Optional( + Type.Array(Type.Integer(), { maxItems: 200, description: "Memory IDs to explain" }), + ), + limit: Type.Optional( + Type.Integer({ minimum: 1, maximum: 50, default: 10, description: "Max results" }), + ), + include_pack_context: Type.Optional( + Type.Boolean({ description: "Include pack assembly context" }), + ), + ...filterProps, + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_explain", async () => { + const body = { ...(params as Record) }; + if (!body.project && client.project) body.project = client.project; + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("POST", "/api/memories/explain", { + body, + signal, + }); + if (!res.ok) return null; + return jsonResult(res.data); + }, + async () => { + // No dedicated CLI twin — surface a clear degraded message. + return errorResult( + "memory_explain requires the codemem viewer server (HTTP). Start it with `codemem serve start`.", + ); + }, + ); + }); + }, + }), + ); + + // ---- memory_recent ---- + register( + tool({ + name: "memory_recent", + label: "Memory Recent", + description: "Return recent memories, newest first.", + parameters: Type.Object({ + limit: Type.Optional( + Type.Integer({ minimum: 1, maximum: 100, default: 8, description: "Max results" }), + ), + ...filterProps, + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_recent", async () => { + const limit = Number(params.limit ?? 8); + const project = projectOrClient(params as Record, client); + const kind = typeof params.kind === "string" ? params.kind : undefined; + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("GET", "/api/memory", { + query: { limit, project, kind }, + signal, + }); + if (!res.ok) return null; + return jsonResult(res.data); + }, + async () => { + const args = ["recent", "--json", "--limit", String(limit)]; + if (project) args.push("--project", project); + if (kind) args.push("--kind", kind); + const { stdout } = await client.execCodemem(args, { signal }); + return jsonResult(parseCliJson(stdout) ?? { items: [] }); + }, + ); + }); + }, + }), + ); + + // ---- memory_pack ---- + register( + tool({ + name: "memory_pack", + label: "Memory Pack", + description: + "Build a formatted memory pack from search results — quick one-shot context block.", + parameters: Type.Object({ + context: Type.String({ description: "Context description to search for" }), + limit: Type.Optional( + Type.Integer({ minimum: 1, maximum: 50, description: "Max items to include" }), + ), + ...filterProps, + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_pack", async () => { + const context = String(params.context ?? ""); + const limit = params.limit != null ? Number(params.limit) : undefined; + const project = projectOrClient(params as Record, client); + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("GET", "/api/pack", { + query: { + context, + limit: limit ?? 10, + token_budget: client.config.injectTokenBudget, + project, + }, + signal, + }); + if (!res.ok) return null; + return jsonResult(res.data); + }, + async () => { + const args = ["pack", context, "--json"]; + if (limit != null) args.push("-n", String(limit)); + if (project) args.push("--project", project); + const { stdout } = await client.execCodemem(args, { signal }); + return jsonResult(parseCliJson(stdout) ?? {}); + }, + ); + }); + }, + }), + ); + + // ---- memory_get ---- + register( + tool({ + name: "memory_get", + label: "Memory Get", + description: "Fetch a single memory item by ID.", + parameters: Type.Object({ + memory_id: Type.Integer({ description: "Memory ID" }), + ...filterProps, + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_get", async () => { + const memoryId = Number(params.memory_id); + const project = projectOrClient(params as Record, client); + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("POST", "/api/memories/expand", { + body: { + ids: [memoryId], + depth_before: 0, + depth_after: 0, + include_observations: true, + project, + }, + signal, + }); + if (!res.ok) return null; + const data = asRecord(res.data); + const obs = Array.isArray(data.observations) ? data.observations : []; + const anchors = Array.isArray(data.anchors) ? data.anchors : []; + const item = obs[0] ?? anchors[0] ?? null; + if (!item) return errorResult("not_found"); + return jsonResult(item); + }, + async () => { + const { stdout } = await client.execCodemem( + ["memory", "show", String(memoryId), "--json"], + { signal }, + ); + const parsed = parseCliJson(stdout); + if ( + parsed != null && + typeof parsed === "object" && + !Array.isArray(parsed) && + (parsed as { error?: string }).error + ) { + return errorResult(String((parsed as { message?: string }).message ?? "not_found")); + } + return jsonResult(parsed); + }, + ); + }); + }, + }), + ); + + // ---- memory_get_observations ---- + register( + tool({ + name: "memory_get_observations", + label: "Memory Get Observations", + description: "Fetch multiple memory items by their IDs.", + parameters: Type.Object({ + ids: Type.Array(Type.Integer(), { maxItems: 200, description: "Memory IDs to fetch" }), + ...filterProps, + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_get_observations", async () => { + const ids = Array.isArray(params.ids) ? params.ids.map(Number) : []; + const project = projectOrClient(params as Record, client); + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("POST", "/api/memories/expand", { + body: { + ids, + depth_before: 0, + depth_after: 0, + include_observations: true, + project, + }, + signal, + }); + if (!res.ok) return null; + const data = asRecord(res.data); + const items = Array.isArray(data.observations) + ? data.observations + : Array.isArray(data.anchors) + ? data.anchors + : []; + return jsonResult({ items }); + }, + async () => { + const items: unknown[] = []; + for (const id of ids) { + try { + const { stdout } = await client.execCodemem( + ["memory", "show", String(id), "--json"], + { signal }, + ); + const parsed = parseCliJson(stdout); + if ( + parsed != null && + typeof parsed === "object" && + !(parsed as { error?: string }).error + ) { + items.push(parsed); + } + } catch { + // skip missing + } + } + return jsonResult({ items }); + }, + ); + }); + }, + }), + ); + + // ---- memory_remember ---- + register( + tool({ + name: "memory_remember", + label: "Memory Remember", + description: "Create a new memory. Use for milestones, decisions, and notable facts.", + parameters: Type.Object({ + kind: memoryKind, + title: Type.String({ description: "Short title" }), + body: Type.String({ description: "Body text (high-signal content)" }), + confidence: Type.Optional( + Type.Number({ minimum: 0, maximum: 1, default: 0.5, description: "Confidence 0-1" }), + ), + project: Type.Optional(Type.String({ description: "Project identifier" })), + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_remember", async () => { + const project = projectOrClient(params as Record, client); + const body = { + kind: params.kind, + title: params.title, + body: params.body, + confidence: params.confidence ?? 0.5, + project, + }; + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("POST", "/api/memories/remember", { + body, + signal, + }); + if (!res.ok) return null; + return jsonResult(res.data); + }, + async () => { + const args = [ + "memory", + "remember", + "-k", + String(params.kind), + "-t", + String(params.title), + "-b", + String(params.body), + "--json", + ]; + if (project) args.push("--project", project); + const { stdout } = await client.execCodemem(args, { signal }); + return jsonResult(parseCliJson(stdout) ?? { status: "ok" }); + }, + ); + }); + }, + }), + ); + + // ---- memory_forget ---- + register( + tool({ + name: "memory_forget", + label: "Memory Forget", + description: "Soft-delete a memory item. Use for incorrect or sensitive data.", + parameters: Type.Object({ + memory_id: Type.Integer({ description: "Memory ID to forget" }), + ...filterProps, + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_forget", async () => { + const memoryId = Number(params.memory_id); + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("POST", "/api/memories/forget", { + body: { memory_id: memoryId }, + signal, + }); + if (!res.ok) return null; + return jsonResult(res.data); + }, + async () => { + const { stdout } = await client.execCodemem( + ["memory", "forget", String(memoryId), "--json"], + { signal }, + ); + return jsonResult(parseCliJson(stdout) ?? { status: "ok" }); + }, + ); + }); + }, + }), + ); + + // ---- memory_learn ---- + register( + tool({ + name: "memory_learn", + label: "Memory Learn", + description: "Learn how to use codemem memory tools. Call this first if unfamiliar.", + parameters: Type.Object({}), + async execute() { + return jsonResult(MEMORY_LEARN_PAYLOAD); + }, + }), + ); + + // ---- memory_schema ---- + register( + tool({ + name: "memory_schema", + label: "Memory Schema", + description: "Return the memory schema — kinds, fields, and available filters.", + parameters: Type.Object({}), + async execute(_id, _rawParams, signal) { + return withToolError("memory_schema", async () => { + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("GET", "/api/memories/schema", { signal }); + if (!res.ok) return null; + return jsonResult(res.data); + }, + async () => { + // Static fallback matching MCP memory_schema when viewer is down. + return jsonResult({ + kinds: [ + "discovery", + "change", + "feature", + "bugfix", + "refactor", + "decision", + "exploration", + ], + kind_descriptions: { + discovery: "Something learned about the codebase, architecture, or tools", + change: "A code change that was made", + feature: "A new feature that was implemented", + bugfix: "A bug that was found and fixed", + refactor: "Code that was refactored or restructured", + decision: "A design or architecture decision", + exploration: "An experiment or investigation (may not have shipped)", + }, + fields: { + title: "short text", + body: "long text", + subtitle: "short text", + facts: "list", + narrative: "long text", + concepts: "list", + files_read: "list", + files_modified: "list", + prompt_number: "int", + }, + filters: ["kind", "project"], + note: "schema served from extension fallback (viewer unreachable)", + }); + }, + ); + }); + }, + }), + ); + + // ---- memory_timeline ---- + register( + tool({ + name: "memory_timeline", + label: "Memory Timeline", + description: "Get a chronological window of memories around an anchor (by ID or query).", + parameters: Type.Object({ + query: Type.Optional(Type.String({ description: "Search query to find anchor" })), + memory_id: Type.Optional(Type.Integer({ description: "Anchor memory ID" })), + depth_before: Type.Optional( + Type.Integer({ minimum: 0, default: 3, description: "Items before anchor" }), + ), + depth_after: Type.Optional( + Type.Integer({ minimum: 0, default: 3, description: "Items after anchor" }), + ), + ...filterProps, + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_timeline", async () => { + const project = projectOrClient(params as Record, client); + const kind = typeof params.kind === "string" ? params.kind : undefined; + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("GET", "/api/memories/timeline", { + query: { + query: typeof params.query === "string" ? params.query : undefined, + memory_id: typeof params.memory_id === "number" ? params.memory_id : undefined, + depth_before: typeof params.depth_before === "number" ? params.depth_before : 3, + depth_after: typeof params.depth_after === "number" ? params.depth_after : 3, + project, + kind, + }, + signal, + }); + if (!res.ok) return null; + return jsonResult(res.data); + }, + async () => { + return errorResult( + "memory_timeline requires the codemem viewer server (HTTP). Start it with `codemem serve start`.", + ); + }, + ); + }); + }, + }), + ); + + // ---- memory_expand ---- + register( + tool({ + name: "memory_expand", + label: "Memory Expand", + description: "Fetch memories by ID with surrounding timeline context.", + parameters: Type.Object({ + ids: Type.Array(Type.Union([Type.Integer(), Type.String()]), { + maxItems: 200, + description: "Memory IDs to expand", + }), + depth_before: Type.Optional( + Type.Integer({ minimum: 0, default: 3, description: "Timeline items before" }), + ), + depth_after: Type.Optional( + Type.Integer({ minimum: 0, default: 3, description: "Timeline items after" }), + ), + include_observations: Type.Optional( + Type.Boolean({ default: false, description: "Include full observation details" }), + ), + ...filterProps, + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_expand", async () => { + const body = { + ids: params.ids, + depth_before: params.depth_before ?? 3, + depth_after: params.depth_after ?? 3, + include_observations: params.include_observations ?? false, + project: projectOrClient(params as Record, client), + kind: typeof params.kind === "string" ? params.kind : undefined, + }; + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("POST", "/api/memories/expand", { + body, + signal, + }); + if (!res.ok) return null; + return jsonResult(res.data); + }, + async () => { + return errorResult( + "memory_expand requires the codemem viewer server (HTTP). Start it with `codemem serve start`.", + ); + }, + ); + }); + }, + }), + ); + + // ---- memory_distill_candidates ---- + register( + tool({ + name: "memory_distill_candidates", + label: "Memory Distill Candidates", + description: "Mine recurring memories into reviewable context candidates.", + parameters: Type.Object({ + limit: Type.Optional( + Type.Integer({ minimum: 1, maximum: 50, default: 10, description: "Max candidates" }), + ), + min_recurrence: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: 50, + default: 2, + description: "Minimum member count per candidate", + }), + ), + kind: Type.Optional(Type.String({ description: "Memory kind to mine" })), + project: Type.Optional(Type.String({ description: "Project identifier" })), + all_projects: Type.Optional( + Type.Boolean({ description: "Mine memories across all projects" }), + ), + include_documented: Type.Optional( + Type.Boolean({ + description: "Include candidates already represented in context files", + }), + ), + judge: Type.Optional( + Type.Boolean({ + description: "Run observer worthiness judgment (default true)", + }), + ), + }), + async execute(_id, rawParams, signal) { + const params = paramsOf(rawParams); + return withToolError("memory_distill_candidates", async () => { + const body = buildDistillBody( + params as Record, + client.project ?? undefined, + ); + return httpOrCli( + client, + signal, + async () => { + const res = await client.httpJson("POST", "/api/memories/distill_candidates", { + body, + signal, + timeoutMs: 60_000, + }); + if (!res.ok) return null; + return jsonResult(res.data); + }, + async () => { + const args = ["distill", "--json"]; + if (params.limit != null) args.push("-l", String(params.limit)); + if (params.min_recurrence != null) { + args.push("-m", String(params.min_recurrence)); + } + if (typeof params.kind === "string" && params.kind) { + args.push("-k", params.kind); + } + const project = body.project; + if (typeof project === "string" && project) args.push("-p", project); + if (params.all_projects) args.push("-A"); + if (params.include_documented) args.push("--include-documented"); + if (params.judge === false) args.push("--no-judge"); + const { stdout } = await client.execCodemem(args, { + signal, + timeoutMs: 60_000, + }); + return jsonResult(parseCliJson(stdout) ?? {}); + }, + ); + }); + }, + }), + ); + + return registered; +} + +/** No-op helper for tests asserting tool absence in adapter mode. */ +export function expectedToolNames(): string[] { + return [ + "memory_search", + "memory_search_index", + "memory_explain", + "memory_recent", + "memory_pack", + "memory_get", + "memory_get_observations", + "memory_remember", + "memory_forget", + "memory_learn", + "memory_schema", + "memory_timeline", + "memory_expand", + "memory_distill_candidates", + ]; +}