From 9d2ba547639a7611176403f81ae97c7aadee98b4 Mon Sep 17 00:00:00 2001 From: ZeR020 <88128532+ZeR020@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:03:25 +0000 Subject: [PATCH] feat: add pi setup, UI, docs, and 0.41.0 pin Idempotent `codemem setup` pi target. Official OpenAI/Anthropic base URLs are not persisted (keeps 0.41 Responses defaults). `/api/pi-hooks` is documented as a compat alias; HTTP pack is unledgered. --- .github/workflows/release.yml | 1 + README.md | 46 +- docs/architecture.md | 15 +- docs/plugin-reference.md | 40 ++ docs/trusted-publisher.md | 4 +- docs/user-guide.md | 1 + docs/versioning.md | 2 + packages/cli/src/commands/setup-config.ts | 17 +- packages/cli/src/commands/setup-pi.test.ts | 544 ++++++++++++++++++ packages/cli/src/commands/setup.ts | 496 +++++++++++++++- packages/pi-extension/README.md | 2 +- packages/pi-extension/package.json | 2 +- .../src/tabs/health/render/health-overview.ts | 5 +- .../components/ObserverPanel.test.tsx | 11 + .../settings/components/ObserverPanel.tsx | 13 +- .../src/tabs/settings/data/model-accessors.ts | 2 +- .../tabs/settings/data/value-helpers.test.ts | 14 +- .../src/tabs/settings/data/value-helpers.ts | 10 + scripts/release-version.mjs | 3 + scripts/release-version.test.mjs | 5 + 20 files changed, 1209 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/commands/setup-pi.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 841c09fa4..0110c7496 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -168,6 +168,7 @@ jobs: publish_if_missing "@codemem/server" "packages/viewer-server" publish_if_missing "codemem" "packages/cli" publish_if_missing "@codemem/opencode-plugin" "packages/opencode-plugin" + publish_if_missing "@codemem/pi-extension" "packages/pi-extension" - name: Verify latest dist-tag is absent or stable # npm assigns `latest` to a package's first-ever version regardless of diff --git a/README.md b/README.md index 5ad875015..45e93db6a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ codemem is persistent coding memory across sessions, machines, and teammates for - **Hybrid retrieval** — FTS5 BM25 lexical search + sqlite-vec semantic search, merged and re-ranked - **Automatic injection for OpenCode 1** — the plugin injects context into every prompt, no manual steps - **Claude Code plugin support** — install from the codemem marketplace source +- **Multi-agent** — OpenCode, Claude Code, Codex, and pi share one project-scoped store - **Built-in viewer** — browse memories, sessions, and observer output in a local web UI - **Remote MCP access** — advanced single-user self-hosting can expose an OAuth-protected Streamable HTTP MCP endpoint to configured remote clients; keep the localhost viewer private ([guide](docs/remote-mcp-oauth.md)) @@ -194,14 +195,53 @@ Codex hook ingestion shares the same raw-event pipeline as Claude and OpenCode t > Was this repository previously installed as `opencode-mem`? See the [rename migration guide](docs/rename-migration.md). It covers this repository's former name, not importing data from [`tickernelz/opencode-mem`](https://github.com/tickernelz/opencode-mem). -## How it works +### Pi + +Pi support ships as the `@codemem/pi-extension` pi-package. Install the CLI, then let setup wire the extension: + +```text +npm i -g codemem +codemem setup +``` + +`codemem setup` auto-detects pi (`pi` on PATH or the agent dir; honors `PI_CODING_AGENT_DIR`) and appends `npm:@codemem/pi-extension@` to `~/.pi/agent/settings.json` `packages`. Flags: + +| Flag | Purpose | +|------|---------| +| `--pi-only` | Only configure pi | +| `--pi-mcp` | Opt into MCP via third-party `pi-mcp-adapter` (writes `mcp.json` only when the adapter is detected) | +| `--pi-extension-path ` | Dev: write a local-path `packages` entry instead of the npm pin | + +Uninstall: remove the `@codemem/pi-extension` entry from pi's `packages` list and restart pi. The shared memory store is left intact. + +What you get: + +- **Ingest** — extension POSTs to `POST /api/pi-hooks` (a compatibility alias that normalizes the payload once and runs it through the canonical ingest envelope with `source: "pi"`, the same event identity as `POST /api/raw-events`), with `codemem pi-hook-ingest` CLI fallback (spool when offline) +- **Injection** — turn-local `systemPrompt` append on `before_agent_start` (`## codemem memories`); never the persistent `message` channel +- **Tools** — all 14 `memory_*` tools registered natively via `pi.registerTool` (HTTP preferred, CLI fallback). No `pi-mcp-adapter` required for tools +- **Compaction** — pi-only observe-only boundary: `session_before_compact` flushes extraction before pi discards context; never replaces pi's summarizer +- **Fork/resume** — stream identity re-keys on every `session_start` +- **Project identity** — the extension resolves the project from the nearest Git root (same walk as the other adapters) +- **Dashboard** — pi rows appear in the source-agnostic feed/sessions/projects tabs with no extra setup + +Cross-agent: one shared store. Memories from OpenCode/Claude/Codex sessions inject into pi (and the reverse) because packs are project-scoped, never agent-scoped. + +Caveats (v1): + +- Observer extraction from pi config supports **API-key providers only**. OAuth-only installs get an explicit `unconfigured (oauth-only)` status — never a silent 401. Set `observer_provider` / `observer_model` explicitly when needed. Selection is cheap-model-first. +- Preferred HTTP `GET /api/pack` is unledgered — pi injection does not write an opencode retrieval-ledger row. +- `--pi-mcp` requires the third-party `pi-mcp-adapter` package; without it setup writes nothing MCP-related and explains the prerequisite. Native tools remain the default surface (`pi.tools_mode: native`). + +See [`packages/pi-extension/README.md`](packages/pi-extension/README.md) and [docs/plugin-reference.md](docs/plugin-reference.md) for config knobs and lifecycle details. -Adapters hook into runtime event systems (the OpenCode 1 plugin and Claude hooks). They capture tool calls and conversation messages, flush them through an observer pipeline that produces typed memories, and surface retrieval context for future prompts. > The workflow below describes OpenCode 1 recall. OpenCode 2 captures activity, > manages lifecycle cleanup, and exposes manual memory tools, but it does not inject > automatic recall because its context hook cannot identify the request safely. +## How it works + +Adapters hook into runtime event systems (OpenCode plugin, Claude/Codex hooks, and the pi extension). They capture tool calls and conversation messages, flush them through an observer pipeline that produces typed memories, and surface retrieval context for future prompts. ```mermaid sequenceDiagram participant OC as OpenCode 1 @@ -292,7 +332,7 @@ For architecture details, see [docs/architecture.md](docs/architecture.md). | **Plumbing** | `codemem mcp` | MCP stdio server; best-effort starts the local viewer unless `CODEMEM_VIEWER=0` or `CODEMEM_VIEWER_AUTO=0` is set | | | `codemem mcp http` | Local Streamable HTTP MCP server (`POST /mcp`, loopback-only by default) | -Run `codemem --help` for the human-facing command list. Adapter plumbing commands (`claude-hook-*`, `codex-hook-*`, `enqueue-raw-event`, and `prompt-pack-ledger`) remain executable for packaged-plugin and stale-client compatibility but are hidden from help and shell completion. `show`, `forget`, and `remember` still work as hidden top-level aliases. `export-memories` and `import-memories` remain visible but are deprecated — they warn on stderr and will be hidden from help and completion in a future release; use `codemem memory export` / `codemem memory import`. +Run `codemem --help` for the human-facing command list. Adapter plumbing commands (`claude-hook-*`, `codex-hook-*`, `pi-hook-*`, `enqueue-raw-event`, and `prompt-pack-ledger`) remain executable for packaged-plugin and stale-client compatibility but are hidden from help and shell completion. `show`, `forget`, and `remember` still work as hidden top-level aliases. `export-memories` and `import-memories` remain visible but are deprecated — they warn on stderr and will be hidden from help and completion in a future release; use `codemem memory export` / `codemem memory import`. Use `codemem status` to answer whether the local database, viewer, sync, maintenance, semantic index, raw-event ingestion, and observer need attention. It is observational: diff --git a/docs/architecture.md b/docs/architecture.md index 84e1c22e7..6bbca6c07 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ codemem has five main pieces: **adapters** that capture shell/runtime activity, | Component | What it does | Key files | |-----------|-------------|-----------| -| Adapters | Capture and normalize agent events before enqueueing raw events | `packages/opencode-plugin/.opencode/plugins/codemem.js`, `packages/opencode-plugin/.opencode/lib/runtime.js`, `plugins/claude/scripts/ingest-hook.mjs`, `plugins/codex/scripts/ingest-hook.mjs`, `packages/core/src/claude-hooks.ts`, `packages/core/src/codex-hooks.ts` | +| Adapters | Capture and normalize agent events before enqueueing raw events | `packages/opencode-plugin/.opencode/plugins/codemem.js`, `packages/opencode-plugin/.opencode/lib/runtime.js`, `plugins/claude/scripts/ingest-hook.mjs`, `plugins/codex/scripts/ingest-hook.mjs`, `packages/core/src/claude-hooks.ts`, `packages/core/src/codex-hooks.ts`, `packages/core/src/pi-hooks.ts`, `packages/pi-extension/` | | Ingest pipeline | Extracts tool events, builds transcripts, runs the observer | `packages/core/src/ingest-pipeline.ts`, `packages/core/src/ingest-events.ts` | | Observer | Produces typed observations and session summaries from transcripts | `packages/core/src/observer-output.ts`, `packages/core/src/observer-output-schema.ts`, `packages/core/src/ingest-xml-parser.ts` | | Store | SQLite persistence for sessions, memories, artifacts, embeddings | `packages/core/src/store.ts`, `packages/core/src/schema.ts` | @@ -29,6 +29,8 @@ flowchart LR CH -->|same envelope: enqueue-raw-event| DB CX["Codex hooks"] -->|normalize once; POST /api/raw-events| VW CX -->|same envelope: enqueue-raw-event/spool| DB + PI["pi extension"] -->|POST /api/pi-hooks (alias)| VW + PI -->|fallback: pi-hook-ingest direct enqueue/spool| DB VW --> DB["SQLite"] DB -->|flush batch claimed| IN["Ingest pipeline"] IN --> OB["Observer"] @@ -40,7 +42,7 @@ flowchart LR 1. Adapters capture tool/conversation lifecycle events and normalize them into raw events with optional `_adapter` envelopes. 2. OpenCode streams raw events to the viewer ingest API (`POST /api/raw-events`) with preflight checks (`GET /api/raw-events/status`) and can fall back to CLI queue enqueue when stream writes fail. Prompt-time packs and prompt-pack ledger transitions also use viewer POST APIs first, with CLI fallback only for retryable transport or version failures. -3. Claude and Codex use checked-in, dependency-free normalizers generated from their core TypeScript implementations. Each detached event wrapper normalizes once, posts the exact envelope to `POST /api/raw-events`, and reuses that serialization for `enqueue-raw-event` or durable spool fallback. The canonical endpoint accepts additive adapter metadata. Named hook routes remain compatibility aliases used by older packaged or plugin-free CLI paths; the current packaged-wrapper audit found no named-route strings, so the aliases are not primary but cannot be removed yet. +3. Claude and Codex use checked-in, dependency-free normalizers generated from their core TypeScript implementations. Each detached event wrapper normalizes once, posts the exact envelope to `POST /api/raw-events`, and reuses that serialization for `enqueue-raw-event` or durable spool fallback. The canonical endpoint accepts additive adapter metadata. Named hook routes remain compatibility aliases used by older packaged or plugin-free CLI paths; the current packaged-wrapper audit found no named-route strings, so the aliases are not primary but cannot be removed yet. The pi extension posts pi lifecycle payloads to `POST /api/pi-hooks`, a compatibility alias that maps the payload through `buildRawEventEnvelopeFromPiEvent` and runs `ingestNormalizedEnvelope` with `source: "pi"` — the same event identity as `POST /api/raw-events` with `source: "pi"` — with `codemem pi-hook-ingest` (plus spool) as the fallback. 4. The viewer/store persists raw events and queues durable flush batches. 5. Idle and sweeper workers claim batches and run them through ingest. 6. Before building session context, raw events are passed through `normalizeEventsForSessionContext` (in `ingest-transcript.ts`) which projects adapter-enveloped events (`_adapter` schema v1.0) into the flat `user_prompt` / `tool.execute.after` shapes that `buildSessionContext` scans. This is critical for Claude Code hook events which always arrive wrapped in the adapter envelope. @@ -62,6 +64,7 @@ Support tiers describe operational expectations for each adapter path: | OpenCode 1 plugin | Supported | Primary reference adapter for lifecycle events and injection behavior. | | OpenCode 2 plugin | Experimental | The beta entrypoint captures conversation, tool, terminal usage, and lifecycle activity with bounded cleanup. It exposes manual `mem-status`, `mem-recent`, and `mem-stats` tools through `tool.transform` with `codemode: false`; automatic recall remains disabled because the context hook has no request kind or request ID. | | Claude hooks/plugin | Supported | Hook-first queue path with CLI/runtime fallback and parity slices tracked in adapter stack PRs. | +| pi extension | Supported | Thin pi-package (`packages/pi-extension`, `packages/core/src/pi-hooks.ts`): extension → `POST /api/pi-hooks` alias → canonical ingest envelope (`source: "pi"`) → observer → memories; turn-local `systemPrompt` injection; 14 native `memory_*` tools; observe-only compaction boundary; fork/resume-aware streams; Git-root project identity. Observer derivation from pi config is API-key-only in v1 (OAuth → explicit `unconfigured (oauth-only)`). | | Codex plugin (hooks + MCP) | Supported | Functional capture pipeline (`plugins/codex/`, `packages/core/src/codex-hooks.ts`) dogfooded end-to-end: edge normalization → `POST /api/raw-events` → observer → memories. Prompt-time injection is present and env-gated but not fully validated on strict models. | | Windsurf integration | Experimental | Planned via shared adapter contract after OpenCode/Claude stabilization. | | Cursor integration | Experimental | Planned via shared adapter contract after OpenCode/Claude stabilization. | @@ -371,6 +374,14 @@ delivery reuses the exact envelope for CLI enqueue fallback. The named `POST /ap remains a compatibility alias/caller for older packaged or plugin-free CLI paths. The queue/sweeper behavior and `CODEMEM_CLAUDE_HOOK_FLUSH_ON_STOP=1` opt-in for `Stop` remain unchanged. +Pi extension ingest posts pi lifecycle payloads to the `POST /api/pi-hooks` compatibility alias, which +normalizes them through `buildRawEventEnvelopeFromPiEvent` and runs `ingestNormalizedEnvelope` with +`source: "pi"` (same event identity as canonical `POST /api/raw-events`); retryable delivery reuses the +payload for `codemem pi-hook-ingest` plus a pi-specific spool. Boundary flush events +(`session_before_compact`, `session_shutdown`) always go through the CLI so extraction actually runs +before pi discards context. Preferred HTTP `GET /api/pack` is unledgered (no opencode +retrieval-ledger row). The queue/sweeper behavior is shared with the other adapters. + ### OpenCode session finalization triggers - `session.idle` — finalizes current local buffer - `session.created` — finalizes before switching to a new session diff --git a/docs/plugin-reference.md b/docs/plugin-reference.md index 106d4a51e..fbe06b7ef 100644 --- a/docs/plugin-reference.md +++ b/docs/plugin-reference.md @@ -214,6 +214,46 @@ Hooks loaded from the user config layer require a one-time trust approval in Cod - **Normalized spool backlog drains automatically** at one envelope per successful ingest. The wrapper never reads or removes files from the legacy native-hook spool. - **A model rejects injected context** (for example "the conversation must end with a user message"): disable prompt-time injection with `CODEMEM_INJECT_CONTEXT=0`. Capture/ingest keeps working and recall is still available through the MCP tools. +## Pi extension + +Pi support is the `@codemem/pi-extension` pi-package. Install once, then restart pi: + +```text +npm i -g codemem +codemem setup --pi-only +``` + +Setup appends `npm:@codemem/pi-extension@` to `~/.pi/agent/settings.json` `packages` (JSONC-safe, idempotent). It also derives unset `observer_*` keys from pi's API-key providers (cheap-model-first) without copying secrets. Flags: + +- `--pi-mcp` — opt into MCP via third-party `pi-mcp-adapter` (writes `mcp.json` only when the adapter is present; flips `pi.tools_mode` to `mcp-adapter`) +- `--pi-extension-path ` — dev local-path `packages` entry + +Uninstall by removing the packages entry and restarting pi. + +### Surfaces + +| Surface | Behavior | +|---|---| +| Ingest | Extension POSTs to `POST /api/pi-hooks`, a compatibility alias that normalizes the payload once into the canonical ingest envelope with `source: "pi"` — the same event identity as `POST /api/raw-events` with `source: "pi"`. Falls back to `codemem pi-hook-ingest` + spool when HTTP is unavailable. Boundary events (`session_before_compact`, `session_shutdown`) always flush via the CLI so extraction actually runs | +| Injection | `before_agent_start` appends a turn-local `systemPrompt` block (`## codemem memories`); never returns `message` | +| Tools | 14 native `memory_*` tools via `pi.registerTool` (HTTP preferred, CLI fallback). Default `pi.tools_mode: native` — no adapter required | +| Compaction | Observe-only: `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` | +| Project identity | Nearest Git root (walks up for a directory `.git` or a `gitdir:` worktree file), same walk as the other adapters | + +Prompt-time pack retrieval uses the preferred HTTP `GET /api/pack` (or `codemem pi-hook-inject` / `pack --json` fallback). That HTTP pack path is unledgered — no opencode retrieval-ledger row is written for pi injection. + +Dashboard tabs are source-agnostic: pi rows appear alongside OpenCode/Claude/Codex with no extra setup. Packs are project-scoped, so memory crosses agents automatically. + +### Observer derivation caveats (v1) + +- API-key providers only (`openai-completions` / `openai-responses` / `anthropic-messages`). OAuth-only installs surface `unconfigured (oauth-only)` — never silent 401s. +- Explicit `observer_*` config/env always wins over pi-derived values. +- Setup never copies pi `auth.json` keys into the codemem config. +- `--pi-mcp` requires `pi-mcp-adapter`; without it setup explains the prerequisite and writes nothing MCP-related. + +See [`packages/pi-extension/README.md`](../packages/pi-extension/README.md) for env knobs and lifecycle rules. + ## Post-restart config sanity checklist After restarting OpenCode or the viewer, run this quick check when behavior looks off: diff --git a/docs/trusted-publisher.md b/docs/trusted-publisher.md index 79373f5a5..9b7df1210 100644 --- a/docs/trusted-publisher.md +++ b/docs/trusted-publisher.md @@ -19,6 +19,7 @@ Trusted publishing must be configured for every package the workflow publishes: - `@codemem/server` - `codemem` - `@codemem/opencode-plugin` +- `@codemem/pi-extension` Before the first tagged release that includes a new npm package, publish a distinct bootstrap prerelease such as `0.0.0-alpha.0` with authenticated @@ -26,7 +27,6 @@ maintainer credentials and a non-latest dist-tag such as `bootstrap`. Do not use the intended release version for this bootstrap. Then configure the trusted publisher above; npm requires the package to exist first. Do not use a release tag until this setup is complete. - ## GitHub workflow behavior `.github/workflows/release.yml` publishes from two triggers: @@ -45,7 +45,7 @@ dependency order: 4. `@codemem/server` 5. `codemem` 6. `@codemem/opencode-plugin` - +7. `@codemem/pi-extension` Publish command shape: - `pnpm --filter publish --provenance --access public --tag ` diff --git a/docs/user-guide.md b/docs/user-guide.md index 9c2ffd2b6..1071b9f87 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -144,6 +144,7 @@ codemem status --db-path ./codemem.sqlite --config ./codemem.json ## Observer auth configuration - Runtime choices are `api_http`, `claude_sidecar`, and `codex_sidecar`. +- Pi users stay on `api_http`. `codemem setup` can derive unset `observer_provider` / `observer_model` from pi API-key providers (cheap-model-first) without copying secrets. OAuth-only pi installs stay explicitly `unconfigured (oauth-only)` in v1 — set observer settings manually. - `claude_sidecar` runs observer calls through the local Claude runtime (subscription/session auth) and does not require `ANTHROPIC_API_KEY`. - `claude_command` controls how `claude_sidecar` invokes Claude CLI (default `["claude"]`). - Wrapper example: `"claude_command": ["wrapper", "claude", "--"]` diff --git a/docs/versioning.md b/docs/versioning.md index 9acc87db2..1f54efd37 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -6,6 +6,7 @@ CodeMem uses one shared semantic version stream across its npm packages. - npm: `codemem` (CLI) - npm: `@codemem/opencode-plugin` (OpenCode plugin) +- npm: `@codemem/pi-extension` (pi coding-agent extension) ## Policy @@ -21,6 +22,7 @@ Version bumps are prepared on a release branch and touch these files: - `packages/embeddings/package.json` (`version`) - `packages/cli/package.json` (`version`) - `packages/opencode-plugin/package.json` (`version`) +- `packages/pi-extension/package.json` (`version`) - `packages/mcp-server/package.json` (`version`) - `packages/viewer-server/package.json` (`version`) - `packages/core/src/index.ts` (`VERSION` export) diff --git a/packages/cli/src/commands/setup-config.ts b/packages/cli/src/commands/setup-config.ts index 8bff86810..fcaf28429 100644 --- a/packages/cli/src/commands/setup-config.ts +++ b/packages/cli/src/commands/setup-config.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { stripJsonComments, stripTrailingCommas } from "@codemem/core"; @@ -25,3 +25,18 @@ export function writeJsonConfig(path: string, data: Record): vo mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8"); } + +/** + * Write JSON config after copying any existing file to `.codemem.bak`. + * Backup failure is non-fatal (write still proceeds). + */ +export function writeJsonConfigWithBackup(path: string, data: Record): void { + if (existsSync(path)) { + try { + copyFileSync(path, `${path}.codemem.bak`); + } catch { + // Non-fatal: continue without a backup rather than blocking install. + } + } + writeJsonConfig(path, data); +} diff --git a/packages/cli/src/commands/setup-pi.test.ts b/packages/cli/src/commands/setup-pi.test.ts new file mode 100644 index 000000000..60ddfc0eb --- /dev/null +++ b/packages/cli/src/commands/setup-pi.test.ts @@ -0,0 +1,544 @@ +/** + * Tests for codemem setup — pi target (packages entry, observer derivation, --pi-mcp). + * + * Covers: fresh install, idempotent second run, relocated home (PI_CODING_AGENT_DIR), + * adapter present/absent, and no-secret-persistence into codemem config. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import { VERSION } from "@codemem/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildPiExtensionPackageSpec, + installPi, + isPiDetected, + isPiExtensionPackageEntry, + isPiMcpAdapterDetected, + piConfigDir, + setupCommand, +} from "./setup.js"; + +const FIXTURE_KEY = "sk-fixture-pi-setup-test-key-DO-NOT-LEAK"; + +const savedEnv: Record = {}; +const ENV_KEYS = [ + "PI_CODING_AGENT_DIR", + "CODEMEM_CONFIG", + "CODEMEM_OBSERVER_PROVIDER", + "CODEMEM_OBSERVER_MODEL", + "CODEMEM_OBSERVER_BASE_URL", + "CODEMEM_OBSERVER_OPENAI_USE_RESPONSES", + "HOME", + "PATH", +] as const; + +let piHome: string; +let configPath: string; +let tempRoot: string; + +function saveEnv(): void { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + } +} + +function restoreEnv(): void { + for (const key of ENV_KEYS) { + const prev = savedEnv[key]; + if (prev === undefined) delete process.env[key]; + else process.env[key] = prev; + } +} + +function writeJson(path: string, data: unknown): void { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8"); +} + +function readJson(path: string): Record { + return JSON.parse(readFileSync(path, "utf-8")) as Record; +} + +function seedPiApiKeyInstall(dir: string): void { + writeJson(join(dir, "settings.json"), { + defaultProvider: "openai", + defaultModel: "openai/gpt-4o", + enabledModels: ["openai/gpt-4o-mini", "openai/gpt-4o"], + packages: [], + }); + writeJson(join(dir, "models.json"), { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-completions", + models: [ + { id: "gpt-4o-mini", cost: { input: 0.15, output: 0.6 } }, + { id: "gpt-4o", cost: { input: 2.5, output: 10 } }, + ], + }, + }, + }); + // Auth is read in-memory only by resolvePiObserverConfig — setup must never copy the key. + writeJson(join(dir, "auth.json"), { + openai: { type: "api_key", key: FIXTURE_KEY }, + }); +} + +beforeEach(() => { + saveEnv(); + tempRoot = mkdtempSync(join(tmpdir(), "codemem-setup-pi-")); + piHome = join(tempRoot, "pi-agent"); + configPath = join(tempRoot, "codemem-config.json"); + mkdirSync(piHome, { recursive: true }); + process.env.PI_CODING_AGENT_DIR = piHome; + process.env.CODEMEM_CONFIG = configPath; + delete process.env.CODEMEM_OBSERVER_PROVIDER; + delete process.env.CODEMEM_OBSERVER_MODEL; + delete process.env.CODEMEM_OBSERVER_BASE_URL; + delete process.env.CODEMEM_OBSERVER_OPENAI_USE_RESPONSES; +}); + +afterEach(() => { + vi.restoreAllMocks(); + restoreEnv(); + rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe("piConfigDir / isPiDetected", () => { + it("honors PI_CODING_AGENT_DIR", () => { + expect(piConfigDir()).toBe(piHome); + }); + + it("does not treat a bare empty agent dir as detected", () => { + // beforeEach creates an empty piHome with no settings.json/auth.json. + // Isolate PATH so a host `pi` binary cannot mask the empty-dir case. + const savedPath = process.env.PATH; + process.env.PATH = ""; + try { + expect(existsSync(piHome)).toBe(true); + expect(isPiDetected()).toBe(false); + } finally { + process.env.PATH = savedPath; + } + }); + + it("detects pi when the agent dir has a non-empty settings.json marker", () => { + const savedPath = process.env.PATH; + process.env.PATH = ""; + try { + writeJson(join(piHome, "settings.json"), { packages: [] }); + expect(isPiDetected()).toBe(true); + } finally { + process.env.PATH = savedPath; + } + }); + + it("detects pi when the agent dir has a non-empty auth.json marker", () => { + const savedPath = process.env.PATH; + process.env.PATH = ""; + try { + writeJson(join(piHome, "auth.json"), { openai: { type: "api_key", key: "x" } }); + expect(isPiDetected()).toBe(true); + } finally { + process.env.PATH = savedPath; + } + }); + + it("ignores empty marker files (falls through to PATH only)", () => { + const savedPath = process.env.PATH; + process.env.PATH = ""; + try { + writeFileSync(join(piHome, "settings.json"), " \n", "utf-8"); + writeFileSync(join(piHome, "auth.json"), "", "utf-8"); + expect(isPiDetected()).toBe(false); + } finally { + process.env.PATH = savedPath; + } + }); + + it("does not treat a missing agent dir as detected (unless pi is on PATH)", () => { + const missing = join(tempRoot, "no-such-pi"); + process.env.PI_CODING_AGENT_DIR = missing; + // May still be true if the real `pi` binary is on PATH in this environment. + // Assert the dir itself is missing; detection then reduces to PATH probe. + expect(existsSync(missing)).toBe(false); + const detected = isPiDetected(); + expect(typeof detected).toBe("boolean"); + }); +}); + +describe("buildPiExtensionPackageSpec / isPiExtensionPackageEntry", () => { + it("pins the npm package to the current codemem VERSION", () => { + expect(buildPiExtensionPackageSpec()).toBe(`npm:@codemem/pi-extension@${VERSION}`); + }); + + it("uses an absolute local path for --pi-extension-path", () => { + const local = join(tempRoot, "packages", "pi-extension"); + expect(buildPiExtensionPackageSpec(local)).toBe(local); + }); + + it("recognizes npm and local-path package entries", () => { + expect(isPiExtensionPackageEntry(`npm:@codemem/pi-extension@${VERSION}`)).toBe(true); + expect(isPiExtensionPackageEntry("npm:@codemem/pi-extension@0.1.0")).toBe(true); + expect(isPiExtensionPackageEntry("/repo/packages/pi-extension")).toBe(true); + expect(isPiExtensionPackageEntry("npm:pi-mcp-adapter@2.0.0")).toBe(false); + expect(isPiExtensionPackageEntry("npm:@other/pkg")).toBe(false); + }); +}); + +describe("installPi — fresh install", () => { + it("appends the packages entry, sets pi.tools_mode native, and derives observer_* (no secrets)", () => { + seedPiApiKeyInstall(piHome); + + expect(installPi({ force: false })).toBe(true); + + const settings = readJson(join(piHome, "settings.json")); + const packages = settings.packages as string[]; + expect(packages).toEqual([`npm:@codemem/pi-extension@${VERSION}`]); + + // Default run writes no MCP config. + expect(existsSync(join(piHome, "mcp.json"))).toBe(false); + + const config = readJson(configPath); + expect(config.observer_provider).toBe("openai"); + expect(typeof config.observer_model).toBe("string"); + expect((config.observer_model as string).length).toBeGreaterThan(0); + // Official OpenAI URL must not be persisted — it would be treated as a custom gateway. + expect(config).not.toHaveProperty("observer_base_url"); + expect(config).not.toHaveProperty("observer_openai_use_responses"); + expect(config).not.toHaveProperty("observer_api_key"); + expect(JSON.stringify(config)).not.toContain(FIXTURE_KEY); + expect(config.pi).toEqual({ tools_mode: "native" }); + + // Backup created for pre-existing settings.json. + expect(existsSync(join(piHome, "settings.json.codemem.bak"))).toBe(true); + }); + + it("supports a local-path packages entry via piExtensionPath", () => { + writeJson(join(piHome, "settings.json"), { packages: [] }); + const local = join(tempRoot, "local-pi-extension"); + mkdirSync(local, { recursive: true }); + + expect(installPi({ force: false, piExtensionPath: local })).toBe(true); + + const settings = readJson(join(piHome, "settings.json")); + expect(settings.packages).toEqual([local]); + }); +}); + +describe("installPi — idempotency", () => { + it("does not duplicate the packages entry on a second run", () => { + seedPiApiKeyInstall(piHome); + + expect(installPi({ force: false })).toBe(true); + expect(installPi({ force: false })).toBe(true); + + const settings = readJson(join(piHome, "settings.json")); + const packages = settings.packages as string[]; + const ours = packages.filter((e) => isPiExtensionPackageEntry(e)); + expect(ours).toHaveLength(1); + expect(ours[0]).toBe(`npm:@codemem/pi-extension@${VERSION}`); + }); + + it("upgrades a stale npm version pin to the current VERSION without --force", () => { + writeJson(join(piHome, "settings.json"), { + packages: ["npm:@codemem/pi-extension@0.0.1", "npm:other"], + }); + + expect(installPi({ force: false })).toBe(true); + + const settings = readJson(join(piHome, "settings.json")); + expect(settings.packages).toEqual(["npm:other", `npm:@codemem/pi-extension@${VERSION}`]); + }); + + it("leaves an equal-version npm pin untouched (order-preserving no-op)", () => { + const pin = `npm:@codemem/pi-extension@${VERSION}`; + writeJson(join(piHome, "settings.json"), { + packages: [pin, "npm:other"], + }); + + expect(installPi({ force: false })).toBe(true); + + const settings = readJson(join(piHome, "settings.json")); + // Order preserved ⇒ no rewrite of settings.packages. + expect(settings.packages).toEqual([pin, "npm:other"]); + }); + + it("leaves a local-path packages entry untouched without --force", () => { + const local = join(tempRoot, "packages", "pi-extension"); + writeJson(join(piHome, "settings.json"), { + packages: [local, "npm:other"], + }); + + expect(installPi({ force: false })).toBe(true); + + const settings = readJson(join(piHome, "settings.json")); + expect(settings.packages).toEqual([local, "npm:other"]); + }); + + it("replaces a prior entry when --force is set", () => { + writeJson(join(piHome, "settings.json"), { + packages: ["npm:@codemem/pi-extension@0.0.1", "npm:other"], + }); + + expect(installPi({ force: true })).toBe(true); + + const settings = readJson(join(piHome, "settings.json")); + expect(settings.packages).toEqual(["npm:other", `npm:@codemem/pi-extension@${VERSION}`]); + }); +}); + +describe("installPi — relocated home", () => { + it("writes configuration to PI_CODING_AGENT_DIR rather than ~/.pi/agent", () => { + const relocated = join(tempRoot, "relocated-pi"); + mkdirSync(relocated, { recursive: true }); + process.env.PI_CODING_AGENT_DIR = relocated; + seedPiApiKeyInstall(relocated); + + expect(installPi({ force: false })).toBe(true); + + expect(existsSync(join(relocated, "settings.json"))).toBe(true); + const settings = readJson(join(relocated, "settings.json")); + expect(settings.packages).toContain(`npm:@codemem/pi-extension@${VERSION}`); + // Original piHome must not be touched. + expect(existsSync(join(piHome, "settings.json"))).toBe(false); + }); +}); + +describe("installPi — observer derivation", () => { + it("does not overwrite existing codemem observer_* keys", () => { + seedPiApiKeyInstall(piHome); + writeJson(configPath, { + observer_provider: "anthropic", + observer_model: "claude-haiku-4-5", + observer_base_url: "https://api.anthropic.com", + observer_openai_use_responses: true, + }); + + expect(installPi({ force: false })).toBe(true); + + const config = readJson(configPath); + expect(config.observer_provider).toBe("anthropic"); + expect(config.observer_model).toBe("claude-haiku-4-5"); + expect(config.observer_base_url).toBe("https://api.anthropic.com"); + // Pre-set wire flag must not be flipped by pi derivation. + expect(config.observer_openai_use_responses).toBe(true); + expect(config.pi).toEqual({ tools_mode: "native" }); + }); + + it("does not persist official OpenAI Responses defaults as custom-gateway flags", () => { + writeJson(join(piHome, "settings.json"), { + defaultProvider: "openai", + defaultModel: "openai/gpt-4o", + enabledModels: ["openai/gpt-4o"], + packages: [], + }); + writeJson(join(piHome, "models.json"), { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + models: [{ id: "gpt-4o", cost: { input: 2.5, output: 10 } }], + }, + }, + }); + writeJson(join(piHome, "auth.json"), { + openai: { type: "api_key", key: FIXTURE_KEY }, + }); + + expect(installPi({ force: false })).toBe(true); + + const config = readJson(configPath); + expect(config.observer_provider).toBe("openai"); + expect(config).not.toHaveProperty("observer_base_url"); + expect(config).not.toHaveProperty("observer_openai_use_responses"); + expect(JSON.stringify(config)).not.toContain(FIXTURE_KEY); + }); + + it("persists a custom gateway base URL and use_responses flag", () => { + writeJson(join(piHome, "settings.json"), { + defaultProvider: "openai", + defaultModel: "openai/gpt-4o-mini", + enabledModels: ["openai/gpt-4o-mini"], + packages: [], + }); + writeJson(join(piHome, "models.json"), { + providers: { + openai: { + baseUrl: "https://gateway.example.test/v1", + api: "openai-completions", + models: [{ id: "gpt-4o-mini", cost: { input: 0.15, output: 0.6 } }], + }, + }, + }); + writeJson(join(piHome, "auth.json"), { + openai: { type: "api_key", key: FIXTURE_KEY }, + }); + + expect(installPi({ force: false })).toBe(true); + + const config = readJson(configPath); + expect(config.observer_base_url).toBe("https://gateway.example.test/v1"); + expect(config.observer_openai_use_responses).toBe(false); + expect(JSON.stringify(config)).not.toContain(FIXTURE_KEY); + }); + + it("does not write official use_responses when provider/model already exist", () => { + seedPiApiKeyInstall(piHome); + writeJson(configPath, { + observer_provider: "openai", + observer_model: "gpt-4o-mini", + }); + + expect(installPi({ force: false })).toBe(true); + + const config = readJson(configPath); + expect(config.observer_provider).toBe("openai"); + expect(config.observer_model).toBe("gpt-4o-mini"); + expect(config).not.toHaveProperty("observer_openai_use_responses"); + }); + + it("never persists pi auth secrets into the codemem config file", () => { + seedPiApiKeyInstall(piHome); + // Pre-seed a red-herring secret in auth that must not leak. + writeJson(join(piHome, "auth.json"), { + openai: { type: "api_key", key: FIXTURE_KEY }, + other: { type: "api_key", key: "sk-other-secret-value-zzzz" }, + }); + + expect(installPi({ force: false })).toBe(true); + + const raw = readFileSync(configPath, "utf-8"); + expect(raw).not.toContain(FIXTURE_KEY); + expect(raw).not.toContain("sk-other-secret-value-zzzz"); + expect(raw).not.toMatch(/sk-/); + const config = readJson(configPath); + expect(config).not.toHaveProperty("observer_api_key"); + }); + + it("preserves a pre-existing user observer_api_key without adding pi's key", () => { + seedPiApiKeyInstall(piHome); + const userKey = "sk-user-already-in-codemem-config"; + writeJson(configPath, { + observer_provider: "openai", + observer_model: "gpt-4o-mini", + observer_api_key: userKey, + }); + + expect(installPi({ force: false })).toBe(true); + + const config = readJson(configPath); + expect(config.observer_api_key).toBe(userKey); + expect(JSON.stringify(config)).not.toContain(FIXTURE_KEY); + }); +}); + +describe("installPi — --pi-mcp opt-in", () => { + it("default run writes no mcp.json even when the adapter is installed", () => { + writeJson(join(piHome, "settings.json"), { + packages: ["npm:pi-mcp-adapter@2.19.0"], + }); + + expect(installPi({ force: false, piMcp: false })).toBe(true); + expect(existsSync(join(piHome, "mcp.json"))).toBe(false); + const config = readJson(configPath); + expect(config.pi).toEqual({ tools_mode: "native" }); + }); + + it("with --pi-mcp and adapter present: writes mcp.json and sets tools_mode mcp-adapter", () => { + writeJson(join(piHome, "settings.json"), { + packages: ["npm:pi-mcp-adapter@2.19.0"], + }); + + expect(installPi({ force: false, piMcp: true })).toBe(true); + + const mcp = readJson(join(piHome, "mcp.json")); + expect(mcp).toEqual({ + mcpServers: { + codemem: { + command: "npx", + args: ["-y", "codemem", "mcp"], + }, + }, + }); + const config = readJson(configPath); + expect(config.pi).toEqual({ tools_mode: "mcp-adapter" }); + }); + + it("with --pi-mcp and adapter absent: writes nothing MCP-related, stays native, and explains the prerequisite", () => { + writeJson(join(piHome, "settings.json"), { packages: [] }); + const warns: string[] = []; + const spy = vi.spyOn(p.log, "warn").mockImplementation((msg: string) => { + warns.push(String(msg)); + }); + + expect(installPi({ force: false, piMcp: true })).toBe(true); + + spy.mockRestore(); + expect(existsSync(join(piHome, "mcp.json"))).toBe(false); + const config = readJson(configPath); + expect(config.pi).toEqual({ tools_mode: "native" }); + expect(warns.join("\n")).toMatch(/pi-mcp-adapter/i); + }); + + it("detects the adapter via an extensions/ directory name", () => { + writeJson(join(piHome, "settings.json"), { packages: [] }); + mkdirSync(join(piHome, "extensions", "pi-mcp-adapter"), { recursive: true }); + + expect(isPiMcpAdapterDetected(piHome)).toBe(true); + + expect(installPi({ force: false, piMcp: true })).toBe(true); + expect(existsSync(join(piHome, "mcp.json"))).toBe(true); + }); + + it("does not duplicate the mcp.json codemem entry on re-run", () => { + writeJson(join(piHome, "settings.json"), { + packages: ["npm:pi-mcp-adapter@2.0.0"], + }); + writeJson(join(piHome, "mcp.json"), { + mcpServers: { + other: { command: "echo" }, + codemem: { command: "npx", args: ["-y", "codemem", "mcp"] }, + }, + }); + // Start from native so the re-run must flip tools_mode even when mcp entry exists. + writeJson(configPath, { pi: { tools_mode: "native" } }); + + expect(installPi({ force: false, piMcp: true })).toBe(true); + + const mcp = readJson(join(piHome, "mcp.json")); + const servers = mcp.mcpServers as Record; + expect(Object.keys(servers).sort()).toEqual(["codemem", "other"]); + expect(servers.other).toEqual({ command: "echo" }); + // Adapter present on --pi-mcp re-run must flip tools_mode even when the + // mcp entry was already written (no-duplicate path). + const config = readJson(configPath); + expect(config.pi).toEqual({ tools_mode: "mcp-adapter" }); + }); +}); + +describe("installPi — parse failure abort", () => { + it("returns false and does not clobber an unparseable settings.json", () => { + const broken = "{ this is not valid json "; + writeFileSync(join(piHome, "settings.json"), broken, "utf-8"); + + expect(installPi({ force: false })).toBe(false); + expect(readFileSync(join(piHome, "settings.json"), "utf-8")).toBe(broken); + expect(existsSync(join(piHome, "settings.json.codemem.bak"))).toBe(false); + // Abort before observer/MCP writes. + expect(existsSync(configPath)).toBe(false); + expect(existsSync(join(piHome, "mcp.json"))).toBe(false); + }); +}); + +describe("setup command options", () => { + it("declares --pi-only, --pi-mcp, and --pi-extension-path", () => { + const longs = setupCommand.options.map((o) => o.long); + expect(longs).toContain("--pi-only"); + expect(longs).toContain("--pi-mcp"); + expect(longs).toContain("--pi-extension-path"); + }); +}); diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 854921441..a9377336c 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -7,19 +7,41 @@ * 1. Adds "@codemem/opencode-plugin" to the plugin array in ~/.config/opencode/opencode.jsonc * 2. Adds/updates the MCP entry in ~/.config/opencode/opencode.jsonc * 3. For Claude Code: installs MCP config and guides marketplace plugin install + * 4. For Codex: MCP + hooks via CODEX_HOME + * 5. For pi: packages entry + observer derivation + optional MCP adapter surface * * Designed to be safe to run repeatedly (idempotent unless --force). */ import { execFileSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { isAbsolute, join, resolve } from "node:path"; import * as p from "@clack/prompts"; -import { VERSION } from "@codemem/core"; +import { + describePiObserverStatus, + readCodememConfigFile, + resolvePiAgentDir, + resolvePiObserverConfig, + VERSION, + writeCodememConfigFile, +} from "@codemem/core"; import { Command } from "commander"; import { helpStyle } from "../help-style.js"; -import { loadJsoncConfig, resolveOpencodeConfigPath, writeJsonConfig } from "./setup-config.js"; +import { + loadJsoncConfig, + resolveOpencodeConfigPath, + writeJsonConfig, + writeJsonConfigWithBackup, +} from "./setup-config.js"; function opencodeConfigDir(): string { return join(homedir(), ".config", "opencode"); @@ -34,10 +56,26 @@ export function codexConfigDir(): string { return process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"); } +/** Resolve the pi agent directory, honoring PI_CODING_AGENT_DIR. */ +export function piConfigDir(): string { + return resolvePiAgentDir(); +} + /** The npm package name used in the OpenCode plugin array. */ const OPENCODE_PLUGIN_SPEC = "@codemem/opencode-plugin"; const LEGACY_OPENCODE_PLUGIN_SPECS = ["codemem", "@kunickiaj/codemem"]; +/** npm packages: entry prefix for the pi extension (version is appended). */ +const PI_EXTENSION_NPM_NAME = "@codemem/pi-extension"; +const PI_EXTENSION_NPM_PREFIX = `npm:${PI_EXTENSION_NPM_NAME}@`; +const PI_MCP_ADAPTER_MARKER = "pi-mcp-adapter"; + +/** Codemem stdio MCP server entry written to pi's mcp.json under --pi-mcp. */ +const PI_MCP_CODEMEM_ENTRY = { + command: "npx", + args: ["-y", "codemem", "mcp"], +} as const; + // --------------------------------------------------------------------------- // Legacy migration helpers // --------------------------------------------------------------------------- @@ -784,30 +822,466 @@ export function installCodex(force: boolean): boolean { return ok; } +// --------------------------------------------------------------------------- +// Pi install (packages entry + observer derivation + optional MCP adapter) +// --------------------------------------------------------------------------- + +export type InstallPiOptions = { + force?: boolean; + /** Opt into writing pi mcp.json + flipping pi.tools_mode to mcp-adapter. */ + piMcp?: boolean; + /** Absolute (or cwd-resolved) local path written instead of the npm packages spec. */ + piExtensionPath?: string; +}; + +/** Build the packages: entry for the pi extension (npm pin or local path). */ +export function buildPiExtensionPackageSpec( + extensionPath?: string, + version: string = VERSION, +): string { + if (extensionPath?.trim()) { + const raw = extensionPath.trim(); + return isAbsolute(raw) ? raw : resolve(raw); + } + return `${PI_EXTENSION_NPM_PREFIX}${version}`; +} + +/** True when a packages: entry refers to @codemem/pi-extension (any version/path form). */ +export function isPiExtensionPackageEntry(entry: unknown): boolean { + if (typeof entry !== "string" || !entry.trim()) return false; + const value = entry.trim(); + if (value === `npm:${PI_EXTENSION_NPM_NAME}` || value.startsWith(PI_EXTENSION_NPM_PREFIX)) { + return true; + } + if (value === PI_EXTENSION_NPM_NAME || value.startsWith(`${PI_EXTENSION_NPM_NAME}@`)) { + return true; + } + // Local-path dogfood entries end with the package folder name. + return ( + /(?:^|[/\\])@codemem[/\\]pi-extension(?:[/\\]|$)/.test(value) || + /(?:^|[/\\])packages[/\\]pi-extension(?:[/\\]|$)/.test(value) + ); +} + +/** True when entry is an npm: pin for @codemem/pi-extension (versioned or bare). */ +function isNpmPiExtensionPackageEntry(entry: unknown): boolean { + if (typeof entry !== "string" || !entry.trim()) return false; + const value = entry.trim(); + return value === `npm:${PI_EXTENSION_NPM_NAME}` || value.startsWith(PI_EXTENSION_NPM_PREFIX); +} + +function piBinaryOnPath(): boolean { + try { + const out = execFileSync(process.platform === "win32" ? "where" : "which", ["pi"], { + encoding: "utf-8", + }); + return Boolean( + out + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean), + ); + } catch { + return false; + } +} + +/** True when path exists and has non-whitespace content (real install marker). */ +function isNonEmptyFile(path: string): boolean { + try { + if (!existsSync(path)) return false; + return readFileSync(path, "utf-8").trim().length > 0; + } catch { + return false; + } +} + +/** + * Detect pi via `pi` on PATH, or an agent dir that contains real install + * markers (non-empty settings.json or auth.json). A bare empty ~/.pi/agent + * directory must not count — users (and tests) create that path incidentally. + * Honors PI_CODING_AGENT_DIR. + */ +export function isPiDetected(): boolean { + if (piBinaryOnPath()) return true; + const dir = piConfigDir(); + if (!existsSync(dir)) return false; + return isNonEmptyFile(join(dir, "settings.json")) || isNonEmptyFile(join(dir, "auth.json")); +} + +/** Detect pi-mcp-adapter via packages: entry or extensions/ directory name. */ +export function isPiMcpAdapterDetected(piDir: string = piConfigDir()): boolean { + const settingsPath = join(piDir, "settings.json"); + if (existsSync(settingsPath)) { + try { + const settings = loadJsoncConfig(settingsPath); + const packages = settings.packages; + if (Array.isArray(packages)) { + const found = packages.some( + (entry) => typeof entry === "string" && entry.includes(PI_MCP_ADAPTER_MARKER), + ); + if (found) return true; + } + } catch { + // Fall through to extensions/ probe; parse failure is handled at write time. + } + } + + const extensionsDir = join(piDir, "extensions"); + if (!existsSync(extensionsDir)) return false; + try { + const entries = readdirSync(extensionsDir, { withFileTypes: true }); + return entries.some((entry) => { + if (!entry.isDirectory() && !entry.isSymbolicLink()) return false; + const name = entry.name.toLowerCase(); + return name.includes("mcp-adapter") || name.includes("mcp_adapter"); + }); + } catch { + return false; + } +} + +/** + * Idempotently ensure the pi extension packages: entry is present in + * settings.json. Backs up before write; aborts on parse failure. + */ +function installPiExtensionPackage(piDir: string, force: boolean, extensionPath?: string): boolean { + const settingsPath = join(piDir, "settings.json"); + const desired = buildPiExtensionPackageSpec(extensionPath); + + let settings: Record; + if (existsSync(settingsPath)) { + try { + settings = loadJsoncConfig(settingsPath); + } catch (err) { + p.log.error( + `Failed to parse ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`, + ); + p.log.info( + `Leaving ${settingsPath} untouched. Fix or remove the file, then re-run \`codemem setup --pi-only\`.`, + ); + return false; + } + } else { + settings = {}; + } + + let packages = settings.packages as unknown; + if (!Array.isArray(packages)) { + packages = []; + } + + const list = packages as unknown[]; + const existingIdx = list.findIndex((entry) => isPiExtensionPackageEntry(entry)); + const existing = existingIdx >= 0 ? list[existingIdx] : undefined; + + if (existing != null && !force) { + // Equal pin → no-op. Stale npm version pin → fall through and upgrade. + // Local-path / non-npm entries are never touched without --force. + const shouldUpgrade = isNpmPiExtensionPackageEntry(existing) && existing !== desired; + if (!shouldUpgrade) { + p.log.info(`Pi extension package already configured in ${settingsPath}`); + return true; + } + } + + const next = list.filter((entry) => !isPiExtensionPackageEntry(entry)); + next.push(desired); + settings.packages = next; + + try { + mkdirSync(piDir, { recursive: true }); + writeJsonConfigWithBackup(settingsPath, settings); + p.log.success( + existing != null + ? `Pi extension package updated: ${desired}` + : `Pi extension package added: ${desired}`, + ); + } catch (err) { + p.log.error( + `Failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } + return true; +} + +function isOfficialObserverBaseUrl(url: string): boolean { + const normalized = url.trim().replace(/\/+$/, "").toLowerCase(); + return ( + normalized === "https://api.openai.com" || + normalized === "https://api.openai.com/v1" || + normalized === "https://api.anthropic.com" || + normalized === "https://api.anthropic.com/v1" + ); +} + +/** + * Derive unset observer_* keys from pi config and ensure pi.tools_mode default. + * Never persists API keys or tokens from pi auth. + */ +function wirePiCodememConfig(opts: { toolsMode: "native" | "mcp-adapter" }): boolean { + let existing: Record; + try { + existing = readCodememConfigFile(); + } catch (err) { + p.log.error( + `Failed to read codemem config: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } + + const next: Record = { ...existing }; + const updated: string[] = []; + + // Nested pi.* block (tools_mode). + const existingPi = + existing.pi != null && typeof existing.pi === "object" && !Array.isArray(existing.pi) + ? { ...(existing.pi as Record) } + : {}; + const piBlock = { ...existingPi }; + if (opts.toolsMode === "mcp-adapter") { + if (piBlock.tools_mode !== "mcp-adapter") { + piBlock.tools_mode = "mcp-adapter"; + updated.push("pi.tools_mode"); + } + } else if (piBlock.tools_mode == null || piBlock.tools_mode === "") { + piBlock.tools_mode = "native"; + updated.push("pi.tools_mode"); + } + next.pi = piBlock; + + // Observer derivation — only fill unset keys; never copy secrets. + // Key names match ObserverClient config mappings in packages/core/src/observer-client.ts + // (observer_provider / observer_model / observer_base_url / observer_openai_use_responses). + const envProvider = process.env.CODEMEM_OBSERVER_PROVIDER?.trim(); + const envModel = process.env.CODEMEM_OBSERVER_MODEL?.trim(); + const envBaseUrl = process.env.CODEMEM_OBSERVER_BASE_URL?.trim(); + const envUseResponses = process.env.CODEMEM_OBSERVER_OPENAI_USE_RESPONSES?.trim(); + + const fileProvider = + typeof existing.observer_provider === "string" ? existing.observer_provider.trim() : ""; + const fileModel = + typeof existing.observer_model === "string" ? existing.observer_model.trim() : ""; + const fileBaseUrl = + typeof existing.observer_base_url === "string" ? existing.observer_base_url.trim() : ""; + // Boolean file key: present (true/false) counts as set; absent/null is unset. + const fileHasUseResponses = existing.observer_openai_use_responses != null; + + const needsProvider = !envProvider && !fileProvider; + const needsModel = !envModel && !fileModel; + const needsBaseUrl = !envBaseUrl && !fileBaseUrl; + const needsUseResponses = !envUseResponses && !fileHasUseResponses; + + if (needsProvider || needsModel || needsBaseUrl || needsUseResponses) { + const resolved = resolvePiObserverConfig(); + p.log.info(`Pi observer: ${describePiObserverStatus(resolved)}`); + if (resolved.ok) { + if (needsProvider) { + next.observer_provider = resolved.provider; + updated.push("observer_provider"); + } + if (needsModel) { + next.observer_model = resolved.model; + updated.push("observer_model"); + } + // Official OpenAI/Anthropic URLs must stay unset: any non-empty + // observer_base_url is treated as a custom gateway (disables default + // Responses + tier routing). Persist only a real custom URL, and the + // use_responses flag only then. + const customBaseUrl = + resolved.baseUrl && !isOfficialObserverBaseUrl(resolved.baseUrl) + ? resolved.baseUrl + : undefined; + if (needsBaseUrl && customBaseUrl) { + next.observer_base_url = customBaseUrl; + updated.push("observer_base_url"); + } + if (needsUseResponses && customBaseUrl) { + next.observer_openai_use_responses = resolved.openAIUseResponses; + updated.push("observer_openai_use_responses"); + } + // Intentionally never write resolved.apiKey (or any credential). + } else if (!fileProvider && !fileModel && !envProvider && !envModel) { + p.log.info( + "Extraction model left unconfigured — set observer_provider/observer_model when ready.", + ); + } + } else { + p.log.info("Existing codemem observer config left unchanged"); + } + + // Never introduce credentials. `next` started as a shallow copy of `existing`, + // so a pre-existing user-supplied observer_api_key is preserved untouched; + // resolvePiObserverConfig's apiKey is intentionally never copied here. + if (updated.length === 0) { + return true; + } + + try { + const saved = writeCodememConfigFile(next); + p.log.success(`Codemem config updated (${updated.join(", ")}): ${saved}`); + } catch (err) { + p.log.error( + `Failed to write codemem config: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } + return true; +} + +/** + * Opt-in MCP surface for pi via pi-mcp-adapter. Writes mcp.json only when the + * adapter is detected; otherwise explains the prerequisite and writes nothing. + */ +function installPiMcp( + piDir: string, + force: boolean, +): { + ok: boolean; + wrote: boolean; + adapterPresent: boolean; +} { + const adapterPresent = isPiMcpAdapterDetected(piDir); + if (!adapterPresent) { + p.log.warn( + "MCP in pi requires the pi-mcp-adapter package. Install it (e.g. `pi install npm:pi-mcp-adapter`), then re-run `codemem setup --pi-only --pi-mcp`.", + ); + return { ok: true, wrote: false, adapterPresent: false }; + } + + const mcpPath = join(piDir, "mcp.json"); + let mcp: Record; + if (existsSync(mcpPath)) { + try { + mcp = loadJsoncConfig(mcpPath); + } catch (err) { + p.log.error( + `Failed to parse ${mcpPath}: ${err instanceof Error ? err.message : String(err)}`, + ); + p.log.info( + `Leaving ${mcpPath} untouched. Fix or remove the file, then re-run with --pi-mcp.`, + ); + return { ok: false, wrote: false, adapterPresent: true }; + } + } else { + mcp = {}; + } + + let servers = mcp.mcpServers as Record | undefined; + if (servers == null || typeof servers !== "object" || Array.isArray(servers)) { + servers = {}; + } + + if ("codemem" in servers && !force) { + p.log.info(`Pi MCP entry already exists in ${mcpPath}`); + return { ok: true, wrote: false, adapterPresent: true }; + } + + servers.codemem = { ...PI_MCP_CODEMEM_ENTRY }; + mcp.mcpServers = servers; + + try { + mkdirSync(piDir, { recursive: true }); + writeJsonConfigWithBackup(mcpPath, mcp); + p.log.success(`Pi MCP entry installed: ${mcpPath}`); + } catch (err) { + p.log.error(`Failed to write ${mcpPath}: ${err instanceof Error ? err.message : String(err)}`); + return { ok: false, wrote: false, adapterPresent: true }; + } + return { ok: true, wrote: true, adapterPresent: true }; +} + +/** + * Configure pi: packages entry, observer derivation, optional MCP adapter surface. + * Idempotent; honors PI_CODING_AGENT_DIR. Returns true on success. + */ +export function installPi(options: InstallPiOptions = {}): boolean { + const force = options.force ?? false; + const piDir = piConfigDir(); + + try { + mkdirSync(piDir, { recursive: true }); + } catch (err) { + p.log.error( + `Failed to create pi agent dir ${piDir}: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } + + p.log.info(`Pi agent directory: ${piDir}`); + + // Packages entry is required; abort the rest on parse/write failure so we + // never partially configure observer/MCP against a broken settings.json. + if (!installPiExtensionPackage(piDir, force, options.piExtensionPath)) { + return false; + } + + let ok = true; + let toolsMode: "native" | "mcp-adapter" = "native"; + if (options.piMcp) { + const mcpResult = installPiMcp(piDir, force); + ok = mcpResult.ok && ok; + if (mcpResult.adapterPresent && mcpResult.ok) { + // Adapter present: flip tools_mode even when the mcp entry was already there. + toolsMode = "mcp-adapter"; + } + } + // Default run writes no MCP config (spec: opt-in only). + + ok = wirePiCodememConfig({ toolsMode }) && ok; + + if (ok) { + p.log.info("Pi next steps:"); + p.log.info(" - Start (or restart) pi to load the extension package"); + if (!options.piMcp) { + p.log.info(" - Optional MCP surface: install pi-mcp-adapter, then re-run with --pi-mcp"); + } + p.log.info( + " - To disable: remove the @codemem/pi-extension entry from pi settings.json packages", + ); + } + + return ok; +} + export const setupCommand = new Command("setup") .configureHelp(helpStyle) - .description("Install codemem plugin + MCP config for OpenCode and Claude Code") + .description("Install codemem plugin + MCP config for OpenCode, Claude Code, Codex, and pi") .option("--force", "overwrite existing installations") .option("--opencode-only", "only install for OpenCode") .option("--claude-only", "only install for Claude Code") .option("--codex-only", "only install for Codex") + .option("--pi-only", "only install for pi") + .option("--pi-mcp", "opt into pi MCP adapter surface (requires pi-mcp-adapter)") + .option( + "--pi-extension-path ", + "dev: write a local path packages entry instead of the npm pin", + ) .action( (opts: { force?: boolean; opencodeOnly?: boolean; claudeOnly?: boolean; codexOnly?: boolean; + piOnly?: boolean; + piMcp?: boolean; + piExtensionPath?: string; }) => { p.intro(`codemem setup v${VERSION}`); const force = opts.force ?? false; let ok = true; - const onlyFlag = Boolean(opts.opencodeOnly || opts.claudeOnly || opts.codexOnly); + const onlyFlag = Boolean( + opts.opencodeOnly || opts.claudeOnly || opts.codexOnly || opts.piOnly, + ); const doOpencode = opts.opencodeOnly || !onlyFlag; const doClaude = opts.claudeOnly || !onlyFlag; // With no only-flag, Codex runs only when a Codex home is detected. const doCodex = opts.codexOnly || (!onlyFlag && existsSync(codexConfigDir())); + // With no only-flag, pi runs only when pi is detected (PATH or agent dir). + const doPi = opts.piOnly || (!onlyFlag && isPiDetected()); if (doOpencode) { p.log.step("Installing OpenCode plugin..."); @@ -830,6 +1304,16 @@ export const setupCommand = new Command("setup") p.log.info(" - MCP recall works immediately (no trust prompt required)"); } + if (doPi) { + p.log.step("Configuring pi (extension package + observer)..."); + ok = + installPi({ + force, + piMcp: opts.piMcp, + piExtensionPath: opts.piExtensionPath, + }) && ok; + } + if (ok) { p.outro("Setup complete — restart your editor to load the plugin"); } else { diff --git a/packages/pi-extension/README.md b/packages/pi-extension/README.md index c062ec99f..a67d5ef09 100644 --- a/packages/pi-extension/README.md +++ b/packages/pi-extension/README.md @@ -31,7 +31,7 @@ Build first so `dist/index.js` exists (`pnpm --filter @codemem/pi-extension buil ## What it does -- **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). +- **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` (compat alias over the canonical ingest envelope), 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** — 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. diff --git a/packages/pi-extension/package.json b/packages/pi-extension/package.json index caff49f96..92062a18c 100644 --- a/packages/pi-extension/package.json +++ b/packages/pi-extension/package.json @@ -1,6 +1,6 @@ { "name": "@codemem/pi-extension", - "version": "0.40.1", + "version": "0.41.0", "description": "CodeMem extension for the pi coding agent — ingest, injection, and native memory tools", "type": "module", "main": "./dist/index.js", diff --git a/packages/ui/src/tabs/health/render/health-overview.ts b/packages/ui/src/tabs/health/render/health-overview.ts index abf6d43be..081c1d00e 100644 --- a/packages/ui/src/tabs/health/render/health-overview.ts +++ b/packages/ui/src/tabs/health/render/health-overview.ts @@ -14,6 +14,7 @@ import { titleCase, } from "../../../lib/format"; import { state } from "../../../lib/state"; +import { formatAgentClientList } from "../../settings/data/value-helpers"; import { buildHealthCard, renderActionList, @@ -308,8 +309,8 @@ export function renderHealthOverview() { actionLabel: "View diagnostics", }); recommendations.push({ - label: "Then retry failed batches for impacted sessions.", - command: "codemem db raw-events-retry ", + label: `Then retry failed batches for impacted sessions (${formatAgentClientList()}).`, + command: "codemem db raw-events-retry", }); } else if (syncState === "stopped") { recommendations.push({ diff --git a/packages/ui/src/tabs/settings/components/ObserverPanel.test.tsx b/packages/ui/src/tabs/settings/components/ObserverPanel.test.tsx index 439dd12aa..f8df0d8bb 100644 --- a/packages/ui/src/tabs/settings/components/ObserverPanel.test.tsx +++ b/packages/ui/src/tabs/settings/components/ObserverPanel.test.tsx @@ -71,4 +71,15 @@ describe("ObserverPanel", () => { expect(mount.querySelector("#codexCommand")).not.toBeNull(); expect(mount.textContent).toContain("codex_command is protected"); }); + + it("mentions pi in observer connection copy", () => { + mount = document.createElement("div"); + document.body.appendChild(mount); + act(() => render(, mount as HTMLDivElement)); + + expect(mount.textContent).toContain("opencode, claude, codex, and pi"); + expect(mount.textContent).toMatch(/pi setup can derive Direct API/i); + const runtimeHelp = mount.querySelector('[aria-label="About connection mode"]'); + expect(runtimeHelp?.getAttribute("data-tooltip") ?? "").toMatch(/pi API-key providers/i); + }); }); diff --git a/packages/ui/src/tabs/settings/components/ObserverPanel.tsx b/packages/ui/src/tabs/settings/components/ObserverPanel.tsx index de2ca9b5a..99aed7ff7 100644 --- a/packages/ui/src/tabs/settings/components/ObserverPanel.tsx +++ b/packages/ui/src/tabs/settings/components/ObserverPanel.tsx @@ -3,6 +3,7 @@ import { RadixSelect } from "../../../components/primitives/radix-select"; import { TextArea } from "../../../components/primitives/text-area"; import { TextInput } from "../../../components/primitives/text-input"; import type { SettingsPanelProps } from "../data/types"; +import { formatAgentClientList } from "../data/value-helpers"; import { Field } from "./Field"; import { SettingsHint } from "./SettingsHint"; import { SettingsSectionIntro } from "./SettingsSectionIntro"; @@ -26,7 +27,7 @@ export function ObserverPanel({ return ( <> {observerStatusBannerSlot} @@ -56,7 +57,10 @@ export function ObserverPanel({ value={values.observerProvider} viewportClassName="settings-select-viewport" /> -
Use `auto` unless you need to pin a specific provider.
+
+ Use `auto` unless you need to pin a specific provider. Pi setup can derive Direct API + provider/model from API-key providers only. +
@@ -87,7 +91,7 @@ export function ObserverPanel({