Skip to content

Commit 62fc7b0

Browse files
author
codejunkie99
committed
merge master into fix/pr-16-followups
# Conflicts: # README.md # install.ps1
2 parents 2e0a707 + 208cf73 commit 62fc7b0

14 files changed

Lines changed: 824 additions & 54 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Cross-platform locked append for episodic JSONL writes.
2+
3+
POSIX `write(2)` in O_APPEND mode is atomic for payloads up to PIPE_BUF
4+
(4 KB on Linux, 512 B minimum per POSIX). Most episodic entries fit,
5+
but failure entries with reflection + context + detail can exceed that,
6+
and two harness hooks writing from the same process (or from two Pi
7+
sessions on the same repo) can interleave bytes mid-line. Silent
8+
corruption is worse than a visible error because every downstream
9+
reader (`auto_dream.py`, `cluster.py`, `context_budget.py`,
10+
`show.py`) skips `JSONDecodeError` lines without surfacing the loss.
11+
12+
This module serializes appends with `fcntl.flock(LOCK_EX)` on POSIX.
13+
On platforms without `fcntl` (native Windows Python) the lock is a
14+
no-op and behavior matches the pre-lock baseline. WSL, git-bash via
15+
Cygwin, macOS, and Linux all provide `fcntl`.
16+
"""
17+
import json
18+
import os
19+
20+
try:
21+
import fcntl # POSIX
22+
_HAVE_FLOCK = True
23+
except ImportError:
24+
_HAVE_FLOCK = False
25+
26+
27+
def append_jsonl(path: str, entry: dict) -> dict:
28+
"""Serialize `entry` to one JSON line and append to `path`.
29+
30+
Uses `open(..., "ab")` (append-binary) to bypass Python's text-mode
31+
buffering and guarantee a single `write(2)` per call. `fcntl.flock`
32+
provides cross-process mutual exclusion on POSIX.
33+
"""
34+
payload = (json.dumps(entry) + "\n").encode("utf-8")
35+
os.makedirs(os.path.dirname(path), exist_ok=True)
36+
with open(path, "ab") as f:
37+
if _HAVE_FLOCK:
38+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
39+
try:
40+
f.write(payload)
41+
f.flush()
42+
finally:
43+
if _HAVE_FLOCK:
44+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
45+
return entry

.agent/harness/hooks/on_failure.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Failures are learning. High pain score + rewrite flag after repeat offenses."""
22
import json, datetime, os
33
from ._provenance import build_source
4+
from ._episodic_io import append_jsonl
45

56
ROOT = os.path.join(os.path.dirname(__file__), "..", "..")
67
EPISODIC = os.path.join(ROOT, "memory/episodic/AGENT_LEARNINGS.jsonl")
@@ -69,7 +70,4 @@ def on_failure(skill_name, action, error, context="", confidence=0.9,
6970
f"Flag for rewrite."
7071
)
7172
entry["pain_score"] = 10
72-
os.makedirs(os.path.dirname(EPISODIC), exist_ok=True)
73-
with open(EPISODIC, "a") as f:
74-
f.write(json.dumps(entry) + "\n")
75-
return entry
73+
return append_jsonl(EPISODIC, entry)
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()

