dsh-waifu-agents 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MAINTAINER.md CHANGED
@@ -87,6 +87,7 @@ updateCheck updateApply updateProgress purge
87
87
  - **非法 id 不进选择器**:`choosableRows()` 只收 `problems` 为空且 `idOk` 为真的行。
88
88
  - **条件样式必须把键写进基础样式,不能让 React 去"移除"。** 这一条看着像代码风格,其实是面板高亮的生死线。React 丢弃一个不再收到的样式属性时写的是 `style[name] = ''`;对普通属性这没问题(开关的 `transform` 就是这么被清掉的,实测正常),但对**简写里的属性不行**:清掉 `borderColor` 之后,`border: 1px solid …` 会把颜色重新应用一遍,而"颜色被清掉"的边框会回落到 `currentColor`。本主题下 `currentColor`(`--dsw-alias-label-primary`)和品牌色(`--dsw-alias-brand-primary` = `neutral-bluish-50`)是同一个近白,于是**一个已经不是默认的行看起来仍然选中**,只有重挂载才会好。这正是用户报的「多次切换默认人设后,选中过的 border 样式存在残留,再次进入就消失了」——而且只发生在**点过的**那些行上(点过才有机会被设过品牌色),没点过的行用的是显式的 `border-l1`。所以 `L.item` 里同时写明 `border` 和 `borderColor`/`borderStyle`,`L.switch` 写明 `borderColor`,`L.thumb` 写明 `transform: 'translateX(0)'`。自测里有一条按**键集合**遍历所有 `条件变体/基础` 配对的断言,加新的条件样式时它会替你拦住。
89
89
  - **会话顶部那个 chip 不是我们的,是 `projectionValues.agentPreset`。** 它由**唯一一种事件**推进:`agent-preset/selected`(`registry.js` 的 `apply` 只认这个),而写它的只有平台的 `select()`。`select()` 对**已开过轮**的会话会抛 `agent-preset/locked` —— 因为"真的换"意味着重挂载整套 preset、连工具一起换。所以 `/waifu-change` 追加**事件本身**:只动标签,不动正在跑的树;人设本来就有 override 在供,标签和模型听到的因此终于一致。两条硬约束:① 只能写**已注册**的 preset id(值会被持久化,恢复会话时按它挂载,写一个没人提供的 id 会让下次恢复失败);② 插件配置的 `syncSessionChip: false` 可以整条关掉(关的理由是这条通道同时决定恢复时挂载谁)。`/waifu-reset` 对应地把标签挪回"会话挂载的那一位,没有就回落到全局默认"。
90
+ - **"这个会话自己是谁"必须在下 override 的那一刻记下来,之后再读就读不到了。** 这是上一条的直接后果,也是 0.1.4 修的 bug(实测 `session-00777175`,2026-10-14 14:23:会话创建时戳的是当时全局默认 达克妮斯,随后在新建任务页改选 惠惠 —— 那是一次 `agent-preset/selected`,chip 因此显示 惠惠,第一轮也确实是 惠惠;`/waifu-change 达克妮斯` + `/waifu-reset` 之后却回到 达克妮斯)。原因不是读错了谁,而是**插件自己把 chip 写成了 override**(`rememberSessionPreset`,见上一条),reset 又把那个 chip 当"会话挂载"读了回来。一个会话其实有**三个**候选答案,一旦被改选过就互不相同:`header`(出生时戳的)/ 换人前的投影(它自己)/ 投影现在的值(override)。只有中间那个是它自己的,而下完写就没了。所以 `setOverride(sessionId, personaId, ownPreset)` 把**第一次**下 override 时读到的投影记进 `overrides.json` 的 `resting` 字段(第二次换人不覆盖它:那时 chip 已经是上一个 override,读回来只会是插件自己的上一次答案),`restoreSessionPreset(agent, resting)` 与 `/waifu-reset` 的落点都以它为准,读不到才退回 `header` 再退回全局默认。断言覆盖:换人记录的是"换之前在跑的那位"(**故意让 header 说别的**,否则断言跟实现对不上也看不出来)、reset 回到它、下一轮真的按它供、第二次换人不改这个答案、以及**标签挪不动时必须出声**(reset 文案追加「会话标签没跟上」,因为 chip 是恢复会话时决定挂载谁的持久值,静默失败会以「重启后怎么又是她」的形式复活)。
90
91
  - **宿主侧拿"还活着的 agent"只有这两个口子,已对着实现核实。** `@deepseek-ai/dsh-agent` 的服务名是 `agents`(`super(ctx, "agents")`),公开方法 `list()`("All live agents, in registration order",返回全新数组)、`get(id)`、`roots()`、`enter()`、`announce()`。`agent` 对象带 `.id` / `.ctx` / `.session`,而且 **`agent.id` 就是 session id**(`agent-preset-registry` 的 `select` 错误载荷里 `sessionId: agent.id`,`dsh-agent-loop` 也是 `agents.get(sessionId)`)。`agentPresets.select` 的远程绑定写着 `scope: { context: 'agent', wire: 'agentId' }` + 第一个参数 `lookup: 'agent'`——平台自己就是把 agentId 解析成 agent 再调 `select(agent, preset)`,所以本地直接调同一个方法形状是对的,**不需要绕 remote**。`sessionProjections` 的服务名是 `sessionProjections`,`stateOf(session, key)` 返回该投影当前值、未注册的 key 返回 `undefined`——这是读"chip 现在显示谁"的唯一正确方式(读 `session.header.agentPreset` 会错,见上条)。
91
92
  用 `_tools/asar-ls.mjs` 复核(Git Bash 下记得 `MSYS_NO_PATHCONV=1`,否则 asar 内部路径会被改写成 Windows 路径):
92
93
  ```bash
@@ -106,7 +107,7 @@ updateCheck updateApply updateProgress purge
106
107
  ## 测试与 CI
107
108
 
108
109
  ```bash
109
- pnpm test # = node selftest.mjs,317 条断言,离线、不碰 profile、不联网
110
+ pnpm test # = node selftest.mjs,323 条断言,离线、不碰 profile、不联网
110
111
  pnpm pack # 先看产物清单
111
112
  pnpm publish --no-git-checks
112
113
  ```
@@ -139,11 +140,12 @@ CI 在 `.github/workflows/test.yml`:push 到 `main`、开 PR、或手动触发
139
140
  15. **同一条会话里换人再问一句** → 口吻必须真的变了(这是整件事唯一的真证明);再打 `/waifu-current` → **只有一行**,且指明当前是谁。**接着连切三次不同的名字** → 每一次口吻都要跟着走(「切了但一直是同一个」是这套设计的头号失效模式)。再切**同一个人** → 必须回「本来就是 X · 没有改动」,不能回「已切换」。顺手确认旁路通道:换人之后每一轮的请求**尾部**应多出一条**用户角色**的「本轮身份 · 运行时确认」,没换过的会话不该有这条(有它 = `includeRuntimeContext` 没被改坏)。
140
141
  16. 打 `/waifu-change`(不带参数)→ 弹选择器,搜索能过滤、当前那位打勾;选一个 → 会话里出现一条「已切换」的记录。
141
142
  17. 先在「人设」工具条打开「拖拽排序」(关着的时候行内控件正常、拖动无效)→ 整行拖到另一行上 → 顺序变了;**开着的时候行内的展开 / 开关 / 导出 / 归档必须点不动**;关掉 DSH 再打开顺序还在;打开那个 `.md` 确认**正文没被动过**。
142
- 18. `/waifu-reset` → 回落到这个会话原本挂载的那一篇,**而且下一轮的口吻必须真的变回那一位** —— 这是本版修掉的一个真 bug(`session-a367789b`,2026-10-14 13:42):reset 走 `mounted` 层,初版把「换过人了」判成 `source === 'override'`,于是前缀与运行时快照**双双沉默**,模型继续用刚被 reset 掉的那位回答。验收点两条:① 请求尾部那条「本轮身份 · 运行时确认」快照必须**点名**回落到的这一位,并带一句「(上一轮还是「X」)」;② 下一轮的回答确实是那一位。附带:换过人的会话此后每轮都带 `【人设已切换】` 前缀(**有意为之**,旧版对 override 就是这样),别当成 bug。**已知没修**:重启 DSH 之后第一次装配不知道这个会话以前服务过谁,「重启 → 接着那个被 reset 的会话聊」这一轮没有快照可发。
143
- 19. **开一个新任务(别发消息)→ 看顶部 preset 下拉**:四个内置模式(standard / ptc / minimal / cordis)必须在**最上面四行**,自定义人设全在下面。这是 `PERSONA_ORDER_BASE` 唯一的验收点。
144
- 20. **默认开关连点**:把默认从 A 换到 B,再换到 C,**每换一次都留在面板里看**——高亮边框必须**当次**就落到新行上,不能还挂在旧行上等下次进来才自愈。然后开新任务,顶部下拉默认选中的必须是 C。
145
- 21. 把默认指向一个**正在被编辑、暂时组装不出来**的人设 → 设置页「新会话默认用谁」下面必须出现「这个默认指不到任何可用的人设」。
146
- 22. **改默认不该需要重启**:在插件面板把默认从 A 换成 B → **立刻**开一个新任务(别发消息)→ 顶部选择器默认选中的必须是 B。这是「写入有没有通知平台」唯一的验收点(`settings/document-updated`);走 `configEditor.edit()` 的旧路径这里会显示 A。
147
- 23. **切完看会话顶上那个标签**:在同一个会话里 `/waifu-change` 到另一位 → 会话标题旁边的标签必须**跟着变**。它不动就是 `agent-preset/selected` 没追加成功;把它写成 `syncSessionChip: false` 时标签**应该**保持不动。
148
- 24. **改默认要连"当前这个空会话"一起换**:让新任务页停在屏幕上别动 → 在插件面板把默认从 A 换成 B → **不要新建会话**,回来看新任务页顶上的选择器,必须已经变成 B。这一条和第 22 条**不是同一件事**:第 22 条考的是"之后新建的会话拿谁"(`selectedDefault`),这条考的是"已经摆在屏幕上的那个空会话"(`projectionValues.agentPreset`)。只修了第 22 条的版本,这里会停在 A——而且 Agent 预设页的「新任务默认」徽章**已经是 B**,两处对不上就是它。
149
- 25. **删掉人设文件 / 退掉默认那位,然后重启** —— 这是「悬空默认」的现场复现,也是这一版修复的验收点:① 把默认指向 A,**关掉 DSH**,把 `~/.dsh/personas/A.md` 从磁盘删掉(不走面板),重开 DSH → 新任务必须能正常建起来,**不能**出现 `agent-preset/not-found`;② 日志里应有一条 `WARN dsh-waifu-agents: 默认人设「A」已经不在名册里了…已清掉这条默认`;③ 打开插件面板 → 「新会话默认用谁」那行下面出现一条「已自动清掉」的说明(`defaultHealed*`),刷新一次面板后它消失;④ 面板里把默认设成 B → 在**面板里**退掉 B(归档)→ 默认必须跟着清掉,`~/.dsh/personas` 里出现 `B.md.off`,且下一次新任务不再失败。只有清了字段、没把屏上那个空会话一起挪走的版本,会在 ① 那里照样失败。
143
+ 18. `/waifu-reset` → 回落到这个会话**自己**的那一篇(落点规则见第 19 条),**而且下一轮的口吻必须真的变回那一位** —— 这是 0.1.3 修掉的一个真 bug(`session-a367789b`,2026-10-14 13:42):reset 走 `mounted` 层,初版把「换过人了」判成 `source === 'override'`,于是前缀与运行时快照**双双沉默**,模型继续用刚被 reset 掉的那位回答。验收点两条:① 请求尾部那条「本轮身份 · 运行时确认」快照必须**点名**回落到的这一位,并带一句「(上一轮还是「X」)」;② 下一轮的回答确实是那一位。附带:换过人的会话此后每轮都带 `【人设已切换】` 前缀(**有意为之**,旧版对 override 就是这样),别当成 bug。**已知没修**:重启 DSH 之后第一次装配不知道这个会话以前服务过谁,「重启 → 接着那个被 reset 的会话聊」这一轮没有快照可发。
144
+ 19. **reset 要回"这个会话自己的人设",不是"最后换到的那位"** —— 0.1.4 的验收点,现场三步:① 先在新任务页**改选一个人设**(别用全局默认那一位),发一句话确认第一轮是她;② `/waifu-change 另一位` → 变;③ `/waifu-reset` → 回的是 **①** 里那位,**不是**另一位,也不是全局默认。同一个会话再 `/waifu-change 第三位` + `/waifu-reset` → 仍然回 ① 那位。肉眼看得见的那半是**会话顶上那个标签**(`agent-preset/selected` 追加成功才会动);看不到的那半在 `~/.dsh/personas/overrides.json`:那条记录的 `resting` 必须是 ① 那位,而 `personaId` 随每次换人变。如果 reset 文案尾巴上多出一句「会话标签没跟上」,说明 chip 那次没挪动(`syncSessionChip: false` 或平台拒绝),这种情况下重启会话确实可能又变回那一位——**文案就是为这种情况准备的**,别当成误报。
145
+ 20. **开一个新任务(别发消息)→ 看顶部 preset 下拉**:四个内置模式(standard / ptc / minimal / cordis)必须在**最上面四行**,自定义人设全在下面。这是 `PERSONA_ORDER_BASE` 唯一的验收点。
146
+ 21. **默认开关连点**:把默认从 A 换到 B,再换到 C,**每换一次都留在面板里看**——高亮边框必须**当次**就落到新行上,不能还挂在旧行上等下次进来才自愈。然后开新任务,顶部下拉默认选中的必须是 C。
147
+ 22. 把默认指向一个**正在被编辑、暂时组装不出来**的人设 → 设置页「新会话默认用谁」下面必须出现「这个默认指不到任何可用的人设」。
148
+ 23. **改默认不该需要重启**:在插件面板把默认从 A 换成 B → **立刻**开一个新任务(别发消息)→ 顶部选择器默认选中的必须是 B。这是「写入有没有通知平台」唯一的验收点(`settings/document-updated`);走 `configEditor.edit()` 的旧路径这里会显示 A。
149
+ 24. **切完看会话顶上那个标签**:在同一个会话里 `/waifu-change` 到另一位 → 会话标题旁边的标签必须**跟着变**。它不动就是 `agent-preset/selected` 没追加成功;把它写成 `syncSessionChip: false` 时标签**应该**保持不动。
150
+ 25. **改默认要连"当前这个空会话"一起换**:让新任务页停在屏幕上别动 → 在插件面板把默认从 A 换成 B → **不要新建会话**,回来看新任务页顶上的选择器,必须已经变成 B。这一条和第 23 条**不是同一件事**:第 23 条考的是"之后新建的会话拿谁"(`selectedDefault`),这条考的是"已经摆在屏幕上的那个空会话"(`projectionValues.agentPreset`)。只修了第 22 条的版本,这里会停在 A——而且 Agent 预设页的「新任务默认」徽章**已经是 B**,两处对不上就是它。
151
+ 26. **删掉人设文件 / 退掉默认那位,然后重启** —— 这是「悬空默认」的现场复现,也是这一版修复的验收点:① 把默认指向 A,**关掉 DSH**,把 `~/.dsh/personas/A.md` 从磁盘删掉(不走面板),重开 DSH → 新任务必须能正常建起来,**不能**出现 `agent-preset/not-found`;② 日志里应有一条 `WARN dsh-waifu-agents: 默认人设「A」已经不在名册里了…已清掉这条默认`;③ 打开插件面板 → 「新会话默认用谁」那行下面出现一条「已自动清掉」的说明(`defaultHealed*`),刷新一次面板后它消失;④ 面板里把默认设成 B → 在**面板里**退掉 B(归档)→ 默认必须跟着清掉,`~/.dsh/personas` 里出现 `B.md.off`,且下一次新任务不再失败。只有清了字段、没把屏上那个空会话一起挪走的版本,会在 ① 那里照样失败。
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ![dsh-waifu-agents](icon.png)
4
4
 
5
- **把一个目录里的每个 `.md` 变成能对话的 agent 人设。** 建、改、导入导出、换默认、升级——全在 **设置 → 插件** 里点,不用敲命令。
5
+ **Waifu Agents** 是一款面向 **DSH** 的人设管理与动态切换插件,支持自定义 **Markdown** 人设、公共提示词配置、Agent Preset 管理、人设分组与排序、搜索、隐藏与恢复、默认人设设置,以及通过命令在下一轮对话中切换人设,让用户更灵活地定制和管理 AI 的角色、性格与回复风格。
6
6
 
7
7
  ## 它能做什么
8
8
 
@@ -24,7 +24,7 @@
24
24
  | --- | --- |
25
25
  | `/waifu-change` | **不带参数**:弹出选择器(可搜索、按分组、当前那位打勾)。**带名字**:`/waifu-change 大肥鱼` 直接切,名字可以只打前几个字 |
26
26
  | `/waifu-current` | 一句话告诉你这个会话现在是谁在回答 |
27
- | `/waifu-reset` | 清掉本会话的覆盖,回到它原本挂载的那一篇 |
27
+ | `/waifu-reset` | 清掉本会话的覆盖,回到这个会话**自己**的那一篇(也就是换人之前它在跑的那位,不是最后换到的那位) |
28
28
 
29
29
  > **「轮次级」是什么意思**:换人**从下一轮开始生效**,你正在打的那一句不动,已经发出去的回答也不会重写。但**下一轮**的请求会带上新人设,并且额外附一句运行时的身份确认,让模型彻底放下上一位——只改系统提示词是不够的。细节见[第四节](#四会话开到一半想换人三条命令)。
30
30
 
@@ -110,7 +110,7 @@ dsh-waifu-agents@0.1.0
110
110
  | --- | --- |
111
111
  | `/waifu-change` | **不带参数**:弹出选择器(可搜索、按分组、当前那位打勾)。**带名字**:`/waifu-change 大肥鱼` 直接切;名字可以只打前几个字,唯一就能对上,歧义会列出候选 |
112
112
  | `/waifu-current` | 一句话告诉你这个会话现在是谁在回答 |
113
- | `/waifu-reset` | 清掉本会话的覆盖,回到它原本挂载的那一篇 |
113
+ | `/waifu-reset` | 清掉本会话的覆盖,回到这个会话**自己**的那一篇(也就是换人之前它在跑的那位,不是最后换到的那位) |
114
114
 
115
115
  **怎么生效**
116
116
 
@@ -124,6 +124,7 @@ dsh-waifu-agents@0.1.0
124
124
  - **打 `@` 也能挑**:候选菜单里多一组人设,选中就把 `/waifu-change 名字` 填进输入框,回车即可。
125
125
  - **只列能用的人设**:你选中的那篇如果组装不出来,**不会替换**,这一轮仍然用原来那位。
126
126
  - **`/waifu-reset` 也算换人**:回落之后这一轮照样会收到「人设已切换」和那句运行时身份确认,所以模型不会继续用刚被 reset 掉的那位说话。(早先的版本会——它把「换过人」判成「这个会话被外挂覆盖过」,而 reset 回落的是会话挂载的那一位,于是刚好漏掉。)
127
+ - **`/waifu-reset` 回的是「这个会话自己的人设」**,不是「你最后换到的那位」。一个会话其实有三个候选答案:创建时戳的那位、你在新任务页改选的那位、以及你后来换到的那位——换过之后只有第二个是它自己的,而且下完那次换人之后它就从会话上读不到了(标签已经被插件挪成新人)。所以第一次换人时插件会把「这个会话原来在跑谁」记进 `overrides.json` 的 `resting`,reset 按它回;换了很多次也一样回那一位,除非你从来没换过(那就没什么可回)。
127
128
  - **会话顶上那个标签会跟着变**。以前它一直显示这个会话**创建时**挂的那一位,换了人也不动(那是平台的行为:给已经开过口的会话换挂载,等于把整套 preset 连同工具一起重装,平台会直接拒绝)。插件现在用平台自己的那条通道把标签挪过去,只动标签、不动正在跑的插件树——所以标签说的和模型听到的终于一致了。
128
129
  - **一个副作用要知道**:标签也是**会话恢复时**用来决定挂载谁的。所以换过人之后,下次打开 DSH 恢复这个会话,它会真的按新人设装配(工具集也跟着换)。如果你不希望这样,把插件配置里的 `syncSessionChip` 设成 `false`,标签就保持原样不动。
129
130
 
@@ -301,7 +302,7 @@ template: _common.md
301
302
  「拖拽排序」默认是关的(防误操作),在「**人设**」工具条打开。开着的时候整行可拖,行内的展开 / 开关 / 导出 / 归档会一起锁住——就是为了不让拖动半路变成误点。关掉立刻恢复。
302
303
 
303
304
  **装新版本装不上、或装完还是旧版本。**
304
- 用**精确版本号**装(如 `dsh-waifu-agents@0.1.2`)。装到磁盘 ≠ 正在运行,重启 DSH 才会换。
305
+ 用**精确版本号**装(如 `dsh-waifu-agents@0.1.3`)。装到磁盘 ≠ 正在运行,重启 DSH 才会换。
305
306
 
306
307
  **卸载插件会删掉我的人设吗?**
307
308
  不会。人设是普通文件,在「设置 → 目录来源」显示的目录里。
package/index.js CHANGED
@@ -876,7 +876,14 @@ export function apply(ctx, config = {}) {
876
876
  for (const [sessionId, value] of Object.entries(stored)) {
877
877
  const personaId = oneLine(value?.personaId);
878
878
  if (sessionId !== "" && personaId !== "")
879
- rows[sessionId] = { personaId, at: Number(value?.at) || 0 };
879
+ rows[sessionId] = {
880
+ personaId,
881
+ at: Number(value?.at) || 0,
882
+ // The preset this session runs with NO override, captured when the
883
+ // override was first laid down. Empty on a record written before
884
+ // this field existed — those fall back to the header.
885
+ resting: oneLine(value?.resting),
886
+ };
880
887
  }
881
888
  }
882
889
  } catch {
@@ -913,12 +920,55 @@ export function apply(ctx, config = {}) {
913
920
  return hit === undefined ? null : hit.personaId;
914
921
  }
915
922
 
916
- /** Point one session at one persona, or — with `null` — clear it. */
917
- function setOverride(sessionId, personaId) {
923
+ /**
924
+ * The preset this session runs with NO override, or `""` when nothing
925
+ * recorded it.
926
+ *
927
+ * Read by `/waifu-reset`, which is the one caller that has to know where the
928
+ * session came from. See {@link setOverride} for why it cannot be read off
929
+ * the session at reset time.
930
+ * @param {string} sessionId
931
+ * @returns {string} the recorded id, or `""`
932
+ */
933
+ function restingOf(sessionId) {
934
+ if (typeof sessionId !== "string" || sessionId === "") return "";
935
+ const hit = readOverrides()[sessionId];
936
+ return hit === undefined ? "" : oneLine(hit.resting);
937
+ }
938
+
939
+ /**
940
+ * Point one session at one persona, or — with `null` — clear it.
941
+ *
942
+ * `ownPreset` is what the session would run as with no override at all, read
943
+ * BEFORE this write. It is recorded for the FIRST override only, because that
944
+ * is the last moment the answer is still the session's own: every later switch
945
+ * runs after this plugin has already moved the session's chip
946
+ * ({@link rememberSessionPreset}) onto the previous override, so reading the
947
+ * chip then would only read back the plugin's own last answer — which is
948
+ * exactly the 「/waifu-reset 回到上一个换过的人」 bug this field exists to
949
+ * kill. A session that was re-picked on the new-task screen makes the point:
950
+ * its header still names the preset it was BORN with while its chip names the
951
+ * pick, and `/waifu-reset` owes the pick.
952
+ * @param {string} sessionId
953
+ * @param {string | null} personaId the override, or `null` to clear it
954
+ * @param {string} [ownPreset] the session's own preset, read before this write
955
+ * @returns {boolean} whether the record was written
956
+ */
957
+ function setOverride(sessionId, personaId, ownPreset) {
918
958
  if (typeof sessionId !== "string" || sessionId === "") return false;
919
959
  const rows = readOverrides();
920
960
  if (personaId === null || oneLine(personaId) === "") delete rows[sessionId];
921
- else rows[sessionId] = { personaId: oneLine(personaId), at: Date.now() };
961
+ else {
962
+ const previous = rows[sessionId];
963
+ const resting =
964
+ oneLine(previous?.resting) ||
965
+ (previous === undefined ? oneLine(ownPreset) : "");
966
+ rows[sessionId] = {
967
+ personaId: oneLine(personaId),
968
+ at: Date.now(),
969
+ ...(resting === "" ? {} : { resting }),
970
+ };
971
+ }
922
972
  writeOverrides();
923
973
  return true;
924
974
  }
@@ -1465,24 +1515,36 @@ export function apply(ctx, config = {}) {
1465
1515
 
1466
1516
  /**
1467
1517
  * Put the label back on what this session resolves to with no override: the
1468
- * preset it was mounted with, else the global default.
1518
+ * preset it runs with on its own, else the global default.
1469
1519
  *
1470
1520
  * `/waifu-reset` used to leave the chip naming the wife that had just been
1471
1521
  * dropped, which is the same lie in the other direction.
1472
1522
  *
1523
+ * Three answers exist for "the session's own preset", and they disagree as
1524
+ * soon as the session was re-picked on the new-task screen:
1525
+ *
1526
+ * - what the override record remembered (`ownPreset`, see `setOverride`) —
1527
+ * the only correct one, because the plugin's own chip-sync has since moved
1528
+ * the projection onto the override;
1529
+ * - the header, which names the preset the session was BORN with;
1530
+ * - the plugin's global default, when neither of the above is usable.
1531
+ *
1473
1532
  * @param {unknown} agent the live agent owning the session
1533
+ * @param {string} [ownPreset] the remembered own preset, best answer first
1474
1534
  * @returns {boolean} whether the label was moved
1475
1535
  */
1476
- function restoreSessionPreset(agent) {
1536
+ function restoreSessionPreset(agent, ownPreset) {
1477
1537
  if (config.syncSessionChip === false) return false;
1478
1538
  const session = agent?.session;
1479
1539
  if (session === undefined || session === null) return false;
1540
+ const remembered = oneLine(ownPreset);
1480
1541
  const mounted = oneLine(session.header?.agentPreset);
1481
- // A mounted id is whatever the session already had, so it is safe by
1482
- // construction. The default is only usable when it is one of ours.
1483
- const resting = mounted !== "" ? mounted : oneLine(cachedDefault.id);
1542
+ // A remembered or mounted id is whatever the session already had, so it is
1543
+ // safe by construction. The default is only usable when it is one of ours.
1544
+ const resting =
1545
+ remembered !== "" ? remembered : mounted !== "" ? mounted : oneLine(cachedDefault.id);
1484
1546
  if (resting === "") return false;
1485
- if (mounted === "" && !owned.has(resting)) return false;
1547
+ if (remembered === "" && mounted === "" && !owned.has(resting)) return false;
1486
1548
  try {
1487
1549
  session.append("agent-preset/selected", { agentPreset: resting });
1488
1550
  return true;
@@ -3919,11 +3981,14 @@ export function apply(ctx, config = {}) {
3919
3981
  error: `「${matched.id}」现在组装不出来(少了 [[body]]、或正文里混进了非法的 {{…}}),没有替换。`,
3920
3982
  };
3921
3983
  }
3922
- setOverride(sessionId, matched.id);
3984
+ // Both reads happen before the override is laid down: the chip still names
3985
+ // the session's own preset here, and this is the last moment it does.
3986
+ const live = liveAgentFor(sessionId);
3987
+ setOverride(sessionId, matched.id, anchorOf(live));
3923
3988
  // Same move as the command path: the persona is the override, the chip is
3924
3989
  // this event, and without it the label names whoever the session was born
3925
3990
  // with (see `rememberSessionPreset`).
3926
- rememberSessionPreset(liveAgentFor(sessionId), matched.id);
3991
+ rememberSessionPreset(live, matched.id);
3927
3992
  return { ok: true, id: matched.id, name: found.name };
3928
3993
  }
3929
3994
 
@@ -3935,8 +4000,10 @@ export function apply(ctx, config = {}) {
3935
4000
  error: "这个操作要知道是哪个会话,但没拿到 session id。",
3936
4001
  };
3937
4002
  const had = overrideOf(sessionId);
4003
+ // Read before the clear: clearing is what drops the record that holds it.
4004
+ const resting = restingOf(sessionId);
3938
4005
  setOverride(sessionId, null);
3939
- restoreSessionPreset(liveAgentFor(sessionId));
4006
+ restoreSessionPreset(liveAgentFor(sessionId), resting);
3940
4007
  await refreshDefault();
3941
4008
  return { ok: true, had, defaultId: cachedDefault.id };
3942
4009
  }
@@ -4243,7 +4310,7 @@ export function apply(ctx, config = {}) {
4243
4310
  };
4244
4311
  }
4245
4312
  const already = overrideOf(sessionId);
4246
- setOverride(sessionId, matched.id);
4313
+ setOverride(sessionId, matched.id, mountedOf(agent));
4247
4314
  // The override is what the model is told; this is what the row above the
4248
4315
  // conversation shows. Without it a running session keeps the label of the
4249
4316
  // wife it was born with (see `rememberSessionPreset`).
@@ -4316,6 +4383,12 @@ export function apply(ctx, config = {}) {
4316
4383
  `(会话挂载的「${mountedId}」已经不可用,所以回落到了全局默认。换人:/waifu-change 名字)`,
4317
4384
  );
4318
4385
  }
4386
+ // Where `/waifu-reset` would land, which is not the mounted id once this
4387
+ // session has been switched: the chip names the override by then.
4388
+ const home = restingOf(sessionId);
4389
+ if (override !== null && home !== "" && home !== found.id) {
4390
+ lines.push(`(清掉覆盖会回到「${home}」:/waifu-reset)`);
4391
+ }
4319
4392
  return { kind: "success", text: lines.join("\n") };
4320
4393
  },
4321
4394
  });
@@ -4323,16 +4396,21 @@ export function apply(ctx, config = {}) {
4323
4396
  ctx.commands.register({
4324
4397
  name: "waifu-reset",
4325
4398
  description:
4326
- "清掉这个会话的人设覆盖,回到它原本挂载的那一篇(那一篇没了就回落全局默认)。",
4399
+ "清掉这个会话的人设覆盖,回到这个会话自己的人设——也就是换人之前它在跑的那位(没记录就回到它出生时挂的那位,再不行回落全局默认)。",
4327
4400
  handler: async ({ agent }) => {
4328
4401
  const sessionId = sidOf(agent);
4329
4402
  if (sessionId === "")
4330
4403
  return { kind: "error", text: "这个命令只能在会话里用。" };
4331
4404
  const had = overrideOf(sessionId);
4405
+ // Read before the clear: clearing is what drops the record that holds it.
4406
+ // Without it the chip — which this plugin moved onto the override — is the
4407
+ // only thing left to read, and the session comes back as the LAST wife it
4408
+ // was switched to instead of the one it started this conversation as.
4409
+ const home = restingOf(sessionId);
4332
4410
  setOverride(sessionId, null);
4333
4411
  // Put the label back where the persona went: the chip has to stop naming a
4334
4412
  // wife this session no longer talks as.
4335
- restoreSessionPreset(agent);
4413
+ restoreSessionPreset(agent, home);
4336
4414
  await refreshDefault();
4337
4415
  if (had === null)
4338
4416
  return {
@@ -4340,15 +4418,27 @@ export function apply(ctx, config = {}) {
4340
4418
  text: "这个会话本来就没有覆盖,什么都没改。",
4341
4419
  };
4342
4420
  dropShadow(sessionId);
4343
- const mountedId = mountedOf(agent);
4421
+ const mountedId = home !== "" ? home : mountedOf(agent);
4344
4422
  const found = sessionPersona(sessionId, mountedId);
4345
4423
  const where =
4346
4424
  found.id === null
4347
4425
  ? "这个会话现在没有可用的人设"
4348
4426
  : `回到 ${found.name}(${SOURCE_LABEL[found.source] ?? found.source})`;
4427
+ /*
4428
+ * The chip is the persisted half of this: a session is re-mounted from
4429
+ * what its projection names, so leaving it on the dropped override would
4430
+ * bring her back on the next resume. If the append did not take (chip sync
4431
+ * off, or the platform refused it), say so instead of reporting the move
4432
+ * as done — the failure would otherwise resurface as 「重启后怎么又是她」.
4433
+ */
4434
+ const chip = mountedOf(agent);
4435
+ const drift =
4436
+ home !== "" && chip !== "" && chip !== home
4437
+ ? `\n(会话标签没跟上:现在还写着「${chip}」,重启这个会话可能又变回她)`
4438
+ : "";
4349
4439
  return {
4350
4440
  kind: "success",
4351
- text: `已清掉覆盖(之前是「${had}」)· ${where} · 从下一轮开始生效`,
4441
+ text: `已清掉覆盖(之前是「${had}」)· ${where} · 从下一轮开始生效${drift}`,
4352
4442
  };
4353
4443
  },
4354
4444
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-waifu-agents",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Registers every *.md in a persona directory as an agent preset, and manages them from the Plugins settings page.",
6
6
  "license": "MIT",
package/selftest.mjs CHANGED
@@ -1913,6 +1913,148 @@ console.log('--- the session chip follows a switch ---');
1913
1913
  );
1914
1914
  }
1915
1915
 
1916
+ console.log('--- a reset comes back to the session OWN preset, not the last one switched to ---');
1917
+ /*
1918
+ * Measured in `session-00777175` (2026-10-14 14:23). That session was stamped
1919
+ * 达克妮斯 at creation — the global default then — then re-picked as 惠惠 on the
1920
+ * new-task screen, which is an `agent-preset/selected` event and therefore what
1921
+ * the chip renders, and served its first turn as 惠惠. `/waifu-change 达克妮斯`
1922
+ * followed by `/waifu-reset` then landed back on 达克妮斯.
1923
+ *
1924
+ * Nothing read the session wrong: the plugin had moved the chip onto the
1925
+ * override itself (`rememberSessionPreset`, so the label stops naming a wife the
1926
+ * session no longer talks as) and the reset read that chip back as "what this
1927
+ * session mounts". A session has three candidate answers and they disagree the
1928
+ * moment it was re-picked:
1929
+ *
1930
+ * header (born with it) | projection before the switch (its own) | the override
1931
+ *
1932
+ * Only the middle one is the session's own, and after the write it is gone — so
1933
+ * `setOverride` records it. This is the case a header read cannot express, and
1934
+ * the reason the assertion cannot simply reuse the fixtures above.
1935
+ */
1936
+ {
1937
+ const projection = new Map([['home-session', 'ov-two']]);
1938
+ services = {
1939
+ sessionProjections: {
1940
+ stateOf: (session, key) =>
1941
+ key === 'agentPreset' ? projection.get(session.id) ?? null : undefined,
1942
+ },
1943
+ };
1944
+ const appended = [];
1945
+ const session = {
1946
+ id: 'home-session',
1947
+ // What it was BORN with — deliberately not what it is running as.
1948
+ header: { agentPreset: 'ov-one' },
1949
+ append: (type, data) => {
1950
+ appended.push({ type, data });
1951
+ if (type === 'agent-preset/selected') projection.set(session.id, data?.agentPreset);
1952
+ },
1953
+ };
1954
+ const agent = { session };
1955
+ const switched = await commands.get('waifu-change').handler({ agent, rawInput: 'ov-one' });
1956
+ check(
1957
+ 'the switch knows which persona the session ran as before it, born-with is not good enough',
1958
+ switched.kind === 'success' && projection.get('home-session') === 'ov-one',
1959
+ JSON.stringify({ switched, projection: projection.get('home-session') }),
1960
+ );
1961
+ appended.length = 0;
1962
+ const back = await commands.get('waifu-reset').handler({ agent });
1963
+ check(
1964
+ 'so the reset returns to that persona, not to the last one switched to',
1965
+ back.kind === 'success' &&
1966
+ appended.length === 1 &&
1967
+ appended[0].data?.agentPreset === 'ov-two' &&
1968
+ projection.get('home-session') === 'ov-two',
1969
+ JSON.stringify({ back, appended, projection: projection.get('home-session') }),
1970
+ );
1971
+ check(
1972
+ 'and the next turn is served as her, so the label and the prompt agree',
1973
+ String(variables.get('waifu_persona')?.({ agent }) ?? '').includes('第二个覆盖用的正文。'),
1974
+ JSON.stringify(String(variables.get('waifu_persona')?.({ agent }) ?? '').slice(0, 120)),
1975
+ );
1976
+ /*
1977
+ * A switch that is never undone must not re-read the chip either: the second
1978
+ * answer would be the first override, so the session would come home to a wife
1979
+ * it was merely passing through.
1980
+ */
1981
+ await commands.get('waifu-change').handler({ agent, rawInput: 'ov-two' });
1982
+ appended.length = 0;
1983
+ const again = await commands.get('waifu-reset').handler({ agent });
1984
+ check(
1985
+ 'a second switch keeps the first answer, so reset still comes home',
1986
+ again.kind === 'success' &&
1987
+ appended.length === 1 &&
1988
+ appended[0].data?.agentPreset === 'ov-two',
1989
+ JSON.stringify({ again, appended }),
1990
+ );
1991
+ /*
1992
+ * The chip is the persisted half of the reset: a session resumes as what its
1993
+ * projection names, so a chip left on the dropped override brings her back.
1994
+ * When the append does not take, the answer must say the move did not stick
1995
+ * rather than report success — the failure would otherwise resurface as
1996
+ * 「重启之后怎么又是她」, with nothing on screen to explain it.
1997
+ */
1998
+ projection.set('flaky-session', 'ov-two');
1999
+ let writes = 0;
2000
+ const flaky = {
2001
+ id: 'flaky-session',
2002
+ header: { agentPreset: 'ov-two' },
2003
+ append: (type, data) => {
2004
+ if (++writes > 1) throw new Error('agent-preset/locked');
2005
+ if (type === 'agent-preset/selected') projection.set('flaky-session', data?.agentPreset);
2006
+ },
2007
+ };
2008
+ await commands.get('waifu-change').handler({ agent: { session: flaky }, rawInput: 'ov-one' });
2009
+ const refusedReset = await commands.get('waifu-reset').handler({ agent: { session: flaky } });
2010
+ check(
2011
+ 'a chip that refused to move back is reported, not papered over',
2012
+ refusedReset.kind === 'success' &&
2013
+ refusedReset.text.includes('标签没跟上') &&
2014
+ projection.get('flaky-session') === 'ov-one',
2015
+ JSON.stringify(refusedReset),
2016
+ );
2017
+ services = {};
2018
+ }
2019
+
2020
+ /*
2021
+ * The panel drives the same pair through the HTTP route, and it is the way most
2022
+ * switches actually happen — so the answer has to be recorded there too, not only
2023
+ * in the command. The route reaches the live agent through the `agents` service,
2024
+ * which is the one thing the command path gets handed for free.
2025
+ */
2026
+ {
2027
+ const projection = new Map([['panel-session', 'ov-two']]);
2028
+ const appended = [];
2029
+ const session = {
2030
+ id: 'panel-session',
2031
+ header: { agentPreset: 'ov-one' },
2032
+ append: (type, data) => {
2033
+ appended.push({ type, data });
2034
+ if (type === 'agent-preset/selected') projection.set(session.id, data?.agentPreset);
2035
+ },
2036
+ };
2037
+ services = {
2038
+ sessionProjections: {
2039
+ stateOf: (one, key) =>
2040
+ key === 'agentPreset' ? projection.get(one.id) ?? null : undefined,
2041
+ },
2042
+ agents: { list: () => [{ id: 'panel-session', session }] },
2043
+ };
2044
+ const applied = await call({ op: 'waifuApply', sessionId: 'panel-session', personaId: 'OvOne' });
2045
+ appended.length = 0;
2046
+ const cleared = await call({ op: 'waifuClear', sessionId: 'panel-session' });
2047
+ check(
2048
+ 'the panel comes home the same way the command does',
2049
+ applied.ok === true &&
2050
+ cleared.ok === true &&
2051
+ appended.length === 1 &&
2052
+ appended[0].data?.agentPreset === 'ov-two',
2053
+ JSON.stringify({ applied, cleared, appended }),
2054
+ );
2055
+ services = {};
2056
+ }
2057
+
1916
2058
  console.log('--- drag to reorder ---');
1917
2059
  check('reorderIds moves one id in front of another', reorderIds(['a', 'b', 'c'], 'c', 'a').join(',') === 'c,a,b');
1918
2060
  check('and moving it backwards works the same way', reorderIds(['a', 'b', 'c'], 'a', 'c').join(',') === 'b,a,c');