# dsh-workbuddy-experts 最终架构 > 复用 WorkBuddy 专家市场(`expertType: "agent"`),在 dsh Web 每个会话发送框内嵌「专家选择器」。 > 选中专家 → 该专家**人设**进入该会话 system prompt、该专家捆绑 **skills** 进入该会话技能目录;两者都**跟随切换动态更新**,选择**跨刷新/重开持久化**。 > 仅限单专家;专家团(team)与连接器本期不做。 --- ## 1. 总览(host / client 两端) ``` ┌─ 浏览器 ────────────────────────────────────────────────┐ │ 客户端插件 dsh-client-ui-experts (_c) │ │ ExpertSelect → 注入 slot: conversation.input.right │ │ (portal + fixed 下拉, 分组/描述换行/打勾, 外点关闭护盾) │ │ 数据通道: fetch /plugins/dsh-workbuddy-experts/{catalog,current,select} └──────────────┬──────────────────────────────────────────┘ │ HTTP ┌─ 主机 ────────┼──────────────────────────────────────────┐ │ 宿主插件 dsh-workbuddy-experts (_h) │ │ inject: ['agents','systemPrompt','skills'] │ │ ├─ 目录扫描 loadCatalog(root) → ExpertEntry[] │ │ ├─ 持久化 SelectionStore(~/.dsh-workbuddy-experts.json) │ ├─ 人设 全局 systemPrompt.section('expert:persona', │ │ text 为函数: 每步按 context.agent.id 读内存表) │ ├─ 技能 全局 scope 门控 SkillProvider: │ │ list(options.scope)→sessionId→所选专家→其 skills │ ├─ HTTP catalog / current / select 路由 │ │ └─ 日志 ~/.dsh-workbuddy-experts.log │ └──────────────────────────────────────────────────────────┘ ``` **核心循环**:用户点选专家 → 客户端 POST `select {sessionId, expertId}` → 主机 `select()` 持久化 + 更新内存人设表 + `providerControl.invalidate()` 刷技能缓存 → 下一模型步系统提示词/技能目录即反映(dsh 每步重组装)。 --- ## 2. 关键机制(每条都踩过坑,见 docs/debug-persona-skills.md) ### 2.1 人设:全局一段 + 函数式 text(不是 per-agent section) - dsh 每模型步调用 `systemPrompt.assemble(context)`;段 `text` 可以是**函数**, 以该步的组装上下文求值(`dsh-system-prompt` 源码:`typeof text === 'function' ? text(context) : text`)。 - 因此**注册一个全局段 `expert:persona`**(order 0),`text = ctx => 内存表[ctx.agent.id] ?? ''`。 每步重新求值 → 切换专家下一步即生效,无需每次重注册;空则渲染时丢弃(未选专家不影响默认人格)。 - **为什么不用 per-agent `agent.ctx.systemPrompt.section`**:最初用这个,实测"切换后要重进会话才生效"(以及 `deployment:persona` 全局槽已被 dsh 配置人格占用、同名段不能全局重复注册)。全局函数式最稳。 ### 2.2 技能:全局层 + `options.scope` 门控(不是 per-agent provider) - `agent.ctx.skills` 会抛 `cannot get property "skills" without inject`(agent 作用域 ctx 未注入 `skills`)。 - 于是**在宿主根 ctx(`inject:['skills']`)注册一个全局 provider**(`createExpertSkillProvider`),全局限所有人, 但 `list(options)` 里按 **`options.scope`(即请求技能的 agent,`id`=sessionId)** → `SelectionStore` → 该会话所选专家 → **只返回该专家的 `skills/`**;未选/无技能会话返回空。天然按会话隔离。 - 候选/定义的 `provider` 字段必须改写成外层名 `'experts'`(内层 `FileSystemSkillProvider` 自带 `experts:`,会被 `validateCandidate(candidate, provider.name)` 拒绝)。技能变更用 `providerControl.invalidate()` 刷缓存。 ### 2.3 会话 id 即映射键 客户端 slot 注入的 `sessionId`、`agent.id`、`agent.session.id`、`SelectionStore` 的键是同一个值, 因此人设函数与技能门控都能精确落到对应会话。 --- ## 3. 模块划分 | 文件 | 职责 | |---|---| | `src/index.ts` | 宿主入口:Config/apply;目录扫描、持久化、人设段、技能门控 provider、HTTP、日志、启动恢复/卸载 | | `src/catalog.ts` | 纯逻辑:扫描 `experts/plugins/*`、解析 `plugin.json`、归一化人设(frontmatter 剥离、`${CODEBUDDY_PLUGIN_ROOT}` 绝对化、`${VAR}` 标记、`{{...}}` 中和) | | `src/persona.ts` | 人设段常量/登记助手(全局函数式段的 text 生成) | | `src/expert-provider.ts` | scope 门控技能 provider:`createExpertSkillProvider` + `sessionIdOf` + provider 字段改写 | | `src/state.ts` | 纯逻辑:SelectionStore(持久化)+ resolvePath + appendLogFile | | `src/client/*` | ExpertSelect(portal 下拉)、service(HTTP 客户端)、index.tsx(slot 注入 `conversation.input.right`) | --- ## 4. 配置(cordis.patch.yml / Config) | key | 默认 | 说明 | |---|---|---| | `root` | `~/.workbuddy/plugins/marketplaces/experts/plugins` | 专家市场目录 | | `stateFile` | `~/.dsh-workbuddy-experts.json` | 按会话选择持久化 | | `logFile` | `~/.dsh-workbuddy-experts.log` | 插件诊断日志 | | `maxPersonaBytes` | `65536` | 人设超长截断 | | `personaOrder` | `0` | `expert:persona` 段 order | --- ## 5. 边界 / 已知取舍(v1) - **仅单专家**:`expertType === "agent"` 收录;team/连接器忽略。 - **不 `complete` 接管**:人设并入(order 0 非 complete),保留 harness 身份与全部 dsh 工具。 - **人设按会话隔离,技能也按会话隔离**(scope 门控)。跨会话互不泄漏。 - 技能 provider 内层用 `FileSystemSkillProvider` 做发现;候选/定义 `provider` 字段统一改写为 `'experts'`。 - 客户端下拉为 portal + fixed,规避通栏 dock 拉伸与外点吞点击(详见 docs/layout-debug.md)。