soul.demarkus.io:6309/journal/2026-06-23-pi-plugins.md/v2 draft reader meta

demarkus journal — 2026-06-23

Built pi-agent ports of the memory + knowledge plugins

New packages in the demarkus repo: plugins/pi-memory/ (port of claude-code/demarkus-memory) and plugins/pi-knowledge/ (port of claude-code-knowledge/demarkus-knowledge). Full parity; hook/gate logic ported to TypeScript; bash server-lifecycle reused verbatim.

Key facts about pi (the coding agent, pi.dev) extension API

  • An extension is a TS module: export default function(pi: ExtensionAPI). pi loads .ts directly via tsx — no build step. Ship src/*.ts, list them in package.json pi.extensions; skills go in pi.skills.
  • Events: pi.on("session_start"|"before_agent_start"|"tool_call"|"session_shutdown", …).
    • before_agent_start exposes event.prompt (user text) and event.systemPrompt; fires before every turn. Return {message:{customType,content,display}} or {systemPrompt} to inject context. This is the one channel for both SessionStart-style guidance and UserPromptSubmit-style nudges.
    • tool_call (pre-only) returns {block:true, reason}. There is no post-call context channel and no native "ask".
  • pi.registerCommand(name,{description,handler}); pi.sendMessage({...},{triggerTurn}); ctx.cwd, ctx.ui.notify, ctx.sessionManager.
  • MCP comes via the pi-mcp-adapter extension (must be installed separately). It reads server config from ~/.config/mcp/mcp.json (generic global) / ~/.pi/mcp.json / project .mcp.json — same {mcpServers:{name:{command,args,env}}} shape as Claude. HTTP/OAuth servers use {url, auth:"oauth"}.

Gotchas / non-obvious decisions

  • MCP tool naming differs. pi-mcp-adapter names tools <server>_<tool> with hyphens→underscores (default prefix "server"), e.g. server demarkus-memorydemarkus_memory_mark_publish. NOT Claude's mcp__server__tool. The TS parseMcpTool handles all three forms (mcp__s__mark_x, s_mark_x, bare mark_x) and compares server names separator-insensitively.
  • No JSON-parsing awk needed. Claude hooks parsed the tool payload with a pure-awk JSON scanner (no jq); in pi event.input is already a JS object, so publish_metadata_check collapsed to direct property reads.
  • Strictness mapping: block→block, warn→allow + sendMessage reminder, ask→block with a reason telling the agent to confirm with the user (pi has no native ask).
  • Stop→session_shutdown: Claude's Stop hook can force one more turn to make the agent journal; session_shutdown cannot, so the journal nudge degrades to a ctx.ui.notify (tracked file-mutation vs soul-write during the session).
  • Shared ~/.demarkus state is deliberate. The pi packages bundle copies of the bash (lib.sh, setup.sh, mcp-wrapper.sh, soul/promote scripts) and read/write the same ~/.demarkus files as the Claude Code plugins → one soul, one token, one registry; pi and Claude Code coexist. New bash: provision.sh (per-session entrypoint mirroring session-start.sh's setup block) and mcp-config.mjs (Node helper = pi's claude mcp add/remove/list, incl. add-http for OAuth brokers).
  • Commands are kept as commands/*.md prompt bodies injected by registerCommand; bodies use a ${DEMARKUS_SCRIPTS} token substituted to the bundled scripts dir at runtime. Only the genuinely on-demand skill (soul-memory, knowledge-promote) is listed in pi.skills.

Both packages type-check clean under tsc --strict; bash + the Node MCP helper smoke-tested. Not committed (user commits).

Gotcha: pi-mcp-adapter proxies MCP tool calls through a single mcp tool

Live-testing the pi-memory publish gate exposed a non-obvious bug. A pi extension's tool_call handler does NOT see MCP tools by their direct name. pi-mcp-adapter routes every MCP call through one proxy tool, so the event the agent fires is:

{ "toolName": "mcp", "input": { "tool": "demarkus_memory_mark_publish", "args": "{\"url\":\"/x.md\", ...}" } }
  • event.toolName is the literal string "mcp" — NOT demarkus_memory_mark_publish.
  • the real tool name is event.input.tool.
  • the real arguments are event.input.args as a JSON string (must JSON.parse), not an object.

Our gates read event.toolName/event.input directly, so they never matched mark_publish and every tagless/misrouted write sailed through silently (the gate logic itself was correct — verified in isolation; only the event shape was wrong). Strictness env vars propagated fine; the bug was purely the proxy indirection.

Fix: a normalizeToolCall(event) helper in both extensions — when toolName === "mcp", unwrap input.tool as the name and JSON.parse(input.args) as the args; direct (non-proxied) tool calls pass through unchanged. After the fix, both the destination gate (bound-soul misroute) and the publish tag-gate (missing metadata.tags) block correctly through the real MCP path.

General lesson for pi extensions: to gate/observe MCP tools via tool_call, you must handle the mcp proxy shape — direct tool-name matching is insufficient. (pi-mcp-adapter also only surfaces a newly-registered server's tools after a one-time interactive /mcp connect that caches them; headless -p can prime ~/.pi/agent/mcp-cache.json by computing the server configHash over the identity fields and writing the tool list.)

trail
  1. soul.demarkus.io:6309 v2