Skip to content

Commit b64b80c

Browse files
committed
feat: add pi tool-result hook and windows installer
1 parent 63bbbd9 commit b64b80c

8 files changed

Lines changed: 328 additions & 7 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#!/usr/bin/env python3
2+
"""Process Pi Coding Agent tool_result events into episodic memory entries.
3+
4+
Pi exposes a project-local extension system. The adapter installs
5+
`.pi/extensions/memory-hook.ts`, which forwards every `tool_result` event to
6+
this script via stdin. We normalize Pi's event shape into the same
7+
tool_input/tool_response structure used by the Claude Code hook so the
8+
importance scoring, reflection generation, and success classification stay
9+
consistent across harnesses.
10+
"""
11+
import json
12+
import os
13+
import sys
14+
15+
HERE = os.path.dirname(os.path.abspath(__file__))
16+
AGENT_ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
17+
18+
sys.path.insert(0, os.path.join(AGENT_ROOT, "harness"))
19+
sys.path.insert(0, os.path.join(AGENT_ROOT, "tools"))
20+
21+
from hooks.post_execution import log_execution # noqa: E402
22+
from hooks.on_failure import on_failure # noqa: E402
23+
import hooks.claude_code_post_tool as cc # noqa: E402
24+
25+
26+
_PI_TO_CANONICAL = {
27+
"bash": "Bash",
28+
"edit": "Edit",
29+
"write": "Write",
30+
"read": "Read",
31+
"grep": "Grep",
32+
"find": "Find",
33+
"ls": "LS",
34+
"task": "Task",
35+
"todowrite": "TodoWrite",
36+
"webfetch": "WebFetch",
37+
}
38+
39+
40+
def _tool_name(name: str) -> str:
41+
if not isinstance(name, str):
42+
return "Unknown"
43+
lowered = name.strip().lower()
44+
return _PI_TO_CANONICAL.get(lowered, name[:1].upper() + name[1:])
45+
46+
47+
def _extract_text(content) -> str:
48+
if isinstance(content, str):
49+
return content[:500]
50+
if not isinstance(content, list):
51+
return ""
52+
texts = []
53+
for item in content:
54+
if isinstance(item, dict) and item.get("type") == "text":
55+
text = item.get("text")
56+
if isinstance(text, str) and text:
57+
texts.append(text)
58+
return " ".join(texts)[:500]
59+
60+
61+
def _normalize_response(event: dict) -> dict:
62+
"""Map Pi tool_result shape to the Claude hook's response schema."""
63+
resp: dict = {"is_error": bool(event.get("isError", False))}
64+
details = event.get("details")
65+
content = event.get("content")
66+
67+
if isinstance(details, dict):
68+
if isinstance(details.get("output"), str):
69+
resp["output"] = details["output"][:500]
70+
if isinstance(details.get("stdout"), str):
71+
resp["stdout"] = details["stdout"][:500]
72+
if isinstance(details.get("stderr"), str):
73+
resp["stderr"] = details["stderr"][:300]
74+
if isinstance(details.get("error"), str):
75+
resp["error"] = details["error"][:300]
76+
if isinstance(details.get("text"), str):
77+
resp["text"] = details["text"][:500]
78+
if "exitCode" in details:
79+
resp["exit_code"] = details.get("exitCode")
80+
if "cancelled" in details:
81+
resp["interrupted"] = bool(details.get("cancelled"))
82+
if "truncated" in details:
83+
resp["truncated"] = bool(details.get("truncated"))
84+
resp["details"] = details
85+
86+
text = _extract_text(content)
87+
if text:
88+
resp.setdefault("output", text)
89+
resp["content"] = [{"type": "text", "text": text}]
90+
91+
if not any(k in resp for k in ("output", "stdout", "text", "content")):
92+
if isinstance(details, str):
93+
resp["output"] = details[:500]
94+
elif details is not None:
95+
resp["output"] = json.dumps(details, default=str)[:500]
96+
97+
return resp
98+
99+
100+
def main() -> None:
101+
try:
102+
raw = sys.stdin.read()
103+
payload = json.loads(raw) if raw.strip() else {}
104+
except (json.JSONDecodeError, OSError):
105+
payload = {}
106+
107+
tool_name = _tool_name(payload.get("tool_name") or "Unknown")
108+
tool_input = payload.get("tool_input") or {}
109+
if not isinstance(tool_input, dict):
110+
tool_input = {"raw": str(tool_input)}
111+
tool_response = _normalize_response(payload)
112+
113+
success = cc._is_success(tool_name, tool_input, tool_response)
114+
importance = cc._importance(tool_name, json.dumps(tool_input))
115+
action = cc._action_label(tool_name, tool_input)
116+
reflection = cc._reflection(tool_name, tool_input, tool_response, success)
117+
detail = cc._detail(tool_name, tool_input, tool_response, success)
118+
119+
pscore = cc._pain_score(importance, success)
120+
if success:
121+
log_execution(
122+
skill_name="pi",
123+
action=action,
124+
result=detail,
125+
success=True,
126+
reflection=reflection,
127+
importance=importance,
128+
confidence=0.7,
129+
pain_score=pscore,
130+
)
131+
else:
132+
on_failure(
133+
skill_name="pi",
134+
action=action,
135+
error=reflection,
136+
context=detail,
137+
confidence=0.7,
138+
importance=importance,
139+
pain_score=pscore,
140+
)
141+
142+
143+
if __name__ == "__main__":
144+
main()

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ verify_codex_fixes.py # v0.8.0 regression checks (33 checks)
224224
| **OpenCode** | `AGENTS.md` + `opencode.json` | partial (permission rules) |
225225
| **OpenClaw** | `AGENTS.md` (auto-injected) + per-project `openclaw agents add --workspace` | varies by fork |
226226
| **Hermes Agent** | `AGENTS.md` (agentskills.io compatible) | partial (own memory) |
227-
| **Pi Coding Agent** | `AGENTS.md` + `.pi/skills/` | no (extension system) |
227+
| **Pi Coding Agent** | `AGENTS.md` + `.pi/skills/` + `.pi/extensions/` | yes (`tool_result` event) |
228228
| **Standalone Python** | `run.py` (any LLM) | yes (full control) |
229229
| **Antigravity** | `ANTIGRAVITY.md` | yes (system context) |
230230

