Skip to content

Commit c121917

Browse files
committed
fix(codex): harden stop-hook installer runtime
1 parent d1675dd commit c121917

6 files changed

Lines changed: 171 additions & 20 deletions

File tree

nowledge-mem-codex-plugin/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ Codex does not give this package hard lifecycle hooks like Claude Code or OpenCl
2727

2828
## Prerequisites
2929

30-
For day-to-day skill usage, `nmem` must be on your PATH.
30+
For day-to-day skill usage, put `nmem` on your PATH.
3131

32-
For automatic `Stop`-hook capture, the Python interpreter that runs `scripts/install_hooks.py` must also be able to import `nmem_cli`. `uvx --from nmem-cli nmem` is enough for interactive CLI commands, but it does not make `nmem_cli` importable to Codex's hook runtime by itself.
32+
For automatic `Stop`-hook capture, the Python interpreter that runs `scripts/install_hooks.py` must be able to import `nmem_cli`. `uvx --from nmem-cli nmem` is enough for interactive CLI commands, but it does not make `nmem_cli` importable to Codex's hook runtime by itself.
3333

3434
**Quickest path** (if the Nowledge Mem desktop app is running):
3535
Settings > Preferences > Developer Tools > Install CLI
@@ -67,7 +67,7 @@ plugins = true
6767
enabled = true
6868
```
6969

70-
Install the bundled Codex hook helper with the same Python interpreter you want Codex to use for the hook runtime:
70+
Install the bundled Codex hook helper with the same Python you want Codex to use for the hook runtime:
7171

7272
```bash
7373
python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py
@@ -216,7 +216,7 @@ If you used `nowledge-mem-codex-prompts` before:
216216
- **"plugin is not installed"**: Check that the plugin files are at `~/.codex/plugins/cache/local/nowledge-mem/local/` and that `.codex-plugin/plugin.json` exists inside that directory.
217217
- **Hooks do not fire**: Run `python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py` again, then confirm `~/.codex/hooks.json` exists and `codex_hooks = true` is present under `[features]` in `~/.codex/config.toml`.
218218
- **Installer says `nmem_cli` is missing**: Run `python3 -m pip install nmem-cli`, then verify `python3 -c "import nmem_cli, nmem_cli.session_import"` succeeds before rerunning `scripts/install_hooks.py`.
219-
- **No auto-save after a response**: Inspect `~/.codex/log/nowledge-mem-stop-hook.log`. If the log shows repeated skips, check that the installed hook starts with the same `#!python` you used for `scripts/install_hooks.py`, and that `python3 -c "import nmem_cli, nmem_cli.session_import"` succeeds in that interpreter.
219+
- **No auto-save after a response**: Inspect `~/.codex/log/nowledge-mem-stop-hook.log`. The hook imports directly from Codex's `transcript_path`; if the log shows repeated skips, check that the installed hook starts with the same `#!python` you used for `scripts/install_hooks.py`, and that `python3 -c "import nmem_cli, nmem_cli.session_import"` succeeds in that interpreter.
220220
- **Only Working Memory runs, but search/distill never show up**: this package is skill-guided, not hook-driven. Merge the package `AGENTS.md` into the project root for stronger repo-specific behavior, and verify you are asking a continuation-style question rather than a fresh isolated one.
221221

222222
## Links

nowledge-mem-codex-plugin/RELEASING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ node nowledge-mem-codex-plugin/scripts/validate-plugin.mjs
2323
- update expected version checks in `scripts/validate-plugin.mjs`
2424
- keep install examples on `cp -r .../. ...` so `.codex-plugin/` is copied
2525
- keep `scripts/install_hooks.py` idempotent
26+
- keep the installer enforcing a Python runtime that can `import nmem_cli`
27+
- keep the copied hook pinned to the installer interpreter (`sys.executable`)
2628
- keep `hooks/nmem-stop-save.py` focused on direct transcript import via `transcript_path`
2729
- re-run `node nowledge-mem-codex-plugin/scripts/validate-plugin.mjs`
2830

@@ -31,6 +33,7 @@ node nowledge-mem-codex-plugin/scripts/validate-plugin.mjs
3133
After copying the plugin into `~/.codex/plugins/cache/local/nowledge-mem/local/`:
3234