.agent/harness/hooks/post_execution.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Runs after every action. Appends a structured entry to episodic memory."""
2-
import json, datetime, os
2+
import datetime, os
33
from ._provenance import build_source
4+
from ._episodic_io import append_jsonl
45

56
ROOT = os.path.join(os.path.dirname(__file__), "..", "..")
67
EPISODIC = os.path.join(ROOT, "memory/episodic/AGENT_LEARNINGS.jsonl")
@@ -15,7 +16,6 @@ def log_execution(skill_name, action, result, success, reflection="",
1516
a higher value (e.g. 5) for high-importance successful operations so
1617
recurring patterns cross the dream-cycle promotion threshold (7.0).
1718
"""
18-
os.makedirs(os.path.dirname(EPISODIC), exist_ok=True)
1919
if pain_score is None:
2020
pain_score = 2 if success else 7
2121
entry = {
@@ -31,6 +31,4 @@ def log_execution(skill_name, action, result, success, reflection="",
3131
"source": build_source(skill_name),
3232
"evidence_ids": list(evidence_ids) if evidence_ids else [],
3333
}
34-
with open(EPISODIC, "a") as f:
35-
f.write(json.dumps(entry) + "\n")
36-
return entry
34+
return append_jsonl(EPISODIC, entry)

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ adapters/ # one small shim per harness
194194
├── cursor/ (.cursor/rules/*.mdc)
195195
├── windsurf/ (.windsurfrules)
196196
├── opencode/ (AGENTS.md + opencode.json)
197-
├── openclaw/ (system-prompt include)
197+
├── openclaw/ (AGENTS.md + system-prompt include; auto-registers per-project agent)
198198
├── hermes/ (AGENTS.md)
199199
├── pi/ (AGENTS.md + .pi/skills symlink)
200200
├── codex/ (AGENTS.md)
@@ -223,9 +223,9 @@ verify_codex_fixes.py # v0.8.0 regression checks (33 checks)
223223
| **Cursor** | `.cursor/rules/*.mdc` | no (manual reflect calls) |
224224
| **Windsurf** | `.windsurfrules` | no (manual reflect calls) |
225225
| **OpenCode** | `AGENTS.md` + `opencode.json` | partial (permission rules) |
226-
| **OpenClaw** | system-prompt include | varies by fork |
226+
| **OpenClaw** | `AGENTS.md` (auto-injected) + per-project `openclaw agents add --workspace` | varies by fork |
227227
| **Hermes Agent** | `AGENTS.md` (agentskills.io compatible) | partial (own memory) |
228-
| **Pi Coding Agent** | `AGENTS.md` + `.pi/skills/` | no (extension system) |
228+
| **Pi Coding Agent** | `AGENTS.md` + `.pi/skills/` + `.pi/extensions/` | yes (`tool_result` event) |
229229
| **Codex** | `AGENTS.md` + `.agents/skills/` | no (manual reflect calls) |
230230
| **Standalone Python** | `run.py` (any LLM) | yes (full control) |
231231
| **Antigravity** | `ANTIGRAVITY.md` | yes (system context) |

adapters/openclaw/AGENTS.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# AGENTS.md — OpenClaw adapter for agentic-stack
2+
3+
OpenClaw auto-injects `AGENTS.md` from the workspace root into the system
4+
prompt. This file points it at the portable brain in `.agent/`.
5+
6+
## Startup (read in order)
7+
1. `.agent/AGENTS.md` — the map
8+
2. `.agent/memory/personal/PREFERENCES.md` — user conventions
9+
3. `.agent/memory/semantic/LESSONS.md` — distilled lessons
10+
4. `.agent/protocols/permissions.md` — hard rules
11+
12+
## Skills
13+
- Read `.agent/skills/_index.md` first.
14+
- Load `.agent/skills/<name>/SKILL.md` only when triggers match.
15+
16+
## Recall before non-trivial tasks
17+
For deploy / ship / migration / schema / timestamp / date / failing test /
18+
debug / refactor, FIRST run:
19+
20+
```bash
21+
python3 .agent/tools/recall.py "<description>"
22+
```
23+
24+
Surface results in a `Consulted lessons before acting:` block and follow
25+
them.
26+
27+
## Memory discipline
28+
- Update `.agent/memory/working/WORKSPACE.md` as you work.
29+
- After significant actions, run
30+
`python3 .agent/tools/memory_reflect.py <skill> <action> <outcome>`.
31+
- Never delete memory entries; archive only.
32+
- Quick state: `python3 .agent/tools/show.py`.
33+
- Teach a rule in one shot:
34+
`python3 .agent/tools/learn.py "<rule>" --rationale "<why>"`.
35+
36+
## Hard rules
37+
- No force push to `main`, `production`, or `staging`.
38+
- No modification of `.agent/protocols/permissions.md`.
39+
- Blocked means blocked.

0 commit comments

Comments
 (0)