adapters/pi/AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,6 @@ them.
4545
- System prompt override: put `.pi/SYSTEM.md` at project root if you
4646
want to replace pi's default system prompt entirely.
4747
- Prompt templates go in `.pi/prompts/`.
48-
- TypeScript extensions go in `.pi/extensions/` (advanced).
48+
- TypeScript extensions go in `.pi/extensions/` (advanced). This adapter
49+
installs `memory-hook.ts`, which logs `tool_result` events to episodic
50+
memory automatically.

adapters/pi/README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ on top so you keep one knowledge base even if you later swap harnesses.
1010
./install.sh pi
1111
```
1212

13+
Or on Windows PowerShell:
14+
```powershell
15+
.\install.ps1 pi C:\path\to\your-project
16+
```
17+
1318
Then install pi itself:
1419
```bash
1520
npm install -g @mariozechner/pi-coding-agent
@@ -23,8 +28,11 @@ npm install -g @mariozechner/pi-coding-agent
2328
- `.pi/skills/` → symlink to `.agent/skills/`. Pi scans this path at
2429
startup. Symlink means there's one source of truth; customize under
2530
`.agent/skills/` and pi sees it immediately.
26-
- `.pi/` directory is created even if empty — ready for optional pi
27-
extensions, prompt templates, and `.pi/SYSTEM.md` overrides.
31+
- `.pi/extensions/memory-hook.ts` — project-local extension that listens
32+
to Pi's `tool_result` event and appends episodic entries via the shared
33+
agentic-stack hook path.
34+
- `.pi/` directory is created for skills, extensions, prompt templates,
35+
and optional `.pi/SYSTEM.md` overrides.
2836

2937
## Coexisting with other adapters
3038
Pi, hermes, and opencode all read `AGENTS.md`. You can install any
@@ -35,6 +43,15 @@ subsequent installs are no-ops on that file.
3543
In pi: ask "what's in my LESSONS file?" — it should read
3644
`.agent/memory/semantic/LESSONS.md`.
3745

46+
Run one tool call, then inspect the episodic log:
47+
48+
```bash
49+
tail -1 .agent/memory/episodic/AGENT_LEARNINGS.jsonl
50+
```
51+
52+
You should see a `skill` of `pi` and an `action` derived from the tool
53+
that just ran.
54+
3855
## Optional
3956
If pi's default system prompt doesn't fit your workflow, drop a
4057
`.pi/SYSTEM.md` at project root. Pi uses it as a complete override.

