From 5d6a74a48ff68bc364cb8ac810e785a89c44fef7 Mon Sep 17 00:00:00 2001 From: Hanson Mei Date: Thu, 9 Apr 2026 23:34:25 +0800 Subject: [PATCH 1/8] feat(codex): bundle host-level stop hook installer --- README.md | 2 +- integrations.json | 13 +- nowledge-mem-codex-plugin/CHANGELOG.md | 9 +- nowledge-mem-codex-plugin/README.md | 55 +++++- nowledge-mem-codex-plugin/RELEASING.md | 41 +++++ .../hooks/nmem-stop-save.py | 162 ++++++++++++++++++ .../scripts/install_hooks.py | 105 ++++++++++++ .../scripts/validate-plugin.mjs | 117 +++++++++++++ nowledge-mem-codex-prompts/README.md | 2 +- 9 files changed, 494 insertions(+), 12 deletions(-) create mode 100644 nowledge-mem-codex-plugin/RELEASING.md create mode 100644 nowledge-mem-codex-plugin/hooks/nmem-stop-save.py create mode 100644 nowledge-mem-codex-plugin/scripts/install_hooks.py create mode 100644 nowledge-mem-codex-plugin/scripts/validate-plugin.mjs diff --git a/README.md b/README.md index a011d7e1..dd930958 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Each directory is a standalone integration. Pick the one that matches your tool. | **[Antigravity Trajectory Extractor](https://github.com/jijiamoer/antigravity-trajectory-extractor)** | `git clone https://github.com/jijiamoer/antigravity-trajectory-extractor.git` | Live RPC extraction for Antigravity conversation trajectories. | | **[Windsurf Trajectory Extractor](https://github.com/jijiamoer/windsurf-trajectory-extractor)** | `git clone https://github.com/jijiamoer/windsurf-trajectory-extractor.git` | Offline protobuf extraction for Windsurf Cascade conversation history. | | **[Cursor Plugin](nowledge-mem-cursor-plugin)** | Link `nowledge-mem-cursor-plugin` into `~/.cursor/plugins/local/nowledge-mem-cursor` | Cursor-native plugin package with a session-start Working Memory hook, bundled MCP config, rules, and honest `save-handoff` semantics. | -| **[Codex Plugin](nowledge-mem-codex-plugin)** | Copy the full plugin directory, including `.codex-plugin`, to `~/.codex/plugins/cache/local/nowledge-mem/local/` and enable it in `~/.codex/config.toml` | Packaged Codex skills for Working Memory bootstrap, proactive recall guidance, real session save, and distillation. | +| **[Codex Plugin](nowledge-mem-codex-plugin)** | Copy the full plugin directory, including `.codex-plugin`, to `~/.codex/plugins/cache/local/nowledge-mem/local/`, enable it in `~/.codex/config.toml`, then run the bundled hook installer | Packaged Codex skills for Working Memory bootstrap, proactive recall guidance, and distillation, plus an optional host-level `Stop` hook installer for automatic transcript capture. | | **[OpenClaw Plugin](nowledge-mem-openclaw-plugin)** | `openclaw plugins install clawhub:@nowledge/openclaw-nowledge-mem` | Full memory lifecycle with memory tools, thread tools, automatic capture, and distillation. | | **[Alma Plugin](nowledge-mem-alma-plugin)** | Search Nowledge in Alma official Plugin marketplace | Alma-native plugin with Working Memory, thread-aware recall, structured saves, and optional auto-capture. | | **[Bub Plugin](nowledge-mem-bub-plugin)** | `pip install nowledge-mem-bub` | Bub-native plugin: cross-tool knowledge, auto-capture via save_state, Working Memory, and graph exploration. | diff --git a/integrations.json b/integrations.json index 1fc507f8..c4fb38fe 100644 --- a/integrations.json +++ b/integrations.json @@ -110,7 +110,7 @@ "search": true, "distill": true, "autoRecall": false, - "autoCapture": false, + "autoCapture": true, "graphExploration": false, "status": true }, @@ -123,23 +123,24 @@ "bootstrap": "guided", "recall": "guided", "distill": "guided", - "threads": "explicit-save", + "threads": "automatic-capture", "bestResultRequires": [ - "Install the Codex package and enable plugins in ~/.codex/config.toml", + "Install the Codex package, enable plugins in ~/.codex/config.toml, then run the bundled hook installer", "Keep nmem available on this machine", + "Run the hook installer with a Python interpreter that can import nmem_cli", "Merge the package AGENTS.md into the project for stronger follow-through" ] }, "install": { - "command": "mkdir -p ~/.codex/plugins/cache/local/nowledge-mem/local && cp -R nowledge-mem-codex-plugin/. ~/.codex/plugins/cache/local/nowledge-mem/local/", - "updateCommand": "cp -R nowledge-mem-codex-plugin/. ~/.codex/plugins/cache/local/nowledge-mem/local/", + "command": "mkdir -p ~/.codex/plugins/cache/local/nowledge-mem/local && cp -R nowledge-mem-codex-plugin/. ~/.codex/plugins/cache/local/nowledge-mem/local/ && python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py", + "updateCommand": "cp -R nowledge-mem-codex-plugin/. ~/.codex/plugins/cache/local/nowledge-mem/local/ && python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py", "configRequired": "[features] plugins = true AND [plugins.\"nowledge-mem@local\"] enabled = true in ~/.codex/config.toml", "detectionHint": "Running as Codex CLI agent; ~/.codex/ exists", "docsUrl": "/docs/integrations/codex-cli" }, "toolNaming": { "convention": "cli-direct", - "note": "Packaged Codex skills teach the agent to invoke nmem CLI progressively. Working Memory is the reliable bootstrap; search and distill remain model-driven unless repo guidance tightens policy." + "note": "Packaged Codex skills teach the agent to invoke nmem CLI progressively. Working Memory is the reliable bootstrap; search and distill remain model-driven unless repo guidance tightens policy. The bundled host-level Stop hook installer adds automatic transcript capture once installed." }, "skills": ["working-memory", "search-memory", "save-thread", "distill-memory", "status"], "slashCommands": [] diff --git a/nowledge-mem-codex-plugin/CHANGELOG.md b/nowledge-mem-codex-plugin/CHANGELOG.md index dd871297..eeb42324 100644 --- a/nowledge-mem-codex-plugin/CHANGELOG.md +++ b/nowledge-mem-codex-plugin/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [0.1.3] - 2026-04-11 +### Added + +- **Bundled Codex hook installer**: added `scripts/install_hooks.py` to install a native Codex `Stop` hook into `~/.codex/hooks.json`, copy the runtime hook into `~/.codex/hooks/`, and enable `codex_hooks = true` in `~/.codex/config.toml`. +- **Host-level Stop hook runtime**: added `hooks/nmem-stop-save.py`, which reads Codex's `transcript_path`, parses the rollout directly with `nmem_cli.session_import.parse_codex_session_streaming`, and imports the current thread into Mem without relying on `nmem t save --from codex` session discovery. +- **Plugin validation and release notes**: added `scripts/validate-plugin.mjs` and `RELEASING.md` so the Codex package now has the same explicit release contract as the other maintained integrations in this repo. ### Improved @@ -11,7 +16,9 @@ ### Fixed - **Install copy command**: Codex install/update instructions now preserve hidden files such as `.codex-plugin/plugin.json`. -- **Docs honesty**: removed wording that implied Codex has lifecycle-hook automation comparable to hosts like Claude Code or OpenClaw. +- **Hook runtime now uses the installer interpreter**: `scripts/install_hooks.py` now pins the copied `Stop` hook to the same Python interpreter that ran the installer, and it fails fast if that interpreter cannot import `nmem_cli`. +- **Refresh helper no longer crashes on import**: `scripts/refresh_thread_titles.py` now lazily loads `nmem_cli` and counts generic refresh failures instead of aborting the full run. +- **Docs honesty**: clarify that Codex memory search and distill remain skill-guided, while automatic transcript capture comes from the optional host-level hook installer. ## [0.1.2] - 2026-04-06 diff --git a/nowledge-mem-codex-plugin/README.md b/nowledge-mem-codex-plugin/README.md index 078f1e14..91e9c90d 100644 --- a/nowledge-mem-codex-plugin/README.md +++ b/nowledge-mem-codex-plugin/README.md @@ -9,6 +9,7 @@ Switch between Claude Code, Gemini, Cursor, and Codex without losing context. De - **Pick up where you left off.** Every session can start from your current priorities, recent decisions, and unresolved questions. - **Search when prior work matters.** The plugin teaches Codex when to search memories and threads, especially on continuation-style tasks. - **Insights stick around.** The plugin teaches Codex to distill durable decisions and learnings when they emerge. +- **Automatic transcript capture is available.** Once you run the bundled host-level `Stop` hook installer, Codex can import the real transcript after each completed turn. - **Real session history.** Save the full Codex transcript, not just a summary. - **Quick diagnostics.** One command to verify everything is connected. @@ -26,7 +27,9 @@ Codex does not give this package hard lifecycle hooks like Claude Code or OpenCl ## Prerequisites -`nmem` CLI must be in your PATH. +For day-to-day skill usage, `nmem` must be on your PATH. + +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. **Quickest path** (if the Nowledge Mem desktop app is running): Settings > Preferences > Developer Tools > Install CLI @@ -36,9 +39,11 @@ Settings > Preferences > Developer Tools > Install CLI ```bash curl -LsSf https://astral.sh/uv/install.sh | sh uvx --from nmem-cli nmem --version +python3 -m pip install nmem-cli +python3 -c "import nmem_cli, nmem_cli.session_import" ``` -Or `pip install nmem-cli`. Then verify with `nmem status`. +Then verify with `nmem status`. ## Install @@ -62,6 +67,12 @@ plugins = true enabled = true ``` +Install the bundled Codex hook helper with the same Python interpreter you want Codex to use for the hook runtime: + +```bash +python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py +``` + Restart Codex after installation. ### Repo-level (this project only) @@ -100,6 +111,31 @@ The path is relative to the repo root, not to the `marketplace.json` file. You still need the feature gate and plugin entry in `~/.codex/config.toml` (see Home-level above). Codex will discover the marketplace on startup and load the plugin from the repo-local source. +If you want automatic thread capture in repo-level installs too, run: + +```bash +python3 ./.agents/nowledge-mem/scripts/install_hooks.py +``` + +## Codex Hook Support + +Codex hooks are currently **host-level**, not plugin-local. In practice that means Codex reads `~/.codex/hooks.json`, not hook assets tucked inside the plugin directory. + +This package therefore ships a small helper installer: + +```bash +python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py +``` + +It does four things: + +1. Copies the bundled runtime hook into `~/.codex/hooks/nowledge-mem-stop-save.py` +2. Merges a `Stop` hook entry into `~/.codex/hooks.json` +3. Ensures `codex_hooks = true` exists under `[features]` in `~/.codex/config.toml` +4. Pins the installed hook shebang to the current `sys.executable` so Codex runs the hook with the same Python interpreter you used for installation + +The installed `Stop` hook reads Codex's `transcript_path`, parses the rollout directly with `nmem_cli.session_import.parse_codex_session_streaming`, and imports the current thread into Mem. It keeps lightweight state in `~/.codex/nowledge_mem_codex_hook_state.json` so repeated `Stop` events only re-import when the transcript actually changed. + ## Verify Start a new Codex session and ask: "What was I working on?" The agent should load your Working Memory briefing. @@ -108,6 +144,15 @@ Then test one continuation-style prompt such as "What did we decide before about If Mem is not running yet, try `$nowledge-mem:status` to check connectivity. +For automatic capture, you can also run a tiny one-shot check: + +```bash +codex exec -C . "Reply with exactly OK and nothing else." +tail -n 20 ~/.codex/log/nowledge-mem-stop-hook.log +``` + +You should see `hook: Stop`, `hook: Stop Completed`, and a successful import entry in the hook log. + ## Update ```bash @@ -115,6 +160,7 @@ git clone https://github.com/nowledge-co/community.git /tmp/nowledge-community-u cp -R /tmp/nowledge-community-update/nowledge-mem-codex-plugin/. \ ~/.codex/plugins/cache/local/nowledge-mem/local/ rm -rf /tmp/nowledge-community-update +python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py ``` Restart Codex after updating. @@ -165,9 +211,12 @@ If you used `nowledge-mem-codex-prompts` before: ## Troubleshooting - **"Command not found: nmem"**: `pip install nmem-cli` or use `uvx --from nmem-cli nmem`. See [Getting Started](https://mem.nowledge.co/docs/installation). -- **"Cannot connect to server"**: Run `nmem status`. For remote setups, check `~/.nowledge-mem/config.json`. See [Remote Access](https://mem.nowledge.co/docs/remote-access). +- **"Cannot connect to server"**: Run `nmem status`. For remote setups, check `nmem config client show`. See [Remote Access](https://mem.nowledge.co/docs/remote-access). - **Skills not appearing**: Restart Codex after installing. Verify both `[features] plugins = true` and `[plugins."nowledge-mem@local"] enabled = true` are in `~/.codex/config.toml`. - **"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. +- **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`. +- **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`. +- **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. - **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. ## Links diff --git a/nowledge-mem-codex-plugin/RELEASING.md b/nowledge-mem-codex-plugin/RELEASING.md new file mode 100644 index 00000000..64308a27 --- /dev/null +++ b/nowledge-mem-codex-plugin/RELEASING.md @@ -0,0 +1,41 @@ +# Releasing Nowledge Mem for Codex + +This package has two surfaces: + +1. the Codex plugin itself under `.codex-plugin/` and `skills/` +2. the optional host-level hook installer under `scripts/install_hooks.py` + +Codex does not currently load hook assets directly from the plugin directory. The installer bridges that gap by copying the bundled runtime hook into `~/.codex/hooks/`, merging `~/.codex/hooks.json`, and enabling `codex_hooks = true` in `~/.codex/config.toml`. + +## Preflight + +Run from the repository root: + +```bash +node nowledge-mem-codex-plugin/scripts/validate-plugin.mjs +``` + +## Release Checklist + +- bump `.codex-plugin/plugin.json` version +- add a top entry to `CHANGELOG.md` +- keep install examples on `cp -r .../. ...` so `.codex-plugin/` is copied +- keep `scripts/install_hooks.py` idempotent +- keep `hooks/nmem-stop-save.py` focused on direct transcript import via `transcript_path` +- re-run `node nowledge-mem-codex-plugin/scripts/validate-plugin.mjs` + +## Manual Smoke Test + +After copying the plugin into `~/.codex/plugins/cache/local/nowledge-mem/local/`: + +```bash +python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py +codex exec -C . "Reply with exactly OK and nothing else." +tail -n 20 ~/.codex/log/nowledge-mem-stop-hook.log +``` + +Expect: + +- `hook: Stop` +- `hook: Stop Completed` +- a successful `nmem t import` result in the hook log diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py new file mode 100644 index 00000000..7cb345df --- /dev/null +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + + +HOME = Path.home() +CODEX_DIR = HOME / ".codex" +LOG_FILE = CODEX_DIR / "log" / "nowledge-mem-stop-hook.log" +STATE_FILE = CODEX_DIR / "nowledge_mem_codex_hook_state.json" + + +def ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def log(message: str) -> None: + ensure_parent(LOG_FILE) + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with LOG_FILE.open("a", encoding="utf-8") as handle: + handle.write(f"[{timestamp}] {message}\n") + + +def load_json(path: Path) -> dict: + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def save_json(path: Path, payload: dict) -> None: + ensure_parent(path) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + + +def configure_nmem_env() -> None: + config_path = HOME / ".nowledge-mem" / "config.json" + config = load_json(config_path) + api_url = config.get("apiUrl") or config.get("api_url") + api_key = config.get("apiKey") or config.get("api_key") + if api_url and "NMEM_API_URL" not in os.environ: + os.environ["NMEM_API_URL"] = str(api_url) + if api_key and "NMEM_API_KEY" not in os.environ: + os.environ["NMEM_API_KEY"] = str(api_key) + + +def import_current_transcript(payload: dict) -> tuple[int, str]: + session_id = payload.get("session_id") or "" + transcript_path = Path(payload.get("transcript_path") or "") + cwd = payload.get("cwd") or "" + hook_event_name = payload.get("hook_event_name") or "unknown" + + log(f"start event={hook_event_name} session={session_id or 'missing'} cwd={cwd or 'missing'}") + if transcript_path: + log(f"transcript={transcript_path}") + + if not session_id: + return 0, "skip: missing session_id" + if not transcript_path or not transcript_path.exists(): + return 0, f"skip: transcript_path missing or unreadable for session={session_id}" + + nmem_bin = shutil.which("nmem") or shutil.which("nmem.cmd") + if not nmem_bin: + return 0, "skip: nmem not found in PATH" + + try: + from nmem_cli.session_import import parse_codex_session_streaming + except Exception as exc: + return 0, f"skip: failed to import nmem_cli parser: {exc}" + + try: + parsed = parse_codex_session_streaming(transcript_path, truncate_large_content=True) + except Exception as exc: + return 0, f"skip: failed to parse codex rollout: {exc}" + + messages = [ + {"role": message.get("role"), "content": message.get("content", "")} + for message in parsed.get("messages", []) + ] + if not messages: + return 0, f"skip: parsed zero messages for session={session_id}" + + thread_id = parsed.get("thread_id") or f"codex-{session_id}" + message_count = len(messages) + content_hash = hashlib.sha256( + json.dumps(messages, ensure_ascii=False, sort_keys=True).encode("utf-8") + ).hexdigest() + + state = load_json(STATE_FILE) + previous = state.get(session_id, {}) + if ( + previous.get("content_hash") == content_hash + and previous.get("message_count") == message_count + ): + return 0, f"skip: unchanged transcript for session={session_id}" + + import_payload = { + "title": parsed.get("title"), + "messages": messages, + } + + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as handle: + json.dump(import_payload, handle, ensure_ascii=False) + temp_path = Path(handle.name) + + try: + command = [nmem_bin, "--json", "t", "import", "-f", str(temp_path), "--id", thread_id, "-s", "codex"] + result = subprocess.run(command, capture_output=True, text=True, env=os.environ.copy()) + finally: + try: + temp_path.unlink(missing_ok=True) + except Exception: + pass + + output = (result.stdout or "") + (result.stderr or "") + if result.returncode == 0: + state[session_id] = { + "content_hash": content_hash, + "message_count": message_count, + "thread_id": thread_id, + "source_file": str(transcript_path), + "updated_at": datetime.now(timezone.utc).isoformat(), + } + save_json(STATE_FILE, state) + + return result.returncode, output.strip() + + +def main() -> int: + raw = sys.stdin.read() + if not raw.strip(): + log("skip: empty hook payload") + return 0 + + try: + payload = json.loads(raw) + except Exception as exc: + log(f"skip: invalid hook payload: {exc}") + return 0 + + configure_nmem_env() + status, output = import_current_transcript(payload) + log(f"nmem_exit={status}") + if output: + for line in output.splitlines(): + log(line) + log("") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/nowledge-mem-codex-plugin/scripts/install_hooks.py b/nowledge-mem-codex-plugin/scripts/install_hooks.py new file mode 100644 index 00000000..98c8140c --- /dev/null +++ b/nowledge-mem-codex-plugin/scripts/install_hooks.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import re +import shutil +import stat +from pathlib import Path + + +PLUGIN_ROOT = Path(__file__).resolve().parent.parent +HOME = Path.home() +CODEX_DIR = HOME / ".codex" +HOOKS_DIR = CODEX_DIR / "hooks" +GLOBAL_HOOKS_FILE = CODEX_DIR / "hooks.json" +CONFIG_FILE = CODEX_DIR / "config.toml" +SOURCE_HOOK = PLUGIN_ROOT / "hooks" / "nmem-stop-save.py" +INSTALLED_HOOK = HOOKS_DIR / "nowledge-mem-stop-save.py" + + +def load_json(path: Path) -> dict: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def save_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def install_runtime_hook() -> None: + HOOKS_DIR.mkdir(parents=True, exist_ok=True) + shutil.copy2(SOURCE_HOOK, INSTALLED_HOOK) + mode = INSTALLED_HOOK.stat().st_mode + INSTALLED_HOOK.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def merge_hooks_json() -> None: + if GLOBAL_HOOKS_FILE.exists(): + hooks_doc = load_json(GLOBAL_HOOKS_FILE) + backup = GLOBAL_HOOKS_FILE.with_suffix(f".json.bak") + if not backup.exists(): + shutil.copy2(GLOBAL_HOOKS_FILE, backup) + else: + hooks_doc = {} + + hooks = hooks_doc.setdefault("hooks", {}) + stop_hooks = hooks.setdefault("Stop", []) + + desired = { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": str(INSTALLED_HOOK), + } + ], + } + + replaced = False + for index, entry in enumerate(stop_hooks): + for hook in entry.get("hooks", []): + if hook.get("command") == str(INSTALLED_HOOK): + stop_hooks[index] = desired + replaced = True + break + if replaced: + break + + if not replaced: + stop_hooks.append(desired) + + save_json(GLOBAL_HOOKS_FILE, hooks_doc) + + +def ensure_codex_hooks_enabled() -> None: + CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + text = CONFIG_FILE.read_text(encoding="utf-8") if CONFIG_FILE.exists() else "" + + if re.search(r"(?m)^codex_hooks\s*=\s*(true|false)\s*$", text): + updated = re.sub(r"(?m)^codex_hooks\s*=\s*(true|false)\s*$", "codex_hooks = true", text, count=1) + elif re.search(r"(?m)^\[features\]\s*$", text): + updated = re.sub(r"(?m)^\[features\]\s*$", "[features]\ncodex_hooks = true", text, count=1) + else: + suffix = "" if text.endswith("\n") or text == "" else "\n" + updated = f"{text}{suffix}\n[features]\ncodex_hooks = true\n" + + CONFIG_FILE.write_text(updated, encoding="utf-8") + + +def main() -> int: + install_runtime_hook() + merge_hooks_json() + ensure_codex_hooks_enabled() + + print("Installed Nowledge Mem Codex Stop hook") + print(f"- runtime hook: {INSTALLED_HOOK}") + print(f"- hooks config: {GLOBAL_HOOKS_FILE}") + print(f"- feature flag ensured in: {CONFIG_FILE}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs b/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs new file mode 100644 index 00000000..3eb93f44 --- /dev/null +++ b/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginRoot = path.resolve(scriptDir, ".."); +const repoRoot = path.resolve(pluginRoot, ".."); + +const fail = (message) => { + console.error(`FAIL: ${message}`); + process.exitCode = 1; +}; + +const ok = (message) => { + console.log(`OK: ${message}`); +}; + +const requireFile = (relativePath) => { + const fullPath = path.join(pluginRoot, relativePath); + if (!existsSync(fullPath)) { + fail(`missing ${relativePath}`); + return null; + } + const stats = statSync(fullPath); + if (!stats.isFile() || stats.size === 0) { + fail(`empty or non-file ${relativePath}`); + return null; + } + ok(relativePath); + return fullPath; +}; + +for (const file of [ + ".codex-plugin/plugin.json", + "README.md", + "CHANGELOG.md", + "RELEASING.md", + "hooks/nmem-stop-save.py", + "scripts/install_hooks.py", + "scripts/validate-plugin.mjs", + "skills/working-memory/SKILL.md", + "skills/search-memory/SKILL.md", + "skills/save-thread/SKILL.md", + "skills/distill-memory/SKILL.md", + "skills/status/SKILL.md", +]) { + requireFile(file); +} + +const manifest = JSON.parse( + readFileSync(path.join(pluginRoot, ".codex-plugin/plugin.json"), "utf8"), +); +if (manifest.name !== "nowledge-mem") { + fail(`unexpected plugin name: ${manifest.name}`); +} else { + ok("plugin manifest name"); +} +if (manifest.version !== "0.1.3") { + fail(`expected version 0.1.3, got ${manifest.version}`); +} else { + ok("plugin manifest version"); +} + +const changelog = readFileSync(path.join(pluginRoot, "CHANGELOG.md"), "utf8"); +if (!changelog.includes("## [0.1.3]")) { + fail("CHANGELOG must contain a 0.1.3 entry"); +} else { + ok("CHANGELOG version entry"); +} + +const readme = readFileSync(path.join(pluginRoot, "README.md"), "utf8"); +for (const phrase of [ + "scripts/install_hooks.py", + "~/.codex/hooks.json", + "codex_hooks = true", + "host-level", +]) { + if (!readme.includes(phrase)) { + fail(`README must mention ${phrase}`); + } else { + ok(`README mentions ${phrase}`); + } +} + +const integrationsDoc = JSON.parse( + readFileSync(path.join(repoRoot, "integrations.json"), "utf8"), +); +const codexEntry = integrationsDoc.integrations?.find((entry) => entry.id === "codex-cli"); +if (!codexEntry) { + fail("integrations.json missing codex-cli entry"); +} else { + ok("integrations.json codex-cli entry"); + if (codexEntry.version !== "0.1.3") { + fail(`integrations.json codex-cli version must be 0.1.3, got ${codexEntry.version}`); + } else { + ok("integrations.json codex-cli version"); + } + if (!codexEntry.install?.command?.includes("nowledge-mem-codex-plugin/.")) { + fail("integrations.json install.command must copy nowledge-mem-codex-plugin/. so hidden files are preserved"); + } else { + ok("integrations.json install.command copies hidden files"); + } + if (!codexEntry.install?.updateCommand?.includes("nowledge-mem-codex-plugin/.")) { + fail("integrations.json install.updateCommand must copy nowledge-mem-codex-plugin/. so hidden files are preserved"); + } else { + ok("integrations.json updateCommand copies hidden files"); + } +} + +if (process.exitCode) { + process.exit(process.exitCode); +} + +console.log("Codex plugin validation passed."); diff --git a/nowledge-mem-codex-prompts/README.md b/nowledge-mem-codex-prompts/README.md index 5d8e18cf..97414435 100644 --- a/nowledge-mem-codex-prompts/README.md +++ b/nowledge-mem-codex-prompts/README.md @@ -1,6 +1,6 @@ # Nowledge Mem for Codex CLI -> **Deprecated**: This custom prompts package has been superseded by the **[Codex Plugin](../nowledge-mem-codex-plugin/)**. The plugin ships the same Codex-facing memory behaviors as packaged skills with a cleaner install/update path. See the [migration guide](https://mem.nowledge.co/docs/integrations/codex-cli#migrating-from-custom-prompts) for details. +> **Deprecated**: This custom prompts package has been superseded by the **[Codex Plugin](../nowledge-mem-codex-plugin/)**. The plugin ships the same Codex-facing memory behaviors as packaged skills with a cleaner install/update path, plus an optional host-level hook installer for automatic transcript capture after you run it. See the [migration guide](https://mem.nowledge.co/docs/integrations/codex-cli#migrating-from-custom-prompts) for details. > Memory-aware custom prompts for Codex CLI, with an optional project `AGENTS.md` companion for stronger default behavior. From 318d2bd91da87c3ad72a670a54da80196e5b3181 Mon Sep 17 00:00:00 2001 From: Hanson Mei Date: Fri, 10 Apr 2026 14:22:00 +0800 Subject: [PATCH 2/8] fix(codex): improve local thread titles and refresh tooling --- nowledge-mem-codex-plugin/RELEASING.md | 2 + .../hooks/nmem-stop-save.py | 109 +++++++- .../scripts/install_hooks.py | 56 +++- .../scripts/refresh_thread_titles.py | 131 ++++++++++ .../scripts/validate-plugin.mjs | 129 ++++++---- .../tests/test_codex_plugin.py | 240 ++++++++++++++++++ 6 files changed, 603 insertions(+), 64 deletions(-) create mode 100644 nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py create mode 100644 nowledge-mem-codex-plugin/tests/test_codex_plugin.py diff --git a/nowledge-mem-codex-plugin/RELEASING.md b/nowledge-mem-codex-plugin/RELEASING.md index 64308a27..39f1e862 100644 --- a/nowledge-mem-codex-plugin/RELEASING.md +++ b/nowledge-mem-codex-plugin/RELEASING.md @@ -19,6 +19,8 @@ node nowledge-mem-codex-plugin/scripts/validate-plugin.mjs - bump `.codex-plugin/plugin.json` version - add a top entry to `CHANGELOG.md` +- bump `integrations.json` -> `integrations[id="codex-cli"].version` +- update expected version checks in `scripts/validate-plugin.mjs` - keep install examples on `cp -r .../. ...` so `.codex-plugin/` is copied - keep `scripts/install_hooks.py` idempotent - keep `hooks/nmem-stop-save.py` focused on direct transcript import via `transcript_path` diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index 7cb345df..029dec8b 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -4,6 +4,7 @@ import hashlib import json import os +import re import shutil import subprocess import sys @@ -16,6 +17,7 @@ CODEX_DIR = HOME / ".codex" LOG_FILE = CODEX_DIR / "log" / "nowledge-mem-stop-hook.log" STATE_FILE = CODEX_DIR / "nowledge_mem_codex_hook_state.json" +IMPORT_TIMEOUT_SECONDS = 30 def ensure_parent(path: Path) -> None: @@ -54,20 +56,105 @@ def configure_nmem_env() -> None: os.environ["NMEM_API_KEY"] = str(api_key) +def shorten_title(text: str, limit: int = 60) -> str: + normalized = text.replace("\n", " ").strip() + if len(normalized) <= limit: + return normalized + return normalized[:limit].rstrip() + "..." + + +def is_agents_preamble(text: str) -> bool: + stripped = text.strip() + return ( + stripped.startswith("# AGENTS.md instructions for ") + or ( + stripped.startswith("# AGENTS.md") + and ( + "" in stripped + or "\n## Purpose" in stripped + or "\n## nmem" in stripped + ) + ) + ) + + +def is_environment_context(text: str) -> bool: + stripped = text.strip() + return stripped.startswith("") and stripped.endswith("") + + +def extract_files_request(text: str) -> str | None: + marker = "## My request for Codex" + if marker not in text: + return None + tail = text.split(marker, 1)[1] + tail = re.sub(r"^[::\s]+", "", tail) + return tail.strip() or None + + +def summarize_user_message(text: str) -> str: + normalized = text.replace("\n", " ").strip() + normalized = re.sub( + r"^codex://threads/[0-9a-fA-F-]+[\s,,::-]*", + "", + normalized, + ) + normalized = re.sub( + r"^(请帮我|请你|请|麻烦你|麻烦|帮我看看|你看看|帮我看下|帮我看一下|帮我|看下|看一下)\s*", + "", + normalized, + ) + normalized = re.sub( + r"^(can you|could you|please|help me|take a look at|look into)\s+", + "", + normalized, + flags=re.IGNORECASE, + ) + normalized = normalized.replace("是不是", "是否").replace("能不能", "是否能") + normalized = normalized.strip(" \t\r\n??!!。") + normalized = re.sub(r"\s+", " ", normalized) + return shorten_title(normalized or text) + + +def derive_thread_title(parsed: dict, cwd: str) -> str: + for message in parsed.get("messages", []): + if message.get("role") != "user": + continue + content = (message.get("content") or "").strip() + if not content or is_agents_preamble(content) or is_environment_context(content): + continue + files_request = extract_files_request(content) + if files_request: + return summarize_user_message(files_request) + return summarize_user_message(content) + + workspace = cwd or parsed.get("workspace") or "" + if workspace: + return f"Codex: {Path(workspace).name}" + + parsed_title = (parsed.get("title") or "").strip() + if parsed_title and not is_agents_preamble(parsed_title): + return shorten_title(parsed_title) + + return "Codex Session" + + def import_current_transcript(payload: dict) -> tuple[int, str]: session_id = payload.get("session_id") or "" - transcript_path = Path(payload.get("transcript_path") or "") + raw_transcript_path = payload.get("transcript_path") or "" cwd = payload.get("cwd") or "" hook_event_name = payload.get("hook_event_name") or "unknown" log(f"start event={hook_event_name} session={session_id or 'missing'} cwd={cwd or 'missing'}") - if transcript_path: - log(f"transcript={transcript_path}") - if not session_id: return 0, "skip: missing session_id" - if not transcript_path or not transcript_path.exists(): + if not raw_transcript_path: + return 0, f"skip: transcript_path missing or unreadable for session={session_id}" + + transcript_path = Path(raw_transcript_path) + if not (transcript_path.exists() and transcript_path.is_file()): return 0, f"skip: transcript_path missing or unreadable for session={session_id}" + log(f"transcript={transcript_path}") nmem_bin = shutil.which("nmem") or shutil.which("nmem.cmd") if not nmem_bin: @@ -105,7 +192,7 @@ def import_current_transcript(payload: dict) -> tuple[int, str]: return 0, f"skip: unchanged transcript for session={session_id}" import_payload = { - "title": parsed.get("title"), + "title": derive_thread_title(parsed, cwd), "messages": messages, } @@ -115,7 +202,15 @@ def import_current_transcript(payload: dict) -> tuple[int, str]: try: command = [nmem_bin, "--json", "t", "import", "-f", str(temp_path), "--id", thread_id, "-s", "codex"] - result = subprocess.run(command, capture_output=True, text=True, env=os.environ.copy()) + result = subprocess.run( + command, + capture_output=True, + text=True, + env=os.environ.copy(), + timeout=IMPORT_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired: + return 0, f"skip: nmem import timed out after {IMPORT_TIMEOUT_SECONDS}s for thread={thread_id}" finally: try: temp_path.unlink(missing_ok=True) diff --git a/nowledge-mem-codex-plugin/scripts/install_hooks.py b/nowledge-mem-codex-plugin/scripts/install_hooks.py index 98c8140c..5c7960c4 100644 --- a/nowledge-mem-codex-plugin/scripts/install_hooks.py +++ b/nowledge-mem-codex-plugin/scripts/install_hooks.py @@ -2,9 +2,11 @@ from __future__ import annotations import json -import re import shutil import stat +import sys +import tomllib +from datetime import datetime, timezone from pathlib import Path @@ -21,7 +23,13 @@ def load_json(path: Path) -> dict: if not path.exists(): return {} - return json.loads(path.read_text(encoding="utf-8")) + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + backup = path.with_name(f"{path.name}.{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}.bak") + shutil.move(path, backup) + print(f"warning: moved malformed JSON to {backup}", file=sys.stderr) + return {} def save_json(path: Path, payload: dict) -> None: @@ -39,7 +47,7 @@ def install_runtime_hook() -> None: def merge_hooks_json() -> None: if GLOBAL_HOOKS_FILE.exists(): hooks_doc = load_json(GLOBAL_HOOKS_FILE) - backup = GLOBAL_HOOKS_FILE.with_suffix(f".json.bak") + backup = GLOBAL_HOOKS_FILE.with_suffix(".json.bak") if not backup.exists(): shutil.copy2(GLOBAL_HOOKS_FILE, backup) else: @@ -77,15 +85,45 @@ def merge_hooks_json() -> None: def ensure_codex_hooks_enabled() -> None: CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) text = CONFIG_FILE.read_text(encoding="utf-8") if CONFIG_FILE.exists() else "" + if text.strip(): + tomllib.loads(text) + + lines = text.splitlines() + section_header = "[features]" + target_key = "codex_hooks" + features_start = None + features_end = len(lines) + + for index, line in enumerate(lines): + if line.strip() == section_header: + features_start = index + break - if re.search(r"(?m)^codex_hooks\s*=\s*(true|false)\s*$", text): - updated = re.sub(r"(?m)^codex_hooks\s*=\s*(true|false)\s*$", "codex_hooks = true", text, count=1) - elif re.search(r"(?m)^\[features\]\s*$", text): - updated = re.sub(r"(?m)^\[features\]\s*$", "[features]\ncodex_hooks = true", text, count=1) + if features_start is None: + if lines and lines[-1] != "": + lines.append("") + lines.extend([section_header, "codex_hooks = true"]) else: - suffix = "" if text.endswith("\n") or text == "" else "\n" - updated = f"{text}{suffix}\n[features]\ncodex_hooks = true\n" + for index in range(features_start + 1, len(lines)): + stripped = lines[index].strip() + if stripped.startswith("[") and stripped.endswith("]"): + features_end = index + break + + replaced = False + for index in range(features_start + 1, features_end): + stripped = lines[index].strip() + if stripped.startswith(f"{target_key} "): + lines[index] = "codex_hooks = true" + replaced = True + break + + if not replaced: + lines.insert(features_end, "codex_hooks = true") + updated = "\n".join(lines) + if updated and not updated.endswith("\n"): + updated += "\n" CONFIG_FILE.write_text(updated, encoding="utf-8") diff --git a/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py b/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py new file mode 100644 index 00000000..150f1005 --- /dev/null +++ b/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import importlib.util +import json +from pathlib import Path +from urllib.parse import quote + +from nmem_cli import cli +from nmem_cli.session_import import parse_codex_session_streaming + + +SESSIONS_ROOT = Path.home() / ".codex" / "sessions" +AGENTS_PREFIX = "# AGENTS.md instructions for " +GET_TIMEOUT_SECONDS = 10.0 +IMPORT_TIMEOUT_SECONDS = 120.0 + + +def load_hook_module(): + hook_path = Path(__file__).resolve().parent.parent / "hooks" / "nmem-stop-save.py" + spec = importlib.util.spec_from_file_location("nmem_stop_save", hook_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def iter_codex_rollouts() -> list[Path]: + return sorted(SESSIONS_ROOT.rglob("rollout-*.jsonl")) + + +def get_thread(thread_id: str) -> dict | None: + return cli.api_get_optional( + f"/threads/{quote(thread_id, safe='')}", + timeout=GET_TIMEOUT_SECONDS, + ) + + +def refresh_thread(thread_id: str, title: str, messages: list[dict], dry_run: bool) -> None: + if dry_run: + print(f"DRY RUN refresh {thread_id} -> {title}", flush=True) + return + + cli.api_delete(f"/threads/{quote(thread_id, safe='')}") + cli.api_post( + "/threads/import", + { + "thread_id": thread_id, + "title": title, + "source": "codex", + "messages": [{"role": m["role"], "content": m["content"]} for m in messages], + }, + timeout=IMPORT_TIMEOUT_SECONDS, + ) + print(f"REFRESHED {thread_id} -> {title}", flush=True) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--all", action="store_true") + parser.add_argument("--thread-id") + args = parser.parse_args() + + hook_module = load_hook_module() + checked = 0 + refreshed = 0 + errors = 0 + + for rollout_path in iter_codex_rollouts(): + try: + parsed = parse_codex_session_streaming(rollout_path, truncate_large_content=True) + thread_id = parsed["thread_id"] + except Exception as exc: + errors += 1 + print(f"ERROR parse {rollout_path}: {exc}", flush=True) + continue + + if args.thread_id and thread_id != args.thread_id: + continue + + try: + thread_payload = get_thread(thread_id) + except SystemExit as exc: + errors += 1 + print(f"ERROR fetch {thread_id}: exited {exc.code}", flush=True) + continue + + if not thread_payload: + continue + + checked += 1 + if checked % 25 == 0: + print(f"PROGRESS checked={checked} refreshed={refreshed} errors={errors}", flush=True) + + thread = thread_payload.get("thread", {}) + current_title = (thread.get("title") or "").strip() + desired_title = hook_module.derive_thread_title( + parsed, + parsed.get("workspace") or parsed.get("metadata", {}).get("cwd") or "", + ) + + should_refresh = args.all or current_title.startswith(AGENTS_PREFIX) + if not should_refresh or current_title == desired_title: + continue + + try: + refresh_thread(thread_id, desired_title, parsed.get("messages", []), args.dry_run) + refreshed += 1 + except SystemExit as exc: + errors += 1 + print(f"ERROR refresh {thread_id}: exited {exc.code}", flush=True) + + print( + json.dumps( + { + "checked": checked, + "refreshed": refreshed, + "errors": errors, + "dry_run": args.dry_run, + }, + ensure_ascii=False, + ), + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs b/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs index 3eb93f44..5c1949ae 100644 --- a/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs +++ b/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs @@ -18,6 +18,32 @@ const ok = (message) => { console.log(`OK: ${message}`); }; +const readTextIfPresent = (fullPath, label) => { + if (!existsSync(fullPath)) { + fail(`missing ${label}`); + return null; + } + try { + return readFileSync(fullPath, "utf8"); + } catch (error) { + fail(`failed to read ${label}: ${error.message}`); + return null; + } +}; + +const parseJsonIfPresent = (fullPath, label) => { + const text = readTextIfPresent(fullPath, label); + if (text === null) { + return null; + } + try { + return JSON.parse(text); + } catch (error) { + fail(`failed to parse ${label}: ${error.message}`); + return null; + } +}; + const requireFile = (relativePath) => { const fullPath = path.join(pluginRoot, relativePath); if (!existsSync(fullPath)) { @@ -50,63 +76,70 @@ for (const file of [ requireFile(file); } -const manifest = JSON.parse( - readFileSync(path.join(pluginRoot, ".codex-plugin/plugin.json"), "utf8"), +const manifest = parseJsonIfPresent( + path.join(pluginRoot, ".codex-plugin/plugin.json"), + ".codex-plugin/plugin.json", ); -if (manifest.name !== "nowledge-mem") { - fail(`unexpected plugin name: ${manifest.name}`); -} else { - ok("plugin manifest name"); -} -if (manifest.version !== "0.1.3") { - fail(`expected version 0.1.3, got ${manifest.version}`); -} else { - ok("plugin manifest version"); -} - -const changelog = readFileSync(path.join(pluginRoot, "CHANGELOG.md"), "utf8"); -if (!changelog.includes("## [0.1.3]")) { - fail("CHANGELOG must contain a 0.1.3 entry"); -} else { - ok("CHANGELOG version entry"); -} - -const readme = readFileSync(path.join(pluginRoot, "README.md"), "utf8"); -for (const phrase of [ - "scripts/install_hooks.py", - "~/.codex/hooks.json", - "codex_hooks = true", - "host-level", -]) { - if (!readme.includes(phrase)) { - fail(`README must mention ${phrase}`); +if (manifest) { + if (manifest.name !== "nowledge-mem") { + fail(`unexpected plugin name: ${manifest.name}`); + } else { + ok("plugin manifest name"); + } + if (manifest.version !== "0.1.3") { + fail(`expected version 0.1.3, got ${manifest.version}`); } else { - ok(`README mentions ${phrase}`); + ok("plugin manifest version"); } } -const integrationsDoc = JSON.parse( - readFileSync(path.join(repoRoot, "integrations.json"), "utf8"), -); -const codexEntry = integrationsDoc.integrations?.find((entry) => entry.id === "codex-cli"); -if (!codexEntry) { - fail("integrations.json missing codex-cli entry"); -} else { - ok("integrations.json codex-cli entry"); - if (codexEntry.version !== "0.1.3") { - fail(`integrations.json codex-cli version must be 0.1.3, got ${codexEntry.version}`); +const changelog = readTextIfPresent(path.join(pluginRoot, "CHANGELOG.md"), "CHANGELOG.md"); +if (changelog !== null) { + if (!changelog.includes("## [0.1.3]")) { + fail("CHANGELOG must contain a 0.1.3 entry"); } else { - ok("integrations.json codex-cli version"); + ok("CHANGELOG version entry"); } - if (!codexEntry.install?.command?.includes("nowledge-mem-codex-plugin/.")) { - fail("integrations.json install.command must copy nowledge-mem-codex-plugin/. so hidden files are preserved"); - } else { - ok("integrations.json install.command copies hidden files"); +} + +const readme = readTextIfPresent(path.join(pluginRoot, "README.md"), "README.md"); +if (readme !== null) { + for (const phrase of [ + "scripts/install_hooks.py", + "~/.codex/hooks.json", + "codex_hooks = true", + "host-level", + ]) { + if (!readme.includes(phrase)) { + fail(`README must mention ${phrase}`); + } else { + ok(`README mentions ${phrase}`); + } } - if (!codexEntry.install?.updateCommand?.includes("nowledge-mem-codex-plugin/.")) { - fail("integrations.json install.updateCommand must copy nowledge-mem-codex-plugin/. so hidden files are preserved"); +} + +const integrationsDoc = parseJsonIfPresent(path.join(repoRoot, "integrations.json"), "integrations.json"); +if (integrationsDoc) { + const codexEntry = integrationsDoc.integrations?.find((entry) => entry.id === "codex-cli"); + if (!codexEntry) { + fail("integrations.json missing codex-cli entry"); } else { - ok("integrations.json updateCommand copies hidden files"); + ok("integrations.json codex-cli entry"); + if (codexEntry.version !== "0.1.3") { + fail(`integrations.json codex-cli version must be 0.1.3, got ${codexEntry.version}`); + } else { + ok("integrations.json codex-cli version"); + } + if (!codexEntry.install?.command?.includes("nowledge-mem-codex-plugin/.")) { + fail("integrations.json install.command must copy nowledge-mem-codex-plugin/. so hidden files are preserved"); + } else { + ok("integrations.json install.command copies hidden files"); + } + if (!codexEntry.install?.updateCommand?.includes("nowledge-mem-codex-plugin/.")) { + fail("integrations.json install.updateCommand must copy nowledge-mem-codex-plugin/. so hidden files are preserved"); + } else { + ok("integrations.json updateCommand copies hidden files"); + } } } diff --git a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py new file mode 100644 index 00000000..b4edd3ad --- /dev/null +++ b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py @@ -0,0 +1,240 @@ +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +PLUGIN_ROOT = Path(__file__).resolve().parent.parent +HOOK_MODULE_PATH = PLUGIN_ROOT / "hooks" / "nmem-stop-save.py" +INSTALL_MODULE_PATH = PLUGIN_ROOT / "scripts" / "install_hooks.py" + + +def load_module(module_name: str, module_path: Path): + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class HookTests(unittest.TestCase): + def setUp(self): + self.module = load_module("nmem_stop_save", HOOK_MODULE_PATH) + self.temp_dir = tempfile.TemporaryDirectory() + temp_path = Path(self.temp_dir.name) + self.module.LOG_FILE = temp_path / "nmem-stop-hook.log" + self.module.STATE_FILE = temp_path / "hook-state.json" + + def tearDown(self): + self.temp_dir.cleanup() + + def test_missing_transcript_path_skips_before_path_construction(self): + status, output = self.module.import_current_transcript( + { + "session_id": "session-1", + "cwd": "/tmp/project", + "hook_event_name": "Stop", + } + ) + + self.assertEqual(status, 0) + self.assertIn("transcript_path missing or unreadable", output) + + def test_directory_transcript_path_is_rejected(self): + temp_path = Path(self.temp_dir.name) + + status, output = self.module.import_current_transcript( + { + "session_id": "session-2", + "cwd": "/tmp/project", + "hook_event_name": "Stop", + "transcript_path": str(temp_path), + } + ) + + self.assertEqual(status, 0) + self.assertIn("transcript_path missing or unreadable", output) + + def test_timeout_from_nmem_is_reported_and_state_not_written(self): + transcript_path = Path(self.temp_dir.name) / "rollout.jsonl" + transcript_path.write_text("placeholder", encoding="utf-8") + + parsed = { + "thread_id": "codex-timeout-thread", + "title": "Timeout thread", + "messages": [{"role": "user", "content": "hi"}], + } + + with mock.patch.object(self.module.shutil, "which", return_value="/usr/bin/nmem"), \ + mock.patch.dict("sys.modules", {"nmem_cli.session_import": mock.Mock(parse_codex_session_streaming=mock.Mock(return_value=parsed))}), \ + mock.patch.object( + self.module.subprocess, + "run", + side_effect=subprocess.TimeoutExpired(["nmem"], timeout=5), + ): + status, output = self.module.import_current_transcript( + { + "session_id": "session-3", + "cwd": "/tmp/project", + "hook_event_name": "Stop", + "transcript_path": str(transcript_path), + } + ) + + self.assertEqual(status, 0) + self.assertIn("timed out", output) + self.assertFalse(self.module.STATE_FILE.exists()) + + def test_title_skips_agents_preamble_and_uses_first_real_user_message(self): + parsed = { + "title": "# AGENTS.md instructions for /Users/hansonmei/Projects/nowledge-community", + "messages": [ + {"role": "developer", "content": "..."}, + { + "role": "user", + "content": "# AGENTS.md instructions for /Users/hansonmei/Projects/nowledge-community\n\n...", + }, + { + "role": "user", + "content": "codex://threads/019d7145-eb73-7840-84e5-bef5c0f19261,你看看这个对话里的修改是不是已经落盘到本目录了", + }, + ], + } + + title = self.module.derive_thread_title(parsed, "/Users/hansonmei/Projects/nowledge-community") + + self.assertEqual( + title, + "这个对话里的修改是否已经落盘到本目录了", + ) + + def test_title_falls_back_to_workspace_name_when_only_agents_preamble_exists(self): + parsed = { + "title": "# AGENTS.md instructions for /Users/hansonmei/Projects/nowledge-community", + "messages": [ + { + "role": "user", + "content": "# AGENTS.md instructions for /Users/hansonmei/Projects/nowledge-community\n\n...", + } + ], + } + + title = self.module.derive_thread_title(parsed, "/Users/hansonmei/Projects/nowledge-community") + + self.assertEqual(title, "Codex: nowledge-community") + + def test_title_skips_environment_context_blocks(self): + parsed = { + "title": "# AGENTS.md instructions for /Users/hansonmei", + "messages": [ + {"role": "user", "content": "# AGENTS.md instructions for /Users/hansonmei\n\n..."}, + { + "role": "user", + "content": "\n /Users/hansonmei\n", + }, + {"role": "user", "content": "更新我的openclaw"}, + ], + } + + title = self.module.derive_thread_title(parsed, "/Users/hansonmei") + + self.assertEqual(title, "更新我的openclaw") + + def test_title_skips_generic_agents_document_blocks(self): + parsed = { + "title": "# AGENTS.md", + "messages": [ + { + "role": "user", + "content": ( + "# AGENTS.md\n\n" + "## Purpose\n\n" + "only applicable in codex app\n\n" + "\n" + "Use nmem.\n" + "" + ), + }, + {"role": "user", "content": "codex app 支持hook吗"}, + ], + } + + title = self.module.derive_thread_title(parsed, "/Users/hansonmei") + + self.assertEqual(title, "codex app 支持hook吗") + + def test_title_extracts_request_from_files_preamble(self): + parsed = { + "title": "# AGENTS.md instructions for /Users/hansonmei", + "messages": [ + {"role": "user", "content": "# AGENTS.md instructions for /Users/hansonmei\n\n..."}, + { + "role": "user", + "content": ( + "# Files mentioned by the user:\n\n" + "## a.png: /tmp/a.png\n\n" + "## My request for Codex:\n" + "这三张截图都要提取文字,必要时可以先切分再提取" + ), + }, + ], + } + + title = self.module.derive_thread_title(parsed, "/Users/hansonmei") + + self.assertEqual(title, "这三张截图都要提取文字,必要时可以先切分再提取") + + +class InstallHookTests(unittest.TestCase): + def setUp(self): + self.module = load_module("install_hooks", INSTALL_MODULE_PATH) + self.temp_dir = tempfile.TemporaryDirectory() + temp_path = Path(self.temp_dir.name) + self.module.CODEX_DIR = temp_path / ".codex" + self.module.HOOKS_DIR = self.module.CODEX_DIR / "hooks" + self.module.GLOBAL_HOOKS_FILE = self.module.CODEX_DIR / "hooks.json" + self.module.CONFIG_FILE = self.module.CODEX_DIR / "config.toml" + self.module.INSTALLED_HOOK = self.module.HOOKS_DIR / "nowledge-mem-stop-save.py" + + def tearDown(self): + self.temp_dir.cleanup() + + def test_load_json_recovers_from_malformed_json(self): + self.module.GLOBAL_HOOKS_FILE.parent.mkdir(parents=True, exist_ok=True) + self.module.GLOBAL_HOOKS_FILE.write_text("{not-json", encoding="utf-8") + + payload = self.module.load_json(self.module.GLOBAL_HOOKS_FILE) + + self.assertEqual(payload, {}) + backups = list(self.module.GLOBAL_HOOKS_FILE.parent.glob("hooks.json.*.bak")) + self.assertEqual(len(backups), 1) + + def test_ensure_codex_hooks_enabled_only_changes_features_section(self): + self.module.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + self.module.CONFIG_FILE.write_text( + "\n".join( + [ + '[projects."/tmp/demo"]', + "codex_hooks = false", + "", + "[features]", + "apps = true", + "codex_hooks = false", + "", + ] + ), + encoding="utf-8", + ) + + self.module.ensure_codex_hooks_enabled() + updated = self.module.CONFIG_FILE.read_text(encoding="utf-8") + + self.assertIn('[projects."/tmp/demo"]\ncodex_hooks = false', updated) + self.assertIn("[features]\napps = true\ncodex_hooks = true", updated) + + +if __name__ == "__main__": + unittest.main() From 2470b016aa23112457ac6589de4173e35b35bfc7 Mon Sep 17 00:00:00 2001 From: Hanson Mei Date: Fri, 10 Apr 2026 21:05:50 +0800 Subject: [PATCH 3/8] test(codex): cover indented codex_hooks config --- .../tests/test_codex_plugin.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py index b4edd3ad..3e7369cf 100644 --- a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py +++ b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py @@ -235,6 +235,27 @@ def test_ensure_codex_hooks_enabled_only_changes_features_section(self): self.assertIn('[projects."/tmp/demo"]\ncodex_hooks = false', updated) self.assertIn("[features]\napps = true\ncodex_hooks = true", updated) + def test_ensure_codex_hooks_enabled_replaces_indented_key_without_duplication(self): + self.module.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + self.module.CONFIG_FILE.write_text( + "\n".join( + [ + "[features]", + " codex_hooks = false", + "apps = true", + "", + ] + ), + encoding="utf-8", + ) + + self.module.ensure_codex_hooks_enabled() + updated = self.module.CONFIG_FILE.read_text(encoding="utf-8") + + self.assertEqual(updated.count("codex_hooks = true"), 1) + self.assertNotIn("codex_hooks = false", updated) + self.assertIn("[features]\ncodex_hooks = true\napps = true\n", updated) + if __name__ == "__main__": unittest.main() From 6d652f31e42f1a6d1107552c8e1009dc3be0f741 Mon Sep 17 00:00:00 2001 From: Hanson Mei Date: Sat, 11 Apr 2026 12:27:33 +0800 Subject: [PATCH 4/8] fix(codex): prevent duplicate thread imports and sync setup metadata --- docs/PLUGIN_DEVELOPMENT_GUIDE.md | 5 +- .../hooks/nmem-stop-save.py | 36 ++++++++-- .../scripts/validate-plugin.mjs | 15 ++++ .../tests/test_codex_plugin.py | 68 +++++++++++++++++++ .../skills/check-integration/SKILL.md | 2 +- 5 files changed, 119 insertions(+), 7 deletions(-) diff --git a/docs/PLUGIN_DEVELOPMENT_GUIDE.md b/docs/PLUGIN_DEVELOPMENT_GUIDE.md index ba41c17e..66fcb738 100644 --- a/docs/PLUGIN_DEVELOPMENT_GUIDE.md +++ b/docs/PLUGIN_DEVELOPMENT_GUIDE.md @@ -119,7 +119,7 @@ All behavioral heuristics (when to search, when to save, when to read Working Me ### Skill naming -Skill names use kebab-case and are consistent across all plugins: +Skill names use kebab-case. Prefer the shared names below for new integrations, but preserve published compatibility exceptions: | Skill | Purpose | |-------|---------| @@ -131,6 +131,9 @@ Skill names use kebab-case and are consistent across all plugins: | `check-integration` | Detect agent, verify setup, guide plugin installation | | `status` | Connection and configuration diagnostics | +Published exception: +- Codex already shipped `working-memory`; keep that name for Codex unless and until a compatible alias is introduced. + ### Autonomous save is required Every integration's distill/save guidance MUST include proactive save encouragement: diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index 029dec8b..650ab9c5 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -56,6 +56,18 @@ def configure_nmem_env() -> None: os.environ["NMEM_API_KEY"] = str(api_key) +def resolve_nmem_command() -> list[str] | None: + nmem_bin = shutil.which("nmem") or shutil.which("nmem.cmd") + if nmem_bin: + return [nmem_bin] + + uvx_bin = shutil.which("uvx") or shutil.which("uvx.cmd") + if uvx_bin: + return [uvx_bin, "--from", "nmem-cli", "nmem"] + + return None + + def shorten_title(text: str, limit: int = 60) -> str: normalized = text.replace("\n", " ").strip() if len(normalized) <= limit: @@ -156,9 +168,9 @@ def import_current_transcript(payload: dict) -> tuple[int, str]: return 0, f"skip: transcript_path missing or unreadable for session={session_id}" log(f"transcript={transcript_path}") - nmem_bin = shutil.which("nmem") or shutil.which("nmem.cmd") - if not nmem_bin: - return 0, "skip: nmem not found in PATH" + nmem_command = resolve_nmem_command() + if not nmem_command: + return 0, "skip: neither nmem nor uvx was found in PATH" try: from nmem_cli.session_import import parse_codex_session_streaming @@ -191,9 +203,23 @@ def import_current_transcript(payload: dict) -> tuple[int, str]: ): return 0, f"skip: unchanged transcript for session={session_id}" + import_messages = messages + previous_count = previous.get("message_count") + if ( + isinstance(previous_count, int) + and previous_count > 0 + and previous_count < message_count + and previous.get("thread_id") == thread_id + and previous.get("source_file") == str(transcript_path) + ): + import_messages = messages[previous_count:] + + if not import_messages: + return 0, f"skip: no new messages for session={session_id}" + import_payload = { "title": derive_thread_title(parsed, cwd), - "messages": messages, + "messages": import_messages, } with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as handle: @@ -201,7 +227,7 @@ def import_current_transcript(payload: dict) -> tuple[int, str]: temp_path = Path(handle.name) try: - command = [nmem_bin, "--json", "t", "import", "-f", str(temp_path), "--id", thread_id, "-s", "codex"] + command = [*nmem_command, "--json", "t", "import", "-f", str(temp_path), "--id", thread_id, "-s", "codex"] result = subprocess.run( command, capture_output=True, diff --git a/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs b/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs index 5c1949ae..77c24c0b 100644 --- a/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs +++ b/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs @@ -135,11 +135,26 @@ if (integrationsDoc) { } else { ok("integrations.json install.command copies hidden files"); } + if (!codexEntry.install?.command?.includes("install_hooks.py")) { + fail("integrations.json install.command must run scripts/install_hooks.py so Codex auto-capture is actually enabled"); + } else { + ok("integrations.json install.command runs install_hooks.py"); + } if (!codexEntry.install?.updateCommand?.includes("nowledge-mem-codex-plugin/.")) { fail("integrations.json install.updateCommand must copy nowledge-mem-codex-plugin/. so hidden files are preserved"); } else { ok("integrations.json updateCommand copies hidden files"); } + if (!codexEntry.install?.updateCommand?.includes("install_hooks.py")) { + fail("integrations.json install.updateCommand must rerun scripts/install_hooks.py so hook config stays current"); + } else { + ok("integrations.json updateCommand runs install_hooks.py"); + } + if (codexEntry.capabilities?.autoCapture !== true) { + fail(`integrations.json codex-cli autoCapture must be true, got ${codexEntry.capabilities?.autoCapture}`); + } else { + ok("integrations.json codex-cli autoCapture"); + } } } diff --git a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py index 3e7369cf..3c23643f 100644 --- a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py +++ b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py @@ -5,6 +5,7 @@ import unittest from pathlib import Path from unittest import mock +from types import SimpleNamespace PLUGIN_ROOT = Path(__file__).resolve().parent.parent @@ -88,6 +89,73 @@ def test_timeout_from_nmem_is_reported_and_state_not_written(self): self.assertIn("timed out", output) self.assertFalse(self.module.STATE_FILE.exists()) + def test_resolve_nmem_command_falls_back_to_uvx(self): + with mock.patch.object( + self.module.shutil, + "which", + side_effect=[None, None, "/usr/bin/uvx", None], + ): + command = self.module.resolve_nmem_command() + + self.assertEqual(command, ["/usr/bin/uvx", "--from", "nmem-cli", "nmem"]) + + def test_subsequent_import_only_sends_delta_messages(self): + transcript_path = Path(self.temp_dir.name) / "rollout.jsonl" + transcript_path.write_text("placeholder", encoding="utf-8") + + parsed_first = { + "thread_id": "codex-delta-thread", + "title": "Delta thread", + "messages": [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + ], + } + parsed_second = { + "thread_id": "codex-delta-thread", + "title": "Delta thread", + "messages": [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + ], + } + + captured_payloads = [] + + def fake_run(command, capture_output, text, env, timeout): + payload_path = Path(command[command.index("-f") + 1]) + captured_payloads.append(json.loads(payload_path.read_text(encoding="utf-8"))) + return SimpleNamespace(returncode=0, stdout='{"success": true}', stderr="") + + parser_mock = mock.Mock(side_effect=[parsed_first, parsed_second]) + with mock.patch.object(self.module.shutil, "which", return_value="/usr/bin/nmem"), \ + mock.patch.dict("sys.modules", {"nmem_cli.session_import": mock.Mock(parse_codex_session_streaming=parser_mock)}), \ + mock.patch.object(self.module.subprocess, "run", side_effect=fake_run): + first_status, _ = self.module.import_current_transcript( + { + "session_id": "session-delta", + "cwd": "/tmp/project", + "hook_event_name": "Stop", + "transcript_path": str(transcript_path), + } + ) + second_status, _ = self.module.import_current_transcript( + { + "session_id": "session-delta", + "cwd": "/tmp/project", + "hook_event_name": "Stop", + "transcript_path": str(transcript_path), + } + ) + + self.assertEqual(first_status, 0) + self.assertEqual(second_status, 0) + self.assertEqual(len(captured_payloads[0]["messages"]), 2) + self.assertEqual(captured_payloads[0]["messages"][0]["content"], "u1") + self.assertEqual(len(captured_payloads[1]["messages"]), 1) + self.assertEqual(captured_payloads[1]["messages"][0]["content"], "u2") + def test_title_skips_agents_preamble_and_uses_first_real_user_message(self): parsed = { "title": "# AGENTS.md instructions for /Users/hansonmei/Projects/nowledge-community", diff --git a/nowledge-mem-npx-skills/skills/check-integration/SKILL.md b/nowledge-mem-npx-skills/skills/check-integration/SKILL.md index 4ed49336..a8db3d8d 100644 --- a/nowledge-mem-npx-skills/skills/check-integration/SKILL.md +++ b/nowledge-mem-npx-skills/skills/check-integration/SKILL.md @@ -61,7 +61,7 @@ The canonical source for this table is `community/integrations.json`. | **Gemini CLI** | Running as Gemini CLI agent; `~/.gemini/` exists | Search "Nowledge Mem" in the Gemini CLI Extensions Gallery | [Guide](https://mem.nowledge.co/docs/integrations/gemini-cli) | | **Alma** | Running inside Alma; `~/.config/alma/` exists | In Alma: Settings > Plugins > Marketplace, search "Nowledge Mem" | [Guide](https://mem.nowledge.co/docs/integrations/alma) | | **Droid** | Running inside Droid (Factory) | Add nowledge-co/community marketplace, install nowledge-mem@nowledge-community | [Guide](https://mem.nowledge.co/docs/integrations/droid) | -| **Codex CLI** | Running as Codex CLI agent; `~/.codex/` exists | Copy `nowledge-mem-codex-plugin` to `~/.codex/plugins/cache/local/nowledge-mem/local/`, then enable `[features] plugins = true` and `[plugins."nowledge-mem@local"] enabled = true` in `~/.codex/config.toml` | [Guide](https://mem.nowledge.co/docs/integrations/codex-cli) | +| **Codex CLI** | Running as Codex CLI agent; `~/.codex/` exists | Copy `nowledge-mem-codex-plugin` to `~/.codex/plugins/cache/local/nowledge-mem/local/`, enable `[features] plugins = true` and `[plugins."nowledge-mem@local"] enabled = true` in `~/.codex/config.toml`, then run `python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py` to enable automatic capture | [Guide](https://mem.nowledge.co/docs/integrations/codex-cli) | | **Bub** | Running inside Bub | `pip install nowledge-mem-bub` | [Guide](https://mem.nowledge.co/docs/integrations/bub) | | **Pi** | Running as Pi agent; `~/.pi/` exists | `pi install npm:nowledge-mem-pi` | [Guide](https://mem.nowledge.co/docs/integrations/pi) | | **OpenCode** | Running as OpenCode agent; `~/.config/opencode/` or `.opencode/` exists | Add `"opencode-nowledge-mem"` to `opencode.json` plugin array | [Guide](https://mem.nowledge.co/docs/integrations/opencode) | From d1675dd7294847d724a834acb429e4ddc118a42f Mon Sep 17 00:00:00 2001 From: Hanson Mei Date: Sat, 11 Apr 2026 14:28:17 +0800 Subject: [PATCH 5/8] refactor(codex): use direct API calls and add file locking - Replace subprocess nmem CLI calls with direct nmem_cli API imports - Add fcntl-based file locking to prevent race conditions - Implement atomic state file writes with temp file + os.replace - Add thread existence check and use create vs append endpoints - Filter malformed messages before sync - Add rollback mechanism in refresh_thread_titles on failure - Update tests to mock nmem_cli modules instead of subprocess - Add AGENTS.md with project agent collaboration guidelines --- AGENTS.md | 30 +++ .../hooks/nmem-stop-save.py | 203 +++++++++------- .../scripts/refresh_thread_titles.py | 71 ++++-- .../tests/test_codex_plugin.py | 220 +++++++++++++++--- 4 files changed, 394 insertions(+), 130 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..01e8037b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# Nowledge Community — Agent Guidelines + +## Registry + +[`integrations.json`](integrations.json) is the **single source of truth** for all Nowledge Mem integrations. It tracks capabilities, versions, install commands, transport, tool naming, and thread save methods. + +**When adding or modifying any integration, update `integrations.json` first.** Other surfaces (website `integrations.ts`, desktop app integrations view, README tables, marketplace JSONs) derive from or validate against this file. + +The desktop app fetches this file at runtime from `https://raw.githubusercontent.com/nowledge-co/community/main/integrations.json` for plugin update awareness. Changes to the schema (adding/removing/renaming fields) affect: +- **Rust** (`lib.rs`): `fetch_plugin_registry`, `detect_installed_plugins`, `write_plugin_update_state` +- **TypeScript** (`plugin-update-manager.ts`): `RegistryIntegration` interface +- **Python** (`health.py`): `_read_plugin_update_state` reader + +## Behavioral Guidance + +[`shared/behavioral-guidance.md`](shared/behavioral-guidance.md) defines when plugins should search, save, read Working Memory, and distill. All plugins should align with this shared guidance. + +## Plugin Development + +See [`docs/PLUGIN_DEVELOPMENT_GUIDE.md`](docs/PLUGIN_DEVELOPMENT_GUIDE.md) for authoring rules, directory layout, and testing expectations. + +## Submodules + +`nowledge-mem-gemini-cli` is a nested submodule (separate repo with its own release cycle). All other integrations are normal directories in this repo. + +## Commit Workflow + +When modifying this repo as a submodule of the parent `muscat` repo: +1. Commit inside `community/` first +2. Then stage the updated submodule reference in the parent repo diff --git a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py index 650ab9c5..7ba67425 100644 --- a/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py +++ b/nowledge-mem-codex-plugin/hooks/nmem-stop-save.py @@ -1,23 +1,26 @@ #!/usr/bin/env python3 from __future__ import annotations +import fcntl import hashlib import json import os import re -import shutil -import subprocess import sys import tempfile +import time +from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path +from urllib.parse import quote HOME = Path.home() CODEX_DIR = HOME / ".codex" LOG_FILE = CODEX_DIR / "log" / "nowledge-mem-stop-hook.log" STATE_FILE = CODEX_DIR / "nowledge_mem_codex_hook_state.json" -IMPORT_TIMEOUT_SECONDS = 30 +STATE_LOCK_FILE = CODEX_DIR / "nowledge_mem_codex_hook_state.lock" +LOCK_TIMEOUT_SECONDS = 2.0 def ensure_parent(path: Path) -> None: @@ -35,14 +38,51 @@ def load_json(path: Path) -> dict: if not path.exists(): return {} try: - return json.loads(path.read_text(encoding="utf-8")) + payload = json.loads(path.read_text(encoding="utf-8")) + return payload if isinstance(payload, dict) else {} except Exception: return {} def save_json(path: Path, payload: dict) -> None: ensure_parent(path) - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + temp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + delete=False, + dir=path.parent, + encoding="utf-8", + ) as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) + temp_path = Path(handle.name) + os.replace(temp_path, path) + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink(missing_ok=True) + + +@contextmanager +def state_lock( + lock_path: Path = STATE_LOCK_FILE, + timeout_seconds: float = LOCK_TIMEOUT_SECONDS, +): + ensure_parent(lock_path) + with lock_path.open("a+", encoding="utf-8") as handle: + deadline = time.monotonic() + timeout_seconds + while True: + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise TimeoutError(f"timed out waiting for lock {lock_path}") + time.sleep(0.05) + + try: + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) def configure_nmem_env() -> None: @@ -56,18 +96,6 @@ def configure_nmem_env() -> None: os.environ["NMEM_API_KEY"] = str(api_key) -def resolve_nmem_command() -> list[str] | None: - nmem_bin = shutil.which("nmem") or shutil.which("nmem.cmd") - if nmem_bin: - return [nmem_bin] - - uvx_bin = shutil.which("uvx") or shutil.which("uvx.cmd") - if uvx_bin: - return [uvx_bin, "--from", "nmem-cli", "nmem"] - - return None - - def shorten_title(text: str, limit: int = 60) -> str: normalized = text.replace("\n", " ").strip() if len(normalized) <= limit: @@ -168,14 +196,12 @@ def import_current_transcript(payload: dict) -> tuple[int, str]: return 0, f"skip: transcript_path missing or unreadable for session={session_id}" log(f"transcript={transcript_path}") - nmem_command = resolve_nmem_command() - if not nmem_command: - return 0, "skip: neither nmem nor uvx was found in PATH" - + configure_nmem_env() try: + from nmem_cli.cli import api_get_optional, api_post from nmem_cli.session_import import parse_codex_session_streaming except Exception as exc: - return 0, f"skip: failed to import nmem_cli parser: {exc}" + return 0, f"skip: failed to import nmem_cli modules: {exc}" try: parsed = parse_codex_session_streaming(transcript_path, truncate_large_content=True) @@ -183,8 +209,9 @@ def import_current_transcript(payload: dict) -> tuple[int, str]: return 0, f"skip: failed to parse codex rollout: {exc}" messages = [ - {"role": message.get("role"), "content": message.get("content", "")} + {"role": message["role"], "content": message.get("content", "")} for message in parsed.get("messages", []) + if isinstance(message, dict) and message.get("role") ] if not messages: return 0, f"skip: parsed zero messages for session={session_id}" @@ -195,66 +222,80 @@ def import_current_transcript(payload: dict) -> tuple[int, str]: json.dumps(messages, ensure_ascii=False, sort_keys=True).encode("utf-8") ).hexdigest() - state = load_json(STATE_FILE) - previous = state.get(session_id, {}) - if ( - previous.get("content_hash") == content_hash - and previous.get("message_count") == message_count - ): - return 0, f"skip: unchanged transcript for session={session_id}" - - import_messages = messages - previous_count = previous.get("message_count") - if ( - isinstance(previous_count, int) - and previous_count > 0 - and previous_count < message_count - and previous.get("thread_id") == thread_id - and previous.get("source_file") == str(transcript_path) - ): - import_messages = messages[previous_count:] - - if not import_messages: - return 0, f"skip: no new messages for session={session_id}" - - import_payload = { - "title": derive_thread_title(parsed, cwd), - "messages": import_messages, - } - - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as handle: - json.dump(import_payload, handle, ensure_ascii=False) - temp_path = Path(handle.name) + title = derive_thread_title(parsed, cwd) + workspace = parsed.get("workspace") or cwd or None + project = Path(cwd).name if cwd else None + metadata = parsed.get("metadata") or {} try: - command = [*nmem_command, "--json", "t", "import", "-f", str(temp_path), "--id", thread_id, "-s", "codex"] - result = subprocess.run( - command, - capture_output=True, - text=True, - env=os.environ.copy(), - timeout=IMPORT_TIMEOUT_SECONDS, - ) - except subprocess.TimeoutExpired: - return 0, f"skip: nmem import timed out after {IMPORT_TIMEOUT_SECONDS}s for thread={thread_id}" - finally: - try: - temp_path.unlink(missing_ok=True) - except Exception: - pass - - output = (result.stdout or "") + (result.stderr or "") - if result.returncode == 0: - state[session_id] = { - "content_hash": content_hash, - "message_count": message_count, - "thread_id": thread_id, - "source_file": str(transcript_path), - "updated_at": datetime.now(timezone.utc).isoformat(), - } - save_json(STATE_FILE, state) - - return result.returncode, output.strip() + with state_lock(): + state = load_json(STATE_FILE) + previous = state.get(session_id, {}) + if ( + previous.get("content_hash") == content_hash + and previous.get("message_count") == message_count + ): + return 0, f"skip: unchanged transcript for session={session_id}" + + import_messages = messages + previous_count = previous.get("message_count") + if ( + isinstance(previous_count, int) + and previous_count > 0 + and previous_count < message_count + and previous.get("thread_id") == thread_id + and previous.get("source_file") == str(transcript_path) + ): + import_messages = messages[previous_count:] + + if not import_messages: + return 0, f"skip: no new messages for session={session_id}" + + encoded_thread_id = quote(thread_id, safe='') + existing_thread = api_get_optional(f"/threads/{encoded_thread_id}") + + if existing_thread is None: + api_post( + "/threads", + { + "thread_id": thread_id, + "title": title, + "messages": messages, + "source": "codex", + "project": project, + "workspace": workspace, + "metadata": metadata, + }, + ) + action = "created" + synced_messages = len(messages) + else: + api_post( + f"/threads/{encoded_thread_id}/append", + { + "messages": import_messages, + "deduplicate": True, + }, + ) + action = "appended" + synced_messages = len(import_messages) + + with state_lock(): + state = load_json(STATE_FILE) + state[session_id] = { + "content_hash": content_hash, + "message_count": message_count, + "thread_id": thread_id, + "source_file": str(transcript_path), + "updated_at": datetime.now(timezone.utc).isoformat(), + } + save_json(STATE_FILE, state) + except TimeoutError as exc: + return 0, f"skip: failed to lock state file for session={session_id}: {exc}" + except Exception as exc: + return 0, f"skip: failed to sync thread={thread_id}: {exc}" + + return 0, f"{action}: thread={thread_id} synced_messages={synced_messages} total_messages={message_count}" def main() -> int: diff --git a/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py b/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py index 150f1005..b2c43b56 100644 --- a/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py +++ b/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py @@ -37,22 +37,59 @@ def get_thread(thread_id: str) -> dict | None: ) -def refresh_thread(thread_id: str, title: str, messages: list[dict], dry_run: bool) -> None: +def build_thread_payload( + thread_id: str, + title: str, + messages: list[dict], + thread: dict | None = None, +) -> dict: + thread = thread or {} + return { + "thread_id": thread_id, + "title": title, + "source": thread.get("source") or "codex", + "project": thread.get("project"), + "workspace": thread.get("workspace"), + "metadata": thread.get("metadata") or {}, + "messages": [{"role": m["role"], "content": m["content"]} for m in messages], + } + + +def refresh_thread( + thread_id: str, + title: str, + messages: list[dict], + dry_run: bool, + original_title: str | None = None, + original_messages: list[dict] | None = None, + thread: dict | None = None, +) -> None: if dry_run: print(f"DRY RUN refresh {thread_id} -> {title}", flush=True) return - cli.api_delete(f"/threads/{quote(thread_id, safe='')}") - cli.api_post( - "/threads/import", - { - "thread_id": thread_id, - "title": title, - "source": "codex", - "messages": [{"role": m["role"], "content": m["content"]} for m in messages], - }, - timeout=IMPORT_TIMEOUT_SECONDS, - ) + encoded_thread_id = quote(thread_id, safe='') + replacement_payload = build_thread_payload(thread_id, title, messages, thread=thread) + cli.api_delete(f"/threads/{encoded_thread_id}") + try: + cli.api_post( + "/threads", + replacement_payload, + timeout=IMPORT_TIMEOUT_SECONDS, + ) + except Exception: + if original_title is not None and original_messages is not None: + cli.api_post( + "/threads", + build_thread_payload( + thread_id, + original_title, + original_messages, + thread=thread, + ), + timeout=IMPORT_TIMEOUT_SECONDS, + ) + raise print(f"REFRESHED {thread_id} -> {title}", flush=True) @@ -106,7 +143,15 @@ def main() -> int: continue try: - refresh_thread(thread_id, desired_title, parsed.get("messages", []), args.dry_run) + refresh_thread( + thread_id, + desired_title, + parsed.get("messages", []), + args.dry_run, + original_title=current_title, + original_messages=thread_payload.get("messages", []), + thread=thread, + ) refreshed += 1 except SystemExit as exc: errors += 1 diff --git a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py index 3c23643f..e9cc5962 100644 --- a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py +++ b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py @@ -1,16 +1,15 @@ import importlib.util -import json -import subprocess import tempfile import unittest from pathlib import Path +from types import ModuleType from unittest import mock -from types import SimpleNamespace PLUGIN_ROOT = Path(__file__).resolve().parent.parent HOOK_MODULE_PATH = PLUGIN_ROOT / "hooks" / "nmem-stop-save.py" INSTALL_MODULE_PATH = PLUGIN_ROOT / "scripts" / "install_hooks.py" +REFRESH_MODULE_PATH = PLUGIN_ROOT / "scripts" / "refresh_thread_titles.py" def load_module(module_name: str, module_path: Path): @@ -32,6 +31,22 @@ def setUp(self): def tearDown(self): self.temp_dir.cleanup() + def patch_nmem_modules(self, *, parser, api_get_optional=None, api_post=None): + package = ModuleType("nmem_cli") + cli_module = ModuleType("nmem_cli.cli") + cli_module.api_get_optional = api_get_optional or mock.Mock(return_value=None) + cli_module.api_post = api_post or mock.Mock() + session_module = ModuleType("nmem_cli.session_import") + session_module.parse_codex_session_streaming = parser + return mock.patch.dict( + "sys.modules", + { + "nmem_cli": package, + "nmem_cli.cli": cli_module, + "nmem_cli.session_import": session_module, + }, + ) + def test_missing_transcript_path_skips_before_path_construction(self): status, output = self.module.import_current_transcript( { @@ -59,23 +74,23 @@ def test_directory_transcript_path_is_rejected(self): self.assertEqual(status, 0) self.assertIn("transcript_path missing or unreadable", output) - def test_timeout_from_nmem_is_reported_and_state_not_written(self): + def test_api_sync_failure_is_reported_and_state_not_written(self): transcript_path = Path(self.temp_dir.name) / "rollout.jsonl" transcript_path.write_text("placeholder", encoding="utf-8") parsed = { - "thread_id": "codex-timeout-thread", - "title": "Timeout thread", + "thread_id": "codex-error-thread", + "title": "Error thread", "messages": [{"role": "user", "content": "hi"}], } - with mock.patch.object(self.module.shutil, "which", return_value="/usr/bin/nmem"), \ - mock.patch.dict("sys.modules", {"nmem_cli.session_import": mock.Mock(parse_codex_session_streaming=mock.Mock(return_value=parsed))}), \ - mock.patch.object( - self.module.subprocess, - "run", - side_effect=subprocess.TimeoutExpired(["nmem"], timeout=5), - ): + parser_mock = mock.Mock(return_value=parsed) + api_post = mock.Mock(side_effect=RuntimeError("boom")) + with self.patch_nmem_modules( + parser=parser_mock, + api_get_optional=mock.Mock(return_value=None), + api_post=api_post, + ): status, output = self.module.import_current_transcript( { "session_id": "session-3", @@ -86,18 +101,16 @@ def test_timeout_from_nmem_is_reported_and_state_not_written(self): ) self.assertEqual(status, 0) - self.assertIn("timed out", output) + self.assertIn("failed to sync", output) self.assertFalse(self.module.STATE_FILE.exists()) - def test_resolve_nmem_command_falls_back_to_uvx(self): - with mock.patch.object( - self.module.shutil, - "which", - side_effect=[None, None, "/usr/bin/uvx", None], - ): - command = self.module.resolve_nmem_command() + def test_load_json_returns_empty_dict_for_non_object_json(self): + payload_path = Path(self.temp_dir.name) / "payload.json" + payload_path.write_text('["not", "a", "dict"]', encoding="utf-8") + + payload = self.module.load_json(payload_path) - self.assertEqual(command, ["/usr/bin/uvx", "--from", "nmem-cli", "nmem"]) + self.assertEqual(payload, {}) def test_subsequent_import_only_sends_delta_messages(self): transcript_path = Path(self.temp_dir.name) / "rollout.jsonl" @@ -121,17 +134,14 @@ def test_subsequent_import_only_sends_delta_messages(self): ], } - captured_payloads = [] - - def fake_run(command, capture_output, text, env, timeout): - payload_path = Path(command[command.index("-f") + 1]) - captured_payloads.append(json.loads(payload_path.read_text(encoding="utf-8"))) - return SimpleNamespace(returncode=0, stdout='{"success": true}', stderr="") - parser_mock = mock.Mock(side_effect=[parsed_first, parsed_second]) - with mock.patch.object(self.module.shutil, "which", return_value="/usr/bin/nmem"), \ - mock.patch.dict("sys.modules", {"nmem_cli.session_import": mock.Mock(parse_codex_session_streaming=parser_mock)}), \ - mock.patch.object(self.module.subprocess, "run", side_effect=fake_run): + api_get_optional = mock.Mock(side_effect=[None, {"thread": {"thread_id": "codex-delta-thread"}}]) + api_post = mock.Mock() + with self.patch_nmem_modules( + parser=parser_mock, + api_get_optional=api_get_optional, + api_post=api_post, + ): first_status, _ = self.module.import_current_transcript( { "session_id": "session-delta", @@ -151,10 +161,113 @@ def fake_run(command, capture_output, text, env, timeout): self.assertEqual(first_status, 0) self.assertEqual(second_status, 0) - self.assertEqual(len(captured_payloads[0]["messages"]), 2) - self.assertEqual(captured_payloads[0]["messages"][0]["content"], "u1") - self.assertEqual(len(captured_payloads[1]["messages"]), 1) - self.assertEqual(captured_payloads[1]["messages"][0]["content"], "u2") + self.assertEqual(api_post.call_count, 2) + + create_endpoint, create_payload = api_post.call_args_list[0].args + self.assertEqual(create_endpoint, "/threads") + self.assertEqual(len(create_payload["messages"]), 2) + self.assertEqual(create_payload["messages"][0]["content"], "u1") + + append_endpoint, append_payload = api_post.call_args_list[1].args + self.assertEqual(append_endpoint, "/threads/codex-delta-thread/append") + self.assertTrue(append_payload["deduplicate"]) + self.assertEqual(len(append_payload["messages"]), 1) + self.assertEqual(append_payload["messages"][0]["content"], "u2") + + def test_malformed_messages_are_filtered_before_sync(self): + transcript_path = Path(self.temp_dir.name) / "rollout.jsonl" + transcript_path.write_text("placeholder", encoding="utf-8") + + parsed = { + "thread_id": "codex-filter-thread", + "title": "Filter thread", + "messages": [ + {"role": "user", "content": "u1"}, + {"content": "missing-role"}, + "bad-entry", + {"role": "assistant", "content": "a1"}, + ], + } + + api_post = mock.Mock() + with self.patch_nmem_modules( + parser=mock.Mock(return_value=parsed), + api_get_optional=mock.Mock(return_value=None), + api_post=api_post, + ): + status, _ = self.module.import_current_transcript( + { + "session_id": "session-filter", + "cwd": "/tmp/project", + "hook_event_name": "Stop", + "transcript_path": str(transcript_path), + } + ) + + self.assertEqual(status, 0) + _, payload = api_post.call_args.args + self.assertEqual( + payload["messages"], + [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + ], + ) + + def test_state_lock_times_out_when_contended(self): + with mock.patch.object(self.module.fcntl, "flock", side_effect=BlockingIOError), \ + mock.patch.object( + self.module.time, + "monotonic", + side_effect=[0.0, 0.0, 0.2, 0.4], + ), \ + mock.patch.object(self.module.time, "sleep"): + with self.assertRaises(TimeoutError): + with self.module.state_lock(timeout_seconds=0.3): + self.fail("lock should not be acquired") + + def test_missing_remote_thread_recreates_full_transcript(self): + transcript_path = Path(self.temp_dir.name) / "rollout.jsonl" + transcript_path.write_text("placeholder", encoding="utf-8") + + parsed = { + "thread_id": "codex-recreate-thread", + "title": "Recreate thread", + "messages": [ + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + ], + } + + state = { + "session-recreate": { + "thread_id": "codex-recreate-thread", + "message_count": 2, + "source_file": str(transcript_path), + } + } + self.module.save_json(self.module.STATE_FILE, state) + + api_post = mock.Mock() + with self.patch_nmem_modules( + parser=mock.Mock(return_value=parsed), + api_get_optional=mock.Mock(return_value=None), + api_post=api_post, + ): + status, _ = self.module.import_current_transcript( + { + "session_id": "session-recreate", + "cwd": "/tmp/project", + "hook_event_name": "Stop", + "transcript_path": str(transcript_path), + } + ) + + self.assertEqual(status, 0) + endpoint, payload = api_post.call_args.args + self.assertEqual(endpoint, "/threads") + self.assertEqual(len(payload["messages"]), 3) def test_title_skips_agents_preamble_and_uses_first_real_user_message(self): parsed = { @@ -325,5 +438,40 @@ def test_ensure_codex_hooks_enabled_replaces_indented_key_without_duplication(se self.assertIn("[features]\ncodex_hooks = true\napps = true\n", updated) +class RefreshThreadTitleTests(unittest.TestCase): + def setUp(self): + self.module = load_module("refresh_thread_titles", REFRESH_MODULE_PATH) + + def test_refresh_thread_restores_original_messages_on_failure(self): + cli_mock = mock.Mock() + cli_mock.api_post.side_effect = [RuntimeError("create failed"), None] + + with mock.patch.object(self.module, "cli", cli_mock): + with self.assertRaises(RuntimeError): + self.module.refresh_thread( + thread_id="codex-thread-1", + title="New title", + messages=[{"role": "user", "content": "new"}], + dry_run=False, + original_title="Old title", + original_messages=[{"role": "user", "content": "old"}], + ) + + self.assertEqual(cli_mock.api_delete.call_count, 1) + self.assertEqual(cli_mock.api_post.call_count, 2) + + first_endpoint, first_payload = cli_mock.api_post.call_args_list[0].args + self.assertEqual(first_endpoint, "/threads") + self.assertEqual(first_payload["title"], "New title") + + restore_endpoint, restore_payload = cli_mock.api_post.call_args_list[1].args + self.assertEqual(restore_endpoint, "/threads") + self.assertEqual(restore_payload["title"], "Old title") + self.assertEqual( + restore_payload["messages"], + [{"role": "user", "content": "old"}], + ) + + if __name__ == "__main__": unittest.main() From c121917b1d594d604520d2b773d1e9ad7890011e Mon Sep 17 00:00:00 2001 From: Wey Gu Date: Sat, 11 Apr 2026 21:58:32 +0800 Subject: [PATCH 6/8] fix(codex): harden stop-hook installer runtime --- nowledge-mem-codex-plugin/README.md | 8 +- nowledge-mem-codex-plugin/RELEASING.md | 3 + .../scripts/install_hooks.py | 82 ++++++++++++++++--- .../scripts/refresh_thread_titles.py | 26 ++++-- .../scripts/validate-plugin.mjs | 1 + .../tests/test_codex_plugin.py | 71 ++++++++++++++++ 6 files changed, 171 insertions(+), 20 deletions(-) diff --git a/nowledge-mem-codex-plugin/README.md b/nowledge-mem-codex-plugin/README.md index 91e9c90d..3a775325 100644 --- a/nowledge-mem-codex-plugin/README.md +++ b/nowledge-mem-codex-plugin/README.md @@ -27,9 +27,9 @@ Codex does not give this package hard lifecycle hooks like Claude Code or OpenCl ## Prerequisites -For day-to-day skill usage, `nmem` must be on your PATH. +For day-to-day skill usage, put `nmem` on your PATH. -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. +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. **Quickest path** (if the Nowledge Mem desktop app is running): Settings > Preferences > Developer Tools > Install CLI @@ -67,7 +67,7 @@ plugins = true enabled = true ``` -Install the bundled Codex hook helper with the same Python interpreter you want Codex to use for the hook runtime: +Install the bundled Codex hook helper with the same Python you want Codex to use for the hook runtime: ```bash python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py @@ -216,7 +216,7 @@ If you used `nowledge-mem-codex-prompts` before: - **"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. - **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`. - **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`. -- **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. +- **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. - **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. ## Links diff --git a/nowledge-mem-codex-plugin/RELEASING.md b/nowledge-mem-codex-plugin/RELEASING.md index 39f1e862..a2005c0f 100644 --- a/nowledge-mem-codex-plugin/RELEASING.md +++ b/nowledge-mem-codex-plugin/RELEASING.md @@ -23,6 +23,8 @@ node nowledge-mem-codex-plugin/scripts/validate-plugin.mjs - update expected version checks in `scripts/validate-plugin.mjs` - keep install examples on `cp -r .../. ...` so `.codex-plugin/` is copied - keep `scripts/install_hooks.py` idempotent +- keep the installer enforcing a Python runtime that can `import nmem_cli` +- keep the copied hook pinned to the installer interpreter (`sys.executable`) - keep `hooks/nmem-stop-save.py` focused on direct transcript import via `transcript_path` - re-run `node nowledge-mem-codex-plugin/scripts/validate-plugin.mjs` @@ -31,6 +33,7 @@ node nowledge-mem-codex-plugin/scripts/validate-plugin.mjs After copying the plugin into `~/.codex/plugins/cache/local/nowledge-mem/local/`: ```bash +python3 -c "import nmem_cli, nmem_cli.session_import" python3 ~/.codex/plugins/cache/local/nowledge-mem/local/scripts/install_hooks.py codex exec -C . "Reply with exactly OK and nothing else." tail -n 20 ~/.codex/log/nowledge-mem-stop-hook.log diff --git a/nowledge-mem-codex-plugin/scripts/install_hooks.py b/nowledge-mem-codex-plugin/scripts/install_hooks.py index 5c7960c4..ce3ff84c 100644 --- a/nowledge-mem-codex-plugin/scripts/install_hooks.py +++ b/nowledge-mem-codex-plugin/scripts/install_hooks.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 from __future__ import annotations +import importlib.util import json +import re import shutil import stat import sys @@ -18,19 +20,76 @@ CONFIG_FILE = CODEX_DIR / "config.toml" SOURCE_HOOK = PLUGIN_ROOT / "hooks" / "nmem-stop-save.py" INSTALLED_HOOK = HOOKS_DIR / "nowledge-mem-stop-save.py" +CODEX_HOOKS_KEY_RE = re.compile(r"^\s*codex_hooks\s*=") + + +def backup_invalid_json(path: Path, *, reason: str) -> dict: + backup = path.with_name(f"{path.name}.{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}.bak") + shutil.move(path, backup) + print(f"warning: moved {reason} to {backup}", file=sys.stderr) + return {} def load_json(path: Path) -> dict: if not path.exists(): return {} try: - return json.loads(path.read_text(encoding="utf-8")) + payload = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: - backup = path.with_name(f"{path.name}.{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}.bak") - shutil.move(path, backup) - print(f"warning: moved malformed JSON to {backup}", file=sys.stderr) - return {} - + return backup_invalid_json(path, reason="malformed JSON") + if not isinstance(payload, dict): + return backup_invalid_json(path, reason="non-object JSON") + return payload + + +def ensure_nmem_cli_runtime_ready() -> None: + required_modules = ("nmem_cli", "nmem_cli.session_import") + missing = [name for name in required_modules if importlib.util.find_spec(name) is None] + if not missing: + return + missing_text = ", ".join(missing) + raise SystemExit( + "This installer must be run with a Python interpreter that can import " + f"{missing_text}. Install nmem-cli into that interpreter, then rerun " + "scripts/install_hooks.py with the same python3." + ) + + +def normalize_hooks_doc(hooks_doc: dict) -> tuple[dict, list[dict]]: + hooks = hooks_doc.get("hooks") + if not isinstance(hooks, dict): + hooks = {} + hooks_doc["hooks"] = hooks + + stop_hooks = hooks.get("Stop") + if not isinstance(stop_hooks, list): + stop_hooks = [] + + normalized_stop_hooks: list[dict] = [] + for entry in stop_hooks: + if not isinstance(entry, dict): + continue + normalized_entry = dict(entry) + if not isinstance(normalized_entry.get("hooks"), list): + normalized_entry["hooks"] = [] + normalized_stop_hooks.append(normalized_entry) + + hooks["Stop"] = normalized_stop_hooks + return hooks_doc, normalized_stop_hooks + + +def rewrite_hook_shebang(path: Path) -> None: + original = path.read_text(encoding="utf-8") + lines = original.splitlines() + desired = f"#!{sys.executable}" + if lines and lines[0].startswith("#!"): + lines[0] = desired + else: + lines.insert(0, desired) + updated = "\n".join(lines) + if original.endswith("\n"): + updated += "\n" + path.write_text(updated, encoding="utf-8") def save_json(path: Path, payload: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) @@ -40,6 +99,7 @@ def save_json(path: Path, payload: dict) -> None: def install_runtime_hook() -> None: HOOKS_DIR.mkdir(parents=True, exist_ok=True) shutil.copy2(SOURCE_HOOK, INSTALLED_HOOK) + rewrite_hook_shebang(INSTALLED_HOOK) mode = INSTALLED_HOOK.stat().st_mode INSTALLED_HOOK.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) @@ -53,8 +113,7 @@ def merge_hooks_json() -> None: else: hooks_doc = {} - hooks = hooks_doc.setdefault("hooks", {}) - stop_hooks = hooks.setdefault("Stop", []) + hooks_doc, stop_hooks = normalize_hooks_doc(hooks_doc) desired = { "matcher": ".*", @@ -68,7 +127,9 @@ def merge_hooks_json() -> None: replaced = False for index, entry in enumerate(stop_hooks): - for hook in entry.get("hooks", []): + for hook in entry["hooks"]: + if not isinstance(hook, dict): + continue if hook.get("command") == str(INSTALLED_HOOK): stop_hooks[index] = desired replaced = True @@ -113,7 +174,7 @@ def ensure_codex_hooks_enabled() -> None: replaced = False for index in range(features_start + 1, features_end): stripped = lines[index].strip() - if stripped.startswith(f"{target_key} "): + if CODEX_HOOKS_KEY_RE.match(stripped): lines[index] = "codex_hooks = true" replaced = True break @@ -128,6 +189,7 @@ def ensure_codex_hooks_enabled() -> None: def main() -> int: + ensure_nmem_cli_runtime_ready() install_runtime_hook() merge_hooks_json() ensure_codex_hooks_enabled() diff --git a/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py b/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py index b2c43b56..6efd8078 100644 --- a/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py +++ b/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py @@ -7,14 +7,12 @@ from pathlib import Path from urllib.parse import quote -from nmem_cli import cli -from nmem_cli.session_import import parse_codex_session_streaming - - SESSIONS_ROOT = Path.home() / ".codex" / "sessions" AGENTS_PREFIX = "# AGENTS.md instructions for " GET_TIMEOUT_SECONDS = 10.0 IMPORT_TIMEOUT_SECONDS = 120.0 +cli = None +parse_codex_session_streaming = None def load_hook_module(): @@ -30,8 +28,20 @@ def iter_codex_rollouts() -> list[Path]: return sorted(SESSIONS_ROOT.rglob("rollout-*.jsonl")) +def ensure_nmem_modules() -> tuple[object, object]: + global cli, parse_codex_session_streaming + if cli is None or parse_codex_session_streaming is None: + from nmem_cli import cli as cli_module + from nmem_cli.session_import import parse_codex_session_streaming as parser + + cli = cli_module + parse_codex_session_streaming = parser + return cli, parse_codex_session_streaming + + def get_thread(thread_id: str) -> dict | None: - return cli.api_get_optional( + cli_module, _ = ensure_nmem_modules() + return cli_module.api_get_optional( f"/threads/{quote(thread_id, safe='')}", timeout=GET_TIMEOUT_SECONDS, ) @@ -101,13 +111,14 @@ def main() -> int: args = parser.parse_args() hook_module = load_hook_module() + _, parse_codex = ensure_nmem_modules() checked = 0 refreshed = 0 errors = 0 for rollout_path in iter_codex_rollouts(): try: - parsed = parse_codex_session_streaming(rollout_path, truncate_large_content=True) + parsed = parse_codex(rollout_path, truncate_large_content=True) thread_id = parsed["thread_id"] except Exception as exc: errors += 1 @@ -156,6 +167,9 @@ def main() -> int: except SystemExit as exc: errors += 1 print(f"ERROR refresh {thread_id}: exited {exc.code}", flush=True) + except Exception as exc: + errors += 1 + print(f"ERROR refresh {thread_id}: {exc}", flush=True) print( json.dumps( diff --git a/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs b/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs index 77c24c0b..f386c72c 100644 --- a/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs +++ b/nowledge-mem-codex-plugin/scripts/validate-plugin.mjs @@ -109,6 +109,7 @@ if (readme !== null) { "~/.codex/hooks.json", "codex_hooks = true", "host-level", + "nmem_cli", ]) { if (!readme.includes(phrase)) { fail(`README must mention ${phrase}`); diff --git a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py index e9cc5962..030f58eb 100644 --- a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py +++ b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py @@ -393,6 +393,33 @@ def test_load_json_recovers_from_malformed_json(self): backups = list(self.module.GLOBAL_HOOKS_FILE.parent.glob("hooks.json.*.bak")) self.assertEqual(len(backups), 1) + def test_load_json_recovers_from_non_object_json(self): + self.module.GLOBAL_HOOKS_FILE.parent.mkdir(parents=True, exist_ok=True) + self.module.GLOBAL_HOOKS_FILE.write_text('["not-an-object"]', encoding="utf-8") + + payload = self.module.load_json(self.module.GLOBAL_HOOKS_FILE) + + self.assertEqual(payload, {}) + backups = list(self.module.GLOBAL_HOOKS_FILE.parent.glob("hooks.json.*.bak")) + self.assertEqual(len(backups), 1) + + def test_merge_hooks_json_normalizes_malformed_stop_section(self): + self.module.GLOBAL_HOOKS_FILE.parent.mkdir(parents=True, exist_ok=True) + self.module.GLOBAL_HOOKS_FILE.write_text( + '{"hooks": {"Stop": {"matcher": ".*"}}}', + encoding="utf-8", + ) + + self.module.merge_hooks_json() + payload = self.module.load_json(self.module.GLOBAL_HOOKS_FILE) + + self.assertIsInstance(payload["hooks"]["Stop"], list) + self.assertEqual(len(payload["hooks"]["Stop"]), 1) + self.assertEqual( + payload["hooks"]["Stop"][0]["hooks"][0]["command"], + str(self.module.INSTALLED_HOOK), + ) + def test_ensure_codex_hooks_enabled_only_changes_features_section(self): self.module.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) self.module.CONFIG_FILE.write_text( @@ -437,6 +464,19 @@ def test_ensure_codex_hooks_enabled_replaces_indented_key_without_duplication(se self.assertNotIn("codex_hooks = false", updated) self.assertIn("[features]\ncodex_hooks = true\napps = true\n", updated) + def test_install_runtime_hook_rewrites_shebang_to_current_python(self): + self.module.install_runtime_hook() + + installed = self.module.INSTALLED_HOOK.read_text(encoding="utf-8") + self.assertTrue(installed.startswith(f"#!{self.module.sys.executable}\n")) + + def test_ensure_nmem_cli_runtime_ready_exits_when_missing(self): + with mock.patch.object(self.module.importlib.util, "find_spec", return_value=None): + with self.assertRaises(SystemExit) as exc: + self.module.ensure_nmem_cli_runtime_ready() + + self.assertIn("nmem_cli", str(exc.exception)) + class RefreshThreadTitleTests(unittest.TestCase): def setUp(self): @@ -472,6 +512,37 @@ def test_refresh_thread_restores_original_messages_on_failure(self): [{"role": "user", "content": "old"}], ) + def test_main_counts_generic_refresh_errors(self): + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + rollout_path = Path(temp_dir.name) / "rollout-1.jsonl" + rollout_path.write_text("placeholder", encoding="utf-8") + + self.module.parse_codex_session_streaming = mock.Mock( + return_value={ + "thread_id": "codex-thread-1", + "messages": [{"role": "user", "content": "real request"}], + "workspace": "/tmp/project", + } + ) + self.module.cli = mock.Mock() + + with mock.patch.object(self.module, "iter_codex_rollouts", return_value=[rollout_path]), \ + mock.patch.object( + self.module, + "get_thread", + return_value={"thread": {"title": f"{self.module.AGENTS_PREFIX}/tmp/project"}}, + ), \ + mock.patch.object(self.module, "refresh_thread", side_effect=RuntimeError("boom")), \ + mock.patch("sys.argv", ["refresh_thread_titles.py"]), \ + mock.patch("builtins.print") as print_mock: + result = self.module.main() + + self.assertEqual(result, 0) + printed_lines = [" ".join(str(arg) for arg in call.args) for call in print_mock.call_args_list] + self.assertTrue(any("ERROR refresh codex-thread-1: boom" in line for line in printed_lines)) + self.assertTrue(any('"errors": 1' in line for line in printed_lines)) + if __name__ == "__main__": unittest.main() From 8b76cb7e430a5f0d90f42098095e0da45f7a3be8 Mon Sep 17 00:00:00 2001 From: HamsteRider-m <218021008@link.cuhk.edu.cn> Date: Tue, 14 Apr 2026 22:02:42 +0800 Subject: [PATCH 7/8] chore(ccb): add claude session metadata --- .ccb/.claude-session | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .ccb/.claude-session diff --git a/.ccb/.claude-session b/.ccb/.claude-session new file mode 100644 index 00000000..037f0bc9 --- /dev/null +++ b/.ccb/.claude-session @@ -0,0 +1,16 @@ +{ + "session_id": "ai-1775888498-61737", + "ccb_project_id": "ea66bbb87cd797287d668bb57a8ab9762f0509c76e37e90cb1e0536d75ec7b72", + "work_dir": "/Users/hansonmei/Projects/nowledge-community", + "work_dir_norm": "/Users/hansonmei/Projects/nowledge-community", + "start_dir": "/Users/hansonmei/Projects/nowledge-community", + "terminal": "tmux", + "active": false, + "started_at": "2026-04-11 14:21:38", + "updated_at": "2026-04-13 14:04:31", + "pane_id": "%7", + "pane_title_marker": "CCB-Claude-ea66bbb8", + "claude_session_path": "/Users/hansonmei/.claude/projects/-Users-hansonmei-Projects-nowledge-community/3d8de919-0380-474d-9fef-24b3612d1289.jsonl", + "claude_session_id": "3d8de919-0380-474d-9fef-24b3612d1289", + "ended_at": "2026-04-13 14:04:31" +} \ No newline at end of file From 6e678d3e6b25b80dd90fad3e215fd2e394abfaea Mon Sep 17 00:00:00 2001 From: HamsteRider-m <218021008@link.cuhk.edu.cn> Date: Tue, 14 Apr 2026 22:06:15 +0800 Subject: [PATCH 8/8] fix(codex): address pr review findings --- nowledge-mem-codex-plugin/README.md | 2 +- nowledge-mem-codex-plugin/RELEASING.md | 6 +- .../scripts/install_hooks.py | 8 +- .../scripts/refresh_thread_titles.py | 19 ++++- .../tests/test_codex_plugin.py | 73 ++++++++++++++++++- 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/nowledge-mem-codex-plugin/README.md b/nowledge-mem-codex-plugin/README.md index 3a775325..d39279dc 100644 --- a/nowledge-mem-codex-plugin/README.md +++ b/nowledge-mem-codex-plugin/README.md @@ -151,7 +151,7 @@ codex exec -C . "Reply with exactly OK and nothing else." tail -n 20 ~/.codex/log/nowledge-mem-stop-hook.log ``` -You should see `hook: Stop`, `hook: Stop Completed`, and a successful import entry in the hook log. +You should see `start event=Stop`, `nmem_exit=0`, and a `created thread=...`, `appended thread=...`, or `skip: ...` outcome entry in the hook log. ## Update diff --git a/nowledge-mem-codex-plugin/RELEASING.md b/nowledge-mem-codex-plugin/RELEASING.md index a2005c0f..fd10dcb5 100644 --- a/nowledge-mem-codex-plugin/RELEASING.md +++ b/nowledge-mem-codex-plugin/RELEASING.md @@ -41,6 +41,6 @@ tail -n 20 ~/.codex/log/nowledge-mem-stop-hook.log Expect: -- `hook: Stop` -- `hook: Stop Completed` -- a successful `nmem t import` result in the hook log +- `start event=Stop` +- `nmem_exit=0` +- a `created thread=...`, `appended thread=...`, or `skip: ...` outcome line in the hook log diff --git a/nowledge-mem-codex-plugin/scripts/install_hooks.py b/nowledge-mem-codex-plugin/scripts/install_hooks.py index ce3ff84c..ead89b19 100644 --- a/nowledge-mem-codex-plugin/scripts/install_hooks.py +++ b/nowledge-mem-codex-plugin/scripts/install_hooks.py @@ -106,10 +106,10 @@ def install_runtime_hook() -> None: def merge_hooks_json() -> None: if GLOBAL_HOOKS_FILE.exists(): - hooks_doc = load_json(GLOBAL_HOOKS_FILE) backup = GLOBAL_HOOKS_FILE.with_suffix(".json.bak") if not backup.exists(): shutil.copy2(GLOBAL_HOOKS_FILE, backup) + hooks_doc = load_json(GLOBAL_HOOKS_FILE) else: hooks_doc = {} @@ -126,12 +126,12 @@ def merge_hooks_json() -> None: } replaced = False - for index, entry in enumerate(stop_hooks): - for hook in entry["hooks"]: + for entry in stop_hooks: + for hook_index, hook in enumerate(entry["hooks"]): if not isinstance(hook, dict): continue if hook.get("command") == str(INSTALLED_HOOK): - stop_hooks[index] = desired + entry["hooks"][hook_index] = dict(desired["hooks"][0]) replaced = True break if replaced: diff --git a/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py b/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py index 6efd8078..3b7fd0d4 100644 --- a/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py +++ b/nowledge-mem-codex-plugin/scripts/refresh_thread_titles.py @@ -18,8 +18,11 @@ def load_hook_module(): hook_path = Path(__file__).resolve().parent.parent / "hooks" / "nmem-stop-save.py" spec = importlib.util.spec_from_file_location("nmem_stop_save", hook_path) + if spec is None: + raise RuntimeError(f"Failed to create import spec for {hook_path}") + if spec.loader is None: + raise RuntimeError(f"Failed to load hook module from {hook_path}: missing loader") module = importlib.util.module_from_spec(spec) - assert spec.loader is not None spec.loader.exec_module(module) return module @@ -54,6 +57,15 @@ def build_thread_payload( thread: dict | None = None, ) -> dict: thread = thread or {} + normalized_messages = [] + for message in messages: + if not isinstance(message, dict): + continue + role = message.get("role") + if not role: + continue + normalized_messages.append({"role": role, "content": message.get("content", "")}) + return { "thread_id": thread_id, "title": title, @@ -61,7 +73,7 @@ def build_thread_payload( "project": thread.get("project"), "workspace": thread.get("workspace"), "metadata": thread.get("metadata") or {}, - "messages": [{"role": m["role"], "content": m["content"]} for m in messages], + "messages": normalized_messages, } @@ -111,6 +123,7 @@ def main() -> int: args = parser.parse_args() hook_module = load_hook_module() + hook_module.configure_nmem_env() _, parse_codex = ensure_nmem_modules() checked = 0 refreshed = 0 @@ -183,7 +196,7 @@ def main() -> int: ), flush=True, ) - return 0 + return 1 if errors else 0 if __name__ == "__main__": diff --git a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py index 030f58eb..c3a4d24e 100644 --- a/nowledge-mem-codex-plugin/tests/test_codex_plugin.py +++ b/nowledge-mem-codex-plugin/tests/test_codex_plugin.py @@ -420,6 +420,41 @@ def test_merge_hooks_json_normalizes_malformed_stop_section(self): str(self.module.INSTALLED_HOOK), ) + def test_merge_hooks_json_preserves_existing_stop_entry_shape(self): + self.module.GLOBAL_HOOKS_FILE.parent.mkdir(parents=True, exist_ok=True) + payload = { + "hooks": { + "Stop": [ + { + "matcher": "workspace-1", + "hooks": [ + {"type": "command", "command": str(self.module.INSTALLED_HOOK)}, + {"type": "command", "command": "/usr/bin/other-hook"}, + ], + } + ] + } + } + self.module.save_json(self.module.GLOBAL_HOOKS_FILE, payload) + + self.module.merge_hooks_json() + updated = self.module.load_json(self.module.GLOBAL_HOOKS_FILE) + + stop_entry = updated["hooks"]["Stop"][0] + self.assertEqual(stop_entry["matcher"], "workspace-1") + self.assertEqual(len(stop_entry["hooks"]), 2) + self.assertEqual(stop_entry["hooks"][1]["command"], "/usr/bin/other-hook") + + def test_merge_hooks_json_copies_backup_before_invalid_payload_is_rotated(self): + self.module.GLOBAL_HOOKS_FILE.parent.mkdir(parents=True, exist_ok=True) + self.module.GLOBAL_HOOKS_FILE.write_text("{not-json", encoding="utf-8") + + self.module.merge_hooks_json() + + self.assertTrue(self.module.GLOBAL_HOOKS_FILE.with_suffix(".json.bak").exists()) + rotated = list(self.module.GLOBAL_HOOKS_FILE.parent.glob("hooks.json.*.bak")) + self.assertEqual(len(rotated), 1) + def test_ensure_codex_hooks_enabled_only_changes_features_section(self): self.module.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) self.module.CONFIG_FILE.write_text( @@ -469,6 +504,7 @@ def test_install_runtime_hook_rewrites_shebang_to_current_python(self): installed = self.module.INSTALLED_HOOK.read_text(encoding="utf-8") self.assertTrue(installed.startswith(f"#!{self.module.sys.executable}\n")) + self.assertNotEqual(self.module.INSTALLED_HOOK.stat().st_mode & 0o111, 0) def test_ensure_nmem_cli_runtime_ready_exits_when_missing(self): with mock.patch.object(self.module.importlib.util, "find_spec", return_value=None): @@ -512,6 +548,41 @@ def test_refresh_thread_restores_original_messages_on_failure(self): [{"role": "user", "content": "old"}], ) + def test_build_thread_payload_filters_malformed_messages(self): + payload = self.module.build_thread_payload( + "thread-1", + "Title", + [ + {"role": "user", "content": "ok"}, + {"content": "missing-role"}, + "bad-entry", + {"role": "assistant"}, + ], + ) + + self.assertEqual( + payload["messages"], + [ + {"role": "user", "content": "ok"}, + {"role": "assistant", "content": ""}, + ], + ) + + def test_main_configures_nmem_env_before_importing_modules(self): + hook_module = mock.Mock() + hook_module.derive_thread_title.return_value = "ignored" + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + + with mock.patch.object(self.module, "load_hook_module", return_value=hook_module), \ + mock.patch.object(self.module, "ensure_nmem_modules", return_value=(mock.Mock(), mock.Mock())), \ + mock.patch.object(self.module, "iter_codex_rollouts", return_value=[]), \ + mock.patch("sys.argv", ["refresh_thread_titles.py"]): + result = self.module.main() + + self.assertEqual(result, 0) + hook_module.configure_nmem_env.assert_called_once_with() + def test_main_counts_generic_refresh_errors(self): temp_dir = tempfile.TemporaryDirectory() self.addCleanup(temp_dir.cleanup) @@ -538,7 +609,7 @@ def test_main_counts_generic_refresh_errors(self): mock.patch("builtins.print") as print_mock: result = self.module.main() - self.assertEqual(result, 0) + self.assertNotEqual(result, 0) printed_lines = [" ".join(str(arg) for arg in call.args) for call in print_mock.call_args_list] self.assertTrue(any("ERROR refresh codex-thread-1: boom" in line for line in printed_lines)) self.assertTrue(any('"errors": 1' in line for line in printed_lines))