Skip to content

Commit 208cf73

Browse files
authored
Merge pull request #17 from hovhannest/pi-tool-result-hook
Add pi tool-result hook and windows installer
2 parents 358ceff + 0f4e780 commit 208cf73

8 files changed

Lines changed: 505 additions & 7 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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+
"todo_write": "TodoWrite",
37+
"webfetch": "WebFetch",
38+
"web_fetch": "WebFetch",
39+
}
40+
41+
# Pi tends to use camelCase for tool_input keys; the shared
42+
# claude_code_post_tool helpers (cc._action_label, cc._reflection,
43+
# cc._importance) expect the snake_case keys Claude Code sends.
44+
# Normalize a known set so action labels and reflections come out
45+
# meaningful instead of degrading to "edit: ?" / "Edited ?".
46+
_PI_INPUT_KEY_MAP = {
47+
"filePath": "file_path",
48+
"filepath": "file_path",
49+
"path": "file_path",
50+
"oldString": "old_string",
51+
"oldstring": "old_string",
52+
"newString": "new_string",
53+
"newstring": "new_string",
54+
"content": "content",
55+
"command": "command",
56+
"todos": "todos",
57+
"todo": "todos",
58+
"url": "url",
59+
"pattern": "pattern",
60+
"query": "query",
61+
}
62+
63+
64+
def _tool_name(name: str) -> str:
65+
if not isinstance(name, str):
66+
return "Unknown"
67+
lowered = name.strip().lower()
68+
if lowered in _PI_TO_CANONICAL:
69+
return _PI_TO_CANONICAL[lowered]
70+
if "_" in name:
71+
return "".join(part[:1].upper() + part[1:] for part in name.split("_") if part)
72+
return name[:1].upper() + name[1:]
73+
74+
75+
def _normalize_input(raw) -> dict:
76+
"""Map Pi tool_input keys to the snake_case shape cc.* helpers expect."""
77+
if not isinstance(raw, dict):
78+
if raw is None:
79+
return {}
80+
return {"raw": str(raw)}
81+
out: dict = {}
82+
for k, v in raw.items():
83+
out[_PI_INPUT_KEY_MAP.get(k, k)] = v
84+
return out
85+
86+
87+
def _extract_text(content) -> str:
88+
if isinstance(content, str):
89+
return content[:500]
90+
if not isinstance(content, list):
91+
return ""
92+
texts = []
93+
for item in content:
94+
if isinstance(item, dict) and item.get("type") == "text":
95+
text = item.get("text")
96+
if isinstance(text, str) and text:
97+
texts.append(text)
98+
return " ".join(texts)[:500]
99+
100+
101+
def _normalize_response(event: dict) -> dict:
102+
"""Map Pi tool_result shape to the Claude hook's response schema."""
103+
resp: dict = {"is_error": bool(event.get("isError", False))}
104+
details = event.get("details")
105+
content = event.get("content")
106+
107+
if isinstance(details, dict):
108+
if isinstance(details.get("output"), str):
109+
resp["output"] = details["output"][:500]
110+
if isinstance(details.get("stdout"), str):
111+
resp["stdout"] = details["stdout"][:500]
112+
if isinstance(details.get("stderr"), str):
113+
resp["stderr"] = details["stderr"][:300]
114+
if isinstance(details.get("error"), str):
115+
resp["error"] = details["error"][:300]
116+
if isinstance(details.get("text"), str):
117+
resp["text"] = details["text"][:500]
118+
if "exitCode" in details:
119+
resp["exit_code"] = details.get("exitCode")
120+
if "cancelled" in details:
121+
resp["interrupted"] = bool(details.get("cancelled"))
122+
if "truncated" in details:
123+
resp["truncated"] = bool(details.get("truncated"))
124+
resp["details"] = details
125+
126+
text = _extract_text(content)
127+
if text:
128+
resp.setdefault("output", text)
129+
resp["content"] = [{"type": "text", "text": text}]
130+
131+
if not any(k in resp for k in ("output", "stdout", "text", "content")):
132+
if isinstance(details, str):
133+
resp["output"] = details[:500]
134+
elif details is not None:
135+
resp["output"] = json.dumps(details, default=str)[:500]
136+
137+
return resp
138+
139+
140+
def _emit_malformed(reason: str, raw_excerpt: str) -> None:
141+
"""Record an explicit failure entry instead of silently logging a bogus
142+
'Unknown success'. If Pi changes payload shape or sends malformed
143+
JSON, this surfaces in AGENT_LEARNINGS.jsonl as a real signal.
144+
"""
145+
excerpt = raw_excerpt[:200] if isinstance(raw_excerpt, str) else ""
146+
# importance MUST be numeric — downstream salience_score() does
147+
# `importance / 10.0` and a string would crash context_budget,
148+
# show.py, and auto_dream readers. 5 ≈ "medium" on the 1-10 scale.
149+
on_failure(
150+
skill_name="pi",
151+
action="hook:malformed_payload",
152+
error=f"pi tool_result payload malformed: {reason}",
153+
context=excerpt,
154+
confidence=0.95,
155+
importance=5,
156+
pain_score=2,
157+
)
158+
159+
160+
def main() -> None:
161+
raw = ""
162+
try:
163+
raw = sys.stdin.read()
164+
except OSError as e:
165+
_emit_malformed(f"stdin read failed: {e}", "")
166+
return
167+
168+
if not raw or not raw.strip():
169+
_emit_malformed("empty payload", "")
170+
return
171+
172+
try:
173+
payload = json.loads(raw)
174+
except json.JSONDecodeError as e:
175+
_emit_malformed(f"json decode error: {e.msg}", raw)
176+
return
177+
178+
if not isinstance(payload, dict):
179+
_emit_malformed(f"payload is {type(payload).__name__}, expected object", raw)
180+
return
181+
182+
if "tool_name" not in payload:
183+
_emit_malformed("missing tool_name", raw)
184+
return
185+
186+
tool_name = _tool_name(payload.get("tool_name") or "Unknown")
187+
tool_input = _normalize_input(payload.get("tool_input"))
188+
tool_response = _normalize_response(payload)
189+
190+
success = cc._is_success(tool_name, tool_input, tool_response)
191+
importance = cc._importance(tool_name, json.dumps(tool_input))
192+
action = cc._action_label(tool_name, tool_input)
193+
reflection = cc._reflection(tool_name, tool_input, tool_response, success)
194+
detail = cc._detail(tool_name, tool_input, tool_response, success)
195+
196+
pscore = cc._pain_score(importance, success)
197+
if success:
198+
log_execution(
199+
skill_name="pi",
200+
action=action,
201+
result=detail,
202+
success=True,
203+
reflection=reflection,
204+
importance=importance,
205+
confidence=0.7,
206+
pain_score=pscore,
207+
)
208+
else:
209+
on_failure(
210+
skill_name="pi",
211+
action=action,
212+
error=reflection,
213+
context=detail,
214+
confidence=0.7,
215+
importance=importance,
216+
pain_score=pscore,
217+
)
218+
219+
220+
if __name__ == "__main__":
221+
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.

0 commit comments

Comments
 (0)