From d89de0713ad83ceb390aa2dab7a9e69dd392b2a7 Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Thu, 23 Apr 2026 23:29:54 -0500 Subject: [PATCH 1/2] feat(pi): add native Pi integration --- DOCS.md | 2 +- PRD-pi-support.md | 182 ++++ README.md | 5 +- cmd/engram/main.go | 8 +- cmd/engram/main_extra_test.go | 24 + cmd/engram/main_test.go | 4 + docs/AGENT-SETUP.md | 88 ++ docs/ARCHITECTURE.md | 8 +- docs/PLUGINS.md | 42 + internal/mcp/mcp.go | 12 + internal/mcp/mcp_test.go | 49 ++ internal/server/server_test.go | 76 ++ internal/setup/generate.go | 4 +- internal/setup/pi_runtime_harness_test.go | 69 ++ .../setup/plugins/pi/extensions/engram.ts | 813 ++++++++++++++++++ internal/setup/plugins/pi/package.json | 24 + .../setup/plugins/pi/skills/engram/SKILL.md | 36 + internal/setup/setup.go | 90 +- internal/setup/setup_test.go | 694 +++++++++++++++ internal/setup/testdata/pi-contract.json | 22 + internal/store/store.go | 38 +- internal/store/store_test.go | 95 ++ plugin/pi/extensions/engram.ts | 813 ++++++++++++++++++ plugin/pi/package.json | 24 + plugin/pi/skills/engram/SKILL.md | 36 + plugin/pi/test/runtime-harness.mjs | 472 ++++++++++ 26 files changed, 3718 insertions(+), 12 deletions(-) create mode 100644 PRD-pi-support.md create mode 100644 internal/setup/pi_runtime_harness_test.go create mode 100644 internal/setup/plugins/pi/extensions/engram.ts create mode 100644 internal/setup/plugins/pi/package.json create mode 100644 internal/setup/plugins/pi/skills/engram/SKILL.md create mode 100644 internal/setup/testdata/pi-contract.json create mode 100644 plugin/pi/extensions/engram.ts create mode 100644 plugin/pi/package.json create mode 100644 plugin/pi/skills/engram/SKILL.md create mode 100644 plugin/pi/test/runtime-harness.mjs diff --git a/DOCS.md b/DOCS.md index 1ab0312c..a474d541 100644 --- a/DOCS.md +++ b/DOCS.md @@ -65,7 +65,7 @@ All endpoints return JSON. Server listens on `127.0.0.1:7437`. ### Sessions - `POST /sessions` — Create session. Body: `{id, project, directory}` -- `POST /sessions/{id}/end` — End session. Body: `{summary}` +- `POST /sessions/{id}/end` — End session and set `ended_at`. Body: `{summary}`. If an existing non-empty summary is already stored, low-signal shutdown metadata summaries (`shutdown reason=... target=...`) are ignored to preserve the higher-signal summary. - `GET /sessions/recent` — Recent sessions. Query: `?project=X&limit=N` ### Observations diff --git a/PRD-pi-support.md b/PRD-pi-support.md new file mode 100644 index 00000000..0087d777 --- /dev/null +++ b/PRD-pi-support.md @@ -0,0 +1,182 @@ +# PRD: Soporte oficial de Engram para pi.dev + +## Contexto + +Pi (`pi-coding-agent`) es un coding agent con extensiones nativas en TypeScript, skills, hooks de ciclo de vida y tools personalizadas. Hoy Engram no tiene soporte oficial de primera clase para pi, lo que obliga a una integración manual y no aprovecha las capacidades nativas del ecosistema de pi. + +## Problema + +Los usuarios de pi no tienen una experiencia oficial, simple y comparable a OpenCode para usar Engram. La integración manual genera fricción y no habilita de forma coherente: + +- protocolo de memoria nativo +- ciclo de vida de sesión +- resiliencia frente a compaction +- auto-start del backend +- una instalación con un solo comando + +## Objetivo + +Agregar soporte oficial para pi mediante: + +```bash +engram setup pi +``` + +La integración debe ser nativa, global, offline/local y comparable a la experiencia actual de OpenCode. + +## Objetivos del producto + +La solución debe: + +- instalarse con un solo comando +- funcionar de forma global +- ser offline/local, sin depender de npm o git durante el setup +- usar una extensión nativa de pi +- auto-arrancar Engram cuando sea necesario, igual que OpenCode +- exponer tools de memoria dentro de pi +- aplicar el Memory Protocol +- soportar session lifecycle +- manejar compaction recovery +- usar una política de memoria inicial asistida pero no intrusiva + +## No objetivos + +Por ahora no buscamos: + +- integración MCP como experiencia principal +- instalación por proyecto +- inyección automática de memoria previa al abrir sesión +- reimplementar lógica de memoria en TypeScript +- introducir una UX inconsistente respecto al resto de Engram + +## Experiencia esperada del usuario + +### Setup + +El usuario ejecuta: + +```bash +engram setup pi +``` + +Y queda listo para usar Engram dentro de pi sin pasos manuales adicionales. + +### Uso inicial + +Al abrir pi: + +- la integración está disponible globalmente +- Engram puede auto-iniciarse si no está corriendo +- el agente cuenta con tools de memoria +- el protocolo de memoria está disponible +- si existe memoria relevante, se notifica su disponibilidad, pero no se inyecta automáticamente en el contexto + +### Durante la sesión + +El agente puede: + +- guardar memorias relevantes +- consultar memorias cuando el usuario lo pida +- consultar memoria de forma proactiva solo cuando haya alta probabilidad de continuidad útil + +### En compaction + +La integración debe preservar continuidad de trabajo sin romper la política conservadora del inicio de sesión. + +## Requisitos funcionales + +### RF1 — Setup oficial + +Debe existir `engram setup pi`. + +### RF2 — Instalación global + +La integración se instala en el espacio global de pi, no por proyecto. + +### RF3 — Distribución embebida + +La extensión y skill necesarias deben poder instalarse desde el binario de Engram, sin depender de fetch remoto durante setup. + +### RF4 — Tools de memoria + +La integración debe exponer tools para buscar, guardar, contextualizar y cerrar sesión de memoria. + +### RF5 — Memory Protocol + +La integración debe enseñar al agente: + +- cuándo guardar +- cuándo buscar +- cómo cerrar sesión +- cómo recuperarse tras compaction + +### RF6 — Session lifecycle + +Debe existir integración con inicio y fin de sesión. + +### RF7 — Auto-start backend + +Debe comportarse como OpenCode: si Engram no está corriendo, se intenta levantar automáticamente. + +### RF8 — Privacidad + +El contenido dentro de `` no debe salir de la integración sin ser sanitizado. + +### RF9 — Memoria inicial conservadora + +Al inicio: + +- no inyectar contexto automáticamente +- no cargar memoria completa +- sí notificar si existe memoria relevante + +### RF10 — Compaction resilience + +La integración debe ayudar a no perder continuidad tras compaction. + +## Decisiones ya tomadas + +Estas decisiones ya están acordadas: + +- instalación mediante `engram setup pi` +- distribución local/offline +- alcance global +- auto-start del backend igual que OpenCode +- política de memoria inicial: notificar memoria relevante sin insertarla +- objetivo de calidad: experiencia comparable a OpenCode + +## Decisiones abiertas + +Estas decisiones requieren diseño técnico posterior: + +1. naming de tools (`mem_*` vs `engram_*`) +2. forma exacta de la notificación inicial +3. criterio de “memoria relevante” +4. estrategia exacta de compaction recovery +5. shape final del adaptador: extensión sola vs extensión + skill + assets auxiliares + +## Principios de diseño + +- adaptador fino +- lógica real en Engram, no en TypeScript +- experiencia consistente con OpenCode +- sin UX mágica o intrusiva +- coherencia de producto antes que soluciones ad hoc + +## Riesgos + +- compaction difícil de diseñar correctamente +- sobrecargar el contexto inicial +- divergencia de naming o semántica respecto a otros agentes +- mover lógica de negocio a la extensión por conveniencia + +## Criterios de éxito + +Consideraremos exitosa la integración si: + +- un usuario puede activar Engram en pi con `engram setup pi` +- no necesita configurar MCP manualmente +- la experiencia se siente first-class +- hay continuidad útil entre sesiones +- no se inserta memoria al inicio sin demanda +- existe señal clara cuando hay memoria relevante disponible diff --git a/README.md b/README.md index 5e5c3730..997bbba5 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,14 @@ Windows, Linux, and other install methods → [docs/INSTALLATION.md](docs/INSTAL | OpenCode | `engram setup opencode` | | Gemini CLI | `engram setup gemini-cli` | | Codex | `engram setup codex` | +| Pi | `engram setup pi` | | VS Code | `code --add-mcp '{"name":"engram","command":"engram","args":["mcp"]}'` | | Cursor / Windsurf / Any MCP | See [docs/AGENT-SETUP.md](docs/AGENT-SETUP.md) | Full per-agent config, Memory Protocol, and compaction survival → [docs/AGENT-SETUP.md](docs/AGENT-SETUP.md) +> Pi note: `engram setup pi` uses Pi's official installer (`pi install `) from offline embedded assets. + That's it. No Node.js, no Python, no Docker. **One binary, one SQLite file.** ## How It Works @@ -136,7 +139,7 @@ Full CLI with all flags → [docs/ARCHITECTURE.md#cli-reference](docs/ARCHITECTU | [Installation](docs/INSTALLATION.md) | All install methods + platform support | | [Agent Setup](docs/AGENT-SETUP.md) | Per-agent configuration + Memory Protocol | | [Architecture](docs/ARCHITECTURE.md) | How it works + MCP tools + project structure | -| [Plugins](docs/PLUGINS.md) | OpenCode & Claude Code plugin details | +| [Plugins](docs/PLUGINS.md) | OpenCode, Claude Code, and Pi integration details | | [Comparison](docs/COMPARISON.md) | Why Engram vs claude-mem | | [Intended Usage](docs/intended-usage.md) | Mental model — how Engram is meant to be used | | [Obsidian Brain](docs/beta/obsidian-brain.md) | Export memories as Obsidian knowledge graph (beta) | diff --git a/cmd/engram/main.go b/cmd/engram/main.go index e295cd12..b53a0dc5 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -1516,6 +1516,12 @@ func printPostInstall(agent string) { fmt.Println(" 1. Restart Codex so MCP config is reloaded") fmt.Println(" 2. Verify ~/.codex/config.toml has [mcp_servers.engram]") fmt.Println(" 3. Verify model_instructions_file + experimental_compact_prompt_file are set") + case "pi": + fmt.Println("\nNext steps:") + fmt.Println(" 1. Restart pi-coding-agent so the installed package is loaded") + fmt.Println(" 2. Verify startup banner includes Engram extension/skill") + fmt.Println(" 3. Setup materializes ~/.config/pi-coding-agent/packages/engram and runs 'pi install '") + fmt.Println(" 4. Re-run 'engram setup pi' after package changes to re-install from local embedded assets") } } @@ -1548,7 +1554,7 @@ Commands: Merge similar project names into one canonical name --all Scan ALL projects for similar name groups --dry-run Preview what would be merged (no changes) - setup [agent] Install/setup agent integration (opencode, claude-code, gemini-cli, codex) + setup [agent] Install/setup agent integration (opencode, claude-code, gemini-cli, codex, pi) sync Export new memories as compressed chunk to .engram/ --import Import new chunks from .engram/ into local DB --status Show sync status (local vs remote chunks) diff --git a/cmd/engram/main_extra_test.go b/cmd/engram/main_extra_test.go index bc11743e..54493af7 100644 --- a/cmd/engram/main_extra_test.go +++ b/cmd/engram/main_extra_test.go @@ -279,6 +279,30 @@ func TestCmdMCPAndTUIBranches(t *testing.T) { } } +func TestCmdSetupInteractiveListsPi(t *testing.T) { + stubRuntimeHooks(t) + stubExitWithPanic(t) + + // Trigger interactive mode by passing a flag-like second arg. + withArgs(t, "engram", "setup", "--interactive") + scanInputLine = func(a ...any) (int, error) { + ptr := a[0].(*string) + *ptr = "1" + return 1, nil + } + setupInstallAgent = func(agent string) (*setup.Result, error) { + return &setup.Result{Agent: agent, Destination: "/tmp/dest", Files: 1}, nil + } + + stdout, stderr, recovered := captureOutputAndRecover(t, func() { cmdSetup() }) + if recovered != nil { + t.Fatalf("setup should not exit in interactive mode, panic=%v stderr=%q", recovered, stderr) + } + if !strings.Contains(stdout, "pi") { + t.Fatalf("expected interactive setup list to include pi, got: %q", stdout) + } +} + func TestCmdSetupDirectAndInteractive(t *testing.T) { stubRuntimeHooks(t) stubExitWithPanic(t) diff --git a/cmd/engram/main_test.go b/cmd/engram/main_test.go index 460d4dd6..f07fd607 100644 --- a/cmd/engram/main_test.go +++ b/cmd/engram/main_test.go @@ -164,6 +164,9 @@ func TestPrintUsage(t *testing.T) { if !strings.Contains(stdout, "search ") || !strings.Contains(stdout, "setup [agent]") { t.Fatalf("usage missing expected commands: %q", stdout) } + if !strings.Contains(stdout, "setup [agent] Install/setup agent integration (opencode, claude-code, gemini-cli, codex, pi)") { + t.Fatalf("usage missing pi setup target: %q", stdout) + } } func TestPrintPostInstall(t *testing.T) { @@ -174,6 +177,7 @@ func TestPrintPostInstall(t *testing.T) { {agent: "opencode", expects: []string{"Restart OpenCode", "engram serve &"}}, {agent: "gemini-cli", expects: []string{"Restart Gemini CLI", "~/.gemini/settings.json"}}, {agent: "codex", expects: []string{"Restart Codex", "~/.codex/config.toml"}}, + {agent: "pi", expects: []string{"Restart pi-coding-agent", "pi install", "~/.config/pi-coding-agent/packages/engram"}}, {agent: "unknown", expects: nil}, } diff --git a/docs/AGENT-SETUP.md b/docs/AGENT-SETUP.md index c73fceb5..dd7afc39 100644 --- a/docs/AGENT-SETUP.md +++ b/docs/AGENT-SETUP.md @@ -12,6 +12,7 @@ Engram works with **any MCP-compatible agent**. Pick your agent below. | OpenCode | `engram setup opencode` | [Details](#opencode) | | Gemini CLI | `engram setup gemini-cli` | [Details](#gemini-cli) | | Codex | `engram setup codex` | [Details](#codex) | +| Pi | `engram setup pi` | [Details](#pi) | | VS Code | `code --add-mcp '{"name":"engram","command":"engram","args":["mcp"]}'` | [Details](#vs-code-copilot--claude-code-extension) | | Antigravity | Manual JSON config | [Details](#antigravity) | | Cursor | Manual JSON config | [Details](#cursor) | @@ -170,6 +171,93 @@ args = ["mcp"] --- +## Pi + +Recommended: one command for global, offline setup: + +```bash +engram setup pi +``` + +`engram setup pi` materializes a local embedded package and runs Pi's official installer: +- Local package path: `~/.config/pi-coding-agent/packages/engram` +- Package assets: + - `package.json` (with top-level `pi` manifest) + - `extensions/engram.ts` + - `skills/engram/SKILL.md` +- Installer invocation: `pi install ~/.config/pi-coding-agent/packages/engram` + +No guessed edits to `~/.config/pi-coding-agent/config.json` are performed by Engram. + +Policy defaults for Pi support: +- notify-only at session start when relevant memory exists +- no automatic full-context injection +- extension lifecycle uses validated events: `session_start`, `session_shutdown`, `input`, `session_before_compact`, `session_compact` +- native Pi tools are registered via `pi.registerTool()` with canonical names: `mem_search`, `mem_context`, `mem_save`, `mem_session_summary`, `mem_get_observation`, `mem_save_prompt` +- compaction recovery guidance is available via extension command `/engram-recovery` + +After install, run `/reload` in Pi so the extension and tools are reloaded in the current client process. + +> Pi contract fields (hook names, manifest schema, notification API) are tracked in `internal/setup/testdata/pi-contract.json` and exercised by deterministic runtime harness tests (`go test ./internal/setup -run TestPiExtensionRuntimeHarnessDeterministic`). Update both if upstream behavior differs. + +### Pi Manual E2E Checklist + +Use this when validating a real Pi installation outside CI: + +1. **Global install + offline path** + - Disconnect network (or block outbound) and run `engram setup pi`. + - Confirm local package exists at `~/.config/pi-coding-agent/packages/engram`. + - Confirm `engram setup pi` invokes `pi install `. +2. **Extension load** + - Start Pi and verify startup banner lists Engram extension/skill. +3. **Auto-start parity** + - Stop Engram backend, then call native mem_search from Pi in a fresh session (`stop Engram backend, then call native mem_search`). + - Confirm extension checks `/health` and attempts `engram serve` before tool/lifecycle calls. +4. **Session lifecycle hooks** + - Start Pi, run `/reload`, then begin and quit a session. + - Verify `session_start` emits `POST /sessions` and `session_shutdown` emits `POST /sessions/{id}/end` (shutdown metadata must not overwrite an existing non-empty/high-signal summary). + - Search for session start/end evidence in Engram storage/logs if those traces are available in your environment. +5. **Native memory tools + prompt capture** + - Confirm Pi lists native tools `mem_search`, `mem_context`, `mem_save`, `mem_session_summary`, `mem_get_observation`, `mem_save_prompt`. + - Trigger an input turn and verify `input` hook emits `POST /prompts`. + - Verify extension-generated input (`event.source === "extension"`) is not double-captured. +6. **Notify-only startup policy** + - Seed memory for the project, start a new Pi session. + - Confirm extension announces memory availability through `ctx.ui.notify(...)` but does **not** auto-fetch/inject `/context`. +7. **Recovery guidance + compaction hooks** + - Run `/engram-recovery` and verify message starts with `FIRST ACTION REQUIRED`. + - Run `/compact`, then verify compaction summary persistence (`run `/compact`, then verify compaction summary persistence`) via `POST /sessions/{id}/end` or recovery notification fallback. + - After compaction, call `mem_context` manually (no automatic context injection). +8. **Privacy sanitization** + - Start/end session values containing `...` and verify outbound payloads are `[REDACTED]`. + +### Pi Troubleshooting: Duplicate package installs + +If Pi reports shortcut/skill conflicts that reference unrelated packages (for example stale `pi-btw` temp installs), clean those duplicates separately from Engram: + +1. List installed Pi packages and identify duplicate or malformed temporary paths. +2. Remove only the stale duplicate package directories (do **not** remove `~/.config/pi-coding-agent/packages/engram`). +3. Re-run `pi install ~/.config/pi-coding-agent/packages/engram` or `engram setup pi`. + +These conflicts are environment cleanup issues, not Engram runtime behavior. + +### Pi Troubleshooting: Auto-start cannot find Engram binary + +If native `mem_*` calls report auto-start failures, Pi could be resolving an old/invalid `engram` binary from `PATH`. + +1. Verify `engram` in your shell resolves to the expected binary (`which engram` / `command -v engram`). +2. Ensure that binary is available in the environment used to launch Pi. +3. If needed, launch Pi with an explicit override: `ENGRAM_BIN=/path/to/engram pi` + +`ENGRAM_BIN` takes precedence over the default binary lookup inside the Pi extension. + +If any checkpoint fails due Pi API differences, update: +- `internal/setup/testdata/pi-contract.json` (assumptions) +- `plugin/pi/extensions/engram.ts` + embedded copy via `go generate ./internal/setup/` +- this checklist section + +--- + ## VS Code (Copilot / Claude Code Extension) VS Code supports MCP servers natively in its chat panel (Copilot agent mode). This works with **any** AI agent running inside VS Code — Copilot, Claude Code extension, or any other MCP-compatible chat provider. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f1495503..1a7a7cc2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -136,6 +136,10 @@ engram/ │ └── view.go # Rendering, per-screen views ├── plugin/ │ ├── opencode/engram.ts # OpenCode adapter plugin +│ ├── pi/ # Pi extension (global install, notify-only startup) +│ │ ├── package.json # Includes top-level `pi` manifest +│ │ ├── extensions/engram.ts +│ │ └── skills/engram/SKILL.md │ └── claude-code/ # Claude Code plugin (hooks + skill) │ ├── .claude-plugin/plugin.json │ ├── .mcp.json @@ -156,7 +160,7 @@ engram/ ## CLI Reference ``` -engram setup [agent] Install/setup agent integration (opencode, claude-code, gemini-cli, codex) +engram setup [agent] Install/setup agent integration (opencode, claude-code, gemini-cli, codex, pi) engram serve [port] Start HTTP API server (default: 7437) engram mcp Start MCP server (stdio transport) engram tui Launch interactive terminal UI @@ -181,5 +185,5 @@ engram version Show version ## Next Steps - [Agent Setup](AGENT-SETUP.md) — connect your agent to Engram -- [Plugins](PLUGINS.md) — what the OpenCode and Claude Code plugins add +- [Plugins](PLUGINS.md) — what the OpenCode, Claude Code, and Pi integrations add - [Obsidian Brain](beta/obsidian-brain.md) — visualize memories as a knowledge graph (beta) diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 45eddade..f70b6ca2 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -4,6 +4,7 @@ - [OpenCode Plugin](#opencode-plugin) - [Claude Code Plugin](#claude-code-plugin) +- [Pi Extension (Contract Assumptions)](#pi-extension-contract-assumptions) - [Privacy](#privacy) --- @@ -120,6 +121,47 @@ plugin/claude-code/ --- +## Pi Extension (Contract Assumptions) + +`engram setup pi` installs a **global**, **offline** Pi bundle from embedded assets by using Pi's official installer: + +- materializes package at `~/.config/pi-coding-agent/packages/engram` +- package includes: + - `package.json` with top-level `pi.extensions` + `pi.skills` + - `extensions/engram.ts` + - `skills/engram/SKILL.md` +- runs `pi install ` (no network fetch, no guessed config internals) + +The current adapter keeps a strict thin boundary: TypeScript only forwards lifecycle and memory-capture transport to Engram HTTP APIs. Memory semantics remain in Go core. + +Runtime behavior (official Pi-native shape): +- extension export uses `export default function (pi: ExtensionAPI)` +- lifecycle handlers use `(event, ctx)` and derive project/session from `ctx.cwd` + `ctx.sessionManager.getSessionFile()` +- startup notices use `ctx.ui.notify(...)` (never `console.info`) +- memory tools are registered natively through `pi.registerTool()` with canonical names: + - `mem_search` + - `mem_context` + - `mem_save` + - `mem_session_summary` + - `mem_get_observation` + - `mem_save_prompt` +- all tool/event payloads apply `...` redaction before forwarding +- backend auto-start uses `ENGRAM_BIN` override first (if set), then falls back to `engram` from PATH + +Pi positioning: native Pi tools (not MCP-first). Engram keeps canonical `mem_*` names directly in Pi via `pi.registerTool()` and keeps `/engram-recovery` as a manual fallback command. + +Pi contract assumptions are tracked in `internal/setup/testdata/pi-contract.json` and enforced by deterministic runtime harness tests (`go test ./internal/setup -run TestPiExtensionRuntimeHarnessDeterministic`), while startup policy stays conservative: + +- notify when relevant memory exists +- do **not** auto-inject prior context +- validated lifecycle hooks: `session_start`, `session_shutdown`, `input`, `session_before_compact`, `session_compact` +- recovery guidance available through `/engram-recovery` command (`FIRST ACTION REQUIRED` text) +- compaction recovery is conservative: extension prepends `FIRST ACTION REQUIRED` via customInstructions when available, otherwise falls back to a UI notification + +If Pi contract details differ in newer Pi releases, update the fixture + runtime harness + installer paths before widening behavior. + +--- + ## Privacy Wrap sensitive content in `` tags — it gets stripped at TWO levels: diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 798b4e09..15bb5f20 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -16,6 +16,7 @@ package mcp import ( "context" "fmt" + "sort" "strings" "time" @@ -80,6 +81,17 @@ var Profiles = map[string]map[string]bool{ "admin": ProfileAdmin, } +// AgentProfileToolNames returns the canonical agent tool surface in stable order. +// Keep this list mem_* only to preserve cross-agent compatibility (including Pi). +func AgentProfileToolNames() []string { + names := make([]string, 0, len(ProfileAgent)) + for name := range ProfileAgent { + names = append(names, name) + } + sort.Strings(names) + return names +} + // ResolveTools takes a comma-separated string of profile names and/or // individual tool names and returns the set of tool names to register. // An empty input means "all" — every tool is registered. diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 952369a0..7835f3e1 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -950,6 +950,55 @@ func TestResolveToolsAgentProfile(t *testing.T) { } } +func TestPiAgentToolSurfaceStaysCanonical(t *testing.T) { + toolNames := AgentProfileToolNames() + if len(toolNames) == 0 { + t.Fatal("expected non-empty agent tool surface") + } + + required := []string{ + "mem_save", "mem_search", "mem_context", "mem_session_summary", + "mem_session_start", "mem_session_end", "mem_get_observation", + "mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt", + "mem_update", + } + + seen := make(map[string]bool, len(toolNames)) + for _, name := range toolNames { + seen[name] = true + if strings.HasPrefix(name, "engram_") { + t.Fatalf("pi/agent surface must stay canonical mem_* names; found alias %q", name) + } + } + + for _, name := range required { + if !seen[name] { + t.Fatalf("missing required agent tool %q for Pi integration", name) + } + } +} + +func TestAgentProfileToolNamesMatchesAgentProfileMap(t *testing.T) { + names := AgentProfileToolNames() + allowlist := ResolveTools("agent") + + if len(names) != len(allowlist) { + t.Fatalf("tool name count mismatch: names=%d allowlist=%d", len(names), len(allowlist)) + } + + for i := 1; i < len(names); i++ { + if names[i-1] > names[i] { + t.Fatalf("expected sorted output, got %q before %q", names[i-1], names[i]) + } + } + + for _, name := range names { + if !allowlist[name] { + t.Fatalf("name %q missing in agent allowlist", name) + } + } +} + func TestResolveToolsAdminProfile(t *testing.T) { result := ResolveTools("admin") if result == nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 0f30ac82..b3787492 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -397,6 +397,82 @@ func TestOnWriteNotCalledOnFailedWrites(t *testing.T) { } } +func TestPiFlowPrivacyTagsAreRedactedThroughServerEndpoints(t *testing.T) { + st := newServerTestStore(t) + srv := New(st, 0) + h := srv.Handler() + + if err := st.CreateSession("pi-session", "engram", "/tmp"); err != nil { + t.Fatalf("create session: %v", err) + } + + obsReq := httptest.NewRequest(http.MethodPost, "/observations", strings.NewReader(`{"session_id":"pi-session","type":"note","title":"Title secret-title","content":"Body secret-body"}`)) + obsReq.Header.Set("Content-Type", "application/json") + obsRec := httptest.NewRecorder() + h.ServeHTTP(obsRec, obsReq) + if obsRec.Code != http.StatusCreated { + t.Fatalf("expected 201 saving observation, got %d", obsRec.Code) + } + + var obsCreated map[string]any + if err := json.NewDecoder(obsRec.Body).Decode(&obsCreated); err != nil { + t.Fatalf("decode observation create response: %v", err) + } + obsID := int(obsCreated["id"].(float64)) + + getObsReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/observations/%d", obsID), nil) + getObsRec := httptest.NewRecorder() + h.ServeHTTP(getObsRec, getObsReq) + if getObsRec.Code != http.StatusOK { + t.Fatalf("expected 200 fetching observation, got %d", getObsRec.Code) + } + + var obs map[string]any + if err := json.NewDecoder(getObsRec.Body).Decode(&obs); err != nil { + t.Fatalf("decode observation response: %v", err) + } + + obsTitle := obs["title"].(string) + obsContent := obs["content"].(string) + if !strings.Contains(obsTitle, "[REDACTED]") || !strings.Contains(obsContent, "[REDACTED]") { + t.Fatalf("expected redaction markers in observation, got title=%q content=%q", obsTitle, obsContent) + } + if strings.Contains(obsTitle, "secret-title") || strings.Contains(obsContent, "secret-body") { + t.Fatalf("private payload leaked in observation, got title=%q content=%q", obsTitle, obsContent) + } + + promptReq := httptest.NewRequest(http.MethodPost, "/prompts", strings.NewReader(`{"session_id":"pi-session","project":"engram","content":"Prompt secret-prompt"}`)) + promptReq.Header.Set("Content-Type", "application/json") + promptRec := httptest.NewRecorder() + h.ServeHTTP(promptRec, promptReq) + if promptRec.Code != http.StatusCreated { + t.Fatalf("expected 201 saving prompt, got %d", promptRec.Code) + } + + recentReq := httptest.NewRequest(http.MethodGet, "/prompts/recent?project=engram&limit=1", nil) + recentRec := httptest.NewRecorder() + h.ServeHTTP(recentRec, recentReq) + if recentRec.Code != http.StatusOK { + t.Fatalf("expected 200 for recent prompts, got %d", recentRec.Code) + } + + var prompts []map[string]any + if err := json.NewDecoder(recentRec.Body).Decode(&prompts); err != nil { + t.Fatalf("decode recent prompts response: %v", err) + } + if len(prompts) != 1 { + t.Fatalf("expected one recent prompt, got %d", len(prompts)) + } + + promptContent := prompts[0]["content"].(string) + if !strings.Contains(promptContent, "[REDACTED]") { + t.Fatalf("expected redaction marker in prompt content, got %q", promptContent) + } + if strings.Contains(promptContent, "secret-prompt") { + t.Fatalf("private prompt content leaked: %q", promptContent) + } +} + func TestHandleStatsReturnsInternalServerErrorOnLoaderError(t *testing.T) { prev := loadServerStats loadServerStats = func(s *store.Store) (*store.Stats, error) { diff --git a/internal/setup/generate.go b/internal/setup/generate.go index ecf5c78b..df270b32 100644 --- a/internal/setup/generate.go +++ b/internal/setup/generate.go @@ -1,6 +1,6 @@ package setup // Sync embedded plugin copies from the source of truth (plugin/ directory). -// Only OpenCode needs embedding — Claude Code is installed via marketplace. +// OpenCode and Pi use offline embedded assets. // Run: go generate ./internal/setup/ -//go:generate sh -c "rm -rf plugins/opencode && mkdir -p plugins/opencode && cp ../../plugin/opencode/engram.ts plugins/opencode/" +//go:generate sh -c "rm -rf plugins/opencode plugins/pi && mkdir -p plugins/opencode plugins/pi/extensions plugins/pi/skills/engram && cp ../../plugin/opencode/engram.ts plugins/opencode/ && cp ../../plugin/pi/package.json plugins/pi/ && cp ../../plugin/pi/extensions/engram.ts plugins/pi/extensions/ && cp ../../plugin/pi/skills/engram/SKILL.md plugins/pi/skills/engram/" diff --git a/internal/setup/pi_runtime_harness_test.go b/internal/setup/pi_runtime_harness_test.go new file mode 100644 index 00000000..33ce277f --- /dev/null +++ b/internal/setup/pi_runtime_harness_test.go @@ -0,0 +1,69 @@ +package setup + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPiExtensionRuntimeHarnessDeterministic(t *testing.T) { + nodePath, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required for pi runtime harness") + } + + scriptPath := filepath.Join("..", "..", "plugin", "pi", "test", "runtime-harness.mjs") + if _, err := os.Stat(scriptPath); err != nil { + t.Fatalf("stat runtime harness script: %v", err) + } + + t.Run("source extension", func(t *testing.T) { + runPiRuntimeHarness(t, nodePath, scriptPath, filepath.Join("..", "..", "plugin", "pi", "extensions", "engram.ts")) + }) + + t.Run("embedded extension", func(t *testing.T) { + raw, err := piReadFile("plugins/pi/extensions/engram.ts") + if err != nil { + t.Fatalf("read embedded pi extension: %v", err) + } + + tmpPath := filepath.Join(t.TempDir(), "engram-embedded.ts") + if err := os.WriteFile(tmpPath, raw, 0644); err != nil { + t.Fatalf("write embedded extension fixture: %v", err) + } + + runPiRuntimeHarness(t, nodePath, scriptPath, tmpPath) + }) +} + +func runPiRuntimeHarness(t *testing.T, nodePath, scriptPath, extensionPath string) { + t.Helper() + + flagSets := [][]string{{"--experimental-strip-types"}, {"--experimental-transform-types"}} + var optionErrOutput string + + for _, flags := range flagSets { + args := append(append([]string{}, flags...), scriptPath, extensionPath) + cmd := exec.Command(nodePath, args...) + output, err := cmd.CombinedOutput() + if err == nil { + return + } + + stderr := string(output) + if strings.Contains(stderr, "bad option") || strings.Contains(stderr, "unknown option") { + optionErrOutput = stderr + continue + } + + t.Fatalf("pi runtime harness failed (%v):\n%s", err, stderr) + } + + if optionErrOutput != "" { + t.Skipf("node in this environment does not support TypeScript runtime flags needed by harness:\n%s", optionErrOutput) + } + + t.Fatal("pi runtime harness did not execute") +} diff --git a/internal/setup/plugins/pi/extensions/engram.ts b/internal/setup/plugins/pi/extensions/engram.ts new file mode 100644 index 00000000..6d2a1e21 --- /dev/null +++ b/internal/setup/plugins/pi/extensions/engram.ts @@ -0,0 +1,813 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent" +import { Type } from "typebox" +import { spawn } from "node:child_process" + +const ENGRAM_PORT = Number.parseInt(process.env.ENGRAM_PORT ?? "7437", 10) +const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}` +const ENGRAM_BIN = process.env.ENGRAM_BIN ?? "engram" + +const STARTUP_NOTICE = + "Engram has relevant memory for this project. Use mem_context or mem_search when useful." + +const COMPACTION_RECOVERY_NOTICE = + "FIRST ACTION REQUIRED: Call mem_session_summary with the compacted summary first, then call mem_context before continuing." + +const COMPACTION_SAVED_NOTICE = + "Compaction summary saved to Engram. Use mem_context to restore continuity." + +const COMPACTION_UNAVAILABLE_NOTICE = + "Compaction summary unavailable. Use /engram-recovery, then mem_context manually." + +const TOOL_GUIDELINES = [ + "Use mem_context for recent continuity before broad searches.", + "Use mem_search with specific project/scope filters when possible.", + "Use mem_save and mem_session_summary for durable continuity.", +] + +type EngramHTTPResult = { + ok: boolean + path: string + status?: number + data?: unknown + error?: string +} + +type BackendReadiness = { + ok: boolean + startupAttempted: boolean + startupError?: string +} + +const BACKEND_STARTUP_POLL_MS = [120, 240, 360, 600, 900] + +function redactPrivateTags(input: string): string { + if (!input) return "" + return input.replace(/[\s\S]*?<\/private>/gi, "[REDACTED]").trim() +} + +function redactValue(value: unknown): unknown { + if (typeof value === "string") { + return redactPrivateTags(value) + } + if (Array.isArray(value)) { + return value.map((item) => redactValue(item)) + } + if (value && typeof value === "object") { + const out: Record = {} + for (const [key, raw] of Object.entries(value as Record)) { + out[key] = redactValue(raw) + } + return out + } + return value +} + +function projectFromDirectory(directory: string): string { + const parts = directory.split(/[\\/]/).filter(Boolean) + return parts.at(-1) ?? "unknown" +} + +function parseSessionID(sessionFile: string): string { + const fileName = sessionFile.split(/[\\/]/).pop() ?? "" + return fileName.replace(/\.[^.]+$/, "") +} + +function deriveSessionId(ctx: any): string { + if (ctx?.sessionManager && typeof ctx.sessionManager.getSessionFile === "function") { + const sessionFile = ctx.sessionManager.getSessionFile() + if (typeof sessionFile === "string" && sessionFile.length > 0) { + return redactPrivateTags(parseSessionID(sessionFile)) + } + } + + const fallbackProject = projectFromDirectory(String(ctx?.cwd ?? "")) + return redactPrivateTags(`pi-${fallbackProject}`) +} + +function deriveRuntime(ctx: any): { sessionId: string; project: string; directory: string } { + const directory = redactPrivateTags(String(ctx.cwd ?? "")) + const project = redactPrivateTags(projectFromDirectory(directory)) + const sessionId = deriveSessionId(ctx) + return { sessionId, project, directory } +} + +async function engramFetch(path: string, init?: RequestInit): Promise { + try { + const response = await fetch(`${ENGRAM_URL}${path}`, init) + const status = response.status + const contentType = response.headers.get("content-type") ?? "" + + let data: unknown = null + if (status !== 204) { + if (contentType.includes("application/json")) { + data = await response.json() + } else { + const text = await response.text() + data = text.length > 0 ? text : null + } + } + + if (!response.ok) { + return { + ok: false, + path, + status, + data, + error: `HTTP ${status}`, + } + } + + return { + ok: true, + path, + status, + data: status === 204 ? { ok: true } : data, + } + } catch (error) { + return { + ok: false, + path, + error: error instanceof Error ? error.message : "network_error", + } + } +} + +async function postJSON(path: string, body: Record): Promise { + return engramFetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(redactValue(body)), + }) +} + +function successToolResult(summary: string, details: unknown) { + return { + content: [{ type: "text", text: summary }], + details, + } +} + +function failureToolResult(message: string, details: Record = {}) { + return { + content: [{ type: "text", text: message }], + details: { + ok: false, + ...details, + }, + isError: true, + } +} + +function compactPreview(value: unknown): string { + if (value === null || value === undefined) return "" + if (typeof value === "string") return value.slice(0, 200) + + try { + const encoded = JSON.stringify(value) + return encoded.slice(0, 200) + } catch { + return String(value).slice(0, 200) + } +} + +function extractObservationSummaryFields(payload: unknown): { id: string; title: string; content: string } { + if (!payload || typeof payload !== "object") { + return { id: "", title: "", content: "" } + } + + const record = payload as Record + const directID = typeof record.id === "string" || typeof record.id === "number" ? String(record.id) : "" + const directTitle = typeof record.title === "string" ? record.title : "" + const directContent = typeof record.content === "string" ? record.content : "" + if (directID || directTitle || directContent) { + return { id: directID, title: directTitle, content: directContent } + } + + const nestedObservation = record.observation + if (nestedObservation && typeof nestedObservation === "object") { + return extractObservationSummaryFields(nestedObservation) + } + + const nestedData = record.data + if (nestedData && typeof nestedData === "object") { + return extractObservationSummaryFields(nestedData) + } + + return { id: "", title: "", content: "" } +} + +function observationSummaryPreview(payload: unknown): string { + const summary = extractObservationSummaryFields(payload) + const titlePreview = summary.title ? redactPrivateTags(summary.title) : "" + const contentPreview = summary.content ? redactPrivateTags(summary.content) : "" + + if (titlePreview && contentPreview) { + return compactPreview(`${titlePreview} — ${contentPreview}`) + } + if (titlePreview) { + return compactPreview(titlePreview) + } + if (contentPreview) { + return compactPreview(contentPreview) + } + + return compactPreview(payload) +} + +function summarizeToolResult(toolName: string, result: EngramHTTPResult) { + if (!result.ok) { + return failureToolResult(`Engram request failed for ${toolName}.`, { + path: result.path, + status: result.status, + error: result.error, + response: result.data, + }) + } + + if (toolName === "mem_save") { + const payload = result.data as Record | null + const id = payload && payload.id ? String(payload.id) : "" + const title = payload && payload.title ? String(payload.title) : "" + + if (id && title) { + return successToolResult(`Memory saved (#${id}: ${title}).`, result) + } + if (id) { + return successToolResult(`Memory saved (#${id}).`, result) + } + return successToolResult("Memory saved.", result) + } + + if (toolName === "mem_search") { + const count = Array.isArray(result.data) ? result.data.length : 0 + if (count > 0) { + return successToolResult(`Found ${count} memory result(s).`, result) + } + const preview = compactPreview(result.data) + return successToolResult(preview ? `Search complete. ${preview}` : "Search complete.", result) + } + + if (toolName === "mem_context") { + const preview = compactPreview(result.data) + return successToolResult(preview ? `Memory context loaded. ${preview}` : "Memory context loaded.", result) + } + + if (toolName === "mem_get_observation") { + const summary = extractObservationSummaryFields(result.data) + const id = summary.id + const preview = observationSummaryPreview(result.data) + if (id) { + if (preview) { + return successToolResult(`Loaded observation #${id}. ${preview}`, result) + } + return successToolResult(`Loaded observation #${id}.`, result) + } + return successToolResult(preview ? `Observation loaded. ${preview}` : "Observation loaded.", result) + } + + if (toolName === "mem_session_summary") { + return successToolResult("Session summary saved.", result) + } + + if (toolName === "mem_save_prompt") { + return successToolResult("Prompt saved.", result) + } + + return successToolResult("Engram request completed.", result) +} + +function queryString(params: Record): string { + const query = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") continue + query.set(key, String(redactValue(value))) + } + const qs = query.toString() + return qs.length > 0 ? `?${qs}` : "" +} + +function observationsFromRecentResult(result: unknown): unknown[] { + if (Array.isArray(result)) return result + if (!result || typeof result !== "object") return [] + + const directObservations = (result as { observations?: unknown[] }).observations + if (Array.isArray(directObservations)) return directObservations + + const wrappedData = (result as { data?: unknown }).data + if (Array.isArray(wrappedData)) return wrappedData + if (wrappedData && typeof wrappedData === "object") { + const dataObservations = wrappedData.observations + if (Array.isArray(dataObservations)) return dataObservations + + const nestedData = (wrappedData as { data?: unknown }).data + if (Array.isArray(nestedData)) return nestedData + if (nestedData && typeof nestedData === "object") { + const nestedObservations = (nestedData as { observations?: unknown[] }).observations + if (Array.isArray(nestedObservations)) return nestedObservations + } + } + + return [] +} + +async function isRunning(): Promise { + try { + const response = await fetch(`${ENGRAM_URL}/health`, { signal: AbortSignal.timeout(500) }) + return response.ok + } catch { + return false + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function backendFailureToolResult(toolName: string, readiness: BackendReadiness) { + return failureToolResult(`Engram auto-start failed for ${toolName}.`, { + autoStartFailed: true, + startupAttempted: readiness.startupAttempted, + startupError: readiness.startupError, + }) +} + +async function ensureBackend(): Promise { + if (await isRunning()) { + return { ok: true, startupAttempted: false } + } + + try { + const child = spawn(ENGRAM_BIN, ["serve"], { + stdio: "ignore", + detached: true, + }) + if (typeof child.unref === "function") { + child.unref() + } + + for (let attempt = 0; attempt < BACKEND_STARTUP_POLL_MS.length; attempt++) { + if (await isRunning()) { + return { ok: true, startupAttempted: true } + } + await sleep(BACKEND_STARTUP_POLL_MS[attempt]) + } + } catch (error) { + return { + ok: false, + startupAttempted: true, + startupError: error instanceof Error ? error.message : "spawn_failed", + } + } + + return { + ok: false, + startupAttempted: true, + startupError: "backend_not_ready_after_spawn", + } +} + +function registerMemoryTools(pi: any): void { + if (typeof pi.registerTool !== "function") return + + pi.registerTool({ + name: "mem_search", + label: "Engram Memory Search", + description: "Search Engram observations using full text and filters.", + parameters: Type.Object({ + query: Type.String({ minLength: 1 }), + project: Type.Optional(Type.String()), + scope: Type.Optional(Type.String()), + type: Type.Optional(Type.String()), + limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), + }), + promptSnippet: "Call mem_search for recall and continuity lookups.", + promptGuidelines: ["Use mem_search by name when querying historical observations.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_search", ready) + } + const runtime = deriveRuntime(ctx) + const result = await engramFetch( + `/search${queryString({ + q: params.query, + project: params.project ?? runtime.project, + scope: params.scope, + type: params.type, + limit: params.limit ?? 10, + })}`, + ) + return summarizeToolResult("mem_search", result) + }, + }) + + pi.registerTool({ + name: "mem_context", + label: "Engram Memory Context", + description: "Fetch compact project context from Engram.", + parameters: Type.Object({ + project: Type.Optional(Type.String()), + scope: Type.Optional(Type.String()), + }), + promptSnippet: "Call mem_context for compact session continuity.", + promptGuidelines: ["Use mem_context by name at startup or after compaction.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_context", ready) + } + const runtime = deriveRuntime(ctx) + const result = await engramFetch( + `/context${queryString({ + project: params.project ?? runtime.project, + scope: params.scope, + })}`, + ) + return summarizeToolResult("mem_context", result) + }, + }) + + pi.registerTool({ + name: "mem_save", + label: "Engram Memory Save", + description: "Save an observation to Engram memory.", + parameters: Type.Object({ + title: Type.String({ minLength: 1 }), + content: Type.String({ minLength: 1 }), + type: Type.Optional(Type.String()), + project: Type.Optional(Type.String()), + scope: Type.Optional(Type.String()), + topic_key: Type.Optional(Type.String()), + session_id: Type.Optional(Type.String()), + }), + promptSnippet: "Call mem_save immediately after decisions, bugfixes, and discoveries.", + promptGuidelines: ["Use mem_save by name with structured What/Why/Where/Learned content.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_save", ready) + } + const runtime = deriveRuntime(ctx) + const result = await postJSON("/observations", { + session_id: params.session_id ?? runtime.sessionId, + project: params.project ?? runtime.project, + title: params.title, + content: params.content, + scope: params.scope ?? "project", + type: params.type ?? "manual", + topic_key: params.topic_key, + }) + return summarizeToolResult("mem_save", result) + }, + }) + + pi.registerTool({ + name: "mem_session_summary", + label: "Engram Session Summary", + description: "Persist end-of-session summary for continuity.", + parameters: Type.Object({ + content: Type.String({ minLength: 1 }), + session_id: Type.Optional(Type.String()), + }), + promptSnippet: "Call mem_session_summary before ending work or after compaction.", + promptGuidelines: ["Use mem_session_summary by name before saying done.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_session_summary", ready) + } + const runtime = deriveRuntime(ctx) + const sessionId = redactPrivateTags(String(params.session_id ?? runtime.sessionId)) + if (!sessionId) { + return failureToolResult("Engram request failed for mem_session_summary.", { + path: "/sessions/{id}/end", + error: "missing_session_id", + }) + } + + const result = await postJSON(`/sessions/${encodeURIComponent(sessionId)}/end`, { + summary: params.content, + }) + return summarizeToolResult("mem_session_summary", result) + }, + }) + + pi.registerTool({ + name: "mem_get_observation", + label: "Engram Get Observation", + description: "Get a full observation by ID.", + parameters: Type.Object({ + id: Type.Number({ minimum: 1 }), + }), + promptSnippet: "Call mem_get_observation for full untruncated memory content.", + promptGuidelines: ["Use mem_get_observation by name after mem_search for full details.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + void ctx + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_get_observation", ready) + } + const result = await engramFetch(`/observations/${encodeURIComponent(String(params.id))}`) + return summarizeToolResult("mem_get_observation", result) + }, + }) + + pi.registerTool({ + name: "mem_save_prompt", + label: "Engram Save Prompt", + description: "Persist user prompt text to Engram for history.", + parameters: Type.Object({ + content: Type.String({ minLength: 1 }), + project: Type.Optional(Type.String()), + session_id: Type.Optional(Type.String()), + }), + promptSnippet: "Call mem_save_prompt when explicit prompt archival is needed.", + promptGuidelines: ["Use mem_save_prompt by name only for prompt capture use-cases.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_save_prompt", ready) + } + const runtime = deriveRuntime(ctx) + const result = await postJSON("/prompts", { + session_id: params.session_id ?? runtime.sessionId, + project: params.project ?? runtime.project, + content: params.content, + }) + return summarizeToolResult("mem_save_prompt", result) + }, + }) +} + +function registerRecoveryCommand(pi: any): void { + if (typeof pi.registerCommand !== "function") { + return + } + + pi.registerCommand("engram-recovery", { + description: "Shows the compacted-session recovery instructions for Engram memory protocol.", + handler: (_args: unknown, ctx: any) => { + if (ctx?.hasUI && ctx?.ui?.notify) { + ctx.ui.notify(COMPACTION_RECOVERY_NOTICE, "info") + } + return COMPACTION_RECOVERY_NOTICE + }, + }) +} + +function compactionInstruction(): string { + return COMPACTION_RECOVERY_NOTICE +} + +function prependInstruction(target: unknown, instruction: string): boolean { + if (Array.isArray(target)) { + target.unshift(instruction) + return true + } + + if (target && typeof target === "object") { + const record = target as Record + if (typeof record.value === "string") { + record.value = `${instruction}\n${record.value}`.trim() + return true + } + } + + return false +} + +function injectCompactionInstruction(event: any): boolean { + const instruction = compactionInstruction() + + if (event && Array.isArray(event.customInstructions)) { + event.customInstructions.unshift(instruction) + return true + } + + if (event && typeof event.customInstructions === "string") { + event.customInstructions = `${instruction}\n${event.customInstructions}`.trim() + return true + } + + if (event && event.customInstructions && typeof event.customInstructions === "object") { + if (Array.isArray(event.customInstructions.items)) { + event.customInstructions.items.unshift(instruction) + return true + } + } + + if (event?.compactionEntry && Array.isArray(event.compactionEntry.customInstructions)) { + event.compactionEntry.customInstructions.unshift(instruction) + return true + } + + if (event?.compactionEntry && prependInstruction(event.compactionEntry.customInstructions, instruction)) { + return true + } + + if (event?.compaction && Array.isArray(event.compaction.customInstructions)) { + event.compaction.customInstructions.unshift(instruction) + return true + } + + if (event?.compaction && prependInstruction(event.compaction.customInstructions, instruction)) { + return true + } + + return false +} + +function extractSummaryText(value: unknown): string { + if (typeof value === "string") { + return redactPrivateTags(value).trim() + } + + if (!value || typeof value !== "object") { + return "" + } + + const candidates = [ + "summary", + "content", + "text", + "compactedSummary", + "compactSummary", + "finalSummary", + "entry", + ] + + for (const key of candidates) { + const raw = (value as Record)[key] + if (typeof raw === "string") { + const clean = redactPrivateTags(raw).trim() + if (clean) return clean + } + if (raw && typeof raw === "object") { + const nested = extractSummaryText(raw) + if (nested) return nested + } + } + + return "" +} + +function extractCompactionSummary(event: any): string { + const candidates: unknown[] = [ + event?.compactionEntry, + event?.compaction, + event?.summary, + event?.compactedSummary, + event?.compactSummary, + event?.content, + event?.text, + event, + ] + + for (const candidate of candidates) { + const summary = extractSummaryText(candidate) + if (summary) return summary + } + + return "" +} + +function notifyCompactionRecovery(ctx: any): void { + const recoveryInstruction = compactionInstruction() + + if (ctx?.hasUI && ctx?.ui?.notify) { + ctx.ui.notify(recoveryInstruction, "info") + } +} + +async function persistCompactionSummary(event: any, ctx: any): Promise { + const runtime = deriveRuntime(ctx) + const compactedSummary = extractCompactionSummary(event) + if (!runtime.sessionId || !compactedSummary) { + return false + } + + const result = await postJSON(`/sessions/${encodeURIComponent(runtime.sessionId)}/end`, { + summary: compactedSummary, + }) + + return Boolean(result.ok) +} + +function notifyCompactionResult(saved: boolean, ctx: any): void { + if (!ctx?.hasUI || !ctx?.ui?.notify) { + return + } + + if (saved) { + ctx.ui.notify(COMPACTION_SAVED_NOTICE, "info") + return + } + + ctx.ui.notify(COMPACTION_UNAVAILABLE_NOTICE, "info") +} + +export default function (pi: ExtensionAPI): void { + registerMemoryTools(pi) + registerRecoveryCommand(pi) + + pi.on("session_start", async (event, ctx) => { + const ready = await ensureBackend() + if (!ready.ok) return + const runtime = deriveRuntime(ctx) + const reason = redactPrivateTags(String(event.reason ?? "")) + + if (runtime.sessionId && runtime.project) { + await postJSON("/sessions", { + id: runtime.sessionId, + project: runtime.project, + directory: runtime.directory, + reason, + }) + } + + if (!runtime.project) return + const result = await engramFetch( + `/observations/recent${queryString({ project: runtime.project, scope: "project", limit: 1 })}`, + ) + const observations = observationsFromRecentResult(result) + + if (observations.length > 0 && ctx.hasUI && ctx.ui?.notify) { + ctx.ui.notify(STARTUP_NOTICE, "info") + } + }) + + pi.on("session_shutdown", async (event, ctx) => { + const ready = await ensureBackend() + if (!ready.ok) return + const runtime = deriveRuntime(ctx) + const reason = redactPrivateTags(String(event.reason ?? "")) + const target = redactPrivateTags(String(event.target ?? "")) + + if (!runtime.sessionId) return + await postJSON(`/sessions/${encodeURIComponent(runtime.sessionId)}/end`, { + summary: `shutdown reason=${reason} target=${target}`, + }) + }) + + pi.on("input", async (event, ctx) => { + if (event.source === "extension") { + return + } + + const ready = await ensureBackend() + if (!ready.ok) return + const runtime = deriveRuntime(ctx) + if (!runtime.sessionId || !runtime.project) return + + await postJSON("/prompts", { + session_id: runtime.sessionId, + project: runtime.project, + content: String(event.text ?? ""), + source: String(event.source ?? ""), + images: event.images, + }) + }) + + pi.on("session_before_compact", async (event, ctx) => { + const ready = await ensureBackend() + if (!ready.ok) { + notifyCompactionRecovery(ctx) + return + } + if (!injectCompactionInstruction(event)) { + notifyCompactionRecovery(ctx) + } + }) + + pi.on("session_compact", async (event, ctx) => { + const ready = await ensureBackend() + if (!ready.ok) { + notifyCompactionRecovery(ctx) + return + } + + const saved = await persistCompactionSummary(event, ctx) + notifyCompactionResult(saved, ctx) + if (!saved) { + notifyCompactionRecovery(ctx) + } + }) +} diff --git a/internal/setup/plugins/pi/package.json b/internal/setup/plugins/pi/package.json new file mode 100644 index 00000000..a639f442 --- /dev/null +++ b/internal/setup/plugins/pi/package.json @@ -0,0 +1,24 @@ +{ + "name": "engram-pi", + "version": "0.2.0", + "private": true, + "description": "Official Engram extension package for pi-coding-agent", + "type": "module", + "keywords": [ + "pi-package", + "engram", + "memory" + ], + "pi": { + "extensions": [ + "./extensions/engram.ts" + ], + "skills": [ + "./skills" + ] + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*", + "typebox": "*" + } +} diff --git a/internal/setup/plugins/pi/skills/engram/SKILL.md b/internal/setup/plugins/pi/skills/engram/SKILL.md new file mode 100644 index 00000000..cff55499 --- /dev/null +++ b/internal/setup/plugins/pi/skills/engram/SKILL.md @@ -0,0 +1,36 @@ +--- +name: engram +description: Memory protocol guidance for Engram's Pi integration. +--- + +## Engram Persistent Memory — Protocol (Pi) + +You have access to Engram persistent memory tools (`mem_*`) as native Pi tools from the extension. + +### Save immediately after +- Bug fixes +- Architecture/design decisions +- Non-obvious discoveries +- Config changes +- Established patterns +- User preferences + +### Search rules +- On recall requests, call `mem_context` first, then `mem_search`. +- Proactive lookup is allowed only when continuity likelihood is high. +- Startup policy is conservative: notify memory availability, do not auto-inject full context. + +### Session close +Before ending a session, call `mem_session_summary` with goal, discoveries, accomplished work, next steps, and relevant files. + +### After compaction +If a compacted summary is present, first call `mem_session_summary` with that content, then call `mem_context`. + +### Compaction hook behavior +- `session_before_compact`: extension injects `FIRST ACTION REQUIRED` into compaction instructions when supported by event shape. +- `session_compact`: extension attempts to persist the compacted summary through Engram session summary endpoint and notifies whether persistence succeeded. +- If summary extraction/persistence is unavailable, run `/engram-recovery` and continue with manual `mem_context`. + +### Pi adapter limitations (validated v0.70.0 contract) +- Engram does not auto-inject full previous context after compaction; `mem_context` stays manual. +- Recovery guidance remains available through `/engram-recovery` for runtimes with partial hook payloads. diff --git a/internal/setup/setup.go b/internal/setup/setup.go index dbc98a85..56864a41 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -10,6 +10,7 @@ // absolute binary path so the subprocess never needs PATH resolution. // - Gemini CLI: injects MCP registration in ~/.gemini/settings.json // - Codex: injects MCP registration in ~/.codex/config.toml +// - Pi: materializes embedded local package and runs `pi install ` package setup import ( @@ -34,6 +35,9 @@ var ( openCodeReadFile = func(path string) ([]byte, error) { return openCodeFS.ReadFile(path) } + piReadFile = func(path string) ([]byte, error) { + return piFS.ReadFile(path) + } statFn = os.Stat openCodeWriteFileFn = os.WriteFile readFileFn = os.ReadFile @@ -53,6 +57,9 @@ var ( //go:embed plugins/opencode/* var openCodeFS embed.FS +//go:embed plugins/pi/* plugins/pi/extensions/* plugins/pi/skills/* plugins/pi/skills/engram/* +var piFS embed.FS + // Agent represents a supported AI coding agent. type Agent struct { Name string @@ -232,6 +239,11 @@ func SupportedAgents() []Agent { Description: "Codex — MCP registration plus model/compaction instruction files", InstallDir: codexConfigPath(), }, + { + Name: "pi", + Description: "pi-coding-agent — Native extension + skill with notify-only memory startup", + InstallDir: piPackageDir(), + }, } } @@ -246,9 +258,64 @@ func Install(agentName string) (*Result, error) { return installGeminiCLI() case "codex": return installCodex() + case "pi": + return installPi() default: - return nil, fmt.Errorf("unknown agent: %q (supported: opencode, claude-code, gemini-cli, codex)", agentName) + return nil, fmt.Errorf("unknown agent: %q (supported: opencode, claude-code, gemini-cli, codex, pi)", agentName) + } +} + +func installPi() (*Result, error) { + packageDir := piPackageDir() + if err := os.MkdirAll(packageDir, 0755); err != nil { + return nil, fmt.Errorf("create pi package dir %s: %w", packageDir, err) + } + + assets := []struct { + embedded string + dest string + }{ + {embedded: "plugins/pi/package.json", dest: filepath.Join(packageDir, "package.json")}, + {embedded: "plugins/pi/extensions/engram.ts", dest: filepath.Join(packageDir, "extensions", "engram.ts")}, + {embedded: "plugins/pi/skills/engram/SKILL.md", dest: filepath.Join(packageDir, "skills", "engram", "SKILL.md")}, + } + + filesWritten := 0 + for _, asset := range assets { + content, err := piReadFile(asset.embedded) + if err != nil { + return nil, fmt.Errorf("read embedded %s: %w", filepath.Base(asset.dest), err) + } + + assetDir := filepath.Dir(asset.dest) + if err := os.MkdirAll(assetDir, 0755); err != nil { + return nil, fmt.Errorf("create pi asset dir %s: %w", assetDir, err) + } + if err := writeFileFn(asset.dest, content, 0644); err != nil { + return nil, fmt.Errorf("write %s: %w", asset.dest, err) + } + filesWritten++ + } + + piBin, err := lookPathFn("pi") + if err != nil { + return nil, fmt.Errorf("pi CLI not found in PATH. Install pi-coding-agent and rerun 'engram setup pi': %w", err) + } + + output, err := runCommand(piBin, "install", packageDir) + if err != nil { + trimmed := strings.TrimSpace(string(output)) + if trimmed != "" { + return nil, fmt.Errorf("run pi install %s: %w: %s", packageDir, err, trimmed) + } + return nil, fmt.Errorf("run pi install %s: %w", packageDir, err) } + + return &Result{ + Agent: "pi", + Destination: packageDir, + Files: filesWritten + 1, + }, nil } // ─── OpenCode ──────────────────────────────────────────────────────────────── @@ -1005,3 +1072,24 @@ func codexInstructionsPath() string { func codexCompactPromptPath() string { return filepath.Join(filepath.Dir(codexConfigPath()), "engram-compact-prompt.md") } + +func piConfigDir() string { + home, _ := userHomeDir() + + if runtimeGOOS == "windows" { + if appData := os.Getenv("APPDATA"); appData != "" { + return filepath.Join(appData, "pi-coding-agent") + } + return filepath.Join(home, "AppData", "Roaming", "pi-coding-agent") + } + + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "pi-coding-agent") + } + + return filepath.Join(home, ".config", "pi-coding-agent") +} + +func piPackageDir() string { + return filepath.Join(piConfigDir(), "packages", "engram") +} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 381ca312..5ce0f3e4 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" "testing" ) @@ -17,6 +18,7 @@ func resetSetupSeams(t *testing.T) { oldRunCommand := runCommand oldStatFn := statFn oldOpenCodeReadFile := openCodeReadFile + oldPiReadFile := piReadFile oldOpenCodeWriteFileFn := openCodeWriteFileFn oldReadFileFn := readFileFn oldWriteFileFn := writeFileFn @@ -39,6 +41,7 @@ func resetSetupSeams(t *testing.T) { runCommand = oldRunCommand statFn = oldStatFn openCodeReadFile = oldOpenCodeReadFile + piReadFile = oldPiReadFile openCodeWriteFileFn = oldOpenCodeWriteFileFn readFileFn = oldReadFileFn writeFileFn = oldWriteFileFn @@ -68,6 +71,7 @@ func TestSupportedAgentsIncludesGeminiAndCodex(t *testing.T) { var hasGemini bool var hasCodex bool + var hasPi bool for _, agent := range agents { if agent.Name == "gemini-cli" { hasGemini = true @@ -75,6 +79,9 @@ func TestSupportedAgentsIncludesGeminiAndCodex(t *testing.T) { if agent.Name == "codex" { hasCodex = true } + if agent.Name == "pi" { + hasPi = true + } } if !hasGemini { @@ -83,6 +90,693 @@ func TestSupportedAgentsIncludesGeminiAndCodex(t *testing.T) { if !hasCodex { t.Fatalf("expected codex in supported agents") } + if !hasPi { + t.Fatalf("expected pi in supported agents") + } +} + +func TestInstallPiCreatesGlobalAssetsAndIsIdempotent(t *testing.T) { + resetSetupSeams(t) + home := useTestHome(t) + runtimeGOOS = "linux" + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) + + lookPathFn = func(file string) (string, error) { + if file != "pi" { + t.Fatalf("expected pi binary lookup, got %q", file) + } + return "/usr/bin/pi", nil + } + + var installCalls int + runCommand = func(name string, args ...string) ([]byte, error) { + if name != "/usr/bin/pi" { + t.Fatalf("expected pi binary path, got %q", name) + } + if len(args) != 2 || args[0] != "install" { + t.Fatalf("expected 'pi install ', got %q %v", name, args) + } + installCalls++ + return []byte("ok"), nil + } + + result, err := Install("pi") + if err != nil { + t.Fatalf("install pi: %v", err) + } + + if result.Agent != "pi" { + t.Fatalf("unexpected agent in result: %q", result.Agent) + } + + if result.Files != 4 { + t.Fatalf("expected 4 setup actions (3 files + installer), got %d", result.Files) + } + + if !strings.HasSuffix(result.Destination, filepath.Join("pi-coding-agent", "packages", "engram")) { + t.Fatalf("unexpected install destination: %q", result.Destination) + } + + extensionPath := filepath.Join(result.Destination, "extensions", "engram.ts") + if _, err := os.Stat(extensionPath); err != nil { + t.Fatalf("expected extensions/engram.ts to exist: %v", err) + } + + packagePath := filepath.Join(result.Destination, "package.json") + if _, err := os.Stat(packagePath); err != nil { + t.Fatalf("expected package.json to exist: %v", err) + } + + skillPath := filepath.Join(result.Destination, "skills", "engram", "SKILL.md") + if _, err := os.Stat(skillPath); err != nil { + t.Fatalf("expected skill file to exist: %v", err) + } + + if installCalls != 1 { + t.Fatalf("expected one pi install invocation, got %d", installCalls) + } + + if _, err := Install("pi"); err != nil { + t.Fatalf("second install should be idempotent: %v", err) + } + + if installCalls != 2 { + t.Fatalf("expected second run to invoke installer again, got %d calls", installCalls) + } + + if _, err := os.Stat(filepath.Join(home, "xdg", "pi-coding-agent", "config.json")); !os.IsNotExist(err) { + t.Fatalf("expected setup to avoid writing guessed pi config internals") + } +} + +func TestInstallPiFailsWithClearErrorWhenPiBinaryMissing(t *testing.T) { + resetSetupSeams(t) + useTestHome(t) + runtimeGOOS = "linux" + + lookPathFn = func(file string) (string, error) { + if file != "pi" { + t.Fatalf("expected pi lookup, got %q", file) + } + return "", errors.New("not found") + } + + _, err := Install("pi") + if err == nil { + t.Fatalf("expected missing pi binary error") + } + if !strings.Contains(err.Error(), "pi CLI not found") { + t.Fatalf("expected clear pi missing error, got %v", err) + } +} + +func TestInstallPiReadEmbeddedError(t *testing.T) { + resetSetupSeams(t) + runtimeGOOS = "linux" + useTestHome(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + piReadFile = func(path string) ([]byte, error) { + return nil, errors.New("missing embedded asset") + } + + _, err := Install("pi") + if err == nil || !strings.Contains(err.Error(), "read embedded") { + t.Fatalf("expected embedded read error, got %v", err) + } +} + +func TestInstallPiOfflineGlobalAndStableAcrossOSVariants(t *testing.T) { + for _, tc := range []struct { + name string + goos string + useXDG bool + useAppData bool + }{ + {name: "linux-xdg", goos: "linux", useXDG: true}, + {name: "darwin-default", goos: "darwin"}, + {name: "windows-appdata", goos: "windows", useAppData: true}, + } { + t.Run(tc.name, func(t *testing.T) { + resetSetupSeams(t) + home := useTestHome(t) + runtimeGOOS = tc.goos + + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("APPDATA", "") + if tc.useXDG { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg")) + } + if tc.useAppData { + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + } + + lookPathFn = func(file string) (string, error) { + if file != "pi" { + t.Fatalf("expected pi lookup, got %q", file) + } + return "/usr/local/bin/pi", nil + } + + var invokedInstall bool + runCommand = func(name string, args ...string) ([]byte, error) { + if name != "/usr/local/bin/pi" { + t.Fatalf("expected resolved pi binary, got %q", name) + } + if len(args) != 2 || args[0] != "install" { + t.Fatalf("expected 'pi install ', got %s %v", name, args) + } + invokedInstall = true + return []byte("installed"), nil + } + + result, err := Install("pi") + if err != nil { + t.Fatalf("install pi: %v", err) + } + + if result.Agent != "pi" { + t.Fatalf("unexpected agent in result: %q", result.Agent) + } + if result.Files != 4 { + t.Fatalf("expected 4 setup actions (3 files + installer), got %d", result.Files) + } + + expectedConfigDir := filepath.Join(home, ".config", "pi-coding-agent") + if tc.useXDG { + expectedConfigDir = filepath.Join(home, "xdg", "pi-coding-agent") + } + if tc.useAppData { + expectedConfigDir = filepath.Join(home, "AppData", "Roaming", "pi-coding-agent") + } + + expectedDest := filepath.Join(expectedConfigDir, "packages", "engram") + if result.Destination != expectedDest { + t.Fatalf("unexpected destination. want=%q got=%q", expectedDest, result.Destination) + } + + mustExist := []string{ + filepath.Join(expectedDest, "extensions", "engram.ts"), + filepath.Join(expectedDest, "package.json"), + filepath.Join(expectedDest, "skills", "engram", "SKILL.md"), + } + for _, path := range mustExist { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected asset %s: %v", path, err) + } + } + + if !invokedInstall { + t.Fatalf("expected pi install invocation for %s", tc.name) + } + + if _, err := Install("pi"); err != nil { + t.Fatalf("second install should be idempotent: %v", err) + } + + if _, err := os.Stat(filepath.Join(expectedConfigDir, "config.json")); !os.IsNotExist(err) { + t.Fatalf("expected setup to avoid writing pi config.json for %s", tc.name) + } + }) + } +} + +func TestPiContractChecklistFixtureIncludesRequiredAssumptions(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "pi-contract.json")) + if err != nil { + t.Fatalf("read pi contract fixture: %v", err) + } + + type contractItem struct { + Status string `json:"status"` + } + + var checklist map[string]contractItem + if err := json.Unmarshal(raw, &checklist); err != nil { + t.Fatalf("parse pi contract fixture: %v", err) + } + + required := []string{ + "global_install_paths", + "extension_manifest_schema", + "lifecycle_hooks", + "compaction_hook_payload", + "notification_api", + } + + for _, key := range required { + item, ok := checklist[key] + if !ok { + t.Fatalf("missing contract key %q", key) + } + if item.Status == "" { + t.Fatalf("expected non-empty status for %q", key) + } + } +} + +func TestPiPackageManifestDeclaresExtensionsAndSkills(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "plugin", "pi", "package.json")) + if err != nil { + t.Fatalf("read pi package manifest: %v", err) + } + + var manifest struct { + Type string `json:"type"` + Keywords []string `json:"keywords"` + Pi struct { + Extensions []string `json:"extensions"` + Skills []string `json:"skills"` + } `json:"pi"` + PeerDependencies map[string]string `json:"peerDependencies"` + } + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatalf("parse pi package manifest: %v", err) + } + + if len(manifest.Pi.Extensions) != 1 || manifest.Pi.Extensions[0] != "./extensions/engram.ts" { + t.Fatalf("expected pi.extensions to include ./extensions/engram.ts, got %#v", manifest.Pi.Extensions) + } + if len(manifest.Pi.Skills) != 1 || manifest.Pi.Skills[0] != "./skills" { + t.Fatalf("expected pi.skills to include ./skills, got %#v", manifest.Pi.Skills) + } + if manifest.Type != "module" { + t.Fatalf("expected package type module, got %q", manifest.Type) + } + if !slices.Contains(manifest.Keywords, "pi-package") { + t.Fatalf("expected package keywords to include pi-package, got %#v", manifest.Keywords) + } + if got := manifest.PeerDependencies["@mariozechner/pi-coding-agent"]; got != "*" { + t.Fatalf("expected peer dependency @mariozechner/pi-coding-agent to be '*', got %q", got) + } + if got := manifest.PeerDependencies["typebox"]; got != "*" { + t.Fatalf("expected peer dependency typebox to be '*', got %q", got) + } +} + +func TestPiSkillIncludesRequiredFrontmatterMetadata(t *testing.T) { + tests := []struct { + name string + read func() ([]byte, error) + }{ + { + name: "source skill", + read: func() ([]byte, error) { + return os.ReadFile(filepath.Join("..", "..", "plugin", "pi", "skills", "engram", "SKILL.md")) + }, + }, + { + name: "embedded skill", + read: func() ([]byte, error) { + return piReadFile("plugins/pi/skills/engram/SKILL.md") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + raw, err := tc.read() + if err != nil { + t.Fatalf("read pi skill: %v", err) + } + + content := string(raw) + if !strings.HasPrefix(content, "---\n") { + t.Fatalf("expected YAML frontmatter delimiter at top of skill") + } + if !strings.Contains(content, "\nname: engram\n") { + t.Fatalf("expected frontmatter to include name: engram") + } + if !strings.Contains(content, "\ndescription:") { + t.Fatalf("expected frontmatter to include description field") + } + if !strings.Contains(content, "\n---\n\n## Engram Persistent Memory") { + t.Fatalf("expected markdown body after frontmatter") + } + }) + } +} + +func TestPiEmbeddedAssetsMatchSourceFiles(t *testing.T) { + tests := []struct { + sourcePath string + embeddedPath string + }{ + {sourcePath: filepath.Join("..", "..", "plugin", "pi", "extensions", "engram.ts"), embeddedPath: "plugins/pi/extensions/engram.ts"}, + {sourcePath: filepath.Join("..", "..", "plugin", "pi", "package.json"), embeddedPath: "plugins/pi/package.json"}, + {sourcePath: filepath.Join("..", "..", "plugin", "pi", "skills", "engram", "SKILL.md"), embeddedPath: "plugins/pi/skills/engram/SKILL.md"}, + } + + for _, tc := range tests { + t.Run(tc.embeddedPath, func(t *testing.T) { + source, err := os.ReadFile(tc.sourcePath) + if err != nil { + t.Fatalf("read source asset: %v", err) + } + + embedded, err := piReadFile(tc.embeddedPath) + if err != nil { + t.Fatalf("read embedded asset: %v", err) + } + + if string(source) != string(embedded) { + t.Fatalf("embedded asset drift for %s; run go generate ./internal/setup/", tc.embeddedPath) + } + }) + } +} + +func TestPiExtensionHasNotifyOnlyStartupAndCompactionRecovery(t *testing.T) { + tests := []struct { + name string + read func() ([]byte, error) + }{ + { + name: "source extension", + read: func() ([]byte, error) { + return os.ReadFile(filepath.Join("..", "..", "plugin", "pi", "extensions", "engram.ts")) + }, + }, + { + name: "embedded extension", + read: func() ([]byte, error) { + return piReadFile("plugins/pi/extensions/engram.ts") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + raw, err := tc.read() + if err != nil { + t.Fatalf("read pi extension: %v", err) + } + src := string(raw) + + if strings.Contains(src, "engramFetch(\"/context") || strings.Contains(src, "engramFetch(`/context") { + t.Fatalf("pi extension must not auto-inject /context at startup") + } + + mustContain := []string{ + "import type { ExtensionAPI } from \"@mariozechner/pi-coding-agent\"", + "import { Type } from \"typebox\"", + "import { spawn } from \"node:child_process\"", + "type BackendReadiness = {", + "const BACKEND_STARTUP_POLL_MS = [120, 240, 360, 600, 900]", + "function sleep(ms: number): Promise", + "startupAttempted: boolean", + "startupError:", + "await sleep(BACKEND_STARTUP_POLL_MS[attempt])", + "/health", + "/sessions", + "/sessions/${encodeURIComponent(sessionId)}/end", + "FIRST ACTION REQUIRED", + "pi.on(\"session_start\", async (event, ctx)", + "pi.on(\"session_shutdown\", async (event, ctx)", + "pi.on(\"input\", async (event, ctx)", + "pi.on(\"session_before_compact\", async (event, ctx)", + "pi.on(\"session_compact\", async (event, ctx)", + "ctx.cwd", + "ctx.sessionManager.getSessionFile()", + "ctx.ui.notify(STARTUP_NOTICE, \"info\")", + "function observationsFromRecentResult(result: unknown): unknown[]", + "if (Array.isArray(result)) return result", + "const directObservations = (result as { observations?: unknown[] }).observations", + "const dataObservations = wrappedData.observations", + "const observations = observationsFromRecentResult(result)", + "if (observations.length > 0 && ctx.hasUI && ctx.ui?.notify)", + "event.reason", + "event.text", + "event.source === \"extension\"", + "pi.registerTool({", + "name: \"mem_search\"", + "name: \"mem_context\"", + "name: \"mem_save\"", + "name: \"mem_session_summary\"", + "name: \"mem_get_observation\"", + "name: \"mem_save_prompt\"", + "label:", + "description:", + "parameters: Type.Object({", + "async execute(toolCallId, params, signal, onUpdate, ctx)", + "promptSnippet", + "promptGuidelines", + "function successToolResult(summary: string, details: unknown)", + "function failureToolResult(message: string, details: Record = {})", + "function backendFailureToolResult(toolName: string, readiness: BackendReadiness)", + "Engram auto-start failed for", + "const child = spawn(ENGRAM_BIN, [\"serve\"], {", + "detached: true", + "if (typeof child.unref === \"function\")", + "child.unref()", + "content: [{ type: \"text\", text: summary }]", + "isError: true", + "function extractObservationSummaryFields(payload: unknown)", + "const nestedObservation = record.observation", + "const nestedData = record.data", + "const titlePreview = summary.title ? redactPrivateTags(summary.title) : \"\"", + "const contentPreview = summary.content ? redactPrivateTags(summary.content) : \"\"", + "Loaded observation #${id}. ${preview}", + "pi.registerCommand(\"engram-recovery\"", + "ctx.ui.notify(COMPACTION_RECOVERY_NOTICE, \"info\")", + "return COMPACTION_RECOVERY_NOTICE", + "function compactionInstruction(): string", + "function prependInstruction(target: unknown, instruction: string): boolean", + "function injectCompactionInstruction(event: any): boolean", + "function extractCompactionSummary(event: any): string", + "event?.compactionEntry", + "event?.compaction", + "event?.summary", + "summary: compactedSummary", + "Compaction summary saved to Engram. Use mem_context to restore continuity.", + "Compaction summary unavailable. Use /engram-recovery, then mem_context manually.", + "[REDACTED]", + } + + for _, token := range mustContain { + if !strings.Contains(src, token) { + t.Fatalf("expected extension source to include %q", token) + } + } + + requiredTools := []string{ + "mem_search", + "mem_context", + "mem_save", + "mem_session_summary", + "mem_get_observation", + "mem_save_prompt", + } + for _, toolName := range requiredTools { + nameToken := `name: "` + toolName + `"` + start := strings.Index(src, nameToken) + if start < 0 { + t.Fatalf("expected object-shaped registerTool to include %q", nameToken) + } + + block := src[start:] + if next := strings.Index(block[len(nameToken):], "pi.registerTool({"); next >= 0 { + block = block[:len(nameToken)+next] + } + + for _, required := range []string{ + "label:", + "description:", + "parameters: Type.Object({", + "async execute(toolCallId, params, signal, onUpdate, ctx)", + "const ready = await ensureBackend()", + "if (!ready.ok)", + "return backendFailureToolResult(", + "return summarizeToolResult(", + } { + if !strings.Contains(block, required) { + t.Fatalf("expected %s registration block to include %q", toolName, required) + } + } + + readyPos := strings.Index(block, "const ready = await ensureBackend()") + if readyPos < 0 { + t.Fatalf("expected %s registration block to call readiness helper", toolName) + } + + requestPos := -1 + for _, token := range []string{"const result = await engramFetch(", "const result = await postJSON("} { + if idx := strings.Index(block, token); idx >= 0 && (requestPos < 0 || idx < requestPos) { + requestPos = idx + } + } + if requestPos >= 0 && requestPos < readyPos { + t.Fatalf("expected %s registration block to perform HTTP request only after readiness check", toolName) + } + + for _, forbidden := range []string{ + "return engramFetch(", + "return postJSON(", + "return null", + } { + if strings.Contains(block, forbidden) { + t.Fatalf("expected %s registration block to avoid raw transport return %q", toolName, forbidden) + } + } + } + + mustNotContain := []string{ + "Bun.spawn(", + "recent.length > 0", + "console.info", + "payload.sessionId", + "payload.project", + "payload.directory", + "onPrompt", + "onPassiveCapture", + "onCompaction", + "pi.registerTool(\"", + "inputSchema:", + "pi.registerCommand({", + "engram.memory.recovery", + "engram:memory:recovery", + } + for _, token := range mustNotContain { + if strings.Contains(src, token) { + t.Fatalf("expected extension source to avoid unvalidated Pi hook %q", token) + } + } + + sessionStartHook := "pi.on(\"session_start\", async (event, ctx)" + sessionStart := strings.Index(src, sessionStartHook) + if sessionStart < 0 { + t.Fatalf("expected extension source to include hook %q", sessionStartHook) + } + + sessionStartBlock := src[sessionStart:] + if next := strings.Index(sessionStartBlock[len(sessionStartHook):], "pi.on(\"session_shutdown\""); next >= 0 { + sessionStartBlock = sessionStartBlock[:len(sessionStartHook)+next] + } + + for _, forbidden := range []string{"/context", "mem_context("} { + if strings.Contains(sessionStartBlock, forbidden) { + t.Fatalf("expected session_start block to avoid automatic context loading via %q", forbidden) + } + } + for _, required := range []string{ + "const ready = await ensureBackend()", + "if (!ready.ok) return", + "observationsFromRecentResult(result)", + "ctx.ui.notify(STARTUP_NOTICE, \"info\")", + } { + if !strings.Contains(sessionStartBlock, required) { + t.Fatalf("expected session_start block to include %q", required) + } + } + + hookBlocks := []string{"pi.on(\"session_before_compact\"", "pi.on(\"session_compact\""} + for _, hook := range hookBlocks { + start := strings.Index(src, hook) + if start < 0 { + t.Fatalf("expected extension source to include hook %q", hook) + } + + block := src[start:] + if next := strings.Index(block[len(hook):], "pi.on("); next >= 0 { + block = block[:len(hook)+next] + } + + for _, forbidden := range []string{"/context", "mem_context("} { + if strings.Contains(block, forbidden) { + t.Fatalf("expected %s block to avoid automatic context loading via %q", hook, forbidden) + } + } + } + }) + } +} + +func TestPiDocsDescribeNativeMemoryToolsAndReloadChecklist(t *testing.T) { + docs := []struct { + name string + path string + must []string + }{ + { + name: "agent setup", + path: filepath.Join("..", "..", "docs", "AGENT-SETUP.md"), + must: []string{ + "mem_search", + "mem_context", + "mem_save", + "mem_session_summary", + "mem_get_observation", + "mem_save_prompt", + "/reload", + "/engram-recovery", + "ENGRAM_BIN=/path/to/engram", + "stop Engram backend, then call native mem_search", + "run `/compact`, then verify compaction summary persistence", + }, + }, + { + name: "plugins guide", + path: filepath.Join("..", "..", "docs", "PLUGINS.md"), + must: []string{ + "pi.registerTool()", + "ctx.ui.notify", + "/engram-recovery", + "ENGRAM_BIN", + "native Pi tools (not MCP-first)", + }, + }, + } + + for _, doc := range docs { + t.Run(doc.name, func(t *testing.T) { + raw, err := os.ReadFile(doc.path) + if err != nil { + t.Fatalf("read %s: %v", doc.path, err) + } + + content := string(raw) + for _, token := range doc.must { + if !strings.Contains(content, token) { + t.Fatalf("expected %s to include %q", doc.path, token) + } + } + + if strings.Contains(content, "engram:memory:recovery") { + t.Fatalf("expected %s to stop referencing legacy command engram:memory:recovery", doc.path) + } + if strings.Contains(content, "Pi uses MCP as the primary integration") { + t.Fatalf("expected %s to avoid MCP-first Pi positioning", doc.path) + } + }) + } +} + +func TestPiSkillReferencesRecoverySlashCommand(t *testing.T) { + skills := []string{ + filepath.Join("..", "..", "plugin", "pi", "skills", "engram", "SKILL.md"), + filepath.Join("plugins", "pi", "skills", "engram", "SKILL.md"), + } + + for _, skillPath := range skills { + raw, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("read %s: %v", skillPath, err) + } + + content := string(raw) + if !strings.Contains(content, "/engram-recovery") { + t.Fatalf("expected %s to include /engram-recovery", skillPath) + } + if strings.Contains(content, "engram:memory:recovery") { + t.Fatalf("expected %s to stop referencing engram:memory:recovery", skillPath) + } + if strings.Contains(content, "when MCP is available") { + t.Fatalf("expected %s to describe native Pi tools, not MCP-gated tooling", skillPath) + } + if !strings.Contains(content, "Compaction hook behavior") { + t.Fatalf("expected %s to document compaction hook behavior", skillPath) + } + } } func TestInstallGeminiCLIInjectsMCPConfig(t *testing.T) { diff --git a/internal/setup/testdata/pi-contract.json b/internal/setup/testdata/pi-contract.json new file mode 100644 index 00000000..0ac4c1a8 --- /dev/null +++ b/internal/setup/testdata/pi-contract.json @@ -0,0 +1,22 @@ +{ + "global_install_paths": { + "status": "validated", + "notes": "Pi local package materialization path uses ~/.config/pi-coding-agent (or APPDATA on Windows) before invoking 'pi install '" + }, + "extension_manifest_schema": { + "status": "validated", + "notes": "Working package shape uses package.json top-level 'pi' manifest with pi.extensions + pi.skills" + }, + "lifecycle_hooks": { + "status": "validated", + "notes": "Official Pi hooks are covered by deterministic runtime harness execution in CI (`internal/setup/TestPiExtensionRuntimeHarnessDeterministic`) plus manual Pi smoke checks for real host parity." + }, + "compaction_hook_payload": { + "status": "validated", + "notes": "Compaction hooks are validated in deterministic runtime harness for instruction injection and graceful fallback behavior; manual `/compact` smoke tests remain recommended for upstream version drift checks." + }, + "notification_api": { + "status": "validated", + "notes": "Official Pi ctx.ui.notify primitive is used for startup memory-available notices and compaction recovery notifications; startup remains notify-only with no automatic context injection." + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 7ea81ebb..cf61abf8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -25,6 +25,8 @@ import ( var openDB = sql.Open +var shutdownMetadataSummaryPattern = regexp.MustCompile(`^shutdown reason=.* target=.*$`) + // sqliteConstraintForeignKey is the extended SQLite result code for a foreign-key // constraint violation (SQLITE_CONSTRAINT_FOREIGNKEY = 787). // See https://www.sqlite.org/rescode.html#constraint_foreignkey @@ -782,9 +784,27 @@ func (s *Store) CreateSession(id, project, directory string) error { func (s *Store) EndSession(id string, summary string) error { return s.withTx(func(tx *sql.Tx) error { + var project, directory string + var existingSummary *string + err := tx.QueryRow( + `SELECT project, directory, summary FROM sessions WHERE id = ?`, + id, + ).Scan(&project, &directory, &existingSummary) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + + summaryToPersist := nullableString(summary) + if shouldPreserveSessionSummary(existingSummary, summary) { + summaryToPersist = existingSummary + } + res, err := s.execHook(tx, `UPDATE sessions SET ended_at = datetime('now'), summary = ? WHERE id = ?`, - nullableString(summary), id, + summaryToPersist, id, ) if err != nil { return err @@ -798,12 +818,11 @@ func (s *Store) EndSession(id string, summary string) error { } var endedAt string - var project, directory string var storedSummary *string if err := tx.QueryRow( - `SELECT project, directory, ended_at, summary FROM sessions WHERE id = ?`, + `SELECT ended_at, summary FROM sessions WHERE id = ?`, id, - ).Scan(&project, &directory, &endedAt, &storedSummary); err != nil { + ).Scan(&endedAt, &storedSummary); err != nil { return err } @@ -3249,6 +3268,17 @@ func nullableString(s string) *string { return &s } +func shouldPreserveSessionSummary(existingSummary *string, incomingSummary string) bool { + if existingSummary == nil || strings.TrimSpace(*existingSummary) == "" { + return false + } + return isShutdownMetadataSummary(incomingSummary) +} + +func isShutdownMetadataSummary(summary string) bool { + return shutdownMetadataSummaryPattern.MatchString(strings.TrimSpace(summary)) +} + func truncate(s string, max int) string { runes := []rune(s) if len(runes) <= max { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 8d8bf12a..90d2458c 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1599,6 +1599,43 @@ func TestStoreAdditionalQueryAndMutationBranches(t *testing.T) { } } +func TestPassiveCaptureRedactsPrivateTagsBeforePersisting(t *testing.T) { + s := newTestStore(t) + + if err := s.CreateSession("s-passive-private", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + + content := "## Key Learnings:\n1. Keep token super-secret-token away from persisted memory always." + result, err := s.PassiveCapture(PassiveCaptureParams{ + SessionID: "s-passive-private", + Content: content, + Project: "engram", + Source: "pi-passive-test", + }) + if err != nil { + t.Fatalf("passive capture: %v", err) + } + if result.Saved != 1 { + t.Fatalf("expected one saved learning, got %+v", result) + } + + obs, err := s.RecentObservations("engram", "project", 1) + if err != nil { + t.Fatalf("recent observations: %v", err) + } + if len(obs) != 1 { + t.Fatalf("expected one observation from passive capture, got %d", len(obs)) + } + + if !strings.Contains(obs[0].Content, "[REDACTED]") { + t.Fatalf("expected redaction marker in passive capture content, got %q", obs[0].Content) + } + if strings.Contains(obs[0].Content, "super-secret-token") { + t.Fatalf("secret leaked into passive capture content: %q", obs[0].Content) + } +} + func TestStoreErrorBranchesWithClosedDatabase(t *testing.T) { s := newTestStore(t) @@ -1656,6 +1693,64 @@ func TestEndSessionEdgeCases(t *testing.T) { } } +func TestEndSessionPreservesHighSignalSummaryOnShutdownMetadata(t *testing.T) { + s := newTestStore(t) + + const preservedSummary = "## Goal\nDocumented high-signal summary" + const shutdownSummary = "shutdown reason=exit target=app" + if err := s.CreateSession("s-preserve", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create session: %v", err) + } + if err := s.EndSession("s-preserve", preservedSummary); err != nil { + t.Fatalf("seed high-signal summary: %v", err) + } + + const fixedEndedAt = "2000-01-01 00:00:00" + if _, err := s.db.Exec(`UPDATE sessions SET ended_at = ? WHERE id = ?`, fixedEndedAt, "s-preserve"); err != nil { + t.Fatalf("set fixed ended_at: %v", err) + } + + if err := s.EndSession("s-preserve", shutdownSummary); err != nil { + t.Fatalf("shutdown metadata end session: %v", err) + } + + sess, err := s.GetSession("s-preserve") + if err != nil { + t.Fatalf("get preserved session: %v", err) + } + if sess.Summary == nil || *sess.Summary != preservedSummary { + t.Fatalf("expected preserved high-signal summary, got %+v", sess.Summary) + } + if sess.EndedAt == nil || *sess.EndedAt == fixedEndedAt { + t.Fatalf("expected ended_at refreshed on shutdown, got %+v", sess.EndedAt) + } + + if err := s.EndSession("s-preserve", "fresh high-signal summary"); err != nil { + t.Fatalf("high-signal overwrite should remain allowed: %v", err) + } + sess, err = s.GetSession("s-preserve") + if err != nil { + t.Fatalf("get overwritten session: %v", err) + } + if sess.Summary == nil || *sess.Summary != "fresh high-signal summary" { + t.Fatalf("expected high-signal overwrite to apply, got %+v", sess.Summary) + } + + if err := s.CreateSession("s-low-only", "engram", "/tmp/engram"); err != nil { + t.Fatalf("create low-only session: %v", err) + } + if err := s.EndSession("s-low-only", shutdownSummary); err != nil { + t.Fatalf("end low-only session: %v", err) + } + lowOnly, err := s.GetSession("s-low-only") + if err != nil { + t.Fatalf("get low-only session: %v", err) + } + if lowOnly.Summary == nil || *lowOnly.Summary != shutdownSummary { + t.Fatalf("expected low-signal summary to persist when no prior summary exists, got %+v", lowOnly.Summary) + } +} + func TestTimelineHandlesMissingSessionRecord(t *testing.T) { s := newTestStore(t) diff --git a/plugin/pi/extensions/engram.ts b/plugin/pi/extensions/engram.ts new file mode 100644 index 00000000..6d2a1e21 --- /dev/null +++ b/plugin/pi/extensions/engram.ts @@ -0,0 +1,813 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent" +import { Type } from "typebox" +import { spawn } from "node:child_process" + +const ENGRAM_PORT = Number.parseInt(process.env.ENGRAM_PORT ?? "7437", 10) +const ENGRAM_URL = `http://127.0.0.1:${ENGRAM_PORT}` +const ENGRAM_BIN = process.env.ENGRAM_BIN ?? "engram" + +const STARTUP_NOTICE = + "Engram has relevant memory for this project. Use mem_context or mem_search when useful." + +const COMPACTION_RECOVERY_NOTICE = + "FIRST ACTION REQUIRED: Call mem_session_summary with the compacted summary first, then call mem_context before continuing." + +const COMPACTION_SAVED_NOTICE = + "Compaction summary saved to Engram. Use mem_context to restore continuity." + +const COMPACTION_UNAVAILABLE_NOTICE = + "Compaction summary unavailable. Use /engram-recovery, then mem_context manually." + +const TOOL_GUIDELINES = [ + "Use mem_context for recent continuity before broad searches.", + "Use mem_search with specific project/scope filters when possible.", + "Use mem_save and mem_session_summary for durable continuity.", +] + +type EngramHTTPResult = { + ok: boolean + path: string + status?: number + data?: unknown + error?: string +} + +type BackendReadiness = { + ok: boolean + startupAttempted: boolean + startupError?: string +} + +const BACKEND_STARTUP_POLL_MS = [120, 240, 360, 600, 900] + +function redactPrivateTags(input: string): string { + if (!input) return "" + return input.replace(/[\s\S]*?<\/private>/gi, "[REDACTED]").trim() +} + +function redactValue(value: unknown): unknown { + if (typeof value === "string") { + return redactPrivateTags(value) + } + if (Array.isArray(value)) { + return value.map((item) => redactValue(item)) + } + if (value && typeof value === "object") { + const out: Record = {} + for (const [key, raw] of Object.entries(value as Record)) { + out[key] = redactValue(raw) + } + return out + } + return value +} + +function projectFromDirectory(directory: string): string { + const parts = directory.split(/[\\/]/).filter(Boolean) + return parts.at(-1) ?? "unknown" +} + +function parseSessionID(sessionFile: string): string { + const fileName = sessionFile.split(/[\\/]/).pop() ?? "" + return fileName.replace(/\.[^.]+$/, "") +} + +function deriveSessionId(ctx: any): string { + if (ctx?.sessionManager && typeof ctx.sessionManager.getSessionFile === "function") { + const sessionFile = ctx.sessionManager.getSessionFile() + if (typeof sessionFile === "string" && sessionFile.length > 0) { + return redactPrivateTags(parseSessionID(sessionFile)) + } + } + + const fallbackProject = projectFromDirectory(String(ctx?.cwd ?? "")) + return redactPrivateTags(`pi-${fallbackProject}`) +} + +function deriveRuntime(ctx: any): { sessionId: string; project: string; directory: string } { + const directory = redactPrivateTags(String(ctx.cwd ?? "")) + const project = redactPrivateTags(projectFromDirectory(directory)) + const sessionId = deriveSessionId(ctx) + return { sessionId, project, directory } +} + +async function engramFetch(path: string, init?: RequestInit): Promise { + try { + const response = await fetch(`${ENGRAM_URL}${path}`, init) + const status = response.status + const contentType = response.headers.get("content-type") ?? "" + + let data: unknown = null + if (status !== 204) { + if (contentType.includes("application/json")) { + data = await response.json() + } else { + const text = await response.text() + data = text.length > 0 ? text : null + } + } + + if (!response.ok) { + return { + ok: false, + path, + status, + data, + error: `HTTP ${status}`, + } + } + + return { + ok: true, + path, + status, + data: status === 204 ? { ok: true } : data, + } + } catch (error) { + return { + ok: false, + path, + error: error instanceof Error ? error.message : "network_error", + } + } +} + +async function postJSON(path: string, body: Record): Promise { + return engramFetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(redactValue(body)), + }) +} + +function successToolResult(summary: string, details: unknown) { + return { + content: [{ type: "text", text: summary }], + details, + } +} + +function failureToolResult(message: string, details: Record = {}) { + return { + content: [{ type: "text", text: message }], + details: { + ok: false, + ...details, + }, + isError: true, + } +} + +function compactPreview(value: unknown): string { + if (value === null || value === undefined) return "" + if (typeof value === "string") return value.slice(0, 200) + + try { + const encoded = JSON.stringify(value) + return encoded.slice(0, 200) + } catch { + return String(value).slice(0, 200) + } +} + +function extractObservationSummaryFields(payload: unknown): { id: string; title: string; content: string } { + if (!payload || typeof payload !== "object") { + return { id: "", title: "", content: "" } + } + + const record = payload as Record + const directID = typeof record.id === "string" || typeof record.id === "number" ? String(record.id) : "" + const directTitle = typeof record.title === "string" ? record.title : "" + const directContent = typeof record.content === "string" ? record.content : "" + if (directID || directTitle || directContent) { + return { id: directID, title: directTitle, content: directContent } + } + + const nestedObservation = record.observation + if (nestedObservation && typeof nestedObservation === "object") { + return extractObservationSummaryFields(nestedObservation) + } + + const nestedData = record.data + if (nestedData && typeof nestedData === "object") { + return extractObservationSummaryFields(nestedData) + } + + return { id: "", title: "", content: "" } +} + +function observationSummaryPreview(payload: unknown): string { + const summary = extractObservationSummaryFields(payload) + const titlePreview = summary.title ? redactPrivateTags(summary.title) : "" + const contentPreview = summary.content ? redactPrivateTags(summary.content) : "" + + if (titlePreview && contentPreview) { + return compactPreview(`${titlePreview} — ${contentPreview}`) + } + if (titlePreview) { + return compactPreview(titlePreview) + } + if (contentPreview) { + return compactPreview(contentPreview) + } + + return compactPreview(payload) +} + +function summarizeToolResult(toolName: string, result: EngramHTTPResult) { + if (!result.ok) { + return failureToolResult(`Engram request failed for ${toolName}.`, { + path: result.path, + status: result.status, + error: result.error, + response: result.data, + }) + } + + if (toolName === "mem_save") { + const payload = result.data as Record | null + const id = payload && payload.id ? String(payload.id) : "" + const title = payload && payload.title ? String(payload.title) : "" + + if (id && title) { + return successToolResult(`Memory saved (#${id}: ${title}).`, result) + } + if (id) { + return successToolResult(`Memory saved (#${id}).`, result) + } + return successToolResult("Memory saved.", result) + } + + if (toolName === "mem_search") { + const count = Array.isArray(result.data) ? result.data.length : 0 + if (count > 0) { + return successToolResult(`Found ${count} memory result(s).`, result) + } + const preview = compactPreview(result.data) + return successToolResult(preview ? `Search complete. ${preview}` : "Search complete.", result) + } + + if (toolName === "mem_context") { + const preview = compactPreview(result.data) + return successToolResult(preview ? `Memory context loaded. ${preview}` : "Memory context loaded.", result) + } + + if (toolName === "mem_get_observation") { + const summary = extractObservationSummaryFields(result.data) + const id = summary.id + const preview = observationSummaryPreview(result.data) + if (id) { + if (preview) { + return successToolResult(`Loaded observation #${id}. ${preview}`, result) + } + return successToolResult(`Loaded observation #${id}.`, result) + } + return successToolResult(preview ? `Observation loaded. ${preview}` : "Observation loaded.", result) + } + + if (toolName === "mem_session_summary") { + return successToolResult("Session summary saved.", result) + } + + if (toolName === "mem_save_prompt") { + return successToolResult("Prompt saved.", result) + } + + return successToolResult("Engram request completed.", result) +} + +function queryString(params: Record): string { + const query = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") continue + query.set(key, String(redactValue(value))) + } + const qs = query.toString() + return qs.length > 0 ? `?${qs}` : "" +} + +function observationsFromRecentResult(result: unknown): unknown[] { + if (Array.isArray(result)) return result + if (!result || typeof result !== "object") return [] + + const directObservations = (result as { observations?: unknown[] }).observations + if (Array.isArray(directObservations)) return directObservations + + const wrappedData = (result as { data?: unknown }).data + if (Array.isArray(wrappedData)) return wrappedData + if (wrappedData && typeof wrappedData === "object") { + const dataObservations = wrappedData.observations + if (Array.isArray(dataObservations)) return dataObservations + + const nestedData = (wrappedData as { data?: unknown }).data + if (Array.isArray(nestedData)) return nestedData + if (nestedData && typeof nestedData === "object") { + const nestedObservations = (nestedData as { observations?: unknown[] }).observations + if (Array.isArray(nestedObservations)) return nestedObservations + } + } + + return [] +} + +async function isRunning(): Promise { + try { + const response = await fetch(`${ENGRAM_URL}/health`, { signal: AbortSignal.timeout(500) }) + return response.ok + } catch { + return false + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function backendFailureToolResult(toolName: string, readiness: BackendReadiness) { + return failureToolResult(`Engram auto-start failed for ${toolName}.`, { + autoStartFailed: true, + startupAttempted: readiness.startupAttempted, + startupError: readiness.startupError, + }) +} + +async function ensureBackend(): Promise { + if (await isRunning()) { + return { ok: true, startupAttempted: false } + } + + try { + const child = spawn(ENGRAM_BIN, ["serve"], { + stdio: "ignore", + detached: true, + }) + if (typeof child.unref === "function") { + child.unref() + } + + for (let attempt = 0; attempt < BACKEND_STARTUP_POLL_MS.length; attempt++) { + if (await isRunning()) { + return { ok: true, startupAttempted: true } + } + await sleep(BACKEND_STARTUP_POLL_MS[attempt]) + } + } catch (error) { + return { + ok: false, + startupAttempted: true, + startupError: error instanceof Error ? error.message : "spawn_failed", + } + } + + return { + ok: false, + startupAttempted: true, + startupError: "backend_not_ready_after_spawn", + } +} + +function registerMemoryTools(pi: any): void { + if (typeof pi.registerTool !== "function") return + + pi.registerTool({ + name: "mem_search", + label: "Engram Memory Search", + description: "Search Engram observations using full text and filters.", + parameters: Type.Object({ + query: Type.String({ minLength: 1 }), + project: Type.Optional(Type.String()), + scope: Type.Optional(Type.String()), + type: Type.Optional(Type.String()), + limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })), + }), + promptSnippet: "Call mem_search for recall and continuity lookups.", + promptGuidelines: ["Use mem_search by name when querying historical observations.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_search", ready) + } + const runtime = deriveRuntime(ctx) + const result = await engramFetch( + `/search${queryString({ + q: params.query, + project: params.project ?? runtime.project, + scope: params.scope, + type: params.type, + limit: params.limit ?? 10, + })}`, + ) + return summarizeToolResult("mem_search", result) + }, + }) + + pi.registerTool({ + name: "mem_context", + label: "Engram Memory Context", + description: "Fetch compact project context from Engram.", + parameters: Type.Object({ + project: Type.Optional(Type.String()), + scope: Type.Optional(Type.String()), + }), + promptSnippet: "Call mem_context for compact session continuity.", + promptGuidelines: ["Use mem_context by name at startup or after compaction.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_context", ready) + } + const runtime = deriveRuntime(ctx) + const result = await engramFetch( + `/context${queryString({ + project: params.project ?? runtime.project, + scope: params.scope, + })}`, + ) + return summarizeToolResult("mem_context", result) + }, + }) + + pi.registerTool({ + name: "mem_save", + label: "Engram Memory Save", + description: "Save an observation to Engram memory.", + parameters: Type.Object({ + title: Type.String({ minLength: 1 }), + content: Type.String({ minLength: 1 }), + type: Type.Optional(Type.String()), + project: Type.Optional(Type.String()), + scope: Type.Optional(Type.String()), + topic_key: Type.Optional(Type.String()), + session_id: Type.Optional(Type.String()), + }), + promptSnippet: "Call mem_save immediately after decisions, bugfixes, and discoveries.", + promptGuidelines: ["Use mem_save by name with structured What/Why/Where/Learned content.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_save", ready) + } + const runtime = deriveRuntime(ctx) + const result = await postJSON("/observations", { + session_id: params.session_id ?? runtime.sessionId, + project: params.project ?? runtime.project, + title: params.title, + content: params.content, + scope: params.scope ?? "project", + type: params.type ?? "manual", + topic_key: params.topic_key, + }) + return summarizeToolResult("mem_save", result) + }, + }) + + pi.registerTool({ + name: "mem_session_summary", + label: "Engram Session Summary", + description: "Persist end-of-session summary for continuity.", + parameters: Type.Object({ + content: Type.String({ minLength: 1 }), + session_id: Type.Optional(Type.String()), + }), + promptSnippet: "Call mem_session_summary before ending work or after compaction.", + promptGuidelines: ["Use mem_session_summary by name before saying done.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_session_summary", ready) + } + const runtime = deriveRuntime(ctx) + const sessionId = redactPrivateTags(String(params.session_id ?? runtime.sessionId)) + if (!sessionId) { + return failureToolResult("Engram request failed for mem_session_summary.", { + path: "/sessions/{id}/end", + error: "missing_session_id", + }) + } + + const result = await postJSON(`/sessions/${encodeURIComponent(sessionId)}/end`, { + summary: params.content, + }) + return summarizeToolResult("mem_session_summary", result) + }, + }) + + pi.registerTool({ + name: "mem_get_observation", + label: "Engram Get Observation", + description: "Get a full observation by ID.", + parameters: Type.Object({ + id: Type.Number({ minimum: 1 }), + }), + promptSnippet: "Call mem_get_observation for full untruncated memory content.", + promptGuidelines: ["Use mem_get_observation by name after mem_search for full details.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + void ctx + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_get_observation", ready) + } + const result = await engramFetch(`/observations/${encodeURIComponent(String(params.id))}`) + return summarizeToolResult("mem_get_observation", result) + }, + }) + + pi.registerTool({ + name: "mem_save_prompt", + label: "Engram Save Prompt", + description: "Persist user prompt text to Engram for history.", + parameters: Type.Object({ + content: Type.String({ minLength: 1 }), + project: Type.Optional(Type.String()), + session_id: Type.Optional(Type.String()), + }), + promptSnippet: "Call mem_save_prompt when explicit prompt archival is needed.", + promptGuidelines: ["Use mem_save_prompt by name only for prompt capture use-cases.", ...TOOL_GUIDELINES], + async execute(toolCallId, params, signal, onUpdate, ctx) { + void toolCallId + void signal + void onUpdate + const ready = await ensureBackend() + if (!ready.ok) { + return backendFailureToolResult("mem_save_prompt", ready) + } + const runtime = deriveRuntime(ctx) + const result = await postJSON("/prompts", { + session_id: params.session_id ?? runtime.sessionId, + project: params.project ?? runtime.project, + content: params.content, + }) + return summarizeToolResult("mem_save_prompt", result) + }, + }) +} + +function registerRecoveryCommand(pi: any): void { + if (typeof pi.registerCommand !== "function") { + return + } + + pi.registerCommand("engram-recovery", { + description: "Shows the compacted-session recovery instructions for Engram memory protocol.", + handler: (_args: unknown, ctx: any) => { + if (ctx?.hasUI && ctx?.ui?.notify) { + ctx.ui.notify(COMPACTION_RECOVERY_NOTICE, "info") + } + return COMPACTION_RECOVERY_NOTICE + }, + }) +} + +function compactionInstruction(): string { + return COMPACTION_RECOVERY_NOTICE +} + +function prependInstruction(target: unknown, instruction: string): boolean { + if (Array.isArray(target)) { + target.unshift(instruction) + return true + } + + if (target && typeof target === "object") { + const record = target as Record + if (typeof record.value === "string") { + record.value = `${instruction}\n${record.value}`.trim() + return true + } + } + + return false +} + +function injectCompactionInstruction(event: any): boolean { + const instruction = compactionInstruction() + + if (event && Array.isArray(event.customInstructions)) { + event.customInstructions.unshift(instruction) + return true + } + + if (event && typeof event.customInstructions === "string") { + event.customInstructions = `${instruction}\n${event.customInstructions}`.trim() + return true + } + + if (event && event.customInstructions && typeof event.customInstructions === "object") { + if (Array.isArray(event.customInstructions.items)) { + event.customInstructions.items.unshift(instruction) + return true + } + } + + if (event?.compactionEntry && Array.isArray(event.compactionEntry.customInstructions)) { + event.compactionEntry.customInstructions.unshift(instruction) + return true + } + + if (event?.compactionEntry && prependInstruction(event.compactionEntry.customInstructions, instruction)) { + return true + } + + if (event?.compaction && Array.isArray(event.compaction.customInstructions)) { + event.compaction.customInstructions.unshift(instruction) + return true + } + + if (event?.compaction && prependInstruction(event.compaction.customInstructions, instruction)) { + return true + } + + return false +} + +function extractSummaryText(value: unknown): string { + if (typeof value === "string") { + return redactPrivateTags(value).trim() + } + + if (!value || typeof value !== "object") { + return "" + } + + const candidates = [ + "summary", + "content", + "text", + "compactedSummary", + "compactSummary", + "finalSummary", + "entry", + ] + + for (const key of candidates) { + const raw = (value as Record)[key] + if (typeof raw === "string") { + const clean = redactPrivateTags(raw).trim() + if (clean) return clean + } + if (raw && typeof raw === "object") { + const nested = extractSummaryText(raw) + if (nested) return nested + } + } + + return "" +} + +function extractCompactionSummary(event: any): string { + const candidates: unknown[] = [ + event?.compactionEntry, + event?.compaction, + event?.summary, + event?.compactedSummary, + event?.compactSummary, + event?.content, + event?.text, + event, + ] + + for (const candidate of candidates) { + const summary = extractSummaryText(candidate) + if (summary) return summary + } + + return "" +} + +function notifyCompactionRecovery(ctx: any): void { + const recoveryInstruction = compactionInstruction() + + if (ctx?.hasUI && ctx?.ui?.notify) { + ctx.ui.notify(recoveryInstruction, "info") + } +} + +async function persistCompactionSummary(event: any, ctx: any): Promise { + const runtime = deriveRuntime(ctx) + const compactedSummary = extractCompactionSummary(event) + if (!runtime.sessionId || !compactedSummary) { + return false + } + + const result = await postJSON(`/sessions/${encodeURIComponent(runtime.sessionId)}/end`, { + summary: compactedSummary, + }) + + return Boolean(result.ok) +} + +function notifyCompactionResult(saved: boolean, ctx: any): void { + if (!ctx?.hasUI || !ctx?.ui?.notify) { + return + } + + if (saved) { + ctx.ui.notify(COMPACTION_SAVED_NOTICE, "info") + return + } + + ctx.ui.notify(COMPACTION_UNAVAILABLE_NOTICE, "info") +} + +export default function (pi: ExtensionAPI): void { + registerMemoryTools(pi) + registerRecoveryCommand(pi) + + pi.on("session_start", async (event, ctx) => { + const ready = await ensureBackend() + if (!ready.ok) return + const runtime = deriveRuntime(ctx) + const reason = redactPrivateTags(String(event.reason ?? "")) + + if (runtime.sessionId && runtime.project) { + await postJSON("/sessions", { + id: runtime.sessionId, + project: runtime.project, + directory: runtime.directory, + reason, + }) + } + + if (!runtime.project) return + const result = await engramFetch( + `/observations/recent${queryString({ project: runtime.project, scope: "project", limit: 1 })}`, + ) + const observations = observationsFromRecentResult(result) + + if (observations.length > 0 && ctx.hasUI && ctx.ui?.notify) { + ctx.ui.notify(STARTUP_NOTICE, "info") + } + }) + + pi.on("session_shutdown", async (event, ctx) => { + const ready = await ensureBackend() + if (!ready.ok) return + const runtime = deriveRuntime(ctx) + const reason = redactPrivateTags(String(event.reason ?? "")) + const target = redactPrivateTags(String(event.target ?? "")) + + if (!runtime.sessionId) return + await postJSON(`/sessions/${encodeURIComponent(runtime.sessionId)}/end`, { + summary: `shutdown reason=${reason} target=${target}`, + }) + }) + + pi.on("input", async (event, ctx) => { + if (event.source === "extension") { + return + } + + const ready = await ensureBackend() + if (!ready.ok) return + const runtime = deriveRuntime(ctx) + if (!runtime.sessionId || !runtime.project) return + + await postJSON("/prompts", { + session_id: runtime.sessionId, + project: runtime.project, + content: String(event.text ?? ""), + source: String(event.source ?? ""), + images: event.images, + }) + }) + + pi.on("session_before_compact", async (event, ctx) => { + const ready = await ensureBackend() + if (!ready.ok) { + notifyCompactionRecovery(ctx) + return + } + if (!injectCompactionInstruction(event)) { + notifyCompactionRecovery(ctx) + } + }) + + pi.on("session_compact", async (event, ctx) => { + const ready = await ensureBackend() + if (!ready.ok) { + notifyCompactionRecovery(ctx) + return + } + + const saved = await persistCompactionSummary(event, ctx) + notifyCompactionResult(saved, ctx) + if (!saved) { + notifyCompactionRecovery(ctx) + } + }) +} diff --git a/plugin/pi/package.json b/plugin/pi/package.json new file mode 100644 index 00000000..a639f442 --- /dev/null +++ b/plugin/pi/package.json @@ -0,0 +1,24 @@ +{ + "name": "engram-pi", + "version": "0.2.0", + "private": true, + "description": "Official Engram extension package for pi-coding-agent", + "type": "module", + "keywords": [ + "pi-package", + "engram", + "memory" + ], + "pi": { + "extensions": [ + "./extensions/engram.ts" + ], + "skills": [ + "./skills" + ] + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*", + "typebox": "*" + } +} diff --git a/plugin/pi/skills/engram/SKILL.md b/plugin/pi/skills/engram/SKILL.md new file mode 100644 index 00000000..cff55499 --- /dev/null +++ b/plugin/pi/skills/engram/SKILL.md @@ -0,0 +1,36 @@ +--- +name: engram +description: Memory protocol guidance for Engram's Pi integration. +--- + +## Engram Persistent Memory — Protocol (Pi) + +You have access to Engram persistent memory tools (`mem_*`) as native Pi tools from the extension. + +### Save immediately after +- Bug fixes +- Architecture/design decisions +- Non-obvious discoveries +- Config changes +- Established patterns +- User preferences + +### Search rules +- On recall requests, call `mem_context` first, then `mem_search`. +- Proactive lookup is allowed only when continuity likelihood is high. +- Startup policy is conservative: notify memory availability, do not auto-inject full context. + +### Session close +Before ending a session, call `mem_session_summary` with goal, discoveries, accomplished work, next steps, and relevant files. + +### After compaction +If a compacted summary is present, first call `mem_session_summary` with that content, then call `mem_context`. + +### Compaction hook behavior +- `session_before_compact`: extension injects `FIRST ACTION REQUIRED` into compaction instructions when supported by event shape. +- `session_compact`: extension attempts to persist the compacted summary through Engram session summary endpoint and notifies whether persistence succeeded. +- If summary extraction/persistence is unavailable, run `/engram-recovery` and continue with manual `mem_context`. + +### Pi adapter limitations (validated v0.70.0 contract) +- Engram does not auto-inject full previous context after compaction; `mem_context` stays manual. +- Recovery guidance remains available through `/engram-recovery` for runtimes with partial hook payloads. diff --git a/plugin/pi/test/runtime-harness.mjs b/plugin/pi/test/runtime-harness.mjs new file mode 100644 index 00000000..b9fac9e1 --- /dev/null +++ b/plugin/pi/test/runtime-harness.mjs @@ -0,0 +1,472 @@ +import assert from "node:assert/strict" +import os from "node:os" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { readFile, writeFile } from "node:fs/promises" + +const extensionPath = process.argv[2] +if (!extensionPath) { + throw new Error("usage: node runtime-harness.mjs ") +} + +function response(status, payload, contentType = "application/json") { + return { + ok: status >= 200 && status < 300, + status, + headers: { + get(key) { + if (String(key).toLowerCase() === "content-type") { + return contentType + } + return null + }, + }, + async json() { + return payload + }, + async text() { + return typeof payload === "string" ? payload : JSON.stringify(payload) + }, + } +} + +let source = await readFile(extensionPath, "utf8") +assert.ok(!source.includes("Bun.spawn("), "extension must not use Bun.spawn") + +source = source.replace( + /^import type \{ ExtensionAPI \} from "@mariozechner\/pi-coding-agent"\n/m, + "", +) + +source = source.replace( + 'import { Type } from "typebox"', + `const Type = { + Object: (shape) => ({ kind: "object", shape }), + String: (opts = {}) => ({ kind: "string", ...opts }), + Number: (opts = {}) => ({ kind: "number", ...opts }), + Optional: (schema) => ({ ...schema, optional: true }), +}`, +) + +source = source.replace( + 'import { spawn } from "node:child_process"', + "const spawn = (...args) => globalThis.__ENGRAM_TEST_SPAWN__(...args)", +) + +const transformedPath = path.join(os.tmpdir(), `engram-pi-runtime-${Date.now()}.ts`) +await writeFile(transformedPath, source) + +const moduleUnderTest = await import(`${pathToFileURL(transformedPath).href}?v=${Date.now()}`) +assert.equal(typeof moduleUnderTest.default, "function", "default export must be a function") + +if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout !== "function") { + AbortSignal.timeout = () => undefined +} + +const spawnCalls = [] +let spawnShouldThrow = false +globalThis.__ENGRAM_TEST_SPAWN__ = (command, args, options) => { + if (spawnShouldThrow) { + throw new Error("spawn exploded") + } + const record = { command, args, options, unrefCalled: false } + spawnCalls.push(record) + return { + unref() { + record.unrefCalled = true + }, + } +} + +let healthChecks = 0 +let recentPayload = [] +let healthAlwaysDown = false +const fetchCalls = [] +const sessionEndBodies = [] +const sessionSummaries = new Map() +const sessionEndCountById = new Map() +const lowSignalShutdownSummary = /^shutdown reason=.* target=.*$/ + +function getIncomingSummary(summary) { + return typeof summary === "string" ? summary : "" +} + +function shouldPreserveSummary(currentSummary, incomingSummary) { + return ( + typeof currentSummary === "string" && + currentSummary.trim() !== "" && + lowSignalShutdownSummary.test(incomingSummary.trim()) + ) +} + +function applySessionSummary(sessionId, incomingSummary) { + if (shouldPreserveSummary(sessionSummaries.get(sessionId), incomingSummary)) { + return + } + + if (incomingSummary.trim() === "") { + sessionSummaries.delete(sessionId) + return + } + + sessionSummaries.set(sessionId, incomingSummary) +} + +globalThis.fetch = async (rawUrl, init = {}) => { + const url = new URL(rawUrl) + fetchCalls.push(url.pathname + url.search) + + if (url.pathname === "/health") { + healthChecks += 1 + if (healthAlwaysDown) { + return response(503, { ok: false }) + } + if (healthChecks === 1) { + return response(503, { ok: false }) + } + return response(200, { ok: true }) + } + + if (url.pathname === "/search") { + return response(200, []) + } + + if (url.pathname === "/context") { + return response(200, { observations: [] }) + } + + if (url.pathname === "/observations/recent") { + return response(200, recentPayload) + } + + if (url.pathname === "/sessions" || url.pathname.includes("/sessions/")) { + if (url.pathname.includes("/sessions/") && init.body) { + try { + const payload = JSON.parse(String(init.body)) + sessionEndBodies.push(payload) + const match = url.pathname.match(/^\/sessions\/([^/]+)\/end$/) + if (match) { + const sessionId = decodeURIComponent(match[1]) + const incomingSummary = getIncomingSummary(payload.summary) + applySessionSummary(sessionId, incomingSummary) + + sessionEndCountById.set(sessionId, (sessionEndCountById.get(sessionId) ?? 0) + 1) + } + } catch { + sessionEndBodies.push({ parseError: true }) + } + } + return response(200, { ok: true }) + } + + if (url.pathname === "/prompts") { + return response(200, { ok: true }) + } + + return response(200, { ok: true }) +} + +const tools = [] +const commands = [] +const hooks = new Map() +const pi = { + registerTool(tool) { + tools.push(tool) + }, + registerCommand(name, definition) { + commands.push({ name, definition }) + }, + on(eventName, handler) { + hooks.set(eventName, handler) + }, +} + +moduleUnderTest.default(pi) + +const expectedToolNames = [ + "mem_search", + "mem_context", + "mem_save", + "mem_session_summary", + "mem_get_observation", + "mem_save_prompt", +] + +assert.equal(tools.length, expectedToolNames.length, "must register all native pi tools") +for (const name of expectedToolNames) { + const tool = tools.find((item) => item.name === name) + assert.ok(tool, `missing tool registration for ${name}`) + assert.equal(typeof tool.name, "string") + assert.equal(typeof tool.label, "string") + assert.equal(typeof tool.description, "string") + assert.equal(typeof tool.parameters, "object") + assert.equal(typeof tool.execute, "function") +} + +assert.equal(commands.length, 1, "must register one command") +assert.equal(commands[0].name, "engram-recovery", "command must use string-first registration") +assert.equal(typeof commands[0].definition.handler, "function") + +const memSearch = tools.find((item) => item.name === "mem_search") +const runtimeContext = { + cwd: "/tmp/worktrees/engram", + sessionManager: { getSessionFile: () => "/tmp/worktrees/engram/.pi/sessions/runtime-session.json" }, + hasUI: true, + ui: { notify() {} }, +} + +const searchResult = await memSearch.execute("call-1", { query: "runtime" }, undefined, undefined, runtimeContext) +assert.ok(Array.isArray(searchResult.content), "tool result must provide content array") +assert.equal(searchResult.content[0].type, "text", "tool result content item must be text") +assert.equal(typeof searchResult.content[0].text, "string") +assert.ok(searchResult.content[0].text.length > 0) + +assert.ok(spawnCalls.length >= 1, "backend bootstrap should spawn ENGRAM_BIN when health check fails") +assert.equal(spawnCalls[0].command, "engram", "default ENGRAM_BIN should be used") +assert.deepEqual(spawnCalls[0].args, ["serve"]) +assert.equal(spawnCalls[0].options?.detached, true) +assert.equal(spawnCalls[0].options?.stdio, "ignore") +assert.equal(spawnCalls[0].unrefCalled, true) + +const sessionStart = hooks.get("session_start") +assert.equal(typeof sessionStart, "function", "session_start hook must be registered") + +const startupCases = [ + { payload: [], expectNotify: false }, + { payload: [{ id: 1 }], expectNotify: true }, + { payload: { observations: [{ id: 2 }] }, expectNotify: true }, + { payload: { data: [{ id: 3 }] }, expectNotify: true }, + { payload: { data: { observations: [{ id: 4 }] } }, expectNotify: true }, + { payload: { unknown: true }, expectNotify: false }, +] + +for (const tc of startupCases) { + const notifications = [] + recentPayload = tc.payload + await sessionStart( + { reason: "startup" }, + { + ...runtimeContext, + hasUI: true, + ui: { + notify(message, level) { + notifications.push({ message, level }) + }, + }, + }, + ) + + if (tc.expectNotify) { + assert.ok(notifications.length > 0, "startup should notify when recent observations exist") + } else { + assert.equal(notifications.length, 0, "startup must stay quiet when no recent observations") + } +} + +const contextCallsAfterStartup = fetchCalls.filter((entry) => entry.startsWith("/context")) +assert.equal(contextCallsAfterStartup.length, 0, "session_start must be notify-only (no /context auto-injection)") + +const sessionBeforeCompact = hooks.get("session_before_compact") +const sessionCompact = hooks.get("session_compact") +const sessionShutdown = hooks.get("session_shutdown") +assert.equal(typeof sessionBeforeCompact, "function", "session_before_compact hook must be registered") +assert.equal(typeof sessionCompact, "function", "session_compact hook must be registered") +assert.equal(typeof sessionShutdown, "function", "session_shutdown hook must be registered") + +const compactionNotifications = [] +const compactionContext = { + ...runtimeContext, + hasUI: true, + ui: { + notify(message, level) { + compactionNotifications.push({ message, level }) + }, + }, +} + +const beforeCompactionCases = [ + { + name: "array_customInstructions", + event: { customInstructions: ["existing"] }, + expectFirst: true, + }, + { + name: "string_customInstructions", + event: { customInstructions: "existing" }, + expectPrefix: true, + }, + { + name: "object_items_customInstructions", + event: { customInstructions: { items: ["existing"] } }, + expectNestedFirst: true, + }, + { + name: "nested_compactionEntry_value", + event: { compactionEntry: { customInstructions: { value: "existing" } } }, + expectNestedPrefix: true, + }, + { + name: "unsupported_payload_notifies_recovery", + event: { unsupported: true }, + expectRecoveryNotice: true, + }, +] + +for (const tc of beforeCompactionCases) { + const beforeCount = compactionNotifications.length + await sessionBeforeCompact(tc.event, compactionContext) + + if (tc.expectFirst) { + assert.equal( + tc.event.customInstructions[0], + "FIRST ACTION REQUIRED: Call mem_session_summary with the compacted summary first, then call mem_context before continuing.", + `session_before_compact should prepend instruction for ${tc.name}`, + ) + } + + if (tc.expectPrefix) { + assert.ok( + String(tc.event.customInstructions).startsWith("FIRST ACTION REQUIRED:"), + `session_before_compact should prefix instruction for ${tc.name}`, + ) + assert.ok( + String(tc.event.customInstructions).includes("call mem_context before continuing"), + `session_before_compact should include recovery steps for ${tc.name}`, + ) + } + + if (tc.expectNestedFirst) { + assert.equal( + tc.event.customInstructions.items[0], + "FIRST ACTION REQUIRED: Call mem_session_summary with the compacted summary first, then call mem_context before continuing.", + `session_before_compact should inject nested item for ${tc.name}`, + ) + } + + if (tc.expectNestedPrefix) { + assert.ok( + String(tc.event.compactionEntry.customInstructions.value).startsWith("FIRST ACTION REQUIRED:"), + `session_before_compact should prefix nested compactionEntry instructions for ${tc.name}`, + ) + } + + if (tc.expectRecoveryNotice) { + assert.ok( + compactionNotifications.length > beforeCount, + `session_before_compact should notify recovery when payload shape unsupported (${tc.name})`, + ) + const last = compactionNotifications[compactionNotifications.length - 1] + assert.ok(String(last.message).includes("FIRST ACTION REQUIRED")) + assert.ok(String(last.message).includes("mem_context")) + assert.equal(last.level, "info") + } +} + +const compactCases = [ + { + name: "direct_summary", + event: { summary: "compacted summary A" }, + expectSaved: true, + }, + { + name: "nested_compaction_entry", + event: { compactionEntry: { finalSummary: "compacted summary B" } }, + expectSaved: true, + }, + { + name: "private_summary_redacted", + event: { compactSummary: "keep secret safe" }, + expectSaved: true, + }, + { + name: "missing_summary_notifies_recovery", + event: { empty: true }, + expectSaved: false, + }, +] + +for (const tc of compactCases) { + const beforeBodies = sessionEndBodies.length + const beforeNotifications = compactionNotifications.length + await sessionCompact(tc.event, compactionContext) + + if (tc.expectSaved) { + assert.ok(sessionEndBodies.length > beforeBodies, `session_compact should persist summary for ${tc.name}`) + const payload = sessionEndBodies[sessionEndBodies.length - 1] + assert.equal(typeof payload.summary, "string") + assert.ok(payload.summary.length > 0) + } else { + assert.equal(sessionEndBodies.length, beforeBodies, `session_compact should skip persistence for ${tc.name}`) + assert.ok( + compactionNotifications.length > beforeNotifications, + `session_compact should notify when summary unavailable (${tc.name})`, + ) + const recent = compactionNotifications.slice(beforeNotifications) + assert.ok(recent.some((item) => String(item.message).includes("FIRST ACTION REQUIRED"))) + assert.ok(recent.some((item) => String(item.message).includes("mem_context"))) + } +} + +const lastSaved = sessionEndBodies[sessionEndBodies.length - 1] +assert.ok( + sessionEndBodies.some((payload) => String(payload.summary).includes("[REDACTED]")), + "session_compact persistence should sanitize blocks", +) +assert.ok(lastSaved || sessionEndBodies.length > 0, "compaction runtime harness must validate persisted summary payloads") + +const summaryBeforeShutdown = sessionSummaries.get("runtime-session") +assert.equal(typeof summaryBeforeShutdown, "string", "compaction should persist a runtime-session summary") +const runtimeEndsBeforeShutdown = sessionEndCountById.get("runtime-session") ?? 0 +await sessionShutdown({ reason: "exit", target: "app" }, runtimeContext) +const summaryAfterShutdown = sessionSummaries.get("runtime-session") +assert.equal( + summaryAfterShutdown, + summaryBeforeShutdown, + "session_shutdown must not overwrite compaction summary with low-signal metadata", +) +assert.equal( + (sessionEndCountById.get("runtime-session") ?? 0), + runtimeEndsBeforeShutdown + 1, + "session_shutdown must still emit /sessions/{id}/end after compaction", +) + +const memSessionSummary = tools.find((item) => item.name === "mem_session_summary") +const summaryToolContext = { + ...runtimeContext, + sessionManager: { getSessionFile: () => "/tmp/worktrees/engram/.pi/sessions/summary-session.json" }, +} +await memSessionSummary.execute( + "call-ss", + { content: "## Goal\nPreserve this high-signal summary" }, + undefined, + undefined, + summaryToolContext, +) + +const summaryToolBeforeShutdown = sessionSummaries.get("summary-session") +assert.equal(typeof summaryToolBeforeShutdown, "string", "mem_session_summary should persist summary before shutdown") +const summaryToolEndsBeforeShutdown = sessionEndCountById.get("summary-session") ?? 0 +await sessionShutdown({ reason: "exit", target: "app" }, summaryToolContext) +const summaryToolAfterShutdown = sessionSummaries.get("summary-session") +assert.equal( + summaryToolAfterShutdown, + summaryToolBeforeShutdown, + "session_shutdown must not overwrite mem_session_summary content", +) +assert.equal( + (sessionEndCountById.get("summary-session") ?? 0), + summaryToolEndsBeforeShutdown + 1, + "session_shutdown must finalize session end even after mem_session_summary", +) + +spawnShouldThrow = true +healthAlwaysDown = true +const memContext = tools.find((item) => item.name === "mem_context") +const failedResult = await memContext.execute("call-2", {}, undefined, undefined, runtimeContext) +assert.equal(failedResult.isError, true, "backend failures must return ToolResult errors, not throw") +assert.ok(Array.isArray(failedResult.content), "error ToolResult must still include content") +assert.equal(failedResult.content[0].type, "text") +assert.ok( + failedResult.content[0].text.includes("Engram auto-start failed for mem_context"), + "error ToolResult should communicate startup failure", +) From ac2624d0177128953465374d27e631b55ee64d22 Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Fri, 24 Apr 2026 00:08:16 -0500 Subject: [PATCH 2/2] fix(pi): address review feedback --- docs/AGENT-SETUP.md | 2 +- .../setup/plugins/pi/extensions/engram.ts | 40 ++++++++++--------- internal/setup/setup_test.go | 3 +- plugin/pi/extensions/engram.ts | 40 ++++++++++--------- plugin/pi/test/runtime-harness.mjs | 11 +++-- 5 files changed, 55 insertions(+), 41 deletions(-) diff --git a/docs/AGENT-SETUP.md b/docs/AGENT-SETUP.md index dd7afc39..1cebd18e 100644 --- a/docs/AGENT-SETUP.md +++ b/docs/AGENT-SETUP.md @@ -251,7 +251,7 @@ If native `mem_*` calls report auto-start failures, Pi could be resolving an old `ENGRAM_BIN` takes precedence over the default binary lookup inside the Pi extension. -If any checkpoint fails due Pi API differences, update: +If any checkpoint fails due to Pi API differences, update: - `internal/setup/testdata/pi-contract.json` (assumptions) - `plugin/pi/extensions/engram.ts` + embedded copy via `go generate ./internal/setup/` - this checklist section diff --git a/internal/setup/plugins/pi/extensions/engram.ts b/internal/setup/plugins/pi/extensions/engram.ts index 6d2a1e21..22e20eee 100644 --- a/internal/setup/plugins/pi/extensions/engram.ts +++ b/internal/setup/plugins/pi/extensions/engram.ts @@ -286,28 +286,32 @@ function queryString(params: Record): string { return qs.length > 0 ? `?${qs}` : "" } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" +} + function observationsFromRecentResult(result: unknown): unknown[] { - if (Array.isArray(result)) return result - if (!result || typeof result !== "object") return [] + if (Array.isArray(result)) return result + if (!isRecord(result)) return [] - const directObservations = (result as { observations?: unknown[] }).observations + const directObservations = result.observations if (Array.isArray(directObservations)) return directObservations - const wrappedData = (result as { data?: unknown }).data - if (Array.isArray(wrappedData)) return wrappedData - if (wrappedData && typeof wrappedData === "object") { - const dataObservations = wrappedData.observations - if (Array.isArray(dataObservations)) return dataObservations - - const nestedData = (wrappedData as { data?: unknown }).data - if (Array.isArray(nestedData)) return nestedData - if (nestedData && typeof nestedData === "object") { - const nestedObservations = (nestedData as { observations?: unknown[] }).observations - if (Array.isArray(nestedObservations)) return nestedObservations - } - } - - return [] + const wrappedData = result.data + if (Array.isArray(wrappedData)) return wrappedData + if (isRecord(wrappedData)) { + const dataObservations = wrappedData.observations + if (Array.isArray(dataObservations)) return dataObservations + + const nestedData = wrappedData.data + if (Array.isArray(nestedData)) return nestedData + if (isRecord(nestedData)) { + const nestedObservations = nestedData.observations + if (Array.isArray(nestedObservations)) return nestedObservations + } + } + + return [] } async function isRunning(): Promise { diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 5ce0f3e4..669823be 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -499,9 +499,10 @@ func TestPiExtensionHasNotifyOnlyStartupAndCompactionRecovery(t *testing.T) { "ctx.cwd", "ctx.sessionManager.getSessionFile()", "ctx.ui.notify(STARTUP_NOTICE, \"info\")", + "function isRecord(value: unknown): value is Record", "function observationsFromRecentResult(result: unknown): unknown[]", "if (Array.isArray(result)) return result", - "const directObservations = (result as { observations?: unknown[] }).observations", + "const directObservations = result.observations", "const dataObservations = wrappedData.observations", "const observations = observationsFromRecentResult(result)", "if (observations.length > 0 && ctx.hasUI && ctx.ui?.notify)", diff --git a/plugin/pi/extensions/engram.ts b/plugin/pi/extensions/engram.ts index 6d2a1e21..22e20eee 100644 --- a/plugin/pi/extensions/engram.ts +++ b/plugin/pi/extensions/engram.ts @@ -286,28 +286,32 @@ function queryString(params: Record): string { return qs.length > 0 ? `?${qs}` : "" } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" +} + function observationsFromRecentResult(result: unknown): unknown[] { - if (Array.isArray(result)) return result - if (!result || typeof result !== "object") return [] + if (Array.isArray(result)) return result + if (!isRecord(result)) return [] - const directObservations = (result as { observations?: unknown[] }).observations + const directObservations = result.observations if (Array.isArray(directObservations)) return directObservations - const wrappedData = (result as { data?: unknown }).data - if (Array.isArray(wrappedData)) return wrappedData - if (wrappedData && typeof wrappedData === "object") { - const dataObservations = wrappedData.observations - if (Array.isArray(dataObservations)) return dataObservations - - const nestedData = (wrappedData as { data?: unknown }).data - if (Array.isArray(nestedData)) return nestedData - if (nestedData && typeof nestedData === "object") { - const nestedObservations = (nestedData as { observations?: unknown[] }).observations - if (Array.isArray(nestedObservations)) return nestedObservations - } - } - - return [] + const wrappedData = result.data + if (Array.isArray(wrappedData)) return wrappedData + if (isRecord(wrappedData)) { + const dataObservations = wrappedData.observations + if (Array.isArray(dataObservations)) return dataObservations + + const nestedData = wrappedData.data + if (Array.isArray(nestedData)) return nestedData + if (isRecord(nestedData)) { + const nestedObservations = nestedData.observations + if (Array.isArray(nestedObservations)) return nestedObservations + } + } + + return [] } async function isRunning(): Promise { diff --git a/plugin/pi/test/runtime-harness.mjs b/plugin/pi/test/runtime-harness.mjs index b9fac9e1..538f8801 100644 --- a/plugin/pi/test/runtime-harness.mjs +++ b/plugin/pi/test/runtime-harness.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict" import os from "node:os" import path from "node:path" import { pathToFileURL } from "node:url" -import { readFile, writeFile } from "node:fs/promises" +import { readFile, rm, writeFile } from "node:fs/promises" const extensionPath = process.argv[2] if (!extensionPath) { @@ -56,8 +56,13 @@ source = source.replace( const transformedPath = path.join(os.tmpdir(), `engram-pi-runtime-${Date.now()}.ts`) await writeFile(transformedPath, source) -const moduleUnderTest = await import(`${pathToFileURL(transformedPath).href}?v=${Date.now()}`) -assert.equal(typeof moduleUnderTest.default, "function", "default export must be a function") +let moduleUnderTest +try { + moduleUnderTest = await import(`${pathToFileURL(transformedPath).href}?v=${Date.now()}`) + assert.equal(typeof moduleUnderTest.default, "function", "default export must be a function") +} finally { + await rm(transformedPath, { force: true }) +} if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout !== "function") { AbortSignal.timeout = () => undefined