From 90c3a331aa5a987806c87545c319df15c6d1cd27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=BA=E8=B6=8A?= Date: Mon, 29 Jun 2026 17:07:01 +0800 Subject: [PATCH] feat(codex): add native hooks and project install --- .claude-plugin/plugin.json | 2 +- .codex/hooks.json | 15 +- .release-please-manifest.json | 2 +- CHANGELOG.md | 21 ++ README.md | 2 +- README_ZH.md | 2 +- pyproject.toml | 2 +- repo_pages/api/cli.md | 18 +- repo_pages/guide/codex.md | 115 ++++--- repo_pages/guide/installation.md | 2 +- repo_pages/guide/mcp-integration.md | 2 +- repo_pages/index.md | 2 +- repo_pages/public/llms.txt | 2 +- repo_pages/quick-start.md | 2 +- repo_pages/zh/api/cli.md | 16 +- repo_pages/zh/guide/codex.md | 108 +++++-- repo_pages/zh/guide/installation.md | 2 +- repo_pages/zh/guide/mcp-integration.md | 2 +- repo_pages/zh/index.md | 2 +- repo_pages/zh/quick-start.md | 2 +- .../design/codex-native-integration-design.md | 118 ++++++++ src/hebb/__init__.py | 2 +- src/hebb/cli/commands/doctor.py | 2 +- src/hebb/cli/commands/setup.py | 2 +- src/hebb/integrations/codex/cli.py | 76 +++-- src/hebb/integrations/codex/install.py | 280 ++++++++++++++++++ src/hebb/integrations/codex/recall.py | 17 ++ src/hebb/integrations/codex/stop.py | 143 +++++++++ src/hebb/integrations/codex/transcript.py | 173 +++++++++++ src/hebb/integrations/codex/uninstall.py | 109 +++++++ src/hebb/upgrade/helper.py | 2 +- tests/unit/integrations/test_codex_cli.py | 41 ++- tests/unit/integrations/test_codex_hooks.py | 232 +++++++++++++++ 33 files changed, 1381 insertions(+), 137 deletions(-) create mode 100644 reports/design/codex-native-integration-design.md create mode 100644 src/hebb/integrations/codex/install.py create mode 100644 src/hebb/integrations/codex/recall.py create mode 100644 src/hebb/integrations/codex/stop.py create mode 100644 src/hebb/integrations/codex/transcript.py create mode 100644 src/hebb/integrations/codex/uninstall.py create mode 100644 tests/unit/integrations/test_codex_hooks.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7a6b79a..0a2d876 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hebb-mind", - "version": "0.2.1", + "version": "0.3.0", "description": "Neuroscience-inspired long-term memory for Claude Code — auto-captures and recalls cross-session context", "author": "afx-team", "homepage": "https://github.com/afx-team/hebb-mind", diff --git a/.codex/hooks.json b/.codex/hooks.json index 7faddce..8c59393 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -6,8 +6,9 @@ "hooks": [ { "type": "command", - "command": "hebb claude-code recall", - "timeout": 30 + "command": "hebb codex recall", + "timeout": 30, + "statusMessage": "Recalling Hebb Mind context" } ] } @@ -17,8 +18,9 @@ "hooks": [ { "type": "command", - "command": "hebb claude-code prompt", - "timeout": 10 + "command": "hebb codex prompt", + "timeout": 10, + "statusMessage": "Searching Hebb Mind" } ] } @@ -28,8 +30,9 @@ "hooks": [ { "type": "command", - "command": "hebb claude-code stop", - "timeout": 30 + "command": "hebb codex stop", + "timeout": 30, + "statusMessage": "Saving turn to Hebb Mind" } ] } diff --git a/.release-please-manifest.json b/.release-please-manifest.json index af55ef0..0ee8c01 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.2.1" + ".": "0.3.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa0d2e..53bd004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 3. Merge to main — publish.yml ships to PyPI on the pyproject.toml change and tags the release. --> +## [0.3.0] - 2026-06-29 + +### Added + +- **Codex-native memory integration**: `hebb codex install` now installs native + Codex MCP plus lifecycle hooks. Project scope is the default and writes + `.codex/config.toml` and `.codex/hooks.json`; `--scope user` registers MCP + through `codex mcp add` and writes user-level hooks. +- **Codex lifecycle hooks**: `SessionStart` and `UserPromptSubmit` recall + relevant memories, while `Stop` captures the completed Codex turn into Hebb + Mind with Codex-specific metadata. +- **Codex transcript parser**: rollout JSONL parsing now extracts the latest + user/assistant turn, tool calls, MCP calls, timestamps, and turn index for + reliable Stop-hook ingestion. + +### Documentation + +- Refreshed Codex public docs, quick starts, MCP integration notes, and CLI + reference in English and Chinese to document project-level install, user-level + install, hook trust flow, and the `--scope` behavior. + ## [0.2.1] - 2026-06-24 ### Added diff --git a/README.md b/README.md index 110111e..b144fb7 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ pipx install hebb-mind # recommended (isolated CLI install) pipx install 'hebb-mind[pg]' # + PostgreSQL/pgvector pipx upgrade hebb-mind # upgrade later hebb claude-code install --scope user # Claude Code: hooks-based recall + turn capture -hebb codex install --scope user # Codex: MCP memory tools +hebb codex install # Codex: project MCP + automatic memory hooks ``` Docker, one-line install, and source build: [Installation Guide](https://afx-team.github.io/hebb-mind/guide/installation.html). diff --git a/README_ZH.md b/README_ZH.md index 365a894..6ade4b8 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -105,7 +105,7 @@ pipx install hebb-mind # 推荐方式(隔离的 CLI 安装) pipx install 'hebb-mind[pg]' # 启用 PostgreSQL/pgvector pipx upgrade hebb-mind # 后续升级 hebb claude-code install --scope user # Claude Code:基于 hooks 的召回 + 回合写入 -hebb codex install --scope user # Codex:MCP 记忆工具 +hebb codex install # Codex:项目级 MCP + 自动记忆 hooks ``` Docker、一键脚本、源码安装详见 [安装指南](https://afx-team.github.io/hebb-mind/zh/guide/installation.html)。 diff --git a/pyproject.toml b/pyproject.toml index 194f356..1d623b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ name = "hebb-mind" # with src/hebb/__init__.py, .release-please-manifest.json, and # .claude-plugin/plugin.json. publish.yml ships to PyPI when this version # changes on main. -version = "0.2.1" +version = "0.3.0" description = "Hebb Mind — neuroscience-inspired long-term memory framework for AI agents (neurons that fire together, wire together)" readme = "README.md" license = {text = "MIT"} diff --git a/repo_pages/api/cli.md b/repo_pages/api/cli.md index ca96c5f..f25b69c 100644 --- a/repo_pages/api/cli.md +++ b/repo_pages/api/cli.md @@ -189,14 +189,24 @@ The hooks are recall-and-capture, not consolidation. `recall` and `prompt` **rea ## hebb codex -Codex CLI integration via `codex mcp add`/`remove`. Codex registers MCP servers globally — there is no per-project scope, so only `--scope user` (global) is supported. +Native Codex integration with MCP plus `SessionStart`, `UserPromptSubmit`, +and `Stop` hooks. Project scope writes `.codex/config.toml` and +`.codex/hooks.json`; user scope uses `codex mcp add` and +`~/.codex/hooks.json`. ```bash -hebb codex install [--scope user] # default: user (global-only) -hebb codex uninstall +hebb codex install [--scope project|user] # default: project; current project +hebb codex uninstall [--scope project|user] # default: project; current project +hebb codex recall # SessionStart hook entry point +hebb codex prompt # UserPromptSubmit hook entry point +hebb codex stop # Stop hook entry point ``` -Verify with `codex mcp list`. +`--scope user` means user-level configuration for the current OS user and +applies to all of that user's Codex projects. Omitting it is equivalent to +`--scope project`, which writes only the current directory's `.codex/`. + +Verify MCP with `codex mcp list` and review hooks with `/hooks`. ## hebb config diff --git a/repo_pages/guide/codex.md b/repo_pages/guide/codex.md index 1a6db7f..af649ec 100644 --- a/repo_pages/guide/codex.md +++ b/repo_pages/guide/codex.md @@ -1,73 +1,122 @@ --- -description: "Give the Codex CLI persistent AI agent memory via MCP: install the hebb-mcp server so Codex can write, search, and consolidate memories across coding sessions." +description: "Give Codex automatic persistent memory with native lifecycle hooks and MCP: project-scoped recall, turn capture, search, write, and consolidation." --- # Codex Integration -Hebb Mind integrates with Codex through MCP tools. Codex can call `write_memory`, `search_memory`, `consolidate`, and `ingest_conversation` when useful. +Hebb Mind integrates with Codex through native lifecycle hooks and MCP. +`SessionStart` recalls cross-session context, `UserPromptSubmit` searches for +prompt-relevant memories, and `Stop` captures the completed turn. MCP also +exposes `write_memory`, `search_memory`, `consolidate`, and +`ingest_conversation` for explicit operations. ## Prerequisites -- **Python >= 3.10** and `pipx` (or a venv) — see [Installation](./installation.md). -- The **`codex` CLI** must be on your `PATH`. `hebb codex install` registers the MCP server by running `codex mcp add`; without the `codex` CLI it cannot complete. +- The **`codex` CLI** must be on `PATH`. +- Install and initialize Hebb Mind. +- Install the background service because hooks and MCP forward requests to + the local REST API. -## Install +## Project installation + +Run this from the repository you want to equip with memory: ```bash -pipx install hebb-mind # use `pipx upgrade hebb-mind` to update later -hebb setup # initialize + download the embedding model -hebb service install # register + start the background service (MCP tools talk to it) -hebb codex install # register Hebb Mind as a Codex MCP server (global-only) +pipx install hebb-mind +hebb setup +hebb service install +hebb codex install ``` -No `pipx`? See [Installation → Install pipx](./installation.md#install-pipx-if-you-don-t-have-it). - -`hebb service install` is required: the MCP tools forward to the local Hebb Mind service on `127.0.0.1:8321`. Skip it and Codex's first memory tool call fails with an opaque connection error. +Project scope is the default. The installer writes: -Verify: +- `.codex/config.toml` — the project-scoped `hebb` MCP server +- `.codex/hooks.json` — automatic recall and turn-capture hooks -```bash -codex mcp list -``` +Existing unrelated Codex config and hooks are preserved. Re-running the +installer replaces only Hebb-managed entries. -## Use it in Codex +`--scope` controls where the integration is written: -Once installed, just talk to Codex naturally — it decides when to call the memory tools. Concrete prompts that exercise each tool: +| Command | Applies to | Writes | +|---|---|---| +| `hebb codex install` | Current project | `.codex/config.toml` and `.codex/hooks.json` | +| `hebb codex install --scope project` | Current project | Same as above | +| `hebb codex install --scope user` | Every Codex project for the current OS user | User-level Codex MCP config and `~/.codex/hooks.json` | -- **Store**: "Remember that I deploy with `make release` and prefer pnpm over npm." -- **Recall**: "What do you remember about how I deploy this project?" -- **Organize**: "Consolidate what you've learned about my preferences." +## Activate and verify -To nudge Codex toward durable memory, add project guidance (see [Capability Boundary](#capability-boundary) below). +Codex loads project configuration only for trusted projects, and command +hooks require explicit review. Start a new Codex thread in the project, then: -## Native Codex Command +```text +/hooks +``` -If you prefer to manage MCP servers directly, pass the **absolute path** to `hebb-mcp` (from `which hebb-mcp`) so it resolves regardless of how Codex is launched: +Review and trust the three Hebb hooks. Verify MCP from a terminal: ```bash -codex mcp add hebb -- "$(which hebb-mcp)" +codex mcp list ``` -For a remote Hebb Mind service, point `HEBB_URL` at the remote host: +## User-wide installation + +To make Hebb Mind available in every project: ```bash -codex mcp add hebb --env HEBB_URL=http://192.168.1.100:8321 -- "$(which hebb-mcp)" +hebb codex install --scope user ``` -## Capability Boundary +This registers MCP through `codex mcp add` and writes hooks to +`~/.codex/hooks.json`. User hooks also require review through `/hooks`. -Codex uses MCP tools for explicit memory operations. Claude Code has an additional hooks layer that recalls memories on session lifecycle events and captures each completed turn to the working-memory inbox. Codex does not currently provide that hooks flow through this integration, so with Codex you (or your project guidance) drive the memory tools explicitly. +## Lifecycle behavior -For best results, add project guidance that tells Codex when durable memory should be used: +| Event | Hebb command | Behavior | +|---|---|---| +| `SessionStart` | `hebb codex recall` | Adds recent cross-session context and preferences | +| `UserPromptSubmit` | `hebb codex prompt` | Adds memories relevant to the current prompt | +| `Stop` | `hebb codex stop` | Parses the Codex rollout and writes the completed turn | -```text -Use the Hebb Mind MCP server when durable user preferences, project facts, or cross-session decisions should be remembered or recalled. +Hook failures degrade to a no-op so a stopped Hebb service does not block +Codex. The next hook or MCP launch asks the installed OS service manager to +start Hebb Mind. + +## MCP tools + +You can still request explicit memory operations: + +- “Remember that this project uses pnpm, not npm.” +- “Search long-term memory for the authentication decision.” +- “Consolidate the memories collected today.” + +Project guidance in `AGENTS.md` can define which decisions should be stored, +but normal cross-session recall and turn capture no longer depend on the +model deciding to call an MCP tool. + +## Remote Hebb Mind service + +For a remote service, user-level MCP can still be registered directly: + +```bash +codex mcp add hebb \ + --env HEBB_URL=http://192.168.1.100:8321 \ + -- "$(which hebb-mcp)" ``` +If lifecycle hooks should use the same remote service, export `HEBB_URL` in +the environment that launches Codex. + ## Uninstall -Codex stores MCP servers globally, so uninstall is global-only (there is no per-project scope): +Remove the current project's integration: ```bash hebb codex uninstall ``` + +Remove the user-wide integration: + +```bash +hebb codex uninstall --scope user +``` diff --git a/repo_pages/guide/installation.md b/repo_pages/guide/installation.md index 8500369..d856dbd 100644 --- a/repo_pages/guide/installation.md +++ b/repo_pages/guide/installation.md @@ -111,5 +111,5 @@ See [Storage Backends](../advanced/storage-backends.md) for details. - [Configuration](./configuration.md) — full config reference - [Claude Code](./claude-code.md) — automatic cross-session memory for Claude Code -- [Codex](./codex.md) — MCP memory tools for Codex +- [Codex](./codex.md) — automatic memory hooks and MCP tools for Codex - [MCP Integration](./mcp-integration.md) — use Hebb Mind as MCP tools in any client diff --git a/repo_pages/guide/mcp-integration.md b/repo_pages/guide/mcp-integration.md index 5fd9151..6a32955 100644 --- a/repo_pages/guide/mcp-integration.md +++ b/repo_pages/guide/mcp-integration.md @@ -78,7 +78,7 @@ If the service runs on a non-default address, set the URL explicitly: Recommended: ```bash -hebb codex install # Codex registers MCP servers globally (global-only) +hebb codex install # project MCP + lifecycle hooks (default) codex mcp list ``` diff --git a/repo_pages/index.md b/repo_pages/index.md index 1131f8f..682bc52 100644 --- a/repo_pages/index.md +++ b/repo_pages/index.md @@ -37,7 +37,7 @@ features: details: Single-page app for memory CRUD, search, partitions, and graph view. Lives at http://localhost:8321/ — no separate deploy. - icon: 🔌 title: REST + MCP + Claude Code hooks - details: Three-line install gives Claude Code cross-session memory; hebb codex install adds the same as MCP tools. REST docs at /docs. + details: Three-line install gives Claude Code or Codex automatic cross-session recall and turn capture, backed by MCP memory tools. REST docs at /docs. ---
diff --git a/repo_pages/public/llms.txt b/repo_pages/public/llms.txt index 7ba87ce..6dfe69b 100644 --- a/repo_pages/public/llms.txt +++ b/repo_pages/public/llms.txt @@ -18,7 +18,7 @@ Headline benchmark results (reproducible via the `eval/` harness): LongMemEval r - [Configuration](https://afx-team.github.io/hebb-mind/guide/configuration.html): One `hebb.json` controls everything — CLI config commands, workspace resolution, embedding model, and LLM/consolidation setup. - [Switch the Embedding Model](https://afx-team.github.io/hebb-mind/guide/switch-embedding-model.html): Use local sentence-transformers or a LiteLLM API embedding provider, handle dimension changes, and re-embed with resume. - [Claude Code Integration](https://afx-team.github.io/hebb-mind/guide/claude-code.html): Give Claude Code cross-session memory via the MCP server plus session hooks for automatic recall, turn capture, and consolidation. -- [Codex Integration](https://afx-team.github.io/hebb-mind/guide/codex.html): Give the Codex CLI persistent memory over MCP via the `hebb-mcp` server. +- [Codex Integration](https://afx-team.github.io/hebb-mind/guide/codex.html): Give Codex automatic cross-session recall and turn capture with native hooks, plus explicit memory tools over MCP. - [MCP Integration](https://afx-team.github.io/hebb-mind/guide/mcp-integration.html): Connect Claude Code, Codex, Cursor, and Claude Desktop to long-term memory over MCP — write, recall, and consolidate. - [Web Console](https://afx-team.github.io/hebb-mind/guide/web-console.html): Browser UI bundled with the service (port 8321) — memory CRUD, hybrid search, partitions, a knowledge-graph view, and live config. - [Migration from mem0 / Letta / Zep](https://afx-team.github.io/hebb-mind/guide/migration.html): Concept and API mapping, before/after code, data import, and an honest gap analysis. diff --git a/repo_pages/quick-start.md b/repo_pages/quick-start.md index 7c26cec..9b8b914 100644 --- a/repo_pages/quick-start.md +++ b/repo_pages/quick-start.md @@ -155,7 +155,7 @@ For Docker, see [Storage Backends](./advanced/storage-backends.md#docker-deploym ```bash hebb claude-code install --scope user # Claude Code: hooks-based auto memory -hebb codex install # Codex: MCP memory tools (global-only) +hebb codex install # Codex: project MCP + automatic memory hooks codex mcp list # verify ``` diff --git a/repo_pages/zh/api/cli.md b/repo_pages/zh/api/cli.md index 73c26be..80d77cf 100644 --- a/repo_pages/zh/api/cli.md +++ b/repo_pages/zh/api/cli.md @@ -187,14 +187,22 @@ hebb claude-code stop # Stop 钩子:记录本轮对话 ## hebb codex -Codex CLI 集成(封装 `codex mcp add/remove`)。Codex 只在全局注册 MCP server,没有按项目区分的 scope,因此这两个命令均为全局生效。 +Codex 原生 MCP 与生命周期 hooks 集成。项目 scope 写入 +`.codex/config.toml` 和 `.codex/hooks.json`;用户 scope 通过 +`codex mcp add` 注册 MCP,并写入 `~/.codex/hooks.json`。 ```bash -hebb codex install # 仅支持全局(--scope user,默认且唯一取值) -hebb codex uninstall # 全局卸载 +hebb codex install [--scope project|user] # 默认 project;当前项目 +hebb codex uninstall [--scope project|user] # 默认 project;当前项目 +hebb codex recall # SessionStart hook 入口 +hebb codex prompt # UserPromptSubmit hook 入口 +hebb codex stop # Stop hook 入口 ``` -可通过 `codex mcp list` 验证。 +`--scope user` 表示当前 OS 用户级配置,对该用户的所有 Codex 项目生效; +不加时等价于 `--scope project`,只写当前目录的 `.codex/`。 + +通过 `codex mcp list` 验证 MCP,并在 Codex 中通过 `/hooks` 审核 hooks。 ## hebb config diff --git a/repo_pages/zh/guide/codex.md b/repo_pages/zh/guide/codex.md index 1e24258..c9b5fb4 100644 --- a/repo_pages/zh/guide/codex.md +++ b/repo_pages/zh/guide/codex.md @@ -1,74 +1,118 @@ --- -description: "通过 MCP 为 Codex CLI 接入持久记忆:注册 hebb-mcp 服务后,Codex 可在编码会话中自动存储、召回与巩固记忆,跨会话保留偏好与项目事实。" +description: "通过 Codex 原生生命周期 hooks 与 MCP 接入自动持久记忆:支持项目级召回、回合写入、搜索、存储和巩固。" --- # Codex 集成 -Hebb Mind 通过 MCP 工具集成 Codex。Codex 可以在需要时调用 `write_memory`、`search_memory`、`consolidate` 和 `ingest_conversation`。 +Hebb Mind 通过 Codex 原生生命周期 hooks 与 MCP 完成集成: +`SessionStart` 召回跨会话上下文,`UserPromptSubmit` 检索与当前提示相关的 +记忆,`Stop` 自动记录已完成的回合。MCP 另外提供 `write_memory`、 +`search_memory`、`consolidate` 和 `ingest_conversation` 显式操作。 ## 前提条件 -需要已安装 Codex CLI(`hebb codex install` 内部通过 `codex mcp add` 注册 MCP 服务)。可用 `codex --version` 确认。 +- `codex` CLI 已在 `PATH` 中。 +- Hebb Mind 已安装并初始化。 +- 已安装后台服务;hooks 和 MCP 会把请求转发到本地 REST API。 -## 安装 +## 项目级安装 + +在需要启用记忆的项目中执行: ```bash -pipx install hebb-mind # 后续升级用 `pipx upgrade hebb-mind` +pipx install hebb-mind hebb setup -hebb service install # 注册并启动后台服务(MCP 工具会访问它) +hebb service install hebb codex install ``` -::: warning 务必先 `hebb service install` -Codex 里的 MCP 工具会把请求 POST 到本地的 `127.0.0.1:8321` 服务。如果跳过 `hebb service install`,第一次让 Codex 记东西时,工具调用会以一个不透明的连接错误失败 —— 而提示「Run: hebb service install」只打在 MCP 服务的 stderr 里,Codex 界面上看不到。 -::: +默认 scope 是 `project`。安装器写入: -没装 `pipx`?参考 [安装 → 如果还没装 pipx](./installation.md#如果还没装-pipx)。 +- `.codex/config.toml`:项目级 `hebb` MCP server +- `.codex/hooks.json`:自动召回与回合写入 hooks -验证: +已有的其他 Codex 配置和 hooks 会被保留。重复安装只替换 Hebb Mind +管理的条目。 -```bash -codex mcp list -``` +`--scope` 控制配置写到哪里: + +| 命令 | 生效范围 | 写入位置 | +|---|---|---| +| `hebb codex install` | 当前项目 | `.codex/config.toml` 和 `.codex/hooks.json` | +| `hebb codex install --scope project` | 当前项目 | 同上 | +| `hebb codex install --scope user` | 当前 OS 用户的所有 Codex 项目 | Codex 用户级 MCP 配置和 `~/.codex/hooks.json` | -## 作用域 +## 激活与验证 -Codex 通过 `codex mcp add` **全局**注册 MCP 服务,没有按项目的作用域,因此本命令是全局唯一的(`--scope` 只接受 `user`,且为默认值)。 +Codex 只会在可信项目中加载项目配置,同时命令 hooks 必须经过显式审核。 +在该项目中新建 Codex thread,然后执行: -## Codex 原生命令 +```text +/hooks +``` -如果希望直接管理 MCP server,请填 `hebb-mcp` 的**绝对路径**(GUI / launchd 下 `PATH` 往往不含 pipx 的 bin 目录,裸命令会静默启动失败)。先用 `which hebb-mcp`(Windows:`where hebb-mcp`)查出路径: +审核并信任三个 Hebb hooks。随后在终端验证 MCP: ```bash -codex mcp add hebb -- "$(which hebb-mcp)" +codex mcp list ``` -远程 Hebb Mind 服务: +## 用户级安装 + +如需在所有项目中启用 Hebb Mind: ```bash -codex mcp add hebb --env HEBB_URL=http://192.168.1.100:8321 -- "$(which hebb-mcp)" +hebb codex install --scope user ``` -## 在 Codex 中使用 +该命令通过 `codex mcp add` 注册 MCP,并把 hooks 写入 +`~/.codex/hooks.json`。用户级 hooks 同样需要通过 `/hooks` 审核。 -装好后,在 Codex 对话里用自然语言即可触发记忆工具,无需手写 API 调用。例如: +## 生命周期行为 -- **存储**:「记住这个项目用 pnpm,不用 npm。」 -- **召回**:「这个项目的包管理器是什么?」 -- **整理**:「把刚才这些记忆巩固一下。」 +| 事件 | Hebb 命令 | 行为 | +|---|---|---| +| `SessionStart` | `hebb codex recall` | 注入最近的跨会话上下文和偏好 | +| `UserPromptSubmit` | `hebb codex prompt` | 注入与当前提示相关的记忆 | +| `Stop` | `hebb codex stop` | 解析 Codex rollout 并写入已完成回合 | -为了让 Codex 知道**何时**该动用长期记忆,建议在项目说明里加一句指引: +Hook 失败时会退化为空操作,因此 Hebb 服务异常不会阻断 Codex。下一次 +hook 或 MCP 启动时会请求已安装的系统服务管理器启动 Hebb Mind。 -```text -当需要记住或召回持久的用户偏好、项目事实或跨会话决策时,使用 Hebb Mind MCP server。 -``` +## MCP 工具 + +仍然可以显式要求 Codex 操作记忆: -## 能力边界 +- “记住这个项目使用 pnpm,不使用 npm。” +- “从长期记忆中查找认证方案的决策。” +- “巩固今天收集的记忆。” -Codex 通过 MCP 工具进行显式记忆操作。Claude Code 额外支持 hooks,可以在会话生命周期中自动召回记忆、并在回合结束时写入。Codex 当前没有同等的 hooks 流程。 +可以在 `AGENTS.md` 中规定哪些决策需要持久保存,但常规跨会话召回和 +回合写入已经不再依赖模型主动决定调用 MCP。 + +## 远程 Hebb Mind 服务 + +远程服务可以直接注册为用户级 MCP: + +```bash +codex mcp add hebb \ + --env HEBB_URL=http://192.168.1.100:8321 \ + -- "$(which hebb-mcp)" +``` + +如需 hooks 使用同一远程服务,还需在启动 Codex 的环境中导出 +`HEBB_URL`。 ## 卸载 +卸载当前项目集成: + ```bash hebb codex uninstall ``` + +卸载用户级集成: + +```bash +hebb codex uninstall --scope user +``` diff --git a/repo_pages/zh/guide/installation.md b/repo_pages/zh/guide/installation.md index ccd86f9..103861d 100644 --- a/repo_pages/zh/guide/installation.md +++ b/repo_pages/zh/guide/installation.md @@ -111,5 +111,5 @@ hebb config set pg_url postgresql://user:pass@localhost/hebb - [配置](./configuration.md) — 完整配置项说明 - [Claude Code](./claude-code.md) — Claude Code 跨会话自动记忆 -- [Codex](./codex.md) — Codex MCP 记忆工具 +- [Codex](./codex.md) — Codex 自动记忆 hooks 与 MCP 工具 - [MCP 集成](./mcp-integration.md) — 在任意 MCP 客户端中使用 Hebb Mind diff --git a/repo_pages/zh/guide/mcp-integration.md b/repo_pages/zh/guide/mcp-integration.md index a7bd9f7..a99a8b3 100644 --- a/repo_pages/zh/guide/mcp-integration.md +++ b/repo_pages/zh/guide/mcp-integration.md @@ -78,7 +78,7 @@ hebb claude-code install --scope user 推荐: ```bash -hebb codex install # Codex 全局注册 MCP 服务(仅全局) +hebb codex install # 项目级 MCP + 生命周期 hooks(默认) codex mcp list ``` diff --git a/repo_pages/zh/index.md b/repo_pages/zh/index.md index 0153492..c29490a 100644 --- a/repo_pages/zh/index.md +++ b/repo_pages/zh/index.md @@ -37,7 +37,7 @@ features: details: 单页应用,覆盖记忆 CRUD、检索、分区、图谱视图。直接位于 http://localhost:8321/,无需另行部署。 - icon: 🔌 title: REST + MCP + Claude Code Hooks - details: 三行命令为 Claude Code 启用跨会话记忆;hebb codex install 一键将能力以 MCP 工具形式接入 Codex。REST 文档位于 /docs。 + details: 三行命令为 Claude Code 或 Codex 启用自动跨会话召回、回合写入与 MCP 记忆工具。REST 文档位于 /docs。 ---
diff --git a/repo_pages/zh/quick-start.md b/repo_pages/zh/quick-start.md index 684569d..c150612 100644 --- a/repo_pages/zh/quick-start.md +++ b/repo_pages/zh/quick-start.md @@ -158,7 +158,7 @@ Docker 部署见 [存储后端](./advanced/storage-backends.md#docker-deployment ```bash hebb claude-code install --scope user # Claude Code:hooks 自动记忆 -hebb codex install # Codex:MCP 记忆工具(仅全局) +hebb codex install # Codex:项目级 MCP + 自动记忆 hooks codex mcp list # 验证 ``` diff --git a/reports/design/codex-native-integration-design.md b/reports/design/codex-native-integration-design.md new file mode 100644 index 0000000..a9a6187 --- /dev/null +++ b/reports/design/codex-native-integration-design.md @@ -0,0 +1,118 @@ +# Codex Native Integration Design + +## Problem + +Hebb Mind's Codex integration currently registers only the `hebb-mcp` +STDIO server in the user-level Codex configuration. It does not install +Codex lifecycle hooks, does not support project-scoped MCP configuration, +and routes the repository-local Codex hooks through Claude Code commands. +The `Stop` hook then attempts to parse Codex rollout JSONL with the Claude +Code transcript parser, whose schema is incompatible. + +Codex now provides native project configuration, repository and user hook +layers, and the `SessionStart`, `UserPromptSubmit`, and `Stop` lifecycle +events. Hebb Mind should use those surfaces directly so memory recall and +capture work without relying on model-initiated MCP calls alone. + +## Solution + +### Installation surfaces + +`hebb codex install` will support two scopes: + +- `project` (default): write `.codex/config.toml` and + `.codex/hooks.json` in the current project. +- `user`: keep using `codex mcp add` for the user-level MCP registration + and write `~/.codex/hooks.json` for lifecycle hooks. + +The project TOML editor owns only the `[mcp_servers.hebb]` table. It +preserves unrelated configuration and replaces an existing Hebb table +idempotently. The hook editor removes only Hebb-managed commands, preserves +other hooks, and installs absolute command paths. + +```mermaid +flowchart LR + Install[hebb codex install] --> Scope{Scope} + Scope -->|project| ProjectConfig[.codex/config.toml] + Scope -->|project| ProjectHooks[.codex/hooks.json] + Scope -->|user| CodexCLI[codex mcp add] + Scope -->|user| UserHooks[~/.codex/hooks.json] +``` + +### Native lifecycle commands + +The Codex command group will expose dedicated hook entry points: + +- `hebb codex recall` for `SessionStart` +- `hebb codex prompt` for `UserPromptSubmit` +- `hebb codex stop` for `Stop` + +Recall behavior can share the existing search pipeline because the Codex +and Claude hook inputs both provide `session_id`, `transcript_path`, `cwd`, +and `prompt` where applicable. The public command and module boundaries +remain Codex-specific. + +### Codex transcript parsing + +A new Codex parser will read rollout JSONL records: + +- `type=response_item`, `payload.type=message`, `payload.role=user` for + human prompts +- the corresponding assistant message records for response text +- `payload.type=function_call` for tool names + +The parser ignores developer/system records and malformed lines. It uses +the stable `last_assistant_message` supplied by the Codex `Stop` hook when +available, falling back to assistant transcript records otherwise. It +records a zero-based human turn index and the user record timestamp. + +```mermaid +sequenceDiagram + participant Codex + participant Hook as hebb codex stop + participant Parser as Codex Transcript Parser + participant API as Hebb REST API + Codex->>Hook: Stop JSON on stdin + Hook->>Parser: transcript_path + last_assistant_message + Parser-->>Hook: user, assistant, tools, turn, timestamp + Hook->>API: POST /api/v1/memories +``` + +## Trade-offs + +- User-scope MCP registration continues to use the official Codex CLI, + while project scope uses a narrow TOML editor because `codex mcp add` + does not expose a scope option. This creates two installation paths but + avoids rewriting arbitrary user TOML. +- Codex documents `transcript_path` as convenient but not stable. The + parser is therefore isolated behind a dedicated module and prefers the + stable `last_assistant_message` hook field. Fixture tests guard the + currently observed rollout schema. +- Project hooks require Codex project trust and separate hook review. The + installer cannot safely bypass that review, so it prints explicit + activation instructions. +- Existing Claude Code recall internals are reused to avoid premature + abstraction. If a third hook host is added, the shared recall pipeline + should move to a host-neutral module. + +## Implementation Plan + +1. Add Codex install/uninstall helpers for MCP and hook configuration. +2. Add dedicated Codex recall, prompt, and stop CLI commands. +3. Add the Codex rollout transcript parser and Stop memory writer. +4. Migrate the repository-local `.codex/hooks.json` to Codex commands. +5. Add parser, installer, hook writer, CLI, and distribution-contract + tests. +6. Update English and Chinese public documentation to describe project + scope, hook trust, automatic recall, and automatic turn capture. + +## Implications for Hebb Mind + +- Codex becomes a first-class lifecycle integration rather than an MCP-only + client. +- Project-local memory behavior can be committed and reviewed with the + repository while user-global behavior remains available. +- Host-specific transcript formats remain isolated, reducing the risk that + Codex schema changes break Claude Code capture or vice versa. +- Future Codex capabilities such as plugin packaging can reuse the native + hook commands without changing the memory storage contract. diff --git a/src/hebb/__init__.py b/src/hebb/__init__.py index f369cdc..34908cc 100644 --- a/src/hebb/__init__.py +++ b/src/hebb/__init__.py @@ -42,7 +42,7 @@ # Bumped MANUALLY on release (release-please automation removed). Keep in sync # with the version in pyproject.toml, .release-please-manifest.json, and # .claude-plugin/plugin.json. -__version__ = "0.2.1" +__version__ = "0.3.0" # ``HebbMind`` pulls in storage + embedding + graph + searcher, which # in turn import heavy third-party libs (sentence-transformers, litellm, diff --git a/src/hebb/cli/commands/doctor.py b/src/hebb/cli/commands/doctor.py index 9c0e6df..0857895 100644 --- a/src/hebb/cli/commands/doctor.py +++ b/src/hebb/cli/commands/doctor.py @@ -126,7 +126,7 @@ def _add_cli_check(table: Table, name: str, command: list[str]) -> None: table.add_row(f"{name} MCP", "[WARN]", str(exc)) return status = "[OK]" if result.returncode == 0 and "hebb" in result.stdout else "[WARN]" - install_cmd = "hebb claude-code install --scope user" if name == "claude" else "hebb codex install --scope user" + install_cmd = "hebb claude-code install --scope user" if name == "claude" else "hebb codex install" detail = "hebb configured" if status == "[OK]" else f"Run: {install_cmd}" table.add_row(f"{name} MCP", status, detail) diff --git a/src/hebb/cli/commands/setup.py b/src/hebb/cli/commands/setup.py index cb80b77..4ef4e17 100644 --- a/src/hebb/cli/commands/setup.py +++ b/src/hebb/cli/commands/setup.py @@ -91,7 +91,7 @@ def setup_cmd(ctx: click.Context, language: str, region: str, profile: str) -> N console.print(" Install background service: [cyan]hebb service install[/]") console.print(" Open Web Console: [cyan]hebb console[/]") console.print(" Claude Code setup: [cyan]hebb claude-code install --scope user[/]") - console.print(" Codex setup: [cyan]hebb codex install --scope user[/]") + console.print(" Codex project setup: [cyan]hebb codex install[/]") console.print(" Check health: [cyan]hebb doctor[/]") diff --git a/src/hebb/integrations/codex/cli.py b/src/hebb/integrations/codex/cli.py index a4a4504..9e11bff 100644 --- a/src/hebb/integrations/codex/cli.py +++ b/src/hebb/integrations/codex/cli.py @@ -3,67 +3,85 @@ from __future__ import annotations import shutil -import subprocess import click -from hebb.utils.cli_paths import hebb_mcp_command, shell_quote - @click.group("codex") def codex() -> None: - """Codex integration — configure Hebb Mind as an MCP server.""" + """Codex integration — native MCP and lifecycle hooks.""" @codex.command("install") @click.option( "--scope", - type=click.Choice(["user"]), - default="user", + type=click.Choice(["project", "user"]), + default="project", show_default=True, - help="Codex stores MCP servers globally in its config; only 'user' (global) is supported.", + help=( + "Where to install: 'project' writes this repo's .codex/ config; " + "'user' writes the current user's global Codex config for all projects." + ), ) def install(scope: str) -> None: - """Install Hebb Mind MCP into Codex. + """Install Hebb Mind MCP and lifecycle hooks into Codex. - Codex registers MCP servers globally via ``codex mcp add`` — there is no - per-project scope, so this command is global-only. + Project scope writes ``.codex/config.toml`` and ``.codex/hooks.json``. + User scope registers MCP through ``codex mcp add`` and writes the user + hooks file. """ _ensure_codex() - # Resolve absolute path to hebb-mcp — Codex launches the MCP server as a - # subprocess whose PATH may not include `pip install --user` bin dirs. - mcp_argv = hebb_mcp_command() - # Replace any prior entry so a fresh install picks up a moved binary. - subprocess.run(["codex", "mcp", "remove", "hebb"], capture_output=True, check=False) - result = subprocess.run(["codex", "mcp", "add", "hebb", "--", *mcp_argv], check=False) - if result.returncode != 0: - raise click.ClickException("codex mcp add failed") + from hebb.integrations.codex.install import handle - click.secho("Installed hebb MCP server for Codex.", fg="green") - click.echo(f" MCP: {shell_quote(mcp_argv)}") - click.echo("Verify with: codex mcp list") + handle(scope) @codex.command("uninstall") @click.option( "--scope", - type=click.Choice(["user"]), - default="user", + type=click.Choice(["project", "user"]), + default="project", show_default=True, - help="Codex stores MCP servers globally in its config; only 'user' (global) is supported.", + help=( + "Where to remove from: 'project' removes this repo's .codex/ config; " + "'user' removes the current user's global Codex config." + ), ) def uninstall(scope: str) -> None: - """Remove Hebb Mind MCP from Codex (global-only).""" + """Remove Hebb Mind MCP and lifecycle hooks from Codex.""" _ensure_codex() - result = subprocess.run(["codex", "mcp", "remove", "hebb"], check=False) - if result.returncode != 0: - raise click.ClickException("codex mcp remove failed") + from hebb.integrations.codex.uninstall import handle + + handle(scope) + + +@codex.command("recall") +def recall() -> None: + """Recall cross-session memories for a Codex SessionStart hook.""" + from hebb.integrations.codex.recall import handle_session_start + + handle_session_start() + + +@codex.command("prompt") +def prompt() -> None: + """Recall prompt-relevant memories for a UserPromptSubmit hook.""" + from hebb.integrations.codex.recall import handle_prompt + + handle_prompt() + + +@codex.command("stop") +def stop() -> None: + """Record the completed Codex turn from a Stop hook.""" + from hebb.integrations.codex.stop import handle - click.secho("Removed hebb MCP server from Codex.", fg="green") + handle() def _ensure_codex() -> None: + """Raise a user-facing error when the Codex CLI is unavailable.""" if not shutil.which("codex"): raise click.ClickException("codex CLI not found on PATH") diff --git a/src/hebb/integrations/codex/install.py b/src/hebb/integrations/codex/install.py new file mode 100644 index 0000000..79dae15 --- /dev/null +++ b/src/hebb/integrations/codex/install.py @@ -0,0 +1,280 @@ +"""Install Hebb Mind into Codex MCP and lifecycle configuration.""" + +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +import click + +from hebb.utils.cli_paths import hebb_command, hebb_mcp_command, shell_quote + + +def config_path(scope: str) -> Path: + """Resolve the Codex configuration path for an installation scope. + + Args: + scope: Either ``project`` or ``user``. + + Returns: + Path to the active ``config.toml`` layer. + + Raises: + ValueError: If the scope is unsupported. + """ + if scope == "project": + return Path.cwd() / ".codex" / "config.toml" + if scope == "user": + return Path.home() / ".codex" / "config.toml" + raise ValueError(f"Unsupported Codex scope: {scope}") + + +def hooks_path(scope: str) -> Path: + """Resolve the Codex hooks path for an installation scope. + + Args: + scope: Either ``project`` or ``user``. + + Returns: + Path to the active ``hooks.json`` layer. + """ + return config_path(scope).with_name("hooks.json") + + +def hooks_config() -> dict[str, list[dict[str, object]]]: + """Build Codex lifecycle hooks with an absolute Hebb command.""" + hebb = hebb_command() + return { + "SessionStart": [ + { + "matcher": "startup|resume|clear|compact", + "hooks": [ + { + "type": "command", + "command": shell_quote([*hebb, "codex", "recall"]), + "timeout": 30, + "statusMessage": "Recalling Hebb Mind context", + } + ], + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": shell_quote([*hebb, "codex", "prompt"]), + "timeout": 10, + "statusMessage": "Searching Hebb Mind", + } + ], + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": shell_quote([*hebb, "codex", "stop"]), + "timeout": 30, + "statusMessage": "Saving turn to Hebb Mind", + } + ], + } + ], + } + + +def is_hebb_hook(command: str) -> bool: + """Return whether a hook command belongs to Hebb Mind. + + Args: + command: Hook command string. + + Returns: + ``True`` for current Codex commands and legacy Claude-routed commands. + """ + return any( + marker in command + for marker in ( + "hebb codex ", + "/hebb codex ", + "hebb.cli.main codex ", + "hebb claude-code ", + "/hebb claude-code ", + "hebb.cli.main claude-code ", + ) + ) + + +def install_hooks(path: Path) -> None: + """Merge Hebb lifecycle hooks into a Codex hooks file. + + Args: + path: Target ``hooks.json`` path. + + Raises: + click.ClickException: If an existing hooks file is invalid. + """ + data: dict[str, Any] = {} + if path.exists(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise click.ClickException(f"Cannot read Codex hooks file: {path}") from exc + if not isinstance(loaded, dict): + raise click.ClickException(f"Codex hooks file must contain an object: {path}") + data = loaded + + raw_hooks = data.get("hooks", {}) + hooks: dict[str, Any] = raw_hooks if isinstance(raw_hooks, dict) else {} + remove_hebb_hooks(hooks) + for event, entries in hooks_config().items(): + existing = hooks.get(event, []) + if not isinstance(existing, list): + existing = [] + hooks[event] = [*existing, *entries] + data["hooks"] = hooks + atomic_write(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n") + + +def install_project_mcp(path: Path, mcp_argv: list[str]) -> None: + """Upsert the project-scoped Hebb MCP table in Codex TOML. + + Args: + path: Project ``.codex/config.toml`` path. + mcp_argv: Absolute command and arguments for the MCP server. + """ + existing = path.read_text(encoding="utf-8") if path.exists() else "" + cleaned = remove_project_mcp_table(existing).rstrip() + block = _mcp_toml(mcp_argv) + output = f"{cleaned}\n\n{block}" if cleaned else block + atomic_write(path, output) + + +def remove_project_mcp_table(text: str) -> str: + """Remove Hebb's MCP TOML table while preserving unrelated config. + + Args: + text: Existing Codex TOML source. + + Returns: + TOML source without ``mcp_servers.hebb`` tables. + """ + lines = text.splitlines(keepends=True) + output: list[str] = [] + skipping = False + for line in lines: + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + table = stripped.strip("[]").strip() + skipping = table == "mcp_servers.hebb" or table.startswith("mcp_servers.hebb.") + if not skipping: + output.append(line) + return "".join(output) + + +def handle(scope: str) -> None: + """Install Hebb MCP and hooks for Codex. + + Args: + scope: ``project`` or ``user``. + + Raises: + click.ClickException: If Codex rejects user-level MCP registration. + """ + mcp_argv = hebb_mcp_command() + target_hooks = hooks_path(scope) + + if scope == "user": + subprocess.run(["codex", "mcp", "remove", "hebb"], capture_output=True, check=False) + result = subprocess.run( + ["codex", "mcp", "add", "hebb", "--", *mcp_argv], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + detail = result.stderr.strip() or "codex mcp add failed" + raise click.ClickException(detail) + else: + install_project_mcp(config_path(scope), mcp_argv) + + install_hooks(target_hooks) + + click.secho(f"Installed Hebb Mind for Codex ({scope}).", fg="green") + if scope == "project": + click.echo(" Scope: project only; applies to this repository after Codex trusts it.") + click.echo(f" MCP: {config_path(scope)}") + else: + click.echo(" Scope: current user; applies to all Codex projects for this OS user.") + click.echo(" MCP: registered with `codex mcp add hebb`") + click.echo(f" Hooks: {target_hooks}") + click.echo(f" Server command: {shell_quote(mcp_argv)}") + click.echo("Verify MCP with: codex mcp list") + click.echo("Review and trust lifecycle hooks with: /hooks") + if scope == "project": + click.echo("Codex must trust this project before project configuration and hooks load.") + click.echo("Start a new Codex thread to activate the integration.") + + +def remove_hebb_hooks(hooks: dict[str, Any]) -> None: + """Remove Hebb-managed handlers from every hook event in place. + + Args: + hooks: Mutable Codex hook event mapping. + """ + for event in list(hooks): + entries = hooks.get(event) + if not isinstance(entries, list): + continue + kept_entries: list[dict[str, Any]] = [] + for entry in entries: + if not isinstance(entry, dict): + continue + handlers = entry.get("hooks") + if not isinstance(handlers, list): + continue + kept_handlers = [ + handler + for handler in handlers + if isinstance(handler, dict) and not is_hebb_hook(str(handler.get("command", ""))) + ] + if kept_handlers: + kept_entries.append({**entry, "hooks": kept_handlers}) + if kept_entries: + hooks[event] = kept_entries + else: + hooks.pop(event, None) + + +def atomic_write(path: Path, content: str) -> None: + """Atomically replace a UTF-8 text file, creating its parent. + + Args: + path: Destination file. + content: Complete replacement content. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(content) + os.replace(temp_name, path) + except Exception: + try: + os.unlink(temp_name) + except OSError: + pass + raise + + +def _mcp_toml(mcp_argv: list[str]) -> str: + """Render the Hebb MCP table as valid TOML.""" + command = json.dumps(mcp_argv[0], ensure_ascii=True) + args = json.dumps(mcp_argv[1:], ensure_ascii=True) + return f"[mcp_servers.hebb]\ncommand = {command}\nargs = {args}\n" diff --git a/src/hebb/integrations/codex/recall.py b/src/hebb/integrations/codex/recall.py new file mode 100644 index 0000000..320153f --- /dev/null +++ b/src/hebb/integrations/codex/recall.py @@ -0,0 +1,17 @@ +"""Codex SessionStart and UserPromptSubmit memory recall hooks.""" + +from __future__ import annotations + + +def handle_session_start() -> None: + """Recall general cross-session context for Codex SessionStart.""" + from hebb.integrations.claude_code.recall import handle + + handle() + + +def handle_prompt() -> None: + """Recall prompt-relevant context for Codex UserPromptSubmit.""" + from hebb.integrations.claude_code.recall import handle_prompt as recall_prompt + + recall_prompt() diff --git a/src/hebb/integrations/codex/stop.py b/src/hebb/integrations/codex/stop.py new file mode 100644 index 0000000..55d8567 --- /dev/null +++ b/src/hebb/integrations/codex/stop.py @@ -0,0 +1,143 @@ +"""Codex Stop hook — record the completed turn as one memory.""" + +from __future__ import annotations + +import logging + +import httpx + +from hebb.integrations._project import detect_project_name +from hebb.integrations.claude_code._client import ( + get_client, + read_hook_input, + resolve_session_id, +) +from hebb.integrations.claude_code.transcript import TurnSummary, format_turn_memory +from hebb.integrations.codex.transcript import extract_last_turn + +logger = logging.getLogger(__name__) + +_PARTITION = "mem_hippocampus" +_DEDUP_SCAN_LIMIT = 50 + + +def handle() -> None: + """Record the final Codex turn supplied to a ``Stop`` hook.""" + hook_input = read_hook_input() + transcript_path = hook_input.get("transcript_path") + if not isinstance(transcript_path, str) or not transcript_path: + return + + session_id = resolve_session_id(hook_input) + project = detect_project_name(hook_input.get("cwd")) + assistant = hook_input.get("last_assistant_message") + assistant_text = assistant if isinstance(assistant, str) else None + + try: + turn = extract_last_turn(transcript_path, last_assistant_message=assistant_text) + except (OSError, ValueError): + logger.debug("Codex transcript parsing failed", exc_info=True) + return + if turn is None: + return + + try: + client = get_client(timeout=30) + except Exception: + logger.debug("Could not connect to Hebb Mind service", exc_info=True) + return + + try: + _record_turn( + client, + turn.summary, + timestamp=turn.timestamp, + session_id=session_id, + turn_id=str(hook_input.get("turn_id", "") or ""), + project=project, + ) + finally: + client.close() + + +def _record_turn( + client: httpx.Client, + summary: TurnSummary, + *, + timestamp: str | None, + session_id: str, + turn_id: str, + project: str | None, +) -> None: + """Write one parsed Codex turn unless it was already captured. + + Args: + client: Connected Hebb Mind HTTP client. + summary: Parsed turn summary. + timestamp: User-message timestamp from the rollout. + session_id: Codex session identifier. + turn_id: Codex turn identifier. + project: Detected project tag. + """ + if summary.turn is not None and _already_written(client, session_id, summary.turn): + return + + content = format_turn_memory(summary, session_id=session_id, timestamp=timestamp) + metadata: dict[str, object] = { + "session_id": session_id, + "host": "codex", + "tools": summary.tools, + "mcps": summary.mcps, + } + if summary.turn is not None: + metadata["turn"] = summary.turn + if turn_id: + metadata["turn_id"] = turn_id + + try: + response = client.post( + "/api/v1/memories", + json={ + "content": content, + "partition_id": _PARTITION, + "importance_score": 4.0, + "tags": [project] if project else [], + "metadata": metadata, + "source": "hook:codex-stop", + }, + ) + response.raise_for_status() + except Exception: + logger.debug("Codex turn memory write failed", exc_info=True) + + +def _already_written(client: httpx.Client, session_id: str, turn: int) -> bool: + """Return whether a session/turn pair already exists. + + Args: + client: Connected Hebb Mind HTTP client. + session_id: Codex session identifier. + turn: Zero-based human turn index. + + Returns: + ``True`` when a matching recent memory exists. + """ + if not session_id: + return False + try: + response = client.get( + "/api/v1/memories", + params={"partition_id": _PARTITION, "limit": _DEDUP_SCAN_LIMIT}, + ) + response.raise_for_status() + items = response.json().get("items", []) + except Exception: + logger.debug("Codex duplicate-turn check failed", exc_info=True) + return False + return any( + isinstance(item, dict) + and isinstance(item.get("metadata"), dict) + and item["metadata"].get("session_id") == session_id + and item["metadata"].get("turn") == turn + for item in items + ) diff --git a/src/hebb/integrations/codex/transcript.py b/src/hebb/integrations/codex/transcript.py new file mode 100644 index 0000000..c00922b --- /dev/null +++ b/src/hebb/integrations/codex/transcript.py @@ -0,0 +1,173 @@ +"""Parse Codex rollout JSONL into memory-ready turn summaries.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from hebb.ingest.noise import clean_user_input, is_greeting_only +from hebb.integrations.claude_code.transcript import TurnSummary + +logger = logging.getLogger(__name__) + +_MAX_USER_LEN = 500 +_MAX_ASSISTANT_LEN = 800 +_MIN_USER_LEN = 10 + + +@dataclass +class CodexTurn: + """A parsed Codex turn and its source timestamp.""" + + summary: TurnSummary + timestamp: str | None = None + + +def extract_last_turn( + transcript_path: str | Path, + *, + last_assistant_message: str | None = None, +) -> CodexTurn | None: + """Extract the final user-to-assistant turn from a Codex rollout. + + Args: + transcript_path: Path to a Codex rollout JSONL file. + last_assistant_message: Stable assistant text supplied by the Codex + ``Stop`` hook. When present, it takes precedence over transcript + assistant records. + + Returns: + Parsed turn with timestamp, or ``None`` when no complete turn exists. + + Raises: + OSError: If the transcript cannot be read. + """ + records = _load_records(Path(transcript_path)) + user_indices = [index for index, record in enumerate(records) if _raw_user_text(record)] + if not user_indices: + return None + + user_index = user_indices[-1] + user_record = records[user_index] + user_text = _clean_user_text(_raw_user_text(user_record)) + if not user_text: + return None + + trailing = records[user_index + 1 :] + assistant_text = _normalize_assistant(last_assistant_message or "") + if not assistant_text: + for record in reversed(trailing): + candidate = _raw_assistant_text(record) + if candidate: + assistant_text = _normalize_assistant(candidate) + break + if not assistant_text: + return None + + tools: list[str] = [] + mcps: list[str] = [] + for record in trailing: + name = _tool_name(record) + if not name: + continue + if name.startswith("mcp__"): + mcps.append(name) + else: + tools.append(name) + + summary = TurnSummary( + user_input=user_text, + assistant_output=assistant_text, + tools=_dedup(tools), + mcps=_dedup(mcps), + turn=len(user_indices) - 1, + ) + timestamp = user_record.get("timestamp") + return CodexTurn(summary=summary, timestamp=timestamp if isinstance(timestamp, str) else None) + + +def _load_records(path: Path) -> list[dict[str, Any]]: + """Load valid JSON object records from a rollout file.""" + records: list[dict[str, Any]] = [] + with path.open(encoding="utf-8") as stream: + for line in stream: + try: + value = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + records.append(value) + return records + + +def _payload(record: dict[str, Any]) -> dict[str, Any]: + """Return a record payload as a mapping.""" + payload = record.get("payload") + return payload if isinstance(payload, dict) else {} + + +def _message_text(record: dict[str, Any], *, role: str, block_type: str) -> str: + """Extract text blocks from a Codex response-item message.""" + if record.get("type") != "response_item": + return "" + payload = _payload(record) + if payload.get("type") != "message" or payload.get("role") != role: + return "" + content = payload.get("content") + if not isinstance(content, list): + return "" + texts = [ + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == block_type and isinstance(block.get("text"), str) + ] + return "\n".join(texts).strip() + + +def _raw_user_text(record: dict[str, Any]) -> str: + """Return human input text from a Codex message record.""" + return _message_text(record, role="user", block_type="input_text") + + +def _raw_assistant_text(record: dict[str, Any]) -> str: + """Return assistant output text from a Codex message record.""" + return _message_text(record, role="assistant", block_type="output_text") + + +def _tool_name(record: dict[str, Any]) -> str: + """Return a function-call name from a Codex response item.""" + if record.get("type") != "response_item": + return "" + payload = _payload(record) + if payload.get("type") != "function_call": + return "" + name = payload.get("name") + return name if isinstance(name, str) else "" + + +def _clean_user_text(raw: str) -> str: + """Apply Hebb's storage filter and length bound to Codex user text.""" + cleaned = clean_user_input(raw) + if not cleaned: + return "" + if not is_greeting_only(cleaned) and len(cleaned) < _MIN_USER_LEN: + return "" + return _truncate(cleaned, _MAX_USER_LEN) + + +def _normalize_assistant(raw: str) -> str: + """Normalize and bound assistant output.""" + return _truncate(raw.strip(), _MAX_ASSISTANT_LEN) + + +def _truncate(value: str, limit: int) -> str: + """Truncate text with a visible ellipsis.""" + return value if len(value) <= limit else value[:limit] + "…" + + +def _dedup(items: list[str]) -> list[str]: + """Remove duplicate strings while preserving order.""" + return list(dict.fromkeys(items)) diff --git a/src/hebb/integrations/codex/uninstall.py b/src/hebb/integrations/codex/uninstall.py new file mode 100644 index 0000000..c4ae10e --- /dev/null +++ b/src/hebb/integrations/codex/uninstall.py @@ -0,0 +1,109 @@ +"""Remove Hebb Mind from Codex MCP and lifecycle configuration.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +import click + +from hebb.integrations.codex.install import ( + atomic_write, + config_path, + hooks_path, + remove_hebb_hooks, + remove_project_mcp_table, +) + + +def uninstall_hooks(path: Path) -> bool: + """Remove Hebb handlers from a Codex hooks file. + + Args: + path: Target ``hooks.json`` path. + + Returns: + Whether the file changed. + + Raises: + click.ClickException: If the hooks file cannot be parsed. + """ + if not path.exists(): + return False + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise click.ClickException(f"Cannot read Codex hooks file: {path}") from exc + if not isinstance(data, dict): + raise click.ClickException(f"Codex hooks file must contain an object: {path}") + + raw_hooks = data.get("hooks") + if not isinstance(raw_hooks, dict): + return False + before = json.dumps(raw_hooks, sort_keys=True) + hooks: dict[str, Any] = raw_hooks + remove_hebb_hooks(hooks) + if json.dumps(hooks, sort_keys=True) == before: + return False + if hooks: + data["hooks"] = hooks + else: + data.pop("hooks", None) + atomic_write(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n") + return True + + +def uninstall_project_mcp(path: Path) -> bool: + """Remove project-scoped Hebb MCP configuration. + + Args: + path: Project ``config.toml`` path. + + Returns: + Whether the file changed. + """ + if not path.exists(): + return False + original = path.read_text(encoding="utf-8") + updated = remove_project_mcp_table(original) + if updated == original: + return False + atomic_write(path, updated.rstrip() + ("\n" if updated.strip() else "")) + return True + + +def handle(scope: str) -> None: + """Remove Hebb MCP and hooks for a Codex scope. + + Args: + scope: ``project`` or ``user``. + + Raises: + click.ClickException: If Codex rejects user-level MCP removal. + """ + changed = uninstall_hooks(hooks_path(scope)) + if scope == "user": + result = subprocess.run( + ["codex", "mcp", "remove", "hebb"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0 and "not found" not in result.stderr.lower(): + detail = result.stderr.strip() or "codex mcp remove failed" + raise click.ClickException(detail) + changed = result.returncode == 0 or changed + else: + changed = uninstall_project_mcp(config_path(scope)) or changed + + if changed: + click.secho(f"Removed Hebb Mind from Codex ({scope}).", fg="green") + else: + click.echo(f"Hebb Mind was not configured for Codex ({scope}).") + if scope == "project": + click.echo("Scope was project only; user-wide Codex config was not changed.") + else: + click.echo("Scope was current user; project-local .codex/ files were not changed.") + click.echo("Start a new Codex thread to apply the change.") diff --git a/src/hebb/upgrade/helper.py b/src/hebb/upgrade/helper.py index c94b50d..7290152 100644 --- a/src/hebb/upgrade/helper.py +++ b/src/hebb/upgrade/helper.py @@ -56,7 +56,7 @@ def _terminate_parent(pid: int, grace: float) -> None: time.sleep(0.5) # SIGKILL does not exist on Windows (signal module has only SIGTERM there); # referencing it would raise AttributeError before the loop even starts. - signals = [signal.SIGTERM] + signals: list[signal.Signals] = [signal.SIGTERM] if os.name != "nt" and hasattr(signal, "SIGKILL"): signals.append(signal.SIGKILL) for sig in signals: diff --git a/tests/unit/integrations/test_codex_cli.py b/tests/unit/integrations/test_codex_cli.py index cb41ed8..56e5211 100644 --- a/tests/unit/integrations/test_codex_cli.py +++ b/tests/unit/integrations/test_codex_cli.py @@ -3,43 +3,62 @@ from __future__ import annotations import subprocess +from pathlib import Path from click.testing import CliRunner +from hebb.integrations.codex import install as install_module +from hebb.integrations.codex import uninstall as uninstall_module from hebb.integrations.codex.cli import codex -def test_codex_install_runs_mcp_add(monkeypatch) -> None: +def test_codex_user_install_runs_mcp_add(monkeypatch, tmp_path: Path) -> None: calls: list[list[str]] = [] def fake_run(args: list[str], check: bool = False, **kwargs) -> subprocess.CompletedProcess[str]: calls.append(args) - return subprocess.CompletedProcess(args=args, returncode=0) + return subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="") monkeypatch.setattr("hebb.integrations.codex.cli.shutil.which", lambda name: f"/bin/{name}") - monkeypatch.setattr("hebb.integrations.codex.cli.subprocess.run", fake_run) + monkeypatch.setattr(install_module, "hebb_command", lambda: ["/bin/hebb"]) + monkeypatch.setattr(install_module, "hebb_mcp_command", lambda: ["/bin/hebb-mcp"]) + monkeypatch.setattr(install_module, "config_path", lambda scope: tmp_path / "config.toml") + monkeypatch.setattr(install_module, "hooks_path", lambda scope: tmp_path / "hooks.json") + monkeypatch.setattr(install_module.subprocess, "run", fake_run) result = CliRunner().invoke(codex, ["install", "--scope", "user"]) assert result.exit_code == 0, result.output - # Codex install resolves hebb-mcp to an absolute path before handing it to - # `codex mcp add`. It also pre-removes any prior entry so the install is - # idempotent / picks up a moved binary. assert calls[0] == ["codex", "mcp", "remove", "hebb"] assert calls[1] == ["codex", "mcp", "add", "hebb", "--", "/bin/hebb-mcp"] + assert "hebb codex recall" in (tmp_path / "hooks.json").read_text() -def test_codex_uninstall_runs_mcp_remove(monkeypatch) -> None: +def test_codex_user_uninstall_runs_mcp_remove(monkeypatch, tmp_path: Path) -> None: calls: list[list[str]] = [] - def fake_run(args: list[str], check: bool = False) -> subprocess.CompletedProcess[str]: + def fake_run(args: list[str], check: bool = False, **kwargs) -> subprocess.CompletedProcess[str]: calls.append(args) - return subprocess.CompletedProcess(args=args, returncode=0) + return subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="") monkeypatch.setattr("hebb.integrations.codex.cli.shutil.which", lambda name: f"/bin/{name}") - monkeypatch.setattr("hebb.integrations.codex.cli.subprocess.run", fake_run) + monkeypatch.setattr(uninstall_module, "hooks_path", lambda scope: tmp_path / "hooks.json") + monkeypatch.setattr(uninstall_module.subprocess, "run", fake_run) - result = CliRunner().invoke(codex, ["uninstall"]) + result = CliRunner().invoke(codex, ["uninstall", "--scope", "user"]) assert result.exit_code == 0, result.output assert calls == [["codex", "mcp", "remove", "hebb"]] + + +def test_codex_defaults_to_project_scope(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("hebb.integrations.codex.cli.shutil.which", lambda name: f"/bin/{name}") + monkeypatch.setattr(install_module, "hebb_command", lambda: ["/bin/hebb"]) + monkeypatch.setattr(install_module, "hebb_mcp_command", lambda: ["/bin/hebb-mcp"]) + + result = CliRunner().invoke(codex, ["install"]) + + assert result.exit_code == 0, result.output + assert (tmp_path / ".codex" / "config.toml").exists() + assert (tmp_path / ".codex" / "hooks.json").exists() diff --git a/tests/unit/integrations/test_codex_hooks.py b/tests/unit/integrations/test_codex_hooks.py new file mode 100644 index 0000000..728769a --- /dev/null +++ b/tests/unit/integrations/test_codex_hooks.py @@ -0,0 +1,232 @@ +"""Tests for native Codex install and lifecycle hook behavior.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from hebb.integrations.claude_code.transcript import TurnSummary +from hebb.integrations.codex import install, stop, uninstall +from hebb.integrations.codex.transcript import CodexTurn, extract_last_turn + + +def _record(record_type: str, payload: dict, timestamp: str = "2026-06-29T01:02:03.456Z") -> str: + return json.dumps({"timestamp": timestamp, "type": record_type, "payload": payload}) + + +def _message(role: str, text: str, *, timestamp: str = "2026-06-29T01:02:03.456Z") -> str: + block_type = "output_text" if role == "assistant" else "input_text" + return _record( + "response_item", + {"type": "message", "role": role, "content": [{"type": block_type, "text": text}]}, + timestamp, + ) + + +class _Response: + def __init__(self, payload: dict | None = None) -> None: + self.payload = payload or {} + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return self.payload + + +class _Client: + def __init__(self, items: list[dict] | None = None) -> None: + self.items = items or [] + self.posts: list[tuple[str, dict]] = [] + self.closed = False + + def get(self, path: str, params: dict | None = None) -> _Response: + return _Response({"items": self.items}) + + def post(self, path: str, json: dict | None = None) -> _Response: + self.posts.append((path, json or {})) + return _Response() + + def close(self) -> None: + self.closed = True + + +def test_codex_transcript_extracts_latest_turn_and_tools(tmp_path: Path) -> None: + transcript = tmp_path / "rollout.jsonl" + transcript.write_text( + "\n".join( + [ + _record("response_item", {"type": "message", "role": "developer", "content": []}), + _message("user", "First substantive prompt"), + _message("assistant", "First answer"), + "{malformed", + _message("user", "Please remember that this project uses pnpm.", timestamp="2026-06-29T02:03:04.567Z"), + _record("response_item", {"type": "function_call", "name": "exec_command", "arguments": "{}"}), + _record("response_item", {"type": "function_call", "name": "mcp__hebb__search_memory", "arguments": "{}"}), + _record("response_item", {"type": "function_call", "name": "exec_command", "arguments": "{}"}), + _message("assistant", "Transcript fallback answer"), + ] + ) + + "\n" + ) + + turn = extract_last_turn(transcript, last_assistant_message="Stable Stop-hook answer") + + assert turn is not None + assert turn.timestamp == "2026-06-29T02:03:04.567Z" + assert turn.summary.user_input == "Please remember that this project uses pnpm." + assert turn.summary.assistant_output == "Stable Stop-hook answer" + assert turn.summary.tools == ["exec_command"] + assert turn.summary.mcps == ["mcp__hebb__search_memory"] + assert turn.summary.turn == 1 + + +def test_codex_transcript_falls_back_to_assistant_record(tmp_path: Path) -> None: + transcript = tmp_path / "rollout.jsonl" + transcript.write_text( + "\n".join( + [ + _message("user", "Explain why this integration test fails."), + _message("assistant", "The fixture uses the wrong schema."), + ] + ) + + "\n" + ) + + turn = extract_last_turn(transcript) + + assert turn is not None + assert turn.summary.assistant_output == "The fixture uses the wrong schema." + + +def test_project_install_is_idempotent_and_preserves_other_config(monkeypatch, tmp_path: Path) -> None: + config = tmp_path / ".codex" / "config.toml" + config.parent.mkdir() + config.write_text( + 'model = "gpt-5.4"\n\n' + "[mcp_servers.other]\n" + 'command = "other"\n\n' + "[mcp_servers.hebb]\n" + 'command = "stale"\n' + 'args = ["old"]\n\n' + "[mcp_servers.hebb.env]\n" + 'OLD = "1"\n\n' + "[features]\n" + "hooks = true\n" + ) + hooks = tmp_path / ".codex" / "hooks.json" + hooks.write_text( + json.dumps( + { + "hooks": { + "SessionStart": [ + {"hooks": [{"type": "command", "command": "other recall"}]}, + {"hooks": [{"type": "command", "command": "hebb claude-code recall"}]}, + ] + } + } + ) + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(install, "hebb_command", lambda: ["/opt/hebb/bin/hebb"]) + monkeypatch.setattr(install, "hebb_mcp_command", lambda: ["/opt/hebb/bin/hebb-mcp"]) + + install.handle("project") + install.handle("project") + + config_text = config.read_text() + assert config_text.count("[mcp_servers.hebb]") == 1 + assert 'command = "/opt/hebb/bin/hebb-mcp"' in config_text + assert "[mcp_servers.other]" in config_text + assert "[features]" in config_text + assert "OLD" not in config_text + + hook_data = json.loads(hooks.read_text()) + commands = [ + handler["command"] + for entries in hook_data["hooks"].values() + for entry in entries + for handler in entry["hooks"] + ] + assert commands.count("/opt/hebb/bin/hebb codex recall") == 1 + assert commands.count("/opt/hebb/bin/hebb codex prompt") == 1 + assert commands.count("/opt/hebb/bin/hebb codex stop") == 1 + assert "other recall" in commands + assert all("claude-code" not in command for command in commands) + + +def test_project_uninstall_preserves_non_hebb_entries(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(install, "hebb_command", lambda: ["/bin/hebb"]) + monkeypatch.setattr(install, "hebb_mcp_command", lambda: ["/bin/hebb-mcp"]) + install.handle("project") + hooks = tmp_path / ".codex" / "hooks.json" + data = json.loads(hooks.read_text()) + data["hooks"]["Stop"].append({"hooks": [{"type": "command", "command": "other stop"}]}) + hooks.write_text(json.dumps(data)) + + uninstall.handle("project") + + assert "[mcp_servers.hebb]" not in (tmp_path / ".codex" / "config.toml").read_text() + assert "other stop" in hooks.read_text() + assert "hebb codex" not in hooks.read_text() + + +def test_codex_stop_writes_native_metadata(monkeypatch) -> None: + client = _Client() + summary = TurnSummary( + user_input="Remember this project uses pnpm.", + assistant_output="Recorded.", + tools=["exec_command"], + mcps=["mcp__hebb__search_memory"], + turn=2, + ) + hook_input = { + "session_id": "session-1", + "turn_id": "turn-3", + "cwd": "/workspace/project", + "transcript_path": "/tmp/rollout.jsonl", + "last_assistant_message": "Recorded.", + } + monkeypatch.setattr(stop, "read_hook_input", lambda: hook_input) + monkeypatch.setattr(stop, "detect_project_name", lambda cwd: "project") + monkeypatch.setattr( + stop, + "extract_last_turn", + lambda path, last_assistant_message=None: CodexTurn(summary, "2026-06-29T01:02:03.456Z"), + ) + monkeypatch.setattr(stop, "get_client", lambda timeout=30: client) + + stop.handle() + + assert client.closed is True + assert len(client.posts) == 1 + payload = client.posts[0][1] + assert payload["source"] == "hook:codex-stop" + assert payload["tags"] == ["project"] + assert payload["metadata"] == { + "session_id": "session-1", + "host": "codex", + "tools": ["exec_command"], + "mcps": ["mcp__hebb__search_memory"], + "turn": 2, + "turn_id": "turn-3", + } + assert payload["content"].startswith("[2026-06-29T01:02:03.456Z]") + + +def test_codex_stop_deduplicates_session_turn(monkeypatch) -> None: + client = _Client(items=[{"metadata": {"session_id": "session-1", "turn": 0}}]) + summary = TurnSummary(user_input="A substantive prompt.", assistant_output="Done.", turn=0) + monkeypatch.setattr( + stop, + "read_hook_input", + lambda: {"session_id": "session-1", "transcript_path": "/tmp/rollout.jsonl"}, + ) + monkeypatch.setattr(stop, "detect_project_name", lambda cwd: None) + monkeypatch.setattr(stop, "extract_last_turn", lambda *args, **kwargs: CodexTurn(summary)) + monkeypatch.setattr(stop, "get_client", lambda timeout=30: client) + + stop.handle() + + assert client.posts == []