mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-13 04:01:35 +00:00
refactor(codex): use MCP tools and native plugin settings
This commit is contained in:
parent
8691778a44
commit
d0c7d2bc32
15 changed files with 723 additions and 273 deletions
|
|
@ -15,8 +15,9 @@ Start ReMe → Install the ReMe plugin → Use memory in Codex
|
|||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+ available as `python3` in the host's environment, with FastMCP >= 3.1 (included with ReMe).
|
||||
- Python 3.11+ available as `python3` in the host's environment, with FastMCP >= 3.4.2 (included with ReMe).
|
||||
- Codex CLI 0.145.0 or a compatible client supporting local plugins, command hooks, and hook trust review.
|
||||
- A graphical host supporting the `openai/settings` MCP extension for the native settings UI. CLI 0.145.0 supports hooks and tools but does not render this UI.
|
||||
- A running ReMe HTTP service exposing `search`, `auto_memory`, and `health_check`, with MCP enabled.
|
||||
- A working model configuration on the ReMe server for memory extraction. Default BM25 recall does not need embeddings.
|
||||
|
||||
|
|
@ -54,68 +55,57 @@ For an explicit query, ask “What did we decide about Project Juniper? Cite the
|
|||
Automatic recording sends completed user/assistant text to the configured ReMe service.
|
||||
Set `auto_memory` to `false` for recall-only use.
|
||||
|
||||
## Optional configuration
|
||||
## Configure through MCP settings
|
||||
|
||||
The default service address works immediately after plugin installation. Create a configuration
|
||||
file only when you want to change the defaults below.
|
||||
Open the installed ReMe MCP server's settings in a host that supports `openai/settings`.
|
||||
The native form exposes the service address, automatic recall and memory switches, batch size,
|
||||
recall limits, timeouts, and timezone. Save changes, then use **Check connection** to call
|
||||
`health_check` on the saved address without sending conversation content. The settings remain
|
||||
available when ReMe is offline, so a wrong address can be corrected there.
|
||||
|
||||
Edit the user configuration file directly to change addresses and behavior. Plugin upgrades preserve
|
||||
this file, and only overrides need to be saved.
|
||||
|
||||
Settings live at `${CODEX_HOME:-$HOME/.codex}/reme/config.json`. For a new file, copy the bundled example; edit an existing file in place:
|
||||
|
||||
```bash
|
||||
mkdir -p "${CODEX_HOME:-$HOME/.codex}/reme"
|
||||
cp integrations/codex/plugins/reme/config.example.json "${CODEX_HOME:-$HOME/.codex}/reme/config.json"
|
||||
```
|
||||
Settings are atomically saved to `${CODEX_HOME:-$HOME/.codex}/reme/config.json`, outside the plugin
|
||||
cache. The next MCP tool call and hook invocation use the new values without a reconnect or reinstall.
|
||||
An in-flight call keeps its original endpoint and settings. Disabling `auto_memory` pauses capture
|
||||
and queued retries; explicit memory tools remain available.
|
||||
|
||||
| Option | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `mcp_url` | `http://127.0.0.1:2333/mcp` | MCP address; removing the trailing `/mcp` gives the default HTTP base for hooks |
|
||||
| `api_url` | Empty string | Optional HTTP Job API base; required for custom MCP paths |
|
||||
| `mcp_url` | `http://127.0.0.1:2333/mcp` | MCP address for both explicit tools and automatic hooks; custom paths are supported |
|
||||
| `auto_recall` | `true` | Search before a user prompt |
|
||||
| `auto_memory` | `true` | Queue and submit completed turns; false also disables retries |
|
||||
| `recall_limit` | `5` | Search result limit |
|
||||
| `recall_min_score` | `0` | Minimum recall score |
|
||||
| `recall_timeout` | `5` | Foreground HTTP timeout in seconds, at most 10 |
|
||||
| `recall_timeout` | `5` | Foreground MCP timeout in seconds, at most 10 |
|
||||
| `request_timeout` | `600` | Memory-write and MCP request timeout in seconds, at most 600 |
|
||||
| `memory_interval` | `5` | Completed turns per batch; use 1 for immediate per-turn writes |
|
||||
| `shutdown_timeout` | `2` | Total best-effort exit drain budget in seconds, at most 2 |
|
||||
| `context_max_chars` | `8000` | Maximum recalled payload characters |
|
||||
| `timezone` | `Asia/Shanghai` | Daily batch timezone; match the ReMe workspace |
|
||||
|
||||
For example, use another port and disable automatic recording:
|
||||
The JSON file remains user-owned and can also be edited directly; only overrides are required.
|
||||
See [config.example.json](plugins/reme/config.example.json) for a complete example. Remove a field
|
||||
from the file to restore its default. Unknown fields and invalid values fail validation; hooks skip
|
||||
work and log `hook_failed` instead of falling back to another service. UI updates reject invalid
|
||||
values without replacing the saved file.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp_url": "http://127.0.0.1:2444/mcp",
|
||||
"auto_memory": false,
|
||||
"auto_recall": true,
|
||||
"memory_interval": 1
|
||||
}
|
||||
```
|
||||
Existing configurations keep working. The deprecated `api_url` field is accepted on read but is
|
||||
no longer used and is removed on the next UI save. All operations use `mcp_url`. Standard `/mcp`
|
||||
endpoints retain existing queue and receipt identities. Pending queues from a custom legacy
|
||||
`api_url` remain untouched: they are not automatically retargeted. Address changes never forward
|
||||
old pending conversations to the new service; switch back to the original endpoint to retry its queue.
|
||||
|
||||
Both MCP and hooks read addresses from this user configuration. Hooks pick up edits on their next
|
||||
invocation. After changing an address or the MCP timeout, reconnect MCP or restart the host so the
|
||||
existing MCP process also reads the new settings. No source edits, cache edits, or reinstall are needed.
|
||||
For a custom MCP path such as `https://memory.example.com/tools`, also set `api_url` to the HTTP Job
|
||||
API base, for example `https://memory.example.com/reme`.
|
||||
|
||||
Unknown fields and invalid values fail validation; hooks skip work and log `hook_failed` instead of
|
||||
falling back to another service. Disabling `auto_memory` stops automatic capture and retries while
|
||||
preserving pending batches; explicit MCP tools remain available. Changing the service address never
|
||||
sends old pending conversations to the new service. Remove a field to restore its default.
|
||||
|
||||
The plugin uses a host-managed stdio process to forward MCP. Start the host with a `python3`
|
||||
environment containing FastMCP. If ReMe runs on another machine or in another Python environment,
|
||||
run `python3 -m pip install "fastmcp>=3.1"` in the host environment. Hooks use only the standard library.
|
||||
Both the host-managed MCP server and short-lived hooks require FastMCP in the host's `python3`
|
||||
environment. If ReMe runs elsewhere, install it with `python3 -m pip install "fastmcp>=3.4.2"`.
|
||||
The plugin does not launch ReMe. Its native form follows the pinned
|
||||
[OpenAI MCP Extensions settings contract](https://github.com/openai/mcp-extensions/blob/node-v0.1.0/docs/spec.md#structured-settings).
|
||||
Clients without that extension can still use tools and hooks, but have no native settings form.
|
||||
|
||||
## Lifecycle and failure behavior
|
||||
|
||||
- `UserPromptSubmit` calls `search` synchronously with a short timeout. Results are bounded and
|
||||
wrapped in `<reme-context>` as untrusted historical evidence. A failure does not reject the prompt.
|
||||
- Synchronous `Stop` hooks extract only the completed user/final-assistant text from the
|
||||
hook's local transcript, then submit `auto_memory` batches through the JSON Job API. Tool output,
|
||||
hook's local transcript, then submit `auto_memory` batches through MCP. Tool output,
|
||||
reasoning, and injected ReMe context are excluded. The server does not need access to host files.
|
||||
- Session and message IDs are stable and host-scoped. Repeated Stops are deduplicated. Per-session
|
||||
process locks serialize writes; batches never mix dates. Subagent events and hook continuations
|
||||
|
|
@ -127,7 +117,7 @@ run `python3 -m pip install "fastmcp>=3.1"` in the host environment. Hooks use o
|
|||
A due memory batch or a startup retry can delay completion by up to `request_timeout`.
|
||||
Claude Code uses its own native asynchronous adapter.
|
||||
- Shutdown and delivery are best-effort. A host killed before the Stop hook captures a turn can
|
||||
lose that turn. A lost HTTP acknowledgement can cause a retry; stable message IDs prevent duplicate
|
||||
lose that turn. A lost MCP acknowledgement can cause a retry; stable message IDs prevent duplicate
|
||||
source messages, but memory extraction is **at least once**, not exactly once. Short residual
|
||||
batches survive for the next session when shutdown has insufficient time.
|
||||
- Queue files contain source conversation text; retain them until delivery completes. Logs in
|
||||
|
|
@ -155,10 +145,10 @@ than guessed. Codex transcript formats are not a stable public interface. Check
|
|||
|
||||
In Codex 0.145.0, an unattended `codex exec` MCP call can return `user cancelled MCP tool call`
|
||||
when approval cannot be collected. Verify explicit tools in an interactive session and approve the
|
||||
specific call. Automatic hooks use the Job API and do not depend on MCP tool approval.
|
||||
specific call. Trusted automatic hooks act as MCP clients themselves; they do not request per-call model tool approval.
|
||||
|
||||
## Source and validation
|
||||
|
||||
Run `pytest tests/unit/test_coding_agent_plugins.py -v` from the ReMe checkout. Tests isolate host
|
||||
Run `pytest tests/unit/test_coding_agent_plugins.py tests/unit/test_codex_plugin_mcp.py -v` from the ReMe checkout. Tests isolate host
|
||||
state and mock service calls. Native hook behavior is described in the [Codex hook reference](https://learn.chatgpt.com/docs/hooks).
|
||||
ReMe is developed at [agentscope-ai/ReMe](https://github.com/agentscope-ai/ReMe) under Apache-2.0.
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@
|
|||
|
||||
## 环境要求
|
||||
|
||||
- 宿主环境中有 Python 3.11+,可通过 `python3` 执行,并已安装 FastMCP >= 3.1(ReMe 自带此依赖)。
|
||||
- 宿主环境中有 Python 3.11+,可通过 `python3` 执行,并已安装 FastMCP >= 3.4.2(ReMe 自带此依赖)。
|
||||
- Codex CLI 0.145.0 或兼容版本,支持插件、`UserPromptSubmit`、`Stop`、同步命令 Hook 和 `last_assistant_message`。
|
||||
- 原生配置界面需要支持 `openai/settings` MCP 扩展的图形宿主;CLI 0.145.0 支持 Hook 和工具,但不渲染该界面。
|
||||
- 已启动 ReMe HTTP 服务,开放 `search`、`auto_memory`、`health_check`,并启用 MCP。
|
||||
- ReMe 服务端有可用于自动记忆提取的模型配置。默认 BM25 召回不要求 Embedding。
|
||||
|
||||
|
|
@ -49,65 +50,53 @@ codex plugin add reme@reme-codex
|
|||
|
||||
自动记录会把完成的用户/助手文本发送到配置的 ReMe 服务;仅需召回时,将 `auto_memory` 设为 `false`。
|
||||
|
||||
## 可选配置
|
||||
## 通过 MCP 设置界面配置
|
||||
|
||||
使用默认服务地址时,安装插件后即可使用。需要调整下列默认行为时,再创建配置文件。
|
||||
在支持 `openai/settings` 的宿主中,打开已安装的 ReMe MCP Server 设置。
|
||||
原生表单提供服务地址、自动召回与记录开关、批次大小、召回限制、超时和时区。
|
||||
保存后点击 **Check connection**,会在已保存地址调用 `health_check`,不发送对话内容。
|
||||
ReMe 服务离线时仍可读取和修改设置,因此可以在此修正错误地址。
|
||||
|
||||
直接编辑用户配置文件即可调整地址和行为,升级插件会保留配置。文件中只需写入要覆盖的选项。
|
||||
|
||||
配置位于 `${CODEX_HOME:-$HOME/.codex}/reme/config.json`。首次创建时,可从仓库根目录复制示例;已有文件请直接编辑:
|
||||
|
||||
```bash
|
||||
mkdir -p "${CODEX_HOME:-$HOME/.codex}/reme"
|
||||
cp integrations/codex/plugins/reme/config.example.json "${CODEX_HOME:-$HOME/.codex}/reme/config.json"
|
||||
```
|
||||
配置原子保存到 `${CODEX_HOME:-$HOME/.codex}/reme/config.json`,位于插件缓存之外,升级后保留。
|
||||
下一次 MCP 工具调用和 Hook 调用直接使用新配置,无需重新连接或重装。
|
||||
正在执行的调用继续使用原来的地址与配置。关闭 `auto_memory` 会暂停自动捕获与队列重试,显式工具仍可使用。
|
||||
|
||||
| 配置项 | 默认值 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `mcp_url` | `http://127.0.0.1:2333/mcp` | ReMe MCP 地址;默认去掉末尾 `/mcp` 得到 Hook 的 HTTP 地址 |
|
||||
| `api_url` | 空字符串 | 可选 HTTP Job API 基址;MCP 使用自定义路径时需显式填写 |
|
||||
| `mcp_url` | `http://127.0.0.1:2333/mcp` | 显式工具和自动 Hook 共用的 MCP 地址,支持自定义路径 |
|
||||
| `auto_recall` | `true` | 用户提交消息时自动检索 |
|
||||
| `auto_memory` | `true` | 自动捕获与提交;关闭后也不重试已有队列 |
|
||||
| `recall_limit` | `5` | 检索结果上限 |
|
||||
| `recall_min_score` | `0` | 自动召回最低分数 |
|
||||
| `recall_timeout` | `5` | 前台 HTTP 超时秒数,最大 10 |
|
||||
| `recall_timeout` | `5` | 前台 MCP 超时秒数,最大 10 |
|
||||
| `request_timeout` | `600` | 写入与 MCP 请求超时秒数,最大 600 |
|
||||
| `memory_interval` | `5` | 每批完成的对话轮数;设为 1 则逐轮提交 |
|
||||
| `shutdown_timeout` | `2` | 退出时尽力刷新队列的总秒数,最大 2 |
|
||||
| `context_max_chars` | `8000` | 召回正文字符上限 |
|
||||
| `timezone` | `Asia/Shanghai` | 批次按此时区分日,应与 ReMe workspace 一致 |
|
||||
|
||||
例如,改用另一个端口并关闭自动记录:
|
||||
JSON 配置仍由用户掌控,也可直接编辑,只需写入要覆盖的选项。
|
||||
完整示例见 [config.example.json](plugins/reme/config.example.json);从文件移除某项即可恢复其默认值。
|
||||
未知字段和无效值会使校验失败,Hook 跳过并记录 `hook_failed`,不会回退到其他服务。
|
||||
界面保存无效值时会返回错误,保留原配置文件。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp_url": "http://127.0.0.1:2444/mcp",
|
||||
"auto_memory": false,
|
||||
"auto_recall": true,
|
||||
"memory_interval": 1
|
||||
}
|
||||
```
|
||||
已有配置仍可读取。旧 `api_url` 字段不再参与请求,下次在界面保存时会移除;所有操作统一使用 `mcp_url`。
|
||||
标准 `/mcp` 地址继续使用原来的队列和收据标识。自定义旧 `api_url` 下的待提交记录原样保留,
|
||||
不会自动迁移目标地址。切换地址不会把旧对话发往新服务;切回原地址可继续重试该地址的队列。
|
||||
|
||||
MCP 和 Hook 从同一份用户配置读取地址。Hook 在下一次调用时读取修改;更改地址或 MCP 超时后,
|
||||
请重新连接 MCP 或重启宿主,确保现有 MCP 进程也使用新配置。无需修改插件源码、安装缓存或重装插件。
|
||||
使用自定义 MCP 路径时,例如 `https://memory.example.com/tools`,同时设置
|
||||
`api_url` 为 HTTP Job API 基址,例如 `https://memory.example.com/reme`。
|
||||
|
||||
未知字段或无效值会使配置校验失败,Hook 会跳过并记录 `hook_failed`,不会回退到其他服务。
|
||||
关闭 `auto_memory` 会停止自动捕获和重试,保留已有队列;显式 MCP 工具仍可使用。
|
||||
修改服务地址不会把旧地址下的待提交对话发送到新服务。移除配置项即可恢复该项默认值。
|
||||
|
||||
插件通过宿主管理的 stdio 连接进程转发 MCP,请确保启动宿主时的 `python3` 环境包含 FastMCP。
|
||||
如果 ReMe 服务运行在另一台机器或另一个 Python 环境,在宿主环境执行
|
||||
`python3 -m pip install "fastmcp>=3.1"`。Hook 本身仅使用标准库。
|
||||
宿主管理的 MCP Server 和短生命周期 Hook 都依赖宿主 `python3` 环境中的 FastMCP。
|
||||
如果 ReMe 运行在其他环境,请执行 `python3 -m pip install "fastmcp>=3.4.2"`。插件不会启动 ReMe 服务。
|
||||
原生表单遵循固定版本的
|
||||
[OpenAI MCP Extensions 设置协议](https://github.com/openai/mcp-extensions/blob/node-v0.1.0/docs/spec.md#structured-settings)。
|
||||
不支持该扩展的宿主仍可使用工具和 Hook,但不会显示原生设置表单。
|
||||
|
||||
## 生命周期与失败行为
|
||||
|
||||
- `UserPromptSubmit` 在短超时范围内同步调用 `search`,用 `<reme-context>` 包裹有长度限制的结果,明确其为不可信历史数据。失败不阻止用户消息。
|
||||
- 同步 `Stop` Hook 从本地 transcript 提取完成轮次的用户与最终助手文本,通过 JSON Job API 分批调用 `auto_memory`。工具输出、推理内容和注入的 ReMe 上下文不作为对话来源。服务端无需读取宿主文件。
|
||||
- 同步 `Stop` Hook 从本地 transcript 提取完成轮次的用户与最终助手文本,通过 MCP 分批调用 `auto_memory`。工具输出、推理内容和注入的 ReMe 上下文不作为对话来源。服务端无需读取宿主文件。
|
||||
- 使用带宿主命名空间的稳定会话和消息 ID,重复 Stop 去重;每个会话串行提交,跨日拆分批次。排除子 Agent 事件和 Hook 续写。插件不启动独立守护进程,也不自动下载依赖或启动 ReMe。
|
||||
- 队列与确认收据保存在宿主的 `reme/queue/`,按服务地址隔离;失败或超时不会丢弃队列。`SessionStart` 同步重试,`SessionEnd` 在短时间预算内同步尝试刷新。
|
||||
- 退出刷新是尽力而为:宿主在捕获前被强制结束时可能丢失该轮。HTTP 确认丢失时可能重复提取;稳定消息 ID 避免重复存储来源,但记忆提取是至少一次语义。未满批次或未及时完成的任务留待下次会话处理。
|
||||
- 退出刷新是尽力而为:宿主在捕获前被强制结束时可能丢失该轮。MCP 确认丢失时可能重复提取;稳定消息 ID 避免重复存储来源,但记忆提取是至少一次语义。未满批次或未及时完成的任务留待下次会话处理。
|
||||
- 队列包含原始对话文本,请在交付完成前保留。`reme/hooks.log` 仅记录事件及异常类型。关闭 `auto_memory` 保留队列且停止重试。
|
||||
- `auto_dream` 整理继续由 ReMe 服务管理。与 OpenClaw 常驻 Gateway 不同,短生命周期 Hook 不另建定时调度器。
|
||||
|
||||
|
|
@ -129,10 +118,10 @@ ReMe 服务是否运行,以及 `service.jobs` 是否开放所需 Job。Hook
|
|||
Codex transcript 不是稳定公开接口;未知格式或缺失文件会跳过并记录状态,不猜测对话。
|
||||
召回正常但未记录时,检查 `capture_skipped`、`hook_failed`。
|
||||
|
||||
在 Codex 0.145.0 中,无人值守的 `codex exec` 无法收集工具审批时,MCP 调用可能返回 `user cancelled MCP tool call`。请在交互会话中批准具体调用来验证显式工具。自动 Hook 使用 Job API,不依赖 MCP 工具审批。
|
||||
在 Codex 0.145.0 中,无人值守的 `codex exec` 无法收集工具审批时,MCP 调用可能返回 `user cancelled MCP tool call`。请在交互会话中批准具体调用来验证显式工具。已信任的自动 Hook 自身作为 MCP 客户端调用服务,不逐次请求模型工具审批。
|
||||
|
||||
## 源码与验证
|
||||
|
||||
在 ReMe 仓库运行 `pytest tests/unit/test_coding_agent_plugins.py -v`。测试使用隔离目录和模拟服务。
|
||||
在 ReMe 仓库运行 `pytest tests/unit/test_coding_agent_plugins.py tests/unit/test_codex_plugin_mcp.py -v`。测试使用隔离目录和模拟服务。
|
||||
宿主行为以 [Codex Hook 文档](https://learn.chatgpt.com/docs/hooks) 为准。
|
||||
ReMe 在 [agentscope-ai/ReMe](https://github.com/agentscope-ai/ReMe) 开发,采用 Apache-2.0 许可证。
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "reme",
|
||||
"version": "0.1.0+codex.20260928104620",
|
||||
"description": "Automatic recall, batched conversation recording, and explicit MCP memory tools for Codex.",
|
||||
"version": "0.1.0+codex.20260930055341",
|
||||
"description": "MCP-native automatic memory and editable plugin settings for Codex.",
|
||||
"author": {
|
||||
"name": "EconML team of Alibaba Tongyi Lab",
|
||||
"email": "jinli.yl@alibaba-inc.com"
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
"mcpServers": "./.mcp.json",
|
||||
"interface": {
|
||||
"displayName": "ReMe",
|
||||
"shortDescription": "File-native long-term memory for Codex",
|
||||
"shortDescription": "Long-term memory for agents",
|
||||
"longDescription": "Recall past decisions and record completed conversations in your own ReMe workspace.",
|
||||
"developerName": "AgentScope",
|
||||
"category": "Productivity",
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
"reme": {
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"hooks/mcp_bridge.py"
|
||||
"mcp_server.py"
|
||||
],
|
||||
"cwd": ".",
|
||||
"env_vars": ["CODEX_HOME"]
|
||||
"env_vars": [
|
||||
"CODEX_HOME"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"mcp_url": "http://127.0.0.1:2333/mcp",
|
||||
"api_url": "",
|
||||
"auto_recall": true,
|
||||
"auto_memory": true,
|
||||
"recall_limit": 5,
|
||||
|
|
|
|||
|
|
@ -1,118 +1,26 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Codex lifecycle adapter. Requires only Python's standard library."""
|
||||
"""Codex lifecycle adapter using the same ReMe MCP connection as explicit tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# Hook scripts run directly from the installed plugin, independently of its MCP process.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
# pylint: disable=wrong-import-position
|
||||
import reme_config # noqa: E402
|
||||
from reme_config import data_dir, load_config, lock, write_json # noqa: E402
|
||||
from reme_mcp import call # noqa: E402
|
||||
|
||||
HOME_ENV = reme_config.HOME_ENV
|
||||
HOST = "codex"
|
||||
HOME_ENV = "CODEX_HOME"
|
||||
HOME_DEFAULT = "~/.codex"
|
||||
DEFAULTS = {
|
||||
"mcp_url": "http://127.0.0.1:2333/mcp",
|
||||
"api_url": "",
|
||||
"auto_recall": True,
|
||||
"auto_memory": True,
|
||||
"recall_limit": 5,
|
||||
"recall_min_score": 0.0,
|
||||
"recall_timeout": 5.0,
|
||||
"request_timeout": 600.0,
|
||||
"memory_interval": 5,
|
||||
"shutdown_timeout": 2.0,
|
||||
"context_max_chars": 8000,
|
||||
"timezone": "Asia/Shanghai",
|
||||
}
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
"""Keep configuration and retry state outside the versioned plugin cache."""
|
||||
return Path(os.environ.get(HOME_ENV) or HOME_DEFAULT).expanduser() / "reme"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Read the same persistent settings used by the MCP bridge and lifecycle hooks."""
|
||||
path = data_dir() / "config.json"
|
||||
values = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
return validate_config(values)
|
||||
|
||||
|
||||
def validate_config(values: dict) -> dict:
|
||||
"""Validate user overrides before either transport can use them."""
|
||||
if not isinstance(values, dict) or values.keys() - DEFAULTS.keys():
|
||||
raise ValueError("Unknown ReMe configuration fields")
|
||||
config = {**DEFAULTS, **values}
|
||||
for key, default in DEFAULTS.items():
|
||||
value = config[key]
|
||||
if isinstance(default, bool):
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{key} must be a boolean")
|
||||
elif isinstance(default, str):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{key} must be a string")
|
||||
elif isinstance(default, (int, float)):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise ValueError(f"{key} must be a finite number")
|
||||
if value < 0 or (value == 0 and key != "recall_min_score"):
|
||||
raise ValueError(f"{key} must be positive")
|
||||
if isinstance(default, int) and not isinstance(value, int):
|
||||
raise ValueError(f"{key} must be an integer")
|
||||
if config["recall_timeout"] > 10 or config["request_timeout"] > 600 or config["shutdown_timeout"] > 2:
|
||||
raise ValueError("Timeout exceeds the lifecycle hook budget")
|
||||
try:
|
||||
ZoneInfo(config["timezone"])
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise ValueError("timezone must be an IANA timezone") from exc
|
||||
config["mcp_url"] = validate_url(config["mcp_url"])
|
||||
if config["api_url"]:
|
||||
config["endpoint"] = validate_url(config["api_url"])
|
||||
elif config["mcp_url"].endswith("/mcp"):
|
||||
config["endpoint"] = config["mcp_url"][:-4]
|
||||
else:
|
||||
raise ValueError("Set api_url explicitly when mcp_url does not end in /mcp")
|
||||
return config
|
||||
|
||||
|
||||
def validate_url(url: str) -> str:
|
||||
"""Accept HTTP endpoints without embedded credentials or ambiguous URL suffixes."""
|
||||
url = url.rstrip("/")
|
||||
parsed = urlsplit(url)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or any((parsed.username is not None, parsed.password is not None, parsed.query, parsed.fragment))
|
||||
or any(character.isspace() for character in url)
|
||||
):
|
||||
raise ValueError("ReMe URLs must be absolute HTTP(S) URLs without credentials, query, or fragment")
|
||||
_ = parsed.port
|
||||
return url
|
||||
|
||||
|
||||
def call(config: dict, action: str, payload: dict, timeout: float) -> dict:
|
||||
"""Require the Job API's explicit success acknowledgement before committing a batch."""
|
||||
request = urllib.request.Request(
|
||||
f"{config['endpoint']}/{action}",
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
result = json.load(response)
|
||||
if not isinstance(result, dict) or result.get("success") is not True:
|
||||
raise RuntimeError("ReMe did not acknowledge the action")
|
||||
return result
|
||||
|
||||
|
||||
def digest(value: str) -> str:
|
||||
|
|
@ -132,59 +40,6 @@ def log_status(event: str, error: Exception | None = None) -> None:
|
|||
pass
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def lock(path: Path):
|
||||
"""Use a non-blocking OS lock, released automatically if a hook is killed."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with path.open("a+b") as handle:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
handle.write(b"\0")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
|
||||
def acquire():
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
|
||||
def release():
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
def acquire():
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
def release():
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
|
||||
try:
|
||||
acquire()
|
||||
except OSError:
|
||||
yield False
|
||||
return
|
||||
try:
|
||||
yield True
|
||||
finally:
|
||||
release()
|
||||
|
||||
|
||||
def write_json(path: Path, value: dict) -> None:
|
||||
"""Atomically persist retry metadata with owner-only file permissions."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".reme-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, ensure_ascii=False)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def clean_text(value: str) -> str:
|
||||
"""Exclude automatic recall and injected reminders from source messages."""
|
||||
value = re.sub(r"<reme-context\b[^>]*>.*?</reme-context>", "", value, flags=re.DOTALL)
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Host-managed stdio connection to the user's independently running ReMe service."""
|
||||
|
||||
from auto_memory import load_config
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Resolve persistent settings on connection; leave MCP transport handling to FastMCP."""
|
||||
config = load_config()
|
||||
try:
|
||||
from fastmcp.server import create_proxy
|
||||
from fastmcp.server.providers.proxy import ProxyClient
|
||||
except ImportError as exc:
|
||||
raise SystemExit(
|
||||
"ReMe MCP requires fastmcp>=3.1 in the host's python3 environment (included in reme-ai).",
|
||||
) from exc
|
||||
upstream = ProxyClient(config["mcp_url"], timeout=config["request_timeout"])
|
||||
proxy = create_proxy(upstream, name="ReMe")
|
||||
proxy.run(transport="stdio", show_banner=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
13
integrations/codex/plugins/reme/mcp.json
Normal file
13
integrations/codex/plugins/reme/mcp.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
"mcpServers": {
|
||||
"reme": {
|
||||
"type": "stdio",
|
||||
"command": "python3",
|
||||
"args": [
|
||||
"${PLUGIN_ROOT}/mcp_server.py"
|
||||
],
|
||||
"cwd": "${PLUGIN_ROOT}"
|
||||
}
|
||||
}
|
||||
}
|
||||
206
integrations/codex/plugins/reme/mcp_server.py
Normal file
206
integrations/codex/plugins/reme/mcp_server.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Codex MCP tools and native settings for the independently running ReMe service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.dependencies import get_context
|
||||
from fastmcp.server.providers import Provider
|
||||
from fastmcp.server.providers.proxy import ProxyClient, ProxyProvider
|
||||
from fastmcp.tools import FunctionTool, ToolResult
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from reme_config import DEFAULTS, load_config, settings_values, update_settings
|
||||
from reme_mcp import call_async
|
||||
|
||||
SETTINGS_READ = "reme_settings_read"
|
||||
SETTINGS_UPDATE = "reme_settings_update"
|
||||
CHECK_CONNECTION = "reme_check_connection"
|
||||
|
||||
# OpenAI MCP Extensions node-v0.1.0, docs/spec.md#structured-settings.
|
||||
# Use its legacy capability advertisement with ReMe's existing FastMCP runtime.
|
||||
FIELDS = {
|
||||
"mcp_url": {"type": "string", "title": "ReMe MCP address", "description": "URL of your running ReMe MCP service."},
|
||||
"auto_recall": {"type": "boolean", "title": "Automatic recall"},
|
||||
"auto_memory": {
|
||||
"type": "boolean",
|
||||
"title": "Automatic memory",
|
||||
"description": "Send completed user/assistant turns to ReMe. Disabling also pauses queued retries.",
|
||||
},
|
||||
"memory_interval": {"type": "integer", "title": "Turns per memory batch", "minimum": 1},
|
||||
"recall_limit": {"type": "integer", "title": "Recall result limit", "minimum": 1},
|
||||
"recall_min_score": {"type": "number", "title": "Minimum recall score", "minimum": 0},
|
||||
"context_max_chars": {"type": "integer", "title": "Recall character limit", "minimum": 1},
|
||||
"recall_timeout": {
|
||||
"type": "number",
|
||||
"title": "Recall timeout (seconds)",
|
||||
"minimum": 0,
|
||||
"description": "Must be greater than zero.",
|
||||
"maximum": 10,
|
||||
},
|
||||
"request_timeout": {
|
||||
"type": "number",
|
||||
"title": "Memory / tool timeout (seconds)",
|
||||
"minimum": 0,
|
||||
"description": "Must be greater than zero.",
|
||||
"maximum": 600,
|
||||
},
|
||||
"shutdown_timeout": {
|
||||
"type": "number",
|
||||
"title": "Exit flush budget (seconds)",
|
||||
"minimum": 0,
|
||||
"description": "Must be greater than zero.",
|
||||
"maximum": 2,
|
||||
},
|
||||
"timezone": {"type": "string", "title": "Memory timezone", "description": "IANA timezone, such as Asia/Shanghai."},
|
||||
}
|
||||
SCHEMA = {"type": "object", "properties": FIELDS, "additionalProperties": False}
|
||||
LAYOUT = [
|
||||
{
|
||||
"kind": "group",
|
||||
"title": "Connection",
|
||||
"items": [
|
||||
{"kind": "property", "property": "mcp_url"},
|
||||
{"kind": "tool", "tool": CHECK_CONNECTION, "title": "Check connection"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "group",
|
||||
"title": "Automatic memory",
|
||||
"items": [
|
||||
{"kind": "property", "property": key}
|
||||
for key in ("auto_recall", "auto_memory", "memory_interval", "timezone")
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "group",
|
||||
"title": "Recall",
|
||||
"items": [
|
||||
{"kind": "property", "property": key} for key in ("recall_limit", "recall_min_score", "context_max_chars")
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "group",
|
||||
"title": "Timeouts",
|
||||
"items": [
|
||||
{"kind": "property", "property": key} for key in ("recall_timeout", "request_timeout", "shutdown_timeout")
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ReMeProvider(Provider):
|
||||
"""Resolve tools against one settings snapshot, without caching across endpoints."""
|
||||
|
||||
@staticmethod
|
||||
def snapshot() -> ProxyProvider:
|
||||
"""Keep an in-flight tool's schema and execution on the same endpoint."""
|
||||
config = load_config()
|
||||
return ProxyProvider(
|
||||
lambda: ProxyClient(
|
||||
config["mcp_url"],
|
||||
timeout=config["request_timeout"],
|
||||
init_timeout=config["recall_timeout"],
|
||||
),
|
||||
cache_ttl=0,
|
||||
)
|
||||
|
||||
async def _list_tools(self):
|
||||
return [
|
||||
tool
|
||||
for tool in await self.snapshot().list_tools()
|
||||
if tool.name not in {SETTINGS_READ, SETTINGS_UPDATE, CHECK_CONNECTION}
|
||||
]
|
||||
|
||||
async def _get_tool(self, name, version=None):
|
||||
# Local settings are authoritative and never depend on upstream availability.
|
||||
if name in {SETTINGS_READ, SETTINGS_UPDATE, CHECK_CONNECTION}:
|
||||
return None
|
||||
return await self.snapshot().get_tool(name, version)
|
||||
|
||||
async def get_tasks(self):
|
||||
"""Proxy tools do not provide background MCP tasks."""
|
||||
return []
|
||||
|
||||
|
||||
def create_server() -> FastMCP:
|
||||
"""Keep settings available even when the configured upstream server is offline."""
|
||||
server = FastMCP(
|
||||
"ReMe",
|
||||
experimental_capabilities={"openai/settings": {"readTool": SETTINGS_READ, "updateTool": SETTINGS_UPDATE}},
|
||||
)
|
||||
server.add_provider(ReMeProvider())
|
||||
|
||||
def read_settings() -> ToolResult:
|
||||
"""Return editable values and their native settings layout."""
|
||||
return ToolResult(
|
||||
content=[],
|
||||
structured_content={
|
||||
"schema": SCHEMA,
|
||||
"values": settings_values(load_config()),
|
||||
"layout": LAYOUT,
|
||||
},
|
||||
)
|
||||
|
||||
async def save_settings(**arguments) -> ToolResult:
|
||||
"""Persist a partial settings update and refresh upstream discovery."""
|
||||
values = update_settings(arguments["set"])
|
||||
await get_context().session.send_tool_list_changed()
|
||||
return ToolResult(content=[], structured_content={"values": values})
|
||||
|
||||
server.add_tool(
|
||||
FunctionTool(
|
||||
name=SETTINGS_READ,
|
||||
title="ReMe settings",
|
||||
description="Read ReMe plugin settings for the host settings UI.",
|
||||
fn=read_settings,
|
||||
parameters={"type": "object", "additionalProperties": False},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {"type": "object"},
|
||||
"values": SCHEMA,
|
||||
"layout": {"type": "array", "items": {"type": "object"}},
|
||||
},
|
||||
"required": ["schema", "values"],
|
||||
},
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False),
|
||||
),
|
||||
)
|
||||
server.add_tool(
|
||||
FunctionTool(
|
||||
name=SETTINGS_UPDATE,
|
||||
title="Save ReMe settings",
|
||||
description="Save changes requested in the ReMe settings UI.",
|
||||
fn=save_settings,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"set": {**SCHEMA, "minProperties": 1},
|
||||
},
|
||||
"required": ["set"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
output_schema={"type": "object", "properties": {"values": SCHEMA}, "required": ["values"]},
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
idempotentHint=True,
|
||||
openWorldHint=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@server.tool(name=CHECK_CONNECTION, title="Check ReMe connection", annotations={"readOnlyHint": True})
|
||||
async def check_connection() -> str:
|
||||
"""Check the saved MCP endpoint without sending conversation content."""
|
||||
config = load_config()
|
||||
result = await call_async(config, "health_check", {}, config["recall_timeout"])
|
||||
return str(result["answer"])
|
||||
|
||||
assert FIELDS.keys() == DEFAULTS.keys()
|
||||
return server
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_server().run(transport="stdio", show_banner=False)
|
||||
20
integrations/codex/plugins/reme/plugin.json
Normal file
20
integrations/codex/plugins/reme/plugin.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"name": "reme",
|
||||
"version": "0.1.0+codex.20260930055341",
|
||||
"description": "MCP-native automatic memory and editable plugin settings for Codex.",
|
||||
"author": {
|
||||
"name": "EconML team of Alibaba Tongyi Lab",
|
||||
"email": "jinli.yl@alibaba-inc.com"
|
||||
},
|
||||
"homepage": "https://reme.agentscope.io",
|
||||
"repository": "https://github.com/agentscope-ai/ReMe",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"memory",
|
||||
"reme",
|
||||
"mcp",
|
||||
"long-term-memory",
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
166
integrations/codex/plugins/reme/reme_config.py
Normal file
166
integrations/codex/plugins/reme/reme_config.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""Persistent Codex plugin settings and owner-only, atomic local storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
HOME_ENV = "CODEX_HOME"
|
||||
HOME_DEFAULT = "~/.codex"
|
||||
DEFAULTS = {
|
||||
"mcp_url": "http://127.0.0.1:2333/mcp",
|
||||
"auto_recall": True,
|
||||
"auto_memory": True,
|
||||
"recall_limit": 5,
|
||||
"recall_min_score": 0.0,
|
||||
"recall_timeout": 5.0,
|
||||
"request_timeout": 600.0,
|
||||
"memory_interval": 5,
|
||||
"shutdown_timeout": 2.0,
|
||||
"context_max_chars": 8000,
|
||||
"timezone": "Asia/Shanghai",
|
||||
}
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
"""Keep configuration and retry state outside the versioned plugin cache."""
|
||||
return Path(os.environ.get(HOME_ENV) or HOME_DEFAULT).expanduser() / "reme"
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Read the persistent settings shared by the MCP server and lifecycle hooks."""
|
||||
path = data_dir() / "config.json"
|
||||
values = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
return validate_config(values)
|
||||
|
||||
|
||||
def validate_config(values: dict) -> dict:
|
||||
"""Validate user overrides before the MCP server or hooks can use them."""
|
||||
if not isinstance(values, dict) or values.keys() - (DEFAULTS.keys() | {"api_url"}):
|
||||
raise ValueError("Unknown ReMe configuration fields")
|
||||
# Accept the former HTTP base only for migration; no request uses it.
|
||||
legacy_api = values.get("api_url", "")
|
||||
if not isinstance(legacy_api, str):
|
||||
raise ValueError("api_url must be a string")
|
||||
if legacy_api:
|
||||
validate_url(legacy_api)
|
||||
config = {**DEFAULTS, **{key: value for key, value in values.items() if key != "api_url"}}
|
||||
for key, default in DEFAULTS.items():
|
||||
value = config[key]
|
||||
if isinstance(default, bool):
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{key} must be a boolean")
|
||||
elif isinstance(default, str):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{key} must be a string")
|
||||
elif isinstance(default, (int, float)):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise ValueError(f"{key} must be a finite number")
|
||||
if value < 0 or (value == 0 and key != "recall_min_score"):
|
||||
raise ValueError(f"{key} must be positive")
|
||||
if isinstance(default, int) and not isinstance(value, int):
|
||||
raise ValueError(f"{key} must be an integer")
|
||||
if config["recall_timeout"] > 10 or config["request_timeout"] > 600 or config["shutdown_timeout"] > 2:
|
||||
raise ValueError("Timeout exceeds the lifecycle hook budget")
|
||||
try:
|
||||
ZoneInfo(config["timezone"])
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise ValueError("timezone must be an IANA timezone") from exc
|
||||
config["mcp_url"] = validate_url(config["mcp_url"])
|
||||
# Preserve standard /mcp retry-directory identities from the HTTP adapter.
|
||||
# Custom legacy api_url queues remain untouched rather than being sent elsewhere.
|
||||
config["endpoint"] = config["mcp_url"][:-4] if config["mcp_url"].endswith("/mcp") else "mcp:" + config["mcp_url"]
|
||||
return config
|
||||
|
||||
|
||||
def validate_url(url: str) -> str:
|
||||
"""Accept HTTP endpoints without embedded credentials or ambiguous URL suffixes."""
|
||||
url = url.rstrip("/")
|
||||
parsed = urlsplit(url)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or any((parsed.username is not None, parsed.password is not None, parsed.query, parsed.fragment))
|
||||
or any(character.isspace() for character in url)
|
||||
):
|
||||
raise ValueError("ReMe URLs must be absolute HTTP(S) URLs without credentials, query, or fragment")
|
||||
_ = parsed.port
|
||||
return url
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def lock(path: Path):
|
||||
"""Use a non-blocking OS lock, released automatically if a hook is killed."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with path.open("a+b") as handle:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
handle.write(b"\0")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
|
||||
def acquire():
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
|
||||
def release():
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
def acquire():
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
def release():
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
|
||||
try:
|
||||
acquire()
|
||||
except OSError:
|
||||
yield False
|
||||
return
|
||||
try:
|
||||
yield True
|
||||
finally:
|
||||
release()
|
||||
|
||||
|
||||
def write_json(path: Path, value: dict) -> None:
|
||||
"""Atomically persist retry metadata with owner-only file permissions."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".reme-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, ensure_ascii=False)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def settings_values(config: dict) -> dict:
|
||||
"""Expose only editable settings, excluding the derived retry-directory identity."""
|
||||
return {key: config[key] for key in DEFAULTS}
|
||||
|
||||
|
||||
def update_settings(changes: dict) -> dict:
|
||||
"""Validate and merge a UI patch under the same cross-process lock as other editors."""
|
||||
if not isinstance(changes, dict) or not changes or changes.keys() - DEFAULTS.keys():
|
||||
raise ValueError("Provide at least one supported ReMe setting")
|
||||
with lock(data_dir() / "config.lock") as acquired:
|
||||
if not acquired:
|
||||
raise RuntimeError("Another settings update is in progress; please retry")
|
||||
current = settings_values(load_config())
|
||||
config = validate_config({**current, **changes})
|
||||
values = settings_values(config)
|
||||
write_json(data_dir() / "config.json", values)
|
||||
return values
|
||||
29
integrations/codex/plugins/reme/reme_mcp.py
Normal file
29
integrations/codex/plugins/reme/reme_mcp.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""ReMe MCP calls shared by hooks and the plugin's connection check."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
async def call_async(config: dict, action: str, payload: dict, timeout: float) -> dict:
|
||||
"""Acknowledge a write only after a successful MCP result, including connection time."""
|
||||
from fastmcp import Client
|
||||
|
||||
async with asyncio.timeout(timeout):
|
||||
async with Client(config["mcp_url"], timeout=timeout) as client:
|
||||
result = await client.call_tool(action, payload, raise_on_error=True)
|
||||
if result.is_error:
|
||||
raise RuntimeError("ReMe MCP did not acknowledge the action")
|
||||
answer = result.data
|
||||
if answer is None:
|
||||
answer = "\n".join(block.text for block in result.content if block.type == "text")
|
||||
return {"success": True, "answer": answer}
|
||||
|
||||
|
||||
def call(config: dict, action: str, payload: dict, timeout: float) -> dict:
|
||||
"""Run one bounded MCP operation in a short-lived native hook process."""
|
||||
try:
|
||||
return asyncio.run(call_async(config, action, payload, timeout))
|
||||
except Exception as exc:
|
||||
# Keep the hook's retry loop independent of MCP transport exception classes.
|
||||
raise RuntimeError("ReMe MCP request failed") from exc
|
||||
|
|
@ -22,14 +22,17 @@ say there is no relevant memory. Do not turn inference into recalled facts.
|
|||
|
||||
## Status and recording
|
||||
|
||||
Call `health_check` and `version` when the user asks to check the connection. Missing tools can
|
||||
Call `reme_check_connection` when the user asks to check the saved connection.
|
||||
Use upstream `health_check` and `version` for additional service details. Missing tools can
|
||||
mean the plugin is disabled, the MCP connection failed, or the server's job allowlist excludes them.
|
||||
Check the plugin and `/mcp` status before concluding the service is stopped. The default service is
|
||||
started with `reme start workspace_dir=/absolute/path/to/workspace service.backend=http`.
|
||||
|
||||
The host's `reme/config.json` supplies `mcp_url` and hook settings to both the MCP bridge and
|
||||
automatic hooks. Users can edit this file directly. Reconnect MCP after changing its address;
|
||||
hook changes apply on the next invocation. Content-free logs live in the host's `reme/` directory.
|
||||
Users configure the plugin through the ReMe MCP server's native settings UI, including its address,
|
||||
`auto_recall`, and `auto_memory`. The UI saves the host's `reme/config.json`; MCP calls and hooks
|
||||
read the same file on each invocation. No reconnect is needed. Use `reme_settings_read` to inspect
|
||||
settings for troubleshooting. Only change settings when the user explicitly requests it; never
|
||||
turn on recording to resolve an unrelated tool error. Content-free logs live in the host's `reme/` directory.
|
||||
Hooks batch five completed turns by default;
|
||||
short batches flush at session boundaries, subject to a bounded shutdown budget. Failed batches
|
||||
remain for a later attempt. Do not manually submit the same conversation just because a queued
|
||||
|
|
|
|||
179
tests/unit/test_codex_plugin_mcp.py
Normal file
179
tests/unit/test_codex_plugin_mcp.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"""Native MCP settings, endpoint isolation, and MCP-only hook transport."""
|
||||
|
||||
# pylint: disable=missing-function-docstring,redefined-outer-name,protected-access
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.exceptions import ToolError
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[2] / "integrations/codex/plugins/reme"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin(tmp_path, monkeypatch):
|
||||
monkeypatch.syspath_prepend(str(PLUGIN))
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "host"))
|
||||
config = importlib.import_module("reme_config")
|
||||
server = importlib.import_module("mcp_server")
|
||||
transport = importlib.import_module("reme_mcp")
|
||||
return config, server, transport
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upstreams(plugin, monkeypatch):
|
||||
config, server, _ = plugin
|
||||
calls = []
|
||||
servers = {}
|
||||
for name in ("first", "second"):
|
||||
upstream = FastMCP(name)
|
||||
|
||||
def search(query: str, _name=name) -> str:
|
||||
calls.append((_name, query))
|
||||
return f"{_name}: daily/fact.md"
|
||||
|
||||
upstream.tool(search)
|
||||
servers[f"http://{name}.test/mcp"] = upstream
|
||||
|
||||
def client(url, **kwargs):
|
||||
return Client(servers[url], **kwargs)
|
||||
|
||||
monkeypatch.setattr(server, "ProxyClient", client)
|
||||
config.update_settings({"mcp_url": "http://first.test/mcp"})
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_remain_editable_offline(plugin, monkeypatch):
|
||||
config, module, _ = plugin
|
||||
connect = AsyncMock(side_effect=ConnectionError("offline"))
|
||||
monkeypatch.setattr(module.ReMeProvider, "_list_tools", connect)
|
||||
async with Client(module.create_server()) as client:
|
||||
capability = client.initialize_result.capabilities.experimental["openai/settings"]
|
||||
assert capability == {"readTool": module.SETTINGS_READ, "updateTool": module.SETTINGS_UPDATE}
|
||||
tools = {tool.name: tool for tool in await client.list_tools()}
|
||||
assert {module.SETTINGS_READ, module.SETTINGS_UPDATE, module.CHECK_CONNECTION} <= tools.keys()
|
||||
read = await client.call_tool(capability["readTool"], {})
|
||||
settings = read.structured_content
|
||||
assert settings["values"] == config.DEFAULTS
|
||||
assert settings["schema"]["properties"].keys() == config.DEFAULTS.keys()
|
||||
assert settings["layout"][0]["items"][-1]["tool"] == module.CHECK_CONNECTION
|
||||
assert tools[module.SETTINGS_READ].annotations.readOnlyHint
|
||||
assert tools[module.SETTINGS_READ].outputSchema
|
||||
assert not (config.data_dir() / "config.json").exists()
|
||||
saved = await client.call_tool(capability["updateTool"], {"set": {"auto_memory": False}})
|
||||
assert saved.structured_content["values"] == {**config.DEFAULTS, "auto_memory": False}
|
||||
assert not config.load_config()["auto_memory"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_patch_validation_and_migration(plugin, upstreams):
|
||||
config, module, _ = plugin
|
||||
path = config.data_dir() / "config.json"
|
||||
config.write_json(path, {"api_url": "http://first.test", "mcp_url": "http://first.test/mcp", "recall_limit": 9})
|
||||
before = path.read_bytes()
|
||||
async with Client(module.create_server()) as client:
|
||||
for changes in (
|
||||
{},
|
||||
{"bogus": 1},
|
||||
{"auto_memory": "false"},
|
||||
{"memory_interval": 0},
|
||||
{"mcp_url": "http://user:secret@first.test/mcp"},
|
||||
{"timezone": "Invalid/Zone"},
|
||||
{"request_timeout": 0},
|
||||
):
|
||||
with pytest.raises(ToolError):
|
||||
await client.call_tool(module.SETTINGS_UPDATE, {"set": changes})
|
||||
assert path.read_bytes() == before
|
||||
await client.call_tool(module.SETTINGS_UPDATE, {"set": {"auto_recall": False}})
|
||||
assert config.load_config()["recall_limit"] == 9
|
||||
assert config.load_config()["endpoint"] == "http://first.test"
|
||||
assert "api_url" not in path.read_text()
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
with config.lock(config.data_dir() / "config.lock"):
|
||||
with pytest.raises(RuntimeError, match="in progress"):
|
||||
config.update_settings({"auto_recall": True})
|
||||
assert not config.load_config()["auto_recall"]
|
||||
assert not upstreams
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_endpoint_switch_and_inflight_snapshot(plugin, upstreams):
|
||||
config, module, _ = plugin
|
||||
server = module.create_server()
|
||||
provider = module.ReMeProvider()
|
||||
previous = await provider.get_tool("search")
|
||||
|
||||
@server.tool
|
||||
async def captured_search(query: str):
|
||||
return await previous.run({"query": query})
|
||||
|
||||
async with Client(server) as client:
|
||||
assert (await client.call_tool("search", {"query": "one"})).data == "first: daily/fact.md"
|
||||
await client.call_tool(module.SETTINGS_UPDATE, {"set": {"mcp_url": "http://second.test/mcp"}})
|
||||
assert (await client.call_tool("search", {"query": "two"})).data == "second: daily/fact.md"
|
||||
# A resolved call retains its original endpoint even when settings change before execution.
|
||||
await client.call_tool("captured_search", {"query": "in flight"})
|
||||
assert upstreams == [("first", "one"), ("second", "two"), ("first", "in flight")]
|
||||
assert config.load_config()["mcp_url"] == "http://second.test/mcp"
|
||||
|
||||
|
||||
def test_custom_endpoint_does_not_alias_standard_or_legacy_queue(plugin):
|
||||
config, _, _ = plugin
|
||||
standard = config.validate_config({"mcp_url": "http://service.test/mcp"})
|
||||
custom = config.validate_config({"mcp_url": "http://service.test", "api_url": "http://service.test"})
|
||||
assert standard["endpoint"] == "http://service.test"
|
||||
assert custom["endpoint"] != standard["endpoint"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hooks_call_mcp_and_preserve_structured_answers(plugin, monkeypatch):
|
||||
config, _, transport = plugin
|
||||
upstream = FastMCP("test")
|
||||
calls = []
|
||||
|
||||
@upstream.tool
|
||||
def search(query: str) -> dict:
|
||||
calls.append(query)
|
||||
return {"path": "daily/fact.md"}
|
||||
|
||||
@upstream.tool
|
||||
def auto_memory() -> str:
|
||||
raise ToolError("write failed")
|
||||
|
||||
@upstream.tool
|
||||
async def delayed() -> str:
|
||||
await asyncio.sleep(1)
|
||||
return "too late"
|
||||
|
||||
monkeypatch.setattr("fastmcp.Client", lambda *args, **kwargs: Client(upstream, **kwargs))
|
||||
result = await transport.call_async(config.load_config(), "search", {"query": "fact"}, 2)
|
||||
assert result == {"success": True, "answer": {"path": "daily/fact.md"}}
|
||||
assert calls == ["fact"]
|
||||
with pytest.raises(ToolError, match="write failed"):
|
||||
await transport.call_async(config.load_config(), "auto_memory", {}, 2)
|
||||
with pytest.raises((TimeoutError, McpError)):
|
||||
await transport.call_async(config.load_config(), "delayed", {}, 0.05)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_action_uses_current_endpoint_and_propagates_failure(plugin, upstreams, monkeypatch):
|
||||
config, module, _ = plugin
|
||||
call = AsyncMock(return_value={"success": True, "answer": "healthy"})
|
||||
monkeypatch.setattr(module, "call_async", call)
|
||||
async with Client(module.create_server()) as client:
|
||||
await client.call_tool(
|
||||
module.SETTINGS_UPDATE,
|
||||
{"set": {"mcp_url": "http://second.test/mcp", "recall_timeout": 3}},
|
||||
)
|
||||
assert (await client.call_tool(module.CHECK_CONNECTION, {})).data == "healthy"
|
||||
assert call.call_args.args == (config.load_config(), "health_check", {}, 3)
|
||||
call.side_effect = ConnectionError("offline")
|
||||
with pytest.raises(ToolError, match="offline"):
|
||||
await client.call_tool(module.CHECK_CONNECTION, {})
|
||||
assert not upstreams
|
||||
|
|
@ -75,10 +75,21 @@ def test_native_manifests_resolve_within_their_own_plugin():
|
|||
if host == "claude-code":
|
||||
assert mcp["args"] == ["${CLAUDE_PLUGIN_ROOT}/hooks/mcp_bridge.py"]
|
||||
else:
|
||||
assert mcp["args"] == ["hooks/mcp_bridge.py"]
|
||||
assert mcp["args"] == ["mcp_server.py"]
|
||||
assert (root / "mcp_server.py").is_file()
|
||||
portable = json.loads((root / "plugin.json").read_text())
|
||||
assert portable["name"] == manifest["name"]
|
||||
assert portable["version"] == manifest["version"]
|
||||
assert not {"skills", "mcpServers", "interface"} & portable.keys()
|
||||
portable_mcp = json.loads((root / "mcp.json").read_text())["mcpServers"]["reme"]
|
||||
assert portable_mcp["type"] == "stdio"
|
||||
assert portable_mcp["args"] == ["${PLUGIN_ROOT}/mcp_server.py"]
|
||||
assert portable_mcp["cwd"] == "${PLUGIN_ROOT}"
|
||||
assert portable_mcp["command"] == mcp["command"]
|
||||
assert mcp["cwd"] == "."
|
||||
assert "CODEX_HOME" in mcp["env_vars"]
|
||||
assert (root / "hooks/mcp_bridge.py").is_file()
|
||||
if host == "claude-code":
|
||||
assert (root / "hooks/mcp_bridge.py").is_file()
|
||||
hooks = json.loads((root / "hooks/hooks.json").read_text())["hooks"]
|
||||
assert "SubagentStop" not in hooks
|
||||
for event, groups in hooks.items():
|
||||
|
|
@ -117,7 +128,6 @@ def test_defaults_and_mcp_endpoint_agree(adapter):
|
|||
{"mcp_url": "http://example.com/mcp?token=secret"},
|
||||
{"mcp_url": "http://example.com/mcp#fragment"},
|
||||
{"mcp_url": "http://example.com:invalid/mcp"},
|
||||
{"mcp_url": "http://example.com/other"},
|
||||
{"mcp_url": "http://example.com/\nmcp"},
|
||||
{"api_url": 12},
|
||||
{"api_url": "relative/path"},
|
||||
|
|
@ -144,7 +154,17 @@ def test_custom_mcp_path_and_http_prefix(adapter):
|
|||
"api_url": "https://example.com/jobs/reme/",
|
||||
},
|
||||
)
|
||||
assert adapter.load_config()["endpoint"] == "https://example.com/jobs/reme"
|
||||
expected = (
|
||||
"https://example.com/jobs/reme" if adapter.HOST == "claude-code" else "mcp:https://example.com/tools/reme"
|
||||
)
|
||||
assert adapter.load_config()["endpoint"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("adapter", ["claude-code"], indirect=True)
|
||||
def test_claude_custom_mcp_path_still_requires_http_base(adapter):
|
||||
adapter.write_json(adapter.data_dir() / "config.json", {"mcp_url": "https://example.com/tools"})
|
||||
with pytest.raises(ValueError):
|
||||
adapter.load_config()
|
||||
|
||||
|
||||
def test_config_edit_takes_effect_on_next_hook(adapter, monkeypatch):
|
||||
|
|
@ -162,6 +182,7 @@ def test_config_edit_takes_effect_on_next_hook(adapter, monkeypatch):
|
|||
assert call.call_args.args[0]["endpoint"] == "http://127.0.0.1:2444"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("adapter", ["claude-code"], indirect=True)
|
||||
def test_mcp_bridge_uses_persistent_configuration(adapter, monkeypatch):
|
||||
import fastmcp.server
|
||||
import fastmcp.server.providers.proxy
|
||||
|
|
@ -286,6 +307,7 @@ def test_disabling_memory_leaves_pending_queue_untouched(adapter, tmp_path, monk
|
|||
assert len(list(adapter.queue_root(config).rglob("*.json"))) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("adapter", ["claude-code"], indirect=True)
|
||||
@pytest.mark.parametrize("response", [{"success": False}, {}, [], {"success": "true"}])
|
||||
def test_http_job_failure_is_never_acknowledged(adapter, monkeypatch, response):
|
||||
def urlopen(request, timeout):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue