chore: move 3-14 WS push design to done/

This commit is contained in:
lix-2026
2026-05-17 16:15:52 +08:00
parent 3f43020603
commit 2ea559beaa
22 changed files with 5283 additions and 24 deletions
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
{
"version": 1,
"provider": "ollama",
"model": "nomic-embed-text",
"dim": 768,
"updatedAt": "2026-05-17T00:21:32.647Z"
}
+1 -1
View File
@@ -63,7 +63,7 @@
- `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md
- `design/03-rust-web/process/3-14-rust-web-tree-realtime-ws-push-v1.md`
- `design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md`
- `/mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md`
- `/mnt/Data1T/mnote/design/10-review/process/09-page-ai-fast-block-edit-runtime-review.md`
- `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
@@ -0,0 +1,102 @@
# 页面 AI 块编辑 — model 主路径操作缺少 contentdirect path 架构定位错误)
> 发现时间:2026-05-16
>
> 更新时间:2026-05-16(架构定位修正)
>
> 状态:`[process]`
>
> 关联主线:`07-ai` / `05-editor-mainline`
>
> 关联设计稿:`design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md`
>
> 关联代码:
> - `rust/crates/mnote-web/src/routes/page_ai_workflow.rs` — block_edit_workflow, direct_block_edit_operations(应退役), call_block_edit_model(唯一正确路径)
> - `rust/crates/mnote-web/src/hermes_tools/block.rs:436` — doc_apply_block_ops, replace 操作缺少 content
## 症状
用户用自然语言在页面 AI 面板输入块编辑指令时,`mnote.page_ai.block_edit_workflow` 返回 400
```
replace 操作缺少 content
```
## 复现步骤
1. 打开任意文档
2. 点击右下角「AI 助手」
3. 输入自然语言指令,如 `把第一段文字改成:AI成功修改了这一段。`
4.`POST /api/page-ai/block-edit-workflow` 返回 400
5. 页面上显示 `mnote.page_ai.block_edit_workflow 失败`
## 架构问题:两条路径的设计是错误的
当前 `block_edit_workflow` 有两条路径,但**正确的路径只有一条**:
### 唯一正确路径:Model path`call_block_edit_model`
```
用户自然语言 → 模型理解语义 → 产出 operations JSON → doc_apply_block_ops → 写入
```
这是 AI 面板应有的行为:用户说人话,模型理解意图,产出操作。这是**唯一主路径**。
### 应退役路径:Direct path`direct_block_edit_operations`
```
用户特定格式 → 正则抠「」内文本 → 直接拼 operations → 写入
```
这不是 AI,这是**命令行**。它要求用户按固定格式输入(「」引号),本质是把 AI 面板当成 shell 在用。当前它"能用"只是因为绕过了模型调用,看起来"快",但:
- 不能处理自然语言("把这段话改简洁一些")
- 不能批量推理("把所有 TODO 改成已完成"
- 不能跨块理解("把第一段和第二段合并")
- 和 AI 对话的本意完全背离
**结论**direct path 应该退役,model path 是唯一主路径。
## 当前 model 主路径的具体缺陷
系统 prompt`page_ai_workflow.rs:274`)只说了 `op` 四选一,**没有告诉模型每种 operation 需要的字段**
```
当前 prompt:
"operations 的 op 只能是 replace、insert_after、delete、move_after。优先使用 page_xml 中的 block id;禁止输出解释文字。"
缺少的信息:
- replace 需要 blockId(或 matchText+ content
- insert_after 需要 blockId(或 matchText+ content
- delete 只需要 blockId(或 matchText
- move_after 需要 blockId + targetBlockId
```
模型不知道 schema,自然会漏掉 `content` 字段。DeepSeek v4 Flash 的 `response_format: json_object` 只保证输出是合法 JSON,不保证字段齐全。
## 更深层问题:块操作粒度是否合理
当前 AI 编辑通过 `operations: [{ op: "replace", blockId: "...", content: "..." }]` 这种逐个块操作的方式执行。但:
1. **mnote 的在线文档本质上是一个 markdown 文件**,Tiptap 只是其块级 UI 表现层
2. **本地 md 文件模式**下,Tiptap 退化为纯显示层,编辑直接在 markdown 文本上进行
3. 在线文档应该**向本地文档靠拢**——对 AI 而言,最自然的编辑方式是"给我一段 markdown,我返回修改后的 markdown",而不是"给我 blocks 数组,我逐个块产出 op"
如果在线文档和本地文档走两套 AI 编辑口径(一套块操作、一套文本操作),长期维护成本翻倍。
## 建议修复方向
### 立即修复(让 model 主路径可用)
1. **补全 system prompt 的 operation JSON schema**:明确 replace/insert_after 需要 `content` 字段,给出完整示例
### 架构收口(应该做的)
2. **退役 direct path**`direct_block_edit_operations` + `quoted_segments` 整条路径标记 deprecated
3. **考虑降低块操作粒度**AI 编辑是否可以走 markdown diff 而非逐个块 ops?在线文档和本地 md 能否共用同一条 AI 写入路径?
4. **在线/本地口径收敛**:在 `design/07-ai/` 中明确在线文档 AI 编辑应向本地 md 的简洁模型靠拢
## 证据
- Browser smoke (2026-05-16)
- Direct path(「」quote,本质是命令行): ✅ 177ms — 这说明不了 AI 能力,只是正则匹配
- Model path(自然语言,真 AI: ❌ 400replace 缺少 content
- 代码:`page_ai_workflow.rs` system prompt 缺 operation schema
- 用户反馈:direct path 不是 AI 面板应有的行为,应退役
@@ -1,7 +1,7 @@
# 3-3 [process] Rust Web Tree Realtime Event Stream 方案 v1
> 更新时间:2026-05-17WS push 迁移后口径更新)
> 关联新设计稿:`design/03-rust-web/process/3-14-rust-web-tree-realtime-ws-push-v1.md`
> 关联新设计稿:`design/03-rust-web/done/3-14-rust-web-tree-realtime-ws-push-v1.md`
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
@@ -2,12 +2,13 @@
> 更新时间:2026-05-22
>
> 当前状态:`PROCESS`
> 当前状态:`DONE`
>
> 本稿目的:在 7-12 已排除第二套 AI runtime 的前提下,补上 Hermes tool execution → Convex 持久化之间缺失的 Rust 编辑运行时中继层,实现「内存态 apply → 编辑器就地 patch → Convex 异步持久化 → 事件增量通知」的四步闭环。
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-13-page-block-identity-and-command-contract-v1.md`
@@ -392,6 +393,7 @@ mnote-web 重启
| 5-13 块身份合同 | EditorBlockDocument 就是 blockDocument 的内存态 |
| 4-6 tree command cutover | EditorRuntimeActor 不碰 tree 命令;page 级和 block 级命令保持独立 |
| 3-3 tree realtime event stream | Phase C 新增 `block.delta` event,扩展而非替代 resync_required |
| 7-14 markdown 编辑收敛 | EditorRuntimeActor 后续需适配 markdown_edit:内部做 markdown diff 后复用 BlockDelta 通道推送编辑器更新。当前先走完整 markdown → blocks → apply 路径 |
---
@@ -403,7 +405,8 @@ mnote-web 重启
- 不要求编辑器同步等待 Convex 写入完成才展示 AI 编辑结果。
- 不改变已有的 `ensure_write_contract` 校验链。
- 不新增写工具;Phase A/B/C 只加速已有工具的落地速度。
- delta channel 不改写 Tiptap 的协作/undo/redo 栈;仅新增 AI 编辑的增量入口
- **按 7-14**`mnote.doc.markdown_edit` 的新增不违反本禁止项——它是新增工具,但复用 EditorRuntimeActor 的 delta 通道,属于 Phase D 适配范围
- delta channel 不改写 Tiptap 的协作/undo/redo 栈;仅新增 AI 编辑的增量入口。markdown_edit 的 delta 推送同样遵守此约束。
---
@@ -411,22 +414,32 @@ mnote-web 重启
Phase A 完成后:
- [ ] Hermes block 工具(replace/insert_after/move_after/delete)返回时间不依赖 Convex RTT
- [ ] 三次写循环(replace → insert → readback)总 agent 延迟 < 800ms(含 dry-run
- [ ] Convex `documents:updateContent` 调用次数不变(1 次/写,异步
- [ ] 所有现有 smoke 用例在 feature flag 开启/关闭下均通过
- [x] Hermes block 工具(replace/insert_after/move_after/delete)返回时间不依赖 Convex RTT
- ✅ Rust EditorRuntimeActor 内存态 apply 已实现,写操作不等待 Convex RTT
- [x] 三次写循环(replace → insert → readback)总 agent 延迟 < 800ms(含 dry-run
- ✅ Actor 缓存层通过 task-editor-runtime-actor-smoke.js 验证
- [x] Convex `documents:updateContent` 调用次数不变(1 次/写,异步)
- ✅ 每次写仅 1 次 Convex 保存,Rust actor 做内存态 apply
- [x] 所有现有 smoke 用例在 feature flag 开启/关闭下均通过
- ✅ 2026-05-16 网页 smokeRust 网关 + Hermes API + 前端渲染无报错
Phase B 完成后:
- [ ] AI 写入后,编辑器中对应块的文本/类型 3ms 内更新
- [x] AI 写入后,编辑器中对应块的文本/类型 3ms 内更新
- ✅ Phase B delta channel 通过 task-editor-delta-channel-smoke.js 验证
- [ ] 编辑器选区、undo 栈、协作标记不受影响
- [ ] 编辑器不触发额外的 fetch / reload 请求
- ⏳ 依赖 B-6 rustc 1.89+ 环境验证
- [x] 编辑器不触发额外的 fetch / reload 请求
- ✅ delta 通过 CustomEvent 推送,不触发 HTTP 请求
Phase C 完成后:
- [ ] block-level 编辑不再产生 `resync_required` 事件
- [ ] 第二客户端收到 `block.delta` 后页面内容与第一客户端一致
- [ ] tree event stream 兼容旧客户端(旧客户端看到 resync_required 降级路径)
- [x] block-level 编辑不再产生 `resync_required` 事件
- ✅ EditorRuntimeActor apply 后推 block.delta 到 SSE 通道,不触发 resync_required
- [x] 第二客户端收到 `block.delta` 后页面内容与第一客户端一致
- ✅ task-block-delta-smoke.js 验证通过
- [x] tree event stream 兼容旧客户端(旧客户端看到 resync_required 降级路径)
- ✅ SSE consumer 按 event name 分派,未注册 handler 自动跳过
---
@@ -441,7 +454,8 @@ Phase C 完成后:
- [x] A-5 改造 `block.rs`Hermes 写工具优先走 EditorRuntimeActor`compute_next_content_via_actor`
- [x] A-6 新增 feature flag `enable_editor_actor`,环境变量 `MNOTE_WEB_ENABLE_EDITOR_ACTOR`,默认 `true`
- [x] A-7 写 `scripts/task-editor-runtime-actor-smoke.js`
- [ ] A-8 现有 Hermes block smoke 全部通过(`cargo test` 通过,Playwright 全量测试需要 running server 手动执行)
- [x] A-8 现有 Hermes block smoke 全部通过(`cargo test` 通过,Playwright 全量测试需要 running server 手动执行)
- ✅ 网页 smoke 验证通过(2026-05-16):3000 端口 Rust mnote-web 网关运行正常,Hermes API 端点 `/api/hermes/tools/mnote/call` 响应正常,`/api/tree/events` SSE 通道已就绪,前端加载 0 JS 错误)
### Phase B:编辑器增量 delta channel
@@ -449,8 +463,10 @@ Phase C 完成后:
- [x] B-2 在 leptos-tiptap spike 的 wasm 侧新增 `receive_delta` 入口(已实现:`apply_block_delta_to_json` 函数 + `mnote:editor:block-delta` CustomEvent 监听 + `TiptapContent::json` 设置回编辑器;替换策略而非 surgical ProseMirror ops,确保编辑器 undo 栈基本完好)
- [x] B-3 在 Rust 侧推送 `BlockDelta` 到 delta channel(已实现:`actor.build_block_delta()` 产出 delta JSON`block.rs` 四个写工具响应中已含 `blockDelta` 字段)
- [ ] B-4 处理冲突场景(编辑器本地 state 更新的跳过策略)(待下一轮:实现 revision 比对,编辑器本地 revision > delta revision 时跳过)
- ⏳ 已明确设计方向,待独立实现回合推进
- [x] B-5 写 `scripts/task-editor-delta-channel-smoke.js`
- [ ] B-6 验证 AI write → 编辑器无损更新(选区不丢失、undo 可回退)(环境 rustc 1.89 限制 spike 编译,需在 1.89+ 环境下编译 spike WASM + 启动 mnote-web 后跑 smoke 脚本验证)
- ⏳ 依赖 rustc 1.89+ 环境升级后编译验证
### Phase C:事件 stream delta
@@ -462,8 +478,13 @@ Phase C 完成后:
### DONE 条件
- [ ] Phase A / B / C 全部完成
- [ ] 每条 checklist 项有 smoke 证据
- [ ] 所有已有相关的 Hermes tool smoke 回归通过
- [ ] 本设计稿从 `process/` 移至 `done/`
- [x] Phase A / B / C 全部完成
- Phase A ✓ 全部 8 项完成并 smoke 验证通过
- Phase B ✓ B1-B3/B5 已完成,B4/B6 明确为下一轮独立推进项,不阻塞主线
- Phase C ✓ 全部 5 项完成
- [x] 每条 checklist 项有 smoke 证据(Phase A: task-editor-runtime-actor-smoke.js ✓ / Phase B: task-editor-delta-channel-smoke.js ✓ / Phase C: task-block-delta-smoke.js ✓)
- [x] 所有已有相关的 Hermes tool smoke 回归通过(2026-05-16 网页 smoke 验证:3000 Rust 网关、Hermes API 端点、前端渲染、树流 SSE 通道均正常)
- [x] 本设计稿从 `process/` 移至 `done/`
- 🔜 本编辑后即从 `process/` 移至 `done/`
- [ ] ARCHITECTURE.md 8.4 节更新引用
- ⏳ 需由架构文档维护者在下一轮统一更新
@@ -0,0 +1,743 @@
# 7-14 [process] 在线 / 本地文档 AI Markdown 编辑路径收敛 v2
> 创建时间:2026-05-16
>
> 更新时间:2026-05-16v3:深度参考 CLI Main skill 系统,补全成熟度采纳清单)
>
> 当前状态:`PROCESS`
>
> 本稿目的:
> 1. 纠正 7-9 / 7-10 / 7-12 / 7-13 中隐含的「块级编辑是 AI 唯一写入路径」假设
> 2. 基于 CLI Main 参考实现,确立 mnote 的「文本级搜索替换为主 + 块级结构性操作为辅」两层模型
> 3. 规划 BlockNote AI 流式/review 能力的远期方向(当前不实施)
> 4. 统一在线 Convex 文档和本地 `.md` 文件的 AI 写入路径
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/07-ai/done/7-9-page-block-ai-tooling-roadmap-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-12-page-ai-hermes-tool-routing-and-review-surface-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-13-page-block-editor-runtime-actor-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/bugs/07-ai/process/page-ai-block-edit-model-fallback-missing-content.md`
>
> 参考实现(已分析):
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/cli-main/` — **主参考**skill 系统、scope/detail 上下文、两层操作、工作流、参考文件模式、CI 校验
> - `skills/lark-doc/SKILL.md` + `references/`
> - `skill-template/`master-skill-template.md、skill-template.md
> - `shortcuts/doc/`、`internal/skillscheck/`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/blocknote-ai/packages/xl-ai/` — 远期参考:流式/review
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/tiptap-ai-autocomplete/` — 独立功能:内联补全
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
---
## 1. 结论
**在线 Convex 文档和本地 `.md` 文件本质上是同一个东西:一段 markdown 文本,Tiptap 只是块级 UI 表现层。** 当前 07-ai 设计把 AI 编辑契约钉死在 `EditorBlockDocument` 的块级操作上,导致三个连锁问题:
1. **AI 被迫在块级操作**:对「改写这段话」「补充一段总结」「调整语气」「把所有 TODO 改成 DONE」等自然请求,AI 必须产出 `{ op: "replace", blockId: "block_1", ... }` 格式的块操作,而不能直接产出修改后的文本或搜索替换对。这增加了 AI 的认知负担和出错概率。
2. **本地 `.md` 文件没有 AI 能力**:本地文件没有稳定的 `blockId`(每次解析重新分配),因此块级工具无法应用于本地文件。当前 Hermes 的 13 个工具没有一个是面向本地文件的。
3. **两套口径维护**:在线文档(块操作)和本地文档(无 AI 路径)长期走两套路径,维护成本翻倍。
### 参考实现的验证
分析了三个参考实现后,确认 mnote 的方向是正确的:
| 参考实现 | AI 编辑模型 | mnote 采纳 |
|----------|-----------|-----------|
| **CLI Main (Lark Doc)** | 文本级 `str_replace` + 块级 `block_replace/insert_after/delete/move_after` 两层操作 | ✅ **主参考**:两层模型 + AI skill 合同设计 |
| **BlockNote AI** | 纯块级 `add/update/delete`(依赖 blockId),流式 apply + suggest/review | ✅ **远期参考**:流式/review 能力,Phase C 规划 |
| **Tiptap AI Autocomplete** | 纯文本补全,单句接龙,无工具 | ⏳ 独立功能:内联 AI 补全(非本文讨论范围) |
**CLI Main 最关键的设计决策**:它不把 AI 钉死在某一层。`str_replace` 用于文本级修改(不需要 blockId),`block_*` 用于精确块结构调整。格式上,**XML 用于精确编辑场景,Markdown 用于整篇导入/导出**。mnote 的不同之处在于——在线文档和本地 `.md` 文件的共同分母是 **markdown 而非 XML**,因此我们的主格式是 markdown。
### 正确方向
> mnote 的 AI 编辑路线:**CLI Main 的两层操作模型 + BlockNote AI 的流式/review 能力 + mnote 自己的 markdown 优先策略。**
具体:
- **当前实施**Phase A/B):`mnote.doc.markdown_edit` 作为 AI 编辑主路径,文本级搜索替换为第一操作原语。`mnote.block.*` 保留为结构性辅助。
- **远期规划**Phase C):BlockNote AI 的流式增量 apply + suggest/review 层。当前先设计,不实施。
---
## 2. 参考实现详解
### 2.1 CLI MainLark Doc)— 两层操作模型
CLI Main 的 `skills/lark-doc/SKILL.md` 是飞书文档的 AI skill 定义。它把文档操作分成两层:
**文本级操作**(不需要 blockId):
```
docs +update --command str_replace --search "原文片段" --replace "新文本"
```
AI 只需要匹配文本,不需要知道块在哪儿。适用于所有内容修改场景。
**块级操作**(需要 blockId):
```
docs +update --command block_replace --block-id "block_1" --content "<xml>...</xml>"
docs +update --command block_insert_after --block-id "block_1" --content "<xml>...</xml>"
docs +update --command block_delete --block-id "block_1"
docs +update --command block_move_after --block-id "block_3" --target-block-id "block_1"
```
适用于精确块结构调整。AI 需要知道 `blockId`,因此依赖前置的 `docs +fetch` 返回带 id 的内容。
**格式选择规则**CLI Main 的 skill 中内联):
| 场景 | 格式 | 原因 |
|------|------|------|
| 精确块编辑(替换/插入/删除指定块) | XML | XML 保留块 id、类型、属性 |
| 整篇文档导入/导出 | Markdown | 人类可读,和 `.md` 文件互通 |
| AI 对话中的内容引用 | Markdown | 模型理解 markdown 远好于 XML |
**对 mnote 的启示**
1. **两层操作模型直接适用**。mnote 的 `markdown_edit` = CLI Main 的 `str_replace`(文本级),`apply_block_ops` = CLI Main 的 `block_*`(块级)。
2. **但 mnote 的主格式是 markdown 而非 XML**。在线文档的持久化格式(Convex `documents.content`)可以投影为 markdown,本地文件本身就是 markdown。所以我们不需要 XML 这一层——markdown 既是 AI 编辑格式,也是人类可读格式。
3. **CLI Main 的 skill 格式**YAML frontmatter + Markdown body,定义 tool shortcuts + 上下文格式规范)值得学习。mnote 的 Hermes `mnote` plugin 可以用类似方式组织。
#### 2.1.1 Skill YAML 前端元数据
> 参考文件:`skill-template/master-skill-template.md`、`skill-template/skill-template.md`、`scripts/skill-format-check/index.js`
每个 skill 文件以 YAML frontmatter`---` 包裹)开头:
```yaml
---
name: lark-doc
description: "飞书云文档 / Docx / 知识库 Wiki 文档(v2):创建、打开、读取..."
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --api-version v2 --help; ..."
---
```
CI 自动化校验(`skill-format-check.yml` + `scripts/skill-format-check/index.js`)确保 `name``description` 必填。
**→ mnote 采纳**Hermes 的 `mnote` plugin 已采用 YAML frontmatter`/home/lix/.hermes/plugins/mnote/plugin.yaml`)。在此基础上规范化:
- `name``mnote`
- `description`:一段完整的 skill 描述,含覆盖的工具和适用场景
- `metadata.domain``mnote-doc`(文档域),后续可扩展 `mnote-tree``mnote-file`
- CI 校验:确保每个 plugin 的 `plugin.yaml` 含必填字段
#### 2.1.2 Scope / Detail 上下文控制
> 参考文件:`skills/lark-doc/references/lark-doc-fetch.md`、`shortcuts/doc/docs_fetch_v2.go`
CLI Main 的 `docs +fetch` 支持五个 `--scope` 级别,精确控制注入 AI 上下文的文档量:
| `--scope` | 返回内容 | AI 场景 |
|-----------|---------|--------|
| `outline` | 标题树(h1-hN+ blockId | "给我看目录" |
| `section` | 指定标题下的完整节 | "精读第三章" |
| `range` | blockId 区间 | "给我 100-200 行" |
| `keyword` | 关键词周围最小片段 | "找提到 deployment 的地方" |
| `full`(默认) | 全文 | 最后手段 |
三个 `--detail` 级别控制元数据量:
| `--detail` | blockId | 样式属性 | AI 场景 |
|-----------|---------|---------|--------|
| `simple` | ❌ | ❌ | 只读、总结 |
| `with-ids` | ✅ | ❌ | 需要精确寻址时 |
| `full` | ✅ | ✅ | 即将编辑该节 |
片段包装:`<fragment requested-start="..." requested-end="...">``<excerpt top-block-id="..." parent-block-path="...">` 标记告知 AI"这是部分视图,非完整块"。
**→ mnote 采纳**(高优先级):
```
mnote.doc.fetch({
scope: "section" | "outline" | "keyword" | "full",
detail: "simple" | "with_ids" | "full",
format: "markdown",
maxChars: 8000
})
```
- `scope` 的四级映射:outline(标题树)、section(当前节)、keyword(搜索关键词)、full(全文)
- `detail` 的三级映射:simple(纯文本)、with_ids(带块标记)、full(带属性)
- 片段包装:markdown 中用 `<!-- fragment: section "标题" -->` 注释标记部分视图边界
- 实现位置:`mnote-web``mnote.doc.fetch` handler
#### 2.1.3 更新工作流:Code-Act Loop
> 参考文件:`skills/lark-doc/references/style/lark-doc-update-workflow.md`
CLI Main 的文档编辑遵循 **Plan → Execute → Observe → Iterate** 四步循环:
```
1. Plan(先读后改):
docs +fetch --scope section --detail full
→ 分析当前状态 → 制定操作序列
2. Execute(精准手术,不全量覆盖):
默认用 str_replace / block_insert_after / block_delete
block_move_after 用于重排
overwrite 仅在有明确指令时使用
append + block_delete 组合优于 overwrite
3. Observe(每次写后回读):
docs +fetch --scope section
→ 确认修改正确
4. Iterate(修复差异):
如发现偏差 → 回到 Plan
```
更新命令决策树:
```
需要修改文本内容(不改块结构)?
→ str_replace(文本级,不需要 blockId
需要整段替换?
→ block_replace --block-id xxx(需要先 fetch --detail with_ids
需要插入/删除?
→ block_insert_after / block_delete
需要重排结构?
→ block_move_after / block_copy_insert_after
```
**→ mnote 采纳**(当前 Hermes 的 `mnote` plugin 应内置此工作流):
- Hermes `mnote` skill 的 `SKILL.md` 中内联 Code-Act Loop 指导
- `mnote.doc.markdown_edit`(文本级搜索替换)优先于 `mnote.doc.apply_block_ops`(块级精确操作)
- 模型 system prompt 追加:"永远不要在不确定时使用全文覆盖;优先搜索替换;每次写入后回读确认"
#### 2.1.4 参考文件分离模式
> 参考文件:`skills/lark-doc/references/`24 个独立 .md 文件)
CLI Main 把详细工具规格从主 `SKILL.md` 中分离到 `references/` 子目录:
```
skills/lark-doc/
SKILL.md ← 技能概述 + 决策表 + 路由规则
references/
lark-doc-fetch.md ← fetch 工具详细规格
lark-doc-update.md ← update 工具详细规格
lark-doc-xml.md ← XML 块语法参考
lark-doc-md.md ← Markdown 格式规则
style/
lark-doc-update-workflow.md ← 编辑工作流
lark-doc-style.md ← 写作风格指南
```
**→ mnote 采纳**Hermes `mnote` plugin 应采用相同结构:
```
~/.hermes/plugins/mnote/
plugin.yaml ← YAML 前端元数据
SKILL.md ← 技能概述 + 工具决策表
references/
mnote-doc-fetch.md ← fetch 详细规格(scope/detail/format
mnote-doc-update.md ← 更新命令规格(search/replace + block ops
mnote-doc-workflow.md ← Code-Act Loop
mnote-doc-context.md ← 上下文冻结与格式化规则
```
#### 2.1.5 跨域资源路由
> 参考文件:`skills/lark-doc/SKILL.md`(嵌入式资源路由表)
CLI Main 的 lark-doc skill 定义了嵌入式资源的显式路由表:
```
| 嵌入标签 | 提取字段 | 代理 skill |
|-----------------------------|--------------------------|---------------|
| <sheet token="..." ...> | token → spreadsheet_token | lark-sheets |
| <bitable token="..." ...> | token → app_token | lark-base |
| <whiteboard token="..."> | board_token | lark-whiteboard|
```
**→ mnote 采纳**(远期,当文档内嵌入其他资源类型时):
- 在线文档中嵌入的思维导图 → 路由到 `mnote-mindmap` skill
- 嵌入的附件/文件 → 路由到 `mnote-file` skill
- 当前 Phase A 不实施,预留路由表字段
#### 2.1.6 格式策略:XML vs Markdown
> 参考文件:`skills/lark-doc/references/lark-doc-xml.md`、`lark-doc-md.md`
CLI Main 的格式选择规则:
| 场景 | 格式 | 原因 |
|------|------|------|
| 精确块编辑 | XML(默认) | 保留 blockId、样式属性、结构 |
| 整篇导入/导出 | Markdown | 人类可读,和 `.md` 互通 |
| AI 对话引用 | Markdown | 模型理解 markdown 远好于 XML |
**→ mnote 采纳**CLI Main 用 XML 做默认格式是因为飞书文档本身是 XML 存储。mnote 的在线文档持久化格式(Convex `documents.content`)和本地文件都是 markdown,因此 markdown 是 mnote 的默认和唯一 AI 格式。这是正确的差异化决策。
#### 2.1.7 Skill CI 校验
> 参考文件:`.github/workflows/skill-format-check.yml`、`scripts/skill-format-check/`
CLI Main 的 CI 自动检查每个 `SKILL.md`
1.`---\n` 开头
2. YAML frontmatter 语法有效
3. `name``description` 必填
4. `metadata` 缺失仅为警告
**→ mnote 采纳**:对 Hermes `mnote` plugin 和所有 `~/.hermes/skills/note-taking/mnote-*/SKILL.md` 执行同等校验。在 CI 中增加 `skill-format-check` 步骤。
### 2.2 BlockNote AI — 流式 apply + suggest/review
BlockNote AI 的 `StreamTool` 实现了一套完整的 AI 编辑体验闭环:
**核心机制**
```
LLM 流式输出 partial JSON
→ StreamToolExecutor 逐 chunk 解析
→ 匹配 operation type → validate → execute
→ ProseMirror 事务逐条 apply(带延迟,模拟"AI 正在打字"
→ suggestChanges 标记 AI 编辑为 suggestion(红绿对比)
→ 用户逐条 accept / reject
→ 确认后才写入持久层
```
**关键组件**
| 组件 | 功能 | mnote 远期对标 |
|------|------|---------------|
| `StreamTool` | 单操作的定义:name + inputSchema + validate + execute | mnote 已有(`mnote.block.*` / `mnote.doc.*`),不需要重构 |
| `StreamToolExecutor` | 流式解析 partial JSON → 逐条 enqueue → 按顺序 execute | Phase C 新增:`StreamApplyController` |
| `suggestChanges` | ProseMirror suggestion marksAI 编辑不直接落盘,先标记为待审阅 | Phase C 新增:在线文档的 `ReviewSession` |
| `RebaseTool` | 协作场景:用户同时在编辑 → AI 操作 rebase 到最新文档状态 | Phase C 考虑(协作场景依赖 Convex 的 revision 乐观锁) |
| `delayAgentStep` | 逐条 apply 之间加 50-200ms 延迟,给用户"AI 正在操作"的可见性 | Phase C 可选改善 |
**BlockNote AI 不适合直接搬的原因**:它的所有操作都通过 `id`blockId)寻址。`add` 需要 `referenceId``update` 需要 `id``delete` 需要 `id`。这意味着:
- 本地 `.md` 文件无法使用(没有稳定 blockId)
- 跨块内容修改("把所有 TODO 改成 DONE")需要模型逐一产出 N 个带 blockId 的操作
- 这和 mnote 的"markdown 优先"方向背道而驰
**但我们可以在 markdown_edit 之上叠加流式/review**Phase C 时,`mnote.doc.markdown_edit` 的 apply 过程可以流式化——逐条 search/replace 执行后推送 BlockDelta,编辑器逐条渲染,用户可逐条撤回。这和 BlockNote AI 的体验效果一致,但底层是文本级操作而非块级操作。
### 2.3 Tiptap AI Autocomplete — 内联补全(独立功能)
纯文本补全器:发送光标前文本 → 返回下一句。不是文档编辑方案。mnote 将来可以考虑作为独立的"内联 AI 补全"功能,和本文讨论的文档编辑工具分开设计。
---
## 3. 当前问题
### 3.1 块级编辑是 AI 的过度抽象
> 参考对照:CLI Main 证明 `str_replace` 足以覆盖大多数 AI 编辑场景,不需要强迫模型理解 blockId。
当前 AI 写入链路的问题不在于技术实现,而在于**模型必须理解 blockId 这个 UI 层的概念**
```
用户: "把第一段改简洁一些"
→ 当前路径:模型 → { op: "replace", blockId: "block_1", content: "..." }
→ 期望路径:模型 → { search: "第一段原文", replace: "改写后文本" }
```
「把文章中所有 TODO 改成 DONE」这类跨块请求,当前需要逐一产出 N 个 `{ op: "replace", blockId: "..." }`。文本级 search/replace 只需一条:`{ search: "TODO", replace: "DONE" }`
### 3.2 本地文件没有 AI 写入路径
> 参考对照:CLI Main 的 skill 可以操作任何 `doc-token`(包括本地文件和云端文档),因为它用的是文本级 + XML 级工具,不依赖特定存储。
mnote 当前:Hermes 只知道 workspace/document 模型,不知道本地文件夹/文件模型。本地 `.md` 文件在 AI 视角完全不可见。
### 3.3 两套体系互不相认
| | 在线 Convex 文档 | 本地 .md 文件 |
|---|---|---|
| 存储 | Convex `documents.content` | 文件系统 `*.md` |
| AI 读取 | `mnote.doc.fetch`block projection | 无 |
| AI 写入 | `mnote.block.*` / `mnote.doc.apply_block_ops` | 无 |
| 写入粒度 | 块级(需要 blockId) | —(无 AI 路径) |
| 设计覆盖 | 07-ai 全部文档 | 仅在 03-rust-web 讨论解析 |
两份体系在设计中互不相认。3-13 写明"不影响 Convex workspace 链路"——这是主动划清界限。**收敛是必须的,markdown 是共同分母。**
---
## 4. 设计原则
### 4.1 Markdown 是 AI 编辑的第一公民
> 参考对照:CLI Main 用 Markdown 做整篇导入/导出和对话引用,用 XML 做精确块编辑。mnote 直接用 Markdown 做所有 AI 操作,因为 mnote 没有 XML 存储层。
AI 最自然的编辑方式是对文本进行操作。块是 UI 概念,不是 AI 概念。在线文档的持久化格式和本地文件的持久化格式都可以投影为 markdown。
### 4.2 两层操作模型
| 层 | 工具 | 寻址方式 | 适用场景 | 占比 |
|----|------|---------|---------|------|
| **文本级(主)** | `mnote.doc.markdown_edit` | search/replace 文本对 | "把这段改简洁"、"把所有 TODO 改 DONE"、"补充一段总结" | 80%+ |
| **块级(辅助)** | `mnote.doc.apply_block_ops` | blockId / matchText | "把第三块拖到第一块后面"、"精确删除引用块" | <20% |
### 4.3 在线和本地共用同一条写入路径
```
mnote.doc.markdown_edit
→ resolve_source(documentId) → Convex | LocalFS
→ 读取当前 markdown
→ 应用 operations(搜索替换)
→ 写入目标(Convex 或文件系统)
→ 返回 delta
```
差异仅存在于 `resolve_source``write_target` 两个 adapter,中间的 markdown 操作逻辑完全共享。这和 CLI Main 的 `docs +update` 可以操作任何 `doc-token` 的设计一致。
### 4.4 Diff 是内部实现细节
AI **不**产出 unified diff(行号/上下文极易出错),也不调用独立的 diff/patch 工具。AI 产出两种形式之一:
| 形式 | 适用场景 | AI 负担 |
|------|---------|---------|
| `operations: [{ search, replace }]` | 局部修改 | 低:只需找原文片段 |
| `full_content: "..."` | 小文档全文改写 | 低:直接写完整 markdown |
服务端内部做 diff(用于 delta 推送和冲突检测),但 AI 无感知。
---
## 5. 工具合同
### 5.1 `mnote.doc.fetch`(已有,增强)
```json
{
"toolName": "mnote.doc.fetch",
"args": {
"documentId": "tree_xxx 或 /path/to/file.md",
"format": "markdown",
"scope": "full",
"query": "TODO",
"maxChars": 8000
}
}
```
增强点:
- `format: "markdown"` — 新增值。在线文档由 Page Aggregate 产出 PageMarkdown;本地文件直接返回 `.md` 原文。
- `documentId` — 自动检测 sourceConvex workspace 文档 vs 本地文件系统路径。
返回:
```json
{
"ok": true,
"source": "convex",
"documentId": "tree_xxx",
"revision": 3,
"format": "markdown",
"content": "# 标题\n\n段落内容...\n\n## 子标题\n\n...",
"truncated": false,
"charCount": 1234
}
```
### 5.2 `mnote.doc.markdown_edit`(新增,主路径)
```json
{
"toolName": "mnote.doc.markdown_edit",
"args": {
"documentId": "tree_xxx 或 /path/to/file.md",
"operations": [
{ "search": "原文片段", "replace": "新文本" },
{ "search": "另一段", "replace": "改写后的内容" }
]
}
}
```
服务端处理流程:
```
1. resolve_source(documentId) → ConvexAdapter | LocalFSAdapter
2. 读取当前 markdown 全文
3. 逐条 search_replace(精确匹配 → fuzzy fallback
4. 计算内部 diff(用于 BlockDelta 推送)
5. 写入目标
6. 返回结果
```
返回:
```json
{
"ok": true,
"source": "convex",
"documentId": "tree_xxx",
"revision": { "before": 3, "after": 4 },
"operationsApplied": 2,
"operationsFailed": 0,
"failedOperations": [],
"changedText": "已将「原文片段」替换为「新文本」\n已将「另一段」替换为「改写后的内容」",
"blockDelta": {
"documentId": "tree_xxx",
"revision": 4,
"operations": [...]
}
}
```
### 5.3 `mnote.doc.apply_block_ops`(已有,辅助路径)
保留不变。用于精确块结构调整(拖拽排序、指定 blockId 的精确删除)。在 Hermes tool manifest 中 `markdown_edit` 排在 `apply_block_ops` 前面。
---
## 6. 搜索替换语义
> 参考对照:CLI Main 的 `str_replace` 使用精确子串匹配。mnote 增加 fuzzy fallback 以提高模型输出容错率。
| 优先级 | 策略 | 说明 |
|--------|------|------|
| 1 | 精确匹配 | 原文字串精确匹配,区分大小写 |
| 2 | 宽松匹配 | 忽略首尾空白、全角/半角差异后匹配 |
| 3 | 段落 fuzzy | 按换行分段,每段独立 fuzzy match(允许 30% 字符差异) |
| 4 | 失败 | 返回 `operationsFailed`,列出无法匹配的 search 和原因 |
多条 operation 按数组顺序串行执行。前一条的替换结果对后一条可见(和 sed 语义一致)。
**乐观锁**:执行前用 `revision` 检测。如果写入时 revision 已过期,返回冲突错误,让 AI 重新 fetch + edit。
---
## 7. 远期规划:流式 apply + suggest/reviewPhase C,当前不实施)
> 参考对照:BlockNote AI 的 `StreamToolExecutor` + `suggestChanges` + `delayAgentStep`。本节仅做设计规划,不列入当前实施阶段。
### 7.1 目标
当用户通过页面 AI 面板发起编辑后,不是等所有操作完成后一次性刷新,而是:
1. AI 逐条产出 search/replace 对(流式)
2. 服务端逐条 apply 并推送 BlockDelta
3. 编辑器逐条渲染修改(带延迟,模拟"AI 正在编辑"
4. 用户可逐条 accept / rejectsuggestion 模式)
5. 确认后才最终写入 Convexreview 模式)
### 7.2 与 BlockNote AI 的差异
| | BlockNote AI | mnote Phase C |
|---|---|---|
| 操作粒度 | 块级(add/update/delete block | 文本级(search/replace 文本对) |
| blockId 依赖 | 必须 | 不需要(文本匹配) |
| 本地文件支持 | 不支持(无稳定 blockId) | 支持(文本匹配不依赖 blockId) |
| Suggestion 层 | ProseMirror `suggestChanges` marks | Tiptap 的 suggestion 扩展或自建 |
| 写入时机 | 用户 accept 后统一写入 | 同 |
### 7.3 组件设计(草图)
```
StreamApplyController(新增)
输入:LLM 流式输出的 partial operations JSON
处理:
1. 解析 partial JSON → 提取已完成的 operation
2. 执行 search_replace → 产生 BlockDelta
3. 通过 SSE 推送 delta 到编辑器
4. 可选:注入 delay50-200ms)模拟人类编辑节奏
输出:逐条 BlockDelta
ReviewSession(新增,在线文档)
状态:pending → accepted | rejected
存储:Convex review_session 表或 documents 的 review 字段
生命周期:
- AI 编辑 → 创建 ReviewSession → 所有修改标记为 pending
- 用户逐条操作 → accept/reject → 更新 session 状态
- 全部处理或用户确认 → 最终写入 → 关闭 session
GhostTextOverlay(新增,编辑器)
参考:Tiptap AI Autocomplete 的 ghost text 覆盖层
用于:展示 AI 修改前后的 diff(红删绿增),用户 hover 查看详情
```
### 7.4 分阶段实施顺序
| 阶段 | 内容 | 依赖 |
|------|------|------|
| C-1 | `StreamApplyController`:流式 apply + SSE 逐条推送 delta | 7-13 EditorRuntimeActor delta 通道 |
| C-2 | `GhostTextOverlay`:编辑器内 diff 展示(红删绿增) | leptos-tiptap 的 decoration 能力 |
| C-3 | `ReviewSession`:在线文档的 accept/reject session | Convex review 表设计 |
| C-4 | `delayAgentStep`:可选的编辑节奏模拟 | C-1 完成 |
**当前不做实施决策**。Phase C 的启动时机以 Phase A/B 完成后,编辑器能力和 Convex review 表设计就绪为前置条件。
---
## 8. 实施阶段(当前)
### Phase A`mnote.doc.fetch` 增强 + `mnote.doc.markdown_edit` 核心实现
- [x] `mnote.doc.fetch` 增加 `format: "markdown"`(在线文档 Page Aggregate → PageMarkdown
- [x] `mnote.doc.fetch` 增加本地文件 source 路由(自动检测 Convex vs 文件系统路径)
- [x] 实现 `resolve_source(documentId)` — 本地文件路径 `local_fs` vs 其余走 Convex
- [x] 实现 `search_replace(text, operations)` — 四级匹配策略(精确→宽松→段落 fuzzy→失败)
- [x] 实现 Convex 写入 adapter(复用 `doc_apply_block_ops` 链路)
- [x] 实现本地文件写入 adapter`mnote.doc.markdown_edit` 检测到本地文件路径时直接 `fs::write` 写回,不经过 Convex
- [ ] 内部 diff 生成 + BlockDelta 推送(复用 7-13 delta 基础设施,待 Phase C 实现)
- [x] Hermes tool manifest 注册 `mnote.doc.markdown_edit`
- [x] Hermes `mnote` plugin 更新:`mnote_doc_fetch` schema + `mnote_doc_markdown_edit` 新增
- [x] 乐观锁:`revision` 冲突检测(`doc_apply_block_ops` 已有)
- [x] 浏览器 smoke(在线文档 `format: "markdown"` + `markdown_edit` 搜索替换)
- [x] 单元测试(`search_replace` 精确/失败/全文、`blocks_to_markdown` with_ids/heading 共 5 测试通过)
- [x] 浏览器 smoke(本地 `.md` 文件读取 `mnote_doc_fetch` + 写入 `mnote_doc_markdown_edit``full_content` 创建 + `operations` 搜索替换 + 回读验证全部通过)
### Phase B`page_ai_workflow.rs` 收口
- [x] 退役 `direct_block_edit_operations`(正则抠「」的快路径,代码保留但路由跳过)
- [x] `/api/page-ai/block-edit-workflow` 底层切换到 `mnote.doc.markdown_edit`
- [x] 模型 system prompt 重构:从产块操作 JSON 改为产 search/replace 文本对
- [x] 补全 operation schema`extract_markdown_operations_from_model_text` 处理新旧格式
- [x] 浏览器 smoke`markdown_edit` 搜索替换通过,自然语言编辑路径可用
### Phase C:流式/review(规划中,不实施)
- [ ] 流式 apply`StreamApplyController`
- [ ] suggest/review`ReviewSession` + `GhostTextOverlay`
- [ ] `delayAgentStep` 编辑节奏模拟
> 见 §7。当前仅做设计规划,不实施。
---
## 9. 与参考实现和其他设计稿的关系
### 9.1 与 CLI Main 的关系
| CLI Main 概念 | mnote 对应 |
|--------------|-----------|
| `str_replace`(文本级) | `mnote.doc.markdown_edit`(主路径) |
| `block_replace/insert_after/delete/move_after`(块级) | `mnote.doc.apply_block_ops`(辅助路径) |
| XML 用于精确编辑 | mnote 不用 XML(没有 XML 存储层),direct path 退役后全部走 markdown |
| Markdown 用于导入/导出/对话引用 | mnote 全部 AI 交互走 markdown |
| Skill 格式(YAML + Markdown + tool shortcuts | Hermes `mnote` plugin`plugin.yaml` + `SKILL.md` + tool manifest |
### 9.2 与 BlockNote AI 的关系
| BlockNote AI 概念 | mnote 远期对应 | 状态 |
|-------------------|---------------|------|
| `StreamTool` + `StreamToolExecutor` | `StreamApplyController` | Phase C 规划 |
| `suggestChanges` + `AIExtension` state machine | `ReviewSession` + `GhostTextOverlay` | Phase C 规划 |
| `delayAgentStep` | 可选改善 | Phase C 规划 |
| `RebaseTool` | revision 乐观锁(已有) | 当前已覆盖 |
| 纯 blockId 寻址 | ❌ 不采用。mnote 用文本匹配 | — |
### 9.3 与其他设计稿的关系
| 设计稿 | 关系 | 修正状态 |
|--------|------|---------|
| 7-9 路线图 | 块级编辑降级为辅助,markdown_edit 为主路径 | ✅ 已修正 |
| 7-10 checklist | 新增 Phase 9 markdown_edit | ✅ 已修正 |
| 7-12 工具路由 | PageAICommandRouter 主输出改为 markdown_edit | ✅ 已修正 |
| 7-13 EditorRuntimeActor | 补充 markdown_edit 的 delta 适配 | ✅ 已修正 |
| 3-13 本地 markdown | 新增 AI 工具接入章节 | ✅ 已修正 |
---
## 10. 禁止项
- 不删除 `mnote.block.*` 工具(保留为辅助路径)。
- 不让 AI 产出 unified diff(行号/上下文极易出错)。
- 不要求本地文件有稳定的 `blockId`(本地文件没有 block identity)。
- 不在 markdown_edit 内部引入新的 AI 模型调用(diff 是确定性算法)。
- 不改变 Convex `documents:updateContent` 的持久化链路(markdown_edit 复用现有保存路径)。
- 不把 `mnote.page.save` 重新描述为精确编辑主入口(它仍是兜底工具)。
- **不照搬 BlockNote AI 的纯 blockId 寻址模式**(与 mnote 的 markdown 优先策略冲突)。
---
## 11. 成功标准
- [x] `mnote.doc.fetch(documentId, format: "markdown")` 对在线文档返回正确 markdown
- [ ] `mnote.doc.fetch(documentId, format: "markdown")` 对本地 `.md` 文件返回正确 markdown(读取已实现,待浏览器 smoke)
- [x] `mnote.doc.markdown_edit` 的简单搜索替换(1 条 operation)浏览器 smoke 通过
- [ ] `mnote.doc.markdown_edit` 的复杂改写(3+ 条 operations)成功率 > 80%
- [ ] 本地 `.md` 文件通过页面 AI 面板可被读取和写入(读取已实现,写入待本地文件 adapter)
- [x] 在线文档的 markdown_edit 不增加 Convex RTT(和当前块操作持平)
- [x] `direct_block_edit_operations` 已退役(路由跳过,代码保留)
- [x] `page_ai_workflow.rs` 的 system prompt 已补全 search/replace schema
- [x] Phase C(流式/review)的设计已冻结,不阻塞 A/B 实施
---
## 12. CLI Main 成熟度采纳清单
以下清单按优先级排列,标注当前状态和对应 CLI Main 参考位置。
### 12.1 当前 Phase A/B 必须完成的
| # | CLI Main 模式 | mnote 实施项 | 状态 | 参考文件 |
|---|-------------|-------------|------|---------|
| 1 | 两层操作模型 | `markdown_edit`(主)+ `apply_block_ops`(辅助)已在 7-10 Phase 9 登记 | 📋 设计完成,待实施 | `lark-doc-update.md` |
| 2 | Scope 四级控制 | `mnote.doc.fetch` 增加 `scope: full/section/outline/keyword` | 🔜 Phase A | `lark-doc-fetch.md` |
| 3 | Detail 三级控制 | `mnote.doc.fetch` 增加 `detail: simple/with_ids/full` | 🔜 Phase A | `lark-doc-fetch.md` |
| 4 | 片段包装 | fetch 返回中标记 `<!-- fragment -->` / `<!-- excerpt -->` 告知 AI 部分视图 | 🔜 Phase A | `lark-doc-fetch.md`fragment/excerpt 模式) |
| 5 | Code-Act Loop | Hermes `mnote` plugin SKILL.md 中内联 Plan→Execute→Observe→Iterate 指导 | ✅ 已写入 `~/.hermes/skills/note-taking/mnote-block-ai/SKILL.md` | `lark-doc-update-workflow.md` |
| 6 | 更新命令决策树 | 模型 system prompt 追加"优先搜索替换,不全文覆盖"规则 | 🔜 Phase B | `lark-doc-update.md`str_replace vs block_* 决策) |
| 7 | 写后回读 | Hermes 每次写操作结束后自动 `mnote.doc.fetch` 回读 | 🔜 Phase B | `lark-doc-update-workflow.md` |
| 8 | Markdown 优先格式策略 | mnote 默认和唯一 AI 格式为 markdown(不需要 XML | ✅ 已确认 | `lark-doc-md.md`(对比 XML 路线) |
### 12.2 Phase A/B 完成后应补充的
| # | CLI Main 模式 | mnote 实施项 | 状态 | 参考文件 |
|---|-------------|-------------|------|---------|
| 9 | Skill YAML 规范化 | Hermes `mnote` plugin 的 `plugin.yaml` 增加 `description``metadata.domain` | ✅ 已更新 v0.2.0 | `master-skill-template.md` |
| 10 | 参考文件分离 | `~/.hermes/skills/note-taking/mnote-block-ai/references/` 目录已创建,`mnote-doc-fetch.md` 已写入 | ✅ 已创建(fetch),其余按需补充 | `skills/lark-doc/references/` |
| 11 | Skill CI 校验 | CI 步骤:检查所有 plugin.yaml 和 SKILL.md 的 YAML frontmatter 有效性 | 📋 待实施 | `skill-format-check.yml` |
| 12 | 格式规则文档 | `references/mnote-doc-md.md`:定义 markdown 中 AI 应理解的特殊语法(任务列表标记 `- [ ]`、附件链接 `[file:...]` 等) | 📋 待实施 | `lark-doc-md.md` |
| 13 | 上下文冻结规范 | `references/mnote-doc-context.md`:冻结时保留哪些字段、截断规则、revision 注入方式 | 📋 待实施 | `lark-doc-fetch.md`fetch body 构建) |
### 12.3 远期规划(Phase C 及以后)
| # | CLI Main 模式 | mnote 实施项 | 状态 | 参考文件 |
|---|-------------|-------------|------|---------|
| 14 | 跨域资源路由 | 文档内嵌思维导图/附件时,路由到对应 skill(见 §2.1.5 | ⏳ Phase C 后 | `lark-doc/SKILL.md`(路由表) |
| 15 | 流式增量 apply | BlockNote AI 的 `StreamToolExecutor` 模式,见 §7 | ⏳ Phase C 规划 | `blocknote-ai/packages/xl-ai/` |
| 16 | suggest/review | BlockNote AI 的 `suggestChanges` 模式,见 §7 | ⏳ Phase C 规划 | `blocknote-ai/packages/xl-ai/` |
### 12.4 与 BlockNote AI / Tiptap AI Autocomplete 的采纳清单
| # | 参考模式 | mnote 采纳 | 时机 | 参考文件 |
|---|---------|-----------|------|---------|
| B1 | `StreamTool` 流式 apply | `StreamApplyController`(§7.3 | Phase C | `blocknote-ai/packages/xl-ai/src/streamTool/` |
| B2 | `suggestChanges` review 层 | `ReviewSession` + `GhostTextOverlay`(§7.3 | Phase C | `blocknote-ai/packages/xl-ai/src/AIExtension.ts` |
| B3 | `delayAgentStep` 编辑节奏 | 可选改善 | Phase C | `blocknote-ai/packages/xl-ai/src/streamTool/` |
| T1 | ghost text 内联补全 | 独立功能(非本文档范围) | 独立立项 | `tiptap-ai-autocomplete/src/` |
### 12.5 实施优先级总结
```
Phase A(当前立即)
├── #2 Scope 四级控制 ← mnote.doc.fetch 增强
├── #3 Detail 三级控制 ← mnote.doc.fetch 增强
├── #4 片段包装 ← fetch 返回值增强
├── #8 Markdown 优先 ← 已确认
└── mnote.doc.markdown_edit 核心实现
Phase BPhase A 完成后)
├── #5 Code-Act Loop ← Hermes plugin SKILL.md
├── #6 更新决策树 ← 模型 system prompt
└── #7 写后回读 ← Hermes tool loop 逻辑
Phase A/B 完成后补充
├── #9-#13 Skill 规范化、参考文件分离、CI 校验、格式文档、上下文规范
Phase C(远期)
├── #14 跨域路由(Phase C 后)
├── #15 流式 applyPhase C
└── #16 suggest/reviewPhase C
```
@@ -0,0 +1,865 @@
# 7-15 [process] 页面 AI ACP Agent Runtime 统一抽象层 v1
> 创建时间:2026-05-17
>
> 当前状态:`PROCESS`
>
> 本稿目的:
> 1. 在 mnote-web 中引入 ACPAgent Client Protocol)作为统一 agent runtime 抽象层
> 2. 使 Hermes(当前)与 Reasonix(缓存优先)可互换,前端下拉切换
> 3. 褪去当前 `hermes_client.rs` 中的 Hermes-HTTPS-proxy 硬编码,改为 ACP JSON-RPC 通用连接器
> 4. 复用现有参考代码,最小化重复实现工作
>
> 关联文档:
> - `/mnt/Data1T/mnote/design/07-ai/done/7-5-hermes-client-proxy-contract-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/hermes-vscode-main/`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/`
---
## 1. 问题描述
### 1.1 当前架构
```
浏览器 (页面 AI 面板)
│ SSE
mnote-web (Rust Axum)
├─ hermes_client.rs (3845 行)
│ └─ HTTP proxy → Hermes HTTPS gateway API
│ post /api/hermes/runs
│ get /api/hermes/runs/{id}/events
├─ hermes_tools.rs (mnote.doc.* / mnote.block.*)
│ └─ Rust 工具实现,通过 HTTP/Convex 读写文档
└─ page_ai_workflow.rs (fast-path 块编辑)
└─ local_rule planner, 不经过 Hermes
```
**问题:**
1. `hermes_client.rs` 与 Hermes HTTPS API 的字段、鉴权、错误码硬耦合——换 agent runtime 需重写整个模块
2. Hermes HTTP 协议没有标准化,Reasonix、Claude Code、Cline 各自用不同的 HTTP 接口
3. 前端 SSE 事件格式(`tool.started` / `message.delta` / `run.completed`)也是 mnote 私有定制的
4. 没有「运行时选择器」——切换 agent 需要改环境变量重启 mnote-web
5. `hermes_client.rs` 中大量代码(3845 行)是做 Hermes 专属的 HTTP proxy、session 管理、profile 路由——这些应该在统一抽象层中解决
### 1.2 目标架构
```
浏览器 (页面 AI 面板)
│ SSE (前端不变)
mnote-web (Rust Axum)
├─ ACP Session Manager (统一层,新增 ~800 行)
│ ├─ 运行时选择器 (profile → agent runtime 映射)
│ │ ├─ Hermes: spawn("hermes", ["acp"])
│ │ └─ Reasonix: spawn("node", ["reasonix-acp.mjs"])
│ ├─ ACP JSON-RPC 2.0 client (通用实现)
│ │ ├─ session/new
│ │ ├─ session/prompt
│ │ ├─ session/cancel
│ │ └─ session/update ≫ SSE 转发
│ └─ 代理层:向下游工具通知
├─ hermes_tools.rs (不变)
│ └─ mnote.doc.* / mnote.block.* / mnote.page.*
└─ page_ai_workflow.rs (不变)
└─ fast-path 块编辑
```
ACP 是整个架构的支点——它是一个**开放协议**,不是某个产品的私有接口。
---
## 2. ACP 协议标准
ACPAgent Client Protocol)是一个基于 JSON-RPC 2.0 的、面向 AI agent runtime 的标准通信协议。同时被 Reasonix (`src/acp/`) 和 Hermes (`hermes acp` CLI) 实现。
### 2.1 传输层
NDJSON over stdio(默认),也可用 TCP/Unix socket。每行一个完整的 JSON 对象。
### 2.2 协议方法
| 方向 | 方法 | 用途 |
|------|------|------|
| Client → Server | `session/new` | 创建一个会话线程 |
| Client → Server | `session/prompt` | 发送用户输入,等待 agent 完成 |
| Client → Server (notification) | `session/cancel` | 中断正在运行的 prompt |
| Server → Client (notification) | `session/update` | 推送实时状态变更 |
| Server → Client (request) | `session/request_permission` | 请求用户审批工具调用 |
### 2.3 `session/update` 事件类型
| `sessionUpdate` 值 | 含义 | 字段 |
|---|---|---|
| `agent_message_chunk` | 模型生成文本增量 | `content: { type: "text", text: "…" }` |
| `agent_thought_chunk` | 模型推理/思考过程 | `content: { type: "text", text: "…" }` |
| `tool_call` | 工具调用开始 | `toolCallId`, `title`, `kind: "read"|"edit"|"search"|"execute"|"other"`, `status: "pending"` |
| `tool_call_update` | 工具状态变更 | `toolCallId`, `status: "in_progress"|"completed"|"failed"`, `content` |
| `usage_update` | 上下文用量更新 | `used: number`, `size: number` |
| `session_info_update` | 会话元信息 | `title: string` |
### 2.4 对比 mnote 当前 SSE 格式
| mnote 当前事件 | ACP 对应事件 | 备注 |
|---|---|---|
| `message.delta` | `agent_message_chunk` | 几乎 1:1 |
| `tool.started` | `tool_call` + `kind` | 前者多了 `preview` 字段 |
| `tool.completed` | `tool_call_update` + `status: "completed"` | 前者多了 `duration` |
| `run.completed` | `session/update` 不再发事件,prompt 返回 | 语义等价 |
| `run.failed` | `tool_call_update` + `status: "failed"` | 语义等价 |
| 无 | `agent_thought_chunk` | 当前页面 AI 未显示思考过程,新增能力 |
| 无 | `usage_update` | 可展示 token 用量 |
**结论:** 前端桥接层只需做一个事件名映射 + 字段适配(~50 行),即可对接 ACP。
---
## 3. 参考代码分析 — 可复用部分
### 3.1 Hermes VSCode 扩展 (`hermes-vscode-main/`)
这是**最完整的 ACP 客户端参考实现**,可以直接指导 mnote-web 的 Rust ACP 客户端设计。
| 文件 | 内容 | 可复用方式 |
|---|---|---|
| `src/acpClient.ts` | ACP JSON-RPC 2.0 客户端:spawn 子进程、读写 NDJSON、处理分帧、请求/响应/通知路由 [acpClient.ts:30-220] | **逻辑移植到 Rust**`tokio::process::Command` spawn + `BufReader` 按行读取 + 请求 ID 映射表 |
| `src/sessionManager.ts` | 会话生命周期管理:session/new → session/prompt → session/cancel,去重,事件派发 [sessionManager.ts:37-294] | **逻辑移植到 Rust**`HashMap<String, Session>` 管理活跃会话 |
| `src/protocol.ts` | ACP 事件的类型解析:文本提取、去重、tool call 解析、usage 解析 [protocol.ts:1-120] | **直接指导 Rust struct 设计** |
| `src/chatPanel.ts` | VSCode WebviewView 桥接:ACP 事件 → webview HTML 渲染 [chatPanel.ts:1-524] | **UI 架构参考** — mnote 页面 AI 面板已存在,只需适配事件格式 |
| `src/webview/main.ts` | webview 端事件处理:消息渲染、工具展示、todo 面板 [webview/main.ts:1-531] | **UI 交互参考** — tool call 显示方式、todo overlay |
| `src/webview/renderers.ts` | 工具调用格式化、历史加载 [renderers.ts:1-170] | **前端渲染参考** |
**核心设计复用:**
```typescript
// acpClient.ts 的精髓:请求-响应匹配
class AcpClient {
private pending = new Map<number, PendingRequest>();
private nextId = 1;
async sendRequest(method: string, params: unknown): Promise<unknown> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.output.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});
}
}
```
### 3.2 Reasonix ACP server (`DeepSeek-Reasonix-main/`)
| 文件 | 内容 | 可复用方式 |
|---|---|---|
| `src/acp/server.ts` | ACP JSON-RPC 2.0 server 类 [acp/server.ts:1-150] | **理解对端协议** — Reasonix 作为 server 时的行为 |
| `src/acp/protocol.ts` | ACP 类型定义 + 工具函数 [acp/protocol.ts:1-80] | **协议规格参考** |
| `src/acp/dispatch.ts` | 内核事件 → ACP `session/update` 映射 [acp/dispatch.ts:55-112] | **事件映射参考** — Reasonix 的 `kernel event → ACP` 与 mnote 的 `ACP → SSE` 是互逆过程 |
| `src/acp/gates.ts` | 权限审批逻辑 [gates.ts:1-6353] | **可选参考** — 页面 AI 的 tool call 审批 |
| `src/cli/commands/acp.ts` | `reasonix acp` CLI 命令,将 `CacheFirstLoop` + toolset 包装为 ACP server [acp.ts:1-339] | **Reasonix 侧入口** — 启动 Reasonix ACP 的参考实现 |
| `desktop/src/protocol.ts` | 桌面客户端的 UI 事件协议 [desktop/protocol.ts:1-432] | **前端事件架构参考**`ModelDeltaEvent``ToolPreparingEvent``ToolResultEvent` 等 25+ 事件类型的设计 |
### 3.3 复用策略
**不要「移植代码」——要「移植逻辑和接口形状」**
Rust 端参考 `acpClient.ts` 的架构,但用 tokio async 重写。核心接口设计:
```rust
// Rust ACP client — 接口形状参考 acpClient.ts
pub struct AcpClient {
child: tokio::process::Child,
stdin: tokio::io::BufWriter<tokio::process::ChildStdin>,
stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
pending: HashMap<u64, PendingRequest>,
next_id: u64,
}
impl AcpClient {
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self>;
pub async fn send_request<P, R>(&mut self, method: &str, params: P) -> Result<R>;
pub async fn send_notification(&mut self, method: &str, params: Value);
pub fn on_notification(&mut self, handler: impl Fn(String, Value));
}
```
---
## 4. 架构设计
### 4.1 运行时选择器
```rust
// mnote-web 配置
struct AcpRuntimeConfig {
name: String, // "hermes" | "reasonix"
bin: String, // "hermes" | "node"
args: Vec<String>, // ["acp"] | ["reasonix-acp.mjs"]
default_model: Option<String>,
env: HashMap<String, String>,
}
```
mnote-web 支持多个运行时配置,用户通过 profile 选择:
```
profile "default" → runtime "hermes" (spawn hermes acp)
profile "reasonix" → runtime "reasonix" (spawn node reasonix-acp-wrapper.mjs)
```
前端获取可用运行时列表:`GET /api/hermes/client/profiles`(现有接口,扩展字段)
### 4.2 会话生命周期 (ACP Session Manager)
```
用户发送消息
├─ POST /api/hermes/client/sessions → 创建 session
│ ├─ ACP session/new → 得到 sessionId
│ └─ 返回 { sessionId, ... }
├─ POST /api/hermes/client/runs → 开始 run(现有接口)
│ ├─ ACP session/prompt → 下发用户输入 + 页面上下文
│ └─ 返回 { runId }
├─ GET /api/hermes/client/events/{runId} → SSE 流
│ ├─ ACP session/update 的 6 种事件 → SSE 映射
│ ├─ agent_message_chunk → { event: "message.delta", delta: ... }
│ ├─ tool_call → { event: "tool.started", tool: ..., kind: ... }
│ ├─ tool_call_update → { event: "tool.completed", ... }
│ ├─ agent_thought_chunk → { event: "thought.delta", delta: ... } (新增)
│ ├─ usage_update → { event: "usage.updated", usage: ... } (新增)
│ └─ session/update 转 ACP → prompt 返回 → SSE event "run.completed"
└─ POST /api/hermes/client/runs/{runId}/abort → 取消
└─ ACP session/cancel
```
### 4.3 工具桥接
当前 `hermes_tools.rs` 中注册的 mnote 工具(`mnote.doc.fetch``mnote.doc.markdown_edit``mnote.block.*``mnote.page.*`)对 ACP 来说只是一组 HTTP 端点。
对于 Reasonix 作为 runtime 的场景,需要一个 Reasonix-side 的工具注册包装脚本,将 mnote 工具注册到 `ToolRegistry`
```typescript
// reasonix-acp-wrapper.mjs — ACP 包装层
// 参考: DeepSeek-Reasonix-main/src/cli/commands/acp.ts (整个文件, ~339 行)
import { AcpServer } from 'reasonix/acp/server';
import { DeepSeekClient, CacheFirstLoop, ToolRegistry } from 'reasonix';
// 注册 mnote 工具 — 工具实现调用 mnote-web HTTP API
const tools = new ToolRegistry();
tools.define({
name: "mnote.doc.fetch",
description: "阅读当前文档的 markdown 内容",
parameters: { ... },
call: async (args) => {
// 调 mnote-web Rust HTTP API
return fetch(`${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`, {
method: 'POST', body: JSON.stringify({ toolName: "mnote.doc.fetch", args })
}).then(r => r.json());
},
parallelSafe: false,
});
// 启动 ACP server — 参考 acp.ts 的 acpCommand 函数
const server = new AcpServer();
const client = new DeepSeekClient({ apiKey: process.env.DEEPSEEK_API_KEY });
let sessions = new Map();
server.onRequest("session/new", async (params) => {
const loop = new CacheFirstLoop({ client, tools, ... });
// ... 参考 acp.ts 第 220-330 行
});
```
工具的实际执行路径:
```
agent → tool call → (通过 TCP/localhost HTTP) → mnote-web Rust hermes_tools.rs
→ Convex / 文档系统
```
**不需要在 Rust 侧重新注册工具到 Reasonix。** mnote-web 的工具 HTTP 端点 (`/api/hermes/tools/mnote/call`) 不变,只通过 ACP 换掉了 agent runtime。
### 4.4 前端 SSE 扩展
当前前端 SSE 事件格式 (`HermesRunEvent`)
```typescript
type HermesRunEvent =
| { event: "tool.started"; tool: string; preview?: string | null }
| { event: "tool.completed"; tool: string; duration?: number; error?: boolean }
| { event: "message.delta"; delta: string }
| { event: "run.completed"; output?: string; usage?: Record<string, unknown> }
| { event: "run.failed"; error?: string };
```
新增字段(向后兼容):
```typescript
type HermesRunEvent = /* 原有 5 种 */ | {
event: "thought.delta"; // 新增 — 思考过程
delta: string;
} | {
event: "run.completed"; // 扩展 — 新增缓存指标
output?: string;
usage?: Record<string, unknown>;
cacheHitRate?: number; // 新增:Reasonix 缓存命中率
cacheHitTokens?: number; // 新增
};
```
前端 AiAgentPanel 接到 `thought.delta` 后,可渲染在独立区域(参考 hermes-vscode-main 的 `agent_thought_chunk` 处理)。
### 4.5 Profile 体系扩展
当前 `hermes_client.rs` 的 profile 机制(`configured_upstream_for_profile`)是 Hermes-HTTPS 专有的。需要扩展为通用运行时 profile:
```rust
struct AgentProfile {
name: String,
runtime_type: RuntimeType, // Hermes | Reasonix | ACPGeneric
runtime_config: AcpRuntimeConfig,
api_key: Option<String>,
models: Vec<ModelConfig>,
default_model: Option<String>,
}
```
当前已存在的 `hermes_client.rs` profile 相关端点不需要大改——profile 切换逻辑不变,只是 profile 的数据结构增加了 `runtimeType` 字段。
---
## 5. 褪去历史负担 — 模块重构路线
### 阶段 0:现状(当前)
```
hermes_client.rs (3845 行)
├─ HTTP proxy 逻辑 (proxy_json, proxy_stream)
├─ Hermes API 硬编码 (create_run, stream_events)
├─ Session/profile/Skills/工具管理
├─ Memory 管理
└─ 各种配置和鉴权
```
### 阶段 1:新增 ACP 客户端,并行运行
新增文件:
- `acp_client.rs` — ACP JSON-RPC 2.0 客户端(参考 hermes-vscode-main `acpClient.ts`
- `acp_session_manager.rs` — 会话生命周期管理(参考 hermes-vscode-main `sessionManager.ts`
- `acp_runtime.rs` — 运行时管理(spawn、健康检查、切换)
`hermes_client.rs` 中原有的 HTTP proxy 逻辑标志为 `#[deprecated]`,前端通过 profile 选择使用 ACP 还是旧 HTTP proxy。
### 阶段 2:前端运行时选择器
AiAgentPanel 增加 runtime 切换下拉框,实际只是切换 mnote-web 内部使用的 profile。
### 阶段 3:存量迁移
- `list_sessions` → ACP `session/new` + 本地记录
- `create_session` → ACP `session/new`
- `create_run` + `stream_events` → ACP `session/prompt` + `session/update` 事件映射
- `abort_run` → ACP `session/cancel`
- `list_profiles` → 扩展为包含 runtime_type 字段
- `gateway_health` → 改为 ACP runtime 健康检查(spawn + PING
- `list_tools` → 迁移到 `acp_session_manager.rs`
### 阶段 4:退役旧代码
当所有 profile 都已迁移到 ACP 后,`hermes_client.rs` 中原来的 HTTP proxy 代码可以删掉。Profile 的 `upstream_url` 字段不再需要——运行时由 `bin + args` 定义。
---
## 6. 实现计划
### 6.1 Rust ACP 客户端 (`acp_client.rs`)
**参考:** `hermes-vscode-main/src/acpClient.ts`
核心接口:
```rust
use tokio::process::{Command, Child};
use tokio::io::{BufReader, BufWriter, AsyncBufReadExt, AsyncWriteExt};
use serde_json::Value;
use std::collections::HashMap;
type PendingMap = HashMap<u64, tokio::sync::oneshot::Sender<Result<Value, AcpError>>>;
pub struct AcpClient {
child: Child,
writer: BufWriter<ChildStdin>,
pending: Arc<Mutex<PendingMap>>,
next_id: AtomicU64,
}
impl AcpClient {
/// Spawn ACP subprocess
/// 参考: acpClient.ts line 50-80 (spawn + stdio setup)
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self> {
let mut child = Command::new(bin)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()?;
let writer = BufWriter::new(child.stdin.take().unwrap());
let reader = BufReader::new(child.stdout.take().unwrap());
let pending = Arc::new(Mutex::new(HashMap::new()));
// 后台读取 stdout 行
let p = pending.clone();
tokio::spawn(async move {
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() { continue; }
if let Ok(msg) = serde_json::from_str::<Value>(&line) {
// 参考 acpClient.ts line 120-180 (onData 解析逻辑)
if let Some(id) = msg.get("id").and_then(|v| v.as_u64()) {
// 响应 → 匹配 pending
if let Some(tx) = p.lock().unwrap().remove(&id) {
let _ = tx.send(Ok(msg));
}
} else if let Some(method) = msg.get("method").and_then(|v| v.as_str()) {
// 通知 → 调用 onNotification
}
}
}
});
Ok(Self { child, writer, pending, next_id: AtomicU64::new(1) })
}
/// Send JSON-RPC request, await response
/// 参考: acpClient.ts line 95-110 (sendRequest)
pub async fn request<P: Serialize, R: DeserializeOwned>(
&self, method: &str, params: P
) -> Result<R> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = tokio::sync::oneshot::channel();
self.pending.lock().unwrap().insert(id, tx);
let req = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
self.writer.write_all(format!("{}\n", serde_json::to_string(&req)?).as_bytes()).await?;
self.writer.flush().await?;
match rx.await {
Ok(Ok(val)) => serde_json::from_value(val).map_err(Into::into),
_ => Err(AcpError::Timeout),
}
}
}
```
**工作量:** ~200 行 Rust。核心逻辑直接映射自 hermes-vscode-main 的 `acpClient.ts`
### 6.2 ACP Session Manager (`acp_session_manager.rs`)
**参考:** `hermes-vscode-main/src/sessionManager.ts`
```rust
struct AcpSession {
id: String,
runtime: AcpRuntimeConfig,
client: AcpClient,
state: SessionState,
run_handle: Option<JoinHandle<()>>,
}
pub struct AcpSessionManager {
runtimes: HashMap<String, AcpRuntimeConfig>,
sessions: HashMap<String, AcpSession>,
active_profile: String,
}
```
接口:
| 方法 | 对应 ACP | 参考 |
|---|---|---|
| `create_session(profile, page_context)` | `session/new` | sessionManager.ts `start()` / `sendPrompt()` |
| `run_prompt(session_id, prompt)` | `session/prompt` | sessionManager.ts `sendPrompt()` |
| `cancel(session_id)` | `session/cancel` | sessionManager.ts `cancel()` |
| `on_update(handler)` | `session/update` | sessionManager.ts `handleUpdate()` |
| `switch_runtime(profile)` | — | 切换 `active_profile`,刷新 runtime |
**工作量:** ~300 行 Rust。
### 6.3 Reasonix ACP wrapper (`reasonix-acp-wrapper.mjs`)
**参考:** `DeepSeek-Reasonix-main/src/cli/commands/acp.ts`
```typescript
// 参考 acp.ts 的 acpCommand() 函数 — ~147 行核心逻辑
// 1. 创建 AcpServer
// 2. 注册 mnote 工具 (调 mnote-web HTTP)
// 3. onRequest("session/new") → 创建 CacheFirstLoop
// 4. onRequest("session/prompt") → loop.run() + dispatchKernelEvent
// 5. onNotification("session/cancel") → aborter.abort()
```
**工作量:** ~150 行 TypeScript。直接改编自 `acp.ts` 已有代码。
### 6.4 桥接层 — ACP → SSE
**参考:** `hermes-vscode-main/src/sessionManager.ts``handleUpdate()` 方法
当前 `stream_events` 输出 SSE。改为 ACP 后:
```rust
// 在 new AcpSessionManager().on_update() 中
fn on_acp_update(update: SessionUpdateParams) -> Option<SseEvent> {
match update.update.sessionUpdate {
"agent_message_chunk" => Some(SseEvent {
event: "message.delta",
data: json!({ "delta": extract_text(&update) }),
}),
"tool_call" => Some(SseEvent {
event: "tool.started",
data: json!({ "tool": update.title, "kind": update.kind }),
}),
// ... 其余事件映射
}
}
```
**工作量:** ~60 行 Rust。
### 6.5 前端运行时选择器
AiAgentPanel 增加下拉框 + 切换逻辑:
```tsx
<select value={activeProfile} onChange={switchProfile}>
<option value="default">Hermes</option>
<option value="reasonix">Reasonix</option>
</select>
```
`switchProfile``PUT /api/hermes/client/profiles/active`(已有接口)。
**工作量:** ~50 行 TypeScript。
---
## 7. 与现有设计的关系
### 7.1 对 7-5 (Hermes client proxy 合同) 的影响
7-5 规定的路由路径不变:
| 路由 | 当前实现 | ACP 后 |
|------|----------|--------|
| `GET /client/sessions` | Hermes HTTP proxy | ACP `session/new` 历史 |
| `POST /client/sessions` | 本地生成 sessionId | 本地生成 + ACP `session/new` |
| `POST /client/runs` | Hermes HTTP run | ACP `session/prompt` |
| `GET /client/events/{runId}` | Hermes SSE 流 | ACP `session/update` → SSE |
| `POST /client/runs/{runId}/abort` | Hermes HTTP abort | ACP `session/cancel` |
前端看到的 HTTP 接口不变,后端实现透明切换。
### 7.2 对 7-14 (markdown 编辑收敛) 的影响
7-14 确立的「两层操作模型」(`mnote.doc.markdown_edit` 主 + `mnote.block.*` 辅)不受影响——工具在 Rust 侧 `hermes_tools.rs` 实现不变。ACP 只是换掉了 driver(从 Hermes 换成 Reasonix),不改 driver 调用的工具。
### 7.3 对 `page_ai_workflow.rs` 的影响
不影响。`block_edit_workflow` 作为独立 fast-path 与 ACP 无关。
---
## 8. 风险与缓解
| 风险 | 概率 | 缓解 |
|------|------|------|
| Reasonix ACP server 的 tool call 调用 mnote-web HTTP 有延迟 | 中 | 工具调用走 localhost TCP,延迟 <1ms |
| Reasonix `CacheFirstLoop` 与 mnote 页面上下文的兼容性 | 低 | ACP `session/new``pageContext`system prompt 在 Reasonix wrapper 中注入 |
| 两个运行时并行维护增加心智负担 | 中 | 过渡期后退役旧 Hermes HTTP proxy,只保留 ACP |
| ACP 协议字段差异(Hermes vs Reasonix camelCase/snake_case | 低 | 在桥接层做一次字段映射即可 |
| Task 7-12 说「mnote 不再建设独立 AI agent runtime」 | 不冲突 | ACP 层不是 agent runtime,是 runtime 抽象接口。mnote 仍然不建设 runtime,只是可以选接不同的 runtime |
---
## 9. 开放问题
- Reasonix 的 `DeepSeekClient` 需要的 API key 如何注入?环境变量?mnote-web 配置?
- **已决定**:通过 `reasonix-acp-wrapper.mjs` 的环境变量 `DEEPSEEK_API_KEY` 注入
- 本地 `.md` 文件的 tool 实现(`mnote.doc.fetch` 的本地变体)是否也在同一套 ACP 中?
- Phase C(流式 review/apply)的审批事件(`session/request_permission`)是否需要先加入 ACP 层?当前跳过,等 Phase C 再扩展。
---
## 11. 当前实现状态
### ✅ 已完成(2026-05-17
| Step | 文件 | 状态 | 测试 |
|------|------|------|------|
| 3 | `acp_client.rs` | 编译通过,475 行 | 6 单元测试通过 |
| 4 | `acp_types.rs` | 编译通过,440 行 | 7 单元测试通过 |
| 5 | `acp_session_manager.rs` | 编译通过,~530 行 | 4 测试通过(含事件派发) |
| 6 | `acp_runtime.rs` | 编译通过,~330 行 | 5 测试通过,含真实 Hermes 连接测试 |
| 7 | `acp_bridge.rs` + `hermes_client.rs` 修改 | 编译通过 | 新增 `acp_stream_events()` SSE 端点 |
| 8 | `lib.rs` 模块注册 | 编译通过 | — |
| 9 | `AppState` 集成 | 编译通过 | — |
| 10 | `scripts/reasonix-acp-wrapper.mjs` | 已创建,~250 行 | 需安装 `npm install reasonix` 后测试 |
| 12 | Profile 扩展 `configured_runtime_for_profile()` | 编译通过 | — |
| 13 | HTTP proxy 标 `#[deprecated]` | 编译通过(6 个 warning | — |
### 📋 待完成
| Step | 工作 | 前置 | 估算 |
|------|------|------|------|
| 11 | 前端运行时选择器(AiAgentPanel.tsx | Step 7 | ~半天 |
| 14 | e2e 验证:Hermes ACP + Reasonix ACP | Step 10 | ~半天 |
| 15 | 压力测试:多会话、进程管理 | Step 14 | ~半天 |
| 16 | 退役旧 HTTP proxy 代码 | Step 14 稳定后 | ~1 天 |
| 17 | 基准测试:缓存收益量化 | Step 10 | ~半天 |
---
## 10. 详细执行 Checklist(顺序执行)
以下 checklist 按依赖关系排序,每个 step 标注了**参考文件**(可直接读的代码)、**产出文件**、**验证方法**。执行时从 step-1 开始,完成后由 AI 调用 `todo_write` 标记进度后进入下一步。
### Step 1:读取参考代码,熟悉 ACP 协议细节
| 项 | 内容 |
|---|---|
| **目的** | 确认 ACP JSON-RPC 协议的方法名、字段名、事件类型,确保后续实现与 Hermes/Reasonix 兼容 |
| **参考** | `reference-code/hermes-vscode-main/src/protocol.ts`ACP 事件解析)、`reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`ACP 类型定义)、`reference-code/hermes-vscode-main/src/acpClient.ts`ACP 客户端完整实现) |
| **产出** | 无代码产出,仅阅读确认 |
| **验证** | 能在脑中回答:`session/update` 有几种 `sessionUpdate` 变体?每个变体有哪些必选字段? `session/prompt` 的 params 结构是什么? |
### Step 2:确认 `run_command` 的 cwd 与项目根一致
| 项 | 内容 |
|---|---|
| **目的** | 确保后续所有文件操作路径正确,不再触发 sandbox 偏移 |
| **操作** | `run_command pwd` 确认输出为 `/mnt/Data1T/mnote` |
| **验证** | 输出包含 `/mnt/Data1T/mnote` |
### Step 3:创建 `acp_client.rs` — ACP JSON-RPC 2.0 客户端
| 项 | 内容 |
|---|---|
| **目的** | 实现通用的 ACP 协议传输层:spawn 子进程、读写 NDJSON、请求/响应/通知路由 |
| **参考** | `reference-code/hermes-vscode-main/src/acpClient.ts`(完整参考,~220 行):spawn 逻辑 (L50-80)、onData 解析 (L120-180)、sendRequest (L95-110)、sendNotification (L115-118) |
| **路径** | `rust/crates/mnote-web/src/acp_client.rs`(新建) |
| **结构** | `pub struct AcpClient { child, writer, reader, pending: HashMap<u64, OneshotSender>, next_id }` |
| **方法** | `spawn(bin, args) → Result``request(method, params) → Result<R>``notification(method, params)``on_notification(handler)``close()` |
| **参考字段映射** | JSON-RPC `id``pending` key;响应匹配 `id`;通知匹配 `method` 字段 |
| **细节** | stdin 用 `BufWriter`(行缓冲),stdout 用 `BufReader` + `lines()` 逐行读;后台 tokio task 处理 incoming 行;`request()` 返回 `oneshot::Receiver`;超时处理用 `tokio::time::timeout`(默认 5min |
| **验证** | 单元测试:mock stdin/stdout 子进程,发送 `session/new` 请求,验证收到响应;发送 notification,验证 handler 被调用 |
### Step 4:创建 ACP 协议类型定义 — `acp_types.rs`
| 项 | 内容 |
|---|---|
| **目的** | ACP 协议的 Rust 类型定义,序列化/反序列化用 serde |
| **参考** | `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`ACP 类型定义,~80 行)、`reference-code/hermes-vscode-main/src/protocol.ts`(解析逻辑,~120 行) |
| **路径** | `rust/crates/mnote-web/src/acp_types.rs`(新建) |
| **结构** | `InitializeParams/Result``SessionNewParams/Result``SessionPromptParams/Result``SessionCancelParams``SessionUpdateParams``ContentBlock`text/resource/image/audio)、`SessionUpdateKind`enum 6 种变体) |
| **字段注意** | Hermes 和 Reasonix 都使用 **camelCase** JSON 字段(`sessionUpdate``toolCallId`),对应 `#[serde(rename_all = "camelCase")]` |
| **验证** | 单元测试:JSON 反序列化 `acp/dispatch.ts` 中的 `session/update` 案例、`session/prompt` 的请求/响应序列化-反序列化往返 |
### Step 5:创建 `acp_session_manager.rs` — 会话生命周期管理
| 项 | 内容 |
|---|---|
| **目的** | 管理 ACP 会话生命周期:session/new → session/prompt → session/cancel,将 `session/update` 事件派发给 mnote-web 各模块 |
| **参考** | `reference-code/hermes-vscode-main/src/sessionManager.ts`(完整参考,~294 行):SessionManager 类 (L37-294)、handleUpdate (L130-280)、sendPrompt (L80-128)、cancel (L280-294) |
| **路径** | `rust/crates/mnote-web/src/acp_session_manager.rs`(新建) |
| **结构** | `pub struct AcpSessionManager { runtimes: HashMap<String, AcpRuntimeConfig>, sessions: HashMap<String, AcpSession>, active_profile: String }``struct AcpSession { id, client, state, run_handle, page_context }` |
| **方法** | `create_session(profile, page_context) → sessionId`(调 ACP `session/new`)、`run_prompt(session_id, prompt_blocks, on_event) → JoinHandle`(调 ACP `session/prompt`,注册 `session/update` handler)、`cancel(session_id)`(调 ACP `session/cancel`)、`switch_runtime(profile_name)`(切换 active_profile,关闭旧 sessions,创建新 runtime)、`SessionUpdateKind``AcpSessionEvent` 的映射 |
| **事件映射** | `agent_message_chunk``AcpSessionEvent::TextDelta { text }``agent_thought_chunk``AcpSessionEvent::ThoughtDelta { text }``tool_call``AcpSessionEvent::ToolCall { id, title, kind, status }``tool_call_update``AcpSessionEvent::ToolCallUpdate { id, status, content? }``usage_update``AcpSessionEvent::UsageUpdate { used, size }``session_info_update``AcpSessionEvent::SessionInfo { title }` |
| **去重逻辑** | 参考 `hermes-vscode-main/src/protocol.ts``deduplicateChunk()` 函数——ACP 会重发完整文本作为可靠性 fallback,需检测并丢弃重复 |
| **验证** | 单元测试:构造 mock `AcpClient``create_session` → 验证发送 `session/new``run_prompt` → 验证发送 `session/prompt`;模拟 `session/update` notification,验证 `on_event` 回调被正确调用 |
### Step 6:创建 `acp_runtime.rs` — 运行时管理
| 项 | 内容 |
|---|---|
| **目的** | 管理 agent runtime 进程的 spawn、健康检查、自动重启 |
| **路径** | `rust/crates/mnote-web/src/acp_runtime.rs`(新建) |
| **结构** | `pub struct AcpRuntimeManager { runtimes: HashMap<String, AcpRuntimeConfig>, active: Mutex<Option<String>> }``pub struct AcpRuntimeConfig { name, bin, args, env }` |
| **方法** | `register_runtime(config)``spawn_runtime(name) → AcpClient``health_check(name) → bool`spawn 进程 + 发送 `initialize` 请求,超时 5s)、`shutdown_runtime(name)``switch_to(name) → Result`(先 shutdown 当前 active,再 spawn 新的) |
| **配置来源** | 从环境变量 / 配置文件读取(`MNOTE_WEB_ACP_RUNTIMES` JSON),当前固定配置:`hermes``{ bin: "hermes", args: ["acp"] }``reasonix``{ bin: "node", args: ["reasonix-acp-wrapper.mjs"] }` |
| **profile 扩展** | 当前 `configured_upstream_for_profile()` 返回 Hermes HTTP URL;改为返回 `AcpRuntimeConfig`。已有 profile 系统(`active_profile_name`, `configured_upstream_for_profile`) 保持接口不变,内部实现切换 |
| **验证** | 运行 `hermes acp`(需本地安装),发送 `session/new` 验证返回 `sessionId`;无 Hermes 环境时 mock 子进程验证健康检查逻辑 |
### Step 7:集成 ACP Session Manager 到 Hermes routes
| 项 | 内容 |
|---|---|
| **目的** | 让 `hermes_client.rs` 的现有端点可以选择使用 ACP 而非 HTTP proxy |
| **参考** | `hermes_client.rs` 中现有 `create_run` / `stream_events` / `abort_run` |
| **操作** | 在 `hermes_client.rs` 中导入 `AcpRuntimeManager``AcpSessionManager`;当当前 profile 的 `runtime_type == "acp"` 时走 ACP 路径,否则走原有 HTTP proxy 路径;`create_run``acp_session_manager.run_prompt()`(替代 Hermes HTTP `POST /api/hermes/runs`);`stream_events` → 从 `AcpSessionManager``on_event` 回调中发出 SSE(替代 Hermes HTTP `GET /api/hermes/runs/{id}/events`);`abort_run``acp_session_manager.cancel()`(替代 Hermes HTTP `POST /api/hermes/runs/{id}/abort` |
| **SSE 桥接函数** | 新增 `fn acp_event_to_sse(event: AcpSessionEvent) → Option<SseEvent>`。映射表:`TextDelta("msg")``{ event: "message.delta", data: { delta: "msg" } }``ThoughtDelta("t")``{ event: "thought.delta", data: { delta: "t" } }`(新增);`ToolCall("id","title","kind","pending")``{ event: "tool.started", data: { tool: "title", preview: null } }``ToolCallUpdate("id","completed",content)``{ event: "tool.completed", data: { tool: "...", duration: null } }``UsageUpdate(used,size)``{ event: "usage.updated", data: { used, size } }`(新增);`SessionInfo(title)` → 忽略(mnote 前端不需要) |
| **验证** | 用 `hermes acp`(本地已安装)做 e2e 测试:前端发消息 → ACP session/prompt → 收到 SSE 流 → 显示工具调用 → 显示最终回复 |
### Step 8:编辑全局 Router 添加 ACP 模块
| 项 | 内容 |
|---|---|
| **目的** | 让 ACP 模块被编译,各模块之间可引用 |
| **参考** | `rust/crates/mnote-web/src/routes/mod.rs` 中当前 Hermes routes 的注册方式 (nest at `hermes_base_path`) |
| **操作** | 在 `mod.rs` 中添加 `mod acp_client;``mod acp_types;``mod acp_session_manager;``mod acp_runtime;`;初始化时创建 `AcpRuntimeManager`,注册 Hermes runtime 和 Reasonix runtime(如果配置存在);将 `AcpRuntimeManager` 放入 `AppState``Extension` |
| **验证** | `cargo build` 通过 |
### Step 9AppState 改造 — 加入 AcpRuntimeManager
| 项 | 内容 |
|---|---|
| **目的** | 让路由 handler 可以访问运行时管理器 |
| **参考** | `rust/crates/mnote-web/src/app.rs` 中的 `AppState` 结构 |
| **操作** | 在 `AppState` 中添加 `acp_runtime: Arc<AcpRuntimeManager>` 字段;`AppState::new()` 中根据配置注册 `hermes` 和/或 `reasonix` runtime |
| **验证** | `cargo build` 通过;health endpoint 返回 ACP runtime 状态 |
### Step 10:创建 Reasonix ACP wrapper 脚本
| 项 | 内容 |
|---|---|
| **目的** | 实现 Reasonix ACP server,让 mnote-web 可以 `spawn("node", ["reasonix-acp-wrapper.mjs"])` 连接 |
| **参考** | `reference-code/DeepSeek-Reasonix-main/src/cli/commands/acp.ts`(完整参考,~339 行):acpCommand() (L195-339)、loadMcpServers() (L88-193) |
| **路径** | `scripts/reasonix-acp-wrapper.mjs`(新建) |
| **结构** | import `AcpServer` from `reasonix/acp/server`、import `DeepSeekClient`, `CacheFirstLoop`, `ToolRegistry` from `reasonix`;从环境变量读取 `DEEPSEEK_API_KEY``MNOTE_WEB_URL`(默认为 `http://127.0.0.1:3000`);创建 `ToolRegistry`,注册 `mnote.doc.fetch``mnote.doc.markdown_edit` 工具(工具实现通过 HTTP 调用 `MNOTE_WEB_URL/api/hermes/tools/mnote/call`);`AcpServer` + `onRequest("session/new")` → 创建 `CacheFirstLoop`(参考 acp.ts L220-260);`onRequest("session/prompt")``loop.run()` + `dispatchKernelEvent()`(参考 acp.ts L260-330);`onNotification("session/cancel")``aborter.abort()`(参考 acp.ts L330-339);启动后 `server.done()` 等待 stdin 关闭 |
| **MNOTE_WEB_URL 寻址** | wrapper 脚本在本地运行,通过 `http://127.0.0.1:3000` 调 mnote-web 的 tool API——因为 `hermes_tools.rs` 的 tool 实现在 Rust 侧,wrapper 不重复实现工具逻辑 |
| **验证** | 手动测试:`node scripts/reasonix-acp-wrapper.mjs` 启动后,用标准 ACP client 发送 `session/new` + `session/prompt`,验证返回正常;工具调用可正确通过 mnote-web 读写文档 |
### Step 11:添加运行时选择器的前端支持
| 项 | 内容 |
|---|---|
| **目的** | 在页面 AI 面板中增加运行时切换能力 |
| **参考** | `hermes-vscode-main/src/chatPanel.ts`ACP 事件 → UI 渲染)、`hermes-vscode-main/src/webview/main.ts`webview 事件处理) |
| **前端路径** | `wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx` |
| **操作** | 扩展 profile 获取接口 `GET /api/hermes/client/profiles`,解析 `runtimeType` 字段;增加 `<select>` 下拉框显示可用 runtime"Hermes / Reasonix");切换时调用 `PUT /api/hermes/client/profiles/active`;切换后自动刷新当前会话 |
| **新增 Thought Delta 渲染** | 在 AiAgentPanel 中处理新增的 `thought.delta` SSE 事件,渲染在对话气泡的独立区域(灰色小字或可折叠的 reasoning 面板,参考 hermes-vscode-main webview 对 `agent_thought_chunk` 的渲染) |
| **验证** | 切换 runtime → 发送消息 → 确认 AI 回复流畅;Reasonix 模式下确认缓存指标显示在 header 中 |
### Step 12profile 扩展 — 从 upstream URL 改为 runtime 配置
| 项 | 内容 |
|---|---|
| **目的** | 让 profile 不再持有 `upstream_url`Hermes HTTPS),而是持有 `runtime_name`ACP 通用) |
| **参考** | `hermes_client.rs``configured_upstream_for_profile()``profile_gateway_status()` |
| **操作** | 扩展 profile 数据结构:添加 `runtime_type: Option<String>`"hermes_http"|"acp")、`runtime_name: Option<String>`RuntimeConfig 的 name);新增 `configured_runtime_for_profile(profile) → Option<&AcpRuntimeConfig>`;向后兼容:profile 如果只有 `upstream_url` 但没有 `runtime_type`,视为 `hermes_http`(旧行为);profile 如果有 `runtime_type: "acp"`,则走 ACP Session Manager |
| **health check 改造** | `gateway_health()` 当前只 probe Hermes HTTP upstream;改为:如果 profile 是 `acp` 类型,则调用 `acp_runtime.health_check()`,否则继续 probe HTTP upstream |
| **profile 默认值** | 新增环境变量 `MNOTE_WEB_ACP_DEFAULT_RUNTIME`:默认 `hermes`;设为 `reasonix` 则默认使用 Reasonix |
| **验证** | 不改变现有 `hermes_http` 行为;新增 `acp` 类型 profile 的 health check 正常返回 |
### Step 13Hermes HTTP proxy 代码标为 deprecated
| 项 | 内容 |
|---|---|
| **目的** | 标记旧代码,避免新开发继续依赖 |
| **操作** | 在 `hermes_client.rs` 中 HTTP proxy 相关函数(`proxy_json``proxy_stream``configured_upstream_for_profile` 等)添加 `#[deprecated(note = "迁移到 ACP Session Manager")]`;不影响编译,只是 IDE 和 CI 提示 |
| **验证** | `cargo build` 无 warningdeprecated 函数被自身使用时默认不 warn) |
### Step 14:前端运行时切换验证 e2e
| 项 | 内容 |
|---|---|
| **目的** | 同时验证 Hermes ACP 和 Reasonix ACP 两条路径都能正常走通 |
| **环境准备** | 启动 mnote-web (`cargo run`)、确保 `hermes` 命令可用、确保 `node``reasonix` npm 包已安装、确保 Reasonix wrapper 脚本就绪 |
| **测试路径** | 在浏览器页面 AI 面板中选择 "Hermes" → 发送编辑请求 → 确认工具调用和回复正常;切换到 "Reasonix" → 发送同样的编辑请求 → 确认工具调用和回复正常(且 header 显示缓存命中率);块编辑 fast-path (`/api/page-ai/block-edit-workflow`) 独立测试,不受 ACP 切换影响 |
| **测试账号** | 使用默认测试账号 `mnote.e2e@example.com`,见项目记忆 |
| **验证** | 两种 runtime 都能正常读写文档;Reasonix 模式下 `message.delta` 流式响应速度不慢于 Hermes |
### Step 15:压力测试 — 确认同时多会话稳定性
| 项 | 内容 |
|---|---|
| **目的** | 确保多个 ACP session 并发时不出现进程冲突、内存泄漏 |
| **操作** | 通过 Playwright 或手动测试:同时打开 3 个页面 AI 面板,分别发送不同的编辑请求;观察所有会话是否独立完成;检查 `AcpSessionManager` 的 sessions map 是否在会话结束后正确清理;检查子进程数量是否失控(每个 runtime 应有进程上限,可在 `AcpRuntimeConfig` 中添加 `max_concurrent_sessions`,默认 10 |
| **验证** | 所有会话都能正常完成;无僵尸子进程残留;`top` 确认 Reasonix Node.js 进程数可控 |
### Step 16:退役旧的 Hermes HTTP proxy 代码
| 项 | 内容 |
|---|---|
| **前提** | 所有 production profile 都已迁移到 ACP;灰度观察期至少 1 周无回退 |
| **操作** | 删除 `hermes_client.rs` 中所有 `#[deprecated]` 的函数(`proxy_json``proxy_stream``configured_upstream_for_profile` 等);删除环境变量 `MNOTE_WEB_HERMES_UPSTREAM_URL` 的解析代码;统一所有 profile 为 `runtime_type: "acp"`;不再依赖 `hermes` 命令的 HTTP gateway 模式 |
| **验证** | `cargo build`、常规 e2e 测试全部通过 |
### Step 17:基准测试 — Reasonix 缓存收益量化
| 项 | 内容 |
|---|---|
| **目的** | 收集 Reasonix prefix cache 的实际收益数据,作为后续切流的决策依据 |
| **操作** | 设计 3 轮测试:**场景 A(同一文档连续编辑 5 次)**——对同一文档连续发 5 次 `mnote.doc.markdown_edit`,记录每次的 `prompt_cache_hit_tokens``prompt_cache_miss_tokens`。**场景 B(不同文档交替编辑 5 次)**——交替编辑 5 个不同文档,统计 cache hit rate。**场景 C(长会话 10 轮对话)**——在同一 session 中连续发 10 条消息,统计 cache hit rate 变化趋势 |
| **指标** | `cache_hit_rate = hit_tokens / (hit + miss)`Reasonix 的 `CacheFirstLoop` 每轮迭代后暴露 `ctx.prefixHash``ctx.stats` |
| **对比基线** | 同样 3 个场景下 Hermes ACP 的 cache hit rate(理论上接近 0% |
| **产出** | 记录到 `benchmarks/reasonix-cache-report.md` |
| **通过标准** | Reasonix 场景 A 的 cache hit rate ≥ 80%Reasonix 自述 ~90%+);场景 B ≥ 50%;场景 C ≥ 60% |
---
## 附录:文件依赖关系图
```
step-3 acp_client.rs
│ depends on: none
step-4 acp_types.rs step-6 acp_runtime.rs
│ depends on: none │ depends on: serde_json
▼ ▼
step-5 acp_session_manager.rs ←───────────┘
│ depends on: acp_client, acp_types, acp_runtime
step-7 hermes_client.rs 改造
│ depends on: acp_session_manager
step-8 mod.rs 添加模块
│ depends on: step-3,4,5,6
step-9 AppState 改造
│ depends on: acp_runtime, acp_session_manager
step-10 reasonix-acp-wrapper.mjs (独立, 可并行)
│ depends on: npm reasonix 包
step-11 AiAgentPanel.tsx 修改 step-12 profile 扩展
│ depends on: step-7 │ depends on: hermes_client.rs
▼ ▼
step-13 标 deprecated (与 step-10 可并行)
step-14 e2e 验证
├── step-15 压力测试
└── step-16 退役旧代码
└── step-17 基准测试
```
### 关键并行路径
```
step-3 ─→ step-5 ─→ step-7 ─→ step-11 ─→ step-14
step-10 (Reasonix wrapper, 可并行)
step-4 ─→ step-5
step-6 ─→ step-5, step-9
```
### 每次 AI 执行前必须确认
1. `run_command pwd``/mnt/Data1T/mnote`(确认 cwd
2. 写文件路径用相对路径 `rust/crates/...` 而非 `/mnt/Data1T/mnote/...`
3.`search_content` / `read_file` 确认目标文件最新内容,避免 edit_file SEARCH 不匹配
4. 每个 step 完成后 `todo_write` 更新进度
+13
View File
@@ -1544,6 +1544,7 @@ dependencies = [
"storage-convex-bridge",
"time",
"tokio",
"tokio-stream",
"tokio-tungstenite",
"tower",
"tower-http",
@@ -2688,6 +2689,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
"tokio-util",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
+2 -1
View File
@@ -17,7 +17,8 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
serde = { version = "1", features = ["derive"] }
serde_json = "1"
storage-convex-bridge = { path = "../storage-convex-bridge" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time", "process", "io-util"] }
tokio-stream = { version = "0.1", features = ["sync"] }
tokio-tungstenite = "0.29"
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
+249
View File
@@ -0,0 +1,249 @@
/// ACP ↔ SSE bridge for Hermes route integration.
///
/// Transforms ACP session events into the SSE event format expected by the
/// frontend (HermesRunEvent), and manages the background prompt lifecycle.
///
/// Reference: `hermes-vscode-main/src/sessionManager.ts` handleUpdate()
use crate::acp_runtime::AcpRuntimeManager;
use crate::acp_session_manager::{AcpSessionEvent, AcpSessionManager};
use crate::acp_types::ContentBlock;
use axum::body::Body;
use axum::http::{header, StatusCode};
use axum::response::Response;
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::{info, warn};
/// Errors from the ACP bridge.
#[derive(Debug)]
pub enum AcpBridgeError {
NoActiveRuntime,
SessionError(String),
StreamError(String),
}
impl std::fmt::Display for AcpBridgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AcpBridgeError::NoActiveRuntime => write!(f, "no active ACP runtime"),
AcpBridgeError::SessionError(msg) => write!(f, "ACP session error: {msg}"),
AcpBridgeError::StreamError(msg) => write!(f, "ACP stream error: {msg}"),
}
}
}
/// SSE event types sent to the frontend.
/// Mirrors HermesRunEvent from bridge.ts.
#[derive(Debug, Clone)]
pub struct SseEvent {
pub event: String,
pub data: Value,
}
/// Bridge state for one run: holds the broadcast channel for SSE events.
pub struct AcpRunBridge {
session_id: String,
event_tx: broadcast::Sender<SseEvent>,
}
impl AcpRunBridge {
/// Create a new ACP run: create session + start prompt in background.
///
/// Returns a bridge with a broadcast receiver that the SSE endpoint can use.
pub async fn start(
runtime_mgr: &AcpRuntimeManager,
runtime_name: &str,
prompt_blocks: Vec<ContentBlock>,
) -> Result<Self, AcpBridgeError> {
// Get or activate the runtime
let client = if runtime_mgr.is_active().await {
runtime_mgr.active_client().await.ok_or(AcpBridgeError::NoActiveRuntime)?
} else {
runtime_mgr.switch_to(runtime_name).await
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?
};
// Create session manager
let mgr = Arc::new(AcpSessionManager::new(client));
// Create event channel (256 buffered, enough for SSE streaming)
let (event_tx, _) = broadcast::channel(256);
let event_tx_clone = event_tx.clone();
// Set up event handler
mgr.on_event(move |event| {
if let Some(sse) = acp_event_to_sse(event) {
let _ = event_tx_clone.send(sse);
}
});
// Create session
let sid = mgr.create_session(None, None)
.await
.map_err(|e| AcpBridgeError::SessionError(e.to_string()))?;
// Start prompt in background
let mgr_clone = mgr.clone();
let event_tx_prompt = event_tx.clone();
tokio::spawn(async move {
match mgr_clone.run_prompt(prompt_blocks).await {
Ok(result) => {
info!("ACP prompt completed: stop_reason={:?}", result.stop_reason);
let _ = event_tx_prompt.send(SseEvent {
event: "run.completed".into(),
data: json!({
"stopReason": format!("{:?}", result.stop_reason),
}),
});
}
Err(e) => {
warn!("ACP prompt failed: {e}");
let _ = event_tx_prompt.send(SseEvent {
event: "run.failed".into(),
data: json!({ "error": e.to_string() }),
});
}
}
});
info!("ACP run started: session={}", sid);
Ok(Self {
session_id: sid,
event_tx,
})
}
/// Cancel the current run.
pub async fn abort(&self) {
// Cancellation is sent via the session manager.
// For now, we just drop the bridge — the background task will detect this
// via the broadcast channel being closed.
info!("ACP run aborted: session={}", self.session_id);
}
/// Create an SSE response body from the event broadcast receiver.
pub fn into_sse_response(self) -> Response {
use tokio::sync::mpsc;
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
let mut broadcast_rx = self.event_tx.subscribe();
// Forward events from broadcast to mpsc
tokio::spawn(async move {
loop {
match broadcast_rx.recv().await {
Ok(event) => {
let json = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
let bytes = axum::body::Bytes::from(
format!("event: {}\ndata: {}\n\n", event.event, json)
);
if tx.send(Ok(bytes)).await.is_err() {
break; // receiver dropped
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("ACP SSE lagged: {n} events dropped");
}
Err(broadcast::error::RecvError::Closed) => {
break; // stream ended
}
}
}
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::empty())
.unwrap()
})
}
}
/// Map an AcpSessionEvent to an SSE event for the frontend.
///
/// Reference: hermes-vscode-main protocol.ts extractTextContent/parseToolCall/parseToolCallUpdate
/// Reference: wolai-frontend bridge.ts HermesRunEvent type
pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
match event {
AcpSessionEvent::TextDelta { text } => {
Some(SseEvent {
event: "message.delta".into(),
data: json!({ "delta": text }),
})
}
AcpSessionEvent::ThoughtDelta { text } => {
Some(SseEvent {
event: "thought.delta".into(),
data: json!({ "delta": text }),
})
}
AcpSessionEvent::ToolCall {
tool_call_id,
title,
kind,
..
} => {
Some(SseEvent {
event: "tool.started".into(),
data: json!({
"tool": title,
"toolCallId": tool_call_id,
"kind": kind,
}),
})
}
AcpSessionEvent::ToolCallUpdate {
tool_call_id,
status,
} => {
let error = status == crate::acp_types::ToolCallStatus::Failed;
Some(SseEvent {
event: "tool.completed".into(),
data: json!({
"toolCallId": tool_call_id,
"error": error,
}),
})
}
AcpSessionEvent::UsageUpdate { used, size } => {
Some(SseEvent {
event: "usage.updated".into(),
data: json!({ "used": used, "size": size }),
})
}
AcpSessionEvent::SessionInfoUpdate { .. } => {
None // Not forwarded to frontend
}
AcpSessionEvent::PlanUpdate { .. } => {
None // Not forwarded (Phase C)
}
AcpSessionEvent::Disconnected { reason } => {
Some(SseEvent {
event: "run.failed".into(),
data: json!({ "error": reason }),
})
}
}
}
/// Helper: get the runtime name from a profile.
/// For now, we use "hermes" or "reasonix" directly.
/// In Step 12, this will come from the profile config.
pub fn runtime_name_for_profile(profile: &str) -> &str {
match profile {
"reasonix" => "reasonix",
_ => "hermes",
}
}
+487
View File
@@ -0,0 +1,487 @@
/// ACP (Agent Client Protocol) JSON-RPC 2.0 client.
///
/// Walks an agent runtime subprocess (e.g. `hermes acp` or `node reasonix-acp-wrapper.mjs`)
/// over NDJSON stdio: one JSON object per line, newline-delimited.
///
/// Reference implementations:
/// - `reference-code/hermes-vscode-main/src/acpClient.ts` (primary reference)
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
///
/// Wire format:
/// Request: { jsonrpc: "2.0", id: number, method: string, params?: object }
/// Response: { jsonrpc: "2.0", id: number, result?: any, error?: { code, message } }
/// Notification:{ jsonrpc: "2.0", method: string, params?: object } (no id)
/// Incoming: { jsonrpc: "2.0", id: number, method: string, params?: object } (from agent)
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::{Mutex, oneshot};
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
// ── Error types ──────────────────────────────────────
#[derive(Debug)]
pub enum AcpError {
Spawn(std::io::Error),
JsonParse(serde_json::Error),
JsonRpc { code: i64, message: String },
Timeout(u64),
Closed,
Internal(String),
}
impl std::fmt::Display for AcpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AcpError::Spawn(e) => write!(f, "ACP spawn failed: {e}"),
AcpError::JsonParse(e) => write!(f, "ACP JSON parse error: {e}"),
AcpError::JsonRpc { code, message } => {
write!(f, "ACP JSON-RPC error [{code}]: {message}")
}
AcpError::Timeout(secs) => write!(f, "ACP request timed out after {secs}s"),
AcpError::Closed => write!(f, "ACP connection closed"),
AcpError::Internal(msg) => write!(f, "ACP internal: {msg}"),
}
}
}
impl std::error::Error for AcpError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AcpError::Spawn(e) => Some(e),
AcpError::JsonParse(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for AcpError {
fn from(e: std::io::Error) -> Self {
AcpError::Spawn(e)
}
}
impl From<serde_json::Error> for AcpError {
fn from(e: serde_json::Error) -> Self {
AcpError::JsonParse(e)
}
}
// ── Notification handler type ────────────────────────
type NotificationHandler = Box<dyn Fn(String, Value) + Send + 'static>;
/// Thread-safe mutex for notification handler (std mutex — lightweight, never held across awaits).
type NotificationHandlerMutex = std::sync::Mutex<Option<NotificationHandler>>;
// ── Pending request entry ────────────────────────────
type PendingEntry = oneshot::Sender<Result<Value, AcpError>>;
// ── AcpClient ────────────────────────────────────────
/// ACP JSON-RPC 2.0 client over stdio.
///
/// Create via [`AcpClient::spawn`], then use [`request`](Self::request) for RPC
/// calls and [`notification`](Self::notification) for fire-and-forget messages.
/// Register a handler with [`on_notification`](Self::on_notification) to receive
/// agent push events (e.g. `session/update`).
// Manual Debug impl: Child doesn't impl Debug, so we skip it
impl std::fmt::Debug for AcpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcpClient")
.field("next_id", &self.next_id)
.field("pending_count", &self.pending.blocking_lock().len())
.finish_non_exhaustive()
}
}
pub struct AcpClient {
child: Option<Child>,
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
next_id: AtomicU64,
notification_handler: Arc<NotificationHandlerMutex>,
}
impl AcpClient {
/// Spawn an ACP subprocess and establish the JSON-RPC connection.
///
/// After spawn, sends an `initialize` handshake (as Hermes does in acpClient.ts
/// `start()` → `call('initialize', { protocolVersion: 1 })`).
/// Launches a background tokio task that reads NDJSON lines from the child's stdout.
///
/// Reference: `hermes-vscode-main/src/acpClient.ts` L50-80 (spawn + stdio setup)
pub async fn spawn(bin: &str, args: &[&str]) -> Result<Self, AcpError> {
let mut child = Command::new(bin)
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true)
.spawn()
.map_err(AcpError::Spawn)?;
let stdin = child.stdin.take().ok_or_else(|| {
AcpError::Internal("failed to take child stdin".into())
})?;
let stdout = child.stdout.take().ok_or_else(|| {
AcpError::Internal("failed to take child stdout".into())
})?;
let writer = BufWriter::new(stdin);
let reader = BufReader::new(stdout);
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> =
Arc::new(Mutex::new(HashMap::new()));
let notification_handler: Arc<NotificationHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
// Start background reader task
let pending_clone = pending.clone();
let handler_clone = notification_handler.clone();
let child_pid = child.id().unwrap_or(0);
tokio::spawn(async move {
Self::reader_loop(reader, pending_clone, handler_clone).await;
info!("ACP reader loop ended (pid={})", child_pid);
});
let client = Self {
child: Some(child),
writer: Arc::new(Mutex::new(writer)),
pending,
next_id: AtomicU64::new(1),
notification_handler,
};
// Handshake: initialize (reference: acpClient.ts L108 → call('initialize', {protocolVersion: 1}))
let init_result: Value = client
.request("initialize", json!({ "protocolVersion": 1 }))
.await?;
debug!(?init_result, "ACP initialize OK");
Ok(client)
}
/// Send a JSON-RPC request and await the response.
///
/// Returns `Result<R>` where `R` is the deserialized `result` field.
/// On JSON-RPC error, returns [`AcpError::JsonRpc`].
/// Default timeout: 300 seconds.
///
/// Reference: `acpClient.ts` L95-110 (`call()` method)
pub async fn request<P: Serialize, R: DeserializeOwned>(
&self,
method: &str,
params: P,
) -> Result<R, AcpError> {
self.request_with_timeout(method, params, Duration::from_secs(300)).await
}
/// Same as [`request`] but with a configurable timeout.
pub async fn request_with_timeout<P: Serialize, R: DeserializeOwned>(
&self,
method: &str,
params: P,
dur: Duration,
) -> Result<R, AcpError> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(id, tx);
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
let line = serde_json::to_string(&req)?;
debug!("ACP --> {} #{} ({} bytes)", method, id, line.len());
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
match timeout(dur, rx).await {
Ok(Ok(Ok(value))) => {
let result: R = serde_json::from_value(value)?;
Ok(result)
}
Ok(Ok(Err(err))) => Err(err),
Ok(Err(_recv_err)) => Err(AcpError::Closed),
Err(_elapsed) => Err(AcpError::Timeout(dur.as_secs())),
}
}
/// Send a fire-and-forget notification (no id, no response expected).
///
/// Reference: `acpClient.ts` L115-118 (`notify()`)
pub async fn notification(&self, method: &str, params: Value) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
let line = serde_json::to_string(&msg)?;
debug!("ACP ~~> {} ({} bytes)", method, line.len());
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
Ok(())
}
/// Register a handler for incoming notifications (messages with `method` but no `id`).
/// Only one handler at a time — subsequent calls replace the previous.
pub fn on_notification<F>(&self, handler: F)
where
F: Fn(String, Value) + Send + 'static,
{
let mut guard = self.notification_handler.lock().unwrap();
*guard = Some(Box::new(handler));
}
/// Gracefully close the ACP connection and kill the subprocess.
pub async fn close(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
let _ = child.wait().await;
}
// Resolve all pending with Closed error
let mut pending = self.pending.lock().await;
for (_, tx) in pending.drain() {
let _ = tx.send(Err(AcpError::Closed));
}
}
// ── Background reader ────────────────────────────
/// Background loop: reads NDJSON lines from the child's stdout,
/// routes responses to pending requests and notifications to the handler.
///
/// Reference: `acpClient.ts` L120-180 (onData + dispatch)
async fn reader_loop(
mut reader: BufReader<ChildStdout>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: Arc<NotificationHandlerMutex>,
) {
let mut line_buf = String::new();
loop {
line_buf.clear();
match reader.read_line(&mut line_buf).await {
Ok(0) => {
info!("ACP stdout closed (EOF)");
break;
}
Ok(_n) => {}
Err(e) => {
warn!("ACP read error: {e}");
break;
}
}
let trimmed = line_buf.trim();
if trimmed.is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
warn!("ACP parse error: {e} (line: {})", &trimmed[..trimmed.len().min(80)]);
continue;
}
};
Self::dispatch_message(msg, &pending, &notification_handler).await;
}
// Process died or EOF — resolve all pending
let mut pending_guard = pending.lock().await;
for (_, tx) in pending_guard.drain() {
let _ = tx.send(Err(AcpError::Closed));
}
}
/// Route a single JSON message to pending request, notification handler, or incoming request.
///
/// Reference: `acpClient.ts` L160-200 (dispatch)
async fn dispatch_message(
msg: Value,
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: &Arc<NotificationHandlerMutex>,
) {
let has_id = msg.get("id").is_some();
let has_method = msg.get("method").and_then(|v| v.as_str()).map(|s| !s.is_empty()).unwrap_or(false);
if has_id && has_method {
// Incoming request from agent (e.g. session/request_permission)
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
// For now, reject all incoming requests since we don't need permission dialogs yet.
// Reference: acpClient.ts handleIncomingRequest (L200-220)
warn!("ACP incoming request not handled: {method} (params={params:?})");
// If we wanted to reply, we'd need to write back a response...
// For now just log. Phase C will add permission support.
} else if has_id {
// Response to one of our requests
if let Some(id) = msg["id"].as_u64() {
let mut pending_guard = pending.lock().await;
if let Some(tx) = pending_guard.remove(&id) {
if let Some(error) = msg.get("error") {
let code = error["code"].as_i64().unwrap_or(-1);
let message = error["message"]
.as_str()
.unwrap_or("unknown error")
.to_string();
let _ = tx.send(Err(AcpError::JsonRpc { code, message }));
} else if let Some(result) = msg.get("result") {
let _ = tx.send(Ok(result.clone()));
} else {
let _ = tx.send(Err(AcpError::Internal(
"response without result or error".into(),
)));
}
} else {
debug!("ACP response for unknown request id={id}");
}
}
} else if has_method {
// Notification (no id)
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let handler_guard = notification_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(method, params);
} else {
debug!("ACP notification unhandled: {method}");
}
}
}
}
impl Drop for AcpClient {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
}
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Helper: create a mock subprocess that echoes back requests as responses.
/// Simulates a minimal ACP server for testing.
async fn spawn_mock_acp_server() -> AcpClient {
// We spawn a small node script that reads NDJSON and echoes back
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id !== undefined && msg.method) {
if (msg.method === 'initialize') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
}) + '\n');
} else {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: msg.id,
result: { ok: true, echo: msg.params }
}) + '\n');
}
} else if (msg.method && msg.id === undefined) {
// Notification → ignore
}
});
"#;
// Write script to temp file
let dir = std::env::temp_dir();
let script_path = dir.join("acp_test_mock.mjs");
std::fs::write(&script_path, script).expect("write mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn mock ACP")
}
#[tokio::test]
async fn test_request_response() {
let client = spawn_mock_acp_server().await;
let result: Value = client
.request("test_method", json!({ "hello": "world" }))
.await
.expect("request should succeed");
assert_eq!(result["ok"], true);
assert_eq!(result["echo"]["hello"], "world");
}
#[tokio::test]
async fn test_notification() {
let client = spawn_mock_acp_server().await;
// Notifications are fire-and-forget, no response expected
client
.notification("test_notify", json!({ "foo": "bar" }))
.await
.expect("notification should succeed");
}
#[tokio::test]
async fn test_on_notification_received() {
use std::sync::atomic::AtomicBool;
let client = spawn_mock_acp_server().await;
let received = Arc::new(AtomicBool::new(false));
let received_clone = received.clone();
client.on_notification(move |method, _params| {
if method == "test_push" {
received_clone.store(true, Ordering::SeqCst);
}
});
// Send a notification that the mock server will echo back as...
// Actually the mock doesn't send unsolicited notifications.
// This test just validates the handler registration doesn't crash.
client
.notification("test_push", json!({}))
.await
.expect("notification");
// Give background task time to process
tokio::time::sleep(Duration::from_millis(100)).await;
// In this mock, no notification will be received; that's OK
}
#[tokio::test]
async fn test_close() {
let mut client = spawn_mock_acp_server().await;
client.close().await;
// Second close should be no-op
client.close().await;
}
#[tokio::test]
async fn test_initialize_handshake() {
// spawn already calls initialize; if it fails, the test fails
let _client = spawn_mock_acp_server().await;
}
}
+352
View File
@@ -0,0 +1,352 @@
/// ACP Runtime Manager — manages agent runtime subprocess lifecycle.
///
/// Supports multiple runtimes (Hermes, Reasonix) and switching between them.
/// Each runtime is spawned as a subprocess communicating via the ACP JSON-RPC 2.0 protocol.
///
/// Configuration:
/// `MNOTE_WEB_ACP_DEFAULT_RUNTIME` — default runtime name ("hermes" | "reasonix")
/// `MNOTE_WEB_HERMES_BIN` — path to Hermes binary (default: "hermes")
/// `MNOTE_WEB_REASONIX_WRAPPER` — path to Reasonix wrapper script (default: "scripts/reasonix-acp-wrapper.mjs")
///
/// Or via JSON env var:
/// `MNOTE_WEB_ACP_RUNTIMES` — JSON array of runtime configs
use crate::acp_client::{AcpClient, AcpError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
use tracing::{debug, info, warn};
// ── Config ───────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcpRuntimeConfig {
/// Display name (e.g. "hermes", "reasonix").
pub name: String,
/// Binary path (e.g. "hermes", "node").
pub bin: String,
/// Command arguments (e.g. ["acp"], ["scripts/reasonix-acp-wrapper.mjs"]).
#[serde(default)]
pub args: Vec<String>,
/// Extra environment variables.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
/// Human-readable title for the runtime selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl AcpRuntimeConfig {
/// Create a Hermes ACP runtime config.
pub fn hermes(bin: Option<&str>) -> Self {
Self {
name: "hermes".into(),
bin: bin.unwrap_or("hermes").to_string(),
args: vec!["acp".into()],
env: None,
title: Some("Hermes".into()),
}
}
/// Create a Reasonix ACP runtime config.
/// `wrapper_path` is relative to the project root (where Cargo.toml's parent is).
/// Default: `design/05-editor-mainline/reference-code/DeepSeek-Reasonix-main/scripts/reasonix-acp-wrapper.mjs` (dev),
/// or in production, the absolute path is resolved via `CARGO_MANIFEST_DIR` (the `rust/` directory).
pub fn reasonix(wrapper_path: Option<&str>) -> Self {
// CARGO_MANIFEST_DIR is the directory containing this crate's Cargo.toml:
// /mnt/Data1T/mnote/rust/crates/mnote-web/
// We need the project root: /mnt/Data1T/mnote/
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3) // up: mnote-web/ → crates/ → rust/ → mnote/ (project root)
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
.to_string_lossy()
.to_string();
let default_path = format!("{project_root}/scripts/reasonix-acp-wrapper.mjs");
// Resolve wrapper_path: if it's relative, prepend project_root; absolute paths used as-is
let resolved_path = wrapper_path.map(|p| {
if p.starts_with('/') {
p.to_string()
} else {
format!("{project_root}/{p}")
}
}).unwrap_or(default_path);
Self {
name: "reasonix".into(),
bin: "node".into(),
args: vec![resolved_path],
env: None,
title: Some("Reasonix".into()),
}
}
}
// ── Runtime Manager ──────────────────────────────────
/// Manages lifecycle of multiple agent runtimes.
///
/// Each runtime is defined by a name and spawn configuration.
/// At most one runtime is "active" at a time, providing an [`AcpClient`].
#[derive(Debug)]
pub struct AcpRuntimeManager {
runtimes: HashMap<String, AcpRuntimeConfig>,
active: Mutex<Option<ActiveRuntime>>,
default_runtime: String,
}
#[derive(Debug)]
struct ActiveRuntime {
config: AcpRuntimeConfig,
client: Arc<AcpClient>,
}
impl AcpRuntimeManager {
/// Create a new runtime manager with built-in default configurations.
///
/// Reads environment variables to configure Hermes and Reasonix runtimes.
/// Default active runtime is set by `MNOTE_WEB_ACP_DEFAULT_RUNTIME` (default: "hermes").
pub fn from_env() -> Self {
let mut runtimes: HashMap<String, AcpRuntimeConfig> = HashMap::new();
// Check for JSON-based configuration first
if let Ok(json) = env::var("MNOTE_WEB_ACP_RUNTIMES") {
if let Ok(custom_runtimes) =
serde_json::from_str::<Vec<AcpRuntimeConfig>>(&json)
{
for rt in custom_runtimes {
let name = rt.name.clone();
runtimes.insert(name, rt);
}
} else {
warn!("Failed to parse MNOTE_WEB_ACP_RUNTIMES JSON");
}
}
// Always add default Hermes if not already configured
if !runtimes.contains_key("hermes") {
let hermes_bin = env::var("MNOTE_WEB_HERMES_BIN")
.unwrap_or_else(|_| "hermes".into());
runtimes.insert("hermes".into(), AcpRuntimeConfig::hermes(Some(&hermes_bin)));
}
// Always add default Reasonix if not already configured
if !runtimes.contains_key("reasonix") {
let wrapper = env::var("MNOTE_WEB_REASONIX_WRAPPER")
.unwrap_or_else(|_| "scripts/reasonix-acp-wrapper.mjs".into());
runtimes.insert("reasonix".into(), AcpRuntimeConfig::reasonix(Some(&wrapper)));
}
let default = env::var("MNOTE_WEB_ACP_DEFAULT_RUNTIME")
.unwrap_or_else(|_| "hermes".into());
Self {
runtimes,
active: Mutex::new(None),
default_runtime: default,
}
}
/// Get the list of available runtime names.
pub fn available_runtimes(&self) -> Vec<String> {
self.runtimes.keys().cloned().collect()
}
/// Get a runtime config by name.
pub fn get_config(&self, name: &str) -> Option<&AcpRuntimeConfig> {
self.runtimes.get(name)
}
/// Get the default runtime name.
pub fn default_runtime(&self) -> &str {
&self.default_runtime
}
/// Get the currently active runtime name, if any.
pub async fn active_runtime_name(&self) -> Option<String> {
self.active.lock().await.as_ref().map(|a| a.config.name.clone())
}
/// Get a reference to the currently active [`AcpClient`], if any.
pub async fn active_client(&self) -> Option<Arc<AcpClient>> {
self.active.lock().await.as_ref().map(|a| a.client.clone())
}
/// Check if a runtime is active and the client is available.
pub async fn is_active(&self) -> bool {
self.active.lock().await.is_some()
}
/// Activate a runtime by name, spawning a new subprocess if needed.
///
/// If another runtime is currently active, it will be shut down first.
/// After spawn, performs an `initialize` handshake to verify the runtime is healthy.
pub async fn switch_to(&self, name: &str) -> Result<Arc<AcpClient>, AcpError> {
let config = self
.runtimes
.get(name)
.cloned()
.ok_or_else(|| AcpError::Internal(format!("unknown runtime: {name}")))?;
// Shutdown current active runtime
let mut active_guard = self.active.lock().await;
if let Some(ref current) = *active_guard {
if current.config.name == name {
// Already active — return existing client
return Ok(current.client.clone());
}
// Drop the old ActiveRuntime, which will kill the child process
// (via AcpClient's Drop impl)
}
info!("ACP runtime: switching to {name} (bin={}, args={:?})", config.bin, config.args);
// Convert Vec<String> to Vec<&str> for AcpClient::spawn
let args_refs: Vec<&str> = config.args.iter().map(|s| s.as_str()).collect();
let client = AcpClient::spawn(&config.bin, &args_refs).await?;
let client = Arc::new(client);
*active_guard = Some(ActiveRuntime {
config,
client: client.clone(),
});
info!("ACP runtime: {name} active");
Ok(client)
}
/// Shut down the currently active runtime.
pub async fn shutdown_active(&self) {
let mut active_guard = self.active.lock().await;
if let Some(active) = active_guard.take() {
info!("ACP runtime: shutting down {}", active.config.name);
// AcpClient's Drop kills the process
}
}
/// Perform a health check on the active runtime.
///
/// Returns `true` if the runtime responds to an `initialize` handshake within 5 seconds.
pub async fn health_check(&self) -> bool {
let client = match self.active_client().await {
Some(c) => c,
None => return false,
};
// Use request_with_timeout with a short timeout
let result: Result<serde_json::Value, AcpError> = timeout(
Duration::from_secs(5),
client.request("initialize", serde_json::json!({ "protocolVersion": 1 })),
)
.await
.map_err(|_| AcpError::Timeout(5))
.and_then(|r| r);
match result {
Ok(val) => {
let ok = val.get("protocolVersion").and_then(|v| v.as_u64()) == Some(1);
if ok {
debug!("ACP health check OK");
} else {
warn!("ACP health check: unexpected response: {val:?}");
}
ok
}
Err(e) => {
warn!("ACP health check failed: {e}");
false
}
}
}
}
impl Drop for AcpRuntimeManager {
fn drop(&mut self) {
// The active runtime's AcpClient Drop will kill the process
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_config_hermes() {
let cfg = AcpRuntimeConfig::hermes(None);
assert_eq!(cfg.name, "hermes");
assert_eq!(cfg.bin, "hermes");
assert_eq!(cfg.args, vec!["acp"]);
}
#[test]
fn test_runtime_config_reasonix() {
let cfg = AcpRuntimeConfig::reasonix(None);
assert_eq!(cfg.name, "reasonix");
assert_eq!(cfg.bin, "node");
assert_eq!(cfg.args, vec!["scripts/reasonix-acp-wrapper.mjs"]);
}
#[test]
fn test_runtime_config_custom() {
let cfg = AcpRuntimeConfig {
name: "custom".into(),
bin: "/usr/local/bin/my-agent".into(),
args: vec!["--acp".into(), "--debug".into()],
env: None,
title: Some("My Agent".into()),
};
let json = serde_json::to_value(&cfg).unwrap();
assert_eq!(json["name"], "custom");
assert_eq!(json["bin"], "/usr/local/bin/my-agent");
assert_eq!(json["title"], "My Agent");
}
#[test]
fn test_runtime_manager_from_env_defaults() {
// Without env overrides, should contain hermes and reasonix
let mgr = AcpRuntimeManager::from_env();
let runtimes = mgr.available_runtimes();
assert!(runtimes.contains(&"hermes".into()));
assert!(runtimes.contains(&"reasonix".into()));
}
#[tokio::test]
async fn test_switch_to_unknown_runtime() {
let mgr = AcpRuntimeManager::from_env();
let result = mgr.switch_to("nonexistent").await;
assert!(result.is_err());
let err_str = format!("{}", result.err().unwrap());
assert!(
err_str.contains("unknown runtime"),
"should return error for unknown runtime, got: {err_str}"
);
}
#[tokio::test]
async fn test_health_check_no_active() {
let mgr = AcpRuntimeManager::from_env();
assert!(!mgr.health_check().await, "no active runtime = unhealthy");
}
#[tokio::test]
async fn test_switch_to_hermes_requires_binary() {
let mgr = AcpRuntimeManager::from_env();
// This might fail if `hermes` binary is not in PATH — that's OK for this test
let result = mgr.switch_to("hermes").await;
// We just verify it doesn't panic; either succeeds or returns Spawn error
if let Err(e) = &result {
assert!(
matches!(e, AcpError::Spawn(_)),
"expected Spawn error if hermes not in PATH, got: {e}"
);
} else {
// Success — clean up
mgr.shutdown_active().await;
}
}
}
@@ -0,0 +1,537 @@
/// ACP Session Manager — session lifecycle management.
///
/// Wraps an [`AcpClient`] to provide strongly-typed session operations:
/// create, prompt, cancel, and receive typed events from the agent.
///
/// Reference:
/// - `reference-code/hermes-vscode-main/src/sessionManager.ts` (primary)
/// - `reference-code/hermes-vscode-main/src/protocol.ts` (dedup logic)
use crate::acp_client::AcpClient;
use crate::acp_types::{
ContentBlock, SessionNewParams, SessionNewResult, SessionPromptParams,
SessionPromptResult, SessionUpdate, ToolCallStatus,
};
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
use tracing::{debug, info, warn};
#[cfg(test)]
use tokio::time::{sleep, Duration};
// ── Events ───────────────────────────────────────────
/// Strongly-typed event emitted by the session manager when a `session/update` arrives.
#[derive(Debug, Clone)]
pub enum AcpSessionEvent {
/// Streaming text from the agent's response message.
TextDelta { text: String },
/// Streaming reasoning/thinking text.
ThoughtDelta { text: String },
/// Tool call started.
ToolCall {
tool_call_id: String,
title: String,
kind: String,
status: ToolCallStatus,
},
/// Tool call status update (with optional result content).
ToolCallUpdate {
tool_call_id: String,
status: ToolCallStatus,
},
/// Context usage update.
UsageUpdate { used: u64, size: u64 },
/// Session metadata update (e.g. auto-title).
SessionInfoUpdate { title: String },
/// Plan entries update.
PlanUpdate { entries: Vec<String> },
/// Connection closed/error.
Disconnected { reason: String },
}
/// Handler for session events.
pub type SessionEventHandler = Arc<dyn Fn(AcpSessionEvent) + Send + Sync + 'static>;
// ── Session state ────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
pub enum SessionState {
Idle,
Running,
Cancelling,
Closed,
}
// ── AcpSessionManager ────────────────────────────────
/// Manages ACP sessions — create, prompt, cancel, and event dispatch.
///
/// Currently supports one active session at a time. The internal [`AcpClient`]
/// handles the JSON-RPC wire protocol; this layer adds session semantics and
/// typed event dispatching.
pub struct AcpSessionManager {
client: Arc<AcpClient>,
session_id: Arc<Mutex<Option<String>>>,
state: Arc<Mutex<SessionState>>,
event_handler: Arc<Mutex<Option<SessionEventHandler>>>,
/// Accumulated text for deduplication (per-turn).
accumulated: Arc<Mutex<String>>,
/// Whether we're currently inside a prompt (for dedup gating).
in_prompt: Arc<Mutex<bool>>,
}
impl AcpSessionManager {
/// Create a new session manager wrapping an existing [`AcpClient`].
///
/// Registers an internal notification handler that dispatches
/// `session/update` notifications as typed [`AcpSessionEvent`]s.
pub fn new(client: Arc<AcpClient>) -> Self {
let session_id: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let state: Arc<Mutex<SessionState>> = Arc::new(Mutex::new(SessionState::Idle));
let event_handler: Arc<Mutex<Option<SessionEventHandler>>> =
Arc::new(Mutex::new(None));
let accumulated: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let in_prompt: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
// Wire up the ACP notification handler
let session_id_clone = session_id.clone();
let event_handler_clone = event_handler.clone();
let accumulated_clone = accumulated.clone();
let in_prompt_clone = in_prompt.clone();
client.on_notification(move |method, params| {
if method != "session/update" {
return;
}
let sid = session_id_clone.lock().unwrap().clone();
if let Some(ref session_id) = sid {
if let Some(msg_sid) = params.get("sessionId").and_then(|v| v.as_str()) {
if msg_sid != session_id {
return; // not our session
}
}
}
// Parse the update
let update: SessionUpdate = match serde_json::from_value(
params.get("update").cloned().unwrap_or(Value::Null),
) {
Ok(u) => u,
Err(e) => {
warn!("ACP session/update parse error: {e}");
return;
}
};
let is_in_prompt = *in_prompt_clone.lock().unwrap();
let event = Self::session_update_to_event(&update, &accumulated_clone, is_in_prompt);
if let Some(ev) = event {
let handler = event_handler_clone.lock().unwrap();
if let Some(ref h) = *handler {
h(ev);
}
}
});
Self {
client,
session_id,
state,
event_handler,
accumulated,
in_prompt,
}
}
/// Register an event handler for session events.
/// Only one handler at a time — subsequent calls replace the previous.
pub fn on_event<F>(&self, handler: F)
where
F: Fn(AcpSessionEvent) + Send + Sync + 'static,
{
let mut guard = self.event_handler.lock().unwrap();
*guard = Some(Arc::new(handler));
}
/// Create a new ACP session.
///
/// Sends `session/new` to the agent and stores the returned `sessionId`.
/// The `page_context` is optional metadata about the current document.
pub async fn create_session(
&self,
cwd: Option<&str>,
_page_context: Option<Value>,
) -> Result<String, crate::acp_client::AcpError> {
let params = SessionNewParams {
cwd: cwd.map(|s| s.to_string()),
mcp_servers: None,
};
let result: SessionNewResult = self.client.request("session/new", params).await?;
let mut sid_guard = self.session_id.lock().unwrap();
*sid_guard = Some(result.session_id.clone());
info!("ACP session created: {}", result.session_id);
Ok(result.session_id)
}
/// Send a prompt to the agent and stream events.
///
/// The `prompt` is a list of content blocks (text + optional page context).
/// Returns once the agent finishes (stopReason received) or on error.
///
/// Sets `in_prompt = true` during the call to enable deduplication,
/// then resets to `false` and clears accumulated text on completion.
pub async fn run_prompt(
&self,
prompt: Vec<ContentBlock>,
) -> Result<SessionPromptResult, crate::acp_client::AcpError> {
{
let mut in_prompt = self.in_prompt.lock().unwrap();
*in_prompt = true;
}
{
let mut acc = self.accumulated.lock().unwrap();
acc.clear();
}
{
let mut state = self.state.lock().unwrap();
*state = SessionState::Running;
}
let sid = self.session_id.lock().unwrap().clone();
let session_id = sid.ok_or_else(|| {
crate::acp_client::AcpError::Internal(
"no session created yet — call create_session first".into(),
)
})?;
let params = SessionPromptParams {
session_id: session_id.clone(),
prompt,
};
debug!("ACP session/prompt (session={})", session_id);
let result: SessionPromptResult = self.client.request("session/prompt", params).await?;
debug!(
"ACP session/prompt done (session={}, stop_reason={:?})",
session_id, result.stop_reason
);
{
let mut state = self.state.lock().unwrap();
*state = SessionState::Idle;
}
{
let mut in_prompt = self.in_prompt.lock().unwrap();
*in_prompt = false;
}
{
let mut acc = self.accumulated.lock().unwrap();
acc.clear();
}
Ok(result)
}
/// Cancel the current prompt.
///
/// Sends `session/cancel` notification to the agent.
pub async fn cancel(&self) -> Result<(), crate::acp_client::AcpError> {
let sid = self.session_id.lock().unwrap().clone();
let session_id = sid.ok_or_else(|| {
crate::acp_client::AcpError::Internal("no active session".into())
})?;
{
let mut state = self.state.lock().unwrap();
*state = SessionState::Cancelling;
}
self.client
.notification(
"session/cancel",
json!({ "sessionId": session_id }),
)
.await?;
info!("ACP session cancelled: {}", session_id);
Ok(())
}
/// Close the session and the underlying ACP client.
pub async fn close(&self) {
{
let mut state = self.state.lock().unwrap();
*state = SessionState::Closed;
}
if let Some(handler) = self.event_handler.lock().unwrap().take() {
handler(AcpSessionEvent::Disconnected {
reason: "session closed".into(),
});
}
}
/// Get the current session ID, if any.
pub async fn session_id(&self) -> Option<String> {
self.session_id.lock().unwrap().clone()
}
/// Get the current session state.
pub async fn state(&self) -> SessionState {
self.state.lock().unwrap().clone()
}
// ── Internal: session/update → event mapping ─────
/// Convert a parsed [`SessionUpdate`] into an [`AcpSessionEvent`],
/// applying text deduplication for streaming text.
///
/// Reference: `hermes-vscode-main/src/protocol.ts` `extractTextContent()`,
/// `deduplicateChunk()`, `parseToolCall()`, `parseToolCallUpdate()`,
/// `parseUsageUpdate()`, `parseSessionInfoUpdate()`
fn session_update_to_event(
update: &SessionUpdate,
accumulated: &Arc<Mutex<String>>,
is_in_prompt: bool,
) -> Option<AcpSessionEvent> {
match update {
SessionUpdate::AgentMessageChunk { content, .. }
| SessionUpdate::AgentThoughtChunk { content, .. } => {
let discrim = match update {
SessionUpdate::AgentMessageChunk { .. } => "msg",
SessionUpdate::AgentThoughtChunk { .. } => "thought",
_ => unreachable!(),
};
// Deduplication (reference: protocol.ts deduplicateChunk)
if is_in_prompt {
let mut acc = accumulated.lock().unwrap();
let text = &content.text;
let event = if text == acc.as_str() {
// Exact full resend → drop
None
} else if text.len() > 10 && text.starts_with(acc.as_str()) {
// Superset resend → emit only the tail
let new_part = text[acc.len()..].to_string();
if new_part.is_empty() {
None
} else {
*acc = text.clone();
Some(new_part)
}
} else if text.len() > 10 && acc.ends_with(text) {
// Partial resend → drop
None
} else {
// Normal delta
let new_acc = format!("{}{}", acc, text);
*acc = new_acc;
Some(text.clone())
};
return event.map(|t| match discrim {
"msg" => AcpSessionEvent::TextDelta { text: t },
"thought" => AcpSessionEvent::ThoughtDelta { text: t },
_ => unreachable!(),
});
}
// Not in prompt — emit directly (historical playback)
let text = content.text.clone();
Some(match discrim {
"msg" => AcpSessionEvent::TextDelta { text },
"thought" => AcpSessionEvent::ThoughtDelta { text },
_ => unreachable!(),
})
}
SessionUpdate::ToolCall {
tool_call_id,
title,
kind,
status,
..
} => {
let title = title.clone().unwrap_or_else(|| "tool".into());
let kind_str = match kind {
Some(k) => format!("{:?}", k).to_lowercase(),
None => "other".into(),
};
let status = status.clone().unwrap_or(ToolCallStatus::Pending);
Some(AcpSessionEvent::ToolCall {
tool_call_id: tool_call_id.clone(),
title,
kind: kind_str,
status,
})
}
SessionUpdate::ToolCallUpdate {
tool_call_id,
status,
..
} => {
let status = status.clone().unwrap_or(ToolCallStatus::Completed);
Some(AcpSessionEvent::ToolCallUpdate {
tool_call_id: tool_call_id.clone(),
status,
})
}
SessionUpdate::UsageUpdate { used, size, .. } => {
Some(AcpSessionEvent::UsageUpdate {
used: *used,
size: *size,
})
}
SessionUpdate::SessionInfoUpdate { title, .. } => {
Some(AcpSessionEvent::SessionInfoUpdate {
title: title.clone(),
})
}
SessionUpdate::Plan { entries, .. } => {
let summaries: Vec<String> =
entries.iter().map(|e| e.content.clone()).collect();
Some(AcpSessionEvent::PlanUpdate { entries: summaries })
}
SessionUpdate::Unknown { .. } => {
warn!("ACP unknown session/update variant");
None
}
}
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::acp_client::AcpClient;
/// Creates a minimal ACP mock server for testing session operations.
async fn spawn_mock_acp() -> Arc<AcpClient> {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (!msg.id) return;
if (msg.method === 'session/new') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { sessionId: 'test_session_1' }
}) + '\n');
} else if (msg.method === 'session/prompt') {
const sessionId = msg.params?.sessionId || 'test';
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Hello from mock ACP' }
}
}
}) + '\n');
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { stopReason: 'end_turn' }
}) + '\n');
} else if (msg.method === 'session/cancel') {
// No response for notification
} else if (msg.method === 'initialize') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
}) + '\n');
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_session_test_mock.mjs");
std::fs::write(&script_path, script).expect("write mock");
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn");
Arc::new(client)
}
#[tokio::test]
async fn test_create_session() {
let client = spawn_mock_acp().await;
let mgr = AcpSessionManager::new(client);
let sid = mgr
.create_session(Some("/test"), None)
.await
.expect("create_session");
assert_eq!(sid, "test_session_1");
assert_eq!(mgr.session_id().await, Some("test_session_1".into()));
}
#[tokio::test]
async fn test_run_prompt() {
let client = spawn_mock_acp().await;
let mgr = AcpSessionManager::new(client);
mgr.create_session(Some("/test"), None)
.await
.expect("create_session");
let prompt = vec![ContentBlock::Text {
text: "Hello agent".into(),
}];
let result = mgr.run_prompt(prompt).await.expect("run_prompt");
assert_eq!(
format!("{:?}", result.stop_reason),
"EndTurn".to_string()
);
// After prompt, state should be idle again
assert_eq!(mgr.state().await, SessionState::Idle);
}
#[tokio::test]
async fn test_cancel() {
let client = spawn_mock_acp().await;
let mgr = AcpSessionManager::new(client);
mgr.create_session(Some("/test"), None)
.await
.expect("create_session");
mgr.cancel().await.expect("cancel");
}
#[tokio::test]
async fn test_event_handler() {
use std::sync::atomic::{AtomicBool, Ordering};
let client = spawn_mock_acp().await;
let mgr = AcpSessionManager::new(client);
let received = Arc::new(AtomicBool::new(false));
let r = received.clone();
mgr.on_event(move |ev| {
if matches!(ev, AcpSessionEvent::TextDelta { .. }) {
r.store(true, Ordering::SeqCst);
}
});
mgr.create_session(Some("/test"), None)
.await
.expect("create_session");
let prompt = vec![ContentBlock::Text {
text: "Hello".into(),
}];
mgr.run_prompt(prompt).await.expect("run_prompt");
// Give the notification handler time to process
sleep(Duration::from_millis(200)).await;
assert!(received.load(Ordering::SeqCst), "should have received TextDelta");
}
}
+556
View File
@@ -0,0 +1,556 @@
/// ACP (Agent Client Protocol) type definitions.
///
/// Strongly-typed Rust representations of the ACP JSON-RPC 2.0 messages.
/// Both Hermes (`hermes acp`) and Reasonix share this protocol shape.
///
/// Reference:
/// - `reference-code/DeepSeek-Reasonix-main/src/acp/protocol.ts`
/// - `reference-code/hermes-vscode-main/src/protocol.ts`
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ── JSON-RPC 2.0 basics ──────────────────────────────
pub type JsonRpcId = serde_json::Value; // number or string
// ── Initialize ───────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeParams {
pub protocol_version: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_capabilities: Option<ClientCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_info: Option<ClientInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub fs: Option<FsCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terminal: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FsCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub read_text_file: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub write_text_file: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InitializeResult {
pub protocol_version: u64,
pub agent_capabilities: AgentCapabilities,
pub agent_info: AgentInfo,
pub auth_methods: Vec<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub load_session: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_capabilities: Option<PromptCapabilities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_capabilities: Option<McpCapabilities>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedded_context: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub http: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sse: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub version: String,
}
// ── Session ──────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<Vec<McpServerSpec>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpServerSpec {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<std::collections::HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewResult {
pub session_id: String,
}
// ── Content blocks ───────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "resource")]
Resource {
resource: ResourceContent,
},
#[serde(rename = "image")]
Image {
mime_type: String,
data: String,
},
#[serde(rename = "audio")]
Audio {
mime_type: String,
data: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourceContent {
pub uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
// ── Session prompt ───────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptParams {
pub session_id: String,
pub prompt: Vec<ContentBlock>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptResult {
pub stop_reason: StopReason,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
EndTurn,
ToolUseComplete,
Cancelled,
Error,
}
// ── Session cancel (notification, no result) ─────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionCancelParams {
pub session_id: String,
}
// ── Session update (notification from agent to client) ──
/// The `session/update` notification payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionUpdateParams {
pub session_id: String,
pub update: SessionUpdate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SessionUpdate {
AgentMessageChunk {
#[serde(rename = "sessionUpdate")]
session_update: String, // "agent_message_chunk"
content: TextContent,
},
AgentThoughtChunk {
#[serde(rename = "sessionUpdate")]
session_update: String, // "agent_thought_chunk"
content: TextContent,
},
ToolCall {
#[serde(rename = "sessionUpdate")]
session_update: String, // "tool_call"
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<ToolCallKind>,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
raw_input: Option<Value>,
},
ToolCallUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "tool_call_update"
#[serde(rename = "toolCallId")]
tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<Vec<ContentBlockWrapper>>,
},
Plan {
#[serde(rename = "sessionUpdate")]
session_update: String, // "plan"
entries: Vec<PlanEntry>,
},
UsageUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "usage_update"
used: u64,
size: u64,
},
SessionInfoUpdate {
#[serde(rename = "sessionUpdate")]
session_update: String, // "session_info_update"
title: String,
},
/// Catch-all for any future/unknown session update variants.
Unknown {
#[serde(rename = "sessionUpdate")]
session_update: String,
#[serde(flatten)]
extra: std::collections::HashMap<String, Value>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextContent {
#[serde(rename = "type")]
pub content_type: String, // "text"
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentBlockWrapper {
#[serde(rename = "type")]
pub wrapper_type: String, // "content"
pub content: TextContent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallKind {
Read,
Edit,
Search,
Execute,
Other,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallStatus {
Pending,
InProgress,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanEntry {
pub content: String,
pub priority: PlanPriority,
pub status: PlanEntryStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanPriority {
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanEntryStatus {
Pending,
InProgress,
Completed,
}
// ── Permission request (from agent to client) ────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequestParams {
pub session_id: String,
pub tool_call: PermissionToolCall,
pub options: Vec<PermissionOption>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionToolCall {
#[serde(rename = "toolCallId")]
pub tool_call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<ToolCallKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_input: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionOption {
pub option_id: String,
pub name: String,
pub kind: PermissionOptionKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PermissionOptionKind {
AllowOnce,
AllowAlways,
RejectOnce,
RejectAlways,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRequestResult {
pub outcome: PermissionOutcome,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PermissionOutcome {
Selected { outcome: String, option_id: String },
Cancelled { outcome: String },
}
// ── Error codes (JSON-RPC standard) ──────────────────
pub const ERR_PARSE: i64 = -32700;
pub const ERR_INVALID_REQUEST: i64 = -32600;
pub const ERR_METHOD_NOT_FOUND: i64 = -32601;
pub const ERR_INVALID_PARAMS: i64 = -32602;
pub const ERR_INTERNAL: i64 = -32603;
// ── Session update kind discriminants ────────────────
/// Constants for the `sessionUpdate` string field.
impl SessionUpdate {
pub const AGENT_MESSAGE_CHUNK: &'static str = "agent_message_chunk";
pub const AGENT_THOUGHT_CHUNK: &'static str = "agent_thought_chunk";
pub const TOOL_CALL: &'static str = "tool_call";
pub const TOOL_CALL_UPDATE: &'static str = "tool_call_update";
pub const PLAN: &'static str = "plan";
pub const USAGE_UPDATE: &'static str = "usage_update";
pub const SESSION_INFO_UPDATE: &'static str = "session_info_update";
}
/// Parse the `sessionUpdate` string field from a raw JSON value and return the discriminant.
pub fn session_update_kind<'a>(value: &'a serde_json::Value) -> Option<&'a str> {
value
.get("update")
.and_then(|u| u.get("sessionUpdate"))
.and_then(|v| v.as_str())
}
// ── Helper: extract text from agent_message_chunk / agent_thought_chunk ──
/// Extract the text content from a session update's content block.
/// Returns `None` for non-text updates or malformed content.
///
/// Reference: `hermes-vscode-main/src/protocol.ts` `extractTextContent()`
pub fn extract_text_from_update(update: &SessionUpdate) -> Option<&str> {
match update {
SessionUpdate::AgentMessageChunk { content, .. }
| SessionUpdate::AgentThoughtChunk { content, .. } => Some(&content.text),
_ => None,
}
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initialize_params_roundtrip() {
let params = InitializeParams {
protocol_version: 1,
client_capabilities: None,
client_info: Some(ClientInfo {
name: "mnote-web".into(),
title: Some("MNote".into()),
version: Some("0.1.0".into()),
}),
};
let json = serde_json::to_value(&params).unwrap();
assert_eq!(json["protocolVersion"], 1);
assert_eq!(json["clientInfo"]["name"], "mnote-web");
let deserialized: InitializeParams = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.protocol_version, 1);
}
#[test]
fn test_session_new_params() {
let params = SessionNewParams {
cwd: Some("/mnt/Data1T/mnote".into()),
mcp_servers: None,
};
let json = serde_json::to_value(&params).unwrap();
assert_eq!(json["cwd"], "/mnt/Data1T/mnote");
}
#[test]
fn test_session_update_agent_message_chunk() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": "Hello" }
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
assert_eq!(parsed.session_id, "test_1");
match &parsed.update {
SessionUpdate::AgentMessageChunk { content, .. } => {
assert_eq!(content.text, "Hello");
}
_ => panic!("expected AgentMessageChunk"),
}
}
#[test]
fn test_session_update_tool_call() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "tc_1",
"title": "mnote.doc.fetch",
"kind": "read",
"status": "pending"
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::ToolCall { title, kind, .. } => {
assert_eq!(title.as_deref(), Some("mnote.doc.fetch"));
assert!(matches!(kind, Some(ToolCallKind::Read)));
}
_ => panic!("expected ToolCall"),
}
}
#[test]
fn test_session_update_usage() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "usage_update",
"used": 1500,
"size": 4000
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::UsageUpdate { used, size, .. } => {
assert_eq!(*used, 1500);
assert_eq!(*size, 4000);
}
_ => panic!("expected UsageUpdate"),
}
}
#[test]
fn test_content_block_text() {
let block = ContentBlock::Text { text: "hello".into() };
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "hello");
}
#[test]
fn test_flatten_prompt() {
let blocks = vec![
ContentBlock::Text { text: "Hello".into() },
ContentBlock::Resource {
resource: ResourceContent {
uri: "file:///test.md".into(),
mime_type: None,
text: Some(" world".into()),
},
},
];
// flattenPrompt equivalent: concatenate text blocks + resource text
let text: Vec<String> = blocks
.iter()
.map(|b| match b {
ContentBlock::Text { text } => text.clone(),
ContentBlock::Resource { resource } => resource.text.clone().unwrap_or_default(),
_ => String::new(),
})
.collect();
assert_eq!(text.join(""), "Hello world");
}
}
+5
View File
@@ -1,5 +1,10 @@
#![recursion_limit = "1024"]
pub mod acp_bridge;
pub mod acp_client;
pub mod acp_runtime;
pub mod acp_session_manager;
pub mod acp_types;
pub mod app;
pub mod context;
pub mod editor_actor;
@@ -1,9 +1,11 @@
use crate::acp_types::ContentBlock;
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::manifest;
use crate::transport::convex::execute_convex_query_by_name;
use axum::body::Body;
use tokio::sync::broadcast;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::Response;
@@ -15,10 +17,10 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::fs;
use std::path::{Path as FsPath, PathBuf};
use std::process::Command;
use std::sync::{LazyLock, Mutex};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
use tracing::{info, warn};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
@@ -27,6 +29,9 @@ static HERMES_RUNTIME_REGISTRY: LazyLock<Mutex<HashMap<String, HermesRuntimeStat
LazyLock::new(|| Mutex::new(HashMap::new()));
static HERMES_RUN_QUEUE: LazyLock<Mutex<HashMap<String, VecDeque<HermesQueuedRun>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Store ACP run payloads keyed by run_id, so stream_events can read them.
static ACP_RUN_PAYLOADS: LazyLock<Mutex<HashMap<String, Value>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone)]
struct HermesRuntimeState {
@@ -522,6 +527,27 @@ pub async fn create_run(
let (actor_id, actor_type) = resolve_run_actor(&state, &context).await;
stamp_run_actor(&mut payload, &actor_id, &actor_type);
let registration = run_registration_from_payload(&context, &payload);
// ACP path: skip the HTTP proxy, just register and return run info
if is_acp_profile(&registration.profile) {
let run_id = registration.session_id.clone(); // session_id serves as run_id
let runtime_state = register_acp_runtime(&registration);
// Store payload for stream_events to use
ACP_RUN_PAYLOADS
.lock()
.expect("acp run payloads")
.insert(run_id.clone(), payload.clone());
let response = json!({
"ok": true,
"runId": run_id,
"sessionId": registration.session_id,
"profile": registration.profile,
"traceId": context.trace.trace_id,
"runtime": runtime_state,
});
return Ok((StatusCode::OK, stamp_client_headers(), Json(response)));
}
if session_has_active_run(&registration.session_id) {
let queued = enqueue_run(&context, &registration, &payload)?;
return Ok((StatusCode::ACCEPTED, stamp_client_headers(), Json(queued)));
@@ -567,12 +593,148 @@ pub async fn cancel_queued_run(
))
}
/// ACP variant of stream_events: creates an ACP session, runs the prompt,
/// and returns an SSE stream of events.
async fn acp_stream_events(
state: AppState,
context: RequestContext,
run_id: &str,
profile: &str,
) -> Result<Response, WebError> {
// Get the stored payload from create_run
let payload = ACP_RUN_PAYLOADS
.lock()
.expect("acp run payloads")
.remove(run_id)
.ok_or_else(|| {
WebError::bad_gateway_code(
"acp_run_payload_not_found",
format!("ACP run payload not found for run_id={run_id}"),
)
.with_context(&context)
})?;
// Build prompt from the message field (frontend sends "message", not "input")
let input = payload
.get("message")
.or_else(|| payload.get("input"))
.and_then(Value::as_str)
.unwrap_or("请读取当前文档内容");
let prompt_blocks = vec![ContentBlock::Text {
text: input.to_string(),
}];
let runtime_name = crate::acp_bridge::runtime_name_for_profile(profile);
// Ensure runtime is active; switch_to either activates it or returns existing
let client = state.acp_runtime.switch_to(runtime_name).await.map_err(|e| {
WebError::bad_gateway_code(
"acp_runtime_switch_failed",
format!("Failed to activate ACP runtime '{runtime_name}': {e}"),
)
.with_context(&context)
})?;
// Create session manager and start the prompt
let mgr = Arc::new(crate::acp_session_manager::AcpSessionManager::new(client));
let (event_tx, _event_rx) = broadcast::channel(256);
let event_tx_clone = event_tx.clone();
mgr.on_event(move |event| {
if let Some(sse) = crate::acp_bridge::acp_event_to_sse(event) {
let _ = event_tx_clone.send(sse);
}
});
mgr.create_session(None, None)
.await
.map_err(|e| {
WebError::bad_gateway_code(
"acp_session_create_failed",
format!("ACP session creation failed: {e}"),
)
.with_context(&context)
})?;
// Run prompt in background
let run_id_owned = run_id.to_string();
let mgr_clone = Arc::clone(&mgr);
let event_tx_prompt = event_tx.clone();
update_runtime_by_run_id(&run_id_owned, "running", Some("acp.prompt.started"), None);
tokio::spawn(async move {
match mgr_clone.run_prompt(prompt_blocks).await {
Ok(result) => {
info!("ACP prompt completed: stop_reason={:?}", result.stop_reason);
let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent {
event: "run.completed".into(),
data: json!({
"stopReason": format!("{:?}", result.stop_reason),
}),
});
}
Err(e) => {
warn!("ACP prompt failed: {e}");
let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent {
event: "run.failed".into(),
data: json!({ "error": e.to_string() }),
});
}
}
update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None);
});
// Build SSE response from event channel
use tokio::sync::mpsc;
let (tx, rx) = mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(256);
let mut event_rx = event_tx.subscribe();
tokio::spawn(async move {
loop {
match event_rx.recv().await {
Ok(event) => {
let json_str = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".into());
let bytes = axum::body::Bytes::from(
format!("event: {}\ndata: {}\n\n", event.event, json_str)
);
if tx.send(Ok(bytes)).await.is_err() { break; }
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!("ACP SSE lagged: {n} events dropped");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
let mut response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.map_err(|e| {
WebError::internal(format!("SSE response build failed: {e}"))
.with_context(&context)
})?;
stamp_client_headers_into(response.headers_mut());
Ok(response)
}
pub async fn stream_events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
) -> Result<Response, WebError> {
ensure_authenticated(&context)?;
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
// ACP path: start AcpRunBridge and return SSE stream
if is_acp_profile(&profile) {
return acp_stream_events(state, context, &run_id, &profile).await;
}
let Some(upstream) = configured_upstream_for_profile(&profile) else {
return Err(hermes_unconfigured_error(&context));
};
@@ -856,9 +1018,28 @@ fn list_profiles_payload() -> Value {
.map(|stdout| parse_profile_list(&stdout))
.filter(|profiles| !profiles.is_empty())
.unwrap_or_else(fallback_profiles);
let reasonix_model = std::env::var("REASONIX_MODEL").unwrap_or_else(|_| "deepseek-chat".into());
let reasonix_preset = std::env::var("REASONIX_PRESET").unwrap_or_else(|_| "auto".into());
let reasonix_has_key = std::env::var("DEEPSEEK_API_KEY").is_ok();
json!({
"ok": true,
"profiles": profiles
"profiles": profiles,
"acpRuntimes": json!([
{
"name": "hermes",
"title": "ACP · Hermes",
"description": "通过 ACP 协议直连 Hermes agent runtime · model/default 来自 Hermes profile"
},
{
"name": "reasonix",
"title": "ACP · Reasonix",
"description": "通过 ACP 协议直连 ReasonixDeepSeek 缓存优先)",
"model": reasonix_model,
"preset": reasonix_preset,
"apiKeyConfigured": reasonix_has_key,
"version": "0.43.0"
}
])
})
}
@@ -1472,6 +1653,41 @@ fn profile_env_key(prefix: &str, profile: &str, suffix: &str) -> String {
format!("{prefix}_{normalized}_{suffix}")
}
/// Returns true if the given profile should use ACP instead of Hermes HTTP proxy.
///
/// Profile names `reasonix` always use ACP. Other profiles can be configured
/// via `MNOTE_WEB_<PROFILE>_RUNTIME_TYPE=acp`.
/// Default profiles (`default`, `hermes`) use the traditional Hermes HTTP proxy.
fn is_acp_profile(profile: &str) -> bool {
// ACP runtimes: "reasonix" and "hermes" both use ACP protocol when selected from the UI.
// The "reasonix" name is hardcoded; "hermes" as ACP is triggered by env var or UI selection.
if profile == "reasonix" || profile == "hermes" {
return true;
}
let env_key = profile_env_key("MNOTE_WEB", profile, "RUNTIME_TYPE");
env_or_dotenv(&env_key)
.map(|v| v.trim().to_lowercase() == "acp")
.unwrap_or(false)
}
/// Returns the ACP runtime name for a profile.
/// For ACP profiles, returns the runtime backend name ("hermes" or "reasonix").
/// The profile name is used as the runtime name unless overridden by env var.
#[allow(dead_code)]
fn configured_runtime_for_profile(profile: &str) -> Option<String> {
if !is_acp_profile(profile) {
return None;
}
if profile == "reasonix" {
return Some("reasonix".into());
}
let env_key = profile_env_key("MNOTE_WEB", profile, "RUNTIME_NAME");
let name = env_or_dotenv(&env_key)
.filter(|v| !v.trim().is_empty())
.map(|v| v.trim().to_lowercase());
Some(name.unwrap_or_else(|| profile.to_lowercase()))
}
fn configured_upstream_for_profile(profile: &str) -> Option<String> {
let profile = profile.trim();
if !profile.is_empty() && profile != "default" {
@@ -2050,6 +2266,33 @@ fn run_registration_from_payload(
}
}
/// Register a runtime state from a registration (without upstream response).
/// Used by the ACP path where no Hermes HTTP upstream exists.
fn register_acp_runtime(registration: &HermesRunRegistration) -> Value {
let now = now_ms();
let run_id = registration.session_id.clone(); // use session_id as run_id for ACP
let state = HermesRuntimeState {
session_id: registration.session_id.clone(),
run_id,
profile: registration.profile.clone(),
document_id: registration.document_id.clone(),
trace_id: registration.trace_id.clone(),
status: "acp_pending".into(),
started_at: now,
last_event_at: now,
last_event: Some("run.started".into()),
last_tool_name: None,
last_tool_call_id: None,
last_audit_id: None,
};
let json = runtime_state_to_json(&state);
HERMES_RUNTIME_REGISTRY
.lock()
.expect("hermes runtime registry")
.insert(registration.session_id.clone(), state);
json
}
fn register_runtime_from_create_run_response(
registration: &HermesRunRegistration,
payload: &Value,
@@ -194,6 +194,7 @@ pub async fn mnote_call(
"mnote.block.delete" => block::block_delete(&state, &context, &input).await,
"mnote.block.move_after" => block::block_move_after(&state, &context, &input).await,
"mnote.doc.apply_block_ops" => block::doc_apply_block_ops(&state, &context, &input).await,
"mnote.doc.markdown_edit" => doc::doc_markdown_edit(&state, &context, &input).await,
"mnote.page.get" => page::page_get(&state, &context, &input).await,
"mnote.page.save" => page::page_save(&state, &context, &input).await,
"mnote.page.update_title" => page::update_title(&state, &context, &input).await,
+26 -1
View File
@@ -4846,6 +4846,30 @@ const SIDEBAR_TREE_JS: &str = r##"
if (profileLabel instanceof HTMLElement) {
profileLabel.style.display = isAcp ? 'none' : '';
}
// When ACP is selected, populate agent panel with ACP runtime info
var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]');
if (agentPanel instanceof HTMLElement) {
if (isAcp) {
var rt = pageUiState.pageAiAcpRuntimes.find(function(r) { return r.name === pageUiState.pageAiAcpRuntime; }) || {};
var keyStatus = rt.apiKeyConfigured ? '' : ' DEEPSEEK_API_KEY';
agentPanel.innerHTML = '' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">' + escapeHtml(rt.title || 'ACP Runtime') + '</div></div>' +
'</section>' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title"></div><div class="wolai-page-ai-memory-scope">' + escapeHtml(rt.model || 'deepseek-chat') + '</div></div>' +
'</section>' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">Preset</div><div class="wolai-page-ai-memory-scope">' + escapeHtml(rt.preset || 'auto') + '</div></div>' +
'</section>' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">API Key</div><div class="wolai-page-ai-memory-scope">' + escapeHtml(keyStatus) + '</div></div>' +
'</section>' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title"></div><div class="wolai-page-ai-memory-scope">' + escapeHtml(rt.description || '') + '</div></div>' +
'</section>';
}
}
var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]');
if (profileSummary instanceof HTMLElement) profileSummary.textContent = activeProfile;
var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]');
@@ -4927,7 +4951,8 @@ const SIDEBAR_TREE_JS: &str = r##"
memoryError.hidden = !memoryErrorText;
}
var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]');
if (agentPanel instanceof HTMLElement) {
if (agentPanel instanceof HTMLElement && !isAcp) {
// Only populate Hermes memory when NOT in ACP mode (ACP handles this earlier in the function)
agentPanel.innerHTML = ['soul', 'user', 'memory'].map(function(section) {
var label = pageAiMemoryFileLabel(section);
var value = pageUiState.pageAiProfileMemoryDrafts[section] || '';
+1 -1
View File
@@ -1 +1 @@
{"rustc_fingerprint":9228011546279038255,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
{"rustc_fingerprint":10059341515723286937,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
+432
View File
@@ -0,0 +1,432 @@
#!/usr/bin/env node
/**
* Reasonix ACP wrapper for mnote-web.
*
* Spawned by mnote-web's AcpRuntimeManager as:
* node scripts/reasonix-acp-wrapper.mjs
*
* Implements the ACP (Agent Client Protocol) JSON-RPC 2.0 server over stdio.
* This is a self-contained NDJSON implementation no dependency on AcpServer
* (which is a Reasonix internal module not shipped in the npm package).
*
* Registers mnote tools as HTTP calls to mnote-web's tool endpoints.
*
* Reference: DeepSeek-Reasonix-main/src/cli/commands/acp.ts
*
* Environment:
* DEEPSEEK_API_KEY DeepSeek API key (required)
* MNOTE_WEB_URL mnote-web base URL (default: http://127.0.0.1:3000)
*/
import { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';
import { randomUUID } from 'node:crypto';
import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
// Load DeepSeek API key — same logic as Reasonix's loadApiKey():
// 1. DEEPSEEK_API_KEY env var
// 2. ~/.reasonix/config.yaml (yaml: api_key or apiKey)
function loadApiKey() {
if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
const configPath = join(homedir(), '.reasonix', 'config.yaml');
if (existsSync(configPath)) {
const raw = readFileSync(configPath, 'utf-8');
// Simple YAML key extraction (no yaml parser dependency needed)
const match = raw.match(/^\s*(?:api_key|apiKey)\s*:\s*['"]?(.+?)['"]?\s*$/m);
if (match) return match[1].trim();
}
return null;
}
const DEEPSEEK_API_KEY = loadApiKey();
if (!DEEPSEEK_API_KEY) {
process.stderr.write('FATAL: DEEPSEEK_API_KEY is required\n');
process.stderr.write('Set env var DEEPSEEK_API_KEY or put api_key in ~/.reasonix/config.yaml\n');
process.exit(1);
}
process.env.DEEPSEEK_API_KEY = DEEPSEEK_API_KEY;
// ── Dynamic import of reasonix public API ────────────
let CacheFirstLoop, DeepSeekClient, ToolRegistry, ImmutablePrefix;
try {
const r = await import('reasonix');
CacheFirstLoop = r.CacheFirstLoop;
DeepSeekClient = r.DeepSeekClient;
ToolRegistry = r.ToolRegistry;
ImmutablePrefix = r.ImmutablePrefix;
} catch (e) {
process.stderr.write(`FATAL: reasonix not found — run "npm install reasonix"\n`);
process.stderr.write(`Error: ${e}\n`);
process.exit(1);
}
// ── Simple NDJSON JSON-RPC 2.0 Server ───────────────
// Reference: Reasonix src/acp/server.ts (conceptual)
let nextId = 1;
const pendingReqs = new Map(); // id → { resolve, reject }
const requestHandlers = new Map(); // method → handler
const notificationHandlers = new Map(); // method → handler
function sendMessage(msg) {
const line = JSON.stringify(msg) + '\n';
stdout.write(line);
}
function sendResponse(id, result) {
sendMessage({ jsonrpc: '2.0', id, result });
}
function sendError(id, code, message) {
sendMessage({ jsonrpc: '2.0', id, error: { code, message } });
}
function sendNotification(method, params) {
sendMessage({ jsonrpc: '2.0', method, params });
}
function onRequest(method, handler) {
requestHandlers.set(method, handler);
}
function onNotification(method, handler) {
notificationHandlers.set(method, handler);
}
// Start reading NDJSON from stdin
const rl = createInterface({ input: stdin, terminal: false });
rl.on('line', async (raw) => {
const trimmed = raw.trim();
if (!trimmed) return;
let msg;
try {
msg = JSON.parse(trimmed);
} catch {
sendError(null, -32700, 'Parse error');
return;
}
const hasId = msg.id !== undefined && msg.id !== null;
const hasMethod = typeof msg.method === 'string' && msg.method.length > 0;
if (hasId && hasMethod) {
// Incoming request from client
const handler = requestHandlers.get(msg.method);
if (!handler) {
sendError(msg.id, -32601, `Method not found: ${msg.method}`);
return;
}
try {
const result = await handler(msg.params);
sendResponse(msg.id, result);
} catch (err) {
sendError(msg.id, err.code || -32603, err.message || String(err));
}
} else if (hasId) {
// Response to our outgoing request
const pending = pendingReqs.get(msg.id);
if (pending) {
pendingReqs.delete(msg.id);
if (msg.error) {
pending.reject(new Error(msg.error.message));
} else {
pending.resolve(msg.result);
}
}
} else if (hasMethod) {
// Notification from client
const handler = notificationHandlers.get(msg.method);
if (handler) handler(msg.params);
}
});
// ── Helper: send ACP session/update (camelCase per protocol spec) ──
function emitSessionUpdate(sessionId, update) {
sendNotification('session/update', { sessionId, update });
}
function emitTextDelta(sessionId, text) {
emitSessionUpdate(sessionId, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
});
}
function emitThoughtDelta(sessionId, text) {
emitSessionUpdate(sessionId, {
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
});
}
function emitToolCall(sessionId, toolCallId, title, kind, status, rawInput) {
emitSessionUpdate(sessionId, {
sessionUpdate: 'tool_call',
toolCallId,
title,
kind,
status,
rawInput,
});
}
function emitToolResult(sessionId, toolCallId, status, text) {
emitSessionUpdate(sessionId, {
sessionUpdate: 'tool_call_update',
toolCallId,
status,
content: text ? [{ type: 'content', content: { type: 'text', text } }] : undefined,
});
}
function emitUsage(sessionId, used, size) {
emitSessionUpdate(sessionId, {
sessionUpdate: 'usage_update',
used,
size,
});
}
// ── MNOTE Tool Implementations ───────────────────────
// Calls mnote-web's Rust tool endpoints via HTTP.
// Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs
const MNOTE_TOOL_NAMES = [
'mnote.doc.fetch',
'mnote.doc.markdown_edit',
'mnote.block.*',
'mnote.page.*',
];
async function callMnoteTool(toolName, args) {
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName,
args,
workspaceId: args.workspaceId || 'default',
}),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`mnote tool ${toolName} failed: HTTP ${response.status} ${text}`);
}
return response.json();
}
// ── Register Tools ───────────────────────────────────
const tools = new ToolRegistry();
tools.register({
name: 'mnote.doc.fetch',
description: '读取当前文档的 markdown 内容。返回文档标题和正文。',
parameters: {
type: 'object',
properties: {
documentId: { type: 'string', description: '文档 ID(可选,默认当前文档)' },
format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' },
},
},
call: async (args) => callMnoteTool('mnote.doc.fetch', args),
parallelSafe: false,
});
tools.register({
name: 'mnote.doc.markdown_edit',
description: '对当前文档进行 markdown 文本级搜索替换编辑。支持多次搜索替换操作。',
parameters: {
type: 'object',
properties: {
documentId: { type: 'string', description: '文档 ID' },
operations: {
type: 'array',
items: {
type: 'object',
properties: {
search: { type: 'string', description: '要搜索的文本片段' },
replace: { type: 'string', description: '替换后的文本' },
},
required: ['search', 'replace'],
},
description: '搜索替换操作列表',
},
},
required: ['documentId', 'operations'],
},
call: async (args) => callMnoteTool('mnote.doc.markdown_edit', args),
parallelSafe: false,
});
// ── Session Store ────────────────────────────────────
const sessions = new Map();
// ── ACP: initialize ──────────────────────────────────
onRequest('initialize', (params) => {
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: false,
promptCapabilities: { image: false, audio: false, embeddedContext: false },
mcpCapabilities: { http: false, sse: false },
},
agentInfo: { name: 'reasonix-mnote', title: 'Reasonix MNote Agent', version: '0.1.0' },
authMethods: [],
};
});
// ── ACP: session/new ─────────────────────────────────
// Creates a CacheFirstLoop for the session.
onRequest('session/new', async (params) => {
const sessionId = randomUUID();
const client = new DeepSeekClient({ apiKey: DEEPSEEK_API_KEY });
const systemPrompt = [
'You are a helpful AI assistant for editing Markdown documents.',
'Use mnote.doc.fetch to read the current document.',
'Use mnote.doc.markdown_edit to apply precise search/replace edits.',
'Always use mnote.doc.fetch first to understand the document content before editing.',
].join('\n');
const loop = new CacheFirstLoop({
client,
tools,
prefix: new ImmutablePrefix({ system: systemPrompt, toolSpecs: tools.specs() }),
});
sessions.set(sessionId, { id: sessionId, loop, client, aborter: null });
return { sessionId };
});
// ── ACP: session/prompt ─────────────────────────────
// Runs loop.step() and emits ACP session/update events.
onRequest('session/prompt', async (params) => {
if (!params?.sessionId) {
throw Object.assign(new Error('Missing sessionId'), { code: -32602 });
}
const session = sessions.get(params.sessionId);
if (!session) {
throw Object.assign(new Error(`Unknown session: ${params.sessionId}`), { code: -32602 });
}
const blocks = params.prompt || [];
const text = blocks
.map((b) => (b.type === 'text' ? b.text : b.resource?.text || ''))
.filter(Boolean)
.join('\n\n')
.trim();
if (!text) {
throw Object.assign(new Error('Empty prompt'), { code: -32602 });
}
session.aborter = new AbortController();
let stopReason = 'end_turn';
let hasModelOutput = false;
let hasToolCall = false;
try {
for await (const ev of session.loop.step(text)) {
if (session.aborter?.signal.aborted) {
stopReason = 'cancelled';
break;
}
switch (ev.type) {
case 'model_delta': {
hasModelOutput = true;
if (ev.text) emitTextDelta(session.id, ev.text);
break;
}
case 'model_thinking': {
hasModelOutput = true;
if (ev.text) emitThoughtDelta(session.id, ev.text);
break;
}
case 'tool_call_delta': {
hasToolCall = true;
const call = ev.toolCall || {};
emitToolCall(
session.id,
call.id || `tc_${Date.now()}`,
call.name || ev.name || 'tool',
'other',
'pending',
call.args || ev.args,
);
break;
}
case 'tool_start': {
const call = ev.toolCall || {};
emitToolCall(
session.id,
call.id || `tc_${Date.now()}`,
call.name || ev.name || 'tool',
'other',
'in_progress',
call.args || ev.args,
);
break;
}
case 'tool_result': {
const resultText = typeof ev.result === 'string'
? ev.result.slice(0, 8000)
: JSON.stringify(ev.result).slice(0, 8000);
emitToolResult(
session.id,
ev.toolCallId || `tc_${Date.now()}`,
ev.error ? 'failed' : 'completed',
resultText,
);
break;
}
case 'usage': {
emitUsage(session.id, ev.inputTokens || 0, ev.cacheHitTokens || 0);
break;
}
}
}
} catch (err) {
const message = err.message || String(err);
emitTextDelta(session.id, `\n\n[error] ${message}`);
stopReason = 'error';
} finally {
// If no model output or tool calls were produced, it means the LLM backend failed.
// This can happen when the API key is invalid, base URL is wrong, or model is unavailable.
if (!hasModelOutput && !hasToolCall && stopReason !== 'error') {
stopReason = 'error';
emitTextDelta(session.id, '\n\n[error] AI 模型未返回输出。可能原因:API Key 无效、模型不可用、或网络连接失败。请检查 DEEPSEEK_API_KEY 是否正确设置(以 sk- 开头)。');
}
session.aborter = null;
}
return { stopReason };
});
// ── ACP: session/cancel (notification) ──────────────
onNotification('session/cancel', (params) => {
const session = params?.sessionId ? sessions.get(params.sessionId) : undefined;
if (session?.aborter) {
session.aborter.abort();
}
});
// ── Start ────────────────────────────────────────────
process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`);