fix(qoder): address plugin review feedback

This commit is contained in:
star-nebula 2026-10-27 16:47:44 +08:00
parent 8158fa80e3
commit 75ab722aa2
5 changed files with 280 additions and 57 deletions

View file

@ -9,53 +9,116 @@ directory packages ReMe automatic recall, completed-turn recording, MCP memory t
## Requirements
- Qoder CLI 1.1.41 or newer with plugin support.
- Python 3.11 or newer as `python3` in the Qoder environment, with FastMCP 3.1 or newer.
- A running ReMe service exposing the MCP endpoint and the HTTP jobs `search` and `auto_memory`.
- Python 3.11 or newer available as `python3` in the environment Qoder uses for plugin hooks and
MCP servers.
- FastMCP 3.1 or newer in that same Python environment. The `reme-ai[core]` install includes it.
- A separately running ReMe service exposing the MCP endpoint and the HTTP jobs `search` and
`auto_memory`.
The default split development setup uses:
## 1. Prepare ReMe
- MCP: `http://127.0.0.1:2333/mcp`
- HTTP Job API: `http://127.0.0.1:2334`
Install ReMe and its Python dependencies from this repository. Use the Python interpreter that
Qoder can access as `python3`.
## Install
macOS or Linux:
From this checkout:
```bash
cd /path/to/ReMe
python3 -m pip install -e ".[core]"
```
Windows PowerShell:
```powershell
qodercli plugins marketplace add E:\path\to\ReMe\integrations\qoder
cd E:\path\to\ReMe
python -m pip install -e ".[core]"
```
Automatic memory generation requires model credentials. Configure `LLM_API_KEY` and
`LLM_BASE_URL` as described in the [Quick Start](../../README.md#environment-variables).
Start ReMe in a separate terminal and leave it running:
```bash
reme start
```
The default HTTP service serves both the Job API and streamable HTTP MCP on
`http://127.0.0.1:2333`. The default Qoder configuration therefore uses:
- MCP: `http://127.0.0.1:2333/mcp`
- HTTP Job API: derived from `mcp_url` as `http://127.0.0.1:2333`
If the deployment intentionally uses separate ports, set `api_url` explicitly.
## 2. Install the plugin
The plugin is distributed from the repository-local marketplace. Keep the checkout available when
installing or updating it.
macOS or Linux:
```bash
cd /path/to/ReMe
reme_root="$(pwd)"
qodercli plugins validate "$reme_root/integrations/qoder/reme" --strict
qodercli plugins marketplace add "$reme_root/integrations/qoder"
qodercli plugins install reme@reme-marketplace --scope user
```
Direct local installation is also supported:
Windows PowerShell:
```powershell
qodercli plugins install E:\path\to\ReMe\integrations\qoder\reme --scope user
cd E:\path\to\ReMe
$remeRoot = (Resolve-Path .).Path
qodercli plugins validate "$remeRoot\integrations\qoder\reme" --strict
qodercli plugins marketplace add "$remeRoot\integrations\qoder"
qodercli plugins install reme@reme-marketplace --scope user
```
Validate the package before installing:
Direct installation from the plugin directory is also supported:
```powershell
qodercli plugins validate E:\path\to\ReMe\integrations\qoder\reme --strict
```bash
qodercli plugins install /path/to/ReMe/integrations/qoder/reme --scope user
```
## Configure
## 3. Configure
The plugin reads `%USERPROFILE%\.qoder\reme\config.json`, or
`${QODER_CONFIG_DIR}/reme/config.json` when `QODER_CONFIG_DIR` is set:
The plugin reads `~/.qoder/reme/config.json`, or `${QODER_CONFIG_DIR}/reme/config.json` when
`QODER_CONFIG_DIR` is set.
macOS or Linux:
```bash
mkdir -p "$HOME/.qoder/reme"
cp integrations/qoder/reme/config.example.json "$HOME/.qoder/reme/config.json"
```
Windows PowerShell:
```powershell
New-Item -ItemType Directory -Force "$HOME\.qoder\reme"
Copy-Item integrations\qoder\reme\config.example.json "$HOME\.qoder\reme\config.json"
Copy-Item .\integrations\qoder\reme\config.example.json "$HOME\.qoder\reme\config.json"
```
`mcp_url` controls the MCP tool connection. `api_url` controls the HTTP job API used by lifecycle
hooks. The two must not be conflated when the ReMe deployment uses separate ports.
The relevant defaults are:
Set `memory_interval` to `1` for immediate per-turn writes. The remaining keys and limits match the
Claude Code integration:
```json
{
"mcp_url": "http://127.0.0.1:2333/mcp",
"api_url": "",
"memory_interval": 1
}
```
An empty `api_url` is derived from `mcp_url` when that URL ends in `/mcp`. Set `api_url` only for a
split-port or reverse-proxy deployment. The shipped example sets `memory_interval` to `1` for
immediate per-turn writes; the code default is `5`.
| Option | Default | Meaning |
| --- | --- | --- |
| `mcp_url` | `http://127.0.0.1:2333/mcp` | ReMe MCP endpoint |
| `api_url` | `""` | Optional HTTP Job API override; derived from `mcp_url` when empty |
| `auto_recall` | `true` | Search before a user prompt |
| `auto_memory` | `true` | Capture and submit completed turns |
| `recall_limit` | `5` | Search result limit |
@ -67,6 +130,9 @@ Claude Code integration:
| `context_max_chars` | `8000` | Maximum recalled context |
| `timezone` | `Asia/Shanghai` | Daily batch timezone |
Restart Qoder after installation, or run `/plugins reload`. Reconnect MCP after changing
`mcp_url`; hook configuration changes apply on the next invocation.
## Lifecycle
`UserPromptSubmit` performs bounded recall and injects results as untrusted historical evidence.
@ -77,7 +143,8 @@ endpoint and protected by per-session locks. Stable message IDs make repeated St
Qoder transcript records use Claude-style `user` and `assistant` JSONL rows. Tool calls, tool
results, reasoning blocks, metadata messages, subagents, and injected `<reme-context>` are excluded.
The hook accepts complete final JSONL rows while Qoder finishes materializing the transcript.
The hook waits for the current final JSONL row instead of treating an older identical reply as the
new turn.
## Migration from manual hooks
@ -88,16 +155,25 @@ memory-gate hooks should remain untouched.
## Verify
```powershell
qodercli plugins list
qodercli mcp list
First verify that Qoder discovered the plugin resources:
```bash
qodercli plugins list --json
```
Open a new Qoder conversation through CodexHost or directly. Success requires:
The `reme` entry should be enabled and report one skill, one `reme` MCP server, and five hook
registrations. `qodercli mcp list` does not report servers bundled by plugins, so it is not a valid
connection test.
1. a `memory_saved` row in `%USERPROFILE%\.qoder\reme\hooks.log`;
2. the corresponding queue `.done` receipt;
3. one new `session/dialog/qoder-*.jsonl` file with a user and final-assistant row.
Open a fresh Qoder session and ask it to call the ReMe `health_check` and `version` MCP tools. Then
finish one memory-worthy turn with `memory_interval` set to `1` and verify all three signals:
1. a content-free `memory_saved` row in `~/.qoder/reme/hooks.log`;
2. the corresponding queue `.done` receipt under `~/.qoder/reme/queue/`;
3. a new `session/dialog/qoder-*.jsonl` file containing the user and final-assistant rows.
Open a second Qoder session and ask for the fact from the first session. Confirm that recall returns
it before considering the installation complete.
The service-owned `auto_dream` consolidation job remains enabled according to the ReMe service
configuration; the plugin does not start another scheduler.

View file

@ -3,47 +3,139 @@
[English](./README.md)
Qoder 支持把 Skills、MCP、Hooks 和配置打包成插件。本目录提供 ReMe 的 Qoder 整包集成:
自动召回、每轮完成的对话采集、MCP 记忆工具,以及 `reme-memory` Skill。
自动召回、完成轮次记录、MCP 记忆工具,以及 `reme-memory` Skill。
## 安装
## 环境要求
- Qoder CLI 1.1.41 或更新版本,并支持插件。
- Qoder 执行 Hook 和 MCP Server 的环境中可以调用 `python3`,且 Python 版本为 3.11 或更新版本。
- 同一个 Python 环境中安装 FastMCP 3.1 或更新版本;`reme-ai[core]` 会一并安装。
- 单独运行一个 ReMe 服务,提供 MCP endpoint 以及 `search`、`auto_memory` HTTP Job。
## 1. 准备 ReMe
在本仓库中安装 ReMe 和 Python 依赖。请使用 Qoder 能通过 `python3` 调用的同一个解释器。
macOS 或 Linux:
```bash
cd /path/to/ReMe
python3 -m pip install -e ".[core]"
```
Windows PowerShell:
```powershell
qodercli plugins marketplace add E:\path\to\ReMe\integrations\qoder
cd E:\path\to\ReMe
python -m pip install -e ".[core]"
```
自动生成记忆需要模型凭据。请按照 [快速开始](../../README_ZH.md#环境变量配置) 配置
`LLM_API_KEY` 和 `LLM_BASE_URL`。
在另一个终端启动 ReMe,并保持服务运行:
```bash
reme start
```
默认 HTTP 服务在同一端口提供 Job API 和 streamable HTTP MCP:
- MCP:`http://127.0.0.1:2333/mcp`
- HTTP Job API:从 `mcp_url` 推导为 `http://127.0.0.1:2333`
如果确实使用拆分端口部署,才需要显式设置 `api_url`。
## 2. 安装插件
插件从仓库内的本地 marketplace 发布。安装或升级时需要使用这个 ReMe checkout。
macOS 或 Linux:
```bash
cd /path/to/ReMe
reme_root="$(pwd)"
qodercli plugins validate "$reme_root/integrations/qoder/reme" --strict
qodercli plugins marketplace add "$reme_root/integrations/qoder"
qodercli plugins install reme@reme-marketplace --scope user
```
Windows PowerShell:
```powershell
cd E:\path\to\ReMe
$remeRoot = (Resolve-Path .).Path
qodercli plugins validate "$remeRoot\integrations\qoder\reme" --strict
qodercli plugins marketplace add "$remeRoot\integrations\qoder"
qodercli plugins install reme@reme-marketplace --scope user
```
也可以直接从插件目录安装:
```powershell
qodercli plugins install E:\path\to\ReMe\integrations\qoder\reme --scope user
```bash
qodercli plugins install /path/to/ReMe/integrations/qoder/reme --scope user
```
安装前可运行:
## 3. 配置
```powershell
qodercli plugins validate E:\path\to\ReMe\integrations\qoder\reme --strict
用户配置位于 `~/.qoder/reme/config.json`,也可以通过 `QODER_CONFIG_DIR` 改配置根。
macOS 或 Linux:
```bash
mkdir -p "$HOME/.qoder/reme"
cp integrations/qoder/reme/config.example.json "$HOME/.qoder/reme/config.json"
```
## 配置
Windows PowerShell:
用户配置位于 `%USERPROFILE%\.qoder\reme\config.json`,也可以通过 `QODER_CONFIG_DIR` 改配置根。
默认拆分服务为:
```powershell
New-Item -ItemType Directory -Force "$HOME\.qoder\reme"
Copy-Item .\integrations\qoder\reme\config.example.json "$HOME\.qoder\reme\config.json"
```
- MCP:`http://127.0.0.1:2333/mcp`
- Hook HTTP Job API:`http://127.0.0.1:2334`
关键默认配置如下:
把 `memory_interval` 设为 `1` 可在每轮结束后立即写入。`auto_recall` 控制提醒前召回,
`auto_memory` 控制完成轮次采集;超时与上下文上限和 Claude Code 集成一致。
```json
{
"mcp_url": "http://127.0.0.1:2333/mcp",
"api_url": "",
"memory_interval": 1
}
```
当 `mcp_url` 以 `/mcp` 结尾时,空的 `api_url` 会自动推导为同一服务地址。只有拆分端口或
反向代理部署才需要设置 `api_url`。仓库中的示例把 `memory_interval` 设为 `1`,用于每轮立即写入;
代码默认值为 `5`。
| 配置项 | 默认值 | 含义 |
| --- | --- | --- |
| `mcp_url` | `http://127.0.0.1:2333/mcp` | ReMe MCP 地址 |
| `api_url` | `""` | 可选的 HTTP Job API 地址;为空时从 `mcp_url` 推导 |
| `auto_recall` | `true` | 用户提问前自动召回 |
| `auto_memory` | `true` | 采集并提交完成轮次 |
| `recall_limit` | `5` | 召回结果上限 |
| `recall_min_score` | `0` | 最低召回分数 |
| `recall_timeout` | `5` | 前台召回超时,最多 10 秒 |
| `request_timeout` | `600` | 记忆写入超时,最多 600 秒 |
| `memory_interval` | `5` | 多少轮完成后批量写入 |
| `shutdown_timeout` | `2` | 会话结束时尽力排空,最多 2 秒 |
| `context_max_chars` | `8000` | 注入的召回上下文上限 |
| `timezone` | `Asia/Shanghai` | 按天分批使用的时区 |
安装后重启 Qoder,或执行 `/plugins reload`。修改 `mcp_url` 后需要重新连接 MCP;Hook 配置会在
下次调用时生效。
## 工作方式
`UserPromptSubmit` 同步召回并注入不受信任的历史证据;`Stop` 先同步捕获本轮 user/最终
assistant 文本到本地队列,再由并行异步 Hook 调用 `POST {api_url}/auto_memory`。
`SessionStart` 重试残留队列,`SessionEnd` 做短时排空。
`UserPromptSubmit` 执行有上限的召回,并作为不受信任的历史证据注入。`Stop` 先同步捕获本轮
user/最终 assistant 文本到本地队列,再由并行异步 Hook 调用 `POST {api_url}/auto_memory`。
`SessionStart` 重试残留队列,`SessionEnd` 做短时排空。队列按服务地址隔离,并带进程锁和
`.done` 回执;稳定的消息 ID 保证重复 Stop 不会重复写入。
队列按服务地址隔离,带进程锁和 `.done` 回执;工具输出、推理、子代理、注入的
`<reme-context>` 都会被排除。Qoder 使用 Claude 风格 JSONL transcript,Hook 支持等到最后一行
完整落盘后再捕获。
Qoder 使用 Claude 风格 JSONL transcript。工具输出、推理、元数据消息、子代理和注入的
`<reme-context>` 都会被排除。当连续两轮最终回复相同时,插件会等待当前轮的新 transcript
行,而不会把上一轮的相同回复当成新轮次。
## 从手工 Hook 迁移
@ -53,11 +145,22 @@ Qoder、Orca、记忆库门禁等其他 Hook 不要删除。
## 验收
```powershell
qodercli plugins list
qodercli mcp list
首先确认 Qoder 发现了插件资源:
```bash
qodercli plugins list --json
```
新开一个 Qoder 会话后,应同时看到 `%USERPROFILE%\.qoder\reme\hooks.log` 中的
`memory_saved`、队列 `.done` 回执,以及新的 `session/dialog/qoder-*.jsonl` user/assistant 两行。
`reme` 条目应当已启用,并显示 1 个 Skill、1 个 `reme` MCP Server 和 5 个 Hook 注册项。
`qodercli mcp list` 不会列出插件内置的 MCP Server,因此不能作为连接是否正常的判断依据。
新开一个 Qoder 会话,让 Agent 实际调用 ReMe 的 `health_check` 和 `version` MCP 工具。然后把
`memory_interval` 设为 `1`,完成一轮值得记忆的对话,并同时确认:
1. `~/.qoder/reme/hooks.log` 中出现不包含正文的 `memory_saved`;
2. `~/.qoder/reme/queue/` 下出现对应的 `.done` 回执;
3. 新的 `session/dialog/qoder-*.jsonl` 中包含该轮 user 和最终 assistant 两行。
最后新开第二个 Qoder 会话,询问第一轮中的事实,确认自动召回成功后再认为安装完成。
`auto_dream` 仍由 ReMe 服务自身调度,插件不会额外启动调度器。

View file

@ -1,6 +1,6 @@
{
"mcp_url": "http://127.0.0.1:2333/mcp",
"api_url": "http://127.0.0.1:2334",
"api_url": "",
"auto_recall": true,
"auto_memory": true,
"recall_limit": 5,

View file

@ -265,9 +265,15 @@ def capture(config: dict, payload: dict) -> Path | None:
if root is None:
return None
deadline = time.monotonic() + CAPTURE_SETTLE_SECONDS
messages = []
while True:
messages = completed_turn(payload, root.name)
if len(messages) == 2 or time.monotonic() >= deadline:
if len(messages) == 2:
# An identical reply may still resolve to the previous turn while Qoder appends this turn.
key = digest(messages[-1]["id"])
if not (root / f"{key}.done").exists() and not (root / f"{key}.json").exists():
break
if time.monotonic() >= deadline:
break
time.sleep(0.1)
if len(messages) != 2:

View file

@ -139,6 +139,44 @@ def test_claude_synchronous_capture_preserves_identical_replies_before_delayed_d
assert len(list(adapter.queue_root(adapter.load_config()).rglob("*.done"))) == 2
def test_qoder_capture_waits_for_delayed_identical_reply(adapter, tmp_path, monkeypatch):
require_host(adapter, "qoder")
monkeypatch.setattr(adapter, "CAPTURE_SETTLE_SECONDS", 0.5)
config = adapter.load_config()
path = tmp_path / "transcript.jsonl"
first = transcript(adapter, path, [("first", "Saved.")])
root = adapter.capture(config, first)
first_pending = next(root.glob("*.json"))
adapter.write_json(first_pending.with_suffix(".done"), {})
first_pending.unlink()
second = transcript(adapter, path, [("first", "Saved."), ("second", "Saved.")])
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
delayed_reply = lines[-1]
path.write_text("".join(lines[:-1]), encoding="utf-8")
def append_delayed_reply():
with path.open("a", encoding="utf-8") as handle:
handle.write(delayed_reply)
captured = []
worker = threading.Thread(target=lambda: captured.append(adapter.capture(config, second)))
timer = threading.Timer(0.05, append_delayed_reply)
timer.start()
worker.start()
worker.join(3)
timer.join(3)
assert not worker.is_alive()
assert not timer.is_alive()
assert captured == [root]
pending = list(root.glob("*.json"))
assert len(pending) == 1
messages = json.loads(pending[0].read_text(encoding="utf-8"))["messages"]
assert [message["content"][0]["text"] for message in messages] == ["second", "Saved."]
def test_claude_delivery_waits_for_parallel_synchronous_capture(adapter, tmp_path, monkeypatch):
require_structured_transcript_host(adapter)
write_config(adapter, {"memory_interval": 1})