3335
```bash
36+
python3 -c "import nmem_cli, nmem_cli.session_import"
3437
python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py
3538
codex exec -C . "Reply with exactly OK and nothing else."
3639
tail -n 20 ~/.codex/log/nowledge-mem-stop-hook.log

nowledge-mem-codex-plugin/scripts/install_hooks.py

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
#!/usr/bin/env python3
22
from __future__ import annotations
33

4+
import importlib.util
45
import json
6+
import re
57
import shutil
68
import stat
79
import sys
@@ -18,19 +20,76 @@
1820
CONFIG_FILE = CODEX_DIR / "config.toml"
1921
SOURCE_HOOK = PLUGIN_ROOT / "hooks" / "nmem-stop-save.py"
2022
INSTALLED_HOOK = HOOKS_DIR / "nowledge-mem-stop-save.py"
23+
CODEX_HOOKS_KEY_RE = re.compile(r"^\s*codex_hooks\s*=")
24+
25+
26+
def backup_invalid_json(path: Path, *, reason: str) -> dict:
27+
backup = path.with_name(f"{path.name}.{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}.bak")
28+
shutil.move(path, backup)
29+
print(f"warning: moved {reason} to {backup}", file=sys.stderr)
30+
return {}
2131

2232

2333
def load_json(path: Path) -> dict:
2434
if not path.exists():
2535
return {}
2636
try:
27-
return json.loads(path.read_text(encoding="utf-8"))
37+
payload = json.loads(path.read_text(encoding="utf-8"))
2838
except json.JSONDecodeError:
29-
backup = path.with_name(f"{path.name}.{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}.bak")
30-
shutil.move(path, backup)
31-
print(f"warning: moved malformed JSON to {backup}", file=sys.stderr)
32-
return {}
33-
39+
return backup_invalid_json(path, reason="malformed JSON")
40+
if not isinstance(payload, dict):
41+
return backup_invalid_json(path, reason="non-object JSON")
42+
return payload
43+
44+
45+
def ensure_nmem_cli_runtime_ready() -> None:
46+
required_modules = ("nmem_cli", "nmem_cli.session_import")
47+
missing = [name for name in required_modules if importlib.util.find_spec(name) is None]
48+
if not missing:
49+
return
50+
missing_text = ", ".join(missing)
51+
raise SystemExit(
52+
"This installer must be run with a Python interpreter that can import "
53+
f"{missing_text}. Install nmem-cli into that interpreter, then rerun "
54+
"scripts/install_hooks.py with the same python3."
55+
)
56+
57+
58+
def normalize_hooks_doc(hooks_doc: dict) -> tuple[dict, list[dict]]:
59+
hooks = hooks_doc.get("hooks")
60+
if not isinstance(hooks, dict):
61+
hooks = {}
62+
hooks_doc["hooks"] = hooks
63+
64+
stop_hooks = hooks.get("Stop")
65+
if not isinstance(stop_hooks, list):
66+
stop_hooks = []
67+
68+
normalized_stop_hooks: list[dict] = []
69+
for entry in stop_hooks:
70+
if not isinstance(entry, dict):
71+
continue
72+
normalized_entry = dict(entry)
73+
if not isinstance(normalized_entry.get("hooks"), list):
74+
normalized_entry["hooks"] = []
75+
normalized_stop_hooks.append(normalized_entry)
76+
77+
hooks["Stop"] = normalized_stop_hooks
78+
return hooks_doc, normalized_stop_hooks
79+
80+
81+
def rewrite_hook_shebang(path: Path) -> None:
82+
original = path.read_text(encoding="utf-8")
83+
lines = original.splitlines()
84+
desired = f"#!{sys.executable}"
85+
if lines and lines[0].startswith("#!"):
86+
lines[0] = desired
87+
else:
88+
lines.insert(0, desired)
89+
updated = "\n".join(lines)
90+
if original.endswith("\n"):
91+
updated += "\n"
92+
path.write_text(updated, encoding="utf-8")
3493

3594
def save_json(path: Path, payload: dict) -> None:
3695
path.parent.mkdir(parents=True, exist_ok=True)
@@ -40,6 +99,7 @@ def save_json(path: Path, payload: dict) -> None:
4099
def install_runtime_hook() -> None:
41100
HOOKS_DIR.mkdir(parents=True, exist_ok=True)
42101
shutil.copy2(SOURCE_HOOK, INSTALLED_HOOK)
102+
rewrite_hook_shebang(INSTALLED_HOOK)
43103
mode = INSTALLED_HOOK.stat().st_mode
44104
INSTALLED_HOOK.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
45105

@@ -53,8 +113,7 @@ def merge_hooks_json() -> None:
53113
else:
54114
hooks_doc = {}
55115

56-
hooks = hooks_doc.setdefault("hooks", {})
57-
stop_hooks = hooks.setdefault("Stop", [])
116+
hooks_doc, stop_hooks = normalize_hooks_doc(hooks_doc)
58117

59118
desired = {
60119
"matcher": ".*",
@@ -68,7 +127,9 @@ def merge_hooks_json() -> None:
68127

69128
replaced = False
70129
for index, entry in enumerate(stop_hooks):
71-
for hook in entry.get("hooks", []):
130+
for hook in entry["hooks"]:
131+
if not isinstance(hook, dict):
132+
continue
72133
if hook.get("command") == str(INSTALLED_HOOK):
73134
stop_hooks[index] = desired
74135
replaced = True
@@ -113,7 +174,7 @@ def ensure_codex_hooks_enabled() -> None:
113174
replaced = False
114175
for index in range(features_start + 1, features_end):
115176
stripped = lines[index].strip()
116-
if stripped.startswith(f"{target_key} "):
177+
if CODEX_HOOKS_KEY_RE.match(stripped):
117178
lines[index] = "codex_hooks = true"
118179
replaced = True
119180
break
@@ -128,6 +189,7 @@ def ensure_codex_hooks_enabled() -> None:
128189

129190

130191
def main() -> int:
192+
ensure_nmem_cli_runtime_ready()
131193
install_runtime_hook()
132194
merge_hooks_json()
133195
ensure_codex_hooks_enabled()

nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,12 @@
77
from pathlib import Path
88
from urllib.parse import quote
99

10-
from nmem_cli import cli
11-
from nmem_cli.session_import import parse_codex_session_streaming
12-
13-
1410
SESSIONS_ROOT = Path.home() / ".codex" / "sessions"
1511
AGENTS_PREFIX = "# AGENTS.md instructions for "
1612
GET_TIMEOUT_SECONDS = 10.0
1713
IMPORT_TIMEOUT_SECONDS = 120.0
14+
cli = None
15+
parse_codex_session_streaming = None
1816

1917

2018
def load_hook_module():
@@ -30,8 +28,20 @@ def iter_codex_rollouts() -> list[Path]:
3028
return sorted(SESSIONS_ROOT.rglob("rollout-*.jsonl"))
3129

3230

31+
def ensure_nmem_modules() -> tuple[object, object]:
32+
global cli, parse_codex_session_streaming
33+
if cli is None or parse_codex_session_streaming is None:
34+
from nmem_cli import cli as cli_module
35+
from nmem_cli.session_import import parse_codex_session_streaming as parser
36+
37+
cli = cli_module
38+
parse_codex_session_streaming = parser
39+
return cli, parse_codex_session_streaming
40+
41+
3342
def get_thread(thread_id: str) -> dict | None:
34-
return cli.api_get_optional(
43+
cli_module, _ = ensure_nmem_modules()
44+
return cli_module.api_get_optional(
3545
f"/threads/{quote(thread_id, safe='')}",
3646
timeout=GET_TIMEOUT_SECONDS,
3747
)
@@ -101,13 +111,14 @@ def main() -> int:
101111
args = parser.parse_args()
102112

103113
hook_module = load_hook_module()
114+
_, parse_codex = ensure_nmem_modules()
104115
checked = 0
105116
refreshed = 0
106117
errors = 0
107118

108119
for rollout_path in iter_codex_rollouts():
109120
try:
110-
parsed = parse_codex_session_streaming(rollout_path, truncate_large_content=True)
121+
parsed = parse_codex(rollout_path, truncate_large_content=True)
111122
thread_id = parsed["thread_id"]
112123
except Exception as exc:
113124
errors += 1
@@ -156,6 +167,9 @@ def main() -> int:
156167
except SystemExit as exc:
157168
errors += 1
158169
print(f"ERROR refresh {thread_id}: exited {exc.code}", flush=True)
170+
except Exception as exc:
171+
errors += 1
172+
print(f"ERROR refresh {thread_id}: {exc}", flush=True)
159173

160174
print(
161175
json.dumps(

nowledge-mem-codex-plugin/scripts/validate-plugin.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ if (readme !== null) {
109109
"~/.codex/hooks.json",
110110
"codex_hooks = true",
111111
"host-level",
112+
"nmem_cli",
112113
]) {
113114
if (!readme.includes(phrase)) {
114115
fail(`README must mention ${phrase}`);

nowledge-mem-codex-plugin/tests/test_codex_plugin.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,33 @@ def test_load_json_recovers_from_malformed_json(self):
393393
backups = list(self.module.GLOBAL_HOOKS_FILE.parent.glob("hooks.json.*.bak"))
394394
self.assertEqual(len(backups), 1)
395395

396+
def test_load_json_recovers_from_non_object_json(self):
397+
self.module.GLOBAL_HOOKS_FILE.parent.mkdir(parents=True, exist_ok=True)
398+
self.module.GLOBAL_HOOKS_FILE.write_text('["not-an-object"]', encoding="utf-8")
399+
400+
payload = self.module.load_json(self.module.GLOBAL_HOOKS_FILE)
401+
402+
self.assertEqual(payload, {})
403+
backups = list(self.module.GLOBAL_HOOKS_FILE.parent.glob("hooks.json.*.bak"))
404+
self.assertEqual(len(backups), 1)
405+
406+
def test_merge_hooks_json_normalizes_malformed_stop_section(self):
407+
self.module.GLOBAL_HOOKS_FILE.parent.mkdir(parents=True, exist_ok=True)
408+
self.module.GLOBAL_HOOKS_FILE.write_text(
409+
'{"hooks": {"Stop": {"matcher": ".*"}}}',
410+
encoding="utf-8",
411+
)
412+
413+
self.module.merge_hooks_json()
414+
payload = self.module.load_json(self.module.GLOBAL_HOOKS_FILE)
415+
416+
self.assertIsInstance(payload["hooks"]["Stop"], list)
417+
self.assertEqual(len(payload["hooks"]["Stop"]), 1)
418+
self.assertEqual(
419+
payload["hooks"]["Stop"][0]["hooks"][0]["command"],
420+
str(self.module.INSTALLED_HOOK),
421+
)
422+
396423
def test_ensure_codex_hooks_enabled_only_changes_features_section(self):
397424
self.module.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
398425
self.module.CONFIG_FILE.write_text(
@@ -437,6 +464,19 @@ def test_ensure_codex_hooks_enabled_replaces_indented_key_without_duplication(se
437464
self.assertNotIn("codex_hooks = false", updated)
438465
self.assertIn("[features]\ncodex_hooks = true\napps = true\n", updated)
439466

467+
def test_install_runtime_hook_rewrites_shebang_to_current_python(self):
468+
self.module.install_runtime_hook()
469+
470+
installed = self.module.INSTALLED_HOOK.read_text(encoding="utf-8")
471+
self.assertTrue(installed.startswith(f"#!{self.module.sys.executable}\n"))
472+
473+
def test_ensure_nmem_cli_runtime_ready_exits_when_missing(self):
474+
with mock.patch.object(self.module.importlib.util, "find_spec", return_value=None):
475+
with self.assertRaises(SystemExit) as exc:
476+
self.module.ensure_nmem_cli_runtime_ready()
477+
478+
self.assertIn("nmem_cli", str(exc.exception))
479+
440480

441481
class RefreshThreadTitleTests(unittest.TestCase):
442482
def setUp(self):
@@ -472,6 +512,37 @@ def test_refresh_thread_restores_original_messages_on_failure(self):
472512
[{"role": "user", "content": "old"}],
473513
)
474514

515+
def test_main_counts_generic_refresh_errors(self):
516+
temp_dir = tempfile.TemporaryDirectory()
517+
self.addCleanup(temp_dir.cleanup)
518+
rollout_path = Path(temp_dir.name) / "rollout-1.jsonl"
519+
rollout_path.write_text("placeholder", encoding="utf-8")
520+
521+
self.module.parse_codex_session_streaming = mock.Mock(
522+
return_value={
523+
"thread_id": "codex-thread-1",
524+
"messages": [{"role": "user", "content": "real request"}],
525+
"workspace": "/tmp/project",
526+
}
527+
)
528+
self.module.cli = mock.Mock()
529+
530+
with mock.patch.object(self.module, "iter_codex_rollouts", return_value=[rollout_path]), \
531+
mock.patch.object(
532+
self.module,
533+
"get_thread",
534+
return_value={"thread": {"title": f"{self.module.AGENTS_PREFIX}/tmp/project"}},
535+
), \
536+
mock.patch.object(self.module, "refresh_thread", side_effect=RuntimeError("boom")), \
537+
mock.patch("sys.argv", ["refresh_thread_titles.py"]), \
538+
mock.patch("builtins.print") as print_mock:
539+
result = self.module.main()
540+
541+
self.assertEqual(result, 0)
542+
printed_lines = [" ".join(str(arg) for arg in call.args) for call in print_mock.call_args_list]
543+
self.assertTrue(any("ERROR refresh codex-thread-1: boom" in line for line in printed_lines))
544+
self.assertTrue(any('"errors": 1' in line for line in printed_lines))
545+
475546

476547
if __name__ == "__main__":
477548
unittest.main()

0 commit comments

Comments
 (0)