diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md new file mode 100644 index 0000000..870f28e --- /dev/null +++ b/docs/dsh-integration.md @@ -0,0 +1,252 @@ +# dsh 插件集成设计:meX 记忆面板与"Add to MeX"抽取链路 + +> 适用对象:mex-dsh-plugin(DeepSeek Harness 插件)。 +> 本文档记录 dsh 侧集成**实现细节**,与 `architecture.md` §8 的 agent 集成章节互补: +> §8 描述 claude/opencode 的 skill 机制(即时写 + 会话结束 hook 留待扩展); +> 本文描述 dsh 特有形态——浏览器 UI 面板 + 用户主动触发的"每轮对话后抽取"。 +> 接口以代码 docstring 为准;本文是设计说明,不替代代码。 + +## 1. 定位与触发时机 + +| 维度 | 说明 | +|---|---| +| 触发方式 | **用户主动点击**面板按钮(Add to MeX),非自动实时抽取 | +| 触发粒度 | 一轮(dsh 的 Turn)对话结束后可触发一次 | +| 与 ADR-12 的关系 | 不冲突。ADR-12 §8.1 的"每轮对话后自动抽取 ❌ 不做"针对的是**自动实时抽取**(LLM 延迟打断对话节奏、成本无谓放大);本面板由**用户显式点击**触发,成本与节奏由用户掌控,属于 §8.1 表格之外的用户主动形态 | +| 依赖 | dsh 默认装配的 spawn 子代理(`dsh-subagent-spawn-in-process`),无需额外安装 | + +## 2. 整体架构 + +``` +┌─────────────┐ ① POST /mex/extract ┌──────────────────────────┐ +│ 浏览器面板 │ ──────────────────────► │ dsh Host(panel.js) │ +│ "Add to MeX"│ │ ② 取缓存的本轮对话文本 │ +└─────────────┘ │ ③ 组装抽取 prompt │ + │ ⑥ openSubagent │ ④ 触发一个全新子代理(保留) │ + ▼ (打开子代理会话) └────────────┬─────────────┘ +┌──────────────────────┐ │ ⑤ 子代理开始思考 +│ 子代理会话视图(透明) │ ◄───────────────────────────┘ +│ 用户实时看到: │ +│ · 思考过程(流式) │ 子代理调用 mex_search / mex_add … +│ · 工具调用卡片 │ +│ · 最终"已写入N条" │ +└──────────────────────┘ +``` + +**透明性设计**:抽取子代理**保留**(不 dispose),作为当前会话的 one-shot +子代理进入 dsh 侧边栏子代理目录;点击"Add to MeX"后 client 立即调用 +`sessions.openSubagent(address)` 打开该子代理的会话视图——用户**实时看到 +抽取 agent 调用工具与思考的完整过程**,不再是隐藏的黑盒。抽取完成后该 +子代理会话保留在目录中,可随时回看(§4.8)。 + +## 3. 组件与文件职责 + +| 文件 | half | 职责 | +|---|---|---| +| `client.js` | Client(浏览器) | 注册 `shell.overlay` 浮动层面板(右下角);轮询 `/mex/panel-state`;"Add to MeX"按钮 → POST `/mex/extract`;展示抽取结果 | +| `panel.js` | Host(Node) | 监听 `agent/turn-stopping` 缓存对话;注册 `/mex/panel-state` 与 `/mex/extract` 两个 HTTP 接口;组装抽取 prompt;触发 spawn 子代理 | +| `index.js` | Host(Node) | 注册 13 个 `mex_*` 工具(薄壳:subprocess 执行 `mex` CLI);接入 `applyMexPanel(ctx)` | +| `package.json` | — | 声明 `dsh.client` 入口(Client half 打包产物 + 依赖注入) | + +## 4. 触发链路逐步说明 + +### 4.0 主题适配(深色 / 浅色 / 跟随系统)与面板拖拽 + +**主题适配**:面板通过 `document.body.hasAttribute('data-ds-dark-theme')` 判断 +当前实际主题(dsh 主题系统已按偏好——深色 / 浅色 / 跟随系统——归一化该属性), +并用 `MutationObserver` 监听属性变化(主题切换时即时重渲染)。 + +- **深色主题**:全部使用 dsh 主题 token(CSS 变量),与官方 UI 一致: + + | 用途 | token | + |---|---| + | 卡片背景 | `var(--dsw-specific-menu)`(= `bg-layer-3` = `neutral-bluish-800`,`rgb(53,54,56)`,比 `bg-overlay` 更深,与官方弹层/菜单一致) | + | 主文字 | `var(--dsw-alias-label-primary)` | + | 次要/禁用文字 | `var(--dsw-alias-label-secondary)` | + | 边框 | `var(--dsw-alias-border-l1)` | + | 运行中徽标/提示(黄) | `var(--dsw-alias-state-warn-primary)` | + | 可抽取徽标/提示/按钮(绿) | `var(--dsw-alias-state-success-primary)` | + | 禁用按钮背景 | `var(--dsw-alias-bg-layer-1)` | + | 阴影 | `var(--dsw-shadow-lv3, fallback)` | + +- **浅色主题**(微调,保证可读性):背景用**非常浅的灰** `#f5f6f8`(比 token + 的 `bg-overlay` 更浅);"可加入记忆"的绿色提示加深为 `#15803d`(token 的 + `state-success-primary` 在浅底上偏亮、对比不足);主文字 `#1f2328`、次要文字 + `#6b7280`、边框 `#e3e6ea`、禁用背景 `#e8eaed`。 + +**面板拖拽**:标题栏(含"⣿"手柄图标)`cursor: move`,按下后跟随鼠标移动 +(`mousemove`/`mouseup` 监听),面板位置改为 `left/top` 固定定位;释放时把 +位置写入 `localStorage['mex-panel-pos']`,下次打开自动恢复。未拖拽时默认 +停靠右下角(`right: 16px; bottom: 16px`)。 + +### 4.1 点击按钮 → 浏览器发请求(client.js) + +`onAddToMex` 只把**当前会话 id** 发给 Host,对话内容本身不随请求传输: + +```js +fetch('/mex/extract', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId: sessionId }), +}) +``` + +### 4.2 Host 取"本轮对话文本"(panel.js) + +Host 维护内存缓存 `pendingTurns`(`sessionId → { turn, dialogue }`),来源是监听 dsh 事件: + +```js +ctx.on('agent/turn-stopping', ({ agent, turn }) => { + const dialogue = extractTurnDialogue(agent.session.log, turn) + if (dialogue.trim()) pendingTurns.set(agent.id, { turn, dialogue }) +}) +``` + +- **`agent/turn-stopping`**:dsh 在"每一轮对话即将结束"时发出的信号(Turn 概念:一次"提问 → agent 完整回答")。回答完、要收尾时触发。 +- `extractTurnDialogue` 遍历会话日志,把 `user/message` 事件写为 `用户:…`,把 `assistant/message` 事件写为 `AI:…`,按 turn 号筛选目标轮次。 +- 读 `agent.session.log`(实时追加数组)而非 `session.events`(惰性冻结快照,可能在 turn 进行中被提前固化,拿不到最新事件)。 + +点击按钮时对话文本**已在** Host 内存,请求处理函数直接取 `pending.dialogue`。 + +### 4.3 防重复机制 + +`extractedTurns`(`sessionId → turn`)记录每个会话"已抽取到第几轮": + +- 点击时比较 `lastTurn > extracted` 才允许抽取,否则返回"没有新的已结束对话可抽取"。 +- 同一轮不会重复抽取、不会重复写入。 + +### 4.4 组装抽取 prompt(panel.js 的 EXTRACT_PROMPT) + +参考 `src/mex/llm/prompts.py` 的抽取规范,精简为给 agent 的工具指令: + +1. **画像 vs 画像外**:能归入 `topic.sub_topic` 槽位的写画像,否则写画像外记录(sub_topic 省略) +2. **置信度五档**:explicit / inferred / speculated / uncertain,禁止 confirmed(仅供人工审查) +3. **质量标准**:具体(保留名称/数字/日期/角色)、独立(脱离对话可理解)、有用、不重复 +4. **时间规范**:写具体日期(YYYY-MM-DD),禁止"最近/上周/昨天"等相对表述 +5. **操作步骤**:先 `mex_profile`/`mex_list` 查重 → `mex_add` 新增 / `mex_update` 更新 → 无内容不写 +6. 最后要求输出"已写入N条记忆"(供面板显示条数) + +组装时替换占位符: + +```js +prompt: [{ type: 'text', text: EXTRACT_PROMPT.replace('{dialogue}', dialogue) }] +``` + +### 4.5 触发全新子代理(panel.js) + +```js +const run = await subagents.start('spawn', { + label: 'meX 记忆抽取', + prompt: [{ type: 'text', text: ... }], // 4.4 组装好的 prompt + parent, // 当前会话的 agent(作为父级) + signal: controller.signal, + maxDepth: 3, +}) +``` + +- `subagents.start('spawn', ...)` 是 dsh 的子代理机制(`dsh-subagent-spawn-in-process` 提供)。 +- 创建一个**全新的子代理**:独立会话、独立上下文、**看不到用户与 AI 的主对话**(零父上下文)。注意:是全新会话(`agents.create` 随机新 id),**不是** fork 主对话。 +- 把 prompt 作为子代理的第一条用户消息。 +- **子代理保留(不 dispose)**:与 dsh 标准工具的"前台调用结束即 dispose"不同,这里刻意保留——子代理作为当前会话的 one-shot 子代理进入侧边栏目录,用户可点开查看完整过程(§4.8)。 +- **为什么用子代理**:抽取是独立任务(读记忆库、写记忆库),不应污染当前对话,也不应看到主对话外的内容。隔离的临时 agent 最干净。 +- 子代理继承所有已注册工具——**包括 13 个 `mex_*` 工具**,这是它能写记忆的原因。 + +### 4.6 子代理调用 mex 工具写记忆 + +子代理自主决策,典型流程: + +1. `mex_profile` / `mex_list` → 查看记忆库已有内容(了解字段、避免重复) +2. 每条值得记的信息 → `mex_add`(topic / sub_topic / content / confidence) +3. 已有信息变化 → `mex_update` +4. 无值得记的内容 → 不调用任何写工具,直接回复"无新增记忆" + +写工具的 description 内置"写作规范"(代词明确、具体日期、自包含、用全称),子代理每次调用可见,与 4.4 的 prompt 双重约束。 + +### 4.7 mex 工具薄壳:subprocess 执行 CLI(index.js) + +```js +async execute(args) { + return await runMex(spec.subprocess, spec.argsFn(args)) +} +``` + +`runMex` 用 `subprocess.spawn` 启动真实的 `mex` 命令行进程,例如子代理调用 +`mex_add(topic="work", sub_topic="company", content="星辰科技")` 底层执行: + +```bash +mex add --topic work --sub-topic company --content "星辰科技" +``` + +`mex add` 是 meX 项目自己的 CLI(`src/mex/cli/write.py`),内部把记忆写入 +`~/.mex/mex.db`(SQLite)。**业务逻辑 100% 留在 meX CLI 内**——唯一事实原则、 +槽位校验、审计、置信度处理都由 meX 承担,插件只做"参数翻译 + 进程调用 + 结果透传"。 + +### 4.8 透明可见:打开子代理会话,实时查看全程 + +**设计目标**:抽取不是黑盒——用户点击后能看到子代理调用工具与思考的全程。 + +触发链路(异步): + +1. **Host 立即返回,不等待**:`onExtract` 在 `subagents.start` 返回后马上响应 + `{ ok, childSessionId, parentSessionId, mode: 'one-shot' }`,**不** `await run.result`, + **不** dispose(子代理保留)。 +2. **Client 打开子代理会话视图**:面板拿到 `childSessionId` 后调用 + `sessions.openSubagent({ parentSessionId, childSessionId, mode: 'one-shot' })`, + 界面切换到该子代理的会话——用户**实时看到**抽取 agent 的思考过程、 + 每一步 `mex_*` 工具调用卡片与结果,与查看普通对话完全一致。 +3. **运行状态**:Host 维护 `extractionState`(`sessionId → { phase: 'running', + childSessionId }`),`/mex/panel-state` 返回给面板;面板显示"抽取中…", + 并提供"查看运行过程"按钮(再次 `openSubagent`,从侧边栏/面板均可进入)。 +4. **完成标记(`subagent/end`)**:Host 监听 `subagent/end` 事件,按 + `info.id`(子代理 session id)关联到父会话,把该轮标记为已抽取(防重复), + 并从 `info.lastAssistantMessage` 解析"已写入N条记忆",更新 + `extractionState` 为 `{ phase: 'done', written, stopReason }`。 +5. **保留回看**:抽取完成的子代理会话保留在侧边栏子代理目录(运行状态圆点 + 变"done"),用户可随时点开回看这次抽取做了什么。 + +```js +// panel.js:subagent/end 完成标记 +ctx.on('subagent/end', (info) => { + const entry = extractionState.get(info.id) // childSessionId → 父会话关联 + if (entry === undefined) return + extractedTurns.set(entry.parentSessionId, entry.turn) + extractionState.set(entry.parentSessionId, { + phase: 'done', + childSessionId: info.id, + written: parseWritten(info.lastAssistantMessage), + stopReason: info.stopReason, + }) +}) +``` + +面板收到 `extraction.phase === 'done'` 后显示"抽取完成:已写入 N 条记忆"。 + +## 5. 关键设计决策 + +| 决策 | 原因 | +|---|---| +| 用子代理而非直接调 `mex extract` | 需求要求"触发一轮新的 agent 对话";子代理是 dsh 的隔离 agent 机制,能看到 mex 工具但看不到主对话上下文——安全且不干扰当前对话 | +| prompt 参考 prompts.py 但改写成"工具指令" | prompts.py 是给 `mex extract` 用的(输出 JSON 让代码解析);本场景 agent 自主**操作工具**,所以 prompt 是"规范 + 操作步骤" | +| 每轮一个对话文本 + `extractedTurns` 防重复 | 避免同一轮对话被反复抽取写脏数据 | +| `agent.status === 'running'` 门控 `hasNew` | 对话进行中(等待模型输出 / 执行工具,如 deep diving)**不算**"已结束的新轮次"——此时 `hasNew=false`、按钮禁用、`extract` 接口拒绝,避免用户在对话未结束时误触发抽取 | +| 子代理保留 + `openSubagent` 打开会话视图 | 抽取过程**透明可见**:用户实时看到工具调用与思考,不再是黑盒;完成后仍可回看 | +| 异步触发(不 `await run.result`)+ `subagent/end` 完成标记 | HTTP 立即返回、不阻塞请求;完成状态由事件驱动,面板轮询展示 | +| `ctx.inject(['webServer'], cb)` 等待路由注册 | bundle 插件 apply 时机可能早于 webServer 服务注册(Cordis 按 inject 依赖激活,mex 只依赖 subprocess/tools);一次性 `ctx.get('webServer')` 会拿到 undefined 导致路由永不注册、请求落到 SPA fallback(曾导致 405 空 body、前端 `Unexpected end of JSON input`)。headless 无 webServer 时回调不执行、不影响 mex 工具 | +| 读 `agent.session.log` 而非 `session.events` | events 是惰性冻结快照,turn 进行中可能已被固化;log 是实时追加数组 | + +## 6. HTTP 接口 + +| 接口 | 方法 | 入参 | 返回 | +|---|---|---|---| +| `/mex/panel-state` | GET | `sessionId` query | `{ ok, hasNew, agentRunning, lastTurn, extractedTurn, extraction }`(`agentRunning`:当前对话是否进行中——进行中 `hasNew` 恒为 `false`;`extraction` 为 `{ phase: 'running'\|'done', childSessionId, written?, stopReason? }`) | +| `/mex/extract` | POST | body `{ sessionId }` | `{ ok, started, childSessionId, parentSessionId, mode: 'one-shot' }`(异步触发,立即返回) | + +同源 HTTP(webServer 注册 exact route),Client half 用 fetch 调用。 + +## 7. 局限与后续 + +- 抽取以"最近一轮"为单位:点一次抽一轮,多轮未点只抽最近一轮。 +- 子代理是真实 LLM 对话,判断有自由度(漏记/置信度偏差),建议定期 `mex review list` 审查 AI 推断记忆。 +- 每次点击都启动一个子代理(一次 LLM 调用);高频点击会放大成本——当前由"同一轮防重复 + 运行中防重入"机制兜底。 +- 子代理保留在侧边栏目录:抽取完成后不回看也不会自动清理,长期使用目录会累积历史抽取会话(可接受;如需清理可删除对应子代理会话)。 +- 未来若做"会话结束自动抽取",可复用 4.2 的 `pendingTurns` 缓存与 4.4 的 prompt,在 hook/事件回调里触发(ADR-12 留待扩展的 hook 能力)。 diff --git a/src/mex/integration/dsh/README.md b/src/mex/integration/dsh/README.md index ea0f0e1..e6adb47 100644 --- a/src/mex/integration/dsh/README.md +++ b/src/mex/integration/dsh/README.md @@ -55,6 +55,38 @@ dsh web 新开一个会话,让 agent"读一下我的画像",应能返回 `mex profile` 的内容(对应 `mex_profile` 工具)。 +## meX 记忆面板(Client half) + +插件自带一个浏览器 UI 面板,默认停靠在页面**右下角**: + +- 每轮对话(dsh 的 Turn)结束后,面板提示"本轮对话已结束,可加入记忆"; +- 点击 **Add to MeX** 按钮:插件触发一轮全新的 agent 对话,自动把 + **抽取记忆 prompt + 本轮对话文本**交给该 agent,由 agent 调用 `mex_add` / + `mex_update` 等工具把值得记住的信息写入 meX; +- 抽取 prompt 依据 `src/mex/llm/prompts.py` 的抽取规范精简而成:画像 vs + 画像外区分、置信度五档(禁止 confirmed)、质量标准(具体/独立/有用/不重复)、 + 时间规范(具体日期),并引导 agent 先用 `mex_profile` / `mex_list` 查重。 + +**透明可见(非黑盒)**:点击后面板自动打开抽取子代理的**会话视图**——用户 +实时看到抽取 agent 的思考过程与每一步工具调用(`mex_search` / `mex_add` 等), +与查看普通对话完全一致。抽取子代理**保留**在当前会话的侧边栏子代理目录中 +(运行状态圆点:进行中 → 完成),可随时点开回看这次抽取做了什么;面板同时 +显示"抽取中…"与"查看运行过程"按钮,完成后显示"已写入 N 条记忆"。 + +实现:Host half(`panel.js`)监听 `agent/turn-stopping` 事件缓存本轮对话, +注册 `/mex/panel-state`(状态轮询)与 `/mex/extract`(触发抽取)两个 HTTP 接口, +并监听 `subagent/end` 标记抽取完成;Client half(`client.js`)注册到 +`shell.overlay` 浮动层并调用这两个接口,通过 `sessions.openSubagent` 打开 +子代理会话视图。面板是**可拖动浮窗**(标题栏拖拽,位置记忆在 localStorage, +默认停靠右下角);颜色跟随 dsh 主题——深色用主题 token,浅色微调为更浅的 +灰底与更深绿提示,保证可读性。 + +> **注意**:抽取依赖 spawn 子代理(`dsh-subagent-spawn-in-process`,dsh 默认 +> 装配),且每次抽取以"最近一轮对话"为单位——多轮未点只抽取最近一轮,点一次抽一轮。 +> +> **完整实现细节**(触发链路、prompt 设计、防重复机制、与 ADR-12 的关系等) +> 见设计文档 [`docs/dsh-integration.md`](../../../../docs/dsh-integration.md)。 + ## 提供的工具(13 个) | 工具 | 对应 mex 命令 | 说明 | @@ -83,11 +115,11 @@ dsh web ## 开发与更新插件(mex 开发者) -插件的**唯一源码位置**是 `src/mex/integration/dsh/` 下的四个模板文件(`index.js` / `package.json` / `cordis.patch.yml` / `README.md`)。`mex-dsh-plugin/` 是 `mex integrate dsh` 的**生成产物**(已 gitignore),**不要直接改它**——改了会在下次重新生成时被覆盖丢失。 +插件的**唯一源码位置**是 `src/mex/integration/dsh/` 下的六个模板文件(`index.js` / `panel.js` / `client.js` / `package.json` / `cordis.patch.yml` / `README.md`)。`mex-dsh-plugin/` 是 `mex integrate dsh` 的**生成产物**(已 gitignore),**不要直接改它**——改了会在下次重新生成时被覆盖丢失。 ### 更新生效三步 -1. **改模板**:编辑 `src/mex/integration/dsh/index.js`(工具实现)或 `cordis.patch.yml`(补丁层)。 +1. **改模板**:编辑 `src/mex/integration/dsh/` 下的源码(`index.js` 工具实现、`panel.js` Host 面板逻辑、`client.js` 浏览器面板)或 `cordis.patch.yml`(补丁层)。 开发环境是 `pip install -e .`(editable 安装),改完即生效,无需重装。 2. **重新生成**:在 mex 项目根目录执行: @@ -108,7 +140,7 @@ dsh web - **一致性检查**:重新生成后确认模板与产物无漂移: ```sh - diff -q src/mex/integration/dsh/index.js mex-dsh-plugin/index.js + diff -rq src/mex/integration/dsh/ mex-dsh-plugin/ # 排除 node_modules/package-lock.json ``` 若不一致,说明有人改过生成产物或模板未重新生成——以模板为准,重新生成一次。 diff --git a/src/mex/integration/dsh/client.js b/src/mex/integration/dsh/client.js new file mode 100644 index 0000000..43bd15c --- /dev/null +++ b/src/mex/integration/dsh/client.js @@ -0,0 +1,363 @@ +// meX × DeepSeek Harness 插件 · Client half(浏览器 UI 面板)。 +// +// 打包产物格式(window.__ModuleLoader__.load),依赖从 dsh 的模块表解析: +// - react:staticModules 静态提供; +// - slots / sessions:通过 inject 声明,运行时由 dsh-client-runtime 提供。 +// +// 面板注册到 shell.overlay(全屏浮动层,additive),默认停靠在页面右下角; +// 每轮对话结束后面板提示"可加入记忆"。点击"Add to MeX"通过 HTTP 接口 +// 通知 Host 触发一轮新的抽取 agent 对话(见 panel.js),随后调用 +// sessions.openSubagent 打开该子代理的会话视图——用户可实时看到抽取 +// agent 调用工具与思考的完整过程(非黑盒)。子代理保留在侧边栏目录。 +window.__ModuleLoader__.load({ + id: 'mex-dsh-plugin', + factory: (require) => { + var module = { exports: {} } + var exports = module.exports + Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }) + + var React = require('react') + + // 面板需要 slots(注册 UI)与 sessions(打开子代理会话视图)。 + var inject = ['slots', 'sessions'] + + // 轮询间隔(毫秒)。 + var POLL_MS = 3000 + + /** 解析面板状态响应:{ hasNew, lastTurn, extractedSeq, extraction, error }。 */ + function parseState(data) { + if (data === null || typeof data !== 'object') return null + return data + } + + /** + * meX 面板:右下角浮动卡片。 + * 从 shell.overlay 的标准 props 取当前 session(useSessions), + * 轮询 Host 的面板状态接口,显示"可加入记忆"提示、抽取运行状态 + * 与触发按钮。sessions 服务实例由 apply 通过闭包注入 props.sessions。 + */ + function MexPanel(props) { + var useSessions = props.useSessions + var sessionId = useSessions(function (s) { return s.current }) + var sessions = props.sessions + + /** 解析 JSON 响应;非 JSON(405 空 body / HTML fallback)转为结构化错误提示。 */ + var parseJson = function (res) { + return res.text().then(function (text) { + if (!text) return { ok: false, error: '接口返回空响应(Host 服务可能未就绪,请重启 dsh 后重试)' } + try { + return JSON.parse(text) + } catch (err) { + return { ok: false, error: '接口返回非 JSON 响应(' + String(err) + '),请重启 dsh 后重试' } + } + }) + } + + var state = React.useState(null) + var panel = state[0] + var setPanel = state[1] + + var busyState = React.useState(false) + var busy = busyState[0] + var setBusy = busyState[1] + + // 主题检测:body[data-ds-dark-theme] 由 dsh 主题系统按偏好 + // (深色/浅色/跟随系统)解析,跟随系统时也已归一化。 + var darkState = React.useState(false) + var isDark = darkState[0] + var setIsDark = darkState[1] + + // 面板位置:null 表示默认右下角;拖动后为 { left, top },持久化到 localStorage。 + var posState = React.useState(null) + var position = posState[0] + var setPosition = posState[1] + var dragRef = React.useRef(null) + + React.useEffect(function () { + var detect = function () { + try { + setIsDark(document.body.hasAttribute('data-ds-dark-theme')) + } catch (err) { + setIsDark(false) + } + } + detect() + var observer = null + try { + observer = new MutationObserver(detect) + observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] }) + } catch (err) { /* 无 MutationObserver 时只检测一次 */ } + return function () { + if (observer !== null) observer.disconnect() + } + }, []) + + // 恢复上次拖动位置(localStorage 持久化)。 + React.useEffect(function () { + try { + var raw = window.localStorage.getItem('mex-panel-pos') + if (raw) { + var saved = JSON.parse(raw) + if (typeof saved.left === 'number' && typeof saved.top === 'number') { + setPosition({ left: saved.left, top: saved.top }) + } + } + } catch (err) { /* 无 localStorage 时忽略 */ } + }, []) + + /** 拖拽面板:标题栏按下 → 跟随鼠标移动 → 释放保存位置。 */ + var onTitleMouseDown = function (e) { + if (e.button !== 0) return + e.preventDefault() + var startX = e.clientX + var startY = e.clientY + var startLeft = position !== null ? position.left : window.innerWidth - 240 - 16 + var startTop = position !== null ? position.top : window.innerHeight - 200 + dragRef.current = { startX, startY, startLeft, startTop } + var onMove = function (ev) { + var d = dragRef.current + if (d === null || d === undefined) return + setPosition({ + left: Math.max(0, d.startLeft + ev.clientX - d.startX), + top: Math.max(0, d.startTop + ev.clientY - d.startY), + }) + } + var onUp = function () { + dragRef.current = null + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + // 保存位置(读取最新 state 不可靠,这里从 DOM 计算) + try { + var el = document.querySelector('[data-plugin="mex-dsh-plugin"]') + if (el) { + var rect = el.getBoundingClientRect() + window.localStorage.setItem('mex-panel-pos', JSON.stringify({ left: rect.left, top: rect.top })) + } + } catch (err) { /* 忽略 */ } + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + } + + // 轮询面板状态:session 切换或每 POLL_MS 刷新一次。 + React.useEffect(function () { + var alive = true + var timer = null + var tick = function () { + if (!sessionId) return + fetch('/mex/panel-state?sessionId=' + encodeURIComponent(sessionId)) + .then(parseJson) + .then(function (data) { + if (alive) setPanel(parseState(data)) + }) + .catch(function () { /* 网络瞬断忽略,下一轮重试 */ }) + } + tick() + timer = setInterval(tick, POLL_MS) + return function () { + alive = false + if (timer !== null) clearInterval(timer) + } + }, [sessionId]) + + /** 打开抽取子代理的会话视图(用户可查看工具调用与思考全程)。 */ + var openExtraction = function (childSessionId) { + if (sessions === null || sessions === undefined || !sessionId || !childSessionId) return + try { + sessions.openSubagent({ + parentSessionId: sessionId, + childSessionId: childSessionId, + mode: 'one-shot', + }) + } catch (err) { + // openSubagent 失败不影响抽取本身,静默忽略。 + } + } + + /** 点击 Add to MeX:触发抽取,并打开子代理会话视图(非黑盒)。 */ + var onAddToMex = function () { + if (!sessionId || busy) return + setBusy(true) + fetch('/mex/extract', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId: sessionId }), + }) + .then(parseJson) + .then(function (data) { + if (data && data.ok && data.childSessionId) { + openExtraction(data.childSessionId) + } + }) + .catch(function () { /* 网络错误:轮询状态会反映真实情况 */ }) + .finally(function () { setBusy(false) }) + } + + var extraction = panel !== null ? (panel.extraction || null) : null + var running = extraction !== null && extraction.phase === 'running' + var done = extraction !== null && extraction.phase === 'done' + // agentRunning:当前对话正在进行(等待模型输出 / 执行工具), + // 本轮尚未结束,按钮不可用、不算"有新轮次可抽取"。 + var agentRunning = panel !== null && panel.ok === true && panel.agentRunning === true + var hasNew = panel !== null && panel.ok === true && panel.hasNew === true + var canRun = sessionId !== undefined && !busy && !running && !agentRunning + + // 配色:深色主题用 dsh token(自动跟随);浅色主题微调—— + // 背景用非常浅的灰,成功绿加深保证可读性。 + // 深色主题背景用 --dsw-specific-menu(= bg-layer-3 = neutral-bluish-800, + // rgb(53,54,56),比 bg-overlay 更深,与官方弹层/菜单一致); + // 浅色主题用非常浅的灰。 + var cardBg = isDark ? 'var(--dsw-specific-menu)' : '#f5f6f8' + var labelMain = isDark ? 'var(--dsw-alias-label-primary)' : '#1f2328' + var labelSecondary = isDark ? 'var(--dsw-alias-label-secondary)' : '#6b7280' + var borderColor = isDark ? 'var(--dsw-alias-border-l1)' : '#e3e6ea' + var successColor = isDark ? 'var(--dsw-alias-state-success-primary)' : '#15803d' + var warnColor = isDark ? 'var(--dsw-alias-state-warn-primary)' : '#b45309' + var disabledBg = isDark ? 'var(--dsw-alias-bg-layer-1)' : '#e8eaed' + + // 面板卡片:默认右下角;用户拖动后固定到指定位置。 + var cardStyle = { + position: 'fixed', + zIndex: 9999, + display: 'flex', + flexDirection: 'column', + gap: '8px', + width: '240px', + padding: '12px', + borderRadius: '12px', + background: cardBg, + color: labelMain, + fontFamily: 'system-ui, sans-serif', + fontSize: '13px', + boxShadow: 'var(--dsw-shadow-lv3, 0 8px 24px rgba(0, 0, 0, 0.28))', + border: '1px solid ' + borderColor, + pointerEvents: 'auto', + userSelect: 'none', + } + if (position !== null) { + cardStyle.left = position.left + 'px' + cardStyle.top = position.top + 'px' + } else { + cardStyle.right = '16px' + cardStyle.bottom = '16px' + } + + var titleStyle = { + display: 'flex', + alignItems: 'center', + gap: '6px', + fontWeight: 600, + fontSize: '14px', + cursor: 'move', + } + + var badgeStyle = { + width: '8px', + height: '8px', + borderRadius: '50%', + background: running ? warnColor : hasNew ? successColor : labelSecondary, + } + + var hintStyle = { + color: running ? warnColor : hasNew ? successColor : labelSecondary, + lineHeight: '18px', + } + + var buttonStyle = { + width: '100%', + padding: '8px 10px', + border: 'none', + borderRadius: '8px', + background: hasNew && !running ? successColor : disabledBg, + color: hasNew && !running ? '#ffffff' : labelSecondary, + cursor: canRun ? 'pointer' : 'not-allowed', + fontWeight: 600, + fontSize: '13px', + } + + var viewStyle = { + width: '100%', + padding: '6px 10px', + border: '1px solid ' + borderColor, + borderRadius: '8px', + background: 'transparent', + color: labelMain, + cursor: 'pointer', + fontWeight: 500, + fontSize: '12px', + } + + // 提示文本:优先抽取运行状态,其次对话进行中,其次抽取完成, + // 再次可抽取,兜底引导。 + var hintText + if (running) { + hintText = '抽取中…点击下方按钮查看运行过程' + } else if (agentRunning) { + hintText = '对话进行中,本轮结束后可加入记忆' + } else if (done) { + hintText = '抽取完成:已写入 ' + (extraction.written || 0) + ' 条记忆' + } else if (hasNew) { + hintText = '本轮对话已结束,可加入记忆' + } else { + hintText = '对话结束后点击下方按钮抽取记忆' + } + + var children = [ + React.createElement( + 'div', + { style: titleStyle, onMouseDown: onTitleMouseDown, title: '拖动面板' }, + React.createElement('span', { style: badgeStyle }), + React.createElement('span', null, 'meX 记忆'), + React.createElement('span', { style: { color: labelSecondary, fontWeight: 400, fontSize: '11px', marginLeft: 'auto' } }, '⣿'), + ), + React.createElement('div', { style: hintStyle }, hintText), + React.createElement( + 'button', + { style: buttonStyle, onClick: onAddToMex, disabled: !canRun }, + running ? '抽取中…' : 'Add to MeX', + ), + ] + + // 抽取进行中:提供"查看运行过程"入口(打开子代理会话视图)。 + if (running && extraction !== null && extraction.childSessionId) { + children.push( + React.createElement( + 'button', + { + style: viewStyle, + onClick: function () { openExtraction(extraction.childSessionId) }, + }, + '查看运行过程', + ), + ) + } + + return React.createElement( + 'div', + { style: cardStyle, 'data-plugin': 'mex-dsh-plugin' }, + children, + ) + } + + /** + * Client 插件入口:注册 shell.overlay 面板,并把 sessions 服务注入组件。 + * @param ctx - client 根上下文(注入 slots / sessions)。 + */ + function apply(ctx) { + var sessions = ctx.sessions + ctx.slots.inject('shell.overlay', function () { + return ctx.slots.register( + { name: 'shell.overlay', id: 'mex-panel', order: 9999, label: 'meX 记忆' }, + // 包一层:把 sessions 服务实例通过 props 传给面板组件。 + function (props) { + return React.createElement(MexPanel, Object.assign({}, props, { sessions: sessions })) + }, + ) + }) + } + + exports.apply = apply + exports.inject = inject + return module.exports + }, +}) diff --git a/src/mex/integration/dsh/index.js b/src/mex/integration/dsh/index.js index a321b8c..23a18a0 100644 --- a/src/mex/integration/dsh/index.js +++ b/src/mex/integration/dsh/index.js @@ -13,6 +13,7 @@ */ import { defineTool } from '@deepseek-ai/dsh-tools' +import { applyMexPanel } from './panel.js' export const name = 'mex-dsh-plugin' @@ -306,4 +307,8 @@ export function apply(ctx) { for (const spec of toolsSpecs) { tools.register(makeTool({ ...spec, subprocess })) } + + // 面板逻辑:注册 /mex/panel-state 与 /mex/extract 接口, + // 供 Client half 的右下角面板轮询状态与触发抽取 agent 对话。 + return applyMexPanel(ctx) } diff --git a/src/mex/integration/dsh/package.json b/src/mex/integration/dsh/package.json index 1cde95a..db91a77 100644 --- a/src/mex/integration/dsh/package.json +++ b/src/mex/integration/dsh/package.json @@ -1,21 +1,29 @@ { "name": "mex-dsh-plugin", - "version": "0.1.0", - "description": "meX 个人记忆系统的 DeepSeek Harness 插件:把 mex CLI 命令镜像成原生工具(读/写/审查)。", + "version": "0.2.0", + "description": "meX 个人记忆系统的 DeepSeek Harness 插件:把 mex CLI 命令镜像成原生工具(读/写/审查),并提供右下角 meX 记忆面板(每轮对话后可一键抽取记忆入库)。", "type": "module", "main": "./index.js", "exports": { ".": "./index.js", + "./client": "./client.js", "./package.json": "./package.json" }, - "files": ["index.js", "cordis.patch.yml", "README.md"], + "files": ["index.js", "panel.js", "client.js", "cordis.patch.yml", "README.md"], "dsh": { "bundle": { "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "inject": ["@deepseek-ai/dsh-client-runtime"] } }, "peerDependencies": { - "cordis": ">=4.0.0-rc <5" + "cordis": ">=4.0.0-rc <5", + "react": "^18.2.0", + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7" }, "license": "MIT" } diff --git a/src/mex/integration/dsh/panel.js b/src/mex/integration/dsh/panel.js new file mode 100644 index 0000000..9b91685 --- /dev/null +++ b/src/mex/integration/dsh/panel.js @@ -0,0 +1,275 @@ +/** + * meX × DeepSeek Harness 插件 · Host 面板逻辑(抽取 agent 触发)。 + * + * 职责: + * 1. 监听 `agent/turn-stopping`(每轮对话即将关闭)——记录该会话"最近一轮 + * 已结束"的对话文本(user/assistant 消息),供面板提示与抽取使用; + * 2. 面板状态查询:client 轮询该会话是否有未抽取的已结束轮次,以及 + * 最近一次抽取的运行状态(进行中 / 已完成 / 写入条数); + * 3. 触发抽取:点击"Add to MeX"后,把未抽取轮次的对话文本 + 抽取 prompt + * 交给一个全新的子代理(spawn),子代理调用 mex_* 工具写入记忆。 + * 子代理**保留**(不 dispose)——它作为当前会话的 one-shot 子代理进入 + * dsh 侧边栏子代理目录,用户可点开查看调用工具与思考的完整过程; + * 抽取完成后由 `subagent/end` 事件标记,供面板展示结果。 + * + * 通信:通过 webServer 注册两个同源 HTTP 接口(/mex/panel-state、 + * /mex/extract),client half 用 fetch 调用。webServer / subagents / + * agents / timer 均为可选依赖:未装配时面板相关功能静默降级。 + */ + +/** 抽取 prompt:参考 src/mex/llm/prompts.py 的抽取规范,精简为工具指令。 */ +const EXTRACT_PROMPT = `你是"个人记忆抽取器"。以下是用户与 AI 助手的一段对话,请从中抽取值得长期记住的个人信息,并调用 meX 提供的工具写入记忆库。 + +抽取规则(必须遵守): +1. 画像 vs 画像外:能归入某个画像字段(topic.sub_topic 均非空且被 schema 声明)的信息 → 写入画像(mex_add 的 topic/sub_topic 参数);归入不了任何槽位的临时信息(情绪、想法、计划、当天完成的事)→ 写为画像外记录(sub_topic 省略,topic 可填领域或省略)。 +2. 置信度五档:explicit(用户原话明确陈述)/ inferred(由多条事实合理推导)/ speculated(少量线索推测)/ uncertain(几乎无证据)。禁止 confirmed(仅供人工审查)。推断信息必须用后三档之一。 +3. 质量标准:每条候选记忆必须同时满足:具体(保留名称/数字/日期/角色/原因)、独立(脱离对话也能理解)、有用(影响未来对话)、不重复(与已有记忆不重复)。 +4. 时间规范:用具体日期(YYYY-MM-DD),禁止"最近/上周/昨天"等相对表述。 +5. 写作规范:代词指代明确(禁止"它/这个/那家"等依赖上下文的指代)、名称用全称、内容自包含。 + +操作步骤: +1. 先用 mex_profile / mex_list 查看现有画像与记忆(了解字段与去重)。 +2. 新信息用 mex_add 写入;已有信息有变化用 mex_update 更新。 +3. 若无值得抽取的内容,不要调用任何写工具,直接回复"无新增记忆"。 + +最后一行必须输出:已写入N条记忆(N 为实际写入条数,未写入则为 0)。 + +对话内容: +{dialogue}` + +/** 每会话最近一轮已结束的对话缓存:sessionId → { turn, dialogue }。 */ +const pendingTurns = new Map() + +/** 每会话已抽取到的最大 turn 号(内存态;重启后未抽取轮次需重新对话才会补录)。 */ +const extractedTurns = new Map() + +/** + * 每会话最近一次抽取的运行状态:sessionId → 状态对象。 + * - 子代理启动后写 { phase: 'running', childSessionId }; + * - subagent/end 事件到达后写 { phase: 'done', childSessionId, written, stopReason }。 + */ +const extractionState = new Map() + +/** 从 ContentBlock 数组中提取纯文本。 */ +function textOf(blocks) { + if (!Array.isArray(blocks)) return '' + return blocks + .filter((b) => b !== null && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('') +} + +/** + * 从 agent 的事件流中提取"第 targetTurn 轮"的用户/AI 对话文本。 + * 在 agent/turn-stopping 事件里调用:此时该轮 user/message 与 + * assistant/message 已全部 append,而 turn/end 尚未写入。 + * user/message 事件本身不带 turn 字段,按 turn/start 边界归属当前轮。 + */ +function extractTurnDialogue(events, targetTurn) { + const lines = [] + let currentTurn = 0 + for (const ev of events || []) { + if (!ev || !ev.data) continue + if (ev.type === 'turn/start' && typeof ev.data.turn === 'number') { + currentTurn = ev.data.turn + continue + } + if (ev.type === 'turn/end' && typeof ev.data.turn === 'number') { + currentTurn = 0 + continue + } + if (currentTurn !== targetTurn) continue + if (ev.type === 'user/message') { + const text = textOf(ev.data.content) + if (text) lines.push(`用户:${text}`) + } else if (ev.type === 'assistant/message' && ev.data.turn === targetTurn) { + const text = textOf(ev.data.message && ev.data.message.content) + if (text) lines.push(`AI:${text}`) + } + } + return lines.join('\n') +} + +/** 从抽取 agent 的输出中解析"已写入N条记忆"。 */ +function parseWritten(outputBlocks) { + const text = (outputBlocks || []) + .filter((b) => b !== null && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('\n') + const match = /已写入\s*(\d+)\s*条记忆/.exec(text) + return match ? Number(match[1]) : 0 +} + +/** 读取 node http 请求体(JSON),返回解析后的对象或 null。 */ +function readJsonBody(req) { + return new Promise((resolve) => { + let raw = '' + req.on('data', (chunk) => { + raw += chunk + if (raw.length > 1024 * 1024) { + req.destroy() + resolve(null) + } + }) + req.on('end', () => { + try { + resolve(JSON.parse(raw || '{}')) + } catch { + resolve(null) + } + }) + req.on('error', () => resolve(null)) + }) +} + +/** 写 JSON 响应(node http 风格)。 */ +function sendJson(res, status, body) { + const payload = JSON.stringify(body) + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }) + res.end(payload) +} + +/** 解析 URL 查询参数(sessionId 等)。 */ +function queryOf(req) { + const url = new URL(req.url || '/', 'http://localhost') + return Object.fromEntries(url.searchParams.entries()) +} + +/** + * 注册面板接口与抽取逻辑。 + * + * 注意:不能用 `ctx.get('webServer')` 一次性获取——bundle 插件的 apply 时机 + * 可能早于 webServer 服务注册(装配时序由 Cordis 依赖决定),拿到 undefined + * 会导致路由永不注册、面板请求落到 SPA fallback。正确做法是用 + * `ctx.inject(['webServer'], ...)` 等待服务可用后再注册路由;headless 环境 + * 服务永不出现则回调不执行,插件其余功能(mex 工具)不受影响。 + * + * @param ctx - bundle 插件上下文。 + */ +export function applyMexPanel(ctx) { + // 每轮对话即将关闭时缓存该轮对话文本,供面板提示与抽取使用。 + // 该监听不依赖 webServer,始终注册。 + ctx.on('agent/turn-stopping', ({ agent, turn }) => { + try { + // session.log 是实时追加数组;session.events 是惰性冻结快照, + // 可能在 turn 进行中被提前固化,故这里读 log 拿到最新事件。 + const dialogue = extractTurnDialogue(agent.session.log, turn) + if (dialogue.trim()) pendingTurns.set(agent.id, { turn, dialogue }) + } catch (error) { + ctx.logger?.warn(`mex-panel: 缓存第 ${turn} 轮对话失败: ${String(error)}`) + } + }) + + // 抽取子代理结束(无论成败):按子代理 session id 关联到父会话, + // 标记该轮已抽取(防重复),并记录写入条数供面板展示。 + ctx.on('subagent/end', (info) => { + const entry = extractionState.get(info.id) + if (entry === undefined) return + const { parentSessionId, turn } = entry + extractedTurns.set(parentSessionId, turn) + extractionState.set(parentSessionId, { + phase: 'done', + childSessionId: info.id, + written: parseWritten(info.lastAssistantMessage), + stopReason: info.stopReason, + }) + }) + + /** GET /mex/panel-state?sessionId=…:面板状态(新轮次 + 抽取运行状态)。 */ + const onPanelState = async (req, res) => { + const sessionId = queryOf(req).sessionId + if (!sessionId) { + sendJson(res, 200, { ok: true, hasNew: false, error: '缺少 sessionId' }) + return + } + const pending = pendingTurns.get(sessionId) + const lastTurn = pending ? pending.turn : 0 + const extracted = extractedTurns.get(sessionId) ?? 0 + const extraction = extractionState.get(sessionId) + // 对话进行中(等待模型输出 / 执行工具)不算"已结束的新轮次": + // hasNew 仅在 agent 空闲且存在未抽取的已结束轮次时为 true。 + const agents = ctx.get('agents') + const parent = agents === undefined ? undefined : agents.get(sessionId) + const agentRunning = parent !== undefined && parent.status === 'running' + sendJson(res, 200, { + ok: true, + hasNew: lastTurn > extracted && !agentRunning, + agentRunning, + lastTurn, + extractedTurn: extracted, + extraction: extraction ?? null, + }) + } + + /** POST /mex/extract body {sessionId}:触发抽取 agent 对话(异步,立即返回)。 */ + const onExtract = async (req, res) => { + const body = await readJsonBody(req) + const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId : undefined + const agents = ctx.get('agents') + const subagents = ctx.get('subagents') + if (!sessionId || agents === undefined || subagents === undefined) { + sendJson(res, 200, { ok: false, error: '依赖服务不可用(agents/subagents 未装配)' }) + return + } + const pending = pendingTurns.get(sessionId) + const lastTurn = pending ? pending.turn : 0 + const extracted = extractedTurns.get(sessionId) ?? 0 + const running = extractionState.get(sessionId) + if (running !== undefined && running.phase === 'running') { + sendJson(res, 200, { ok: false, error: '上一轮抽取仍在进行中', childSessionId: running.childSessionId }) + return + } + if (lastTurn <= extracted || !pending) { + sendJson(res, 200, { ok: false, error: '没有新的已结束对话可抽取' }) + return + } + const parent = agents.get(sessionId) + if (parent === undefined) { + sendJson(res, 200, { ok: false, error: '会话未激活(agent 不在运行中)' }) + return + } + // 对话进行中(等待模型输出 / 执行工具):本轮尚未结束,拒绝抽取。 + if (parent.status === 'running') { + sendJson(res, 200, { ok: false, error: '对话仍在进行中,请等本轮结束后再抽取' }) + return + } + const controller = new AbortController() + try { + const run = await subagents.start('spawn', { + label: 'meX 记忆抽取', + prompt: [{ type: 'text', text: EXTRACT_PROMPT.replace('{dialogue}', pending.dialogue) }], + parent, + signal: controller.signal, + maxDepth: 3, + }) + // 记录运行状态;子代理保留(不 dispose),供用户在侧边栏目录查看全程。 + extractionState.set(sessionId, { phase: 'running', childSessionId: run.id }) + // 建立 childSessionId → 父会话关联,供 subagent/end 回调标记完成。 + extractionState.set(run.id, { parentSessionId: sessionId, turn: lastTurn }) + sendJson(res, 200, { + ok: true, + started: true, + childSessionId: run.id, + parentSessionId: sessionId, + mode: 'one-shot', + }) + } catch (error) { + sendJson(res, 200, { ok: false, error: error instanceof Error ? error.message : String(error) }) + } + } + + // 等待 webServer 服务可用后再注册路由(web 环境);回调返回 disposer, + // 服务卸载时由 Cordis 收集清理。 + ctx.inject(['webServer'], (webCtx) => { + const webServer = webCtx.webServer + const d1 = webServer.register({ kind: 'exact', path: '/mex/panel-state', handler: onPanelState }) + const d2 = webServer.register({ kind: 'exact', path: '/mex/extract', handler: onExtract }) + return () => { + d1() + d2() + } + }) +} diff --git a/src/mex/services/integrate.py b/src/mex/services/integrate.py index 8f905fc..6b1a3d8 100644 --- a/src/mex/services/integrate.py +++ b/src/mex/services/integrate.py @@ -5,7 +5,7 @@ claude / opencode 产出 skill 说明书(写入官方 skills/mex/SKILL.md 发现路径)+ README.md (写入 agent 配置目录)。不再生成 hooks.md——OpenCode 原生不支持 SessionEnd hook (官方配置 schema 无 hooks 字段),写路径由 skill 引导 agent 即时写承担,hook 能力留待扩展; -dsh 产出标准 DSH bundle 目录(index.js + package.json + cordis.patch.yml + README.md)。 +dsh 产出标准 DSH bundle 目录(index.js + panel.js + client.js + package.json + cordis.patch.yml + README.md)。 """ from __future__ import annotations @@ -33,7 +33,9 @@ ) # DSH bundle 的组成文件(独立于 SKILL_FILE_LAYOUT 的清单)。 -DSH_BUNDLE_FILES = ("index.js", "package.json", "cordis.patch.yml", "README.md") +# index.js = Host half(工具注册);panel.js = Host 面板逻辑(抽取 agent 触发); +# client.js = Client half(浏览器 UI 面板);package.json 声明 dsh.client 入口。 +DSH_BUNDLE_FILES = ("index.js", "panel.js", "client.js", "package.json", "cordis.patch.yml", "README.md") @dataclass(frozen=True) @@ -108,7 +110,7 @@ def _generate_skill_files(agent: AgentName, base_dir: Path) -> list[str]: def _generate_dsh_bundle(base_dir: Path) -> list[str]: - """复制 DSH bundle 目录(index.js 等四件套)到 base_dir。""" + """复制 DSH bundle 目录(index.js/panel.js/client.js 等六件套)到 base_dir。""" written: list[str] = [] for name in DSH_BUNDLE_FILES: source = resources.files("mex.integration").joinpath("dsh", name) diff --git a/tests/services/test_integrate.py b/tests/services/test_integrate.py index d02b032..5306538 100644 --- a/tests/services/test_integrate.py +++ b/tests/services/test_integrate.py @@ -98,9 +98,9 @@ def test_unknown_scope_raises(self, mex_home, tmp_path, monkeypatch): class TestIntegrateDsh: - """dsh 产出标准 DSH bundle 目录(四件套),形态与 claude/opencode 的三件 .md 不同。""" + """dsh 产出标准 DSH bundle 目录(六件套:Host/Client 双 half),形态与 claude/opencode 的三件 .md 不同。""" - DSH_FILES = ("index.js", "package.json", "cordis.patch.yml", "README.md") + DSH_FILES = ("index.js", "panel.js", "client.js", "package.json", "cordis.patch.yml", "README.md") @pytest.fixture def mex_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: @@ -121,7 +121,7 @@ def test_generates_bundle(self, mex_home, tmp_path, monkeypatch, scope): result = integrate("dsh", scope) assert result.agent == "dsh" and result.scope == scope - assert len(result.written_files) == 4 + assert len(result.written_files) == 6 for name in self.DSH_FILES: assert (expected / name).exists() assert str(expected / name) in result.written_files @@ -134,11 +134,24 @@ def test_bundle_manifest_valid(self, mex_home, tmp_path, monkeypatch): pkg = (tmp_path / "mex-dsh-plugin" / "package.json").read_text(encoding="utf-8") assert "mex-dsh-plugin" in pkg assert '"type": "module"' in pkg + assert '"client"' in pkg # Client half 入口 js = (tmp_path / "mex-dsh-plugin" / "index.js").read_text(encoding="utf-8") assert "@deepseek-ai/dsh-tools" in js assert "export function apply" in js assert "defineTool" in js + assert "applyMexPanel" in js # Host 面板逻辑已接入 + + client = (tmp_path / "mex-dsh-plugin" / "client.js").read_text(encoding="utf-8") + assert "__ModuleLoader__.load" in client # Client half 打包格式 + assert "shell.overlay" in client # 注册到浮动层 + assert "Add to MeX" in client # 抽取按钮 + + panel = (tmp_path / "mex-dsh-plugin" / "panel.js").read_text(encoding="utf-8") + assert "agent/turn-stopping" in panel # 每轮结束感知 + assert "/mex/panel-state" in panel + assert "/mex/extract" in panel + assert "subagents.start" in panel # 触发新 agent 对话 def test_idempotent(self, mex_home, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path)