Skip to content

Commit 9259812

Browse files
committed
refactor(plugins): move codex hooks to uv scripts
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent aa51d70 commit 9259812

7 files changed

Lines changed: 503 additions & 416 deletions

File tree

plugins/codex/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ verification, decision capture, and resumable checkpoints.
2929
| `.codex-plugin/plugin.json` | Codex plugin manifest |
3030
| `.mcp.json` | Basic Memory MCP server configuration |
3131
| `hooks/hooks.json` | SessionStart and PreCompact hook registration |
32-
| `hooks/session-start.sh` | Injects a compact memory brief at thread start |
33-
| `hooks/pre-compact.sh` | Writes an automatic Codex checkpoint before compaction |
32+
| `hooks/session-start.sh` | Launches the SessionStart uv script |
33+
| `hooks/session-start.py` | Injects a compact memory brief at thread start |
34+
| `hooks/pre-compact.sh` | Launches the PreCompact uv script |
35+
| `hooks/pre-compact.py` | Writes an automatic Codex checkpoint before compaction |
3436
| `skills/` | Codex-native Basic Memory workflows |
3537
| `schemas/` | Seed schemas for Codex sessions, decisions, and tasks |
3638

plugins/codex/hooks/pre-compact.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
#!/usr/bin/env -S uv run --script
2+
"""Checkpoint Codex work into Basic Memory before compaction."""
3+
4+
import json
5+
import os
6+
import re
7+
import shlex
8+
import shutil
9+
import subprocess
10+
import sys
11+
from datetime import datetime, timezone
12+
from pathlib import Path
13+
14+
15+
UUID_RE = re.compile(
16+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
17+
re.IGNORECASE,
18+
)
19+
20+
21+
def basic_memory_command() -> list[str] | None:
22+
configured = os.environ.get("BM_BIN")
23+
if configured:
24+
return shlex.split(configured)
25+
if shutil.which("basic-memory"):
26+
return ["basic-memory"]
27+
if shutil.which("bm"):
28+
return ["bm"]
29+
if shutil.which("uvx"):
30+
return ["uvx", "basic-memory"]
31+
if shutil.which("uv"):
32+
return ["uv", "tool", "run", "basic-memory"]
33+
return None
34+
35+
36+
def parse_payload() -> dict:
37+
try:
38+
payload = json.loads(sys.stdin.read() or "{}")
39+
except Exception:
40+
return {}
41+
return payload if isinstance(payload, dict) else {}
42+
43+
44+
def load_config(directory: Path) -> dict:
45+
path = directory / ".codex" / "basic-memory.json"
46+
try:
47+
data = json.loads(path.read_text())
48+
except Exception:
49+
return {}
50+
if not isinstance(data, dict):
51+
return {}
52+
return data.get("basicMemory", data)
53+
54+
55+
def text_of(content):
56+
if isinstance(content, str):
57+
return content
58+
if isinstance(content, list):
59+
parts = []
60+
for block in content:
61+
if isinstance(block, dict) and block.get("type") == "text":
62+
text = block.get("text")
63+
if isinstance(text, str):
64+
parts.append(text)
65+
return "\n".join(parts)
66+
return ""
67+
68+
69+
def transcript_turns(path: str):
70+
collected = []
71+
if not path:
72+
return collected
73+
try:
74+
with open(path) as handle:
75+
for line in handle:
76+
line = line.strip()
77+
if not line:
78+
continue
79+
try:
80+
obj = json.loads(line)
81+
except Exception:
82+
continue
83+
if obj.get("isMeta") or obj.get("toolUseResult") is not None:
84+
continue
85+
msg = obj.get("message") if isinstance(obj.get("message"), dict) else obj
86+
role = msg.get("role") or obj.get("type")
87+
if role not in ("user", "assistant"):
88+
continue
89+
text = text_of(msg.get("content")).strip()
90+
if text:
91+
collected.append((role, text))
92+
except Exception:
93+
return []
94+
return collected
95+
96+
97+
def git_status(directory: Path) -> list[str]:
98+
try:
99+
out = subprocess.run(
100+
["git", "status", "--short"],
101+
cwd=directory,
102+
capture_output=True,
103+
text=True,
104+
timeout=5,
105+
)
106+
except Exception:
107+
return []
108+
if out.returncode != 0:
109+
return []
110+
return [line for line in out.stdout.splitlines() if line.strip()][:20]
111+
112+
113+
def clip(value: str, limit: int) -> str:
114+
compact = " ".join(value.split())
115+
return compact if len(compact) <= limit else compact[: limit - 1].rstrip() + "..."
116+
117+
118+
def main() -> int:
119+
bm_cmd = basic_memory_command()
120+
if not bm_cmd:
121+
return 0
122+
123+
payload = parse_payload()
124+
cwd = Path(payload.get("cwd") or os.getcwd())
125+
transcript_path = payload.get("transcript_path") or ""
126+
session_id = payload.get("session_id") or ""
127+
turn_id = payload.get("turn_id") or ""
128+
trigger = payload.get("trigger") or ""
129+
model = payload.get("model") or ""
130+
131+
cfg = load_config(cwd)
132+
primary_project = str(cfg.get("primaryProject") or "").strip()
133+
capture_folder = str(cfg.get("captureFolder") or "codex-sessions").strip()
134+
135+
if not primary_project:
136+
return 0
137+
138+
conversation = transcript_turns(transcript_path)
139+
if not conversation or not any(role == "user" for role, _ in conversation):
140+
return 0
141+
142+
user_messages = [text for role, text in conversation if role == "user"]
143+
assistant_messages = [text for role, text in conversation if role == "assistant"]
144+
opening = user_messages[0] if user_messages else ""
145+
recent_user = user_messages[-3:]
146+
recent_assistant = assistant_messages[-2:]
147+
status_lines = git_status(cwd)
148+
149+
now = datetime.now(timezone.utc)
150+
iso = now.isoformat(timespec="seconds")
151+
title = f"Codex session {now.strftime('%Y-%m-%d %H:%M:%S')} - {clip(opening, 40)}"
152+
153+
frontmatter = [
154+
"---",
155+
"type: codex_session",
156+
"status: open",
157+
f"started: {iso}",
158+
f"ended: {iso}",
159+
f"project: {primary_project}",
160+
f"cwd: {cwd}",
161+
]
162+
if session_id:
163+
frontmatter.append(f"codex_session_id: {session_id}")
164+
if turn_id:
165+
frontmatter.append(f"codex_turn_id: {turn_id}")
166+
if trigger:
167+
frontmatter.append(f"trigger: {trigger}")
168+
if model:
169+
frontmatter.append(f"model: {model}")
170+
frontmatter += ["capture: extractive", "---"]
171+
172+
body = [
173+
"",
174+
f"# {title}",
175+
"",
176+
"_Automatic Codex pre-compaction checkpoint. It records the working cursor, "
177+
"not a polished summary._",
178+
"",
179+
"## Summary",
180+
f"Working in `{cwd}`.",
181+
f"- Opening request: {clip(opening, 300)}" if opening else "",
182+
"",
183+
"## Recent User Cursor",
184+
]
185+
body += [f"- {clip(message, 240)}" for message in recent_user]
186+
if recent_assistant:
187+
body += ["", "## Recent Assistant Notes"]
188+
body += [f"- {clip(message, 240)}" for message in recent_assistant]
189+
if status_lines:
190+
body += ["", "## Working Tree"]
191+
body += [f"- `{line}`" for line in status_lines]
192+
body += [
193+
"",
194+
"## Observations",
195+
f"- [context] Codex worked in `{cwd}`",
196+
f"- [context] Session opened with: {clip(opening, 200)}" if opening else "",
197+
"- [next_step] Re-read this checkpoint, inspect the current worktree, and "
198+
"continue from the latest user request",
199+
]
200+
201+
content = "\n".join(frontmatter + body)
202+
project_flag = "--project-id" if UUID_RE.match(primary_project) else "--project"
203+
204+
try:
205+
subprocess.run(
206+
[
207+
*bm_cmd,
208+
"tool",
209+
"write-note",
210+
"--title",
211+
title,
212+
"--folder",
213+
capture_folder,
214+
project_flag,
215+
primary_project,
216+
"--tags",
217+
"codex",
218+
"--tags",
219+
"auto-capture",
220+
],
221+
input=content,
222+
capture_output=True,
223+
text=True,
224+
timeout=60,
225+
)
226+
except Exception:
227+
return 0
228+
return 0
229+
230+
231+
if __name__ == "__main__":
232+
raise SystemExit(main())

0 commit comments

Comments
 (0)