Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/pi-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
96 changes: 95 additions & 1 deletion packages/pi-extension/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
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";
import type { PiExtensionConfig } from "./config.js";
import { defaultPiExtensionConfig } from "./config.js";
import codememPiExtension, {
__setTestExecImpl,
expectedToolNames,
formatPiInjectionBlock,
stableMessageEntryId,
} from "./index.js";
Expand Down Expand Up @@ -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 = [
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
80 changes: 77 additions & 3 deletions packages/pi-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, unknown>).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;
Expand All @@ -192,6 +245,16 @@ function readPathFromToolInput(input: Record<string, unknown>): 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.
Expand All @@ -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();
Expand All @@ -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(() => {});

Expand Down Expand Up @@ -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}` }],
};
Expand Down Expand Up @@ -467,3 +540,4 @@ export {
formatPiInjectionBlock,
stableMessageEntryId,
} from "./payloads.js";
export { expectedToolNames, registerMemoryTools } from "./tools.js";
46 changes: 46 additions & 0 deletions packages/pi-extension/src/tools.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading