# 人设工作区绑定(0.8.1 设计草案) > 状态:**部分交付,UI 已撤下**。0.8.1-a(纯函数层)、0.8.1-b(宿主侧解析)、0.8.1-c(绑定路由) > 的代码全部保留并仍由断言守着;**0.8.1-d 的设置页分组在 0.8.0 发版前被撤下** —— 按目录绑人设 > 将被 **0.9.0 的三级隔离**(Global → Workspace → Session)整体接管,而它的交互是按「目录字符串」 > 思考的,与工作区平铺卡片完全不同。 > 本文件继续作为 0.9.0 的设计输入:§1–§6 的目标与取舍仍然成立,§7 的 UI 形态会被重写。 > 本文件描述 0.8.1 的目标、取舍与验收方式;§10 的分层编号沿用草案写作时的 0.8.0-a..d, > 实际发版本号为 **0.8.1-a..d**(草案写作期间路线图调过一次序:0.8.0 让给了变更审计与回滚)。 > 前置阅读:`lib/personas.mjs` 文件头四条设计约定、`RELEASE_NOTES.md` 的 v0.7.2 段落、`PUBLISHING.md` 的发版流程。 ## 1. 目标 让**目录**决定 Agent 用什么人设。 用户在 `D:\项目A` 打开会话,Agent 自动是「资深架构师」;在 `D:\项目B` 打开,自动是「极简主义者」;不配置时,行为与现在完全一致。配置一次,之后每进这个目录都自动生效,包括子目录与新会话。 一句话:**把「每次手动切人设」变成「环境自带人设」。** ## 2. 为什么是目录键,而不是会话键 曾考虑过「按 session 绑定」。否决理由是一条硬约束: **子代理会继承父会话的目录,但拿到全新的 sessionId。** 宿主 `dsh-subagent/lib/index.js` 的 `childSessionMeta`: ```js function childSessionMeta(parent, childDepth, isSeeded) { const parentHeader = parent.session.header; const agentPreset = parent.ctx.get('agentPresets')?.composedPreset(parent.ctx); return { ...(parentHeader.cwd !== void 0 ? { cwd: parentHeader.cwd } : {}), ...(agentPreset === void 0 ? {} : { agentPreset }), parentSession: parentHeader.id, isSeeded, origin: 'subagent', delegationDepth: childDepth }; } ``` `cwd` 原样继承、`id` 全新。纯会话键的方案下,用户在主会话绑了人设、派一个子代理出去,子代理会**静默回落**到全局默认 —— 而且没有任何提示。这是不可接受的。 同理,跨项目来回切换时目录键零操作、会话键每次都要重设。 | 维度 | 目录键 | 会话键 | | --- | --- | --- | | 子代理继承 | 零成本自动继承 | 键变了 ⇒ 静默回落 | | 跨项目切换 | 进目录自动生效 | 每新会话重设一次 | | 同一项目跑两种人设 | 需靠会话层覆盖 | 天然支持 | | 数据清理 | 无状态 | 每会话一条,需随 agent 回收 | | Windows 路径坑 | 需规范化(大小写 / Junction) | 无 | 结论:**目录是主键**,会话层仅作为显式覆盖存在(见 §5)。 ### 2.1 子代理走哪条路径要分清 同一份源码里有**两条**子代理 cwd 路径,不要混为一谈: | 路径 | 载体 | cwd 来源 | | --- | --- | --- | | **进程内子代理**(`dsh-subagent` 内部) | `childSessionMeta(parent, childDepth, isSeeded)` | 原样继承 `parentHeader.cwd` | | **外部进程子代理**(`resolveChildCwd` / `validateConfiguredCwd`) | 导出给插件调用的 API | 部署配置的 `cwd` 覆盖值优先,其次父会话 | `resolveChildCwd` 在全树**没有任何调用点** —— 它是导出给插件消费的,宿主内部不自己走。进程内子代理(也是日常对话里唯一会出现的那种)走的始终是 `childSessionMeta`。 **设计含义**:目录隔离下,子代理继承父会话目录 ⇒ 子代理也用人设。这是**刻意**的 —— 主会话用「资深架构师」,它派出去调研的子任务也应该按同样的专业口吻回答,而不是突然变成出厂默认。 但要注意:若将来某个部署给自己配了 `cwd` 覆盖,外部进程子代理的 cwd 可能与父会话**不同** ⇒ 它会命中另一条绑定(或回落)。这是正确行为,但属于需要在文档里说清的情况,不是 bug。 ## 3. 命中规则:一条绑定 = 一棵子树 ### 3.1 规则 1. **目录即键。** 同一目录重复绑定 = 改绑,不是新增。 2. **前缀继承。** 绑定 `D:\项目A` 后,`D:\项目A\子模块` 也用它 —— 用户绑目录的意图就是「这棵树都归它」。 3. **允许嵌套绑定,最长前缀优先。** 若同时存在 `D:\项目A` → 资深架构师、`D:\项目A\实验性重构` → 头脑风暴伙伴,则后者目录下的会话用头脑风暴伙伴,其余兄弟目录仍用资深架构师。 4. **子代理天然继承**,因为子代理继承 `cwd`(§2)。 5. 最坏情况回落全局配置 —— 与今天的行为一致。 嵌套绑定**不加写入限制**。曾设计过「父目录已有绑定时拒绝新增子绑定」的约束,已撤回:两个不同的键在结构上并不冲突(用户的判断是对的),真正需要决定的不是「能不能存」,而是「一个会话同时命中两条时谁生效」,而这个问题只由规则 3 回答。 ### 3.2 命中判定必须落在文件边界上 前缀比较**不能**是裸字符串 `startsWith`。否则 `D:\项目A` 会错误命中 `D:\项目AB`。 判定条件: ``` candidate === bound || candidate.startsWith(bound + sep) ``` `sep` 取自规范化后的路径(Windows 用 `\`)。这条必须有独立断言 —— 它是整个功能里最容易写错、且错了以后表现得「像随机」的一行。 ### 3.3 路径规范化 比较前统一规范化,四条: 1. 统一分隔符(`/` → `\`;或统一为 `/`,二者选一后写进注释)。 2. 去尾部分隔符(`D:\项目A\` 与 `D:\项目A` 视为同一目录)。 3. Windows 大小写不敏感 —— 大小写折算仅用于**比较**,展示仍用用户选择时的原样。 4. 解析符号链接与 Junction。**这不是理论问题**:本机 `profiles\desktop\node_modules\dsh-soul` 就是一个 Junction,`D:\AliuProject` 这类开发目录同样常见链接。 解析用 `realpath`。若目标不存在(目录被删、网络盘离线),**保留原样不报错** —— 离线盘不该让整个设置页崩掉。 ## 4. 数据模型 **独立文件,不放进人设。** `$DSH_HOME/soul-bindings.json`(与 `soul-config.json` 同级,见 `index.mjs:43-44` 的 `configPath()`): ```json { "version": 1, "bindings": { "D:\\项目A": "资深架构师", "D:\\项目A\\实验性重构": "头脑风暴伙伴" } } ``` 以**规范化目录**为键、人设名为值。 ### 4.1 为什么不放进人设条目 「工作区绑定」是**本机环境事实**,不是人设属性。同一份「资深架构师」在台式机和笔记本绑的目录必然不同。 放进人设会带来三个具体问题: 1. 导出 / 分享人设时带出本机绝对路径 —— `D:\AliuProject` 对别人没有意义,还泄露了本机目录结构。 2. 内置人设不能带路径(它们随插件分发,是共享的)。 3. 删掉人设时,它的绑定该不该一起消失?放在独立表里,答案是「绑定指向一个已不存在的人设名 ⇒ 该条失效但保留」,语义干净。 ### 4.2 失效引用 绑定值是人设名,人设可能被删或被改名。解析时: - 目标人设不存在 ⇒ **忽略该条绑定**,回落(若父级有绑定则用父级,否则用全局配置)。 - 不自动删除这条绑定 —— 用户可能只是临时删了人设,重命名回来即恢复。 - 设置页对失效项**显式标注**(「人设『X』已不存在」),并给出删除入口。 ### 4.3 文件读写 复用 `lib/store.mjs` 的既有能力(`readConfigFile` / `writeConfigFile` / `moveAsideConfigFile` / `describeConfigFailure`),不另造一套: - 原子写:`writeConfigFile` 已经是「先写同目录 `.tmp` 再 rename」。 - 损坏文件:沿用配置文件的逃生口语义 —— 挪到一边、记备份路径、以空表继续运行,**不静默覆盖**。 - 内存缓存:与 `loadConfig()` 同理(`index.mjs:328-340`),但需注意绑定的读频率远低于配置(每轮装配一次),可以不做缓存,或缓存并在写后失效。 - 写队列:绑定写入复用 `enqueueConfigWrite` 的**串行化语义**,但**用独立队列实例** —— 两套数据在因果上无关(绑定的写入不依赖配置的当前值,反之亦然),共用一个队列只会把两者的延迟叠在一起。`index.mjs:365-372` 的实现可以直接照抄一份,或把 `enqueueConfigWrite` 抽成参数化工厂。 ### 4.4 会话层覆盖的存储 会话层覆盖**不写进这个文件**。绑定表是「环境事实」(跨会话长期有效),会话覆盖是「此刻的选择」(随会话消失)。 选项: - 存进宿主会话的 `projectionValues`(客户端插件已支持投影)。需要确认插件能否写入自己的投影命名空间。 - 或存进内存 Map,进程重启即失效 —— 语义上可接受(会话本身就是进程级对象)。 **0.8.0 的建议:先做内存 Map。** 理由:会话覆盖本来就不该活得比会话久;存盘反而制造「重启后还记着我临时切过」这种需要额外解释的行为。若后续发现需要持久化,再迁移。 **但要注意**:进程内保存会话对象时要用 `agent/disposed` 事件(`dsh-agent/lib/types/runtime-types.d.ts:240-242`)清理,否则 Map 会随会话数无限增长。 ## 5. 解析层级与项目设置的关系 **三层,从具体到宽泛:** ``` 会话层覆盖(仅当用户显式设置) → 目录绑定(最长前缀) → 全局配置(现状) ``` ### 5.1 会话层为什么保留 它是唯一的安全阀:同一台机器、同一个项目,今天想做重构用它、明天想做评审用另一个。没有它,目录绑定会变成一种「进去了就改不掉」的强制。 但**必须是显式的**:用户在会话里选人设,**不自动**变成会话层覆盖。否则「我刚选的人设,为什么新开会话又变回去了」是必然会被问的问题 —— 而且用户答不出来。 会话层覆盖的入口:设置页「当前会话」区块显示 `本次会话临时改用:X [恢复为目录绑定]`,只在覆盖值与目录解析结果不同时出现。 ### 5.2 「项目设置」的定位 用户可能把「绑定目录」理解成「给这个项目建一份项目设置」。需要明确区分: - **项目设置(假设宿主将来提供)**:进这个项目就自动带上模型、权限策略、技能集。 - **人设绑定(本设计)**:进这个目录就用这个人设。 两者**不合并**。理由:人设是排他的(一次会话只注入一个 `soul:persona` section),而项目设置里其他项是累加的(权限、技能)。把它们塞进同一个概念会让「我改了人设为什么权限也变了」这类问题无处解释。 (关于「预设 = 语气 + 能力集」的想法 —— 让预设声明推荐启用哪些 skill/工具 —— 本版**不做**。自动开关工具涉及权限,代价与风险都不在一个量级。) ## 6. 宿主契约(已逐条查证) ### 6.1 装配期能拿到 agent 与它的 cwd ★ 宿主 `dsh-agent` 给 `AssembleContext` 打了类型补丁(`dsh-agent/lib/types/runtime-types.d.ts:14-19`): ```ts declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { /** Agent for this assembly; absent on diagnostics. When present, `scope` must identify the same agent. */ agent?: Agent; } } ``` 装配时宿主构造的上下文(`dsh-agent/lib/index.js:291-297`): ```js function assembleContextFor(agent, signal) { return { agent, scope: agent, ...(signal === void 0 ? {} : { signal }) }; } ``` 调用点:`dsh-agent-loop/lib/index.js:907` `const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal));` 会话目录同步可达(`dsh-session/lib/types/types.d.ts:69`,`SessionHeader.cwd`,注释「Immutable validated storage metadata, kept outside the conversation event log」): ```js agent?.session?.header?.cwd ``` **结论:section 的 `text` 回调每轮都能按 cwd 解析人设,无需任何按 agent 注册的机制。** 这是本设计里最重要的简化 —— 它把「隔离」从「注册期的作用域技巧」降级成「装配期的一次查表」。 ### 6.2 备选路径(本设计不采用) 宿主 `systemPrompt.section()` 的作用域语义支持「同名 section 在 agent 作用域内覆盖全局」(`dsh-system-prompt/lib/index.js` 的 JSDoc 原文:「A scoped section shadows a global section with the same name」;`ScopedLayers.merge` 实现为逐层 `set`,nearest wins),且宿主 `dsh-agent-instructions` 那类插件走的正是「按 cwd 分层」的思路。 已知可用的三条路: | 方案 | 机制 | 何时采用 | | --- | --- | --- | | **A(本设计)** | `text: (ctx) => resolve(ctx?.agent?.session?.header?.cwd)` | 默认 | | B | 在 `agent.ctx` 里注册同名 section 覆盖全局 | 若将来出现「同一 agent 内也要按条件切换」的需求 | | C | `agent/pre-step` 里插一条 context 消息 | 若需要每步动态变化(宿主自己的 AGENTS.md 就是这条路) | 选 A 的理由:改动最小,且**不触碰宿主的作用域机制** —— B 需要维护「每 agent 一个注册 + 随 agent 销毁回收」,C 需要处理消息去重与 inbox 同步,两者都在增加状态。A 是无状态的。 ### 6.3 注入通道要逐 agent 编译 `injectPromptToAllAgents`(`index.mjs:558-631`)遍历 `agents.list()` 向每个 agent `inject` 一条用户消息,用于「保存后立刻生效」。 **通道保留,但编译粒度必须改。** 现在是一个全局文本(`index.mjs:590` 的 `compilePrompt(config)`)发给所有 agent —— 工作区隔离下这会把 A 项目的人设注入 B 项目的会话,比不注入更糟。必须改成:循环体内按该 agent 的 `cwd` 解析人设、各自编译。 循环体拿得到 agent 身份(`index.mjs:597` 的 `for (const agent of listed)`),所以这次改动局限在循环内部,不需要重构函数签名。 ### 6.4 三类变更都要触发刷新 `refreshPromptAndInject`(`index.mjs:633`)触发条件都是「配置的提示词字段变了」(`diffKeys` → `promptChanged`)。已知调用行(用于定位,不是完整列表):`index.mjs:695`、`:807`、`:935`、`:1387`、`:1431`、`:1440`、`:1446`。 (该标识符在 `index.mjs` 内共 **13 处命中**(实测),含定义、调用与注释提及。**实施时重新 `grep` 计数,不要把这个数字当成契约** —— 新增绑定路由会合法地增加调用点。文档里写死数字的代价是:后人看到数字对不上,会以为自己改错了。) 工作区隔离引入**第三类**会改变提示词的事实:**新增或删除绑定**。它不改 `soul-config.json`,所以现有的 `promptChanged` 门控看不见它。绑定路由的写路径必须自己调用刷新。 ## 7. 客户端 UI ### 7.1 设计原则 **「绑定」是配置动作(低频,要增删改查);「当前用什么」是状态(高频,只读)。两者不塞进同一个控件。** UI 必须先回答「我这个项目现在是谁」,再让用户改。 ### 7.2 位置 设置页新增第 4 组「工作区绑定」,插在「人设预设」与「Agent 工具」之间。落点:`client/index.mjs` 中 `group.personas` 分组渲染(约 2017 行)与 `group.tool` 分组渲染(约 2300 行)之间的锚点。 ### 7.3 三块结构 1. **当前会话目录卡**:路径 + `[选择目录…]`;下方三行「生效人设 / 来源 / 操作」。 **「来源」一行是灵魂** —— 显示 `D:\项目A(上级目录继承)` 或 `全局配置`。没有它,用户在子目录里看到别的人设会以为是 bug。 2. **绑定列表**:按层级排序,用 `└` 前缀画出「一条绑定 = 一棵子树」;当前目录所在行高亮;每行「人设名下拉 + [解绑]」;失效人设标注并给出删除。 3. **未绑定回落行**:`未绑定的目录使用全局配置 · 🐋 苏格拉底式提问者 [改]` —— 把隐式行为显式写成一条可改的规则。 ### 7.4 操作入口 - **绑新目录默认点选**:`ctx.uiWorkspace.pickDirectory(): Promise`(`null` = 用户取消)。宿主客户端插件 `dsh-client-ui-workspace` 提供,本机(Windows + `127.0.0.1` 回环)走 native 后端,可调起系统选择器。 - **改绑**:列表行内 `