From b9d1b2dbfc620f6b97e284ef7cbf75746885ebab Mon Sep 17 00:00:00 2001 From: Ed Heltzel <402910+edheltzel@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:46:04 -0400 Subject: [PATCH] feat(pi): package Recall on native surfaces --- AGENTS.md | 8 +- FOR_PI.md | 81 +++++--- README.md | 3 +- agent-skills/AGENTS.md | 8 +- docs/PI_INTEGRATION.md | 214 ++++++++++++--------- docs/agent-skills.md | 2 +- docs/architecture.md | 19 +- docs/installation.md | 2 +- docs/lifecycle.md | 2 +- docs/upgrading.md | 3 + install.sh | 6 +- lib/install-lib.sh | 198 ++++++++++++++------ package.json | 12 +- pi/RecallExtract.ts | 62 ++---- pi/RecallPreCompact.ts | 16 +- scripts/e2e-claude-plugin.ts | 29 +-- scripts/e2e-codex-plugin.ts | 29 +-- scripts/e2e-pi-integration.ts | 312 +++++++++++++++++++++++++++++++ scripts/lib/e2e-isolation.ts | 33 ++++ src/hosts/pi.ts | 2 +- tests/AGENTS.md | 1 + tests/hosts/native-hosts.test.ts | 3 + tests/install/skills.test.ts | 19 +- tests/install/update.test.ts | 10 +- tests/pi-integration.test.ts | 176 +++++++++++++++-- uninstall.sh | 13 +- update.sh | 2 +- 27 files changed, 931 insertions(+), 334 deletions(-) create mode 100644 scripts/e2e-pi-integration.ts create mode 100644 scripts/lib/e2e-isolation.ts diff --git a/AGENTS.md b/AGENTS.md index c7a03c0..f424528 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,12 +25,12 @@ Top-level directories, by purpose (one line each — not a file enumeration): - `hooks/` — self-contained lifecycle hooks + cron jobs (never import from `src/`) - `tests/` — `bun:test` suite mirroring source areas, plus install-lifecycle tests - `benchmarks/` — wake-up context-efficiency benchmark harness -- `agent-skills/` — Agent Skills (SKILL.md, one per skill dir) installed to `~/.claude/skills`, `~/.pi/agent/skills`, `~/.omp/agent/skills` — the single `recall-*` command surface (the former `/Recall:*` slash commands, #228) +- `agent-skills/` — canonical Agent Skills (SKILL.md, one per skill dir) installed to `~/.claude/skills` and `~/.omp/agent/skills`, discovered by Pi through the root package manifest, and generated into native host plugin payloads — the single `recall-*` command surface (the former `/Recall:*` slash commands, #228) - `plugins/` — native host plugin bundles, one per host: Codex in `plugins/recall/`, Claude Code in `plugins/recall-claude/`, each packaging MCP plus its own skill payload - `docs/` — user-facing published docs + ADRs (`docs/adr/`) + agent skill docs (`docs/agents/`) - `lib/` — shared bash for the install / update / uninstall lifecycle scripts - `opencode/` — OpenCode host integration (plugins / hooks / guide) -- `pi/` — Pi host integration (extensions / hooks) +- `pi/` — Pi package extensions for native lifecycle capture and memory injection - `scripts/` — dev / CI helper scripts (version check, e2e) - `templates/` — install templates (`CLAUDE.md.template`, `mcp.json.template`) - `assets/` — README banner + VHS demo tapes / gifs @@ -115,7 +115,7 @@ Before adding code or content, search for an existing definition and extend it. - **Runtime**: Bun (not Node). Uses `bun:sqlite` directly. Shebangs are `#!/usr/bin/env bun`. - **Build**: tsup produces ESM. Build script replaces node shebang with bun shebang. - **Database**: SQLite at `~/.agents/Recall/recall.db` (override via `RECALL_DB_PATH`; legacy `MEM_DB_PATH` still accepted). WAL mode. FTS5 full-text search with sync triggers. -- **Install layout**: Canonical files live under `~/.agents/Recall/` (`shared/hooks/`, `shared/skills/`, `opencode/plugins/`, `pi/extensions/`, `MEMORY/`, `backups/`). Platform homes (`~/.claude/hooks/`, `~/.config/pencode/plugins/`, `~/.pi/agent/extensions/`) receive **per-file symlinks** back to canonicals. The collision rule in `lib/install-lib.sh:recall_link` backs up any existing user file before replacing it with a symlink. +- **Install layout**: Canonical files live under `~/.agents/Recall/` (`shared/hooks/`, `shared/skills/`, `opencode/plugins/`, `MEMORY/`, `backups/`). Claude lifecycle hooks and OpenCode integration receive **per-file symlinks** back to canonicals; when the optional Claude plugin is active, it owns skills + MCP and the installer reconciles the legacy duplicates. Pi loads `pi/*.ts` and the canonical Agent Skills from the root package's native `package.json#pi` manifest; its MCP adapter and `mcp.json` registration remain separate because Pi packages have no MCP resource. The collision rule in `lib/install-lib.sh:recall_link` backs up any existing user file before replacing it with a symlink. - **MCP registration**: User scope in `~/.claude/settings.json` (or `~/.claude.json` if managed by `claude mcp add`) under `mcpServers`. The `env.RECALL_DB_PATH` block is populated by the installer. - **Hook registration**: `Stop`, `SessionStart`, `PreCompact` events in `~/.claude/settings.json` under `hooks.*`. - **Hooks are self-contained**: `RecallExtract.ts`, `RecallStart.ts`, etc. are standalone scripts symlinked into `~/.claude/hooks/` from `~/.agents/Recall/shared/hooks/`. They don't import from `src/`. The shared resolver `hooks/lib/db-path.ts` centralizes DB-path resolution so the CLI and every hook agree. @@ -131,7 +131,7 @@ Before adding code or content, search for an existing definition and extend it. - **Modify extraction**: Edit `hooks/RecallExtract.ts` (self-contained, no build step) - **Add a hook helper**: Create `hooks/lib/foo.ts` — kept standalone so hooks don't import from `src/` - **Edit lifecycle scripts**: `install.sh`, `update.sh`, and `uninstall.sh` share `lib/install-lib.sh` — put shared bash functions there, not duplicated across scripts. Validate each with `bash -n`. -- **Add an Agent Skill**: Create `agent-skills//SKILL.md` — the install/update/uninstall scripts pick it up automatically (canonical copy under `$RECALL_SHARED_SKILLS_DIR`, per-file symlinks into `~/.claude/skills`, `~/.pi/agent/skills`, `~/.omp/agent/skills`). Also add `` to `RECALL_SKILL_NAMES` in `uninstall.sh` so uninstall removes it, and regenerate the native plugin bundles with `bun run build:codex-plugin` and `bun run build:claude-plugin` (their tests fail on drift). +- **Add an Agent Skill**: Create `agent-skills//SKILL.md` — the install/update/uninstall scripts pick it up automatically (canonical copy under `$RECALL_SHARED_SKILLS_DIR`, per-file symlinks into `~/.claude/skills` and `~/.omp/agent/skills`; Pi discovers it through the root native package manifest). Also add `` to `RECALL_SKILL_NAMES` in `uninstall.sh` so legacy/uninstall cleanup removes it, and regenerate the native plugin bundles with `bun run build:codex-plugin` and `bun run build:claude-plugin` (their tests fail on drift). - **Update the Claude guide**: Edit `FOR_CLAUDE.md` (installer copies it to `~/.claude/Recall_GUIDE.md`). Keep `FOR_OPENCODE.md` and `FOR_PI.md` in sync if lifecycle commands change. - **Cut a release**: See `docs/releasing.md` for the tag → GitHub release flow. diff --git a/FOR_PI.md b/FOR_PI.md index 425c102..59d0507 100644 --- a/FOR_PI.md +++ b/FOR_PI.md @@ -19,66 +19,78 @@ user has never written one, recommend `recall onboard` via Bash to create it. ## Your MCP Tools -These tools are available via the `recall-memory` MCP server. In Pi, tool names are prefixed with the server name using `recall-memory_`: +These tools are available via the `recall-memory` MCP server. Recall enables the adapter's direct-tool mode by default, which normalizes the server prefix to `recall_memory_`; an existing explicit `directTools` preference is preserved: -### recall-memory_memory_search +On the first Pi session after adding the server, `pi-mcp-adapter` populates its metadata cache in the background and keeps the operations available through its `mcp` proxy tool. +After one Pi reload or restart, the nine direct names below are registered from that cache. +If a direct name is not present yet, call the same base operation through `mcp`, for example `mcp({ server: "recall-memory", tool: "memory_search", args: '{"query":"deployment"}' })`. + +### recall_memory_memory_search Search all memory with FTS5 full-text search. **Use this BEFORE asking the user to repeat anything.** Use `table` for a hard filter; use `bias_type` to prefer one type while preserving other matches. ``` -recall-memory_memory_search({ query: "kubernetes auth", project: "my-app" }) -recall-memory_memory_search({ query: "database choice", table: "decisions" }) // decisions only -recall-memory_memory_search({ query: "database choice", bias_type: "decisions" }) // decisions first, broader context kept +recall_memory_memory_search({ query: "kubernetes auth", project: "my-app" }) +recall_memory_memory_search({ query: "database choice", table: "decisions" }) // decisions only +recall_memory_memory_search({ query: "database choice", bias_type: "decisions" }) // decisions first, broader context kept ``` Bias quick picks: `decisions` for “what did we decide,” `learnings` for “what did we learn,” `breadcrumbs` for “where did we leave off,” `loa` for curated summaries, `messages` for raw conversation traces. -### recall-memory_memory_hybrid_search +### recall_memory_memory_hybrid_search Combines keyword (FTS5) and semantic (embedding) search. Best for natural language queries. ``` -recall-memory_memory_hybrid_search({ query: "how did we handle rate limiting" }) +recall_memory_memory_hybrid_search({ query: "how did we handle rate limiting" }) ``` -### recall-memory_memory_recall +### recall_memory_memory_recall Get recent context — LoA entries, decisions, breadcrumbs. Good for session start. ``` -recall-memory_memory_recall({ limit: 5, project: "my-app" }) +recall_memory_memory_recall({ limit: 5, project: "my-app" }) ``` -### recall-memory_memory_add +### recall_memory_memory_add Record structured information during sessions: ``` -recall-memory_memory_add({ type: "decision", content: "Use PostgreSQL over MySQL", detail: "Better JSON support and extensions" }) -recall-memory_memory_add({ type: "learning", content: "bun:sqlite uses $param syntax", detail: "Not :param like better-sqlite3" }) -recall-memory_memory_add({ type: "breadcrumb", content: "Auth refactor in progress, do not touch middleware yet" }) -recall-memory_memory_add({ type: "decision", content: "Ship onboarding first", importance: 9 }) +recall_memory_memory_add({ type: "decision", content: "Use PostgreSQL over MySQL", detail: "Better JSON support and extensions" }) +recall_memory_memory_add({ type: "learning", content: "bun:sqlite uses $param syntax", detail: "Not :param like better-sqlite3" }) +recall_memory_memory_add({ type: "breadcrumb", content: "Auth refactor in progress, do not touch middleware yet" }) +recall_memory_memory_add({ type: "decision", content: "Ship onboarding first", importance: 9 }) ``` The optional `importance` parameter (integer 1-10, default 5) controls L1 tier ranking at session start. LoA entries have a floor of 5. -### recall-memory_memory_stats +### recall_memory_memory_stats Get database statistics (record counts, database size). -### recall-memory_loa_show +### recall_memory_loa_show Show a full Library of Alexandria entry with its extracted wisdom. -### recall-memory_context_for_agent +### recall_memory_context_for_agent Get context to pass to a subagent before delegating tasks: ``` -recall-memory_context_for_agent({ agent_task: "implement the auth middleware", project: "my-app" }) +recall_memory_context_for_agent({ agent_task: "implement the auth middleware", project: "my-app" }) ``` +### recall_memory_memory_dump + +Persist caller-supplied visible messages as a session and LoA entry. Use the `recall-dump` skill so the explicit user-consent gate is preserved. + +### recall_memory_decision_update + +Change an existing decision's status by ID when it is superseded, reverted, or confirmed. + ## The CLI You can also use the `recall` CLI directly via shell commands: @@ -102,8 +114,8 @@ Canonical workflow — memory-first, sensitive-data boundary, opt-in artifacts | Canonical step | Your tool / invocation | |---|---| -| Invoke the workflow | `recall-scout` agent skill (installed at `~/.pi/agent/skills/recall-scout/`) | -| "Search Recall first" (memory-first) | `recall-memory_memory_search` (keyword), `recall-memory_memory_hybrid_search` (natural language) | +| Invoke the workflow | `recall-scout` Agent Skill (discovered from Recall's native Pi package) | +| "Search Recall first" (memory-first) | `recall_memory_memory_search` (keyword), `recall_memory_memory_hybrid_search` (natural language) | | Persist a report (only if endorsed) | Write to `.agents/atlas/artifacts/YYYY-MM-DD-scout-.md` | ## Lifecycle scripts @@ -115,7 +127,7 @@ They are platform-agnostic — Pi, Claude Code, and OpenCode share them. |---|---| | `./install.sh` | Install or reinstall. Idempotent. | | `./update.sh --check` | Check if a newer GitHub release exists. Check-only. | -| `./update.sh` | Pull latest, rebuild, migrate DB, re-register extensions. Exit Pi first. | +| `./update.sh` | Pull latest, rebuild, migrate DB, and refresh the Pi package plus MCP registration. Exit Pi first. | | `./uninstall.sh --dry-run` | Preview what would be removed. | | `./uninstall.sh` | Surgical remove; preserves `~/.agents/Recall/` (DB + backups + canonicals) by default. | | `./uninstall.sh --purge` | Also destroy `~/.agents/Recall/` and any legacy DB (double-confirmed). | @@ -127,24 +139,37 @@ binary lives in the same `bun link` process tree, and rebuilding mid-session can corrupt in-flight extension invocations. Have them exit Pi first. +## How Pi Is Installed + +Pi packages can carry Recall's two extensions and nine Agent Skills together. +Pi packages cannot register an MCP server. + +`recall install` therefore coordinates separate native pieces: the Recall Pi package, the `pi-mcp-adapter` package, the owned `recall-memory` entry in `mcp.json`, and this guide. +It also removes pre-package Recall extension and skill symlinks so Pi never loads two copies. + +Do not treat `pi install npm:recall-memory` as a complete Recall install. +It omits the MCP adapter/configuration and does not guarantee that `recall` or `recall-mcp` is on `PATH`. + +See [`docs/PI_INTEGRATION.md`](docs/PI_INTEGRATION.md) for the verified Pi capability matrix and the explicit differences from Codex and Claude plugin bundles. + ## Core Rules 1. **Search before asking** — Before asking the user to repeat information, search memory first. Use `bias_type` when a likely record type should come first without hiding other context; use `table` only when you need one type exclusively. -2. **Record decisions** — When architectural decisions are made, use `recall-memory_memory_add` to record them -3. **Delegate with context** — Before spawning subagents, call `recall-memory_context_for_agent` to give them relevant history +2. **Record decisions** — When architectural decisions are made, use `recall_memory_memory_add` to record them +3. **Delegate with context** — Before spawning subagents, call `recall_memory_context_for_agent` to give them relevant history 4. **Capture sessions** — At the end of a session, run `recall dump "Descriptive Title"` (the `recall-dump` skill) to persist the conversation 5. **Onboarding check** — At session start, if the L0 identity tier is empty (no `~/.claude/MEMORY/identity.md` or the file is missing), suggest `recall onboard` once. Do not nag on subsequent turns. -6. **Never store secrets** — `recall-memory_memory_add` and `recall dump` persist content verbatim into `recall.db`, and stored records can resurface in future sessions' L0/L1 context. Redact API keys, tokens, passwords, and credential-bearing snippets before recording (e.g. `[REDACTED:api-key]`). When dumping a session that touched credentials, say so and confirm with the user first. -7. **Record corrections** — When the user corrects you ("no, actually…", "that's wrong, use X"), record it immediately: `recall-memory_memory_add({ type: "learning", content: "", confidence: "high", importance: 7 })`. Corrections are the highest-signal and most perishable memory; do not wait for session end. +6. **Never store secrets** — `recall_memory_memory_add` and `recall dump` persist content verbatim into `recall.db`, and stored records can resurface in future sessions' L0/L1 context. Redact API keys, tokens, passwords, and credential-bearing snippets before recording (e.g. `[REDACTED:api-key]`). When dumping a session that touched credentials, say so and confirm with the user first. +7. **Record corrections** — When the user corrects you ("no, actually…", "that's wrong, use X"), record it immediately: `recall_memory_memory_add({ type: "learning", content: "", confidence: "high", importance: 7 })`. Corrections are the highest-signal and most perishable memory; do not wait for session end. ## How Extraction Works A Recall extension (`RecallPreCompact.ts` + `RecallExtract.ts`) runs inside Pi: 1. `RecallPreCompact.ts` hooks into `before_agent_start` and injects relevant memory into the system prompt before each agent turn -2. `RecallExtract.ts` hooks into `session_shutdown` (fires on exit — Ctrl+C, Ctrl+D, SIGTERM) +2. `RecallExtract.ts` hooks into `session_shutdown` and reads the supported path from `ctx.sessionManager.getSessionFile()` 3. Pi sessions use tree-structured JSONL (each entry has `id` and `parentId`) rather than a linear format — the extension linearizes the active branch into flat markdown -4. The markdown is dropped into `~/.claude/MEMORY/pi-sessions/` +4. The markdown is dropped into `$RECALL_HOME/MEMORY/pi-sessions/` (default `~/.agents/Recall/MEMORY/pi-sessions/`) 5. A batch extraction cron job (`RecallBatchExtract`) processes these files into structured memory ## Database Location @@ -155,4 +180,4 @@ The SQLite database is at `~/.agents/Recall/recall.db` (or wherever `RECALL_DB_P - **FTS5** indexes on all text tables - **Vector embeddings** (optional, requires Ollama) for semantic search -The same database is shared with Claude Code and OpenCode if both are installed — your memory is unified across platforms. +The same database is shared with Claude Code, OpenCode, and Codex if they are installed — your memory is unified across platforms. diff --git a/README.md b/README.md index 601a10f..93b1b81 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ Recall is built around two integration surfaces: **MCP** (memory search and add, | Agent | MCP | Lifecycle hooks | Status | | ------------------------------------------------------------- | :-: | :--------------------------------------------------------: | ------------------------------------- | | [**Claude Code**](https://claude.com/claude-code) | ✅ | ✅ Stop · SessionStart · PreCompact | **Stable** — reference implementation; native plugin ships skills + MCP | -| [**Pi**](https://pi.dev/) | ✅ | ⚠ Beta — `recall-compaction` + `recall-extract` extensions | In progress | +| [**Pi**](https://pi.dev/) | ✅ | ⚠ Beta — native package with memory-injection + shutdown-capture extensions | Package + separate MCP adapter/config | | [**OpenCode**](https://opencode.ai/) | ✅ | ⚠ Alpha — `recall-extract` plugin | In progress | | [**Codex CLI**](https://github.com/openai/codex) | ✅ | — native plugin, explicit dump only | MCP + skills available | | [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) | — | — | Coming soon | @@ -342,6 +342,7 @@ Have an agent you'd like to see supported? [Open an issue](https://github.com/ed | [Agent Skills](docs/agent-skills.md) | `recall-*` skills for Claude Code, Pi, and omp | | [Codex Integration](docs/CODEX_INTEGRATION.md) | Native plugin install, MCP coverage, and lifecycle limits | | [Claude Integration](docs/CLAUDE_INTEGRATION.md) | Native plugin install, plugin/installer ownership split, migration | +| [Pi Integration](docs/PI_INTEGRATION.md) | Native package, separate MCP setup, lifecycle coverage, and host limits | | [Upgrading](docs/upgrading.md) | Update, backup, migration system | | [Troubleshooting](docs/troubleshooting.md) | Common issues and fixes | | [Changelog](CHANGELOG.md) | Release notes and breaking changes | diff --git a/agent-skills/AGENTS.md b/agent-skills/AGENTS.md index bf66782..9b316cb 100644 --- a/agent-skills/AGENTS.md +++ b/agent-skills/AGENTS.md @@ -4,7 +4,7 @@ ## Purpose -Agent Skill definitions (one `SKILL.md` per skill directory, per the [agentskills.io](https://agentskills.io) standard) for the `recall-*` namespace — the canonical command source across skill hosts. The installer symlinks canonicals into `~/.claude/skills/`, `~/.pi/agent/skills/`, and `~/.omp/agent/skills/`; `scripts/build-codex-plugin.ts` generates Codex-native adapters from them. +Agent Skill definitions (one `SKILL.md` per skill directory, per the [agentskills.io](https://agentskills.io) standard) for the `recall-*` namespace — the canonical command source across skill hosts. The lifecycle installer symlinks canonicals into `~/.claude/skills/` and `~/.omp/agent/skills/`; Pi loads them through the root package's native `package.json#pi` manifest, while `scripts/build-codex-plugin.ts` and `scripts/build-claude-plugin.ts` generate the native plugin payloads. ## Ownership @@ -15,17 +15,19 @@ Agent Skill definitions (one `SKILL.md` per skill directory, per the [agentskill - These are Markdown skill specs, not code; each maps to underlying `recall` CLI / MCP behavior — keep its body aligned with that command and with `docs/agent-skills.md`. - `recall-dump/SKILL.md` carries `disable-model-invocation: true` — dumping a session is always the user's call; do not remove that gate. - Codex does not accept every canonical frontmatter key. Keep host adaptations in `scripts/build-codex-plugin.ts` and verify the generated `plugins/recall/skills/` behavior; never infer parity from matching bytes. +- Claude's plugin payload is byte-verbatim. Regenerate it with `scripts/build-claude-plugin.ts`; never hand-edit `plugins/recall-claude/skills/`. +- Pi reads the canonical files directly through `package.json#pi`; do not generate or copy a second Pi skill tree. - `recall-scout/SKILL.md` output is chat-only by default; a persisted scout artifact goes to `.agents/atlas/artifacts/` (see root `AGENTS.md`), never under `agent-skills/` or `docs/`. - `recall-scout/SKILL.md` grounds its repo map / key paths / risks in **CodeGraph (primary)** via the external `codegraph` CLI: a `codegraph status --json` capability probe (on an unindexed repo scout **offers** `codegraph init` and runs it only on an explicit user yes — never auto-runs), a cheap orientation bundle (`status --json` + `files --max-depth 2 --no-metadata`), and at most two narrow `explore` calls under the query discipline pinned in the skill, with a grep/tree-walk backstop. CodeGraph is an enhancement, never a hard dependency — scout degrades gracefully when it isn't present. - The former `/Recall:*` slash commands (`commands/Recall/`) were retired in favor of these skills (#228); `install.sh`/`update.sh` clean up their symlinks. Do not reintroduce a parallel command surface. ## Work Guidance -- Add a skill: create `/SKILL.md`, document it in `docs/agent-skills.md`, add `` to `RECALL_SKILL_NAMES` in `uninstall.sh`, and regenerate the Codex plugin adapters. +- Add a skill: create `/SKILL.md`, document it in `docs/agent-skills.md`, add `` to `RECALL_SKILL_NAMES` in `uninstall.sh`, and regenerate both native plugin payloads. ## Verification -`tests/install/skills.test.ts` (install/uninstall lifecycle), `tests/commands/scout-workflow.test.ts` (scout workflow contracts), `tests/plugins/codex-plugin.test.ts` (generated Codex adapters). +`tests/install/skills.test.ts` (install/uninstall lifecycle), `tests/commands/scout-workflow.test.ts` (scout workflow contracts), `tests/plugins/{codex,claude}-plugin.test.ts` (generated native payloads), `tests/pi-integration.test.ts` and `scripts/e2e-pi-integration.ts` (native Pi package discovery). ## Child DOX Index diff --git a/docs/PI_INTEGRATION.md b/docs/PI_INTEGRATION.md index 321fb20..02bbcfb 100644 --- a/docs/PI_INTEGRATION.md +++ b/docs/PI_INTEGRATION.md @@ -1,130 +1,166 @@ -# Recall + Pi Integration Architecture +# Pi Integration -> Architecture document for extending Recall to work with Pi alongside Claude Code and OpenCode. +[Back to README](../README.md) -## Overview +Recall uses the native Pi mechanisms that exist today, but Pi does not have one bundle primitive that spans extensions, skills, and MCP. -Recall's core (SQLite DB, MCP server, CLI) is platform-agnostic. Pi connects to it via the same `recall-memory` MCP server used by Claude Code and OpenCode, through a `pi-mcp-adapter` that handles the protocol bridge and applies a server prefix to tool names. +The one-step Recall installer coordinates several separately owned Pi surfaces instead of pretending they are one plugin. -The integration follows the same three-layer pattern as other platforms: -1. **MCP registration** — `~/.pi/agent/mcp.json` -2. **Session extraction** — tree JSONL → linearized markdown → drop dir → RecallBatchExtract cron -3. **Memory injection** — `before_agent_start` hook → `recall search` → system prompt append +## Verified Pi surface -## Architecture +This integration was verified on 2026-07-22 against the installed `@earendil-works/pi-coding-agent` 0.81.1 CLI and its matching official documentation. -``` -┌─────────────────────────────────────────────────────────┐ -│ PLATFORM-AGNOSTIC CORE │ -│ │ -│ recall.db (SQLite/FTS5) ← MCP Server (recall-memory) │ -│ 7 tools, stdio │ -└─────────────────────────────────────┬───────────────────┘ - │ - ┌───────────────────────┼───────────────────┐ - │ │ │ - Claude Code Adapter OpenCode Adapter Pi Adapter - ~/.claude/settings.json ~/.config/opencode/ ~/.pi/agent/mcp.json - hooks/RecallExtract.ts plugins/ extensions/ - RecallExtract.ts RecallExtract.ts - RecallPreCompact.ts RecallPreCompact.ts -``` +The live probes were: -## Session Extraction +```bash +pi --version +pi --help +pi install --help +pi --no-extensions --help +``` -Pi stores conversation history as a tree JSONL (branching structure from edits and retries). The extraction pipeline: +`pi --help` exposed `--extension`, `--skill`, and an extension-contributed `--mcp-config` flag. -1. **Linearize**: Read the tree JSONL and walk the active branch (most recent path from root to leaf) -2. **Convert**: Render the active-branch messages as markdown (user/assistant turns) -3. **Drop**: Write the markdown file to `~/.agents/Recall/MEMORY/pi-sessions/.md` -4. **Extract**: The existing `RecallBatchExtract.ts` cron job scans the drop directory every 30 minutes and processes new files via Claude Haiku +The same help command with `--no-extensions` still exposed Pi's native extension and skill loaders, but the MCP flag disappeared. -This reuses the RecallBatchExtract infrastructure without modification — it already handles markdown input from the OpenCode drop dir. +That is direct CLI evidence that MCP is supplied by an installed extension, not by Pi's package manifest. -The extraction logic lives in `~/.pi/agent/extensions/RecallExtract.ts`, which hooks into Pi's session lifecycle. +The official Pi 0.81.1 documentation independently defines [packages](https://github.com/earendil-works/pi/blob/v0.81.1/packages/coding-agent/docs/packages.md), [skills](https://github.com/earendil-works/pi/blob/v0.81.1/packages/coding-agent/docs/skills.md), and [extension lifecycle events](https://github.com/earendil-works/pi/blob/v0.81.1/packages/coding-agent/docs/extensions.md#session-events). -### Deduplication +| Surface | Pi can discover it | A Pi package can install it | Recall's Pi shape | +|---|---:|---:|---| +| Extension | Yes | Yes | The root `recall-memory` package declares `pi/*.ts` | +| Agent Skills | Yes | Yes | The same package declares all nine canonical `agent-skills/*/SKILL.md` files | +| Lifecycle handlers | Yes, as extension code | Yes, inside an extension | Recall subscribes to `before_agent_start` and `session_shutdown` | +| MCP server | Only through an MCP client extension | No | `pi-mcp-adapter` plus `~/.pi/agent/mcp.json` | -A JSON tracker at `~/.agents/Recall/MEMORY/pi-sessions/.extraction_tracker.json` records processed session IDs. It persists across extension restarts to avoid re-extracting sessions. +Extensions, skills, and their lifecycle handlers can therefore travel together as a real Pi package. -## Memory Injection +MCP cannot join that unit because `mcp` is not a Pi package resource. -Before each agent turn, the `before_agent_start` hook runs `recall search ` and appends results to the system prompt. This is per-turn injection, not persistent session state — the context is re-fetched on every turn. Humans can narrow manual keyword searches with `-t ` or softly prefer a type with `--bias-type
`. +## Install -The hook lives in `~/.pi/agent/extensions/RecallPreCompact.ts` (named for consistency with the OpenCode equivalent, though it handles injection rather than compaction context). +If Recall is already installed as a global package, run one integration command: -Injection flow: +```bash +recall install --yes ``` -before_agent_start → extract project/task keywords from input - → recall search --limit 5 - → append to system prompt as "## Persistent Memory" - → agent runs with context + +For a one-shot package install with Bun already on `PATH`, use: + +```bash +npx --package=recall-memory recall install --yes ``` -Manual targeted search examples: +From a source checkout, the equivalent one-step command is: + ```bash -recall search "database choice" -t decisions # decisions only -recall search "database choice" --bias-type decisions # decisions first, broader context preserved +./install.sh --yes ``` -If `recall` is unavailable or times out (5s limit), the hook skips silently. +The installer performs these separate operations in order: -## File Locations +1. Installs or preserves `pi-mcp-adapter` in Pi's configured home. +2. Registers the Recall root as a native Pi package for the two extensions and nine skills. +3. Writes only the owned `recall-memory` entry in Pi's `mcp.json`. +4. Installs the Recall guide and its marked `AGENTS.md` pointer. +5. Removes old Recall extension and skill symlinks after the package is working, backing up customized or foreign collisions first. -| File | Purpose | -|------|---------| -| `~/.pi/agent/extensions/RecallExtract.ts` | Session extraction hook | -| `~/.pi/agent/extensions/RecallPreCompact.ts` | Memory injection hook | -| `~/.pi/agent/mcp.json` | MCP server registration | -| `~/.pi/agent/Recall_GUIDE.md` | Agent guide (installed from `FOR_PI.md` with Pi-specific tool names) | -| `~/.pi/agent/AGENTS.md` | Syntax-free pointer to the installed Recall guide and live MCP schemas | -| `~/.agents/Recall/MEMORY/pi-sessions/` | Drop directory for extracted session markdown | -| `~/.agents/Recall/recall.db` | Shared SQLite DB (same as Claude Code and OpenCode) | +Re-running the command converges on the same state. -## Tool Name Mapping +An existing adapter is preserved if npm is temporarily unavailable. -Pi uses `pi-mcp-adapter` in server prefix mode, which prepends `recall-memory_` to all tool names: +A fresh install fails instead of claiming success when the adapter or native Recall package cannot be installed. -| Base Tool | Pi Tool Name | Description | -|-----------|-------------|-------------| -| `memory_search` | `recall-memory_memory_search` | FTS5 keyword search; supports `table` hard filters and `bias_type` soft boosts | -| `memory_hybrid_search` | `recall-memory_memory_hybrid_search` | FTS5 + vector search | -| `memory_recall` | `recall-memory_memory_recall` | Contextual recall | -| `memory_add` | `recall-memory_memory_add` | Add memory entry | -| `context_for_agent` | `recall-memory_context_for_agent` | Prepare context for subagent | -| `memory_stats` | `recall-memory_memory_stats` | DB statistics | -| `loa_show` | `recall-memory_loa_show` | Show level of abstraction | +Do not use `pi install npm:recall-memory` as the full installation command. -This is the same prefix pattern as OpenCode. The `Recall_GUIDE.md` installed at `~/.pi/agent/` documents these prefixed names. +That command can load Recall's extensions and skills, but it cannot register `recall-memory` with an MCP client or ensure the `recall` and `recall-mcp` executables are on `PATH`. -## AGENTS.md Pointer +## What MCP covers -The installer adds a marked, syntax-free pointer to `~/.pi/agent/AGENTS.md`. -The workflow and Pi-prefixed tool names stay canonical in `Recall_GUIDE.md` -and the live MCP schemas: +MCP is the primary cross-host operation seam. -```markdown -## MEMORY - +The separately configured `recall-memory` server exposes all nine Recall operations: -Read and follow the canonical Recall guide at `~/.pi/agent/Recall_GUIDE.md`. Use the live `recall-memory` MCP schemas as the source of truth for tool call shapes. -``` +- `memory_search` +- `memory_hybrid_search` +- `memory_recall` +- `context_for_agent` +- `memory_add` +- `memory_stats` +- `loa_show` +- `memory_dump` +- `decision_update` + +Recall enables the adapter's direct-tool mode by default, which exposes them with the normalized `recall_memory_` server prefix inside Pi. + +An existing explicit `directTools` preference is preserved during reinstall. + +On the first Pi session after a new server is configured, the adapter exposes its `mcp` proxy while it builds the metadata cache. + +The direct tool names register on the next Pi reload or restart; all nine operations remain reachable through the proxy during that warm-up session. + +The server and CLI use the same SQLite store. + +The installer writes the resolved `RECALL_DB_PATH` into the MCP entry so a custom database remains explicit. + +## Lifecycle behavior + +Pi's native extension API provides real lifecycle events, so Recall does not need to infer them from transcript files. + +`RecallPreCompact.ts` subscribes to `before_agent_start` and uses Pi's asynchronous `pi.exec()` API to fetch relevant Recall context before the turn. + +`RecallExtract.ts` subscribes to `session_shutdown` and obtains the active JSONL path from `ctx.sessionManager.getSessionFile()`. + +It linearizes Pi's active tree branch and writes markdown under `$RECALL_HOME/MEMORY/pi-sessions/` for the existing batch extraction pipeline. + +Pi also exposes compaction events, but Recall does not claim a separate pre-compaction flush. + +Pi retains the session tree in its JSONL, so shutdown capture reads the supported persisted session instead of duplicating partial snapshots. + +Ephemeral `--no-session` runs have no session file and are not auto-captured. + +## Installed state + +The default paths are: + +| Path | Owner and purpose | +|---|---| +| `~/.pi/agent/settings.json` | Pi package registrations for Recall and `pi-mcp-adapter` | +| `~/.pi/agent/mcp.json` | Adapter configuration containing Recall's owned server entry | +| `~/.pi/agent/Recall_GUIDE.md` | Recall's Pi usage guide | +| `~/.pi/agent/AGENTS.md` | Marked pointer to the guide and live MCP schemas | +| `~/.agents/Recall/MEMORY/pi-sessions/` | Pi shutdown-capture drop directory | +| `~/.agents/Recall/recall.db` | Shared Recall database | + +Pi honors `PI_CODING_AGENT_DIR` for a non-default Pi home. + +Recall's lifecycle scripts derive `PI_CONFIG_DIR` from that variable and pass it back to every `pi install`, `pi list`, and `pi remove` operation. + +## What Pi cannot do that Codex and Claude can + +Pi cannot declare Recall's MCP server in the same package manifest that declares its extensions and skills. + +Codex's native Recall plugin can carry `.mcp.json` and skills in its `.codex-plugin` bundle. + +Claude's plugin system likewise has a host-owned plugin manifest and component model. + +Pi's `package.json#pi` manifest has only extensions, skills, prompts, and themes. + +Therefore Recall on Pi has no single installable artifact with the same completeness as those plugin bundles. -Install and update refresh marked sections and migrate the normalized exact -legacy-generated Pi body. Unmarked customized/external `## MEMORY` sections -are preserved; marked sections remain Recall-owned, so remove the marker before -taking external ownership. +The Recall installer is one human step, but the resulting state remains visibly separate: a Pi package, an MCP adapter package, an MCP config entry, and Recall-owned guide files. -## Database +Recall does not introduce a cross-host bundle abstraction to hide that difference. -Pi shares `~/.agents/Recall/recall.db` with Claude Code and OpenCode. WAL mode handles concurrent access. Pi session extractions go through the same markdown pipeline as OpenCode — they appear in flat memory files (DISTILLED.md, SESSION_INDEX.json, DECISIONS.log, etc.), labeled `pi/`. They do not insert into the SQLite `sessions` table with a `source` field (that column is only populated via `recall import` or `recall dump`, not the drop-dir extraction pipeline). +Pi also does not make MCP available when extensions are disabled, and it cannot auto-capture ephemeral sessions that have no persisted session file. -No schema changes beyond what OpenCode already added (the `source` column in schema v3). +## Development verification -## Open Questions +`bun run test:e2e:pi-integration` builds Recall and exercises the current installed Pi CLI in an isolated `PI_CODING_AGENT_DIR` with a disposable `RECALL_HOME` and `RECALL_DB_PATH`. -1. **`ctx.sessionManager` verification needed**: The extraction hook assumes Pi exposes session state via `ctx.sessionManager` or equivalent. The actual API shape needs verification against Pi's extension docs before implementation. +The test installs the separate Pi resources twice, holds a first Pi RPC session open for the adapter's documented metadata warm-up, then verifies all nine skills and direct MCP tools after restart. -2. **Pi JSONL message structure**: The tree JSONL format (branching structure, node schema, active-branch pointer) needs real-world verification. The linearization logic in `RecallExtract.ts` is written against an assumed structure and must be validated against actual Pi session files. +It also loads a synthetic persisted Pi session through the real CLI, checks shutdown capture, and proves the production database metadata did not change. -3. **`before_agent_start` hook availability**: Pi's extension hook lifecycle needs confirmation that a pre-turn hook exists with system prompt mutation capability. +The focused unit coverage is in `tests/pi-integration.test.ts`. diff --git a/docs/agent-skills.md b/docs/agent-skills.md index 5f2599b..69cca1d 100644 --- a/docs/agent-skills.md +++ b/docs/agent-skills.md @@ -2,7 +2,7 @@ [Back to README](../README.md) -Recall ships its command surface as [Agent Skills](https://agentskills.io) — one canonical `SKILL.md` per skill for Claude Code (`~/.claude/skills/`), Pi (`~/.pi/agent/skills/`), and omp (`~/.omp/agent/skills/`). Codex receives generated native adapters in `plugins/recall/skills/`; byte-identical skill text is not treated as behavioral equivalence across hosts. In Claude Code, invoke skills as `/recall-`; the model can also trigger most of them itself when the conversation calls for it. +Recall ships its command surface as [Agent Skills](https://agentskills.io) — one canonical `SKILL.md` per skill under `agent-skills/`. Claude Code and omp receive per-file links in their skill homes; Pi discovers the canonicals directly through Recall's native package manifest. Codex receives generated native adapters in `plugins/recall/skills/`; byte-identical skill text is not treated as behavioral equivalence across hosts. In Claude Code, invoke skills as `/recall-`; the model can also trigger most of them itself when the conversation calls for it. > **Migrating from `/Recall:*` slash commands?** The old namespaced slash commands were retired in favor of these skills (issue #228) — same bodies, one namespace across all hosts. `install.sh` / `update.sh` remove the stale `~/.claude/commands/Recall/` symlinks automatically. See [Upgrading](upgrading.md). diff --git a/docs/architecture.md b/docs/architecture.md index 79d07c1..84032af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,9 +4,10 @@ ## File Layout -Canonical Recall files live under `~/.agents/Recall/`. Platform homes -(`~/.claude/`, `~/.config/opencode/`, `~/.pi/agent/`) contain per-file -symlinks back to those canonicals. +Canonical Recall runtime files live under `~/.agents/Recall/`. Claude Code and +OpenCode platform homes contain per-file symlinks back to those canonicals. +Pi instead records Recall's source as a native package and keeps its separately +installed MCP adapter/configuration under `~/.pi/agent/`. ``` ~/.agents/Recall/ # Recall install root (canonical files) @@ -24,8 +25,7 @@ symlinks back to those canonicals. │ ├── plugins/ # OpenCode plugin canonicals │ └── Recall_GUIDE.md # Guide for OpenCode ├── pi/ -│ ├── extensions/ # Pi extension canonicals -│ └── Recall_GUIDE.md # Guide for Pi +│ └── Recall_GUIDE.md # Canonical guide linked into Pi home ├── MEMORY/ # Migrated user-authored MEMORY files │ ├── identity.md # L0 identity (user-authored via recall onboard) │ └── DISTILLED.md # All extracted session summaries (full archive) @@ -73,6 +73,12 @@ Its `.mcp.json` registers `recall-memory`, and `scripts/build-codex-plugin.ts` g MCP covers the nine query/write operations but does not define transcript lifecycle events; see [Codex Integration](CODEX_INTEGRATION.md). +Claude Code can install the native plugin in `plugins/recall-claude/` for skills + MCP while the lifecycle installer continues to own hooks and reconciles legacy duplicate surfaces; see [Claude Integration](CLAUDE_INTEGRATION.md). + +Pi discovers the root package's `pi/*.ts` extensions and canonical `agent-skills/*/SKILL.md` files through `package.json#pi`. + +Because Pi packages cannot declare MCP servers, `lib/install-lib.sh` separately installs `pi-mcp-adapter` and merges Recall's owned entry into Pi's `mcp.json`; see [Pi Integration](PI_INTEGRATION.md). + ## Database Tables | Table | Purpose | FTS5 Indexed | @@ -245,6 +251,7 @@ Sourced by all three scripts. Key functions: | `recall_link_global` | Hardened `bun link` flow: bun link → verify bin symlinks → `npm link` fallback → verify → exit 1 with recovery recipe. Catches the silent-no-op case where `bun link` exits 0 but doesn't refresh `~/.bun/bin/recall` / `recall-mcp` (added in 0.7.22) | | `recall_verify_global_link` | Invariant checker: confirms `~/.bun/bin/recall` and `recall-mcp` exist, are symlinks, and resolve to readable targets. Emits an `ls -la` diagnostic block on failure | | `recall_copy_runtime_files` | Copies `hooks/*.ts`, `hooks/lib/*.ts`, `agent-skills/*/SKILL.md`, `FOR_CLAUDE.md` → `Recall_GUIDE.md`, and `extract_prompt.md` (diff-check: writes `.new` on drift rather than overwriting user edits); removes legacy `/Recall:*` slash-command symlinks | +| `recall_install_pi_platform` | Coordinates Pi's separate native package, `pi-mcp-adapter`, owned `mcp.json` entry, guide, and legacy-shadow cleanup; safe to re-run | | `recall_append_memory_section` | Shared Claude/Pi append path: completes an unterminated final line, inserts one blank separator, then writes the generated pointer | | `recall_memory_section_mutate` / `recall_configure_claude_md` | Shared Claude/Pi ownership classifier plus Claude bootstrap entry point. Marked sections and normalized exact legacy-generated bodies are refreshed on install/update and removable on uninstall; unmarked customized/external sections survive. Remove the marker before taking external ownership. A Recall-specific `~/.claude/rules/memory.md` takes precedence during install/update and leaves `CLAUDE.md` unchanged | @@ -258,7 +265,7 @@ BACKUP_BASE="$CLAUDE_DIR/backups/recall" TIMESTAMP="$(date +%Y%m%d_%H%M%S)" BACKUP_DIR="$BACKUP_BASE/$TIMESTAMP" OPENCODE_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode" -PI_CONFIG_DIR="$HOME/.pi/agent" +PI_CONFIG_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}" ``` All use `: "${VAR:=default}"` so an override set *before* `source` diff --git a/docs/installation.md b/docs/installation.md index 08f4464..9f53203 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -269,7 +269,7 @@ cd /path/to/Recall - The `## MEMORY` section in `~/.claude/CLAUDE.md` only if Recall generated it (current ownership marker or a normalized exact match of the complete legacy-generated body); unmarked customized/externally owned sections and the rest of `CLAUDE.md` are preserved; a marked section remains Recall-owned even if its body was edited - `~/.claude/MEMORY/extract_prompt.md` — only if unmodified from source; user-edited versions are preserved - OpenCode MCP entry + plugins + agent (unless `--skip-opencode`) -- Pi MCP entry + extensions + Recall-generated `AGENTS.md` MEMORY section (current marker or normalized exact legacy Pi body); unmarked customized/external Pi sections survive, while marked sections remain Recall-owned (unless `--skip-pi`) +- Recall's native Pi package registration, owned Pi MCP entry, guide link, and Recall-generated `AGENTS.md` MEMORY section (current marker or normalized exact legacy Pi body); legacy Recall extension/skill links are removed, while unrelated Pi packages and `pi-mcp-adapter` remain (unless `--skip-pi`) - `bun unlink` (removes `recall` and `recall-mcp` from your PATH) ### What is preserved (default) diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 64edbe2..1282403 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -32,7 +32,7 @@ The `recall update` / `recall uninstall` / `recall install` subcommands simply f ## Fresh install -Recall installs to a canonical root at `~/.agents/Recall/` and symlinks into each detected agent's home (Claude Code, OpenCode, Pi). Pick the on-ramp that matches how you got Recall: +Recall installs runtime state under `~/.agents/Recall/`, links host-owned files where appropriate, and registers native host packages where available. On Pi, one Recall command coordinates a native package plus the separately discovered MCP adapter/configuration. Pick the on-ramp that matches how you got Recall: - **npm (recommended):** `bun install -g recall-memory` puts the `recall` / `recall-mcp` binaries on your PATH, then `recall install` runs the canonical setup — MCP registration, hooks, agent skills, guides — for every detected agent. Prefer `bun install -g` over `npm install -g`: the `#!/usr/bin/env bun` shebang needs Bun on PATH, and nvm/fnm shells can hide it. - **npx (one-shot):** `npx --package=recall-memory recall install` — same canonical setup, no global install. Bun must still be on PATH. diff --git a/docs/upgrading.md b/docs/upgrading.md index 3102525..d51db59 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -56,6 +56,9 @@ cd /path/to/Recall unmarked customized/external sections are preserved, while marked sections remain Recall-owned. A Recall-specific `~/.claude/rules/memory.md` leaves `CLAUDE.md` unchanged. + For Pi, refresh means re-registering the native Recall package and separately + converging `pi-mcp-adapter`, `mcp.json`, and the guide; it does not create a + cross-host bundle. `extract_prompt.md` gets a drift check — if you edited it, the new version lands at `extract_prompt.md.new` and your edits are preserved. 7. Forces re-registration of all four hooks (RecallExtract, diff --git a/install.sh b/install.sh index 0f3ffe4..30b4c73 100755 --- a/install.sh +++ b/install.sh @@ -223,7 +223,7 @@ do_install() { fi if [[ "$PI_DETECTED" == "true" ]]; then - _step "Pi" "Configuring Pi integration" + _step "Pi" "Configuring Pi package and MCP integration" recall_install_pi_platform fi @@ -291,7 +291,7 @@ do_install() { echo "Platforms configured:" [[ "$CLAUDE_CODE_DETECTED" == "true" ]] && echo " ✓ Claude Code (MCP + hooks + CLAUDE.md)" [[ "$OPENCODE_DETECTED" == "true" ]] && echo " ✓ OpenCode (MCP + plugins + agent)" - [[ "$PI_DETECTED" == "true" ]] && echo " ✓ Pi (MCP via adapter + extensions + AGENTS.md)" + [[ "$PI_DETECTED" == "true" ]] && echo " ✓ Pi (native package + separate MCP adapter/config + AGENTS.md)" echo "" echo "Next steps:" local step=1 @@ -304,7 +304,7 @@ do_install() { step=$((step + 1)) fi if [[ "$PI_DETECTED" == "true" ]]; then - echo " $step. Restart Pi to load MCP adapter and extensions" + echo " $step. Restart Pi to load the Recall package and MCP adapter" step=$((step + 1)) fi echo " $step. Test: recall stats" diff --git a/lib/install-lib.sh b/lib/install-lib.sh index 0c40a7a..d3ab2ba 100644 --- a/lib/install-lib.sh +++ b/lib/install-lib.sh @@ -34,8 +34,9 @@ fi : "${CLAUDE_DIR:=$HOME/.claude}" # Recall install root — canonical home for hooks, commands, guides, the DB, -# and backups. Platform homes (~/.claude/, ~/.config/opencode/, ~/.pi/agent/) -# receive per-file symlinks back here. Override via $RECALL_DIR to relocate. +# and backups. Claude/OpenCode homes receive per-file symlinks back here; Pi's +# extensions + skills load through its native package manifest. Override via +# $RECALL_DIR to relocate. : "${RECALL_DIR:=$HOME/.agents/Recall}" : "${RECALL_SHARED_DIR:=$RECALL_DIR/shared}" : "${RECALL_SHARED_HOOKS_DIR:=$RECALL_SHARED_DIR/hooks}" @@ -46,7 +47,6 @@ fi : "${RECALL_OPENCODE_ROOT:=$RECALL_DIR/opencode}" : "${RECALL_OPENCODE_PLUGINS_DIR:=$RECALL_OPENCODE_ROOT/plugins}" : "${RECALL_PI_ROOT:=$RECALL_DIR/pi}" -: "${RECALL_PI_EXTENSIONS_DIR:=$RECALL_PI_ROOT/extensions}" : "${RECALL_MEMORY_DIR:=$RECALL_DIR/MEMORY}" # Completion sentinel — written at install start, removed only after the @@ -95,7 +95,7 @@ fi # Platform configuration : "${OPENCODE_CONFIG_DIR:=${XDG_CONFIG_HOME:-$HOME/.config}/opencode}" -: "${PI_CONFIG_DIR:=$HOME/.pi/agent}" +: "${PI_CONFIG_DIR:=${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}}" # omp is skills-only (no MCP/hooks integration exists for it yet) — its # config dir is used solely as the target for `~/.omp/agent/skills`. : "${OMP_CONFIG_DIR:=$HOME/.omp/agent}" @@ -987,10 +987,10 @@ recall_do_restore() { # ── Install root + symlink helpers ─────────────────────────────────────────── # -# Recall stores canonical files under $RECALL_DIR. Each platform home -# (~/.claude/, ~/.config/opencode/, ~/.pi/agent/) receives PER-FILE symlinks -# back to the canonical files — never directory-level symlinks — so a platform -# directory can mix Recall-owned symlinks with files owned by other tools. +# Recall stores canonical runtime files under $RECALL_DIR. Claude/OpenCode +# homes receive PER-FILE symlinks back to those files; Pi receives only its +# guide link because native package discovery reads extensions + skills from +# the Recall package itself. # # Functions: # recall_create_install_root — mkdir the full $RECALL_DIR tree @@ -1009,7 +1009,6 @@ recall_create_install_root() { "$RECALL_SHARED_HOOKS_LIB_DIR" \ "$RECALL_SHARED_SKILLS_DIR" \ "$RECALL_OPENCODE_PLUGINS_DIR" \ - "$RECALL_PI_EXTENSIONS_DIR" \ "$RECALL_MEMORY_DIR" \ "$BACKUP_BASE" } @@ -1048,7 +1047,7 @@ recall_copy_canonical() { cp "$src" "$dest" } -# Move a regular file (or foreign symlink) at TARGET into the backup tree +# Move a regular file, directory, or foreign symlink at TARGET into the backup tree # preserving its relative path under $HOME, so we never lose user edits. # Used by the collision rule before replacing an existing target with a # Recall-managed symlink. @@ -1371,14 +1370,6 @@ recall_install_claude_skills() { _recall_link_skills_to "$CLAUDE_DIR/skills" } -# Pi skills — canonicals are already refreshed by whichever caller ran -# recall_install_claude_skills / recall_copy_runtime_files first; this only -# adds the Pi-side symlinks. Called from recall_install_pi_platform. -recall_install_pi_skills() { - _recall_copy_skill_files - _recall_link_skills_to "$PI_CONFIG_DIR/skills" -} - # omp integration is skills-only today — no MCP registration, hooks, or guide # exist for omp in this repo. Gated behind OMP_DETECTED like the other # platforms so we never create ~/.omp on a machine that doesn't use it. @@ -2216,7 +2207,7 @@ Read and follow the canonical Recall guide at \`$CLAUDE_DIR/Recall_GUIDE.md\`. U # ── Shared MCP config writer ───────────────────────────────────────────────── # -# _recall_jsonc_merge_mcp_entry FILE PARENT_KEY VALUE_JSON +# _recall_jsonc_merge_mcp_entry FILE PARENT_KEY VALUE_JSON [PRESERVE_KEYS] # # Single hardened JSONC merge used by every platform's MCP registration # (OpenCode's opencode.json under "mcp", Pi's mcp.json under "mcpServers"). @@ -2228,7 +2219,9 @@ Read and follow the canonical Recall guide at \`$CLAUDE_DIR/Recall_GUIDE.md\`. U # FILE absolute path to the config file # PARENT_KEY container key for MCP servers ("mcp" or "mcpServers") # VALUE_JSON JSON object of the owned fields for the recall-memory entry; -# its `environment` (if any) is deep-merged over the existing one +# its `environment` or `env` (if any) is deep-merged over the existing one +# PRESERVE_KEYS optional comma-separated entry keys whose existing explicit +# values win over VALUE_JSON defaults # # Hardening invariants (architect/RedTeam V-1, V-3, V-4): # V-1 Unparseable input → exit non-zero BEFORE any write (file untouched). @@ -2236,13 +2229,14 @@ Read and follow the canonical Recall guide at \`$CLAUDE_DIR/Recall_GUIDE.md\`. U # names); every other key on the entry and every other env var survive. # V-4 PARENT_KEY present but not a plain object → refuse, file unchanged. _recall_jsonc_merge_mcp_entry() { - CONFIG_PATH="$1" PARENT_KEY="$2" ENTRY_JSON="$3" REPO_DIR="$RECALL_REPO_DIR" \ + CONFIG_PATH="$1" PARENT_KEY="$2" ENTRY_JSON="$3" PRESERVE_KEYS="${4:-}" REPO_DIR="$RECALL_REPO_DIR" \ bun -e ' const fs = require("fs"); const path = require("path"); const { parse, modify, applyEdits } = require(process.env.REPO_DIR + "/node_modules/jsonc-parser"); const file = process.env.CONFIG_PATH; const parentKey = process.env.PARENT_KEY; + const preserveKeys = new Set((process.env.PRESERVE_KEYS || "").split(",").filter(Boolean)); const name = path.basename(file); const entry = JSON.parse(process.env.ENTRY_JSON); @@ -2282,13 +2276,22 @@ _recall_jsonc_merge_mcp_entry() { const container = existing[parentKey] || {}; const prevRaw = container["recall-memory"]; const prev = (prevRaw && typeof prevRaw === "object" && !Array.isArray(prevRaw)) ? prevRaw : {}; - const prevEnv = (prev.environment && typeof prev.environment === "object" && !Array.isArray(prev.environment)) ? prev.environment : {}; - const newEnv = (entry.environment && typeof entry.environment === "object" && !Array.isArray(entry.environment)) ? entry.environment : {}; const mergedEntry = { ...prev, - ...entry, - environment: { ...prevEnv, ...newEnv } + ...entry }; + for (const key of preserveKeys) { + if (Object.prototype.hasOwnProperty.call(prev, key)) mergedEntry[key] = prev[key]; + } + // Claude/OpenCode use `environment`; pi-mcp-adapter uses `env`. + // Deep-merge whichever owned field the caller supplied so custom sibling + // variables survive without inventing one normalized cross-host schema. + for (const envKey of ["environment", "env"]) { + if (!Object.prototype.hasOwnProperty.call(entry, envKey)) continue; + const prevEnv = (prev[envKey] && typeof prev[envKey] === "object" && !Array.isArray(prev[envKey])) ? prev[envKey] : {}; + const newEnv = (entry[envKey] && typeof entry[envKey] === "object" && !Array.isArray(entry[envKey])) ? entry[envKey] : {}; + mergedEntry[envKey] = { ...prevEnv, ...newEnv }; + } // In-place edit via jsonc-parser modify/applyEdits: only the // recall-memory entry'"'"'s value slot is rewritten. Surrounding bytes @@ -2296,11 +2299,22 @@ _recall_jsonc_merge_mcp_entry() { // top-of-file leading comments) survive byte-for-byte. The Cycle 1 // preservation tests in tests/{opencode,pi}-integration.test.ts pin // this contract. - const edits = modify(text, [parentKey, "recall-memory"], mergedEntry, { + const hasContainer = Object.prototype.hasOwnProperty.call(existing, parentKey); + const editPath = hasContainer ? [parentKey, "recall-memory"] : [parentKey]; + const editValue = hasContainer ? mergedEntry : { "recall-memory": mergedEntry }; + const edits = modify(text, editPath, editValue, { formattingOptions: { tabSize: 2, insertSpaces: true } }); const newText = applyEdits(text, edits); + // jsonc-parser cannot materialize a missing intermediate object when + // asked to edit [parentKey, child]. Create the parent explicitly above, + // then reject any no-op so a fresh install can never print false success. + if (!hasContainer && (edits.length === 0 || newText === text)) { + console.error("recall: failed to construct " + name + " — merge produced no edit"); + process.exit(1); + } + // V-8: a write failure (read-only file/dir, ENOSPC) must surface as a // non-zero exit. `bun -e` swallows an uncaught synchronous writeFileSync // EACCES (exits 0, prints nothing), so without this guard the caller @@ -2409,12 +2423,99 @@ recall_install_opencode_platform() { recall_install_pi_adapter() { log_info "Ensuring pi-mcp-adapter is installed..." - if pi install npm:pi-mcp-adapter 2>/dev/null; then + if PI_CODING_AGENT_DIR="$PI_CONFIG_DIR" pi install npm:pi-mcp-adapter --no-approve 2>/dev/null; then log_success "pi-mcp-adapter ready" - else - log_warn "Could not install pi-mcp-adapter automatically" - log_warn "Install manually: pi install npm:pi-mcp-adapter" + return 0 + fi + + # Preserve an existing working adapter when a reinstall cannot reach npm. + # A fresh install without the adapter is incomplete and must fail loudly. + if PI_CODING_AGENT_DIR="$PI_CONFIG_DIR" PI_OFFLINE=1 pi list --no-approve 2>/dev/null \ + | grep -Fq 'npm:pi-mcp-adapter'; then + log_warn "Could not refresh pi-mcp-adapter; preserving the installed copy" + return 0 fi + + log_error "Could not install pi-mcp-adapter; Pi has no native MCP client" + log_error "Retry when npm is reachable: PI_CODING_AGENT_DIR=\"$PI_CONFIG_DIR\" pi install npm:pi-mcp-adapter" + return 1 +} + +# Remove Recall resources installed by the pre-package integration only after +# Pi has accepted the native Recall package. Keeping both would make Pi discover +# each extension and skill twice. Recall-managed symlinks can be discarded; +# drifted files and foreign links are moved into the install backup first. +recall_remove_legacy_pi_resources() { + local ext_dir="$PI_CONFIG_DIR/extensions" + local ext target + for ext in RecallExtract.ts RecallPreCompact.ts; do + target="$ext_dir/$ext" + if [[ -L "$target" ]] && [[ "$(readlink "$target")" == "$RECALL_DIR"/* ]]; then + rm -f "$target" + log_info "Removed legacy Pi extension symlink: $target" + elif [[ -e "$target" || -L "$target" ]]; then + _recall_backup_collision "$target" + log_warn "Backed up legacy Pi extension before enabling the package: $target" + fi + done + rmdir "$ext_dir" 2>/dev/null || true + + local skills_src="$RECALL_REPO_DIR/agent-skills" + local skill_src skill_name skill_dir child + if [[ -d "$skills_src" ]]; then + for skill_src in "$skills_src"/*/; do + [[ -d "$skill_src" ]] || continue + skill_name="$(basename "$skill_src")" + skill_dir="$PI_CONFIG_DIR/skills/$skill_name" + [[ -d "$skill_dir" || -L "$skill_dir" ]] || continue + + if [[ -L "$skill_dir" ]]; then + if [[ "$(readlink "$skill_dir")" == "$RECALL_DIR"/* ]]; then + rm -f "$skill_dir" + log_info "Removed legacy Pi skill symlink: $skill_dir" + else + _recall_backup_collision "$skill_dir" + log_warn "Backed up legacy Pi skill link before enabling the package: $skill_dir" + fi + continue + fi + + for child in "$skill_dir"/*; do + [[ -e "$child" || -L "$child" ]] || continue + if [[ -L "$child" ]] && [[ "$(readlink "$child")" == "$RECALL_DIR"/* ]]; then + rm -f "$child" + fi + done + if rmdir "$skill_dir" 2>/dev/null; then + log_info "Removed legacy Pi skill links: $skill_name" + else + _recall_backup_collision "$skill_dir" + log_warn "Backed up customized legacy Pi skill before enabling the package: $skill_dir" + fi + done + fi + rmdir "$PI_CONFIG_DIR/skills" 2>/dev/null || true +} + +# Pi packages can own extensions and skills, but not MCP servers. Register the +# root Recall package for those two native resources; the adapter and mcp.json +# remain deliberately separate steps in recall_install_pi_platform. +recall_install_pi_package() { + log_info "Installing Recall's native Pi extension + skill package..." + if ! PI_CODING_AGENT_DIR="$PI_CONFIG_DIR" pi install "$RECALL_REPO_DIR" --no-approve 2>/dev/null; then + log_error "Pi rejected the Recall package at $RECALL_REPO_DIR" + return 1 + fi + + local package_list + if ! package_list="$(PI_CODING_AGENT_DIR="$PI_CONFIG_DIR" PI_OFFLINE=1 pi list --no-approve 2>/dev/null)" \ + || ! grep -Fq "$RECALL_REPO_DIR" <<<"$package_list"; then + log_error "Pi did not retain Recall's package registration at $RECALL_REPO_DIR" + return 1 + fi + + recall_remove_legacy_pi_resources + log_success "Recall Pi package ready (extensions + skills)" } recall_configure_pi_mcp() { @@ -2435,10 +2536,11 @@ recall_configure_pi_mcp() { command: process.env.MEM_MCP_PATH, args: [], lifecycle: "lazy", - environment: { RECALL_DB_PATH: process.env.DB_PATH_ABS } + directTools: true, + env: { RECALL_DB_PATH: process.env.DB_PATH_ABS } }))')" - if _recall_jsonc_merge_mcp_entry "$config" "mcpServers" "$entry_json"; then + if _recall_jsonc_merge_mcp_entry "$config" "mcpServers" "$entry_json" "directTools"; then log_success "Registered recall-memory in Pi mcp.json" else log_error "Failed to register recall-memory in Pi mcp.json (existing config is invalid or unsupported — left unchanged)" @@ -2446,23 +2548,6 @@ recall_configure_pi_mcp() { fi } -recall_install_pi_extensions() { - local ext_dir="$PI_CONFIG_DIR/extensions" - local src_dir="$RECALL_REPO_DIR/pi" - - recall_create_install_root - mkdir -p "$ext_dir" - - local ext - for ext in RecallExtract.ts RecallPreCompact.ts; do - if [[ -f "$src_dir/$ext" ]]; then - recall_copy_canonical "$src_dir/$ext" "$RECALL_PI_EXTENSIONS_DIR/$ext" - recall_link "$ext_dir/$ext" "$RECALL_PI_EXTENSIONS_DIR/$ext" - log_success "Installed $ext Pi extension" - fi - done -} - recall_install_pi_guide() { recall_create_install_root mkdir -p "$PI_CONFIG_DIR" @@ -2474,10 +2559,11 @@ recall_install_pi_guide() { fi local agents_md="$PI_CONFIG_DIR/AGENTS.md" + local pi_guide_display="${PI_CONFIG_DIR/#$HOME/\~}/Recall_GUIDE.md" local memory_section="## MEMORY -Read and follow the canonical Recall guide at \`~/.pi/agent/Recall_GUIDE.md\`. Use the live \`recall-memory\` MCP schemas as the source of truth for tool call shapes." +Read and follow the canonical Recall guide at \`$pi_guide_display\`. Use the live \`recall-memory\` MCP schemas as the source of truth for tool call shapes." if [[ -f "$agents_md" ]] && grep -q "^## MEMORY" "$agents_md"; then if recall_memory_section_mutate "$agents_md" pi migrate "$memory_section"; then @@ -2497,17 +2583,15 @@ Read and follow the canonical Recall guide at \`~/.pi/agent/Recall_GUIDE.md\`. U log_success "Added MEMORY section to AGENTS.md" } -# Canonical Pi platform install entry point — composes the five Pi surfaces -# (adapter package, MCP config, extensions, guide, skills) in the order -# install.sh has always used. Same scope caveats as -# recall_install_opencode_platform above: closes within-platform drift, not -# broader gap classes. +# Canonical Pi platform install entry point. There is intentionally no single +# bundle spanning these surfaces: Pi's package owns extensions + skills, the +# third-party adapter owns MCP client behavior, Recall owns mcp.json, and the +# guide remains a separately linked context file. recall_install_pi_platform() { recall_install_pi_adapter + recall_install_pi_package recall_configure_pi_mcp - recall_install_pi_extensions recall_install_pi_guide - recall_install_pi_skills } # ── Runtime file refresh (shared between install.sh and update.sh) ─────────── diff --git a/package.json b/package.json index 46cffa0..85963b3 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,14 @@ "README.md", "LICENSE" ], + "pi": { + "extensions": [ + "./pi/*.ts" + ], + "skills": [ + "./agent-skills/*/SKILL.md" + ] + }, "publishConfig": { "access": "public" }, @@ -33,6 +41,7 @@ "lint": "tsc --noEmit -p tsconfig.lint.json", "build:codex-plugin": "bun run scripts/build-codex-plugin.ts", "test:e2e:codex-plugin": "bun run build && bun run scripts/e2e-codex-plugin.ts", + "test:e2e:pi-integration": "bun run build && bun run scripts/e2e-pi-integration.ts", "check:version": "bun run scripts/check-version.ts", "prepublishOnly": "bun run build", "build:claude-plugin": "bun run scripts/build-claude-plugin.ts", @@ -67,7 +76,8 @@ "ai-memory", "sqlite", "fts5", - "mcp" + "mcp", + "pi-package" ], "author": "edheltzel", "license": "MIT", diff --git a/pi/RecallExtract.ts b/pi/RecallExtract.ts index a704e0e..2bfde0f 100644 --- a/pi/RecallExtract.ts +++ b/pi/RecallExtract.ts @@ -5,13 +5,12 @@ // This extension linearizes the active branch into flat markdown, then drops it into // Recall's canonical MEMORY/pi-sessions/ directory for RecallBatchExtract to pick up. // -// VERIFIED APIs: -// - pi.on("session_shutdown", handler) — fires on exit (Ctrl+C, Ctrl+D, SIGTERM) -// - handler receives (_event, ctx) -// - ctx.sessionManager access patterns are UNVERIFIED — fallback scans sessions dir +// VERIFIED AGAINST PI 0.81.1: +// - pi.on("session_shutdown", handler) — fires before quit/reload/new/resume/fork +// - ctx.sessionManager.getSessionFile() — returns the active JSONL or undefined -import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "fs" -import { join } from "path" +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" +import { basename, join } from "path" import { homedir } from "os" /** @@ -38,26 +37,23 @@ function extractTextFromContent(content: any): string { return "" } -const RECALL_HOME = process.env.RECALL_HOME || join(homedir(), ".agents", "Recall") -const DROP_DIR = join(RECALL_HOME, "MEMORY", "pi-sessions") -const TRACKER_PATH = join(DROP_DIR, ".extraction_tracker.json") const MIN_MESSAGE_LENGTH = 10 const MAX_MESSAGE_LENGTH = 4000 const MIN_SESSION_LENGTH = 500 -function loadTracker(): Set { +function loadTracker(trackerPath: string): Set { try { - if (existsSync(TRACKER_PATH)) { - const data = JSON.parse(readFileSync(TRACKER_PATH, "utf-8")) + if (existsSync(trackerPath)) { + const data = JSON.parse(readFileSync(trackerPath, "utf-8")) return new Set(Array.isArray(data) ? data : []) } } catch {} return new Set() } -function saveTracker(tracker: Set): void { +function saveTracker(trackerPath: string, tracker: Set): void { try { - writeFileSync(TRACKER_PATH, JSON.stringify([...tracker]), "utf-8") + writeFileSync(trackerPath, JSON.stringify([...tracker]), "utf-8") } catch {} } @@ -102,34 +98,15 @@ export function linearizeSession(jsonlPath: string): string { } export default function (pi: any) { - mkdirSync(DROP_DIR, { recursive: true }) - const tracker = loadTracker() + const recallHome = process.env.RECALL_HOME || join(homedir(), ".agents", "Recall") + const dropDir = join(recallHome, "MEMORY", "pi-sessions") + const trackerPath = join(dropDir, ".extraction_tracker.json") + mkdirSync(dropDir, { recursive: true }) + const tracker = loadTracker(trackerPath) pi.on("session_shutdown", (_event: any, ctx: any) => { try { - // Get session file path — try ctx.sessionManager first, fallback to scanning - let sessionPath = ctx?.sessionManager?.currentSessionPath - || ctx?.sessionManager?.currentSession?.path - - // Fallback: scan Pi's sessions directory for most recently modified .jsonl - if (!sessionPath || !existsSync(sessionPath)) { - const piSessionsDir = join(homedir(), ".pi", "agent", "sessions") - if (existsSync(piSessionsDir)) { - let latest = { path: "", mtime: 0 } - for (const dir of readdirSync(piSessionsDir)) { - const subdir = join(piSessionsDir, dir) - try { - for (const f of readdirSync(subdir)) { - if (!f.endsWith(".jsonl")) continue - const full = join(subdir, f) - const mt = statSync(full).mtimeMs - if (mt > latest.mtime) latest = { path: full, mtime: mt } - } - } catch {} - } - if (latest.path) sessionPath = latest.path - } - } + const sessionPath = ctx?.sessionManager?.getSessionFile?.() if (!sessionPath || !existsSync(sessionPath)) return if (tracker.has(sessionPath)) return @@ -137,11 +114,10 @@ export default function (pi: any) { const markdown = linearizeSession(sessionPath) if (markdown.length < MIN_SESSION_LENGTH) return // Skip trivial sessions + const fileName = basename(sessionPath).replace(/\.jsonl$/, ".md") || "session.md" + writeFileSync(join(dropDir, fileName), markdown, "utf-8") tracker.add(sessionPath) - saveTracker(tracker) - - const fileName = sessionPath.split("/").pop()?.replace(".jsonl", ".md") || "session.md" - writeFileSync(join(DROP_DIR, fileName), markdown, "utf-8") + saveTracker(trackerPath, tracker) } catch { // Non-fatal — don't crash Pi on extraction failure } diff --git a/pi/RecallPreCompact.ts b/pi/RecallPreCompact.ts index f396984..e63b87c 100644 --- a/pi/RecallPreCompact.ts +++ b/pi/RecallPreCompact.ts @@ -1,27 +1,23 @@ // pi/RecallPreCompact.ts // Pi extension: injects Recall memory into system prompt before every agent turn. // -// VERIFIED APIs: +// VERIFIED AGAINST PI 0.81.1: // - pi.on("before_agent_start", handler) — fires before every agent turn // - handler receives (event, ctx) where event.systemPrompt is current prompt // - Returning { systemPrompt: "..." } replaces it for that turn only -// - execFileSync for recall CLI — standard Node/Bun API - -import { execFileSync } from "child_process" +// - async handlers and pi.exec(command, args, { timeout }) are supported export default function (pi: any) { - pi.on("before_agent_start", (event: any, ctx: any) => { + pi.on("before_agent_start", async (event: any, ctx: any) => { try { const cwd = ctx?.cwd || "" const projectName = cwd.split("/").pop() || "" if (!projectName) return - // execFileSync blocks the event loop, but the 5-second timeout caps the - // worst-case delay. Pi's before_agent_start hook must return synchronously, - // so async execFile is not viable here without Pi's explicit async support. - const context = execFileSync("recall", [ + const result = await pi.exec("recall", [ "search", projectName, "--limit", "5" - ], { encoding: "utf-8", timeout: 5000 }) + ], { signal: ctx?.signal, timeout: 5000 }) + const context = result.code === 0 ? result.stdout : "" if (context.trim()) { return { diff --git a/scripts/e2e-claude-plugin.ts b/scripts/e2e-claude-plugin.ts index 37f2d5c..e0a8efb 100644 --- a/scripts/e2e-claude-plugin.ts +++ b/scripts/e2e-claude-plugin.ts @@ -6,11 +6,12 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { homedir, tmpdir } from 'os'; import { dirname, join } from 'path'; import { spawnSync } from 'child_process'; import { fileURLToPath } from 'url'; +import { assertMetadataUnchanged, assertSafeTestDb, metadata, stringEnv } from './lib/e2e-isolation'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const productionDb = join(homedir(), '.agents', 'Recall', 'recall.db'); @@ -22,23 +23,6 @@ const testHome = join(tempRoot, 'home'); const testClaudeHome = join(testHome, '.claude'); const testBin = join(tempRoot, 'bin'); -interface FileMetadata { - exists: boolean; - size?: number; - mtimeMs?: number; - ino?: number; -} - -function metadata(path: string): FileMetadata { - if (!existsSync(path)) return { exists: false }; - const stat = statSync(path); - return { exists: true, size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino }; -} - -function stringEnv(env: NodeJS.ProcessEnv): Record { - return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)); -} - function runClaude(args: string[], env: Record): string { const result = spawnSync('claude', args, { cwd: repoRoot, env, encoding: 'utf-8' }); if (result.status !== 0) { @@ -77,9 +61,7 @@ async function main(): Promise { for (const dir of [testRecallHome, testHome, testClaudeHome, testBin]) mkdirSync(dir, { recursive: true }); - if (testDb === productionDb || testDb.startsWith(dirname(productionDb) + '/')) { - throw new Error(`unsafe test database path: ${testDb}`); - } + assertSafeTestDb(testDb, productionDb); if (testClaudeHome.startsWith(productionClaudeHome)) { throw new Error(`unsafe test Claude home: ${testClaudeHome}`); } @@ -299,10 +281,7 @@ async function main(): Promise { await client.close(); } - const productionAfter = metadata(productionDb); - if (JSON.stringify(productionAfter) !== JSON.stringify(productionBefore)) { - throw new Error(`production DB metadata changed: before=${JSON.stringify(productionBefore)} after=${JSON.stringify(productionAfter)}`); - } + assertMetadataUnchanged(productionDb, productionBefore); const productionClaudeAfter = metadata(join(productionClaudeHome, 'settings.json')); if (JSON.stringify(productionClaudeAfter) !== JSON.stringify(productionClaudeBefore)) { throw new Error(`production Claude settings changed: before=${JSON.stringify(productionClaudeBefore)} after=${JSON.stringify(productionClaudeAfter)}`); diff --git a/scripts/e2e-codex-plugin.ts b/scripts/e2e-codex-plugin.ts index faf65fc..c8a4ae4 100644 --- a/scripts/e2e-codex-plugin.ts +++ b/scripts/e2e-codex-plugin.ts @@ -2,11 +2,12 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { homedir, tmpdir } from 'os'; import { dirname, join } from 'path'; import { spawnSync } from 'child_process'; import { fileURLToPath } from 'url'; +import { assertMetadataUnchanged, assertSafeTestDb, metadata, stringEnv } from './lib/e2e-isolation'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const productionDb = join(homedir(), '.agents', 'Recall', 'recall.db'); @@ -17,23 +18,6 @@ const testCodexHome = join(tempRoot, 'codex-home'); const testHome = join(tempRoot, 'home'); const testBin = join(tempRoot, 'bin'); -interface FileMetadata { - exists: boolean; - size?: number; - mtimeMs?: number; - ino?: number; -} - -function metadata(path: string): FileMetadata { - if (!existsSync(path)) return { exists: false }; - const stat = statSync(path); - return { exists: true, size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino }; -} - -function stringEnv(env: NodeJS.ProcessEnv): Record { - return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)); -} - function runCodex(args: string[], env: Record): string { const result = spawnSync('codex', args, { cwd: repoRoot, env, encoding: 'utf-8' }); if (result.status !== 0) { @@ -56,9 +40,7 @@ async function main(): Promise { mkdirSync(testHome, { recursive: true }); mkdirSync(testBin, { recursive: true }); - if (testDb === productionDb || testDb.startsWith(dirname(productionDb) + '/')) { - throw new Error(`unsafe test database path: ${testDb}`); - } + assertSafeTestDb(testDb, productionDb); const env = stringEnv({ ...process.env, @@ -189,10 +171,7 @@ async function main(): Promise { await client.close(); } - const productionAfter = metadata(productionDb); - if (JSON.stringify(productionAfter) !== JSON.stringify(productionBefore)) { - throw new Error(`production DB metadata changed: before=${JSON.stringify(productionBefore)} after=${JSON.stringify(productionAfter)}`); - } + assertMetadataUnchanged(productionDb, productionBefore); console.log('isolation.production_db_unchanged=true'); console.log('e2e.status=PASS'); } diff --git a/scripts/e2e-pi-integration.ts b/scripts/e2e-pi-integration.ts new file mode 100644 index 0000000..a9aa949 --- /dev/null +++ b/scripts/e2e-pi-integration.ts @@ -0,0 +1,312 @@ +#!/usr/bin/env bun + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'fs'; +import { homedir, tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { spawn, spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { assertMetadataUnchanged, assertSafeTestDb, metadata, stringEnv } from './lib/e2e-isolation'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const productionDb = join(homedir(), '.agents', 'Recall', 'recall.db'); +const tempRoot = mkdtempSync(join(tmpdir(), 'recall-pi-integration-e2e-')); +const testHome = join(tempRoot, 'home'); +const testPiHome = join(tempRoot, 'pi-agent'); +const testRecallHome = join(tempRoot, 'recall-home'); +const testDb = join(tempRoot, 'recall-test.db'); +const testBin = join(tempRoot, 'bin'); +const probeOutput = join(tempRoot, 'pi-tools.json'); + +const mcpTools = [ + 'context_for_agent', + 'decision_update', + 'loa_show', + 'memory_add', + 'memory_dump', + 'memory_hybrid_search', + 'memory_recall', + 'memory_search', + 'memory_stats', +]; + +function run(command: string, args: string[], env: Record, input?: string, timeout = 60_000): string { + const result = spawnSync(command, args, { + cwd: repoRoot, + env, + encoding: 'utf-8', + input, + timeout, + detached: command === 'pi', + }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed (${result.status})\n${result.stdout}\n${result.stderr}`); + } + if (process.env.RECALL_E2E_DEBUG === '1' && result.stderr.trim()) { + console.error(`${command} stderr:\n${result.stderr}`); + } + return result.stdout; +} + +async function runPiRpcUntil( + args: string[], + env: Record, + input: string, + ready: (stdout: string) => boolean, +): Promise { + const child = spawn('pi', args, { + cwd: repoRoot, + env, + detached: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null; + child.stdout.setEncoding('utf-8'); + child.stderr.setEncoding('utf-8'); + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { + child.once('exit', (code, signal) => { + exited = { code, signal }; + resolve(exited); + }); + }); + + child.stdin.write(input); + const deadline = Date.now() + 30_000; + while (!ready(stdout)) { + if (exited) { + throw new Error(`pi ${args.join(' ')} exited before readiness (${exited.code}, ${exited.signal})\n${stdout}\n${stderr}`); + } + if (Date.now() >= deadline) { + child.kill('SIGTERM'); + await exitPromise; + throw new Error(`pi ${args.join(' ')} timed out waiting for readiness\n${stdout}\n${stderr}`); + } + await Bun.sleep(100); + } + + child.stdin.end(); + const result = await exitPromise; + if (result.code !== 0) { + throw new Error(`pi ${args.join(' ')} failed (${result.code}, ${result.signal})\n${stdout}\n${stderr}`); + } + if (process.env.RECALL_E2E_DEBUG === '1' && stderr.trim()) { + console.error(`pi stderr:\n${stderr}`); + } + return stdout; +} + +function cachedRecallToolCount(): number { + const cachePath = join(testPiHome, 'mcp-cache.json'); + if (!existsSync(cachePath)) return 0; + try { + const cache = JSON.parse(readFileSync(cachePath, 'utf-8')); + return cache.servers?.['recall-memory']?.tools?.length ?? 0; + } catch { + return 0; + } +} + +function installPiSurfaces(env: Record): void { + run( + 'bash', + ['-c', 'source "$1"; recall_create_install_root; recall_install_pi_platform', '_', join(repoRoot, 'lib', 'install-lib.sh')], + env, + undefined, + 180_000, + ); +} + +function writeSyntheticSession(path: string): void { + const timestamp = '2026-07-22T12:00:00.000Z'; + const entries = [ + { type: 'session', version: 3, id: 'recall-pi-e2e', timestamp, cwd: repoRoot }, + { + type: 'message', + id: '11111111', + parentId: null, + timestamp, + message: { role: 'user', content: `Verify Recall's native Pi package in isolation. ${'x'.repeat(320)}`, timestamp: 1 }, + }, + { + type: 'message', + id: '22222222', + parentId: '11111111', + timestamp, + message: { role: 'user', content: `Confirm lifecycle capture uses Pi's supported session API. ${'y'.repeat(320)}`, timestamp: 2 }, + }, + ]; + writeFileSync(path, entries.map(entry => JSON.stringify(entry)).join('\n') + '\n'); +} + +async function main(): Promise { + const productionBefore = metadata(productionDb); + assertSafeTestDb(testDb, productionDb); + + mkdirSync(testHome, { recursive: true }); + mkdirSync(testPiHome, { recursive: true }); + mkdirSync(testRecallHome, { recursive: true }); + mkdirSync(testBin, { recursive: true }); + + const env = stringEnv({ + ...process.env, + HOME: testHome, + PI_CODING_AGENT_DIR: testPiHome, + PI_CONFIG_DIR: testPiHome, + RECALL_DIR: testRecallHome, + RECALL_HOME: testRecallHome, + RECALL_DB_PATH: testDb, + RECALL_REPO_DIR: repoRoot, + RECALL_SKIP_LEGACY_DATA_MIGRATIONS: '1', + BACKUP_BASE: join(tempRoot, 'backups'), + BACKUP_DIR: join(tempRoot, 'backups', 'e2e'), + NO_COLOR: '1', + RECALL_PI_PROBE: probeOutput, + PATH: `${testBin}:${process.env.PATH || ''}`, + }); + + console.log(`isolation.pi_home=${testPiHome}`); + console.log(`isolation.test_db=${testDb}`); + console.log(`isolation.production_db=${productionDb}`); + console.log(`isolation.production_before=${JSON.stringify(productionBefore)}`); + console.log('isolation.production_db_opened=false'); + + const initOutput = run('bun', ['run', 'src/index.ts', 'init'], env); + if (!existsSync(testDb)) throw new Error(`test DB was not created\n${initOutput}`); + + writeFileSync( + join(testBin, 'recall-mcp'), + `#!/bin/sh\nexec bun ${JSON.stringify(join(repoRoot, 'dist', 'mcp-server.js'))} "$@"\n`, + { mode: 0o755 }, + ); + writeFileSync( + join(testBin, 'recall'), + `#!/bin/sh\nexec bun run ${JSON.stringify(join(repoRoot, 'src', 'index.ts'))} "$@"\n`, + { mode: 0o755 }, + ); + + // Seed the exact pre-package shape so the live installer must remove it + // without leaving duplicate extension or skill discovery paths. + const legacyExtensionCanonical = join(testRecallHome, 'pi', 'extensions', 'RecallExtract.ts'); + const legacySkillCanonical = join(testRecallHome, 'shared', 'skills', 'recall-add', 'SKILL.md'); + mkdirSync(dirname(legacyExtensionCanonical), { recursive: true }); + mkdirSync(dirname(legacySkillCanonical), { recursive: true }); + mkdirSync(join(testPiHome, 'extensions'), { recursive: true }); + mkdirSync(join(testPiHome, 'skills', 'recall-add'), { recursive: true }); + writeFileSync(legacyExtensionCanonical, '// legacy Recall extension\n'); + writeFileSync(legacySkillCanonical, '# legacy Recall skill\n'); + symlinkSync(legacyExtensionCanonical, join(testPiHome, 'extensions', 'RecallExtract.ts')); + symlinkSync(legacySkillCanonical, join(testPiHome, 'skills', 'recall-add', 'SKILL.md')); + + console.log(`pi.version=${run('pi', ['--version'], env).trim()}`); + installPiSurfaces(env); + installPiSurfaces(env); + + if (existsSync(join(testPiHome, 'extensions', 'RecallExtract.ts'))) { + throw new Error('legacy Recall extension still shadows the native Pi package'); + } + if (existsSync(join(testPiHome, 'skills', 'recall-add', 'SKILL.md'))) { + throw new Error('legacy Recall skill still shadows the native Pi package'); + } + + const packageList = run('pi', ['list', '--no-approve'], { ...env, PI_OFFLINE: '1' }); + if (!packageList.includes('npm:pi-mcp-adapter') || !packageList.includes(repoRoot)) { + throw new Error(`Pi package list is incomplete:\n${packageList}`); + } + const piSettings = JSON.parse(readFileSync(join(testPiHome, 'settings.json'), 'utf-8')); + if (piSettings.packages?.length !== 2 || piSettings.packages.filter((source: string) => source === 'npm:pi-mcp-adapter').length !== 1) { + throw new Error(`Pi package settings did not converge: ${JSON.stringify(piSettings)}`); + } + + const mcpConfig = JSON.parse(readFileSync(join(testPiHome, 'mcp.json'), 'utf-8')); + const recallServer = mcpConfig.mcpServers?.['recall-memory']; + if (recallServer?.env?.RECALL_DB_PATH !== testDb || recallServer?.directTools !== true) { + throw new Error(`unexpected Pi MCP registration: ${JSON.stringify(recallServer)}`); + } + console.log('pi.install_idempotent=true'); + console.log('pi.legacy_shadows_removed=true'); + + const probeExtension = join(tempRoot, 'probe-tools.ts'); + writeFileSync(probeExtension, ` +import { writeFileSync } from "node:fs"; +export default function (pi) { + pi.on("session_start", () => { + const names = pi.getAllTools().map(tool => tool.name).sort(); + writeFileSync(process.env.RECALL_PI_PROBE, JSON.stringify(names)); + }); +} +`); + + const sessionPath = join(tempRoot, 'pi-session.jsonl'); + writeSyntheticSession(sessionPath); + + // pi-mcp-adapter bootstraps metadata for newly configured direct tools on + // the first session and registers those cached tools on the next startup. + await runPiRpcUntil( + ['--mode', 'rpc', '--no-session', '--no-builtin-tools'], + { ...env, PI_OFFLINE: '1' }, + '{"id":"warmup","type":"get_state"}\n', + () => cachedRecallToolCount() === mcpTools.length, + ); + const rpcOutput = await runPiRpcUntil( + ['--mode', 'rpc', '--session', sessionPath, '--no-builtin-tools', '--extension', probeExtension], + { ...env, PI_OFFLINE: '1' }, + '{"id":"commands","type":"get_commands"}\n', + stdout => existsSync(probeOutput) && stdout.split('\n').some(line => line.includes('"id":"commands"')), + ); + const response = rpcOutput + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)) + .find(message => message.id === 'commands'); + const skillNames = response?.data?.commands + ?.filter((command: { source?: string }) => command.source === 'skill') + .map((command: { name: string }) => command.name) + .sort(); + const expectedSkills = [ + 'skill:recall-add', + 'skill:recall-doctor', + 'skill:recall-dump', + 'skill:recall-loa', + 'skill:recall-recent', + 'skill:recall-scout', + 'skill:recall-search', + 'skill:recall-stats', + 'skill:recall-update', + ]; + if (JSON.stringify(skillNames) !== JSON.stringify(expectedSkills)) { + throw new Error(`unexpected Pi skills: ${JSON.stringify(skillNames)}`); + } + + const piTools = JSON.parse(readFileSync(probeOutput, 'utf-8')) as string[]; + const expectedPiTools = mcpTools.map(name => `recall_memory_${name}`).sort(); + const actualPiTools = piTools.filter(name => name.startsWith('recall_memory_')).sort(); + if (JSON.stringify(actualPiTools) !== JSON.stringify(expectedPiTools)) { + throw new Error(`unexpected Recall tools in Pi: ${JSON.stringify(actualPiTools)}; all=${JSON.stringify(piTools)}`); + } + console.log(`pi.skills_verified=${skillNames.length}`); + console.log(`pi.mcp_tools_verified=${actualPiTools.length}`); + + const captured = join(testRecallHome, 'MEMORY', 'pi-sessions', 'pi-session.md'); + if (!readFileSync(captured, 'utf-8').includes("Verify Recall's native Pi package")) { + throw new Error('Pi session_shutdown lifecycle capture did not write the synthetic session'); + } + console.log('pi.lifecycle_capture_verified=true'); + + assertMetadataUnchanged(productionDb, productionBefore); + console.log(`isolation.production_after=${JSON.stringify(metadata(productionDb))}`); + console.log('isolation.production_db_unchanged=true'); + console.log('e2e.status=PASS'); +} + +try { + await main(); +} finally { + if (process.env.RECALL_E2E_KEEP === '1') { + console.error(`isolation.temp_root_preserved=${tempRoot}`); + } else { + rmSync(tempRoot, { recursive: true, force: true }); + } +} diff --git a/scripts/lib/e2e-isolation.ts b/scripts/lib/e2e-isolation.ts new file mode 100644 index 0000000..b33fbed --- /dev/null +++ b/scripts/lib/e2e-isolation.ts @@ -0,0 +1,33 @@ +import { existsSync, statSync } from 'fs'; +import { dirname } from 'path'; + +export interface FileMetadata { + exists: boolean; + size?: number; + mtimeMs?: number; + ino?: number; +} + +export function metadata(path: string): FileMetadata { + if (!existsSync(path)) return { exists: false }; + const stat = statSync(path); + return { exists: true, size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino }; +} + +export function stringEnv(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)); +} + +export function assertSafeTestDb(testDb: string, productionDb: string): void { + const productionDir = dirname(productionDb); + if (testDb === productionDb || testDb.startsWith(`${productionDir}/`)) { + throw new Error(`unsafe test database path: ${testDb}`); + } +} + +export function assertMetadataUnchanged(path: string, before: FileMetadata): void { + const after = metadata(path); + if (JSON.stringify(after) !== JSON.stringify(before)) { + throw new Error(`production DB metadata changed: before=${JSON.stringify(before)} after=${JSON.stringify(after)}`); + } +} diff --git a/src/hosts/pi.ts b/src/hosts/pi.ts index e1ecb2c..825dc09 100644 --- a/src/hosts/pi.ts +++ b/src/hosts/pi.ts @@ -24,7 +24,7 @@ export const piHost: NativeHostAdapter = { return [{ host: 'pi', path: paths.mcpSettings, - envPath: ['mcpServers', 'recall-memory', 'environment'], + envPath: ['mcpServers', 'recall-memory', 'env'], format: 'json', }]; }, diff --git a/tests/AGENTS.md b/tests/AGENTS.md index fb5478c..6b342df 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -23,6 +23,7 @@ Automated coverage for the CLI, MCP server, hooks, data layer, libraries, instal - Mirror the source layout: a test for `src/commands/foo.ts` goes in `tests/commands/`; a hook test in `tests/hooks/`. - `scripts/e2e-codex-plugin.ts` owns the isolated current-CLI verification for the native Codex marketplace, plugin install, and all nine MCP tools. - `scripts/e2e-claude-plugin.ts` does the same for Claude, and additionally seeds a legacy lifecycle install to prove the migration removes the duplicate skill symlinks and MCP registration idempotently. It must isolate the Claude home too (`HOME` + `CLAUDE_DIR`), not just the database, and assert both were left unchanged. +- `scripts/e2e-pi-integration.ts` owns the isolated current-CLI verification for Pi's separate package, MCP adapter/config, lifecycle capture, and all nine skills/tools. ## Verification diff --git a/tests/hosts/native-hosts.test.ts b/tests/hosts/native-hosts.test.ts index 8ddc5f6..19614e9 100644 --- a/tests/hosts/native-hosts.test.ts +++ b/tests/hosts/native-hosts.test.ts @@ -15,6 +15,9 @@ describe('native host boundaries', () => { expect(targets.find(target => target.host === 'opencode')?.envPath).toEqual([ 'mcp', 'recall-memory', 'environment', ]); + expect(targets.find(target => target.host === 'pi')?.envPath).toEqual([ + 'mcpServers', 'recall-memory', 'env', + ]); expect(claudePaths('/test-home').projects).toBe('/test-home/.claude/projects'); }); diff --git a/tests/install/skills.test.ts b/tests/install/skills.test.ts index e79bf21..a718064 100644 --- a/tests/install/skills.test.ts +++ b/tests/install/skills.test.ts @@ -1,8 +1,8 @@ // Agent Skills (agent-skills//SKILL.md) are the single command surface // (#228 — the old /Recall:* slash commands are gone): canonical copy under -// $RECALL_SHARED_SKILLS_DIR//, per-file symlinks into each detected -// platform's skills directory (~/.claude/skills, ~/.pi/agent/skills, -// ~/.omp/agent/skills). +// $RECALL_SHARED_SKILLS_DIR//, per-file symlinks into Claude Code and +// omp. Pi discovers the canonical sources through the root package manifest; +// that native-package contract is covered by tests/pi-integration.test.ts. // // Install-side tests drive lib/install-lib.sh directly against a // tmpdir-scoped HOME/CLAUDE_DIR/RECALL_DIR/RECALL_REPO_DIR, mirroring @@ -38,7 +38,6 @@ describe('Agent Skills install (lib/install-lib.sh)', () => { let claudeDir: string; let recallDir: string; let fakeRepo: string; - let piConfigDir: string; let ompConfigDir: string; let driverSeq = 0; @@ -47,7 +46,6 @@ describe('Agent Skills install (lib/install-lib.sh)', () => { claudeDir = join(tempRoot, '.claude'); recallDir = join(tempRoot, '.agents', 'Recall'); fakeRepo = join(tempRoot, 'repo'); - piConfigDir = join(tempRoot, '.pi', 'agent'); ompConfigDir = join(tempRoot, '.omp', 'agent'); mkdirSync(claudeDir, { recursive: true }); @@ -76,7 +74,6 @@ describe('Agent Skills install (lib/install-lib.sh)', () => { `export CLAUDE_DIR="${claudeDir}"`, `export RECALL_DIR="${recallDir}"`, `export RECALL_REPO_DIR="${fakeRepo}"`, - `export PI_CONFIG_DIR="${piConfigDir}"`, `export OMP_CONFIG_DIR="${ompConfigDir}"`, 'export NO_COLOR=1', `source "${INSTALL_LIB}"`, @@ -110,16 +107,6 @@ describe('Agent Skills install (lib/install-lib.sh)', () => { expect(existsSync(join(claudeDir, 'skills', 'recall-doctor', 'SKILL.md'))).toBe(true); }); - test('recall_install_pi_skills symlinks into $PI_CONFIG_DIR/skills, not $CLAUDE_DIR/skills', () => { - const r = runDriver(['recall_install_pi_skills']); - expect(r.status).toBe(0); - - const piTarget = join(piConfigDir, 'skills', 'recall-doctor', 'SKILL.md'); - expect(existsSync(piTarget)).toBe(true); - expect(lstatSync(piTarget).isSymbolicLink()).toBe(true); - expect(existsSync(join(claudeDir, 'skills'))).toBe(false); - }); - test('recall_install_omp_platform symlinks into $OMP_CONFIG_DIR/skills', () => { const r = runDriver(['recall_install_omp_platform']); expect(r.status).toBe(0); diff --git a/tests/install/update.test.ts b/tests/install/update.test.ts index 879a02a..226bd8f 100644 --- a/tests/install/update.test.ts +++ b/tests/install/update.test.ts @@ -304,8 +304,8 @@ describe('update.sh', () => { recall_configure_opencode_mcp() { echo "CALL:recall_configure_opencode_mcp"; } recall_install_opencode_plugins(){ echo "CALL:recall_install_opencode_plugins"; } recall_install_pi_adapter() { echo "CALL:recall_install_pi_adapter"; } + recall_install_pi_package() { echo "CALL:recall_install_pi_package"; } recall_configure_pi_mcp() { echo "CALL:recall_configure_pi_mcp"; } - recall_install_pi_extensions() { echo "CALL:recall_install_pi_extensions"; } recall_install_pi_guide() { echo "CALL:recall_install_pi_guide"; } recall_install_opencode_platform() { echo "CALL:recall_install_opencode_platform"; } recall_install_pi_platform() { echo "CALL:recall_install_pi_platform"; } @@ -355,20 +355,20 @@ describe('update.sh', () => { expect(ok).toBe(true); }); - test('Pi: invokes all 4 install functions when PI_DETECTED=true', () => { + test('Pi: invokes its canonical separate-surface installer when PI_DETECTED=true', () => { const r = runRefresh({ OPENCODE_DETECTED: 'false', PI_DETECTED: 'true' }); expect(r.status).toBe(0); - // Original install.sh order (lib/install-lib.sh:1750-1854): + // Canonical order: // recall_install_pi_adapter + // recall_install_pi_package // recall_configure_pi_mcp - // recall_install_pi_extensions // recall_install_pi_guide const ok = r.stdout.includes('CALL:recall_install_pi_platform') || ( r.stdout.includes('CALL:recall_install_pi_adapter') && + r.stdout.includes('CALL:recall_install_pi_package') && r.stdout.includes('CALL:recall_configure_pi_mcp') && - r.stdout.includes('CALL:recall_install_pi_extensions') && r.stdout.includes('CALL:recall_install_pi_guide') ); expect(ok).toBe(true); diff --git a/tests/pi-integration.test.ts b/tests/pi-integration.test.ts index 37a1b02..0216cee 100644 --- a/tests/pi-integration.test.ts +++ b/tests/pi-integration.test.ts @@ -3,7 +3,8 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, readFileSync import { join } from 'path'; import { tmpdir } from 'os'; import { execFileSync } from 'child_process'; -import { linearizeSession } from '../pi/RecallExtract'; +import registerRecallExtract, { linearizeSession } from '../pi/RecallExtract'; +import registerRecallInjection from '../pi/RecallPreCompact'; // ─── Tree JSONL Linearization ─── @@ -111,6 +112,72 @@ describe('tree JSONL linearization', () => { }); }); +// ─── Live Pi lifecycle contracts ─── + +describe('Pi extension lifecycle contracts', () => { + let tempDir: string; + let previousRecallHome: string | undefined; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'recall-pi-lifecycle-')); + previousRecallHome = process.env.RECALL_HOME; + process.env.RECALL_HOME = join(tempDir, 'recall-home'); + }); + + afterEach(() => { + if (previousRecallHome === undefined) delete process.env.RECALL_HOME; + else process.env.RECALL_HOME = previousRecallHome; + rmSync(tempDir, { recursive: true, force: true }); + }); + + test('session_shutdown captures the path from sessionManager.getSessionFile()', () => { + const sessionPath = join(tempDir, 'pi-session.jsonl'); + const entries = [ + { id: '1', parentId: null, type: 'message', message: { role: 'user', content: `Plan the Recall Pi package. ${'x'.repeat(300)}` } }, + { id: '2', parentId: '1', type: 'message', message: { role: 'assistant', content: `Use Pi packages for extensions and skills only. ${'y'.repeat(300)}` } }, + ]; + writeFileSync(sessionPath, entries.map(entry => JSON.stringify(entry)).join('\n')); + + let shutdown: ((event: unknown, ctx: unknown) => void) | undefined; + registerRecallExtract({ + on(event: string, handler: (event: unknown, ctx: unknown) => void) { + if (event === 'session_shutdown') shutdown = handler; + }, + }); + expect(shutdown).toBeDefined(); + + shutdown?.({}, { sessionManager: { getSessionFile: () => sessionPath } }); + + const drop = join(process.env.RECALL_HOME!, 'MEMORY', 'pi-sessions', 'pi-session.md'); + expect(readFileSync(drop, 'utf-8')).toContain('Recall Pi package'); + }); + + test('before_agent_start uses Pi exec and returns a chained system prompt', async () => { + let beforeStart: ((event: any, ctx: any) => Promise) | undefined; + const calls: unknown[][] = []; + registerRecallInjection({ + on(event: string, handler: (event: any, ctx: any) => Promise) { + if (event === 'before_agent_start') beforeStart = handler; + }, + async exec(...args: unknown[]) { + calls.push(args); + return { code: 0, stdout: 'Prior Pi packaging decision', stderr: '', killed: false }; + }, + }); + expect(beforeStart).toBeDefined(); + + const result = await beforeStart?.( + { systemPrompt: 'Base prompt' }, + { cwd: '/work/Recall', signal: undefined }, + ); + + expect(calls[0]?.[0]).toBe('recall'); + expect(calls[0]?.[1]).toEqual(['search', 'Recall', '--limit', '5']); + expect(result.systemPrompt).toContain('Base prompt'); + expect(result.systemPrompt).toContain('Prior Pi packaging decision'); + }); +}); + // ─── RecallBatchExtract Pi Session Scanning ─── describe('RecallBatchExtract Pi session scanning', () => { @@ -205,13 +272,66 @@ describe('installer Pi integration', () => { expect(content).toContain('lifecycle'); }); - test('install.sh copies Pi extensions', () => { - const installPath = join(__dirname, '..', 'install.sh'); + test('package manifest exposes extensions and all nine skills, but not MCP', () => { + const packageJson = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')); + expect(packageJson.keywords).toContain('pi-package'); + expect(packageJson.pi).toEqual({ + extensions: ['./pi/*.ts'], + skills: ['./agent-skills/*/SKILL.md'], + }); + expect(packageJson.pi.mcp).toBeUndefined(); + }); + + test('install.sh registers the native Pi package and removes legacy resource shadows', () => { const libPath = join(__dirname, '..', 'lib', 'install-lib.sh'); - const content = readFileSync(installPath, 'utf-8') + '\n' + readFileSync(libPath, 'utf-8'); - expect(content).toContain('install_pi_extensions'); + const content = readFileSync(libPath, 'utf-8'); + expect(content).toContain('recall_install_pi_package'); + expect(content).toContain('recall_remove_legacy_pi_resources'); expect(content).toContain('RecallExtract.ts'); expect(content).toContain('RecallPreCompact.ts'); + expect(content).not.toContain('recall_install_pi_extensions'); + expect(content).not.toContain('recall_install_pi_skills'); + }); + + test('legacy cleanup backs up customized Pi resources instead of deleting them', () => { + const sandbox = mkdtempSync(join(tmpdir(), 'recall-pi-legacy-cleanup-')); + const fakeHome = join(sandbox, 'home'); + const piHome = join(fakeHome, '.pi', 'agent'); + const backupDir = join(sandbox, 'backup'); + const customExtension = join(piHome, 'extensions', 'RecallExtract.ts'); + const customSkill = join(piHome, 'skills', 'recall-add', 'SKILL.md'); + mkdirSync(join(piHome, 'extensions'), { recursive: true }); + mkdirSync(join(piHome, 'skills', 'recall-add'), { recursive: true }); + writeFileSync(customExtension, '// user-customized extension\n'); + writeFileSync(customSkill, '# user-customized skill\n'); + + try { + execFileSync('bash', [ + '-c', + 'source "$1"; recall_remove_legacy_pi_resources', + '_', + join(__dirname, '..', 'lib', 'install-lib.sh'), + ], { + env: { + ...process.env, + HOME: fakeHome, + PI_CONFIG_DIR: piHome, + RECALL_DIR: join(fakeHome, '.agents', 'Recall'), + RECALL_REPO_DIR: join(__dirname, '..'), + BACKUP_DIR: backupDir, + NO_COLOR: '1', + }, + }); + + expect(existsSync(customExtension)).toBe(false); + expect(existsSync(customSkill)).toBe(false); + expect(readFileSync(join(backupDir, 'collisions', '.pi', 'agent', 'extensions', 'RecallExtract.ts'), 'utf-8')) + .toContain('user-customized extension'); + expect(readFileSync(join(backupDir, 'collisions', '.pi', 'agent', 'skills', 'recall-add', 'SKILL.md'), 'utf-8')) + .toContain('user-customized skill'); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } }); // update.sh's refresh path propagating the Pi guide (recall_install_pi_guide @@ -299,6 +419,20 @@ describe('recall_configure_pi_mcp preserves user customizations', () => { }); } + test('creates mcp.json and its parent container on a fresh install', () => { + expect(existsSync(mcpJsonPath)).toBe(false); + + runConfigure(); + + const config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8')); + expect(config.mcpServers['recall-memory']).toMatchObject({ + args: [], + lifecycle: 'lazy', + directTools: true, + env: { RECALL_DB_PATH: join(sandboxDir, 'fake-recall.db') }, + }); + }); + test('preserves inline // comments, non-Recall MCP entries, and JSON5 trailing commas', () => { const userConfig = [ '{', @@ -308,14 +442,14 @@ describe('recall_configure_pi_mcp preserves user customizations', () => { ' "command": "/old/recall-mcp",', ' "args": [],', ' "lifecycle": "lazy",', - ' "environment": { "RECALL_DB_PATH": "/old/db" }', + ' "env": { "RECALL_DB_PATH": "/old/db" }', ' },', ' // GitHub MCP — critical for Pi workflows, hand-tuned', ' "github": {', ' "command": "gh-mcp",', ' "args": ["--scope=repo"],', ' "lifecycle": "lazy",', - ' "environment": { "GITHUB_TOKEN": "ghp_xxx" },', + ' "env": { "GITHUB_TOKEN": "ghp_xxx" },', ' },', ' },', '}', @@ -378,6 +512,24 @@ describe('recall_configure_pi_mcp preserves user customizations', () => { expect(after).toContain('"recall-memory"'); expect(after).toContain('fake-recall.db'); }); + + test('preserves an explicit user directTools preference while defaulting fresh installs on', () => { + writeFileSync(mcpJsonPath, JSON.stringify({ + mcpServers: { + 'recall-memory': { + command: '/old/recall-mcp', + directTools: false, + env: { RECALL_DB_PATH: '/old/db' }, + }, + }, + }, null, 2)); + + runConfigure(); + + const config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8')); + expect(config.mcpServers['recall-memory'].directTools).toBe(false); + expect(config.mcpServers['recall-memory'].env.RECALL_DB_PATH).toContain('fake-recall.db'); + }); }); // ─── MCP config hardening RED — V-1, V-3, V-4 (recall_configure_pi_mcp) ─── @@ -390,8 +542,8 @@ describe('recall_configure_pi_mcp preserves user customizations', () => { // // V-1 Malformed JSON → non-zero exit AND file byte-identical. // V-3 User custom keys on the recall-memory entry (myExtraKey, -// environment.MY_CUSTOM_VAR) survive a refresh while -// environment.RECALL_DB_PATH is updated. +// env.MY_CUSTOM_VAR) survive a refresh while env.RECALL_DB_PATH is +// updated. // V-4 The mcpServers container present but a non-object // ("mcpServers": "disabled") → non-zero exit AND file unchanged. describe('recall_configure_pi_mcp hardening (V-1, V-3, V-4) [RED]', () => { @@ -449,13 +601,13 @@ describe('recall_configure_pi_mcp hardening (V-1, V-3, V-4) [RED]', () => { ' "args": [],', ' "lifecycle": "lazy",', ' "myExtraKey": "bar",', - ' "environment": { "RECALL_DB_PATH": "/old/db", "MY_CUSTOM_VAR": "foo" }', + ' "env": { "RECALL_DB_PATH": "/old/db", "MY_CUSTOM_VAR": "foo" }', ' },', ' "github": {', ' "command": "gh-mcp",', ' "args": ["--scope=repo"],', ' "lifecycle": "lazy",', - ' "environment": { "GITHUB_TOKEN": "ghp_xxx" }', + ' "env": { "GITHUB_TOKEN": "ghp_xxx" }', ' }', ' }', '}', @@ -470,7 +622,7 @@ describe('recall_configure_pi_mcp hardening (V-1, V-3, V-4) [RED]', () => { // Custom key directly on the recall-memory entry must survive. expect(after).toMatch(/"myExtraKey":\s*"bar"/); - // Custom env var nested under recall-memory.environment must survive. + // Custom env var nested under recall-memory.env must survive. expect(after).toMatch(/"MY_CUSTOM_VAR":\s*"foo"/); // RECALL_DB_PATH must be updated to the new path (the point of a refresh). expect(after).toContain('fake-recall.db'); diff --git a/uninstall.sh b/uninstall.sh index 57008b4..9b33106 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -164,7 +164,7 @@ print_summary() { echo " • Recall-generated ## MEMORY section in ~/.claude/CLAUDE.md (external sections stay)" echo " • ~/.claude/MEMORY/extract_prompt.md (symlink → ~/.agents/Recall/shared/)" [[ "$SKIP_OPENCODE" != "true" ]] && echo " • OpenCode MCP entry + plugin symlinks" - [[ "$SKIP_PI" != "true" ]] && echo " • Pi MCP entry + extension symlinks + Recall-generated AGENTS.md MEMORY section + skills" + [[ "$SKIP_PI" != "true" ]] && echo " • Pi MCP entry + Recall package + Recall-generated AGENTS.md MEMORY section" [[ "$SKIP_OMP" != "true" ]] && echo " • omp agent skills (~/.omp/agent/skills/)" echo " • bun unlink (removes recall/recall-mcp from PATH)" echo "" @@ -493,6 +493,17 @@ remove_opencode() { remove_pi() { local config="$PI_CONFIG_DIR/mcp.json" + # Recall is a local Pi package for extensions + skills. The MCP adapter is a + # separate shared Pi package and may serve other servers, so leave it alone. + if command -v pi >/dev/null 2>&1; then + if [[ "$DRY_RUN" == "true" ]]; then + echo " [dry-run] would run: PI_CODING_AGENT_DIR=$PI_CONFIG_DIR pi remove $RECALL_REPO_DIR --no-approve" + else + PI_CODING_AGENT_DIR="$PI_CONFIG_DIR" pi remove "$RECALL_REPO_DIR" --no-approve >/dev/null 2>&1 || true + log_success "Removed Recall's Pi package registration" + fi + fi + if [[ -f "$config" ]]; then if [[ "$DRY_RUN" == "true" ]]; then echo " [dry-run] would remove recall-memory from $config" diff --git a/update.sh b/update.sh index 85875b3..ecbd4dc 100755 --- a/update.sh +++ b/update.sh @@ -333,7 +333,7 @@ step_migrate() { step_refresh_runtime() { log_info "Refreshing runtime files (canonical + symlinks)..." if [[ "$DRY_RUN" == "true" ]]; then - echo " [dry-run] would: write canonicals to $RECALL_DIR and per-file symlinks into platform homes" + echo " [dry-run] would: write canonicals to $RECALL_DIR, refresh host links, and refresh native package registrations" echo " [dry-run] would: migrate Recall-owned Claude/Pi MEMORY bootstraps and refresh all detected platform guides, prompts, and skills" return fi