adapters/pi/memory-hook.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2+
import { existsSync } from "node:fs";
3+
import { spawn } from "node:child_process";
4+
import path from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
7+
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
8+
const PROJECT_ROOT = path.resolve(EXTENSION_DIR, "..", "..");
9+
const HOOK_SCRIPT = path.join(
10+
PROJECT_ROOT,
11+
".agent",
12+
"harness",
13+
"hooks",
14+
"pi_post_tool.py",
15+
);
16+
17+
let warnedMissingHook = false;
18+
let warnedMissingPython = false;
19+
let warnedHookFailure = false;
20+
21+
type PythonCandidate = {
22+
command: string;
23+
args: string[];
24+
};
25+
26+
function pythonCandidates(): PythonCandidate[] {
27+
const envPy = process.env.AGENT_PYTHON?.trim();
28+
const out: PythonCandidate[] = [];
29+
if (envPy) out.push({ command: envPy, args: [] });
30+
out.push({ command: "python3", args: [] });
31+
out.push({ command: "python", args: [] });
32+
out.push({ command: "py", args: ["-3"] });
33+
return out;
34+
}
35+
36+
function tryRun(
37+
candidate: PythonCandidate,
38+
payload: Record<string, unknown>,
39+
): Promise<"ok" | "spawn-error" | "hook-failure"> {
40+
return new Promise((resolve) => {
41+
const child = spawn(
42+
candidate.command,
43+
[...candidate.args, HOOK_SCRIPT],
44+
{
45+
cwd: PROJECT_ROOT,
46+
stdio: ["pipe", "ignore", "ignore"],
47+
},
48+
);
49+
child.on("error", () => resolve("spawn-error"));
50+
child.on("spawn", () => {
51+
child.stdin.end(JSON.stringify(payload));
52+
});
53+
child.on("close", (code) => {
54+
resolve(code === 0 ? "ok" : "hook-failure");
55+
});
56+
});
57+
}
58+
59+
async function runHook(payload: Record<string, unknown>) {
60+
if (!existsSync(HOOK_SCRIPT)) return "missing-hook";
61+
for (const candidate of pythonCandidates()) {
62+
const result = await tryRun(candidate, payload);
63+
if (result === "ok") return "ok";
64+
if (result === "hook-failure") return "hook-failure";
65+
}
66+
return "missing-python";
67+
}
68+
69+
export default function (pi: ExtensionAPI) {
70+
pi.on("tool_result", async (event, ctx) => {
71+
const payload = {
72+
tool_name: event.toolName,
73+
tool_input: event.input ?? {},
74+
content: event.content ?? [],
75+
details: event.details ?? {},
76+
isError: event.isError ?? false,
77+
};
78+
79+
try {
80+
const result = await runHook(payload);
81+
if (result === "missing-hook" && !warnedMissingHook) {
82+
warnedMissingHook = true;
83+
ctx.ui.notify(
84+
"agentic-stack pi memory hook missing; automatic episodic logging disabled.",
85+
"warning",
86+
);
87+
} else if (result === "missing-python" && !warnedMissingPython) {
88+
warnedMissingPython = true;
89+
ctx.ui.notify(
90+
"agentic-stack pi memory hook: python3/python not found; automatic episodic logging disabled.",
91+
"warning",
92+
);
93+
} else if (result === "hook-failure" && !warnedHookFailure) {
94+
warnedHookFailure = true;
95+
ctx.ui.notify(
96+
"agentic-stack pi memory hook failed; continuing without automatic episodic logging.",
97+
"warning",
98+
);
99+
}
100+
} catch {
101+
if (!warnedHookFailure) {
102+
warnedHookFailure = true;
103+
ctx.ui.notify(
104+
"agentic-stack pi memory hook failed; continuing without automatic episodic logging.",
105+
"warning",
106+
);
107+
}
108+
}
109+
});
110+
}

docs/per-harness/pi.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and a TypeScript extension system. Our adapter layers the portable
1111
- `.pi/` directory
1212
- `.pi/skills` symlinked to `.agent/skills` (falls back to copy on
1313
platforms without symlinks, e.g. Windows without developer mode)
14+
- `.pi/extensions/memory-hook.ts`, auto-discovered by pi at startup and
15+
wired to the `tool_result` event for episodic logging
1416

1517
## Install
1618
```bash
@@ -19,18 +21,31 @@ npm install -g @mariozechner/pi-coding-agent
1921
pi
2022
```
2123

24+
On Windows PowerShell:
25+
```powershell
26+
.\install.ps1 pi C:\path\to\your-project
27+
npm install -g @mariozechner/pi-coding-agent
28+
pi
29+
```
30+
2231
## How it works
2332
- Pi loads `AGENTS.md` (or `CLAUDE.md`) from `~/.pi/agent/` and walks
2433
the current directory up to the filesystem root, aggregating
2534
context.
2635
- Skills at `.pi/skills/<name>/SKILL.md` use the same frontmatter-plus
2736
-body shape as agentskills.io and our `.agent/skills/` layout.
28-
- Pi extensions live in `.pi/extensions/` (TypeScript, optional).
37+
- Pi extensions live in `.pi/extensions/` (TypeScript). The adapter's
38+
`memory-hook.ts` listens to `tool_result` and forwards each tool result
39+
to `.agent/harness/hooks/pi_post_tool.py`, which reuses the same
40+
scoring / reflection logic as the Claude Code hook.
2941

3042
## Troubleshooting
3143
- If pi doesn't see your skills, run `pi skills list` — it should
3244
print entries from `.pi/skills/`. If the directory is a broken
3345
symlink, re-run `./install.sh pi` to rebuild.
46+
- If episodic logging stays empty, make sure Python is available as
47+
`python3`, `python`, or via `AGENT_PYTHON`, since the extension shells
48+
out to `.agent/harness/hooks/pi_post_tool.py`.
3449
- On Windows without symlink support, the installer copies
3550
`.agent/skills/` instead. Changes to `.agent/skills/` won't
3651
propagate — re-run the installer to sync.

0 commit comments

Comments
 (0)