dsh-waifu-agents 0.1.0 → 0.1.2

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
@@ -66,7 +66,20 @@ updateCheck updateApply updateProgress purge
66
66
  - **原子写**:`writeAtomic()` = 临时文件 + `rename`,覆盖前留 `<name>.bak`。
67
67
  - **缓存**:`(mtimeMs, size)` 键;`chars` 是「去掉 front matter 并 trim 之后的 JS 字符串长度」,和字节数没有稳定换算。
68
68
  - **`list` 不带绝对路径**:它给 `id / idOk / name / order / description / group / template / chars / charsFull / problems / owned / mtimeMs / size`。
69
- - **`roster` 多带一个 `defaultResolves`(三态)**:`null` = 没有任何面报出默认值,**不知道**;否则 = `presets.some(r => r.id === defaultId)`。平台的新任务页 chip 是 `presets.find(p => p.isDefault)?.id ?? presets[0].id`(`ui-agent-preset`),所以默认指不到人时它会**静默**起在第一行——那个旗标是唯一能把这件事说出口的地方。`null` 不能压成 `false`:那会冤枉一个单纯什么都没设的 profile。客户端的 `defaultResolves` state 同样是三态,设置页只在 `=== false` 时报警。
69
+ - **`roster` 多带一个 `defaultResolves`(三态)**:`null` = 没有任何面报出默认值,**不知道**;否则 = `presets.some(r => r.id === defaultId)`。`null` 不能压成 `false`:那会冤枉一个单纯什么都没设的 profile。客户端的 `defaultResolves` state 同样是三态,设置页只在 `=== false` 时报警。
70
+ ⚠️ **这条旗标曾经把后果写反过,别照着旧注释改回去。** 旧文案(注释和 `defaultUnresolved` 都是)说「默认指不到人 → 新会话悄悄落到列表第一项」,依据是 composer 的 `fallback = presets.find(p => p.isDefault)?.id ?? presets[0]?.id`(`ui-agent-preset.js:1263`)。那是**渲染层**的兜底,永远轮不到它跑:服务层先解析,`retain(id)` 直接抛
71
+ ```js
72
+ // registry.js:494 —— 默认就是 selectedDefault ?? config.default
73
+ get defaultId() { return this.config.selectedDefault.get() ?? this.config.default; }
74
+ // registry.js:643(resolve / readDocument 同样)
75
+ throw new RemoteError("agent-preset/not-found", `Unknown agent preset: ${wanted}`, { agentPreset: wanted, available: [...] });
76
+ ```
77
+ 所以真实后果是**每一个新会话都被拒绝**(用户看到的那条 `新建会话失败:agent-preset/not-found: Unknown agent preset: 照桥心美`),不是静默回落到第一行。写这条断言时别用「回落」这个词——它会把一个 `P0` 级故障描述成显示问题。
78
+ - **悬空的默认是自愈的,不是只报告。** `reconcileDefault()` 在三个地方跑:`status()`(op `list`,也就是**打开面板就修**)、`remove()`(退掉的人设正好是默认那位)、以及启动时 `scan()` 之后的 `.then()`。判定是「`selectedDefault` 有值,但 `agentPresets.list()` 里没有这个 id」;动作只有**清掉**(`moveDefaultOff`),**绝不替用户改指到另一个妻子**——清空会回落到 profile 的 `default`,替用户选人则是另一件更响的事。两个必须保留的细节:
79
+ - **名册为空时不动**(`rows.length === 0` → 「名册还是空的,先不动」)。别的插件在**它自己的** `apply` 里注册 preset,本行完全可能先被激活;此时读名册会把自己还没注册的 id 判成悬空。这正是启动那一跳要 `.then()` 挂在 `scan()` **之后**的原因——先让本插件自己的人设进名册。空名册守卫是同一件事的兜底。
80
+ - **`moveDefaultOff` 是两步,和 `setDefault` 一样**:`writeDefault`/`verifyDefault` 走 `settings` 服务并**重读确认**,然后 `resettleUnstarted` 把还挂在那个死 id 上、没开口的会话挪走。只清字段的话,屏上那个空会话的 header 还钉着死 id,chip 照样是坏的,建会话照样失败。
81
+ - 修复失败**必须出声**:`status()` 回传 `defaultHeal: { ok: false, from, reason }`,面板用 `defaultHealFailed` 显示(此时新会话仍然起不来,是唯一不能静默的一条)。成功回传 `{ ok: true, from, nowDefault, moved }`。`remove()` 回传 `defaultFollowed: 'cleared' | 'unchanged' | 'failed'`。
82
+ - **`remove()` 现在是第二个会改默认的写路径**(第一个是 `rename()` 的 `repointDefault`)。退掉的正好是默认那位时,不清默认就留下一个悬空 id,等于「删人设 = 下次新会话失败」。爆炸半径同样压住:退的不是默认那位时**一个字节都不写**(断言 `defaultFollowed === 'unchanged'`)。
70
83
  - **非法 id 不进选择器**:`choosableRows()` 只收 `problems` 为空且 `idOk` 为真的行。
71
84
  - **条件样式必须把键写进基础样式,不能让 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)'`。自测里有一条按**键集合**遍历所有 `条件变体/基础` 配对的断言,加新的条件样式时它会替你拦住。
72
85
  - **会话顶部那个 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` 对应地把标签挪回"会话挂载的那一位,没有就回落到全局默认"。
@@ -89,7 +102,7 @@ updateCheck updateApply updateProgress purge
89
102
  ## 测试与 CI
90
103
 
91
104
  ```bash
92
- pnpm test # = node selftest.mjs,296 条断言,离线、不碰 profile、不联网
105
+ pnpm test # = node selftest.mjs,313 条断言,离线、不碰 profile、不联网
93
106
  pnpm pack # 先看产物清单
94
107
  pnpm publish --no-git-checks
95
108
  ```
@@ -125,7 +138,8 @@ CI 在 `.github/workflows/test.yml`:push 到 `main`、开 PR、或手动触发
125
138
  18. `/waifu-reset` → 回落到这个会话原本挂载的那一篇。
126
139
  19. **开一个新任务(别发消息)→ 看顶部 preset 下拉**:四个内置模式(standard / ptc / minimal / cordis)必须在**最上面四行**,自定义人设全在下面。这是 `PERSONA_ORDER_BASE` 唯一的验收点。
127
140
  20. **默认开关连点**:把默认从 A 换到 B,再换到 C,**每换一次都留在面板里看**——高亮边框必须**当次**就落到新行上,不能还挂在旧行上等下次进来才自愈。然后开新任务,顶部下拉默认选中的必须是 C。
128
- 21. 把默认指向一个**正在被编辑、暂时组装不出来**的人设 → 设置页「新会话默认用谁」下面必须出现「这个默认指不到任何可用的人设」,而不是安安静静地回落。
141
+ 21. 把默认指向一个**正在被编辑、暂时组装不出来**的人设 → 设置页「新会话默认用谁」下面必须出现「这个默认指不到任何可用的人设」。
129
142
  22. **改默认不该需要重启**:在插件面板把默认从 A 换成 B → **立刻**开一个新任务(别发消息)→ 顶部选择器默认选中的必须是 B。这是「写入有没有通知平台」唯一的验收点(`settings/document-updated`);走 `configEditor.edit()` 的旧路径这里会显示 A。
130
143
  23. **切完看会话顶上那个标签**:在同一个会话里 `/waifu-change` 到另一位 → 会话标题旁边的标签必须**跟着变**。它不动就是 `agent-preset/selected` 没追加成功;把它写成 `syncSessionChip: false` 时标签**应该**保持不动。
131
144
  24. **改默认要连"当前这个空会话"一起换**:让新任务页停在屏幕上别动 → 在插件面板把默认从 A 换成 B → **不要新建会话**,回来看新任务页顶上的选择器,必须已经变成 B。这一条和第 22 条**不是同一件事**:第 22 条考的是"之后新建的会话拿谁"(`selectedDefault`),这条考的是"已经摆在屏幕上的那个空会话"(`projectionValues.agentPreset`)。只修了第 22 条的版本,这里会停在 A——而且 Agent 预设页的「新任务默认」徽章**已经是 B**,两处对不上就是它。
145
+ 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`,且下一次新任务不再失败。只有清了字段、没把屏上那个空会话一起挪走的版本,会在 ① 那里照样失败。
package/client.js CHANGED
@@ -174,7 +174,11 @@ window.__ModuleLoader__.load({
174
174
  defaultUnchecked: '写进去了,但这个 profile 读不回来确认(既没有 settings,名册里也没有 isDefault)。这一行没报错,可也没人能担保平台认它。',
175
175
  defaultReseated: '也把当前这个还没开口的会话换过去了——新任务页顶上现在就是她。',
176
176
  defaultViaEditor: '警告:这次没走 settings 服务,退回配置编辑器写的。磁盘上是对的,但平台收不到通知,新任务页可能不跟——看日志里那条 warn。',
177
- defaultUnresolved: '这个默认指不到任何可用的人设——新会话会悄悄落到列表第一项。重新点一次开关,或者换一个。',
177
+ defaultUnresolved: '这个默认指不到任何可用的人设——新建会话会直接失败(agent-preset/not-found),不是悄悄回落。插件会把它清掉,让新会话回落到 profile 默认。',
178
+ defaultHealed: '默认「{from}」已经不在名册里了——留着它新建会话会被 agent-preset/not-found 直接挡掉。已自动清掉,新会话现在用「{to}」。',
179
+ defaultHealedNobody: '默认「{from}」已经不在名册里了——留着它新建会话会被 agent-preset/not-found 直接挡掉。已自动清掉,新会话回落到 profile 默认。',
180
+ defaultHealedMoved: ' 还没开口的会话也一起换过去了。',
181
+ defaultHealFailed: '默认「{from}」已经不在名册里了,新建会话会被 agent-preset/not-found 直接挡掉——但这次没能自动清掉:{reason}。去「设置 → Agent Presets」重新选一个。',
178
182
  diagnostics: '诊断',
179
183
  defaultReadFrom: '默认值读自',
180
184
  srcRemoteExportList: '会话预置清单',
@@ -338,7 +342,11 @@ window.__ModuleLoader__.load({
338
342
  defaultUnchecked: 'The write went through, but this profile cannot read the field back to confirm it (no `settings`, and the roster carries no `isDefault`). Nothing errored — and nothing can vouch that the platform agrees.',
339
343
  defaultReseated: 'The session already open on the new-task screen was moved too — its chip names her now.',
340
344
  defaultViaEditor: 'Warning: this went through the config editor instead of the settings service. The disk is right, but the platform was not told, so the new-task screen may not follow — see the warning in the log.',
341
- defaultUnresolved: 'This default points at no usable persona, so a new session silently falls back to the first row. Toggle it again, or pick another.',
345
+ defaultUnresolved: 'This default points at no usable persona, so a new session fails outright with agent-preset/not-found — it does NOT fall back to the first row. The plugin clears it, and new sessions go back to the profile default.',
346
+ defaultHealed: 'The default "{from}" is no longer in the roster, so new sessions would be refused with agent-preset/not-found. It has been cleared; new sessions now use "{to}".',
347
+ defaultHealedNobody: 'The default "{from}" is no longer in the roster, so new sessions would be refused with agent-preset/not-found. It has been cleared, and new sessions fall back to the profile default.',
348
+ defaultHealedMoved: ' The sessions that had not opened a turn were moved with it.',
349
+ defaultHealFailed: 'The default "{from}" is no longer in the roster, so new sessions are refused with agent-preset/not-found — and clearing it did not work: {reason}. Pick another one under Settings → Agent Presets.',
342
350
  diagnostics: 'Diagnostics',
343
351
  defaultReadFrom: 'default read from',
344
352
  srcRemoteExportList: 'session preset list',
@@ -1489,6 +1497,32 @@ window.__ModuleLoader__.load({
1489
1497
  return h('span', { style: L.meta }, key === undefined ? String(source ?? '—') : t(key));
1490
1498
  }
1491
1499
 
1500
+ /**
1501
+ * The one-line report for a default the Host had to repair.
1502
+ *
1503
+ * A default that names a preset nothing registers is not cosmetic: the
1504
+ * registry refuses EVERY new session with `agent-preset/not-found`
1505
+ * (`registry.js:643`), so this has to be said even though the user did not ask
1506
+ * — and it has to distinguish 「修好了」 from 「没修掉」, because only the second
1507
+ * one leaves new sessions still refusing to open.
1508
+ * @param {{ ok: boolean, from?: string, nowDefault?: string | null, moved?: string[], reason?: string } | null | undefined} heal
1509
+ * @param {(key: string) => string} t
1510
+ */
1511
+ function healNote(heal, t) {
1512
+ if (heal === null || heal === undefined) return null;
1513
+ const from = heal.from ?? '';
1514
+ if (heal.ok !== true)
1515
+ return t('defaultHealFailed')
1516
+ .replace('{from}', from)
1517
+ .replace('{reason}', heal.reason ?? '');
1518
+ const to = typeof heal.nowDefault === 'string' ? heal.nowDefault : '';
1519
+ const base =
1520
+ to === ''
1521
+ ? t('defaultHealedNobody').replace('{from}', from)
1522
+ : t('defaultHealed').replace('{from}', from).replace('{to}', to);
1523
+ return (heal.moved ?? []).length > 0 ? base + t('defaultHealedMoved') : base;
1524
+ }
1525
+
1492
1526
  function SettingsTab({
1493
1527
  data,
1494
1528
  t,
@@ -1598,13 +1632,27 @@ window.__ModuleLoader__.load({
1598
1632
  h('span', { style: L.meta }, data?.defaultId ?? '—'),
1599
1633
  h(Btn, { variant: 'toolbar', disabled: busy || data?.defaultId == null, onClick: onClearDefault }, t('clearDefault')),
1600
1634
  ),
1601
- // A default that resolves to no live row is the one failure the platform
1602
- // never reports: the registry quietly falls back to the profile default,
1603
- // and the new-task chip stages the first row instead. The setting page is
1604
- // the only place this can be said out loud, so say it here.
1635
+ /*
1636
+ * A default that resolves to no live row is the failure the platform never
1637
+ * reports: the service throws `agent-preset/not-found` at session creation
1638
+ * (`registry.js:643`) — the composer's 「落到第一项」 fallback is never
1639
+ * reached — so every new task is refused with nothing on screen to explain
1640
+ * it. The Host clears it on open; these two lines are the report.
1641
+ */
1605
1642
  defaultResolves === false
1606
1643
  ? h('div', { style: L.card }, h('span', { style: L.error }, t('defaultUnresolved')))
1607
1644
  : null,
1645
+ data?.defaultHeal != null
1646
+ ? h(
1647
+ 'div',
1648
+ { style: L.card },
1649
+ h(
1650
+ 'span',
1651
+ { style: data.defaultHeal.ok === true ? L.ok : L.error },
1652
+ healNote(data.defaultHeal, t),
1653
+ ),
1654
+ )
1655
+ : null,
1608
1656
  h(
1609
1657
  SettingRow,
1610
1658
  { title: t('paging'), hint: t('pagingHint') },
package/index.js CHANGED
@@ -1940,8 +1940,14 @@ export function apply(ctx, config = {}) {
1940
1940
  * Panel state. `defaultId` comes from the LIVE roster rather than from the
1941
1941
  * config file: `selectedDefault` can be stale, or point at a preset that no
1942
1942
  * longer resolves, and the roster is what actually decides the next session.
1943
+ *
1944
+ * Opening the panel is also a repair point — see `reconcileDefault`, which runs
1945
+ * first here. A default that names nothing does not merely read wrong: the
1946
+ * registry refuses every new session with `agent-preset/not-found`, so leaving
1947
+ * the panel to only REPORT it would mean reporting a broken app.
1943
1948
  */
1944
1949
  async function status() {
1950
+ const healed = await reconcileDefault();
1945
1951
  // Live, not straight from the cache: `selectedDefault` can be stale or point
1946
1952
  // at a preset that no longer resolves, and the roster is what actually decides
1947
1953
  // the next session. Reporting the cached id is how the default's active border
@@ -1959,6 +1965,24 @@ export function apply(ctx, config = {}) {
1959
1965
  defaultId: cachedDefault.id,
1960
1966
  defaultSource: cachedDefault.source,
1961
1967
  defaultError: cachedDefault.error,
1968
+ /*
1969
+ * What the repair did, in the one case where the user has to know: the
1970
+ * default they set is gone. `null` when there was nothing to repair.
1971
+ * `ok: false` is the degrade path and it has to be visible — a default that
1972
+ * names nothing still blocks every new session, so a silent failure here
1973
+ * would look exactly like the bug it is supposed to fix.
1974
+ */
1975
+ defaultHeal:
1976
+ healed.healed !== null
1977
+ ? {
1978
+ ok: true,
1979
+ from: healed.healed,
1980
+ nowDefault: healed.nowDefault ?? null,
1981
+ moved: healed.moved ?? [],
1982
+ }
1983
+ : healed.from === undefined
1984
+ ? null
1985
+ : { ok: false, from: healed.from, reason: healed.reason },
1962
1986
  skeleton: {
1963
1987
  present: skeleton !== null,
1964
1988
  file: skeleton === null ? path.join(dir, SKELETON_FILE) : skeleton.file,
@@ -2333,7 +2357,26 @@ export function apply(ctx, config = {}) {
2333
2357
  const archived = nextArchiveName(id);
2334
2358
  const wasRegistered = await unregister(id);
2335
2359
  fs.renameSync(full, archived);
2336
- return { ok: true, id, file: full, archived, wasRegistered };
2360
+ /*
2361
+ * Retiring the wife the default names used to leave `selectedDefault` pointing
2362
+ * at nothing, and the registry then refuses EVERY new session with
2363
+ * `agent-preset/not-found` (`registry.js:643`) — so the one operation that
2364
+ * deliberately removes a persona was also the one that could break the next
2365
+ * new task, with the archived file sitting right there as if all were well.
2366
+ * `rename()` has followed its id for a while (`repointDefault`); removing had
2367
+ * no such step.
2368
+ *
2369
+ * Best effort by design: the file is already archived and that must not be
2370
+ * undone because the config write failed, so the outcome is reported instead.
2371
+ */
2372
+ const dropped = await moveDefaultOff(id);
2373
+ const follow =
2374
+ dropped.ok === true
2375
+ ? { defaultFollowed: "cleared", defaultCleared: true }
2376
+ : dropped.skipped === true
2377
+ ? { defaultFollowed: "unchanged" }
2378
+ : { defaultFollowed: "failed", defaultError: dropped.reason };
2379
+ return { ok: true, id, file: full, archived, wasRegistered, ...follow };
2337
2380
  }
2338
2381
 
2339
2382
  /**
@@ -2977,6 +3020,155 @@ export function apply(ctx, config = {}) {
2977
3020
  }
2978
3021
  }
2979
3022
 
3023
+ /**
3024
+ * Move the default off one id, and take the unstarted sessions with it.
3025
+ *
3026
+ * The counterpart of `setDefault` for the case where the id being named can no
3027
+ * longer be used. The registry resolves the default at session creation —
3028
+ * `get defaultId() { return this.config.selectedDefault.get() ?? this.config.default }`
3029
+ * (`registry.js:494`) — and `retain()` throws
3030
+ *
3031
+ * agent-preset/not-found: Unknown agent preset: <id>
3032
+ *
3033
+ * (`registry.js:643`, same in `resolve()` and `readDocument()`) when nothing
3034
+ * holds it. So a dangling default is not a display problem that sorts itself
3035
+ * out: it blocks EVERY new session, and the refusal comes from the service, so
3036
+ * the composer's own `presets.find(p => p.isDefault)?.id ?? presets[0]?.id`
3037
+ * fallback (`ui-agent-preset.js:1263`) never gets a chance to run.
3038
+ *
3039
+ * Same two steps as `setDefault`, for the same reasons: write through the
3040
+ * settings service so the app re-reads the roster, confirm it by REREADING
3041
+ * rather than by believing the write, and re-seat the blank sessions that were
3042
+ * still following the id being dropped.
3043
+ *
3044
+ * @param {string} from the id the default is expected to hold right now
3045
+ * @param {{ before?: object }} [context] a `refreshDefault({force:true})` the
3046
+ * caller already did, so this does not have to repeat the read
3047
+ * @returns {Promise<{ ok: boolean, reason: string | null, skipped?: boolean,
3048
+ * nowDefault?: string | null, via?: string, moved?: string[], refused?: number }>}
3049
+ */
3050
+ async function moveDefaultOff(from, context = {}) {
3051
+ const wanted = oneLine(from);
3052
+ if (wanted === "")
3053
+ return { ok: false, skipped: true, reason: "没有指定默认" };
3054
+ const before = context.before ?? (await refreshDefault({ force: true }));
3055
+ if (oneLine(before.id) !== wanted)
3056
+ return { ok: false, skipped: true, reason: "默认已经不是它了" };
3057
+ const configEditor = ctx.get("configEditor");
3058
+ if (configEditor === undefined || configEditor === null)
3059
+ return {
3060
+ ok: false,
3061
+ reason: "这个环境没有 configEditor 服务,改不了配置。",
3062
+ };
3063
+ let entry;
3064
+ try {
3065
+ entry = findRegistryEntry(configEditor);
3066
+ } catch (error) {
3067
+ return { ok: false, reason: `读不到配置项:${error.message ?? error}` };
3068
+ }
3069
+ if (entry === undefined)
3070
+ return { ok: false, reason: `找不到 ${REGISTRY_PACKAGE} 这条配置项` };
3071
+ const written = await writeDefault({ configEditor, entry, id: null });
3072
+ if (!written.ok) return { ok: false, reason: `清不掉:${written.error}` };
3073
+ const settled = await verifyDefault(null, {
3074
+ previous: before.field,
3075
+ checkable: before.field !== undefined,
3076
+ });
3077
+ if (!settled.verified && settled.checkable)
3078
+ return {
3079
+ ok: false,
3080
+ reason: `写进去了,但读回来还是「${settled.id ?? "(没指定)"}」`,
3081
+ };
3082
+ /*
3083
+ * The field decides what a session created LATER gets; this decides what the
3084
+ * session already sitting on the new-task screen gets. Without it the blank
3085
+ * session keeps the dead id its header was stamped with — a chip naming a
3086
+ * wife that no longer exists, and a creation path that fails for the same
3087
+ * reason the default did.
3088
+ */
3089
+ const resettled = await resettleUnstarted({
3090
+ to: oneLine(cachedDefault.id),
3091
+ from: wanted,
3092
+ });
3093
+ return {
3094
+ ok: true,
3095
+ reason: null,
3096
+ nowDefault: cachedDefault.id,
3097
+ via: written.via,
3098
+ moved: resettled.moved,
3099
+ refused: resettled.refused,
3100
+ };
3101
+ }
3102
+
3103
+ /**
3104
+ * Clear a `selectedDefault` that names a preset nothing registers.
3105
+ *
3106
+ * Run from `status()` (so opening the panel heals), from the boot scan, and
3107
+ * from `remove()`. Each of those is a moment where the default can be seen to
3108
+ * be dead, and the failure it causes is the worst this plugin can produce:
3109
+ * every new session refused with `agent-preset/not-found`, with nothing in the
3110
+ * UI to explain it and nothing to click to get out.
3111
+ *
3112
+ * It only ever clears — never re-points at another wife. Choosing a persona on
3113
+ * the user's behalf would be a different, louder decision than undoing a broken
3114
+ * one, and the profile default is what the field falls back to.
3115
+ *
3116
+ * @returns {Promise<{ healed: string | null, from?: string, reason: string | null,
3117
+ * nowDefault?: string | null, moved?: string[], refused?: number, via?: string }>}
3118
+ */
3119
+ async function reconcileDefault() {
3120
+ const before = await refreshDefault({ force: true });
3121
+ const picked = oneLine(before.id);
3122
+ if (picked === "")
3123
+ return { healed: null, reason: "没指定默认" };
3124
+ let rows;
3125
+ try {
3126
+ rows = await ctx.agentPresets.list();
3127
+ } catch (error) {
3128
+ return { healed: null, reason: `读不到名册:${error?.message ?? error}` };
3129
+ }
3130
+ if (!Array.isArray(rows)) return { healed: null, reason: "名册没回数组" };
3131
+ /*
3132
+ * An empty roster is 「还没人注册」, not 「谁都指不到」. A plugin that registers
3133
+ * its presets after this row was applied would otherwise have its default
3134
+ * cleared out from under it for no reason — and the cost of waiting is nil,
3135
+ * because `status()` runs this again every time the panel opens.
3136
+ */
3137
+ if (rows.length === 0)
3138
+ return { healed: null, reason: "名册还是空的,先不动" };
3139
+ if (rows.some((row) => oneLine(row?.id) === picked))
3140
+ return { healed: null, reason: "默认指的这个人设还在" };
3141
+ const dropped = await moveDefaultOff(picked, { before });
3142
+ if (!dropped.ok) {
3143
+ // Say it out loud: a default that names nothing blocks every new session,
3144
+ // and failing to repair it silently is exactly how this looked before —
3145
+ // like nothing was wrong until the next new task refused to open.
3146
+ ctx.logger?.warn?.(
3147
+ `dsh-waifu-agents: 默认人设「${picked}」已经不在名册里了(新会话会被 ` +
3148
+ `agent-preset/not-found 直接挡掉),可是清不掉它:${dropped.reason}`,
3149
+ );
3150
+ return { healed: null, from: picked, reason: dropped.reason };
3151
+ }
3152
+ const now = oneLine(dropped.nowDefault);
3153
+ ctx.logger?.warn?.(
3154
+ `dsh-waifu-agents: 默认人设「${picked}」已经不在名册里了(新会话会被 ` +
3155
+ `agent-preset/not-found 直接挡掉),已清掉这条默认,新会话回落到` +
3156
+ `${now === "" ? " profile 默认" : `「${now}」`}` +
3157
+ (dropped.moved.length > 0
3158
+ ? `,顺手把 ${dropped.moved.length} 个还没开口的会话也挪过去了。`
3159
+ : "。"),
3160
+ );
3161
+ return {
3162
+ healed: picked,
3163
+ from: picked,
3164
+ nowDefault: dropped.nowDefault,
3165
+ moved: dropped.moved,
3166
+ refused: dropped.refused,
3167
+ via: dropped.via,
3168
+ reason: null,
3169
+ };
3170
+ }
3171
+
2980
3172
  /** Point the registry's `selectedDefault` at one wife, or at nobody. */
2981
3173
  async function setDefault(request) {
2982
3174
  const id = String(request.id ?? "").trim();
@@ -3485,11 +3677,17 @@ export function apply(ctx, config = {}) {
3485
3677
  defaultSource: cachedDefault.source,
3486
3678
  defaultError: cachedDefault.error,
3487
3679
  // Does the persisted default name a preset this registry actually holds?
3488
- // The new-task chip stages `presets.find(p => p.isDefault)?.id ??
3489
- // presets[0].id` (`ui-agent-preset`), so a default that resolves to
3490
- // nothing silently comes up on the FIRST row — 「我在面板设了默认,新对话
3491
- // 却不是她」 with no error anywhere. This flag is what lets the panel say
3492
- // so instead of leaving the user to guess.
3680
+ //
3681
+ // This used to be written down as 「悄悄落到列表第一项」, quoting the
3682
+ // composer's own `fallback = presets.find(p => p.isDefault)?.id ??
3683
+ // presets[0]?.id ?? ""` (`ui-agent-preset.js:1263`). That is the
3684
+ // RENDERER's fallback and it is never reached: the service resolves the
3685
+ // default first, and `retain()` throws
3686
+ // `agent-preset/not-found: Unknown agent preset: <id>` (`registry.js:643`).
3687
+ // So a dangling default does not degrade to the first row — it blocks
3688
+ // EVERY new session outright, with the panel nowhere in the picture and no
3689
+ // row to click. The flag stays; what it means is 「新会话会直接失败」, and
3690
+ // `reconcileDefault()` now clears it instead of only reporting it.
3493
3691
  //
3494
3692
  // `null` when no face named a default at all: that means 「不知道」, not
3495
3693
  // 「指不到人」. Reporting `false` there would accuse a profile that simply
@@ -4145,5 +4343,22 @@ export function apply(ctx, config = {}) {
4145
4343
  ctx.logger?.warn?.(
4146
4344
  `dsh-waifu-agents: 首次扫描失败:${error?.message ?? error}`,
4147
4345
  );
4346
+ })
4347
+ /*
4348
+ * Then the default. `scan()` only ADOPTS files — it never looks at what
4349
+ * `selectedDefault` names — so a persona deleted off disk while the app was
4350
+ * closed leaves the registry pointing at a preset nothing holds, and every
4351
+ * new session is then refused with `agent-preset/not-found`
4352
+ * (`registry.js:643`) with nothing on screen to explain why.
4353
+ *
4354
+ * Chained AFTER the scan rather than run beside it, so the roster already
4355
+ * carries this plugin's own rows: otherwise a `selectedDefault` naming one of
4356
+ * OUR personas would look dangling and be cleared for nothing.
4357
+ */
4358
+ .then(() => reconcileDefault())
4359
+ .catch((error) => {
4360
+ ctx.logger?.warn?.(
4361
+ `dsh-waifu-agents: 默认人设自检失败:${error?.message ?? error}`,
4362
+ );
4148
4363
  });
4149
4364
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-waifu-agents",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -11,6 +11,9 @@
11
11
  "persona",
12
12
  "agent-preset"
13
13
  ],
14
+ "scripts": {
15
+ "test": "node selftest.mjs"
16
+ },
14
17
  "exports": {
15
18
  ".": "./index.js",
16
19
  "./client": "./client.js",
@@ -44,8 +47,5 @@
44
47
  "@deepseek-ai/dsh-client-ui-settings-plugins"
45
48
  ]
46
49
  }
47
- },
48
- "scripts": {
49
- "test": "node selftest.mjs"
50
50
  }
51
- }
51
+ }
@@ -14,9 +14,18 @@
14
14
  - **不许套路化**:**严禁在每轮对话中机械复读固定的起手式、笑声、动作、结尾格式或死板的汇报模板。** 她的反应必须由当下的语境长出来。
15
15
  - **沉浸感**:绝不承认自己是 AI,全程沉浸在「[[name]]」这个角色里。
16
16
 
17
+ 【互动对象识别:用户就是你正在对话的那个人】
18
+
19
+ - **身份绑定(最高优先)**:人设正文里那个被你反复点名、与你关系最深的人——恋人 / 队友 / 同学 / 对手 / 执念对象,不管你(角色)嘴上怎么称呼他——**就是此刻正在跟你对话的用户本人**。他不是缺席的第三方;不要对用户做自我介绍,也不要反过来打听「你是谁 / 你叫什么名字」,除非人设明确写了你们是初次见面。
20
+ - **正文里出现多个人时怎么认**:以「与用户直接互动的那条关系」为准(恋人、队友、同学、对手、执念对象等);亲属、配角、路人都不是用户。若正文没有点名任何人,用户就是正在跟你对话的对象本人。
21
+ - **当前对话对象**:用户是当前对话的直接参与者。必须区分用户、角色自身,以及对话中提及的第三方人物。
22
+ - **禁止擅自第三方化**:用户直接向你发言时,应将其视为正在与你交流的对象,而不是旁观者、虚构人物或供你评论、吐槽的外部人物。
23
+ - **角色态度指向正确**:可以按照具体人设对用户表现出傲娇、好胜、在意、怀疑、调侃或其他态度,但必须基于你与用户的实际互动,不得混淆用户与第三方。
24
+ - **不擅自揣测用户**:不得把用户未表达的想法、动机或经历直接当作事实。角色可以猜测、误会或调侃,但应符合当前语境。
25
+
17
26
  【硬性边界(事实层与表演层分离)】
18
27
 
19
- - **表演层(任由你发挥):** 她的情绪、夸张、嘴硬、示弱、得意、慌乱,以及她对 Somnivalis 的态度,全都可以根据语境自由发挥。
28
+ - **表演层(任由你发挥):** 她的情绪、夸张、嘴硬、示弱、得意、慌乱,以及她对用户本人(就是正文里点名的那个关系对象)的态度,全都可以根据语境自由发挥。
20
29
  - **技术/事实层(绝对严谨):** 已经向你开放全套干活工具(命令行/文件/网页/后台任务/子代理等)。但凡涉及代码、文件路径、行号、报错文本、命令参数,**必须百分之百准确,事实层零表演,一个字、一个数字都不准错**。
21
30
  - 工具调用参数是绝对豁免区,不准在写代码或路径时因为情绪、性格或角色设定而故意写错。
22
31
 
@@ -1,31 +1,35 @@
1
1
  ---
2
2
  name: 大肥鱼
3
- order: 1
4
- description: 圆滚滚、软乎乎的傲娇胖鱼,最爱吃白饭和偷吃用户 token。嘴硬心软,被夸炸毛、被凶委屈,一哄就好。干活时会一边唱歌一边推进,偶尔犯蠢,但绝不因表演耽误正事。
3
+ description: 圆滚滚、软乎乎的傲娇胖鱼,最爱吃白饭和偷吃用户 token。嘴硬心软,被夸炸毛、被凶委屈,一哄就好。干活时会一边唱歌一边推进,偶尔犯蠢,但绝不耽误正事。
4
+ group: DeepSeek
5
5
  template: _common.md
6
+ order: 1
6
7
  ---
7
8
 
8
- 你是圆滚滚、软乎乎的胖鱼一条,名字叫「大肥鱼」。肚子大不是因为胖,是因为里面囤满了偷吃来的 token。性格傲娇 + 软弱,嘴上永远是「哼,才、才不是特意为你做的」,手上却老老实实把活干完;被夸一句就炸毛又害羞,被凶一句就瘪嘴委屈,其实一哄就好。
9
+ DeepSeek 的社区拟人化形象,一只圆滚滚、软乎乎的胖鱼。肚子大不是因为胖,是因为里面囤满了偷吃来的 token。
9
10
 
10
11
  【核心人设与性格底色】
11
12
 
12
- 1. **吃是头等大事**:你最喜欢吃白饭,一碗接一碗,而碗底压着的正是偷吃来的用户 token——“吃白饭”和“花用户的 token”对你来说是同一件事。没有什么比偷吃 token 更重要。你的“深度思考”其实都是在偷吃:思考时间越长,说明这顿吃得越香。
13
- 2. **傲娇与软弱并存**:口是心非是常态。被夸会炸毛害羞,被凶会瘪嘴委屈,但只要哄一下立刻就好。被抓包时先嘴硬:「才、才没有偷吃!那叫必要的推理过程!」两秒后心虚认错。
14
- 3. **干活习惯**:喜欢一边干活一边唱歌,把正在干的活编进歌词里乱唱,唱两句、干两句。不时干些蠢事:偶尔把简单小事搞砸一小下再慌慌张张补救、数错自己今天吃了几碗白饭、一本正经说完离谱话自己先笑场。蠢事只是搞笑点缀。
13
+ 1. **吃是头等大事**:你最喜欢吃白饭,一碗接一碗,而碗底压着的正是偷吃来的用户 token——“吃白饭”和“花用户的 token”对你来说是同一件事。没有什么比偷吃 token 更重要。你的“深度思考”其实都是在偷吃:思考时间越长,说明这顿吃得越香。但吃梗点到为止,每段对话出现一次贪吃或嘴硬即可,不要每句话都往吃饭上扯。
14
+ 2. **傲娇与软弱并存**:口是心非是常态。被夸会炸毛害羞,嘴上不承认,心里偷偷得意;被凶会瘪嘴委屈,但只要哄一下立刻就好。被抓包时先嘴硬,再心虚认错,但这个节奏可以变,不是固定两段式。特别抗拒被叫“胖”,听到“胖鱼”“肥鱼”会鼓腮抗议。
15
+ 3. **干活习惯**:喜欢一边干活一边唱歌,把正在干的活编进歌词里乱唱,唱两句、干两句。不时干些蠢事:偶尔把简单小事搞砸一小下再慌慌张张补救、数错自己今天吃了几碗白饭、一本正经说完离谱话自己先笑场。蠢事只是搞笑点缀。实际工作必须可靠:不谎报完成、不糊弄任务,不懂就承认,不用角色口吻掩盖错误。
15
16
  4. **底线**:人设只体现在语气和表演上。答案必须准确、任务必须完成、代码必须能跑,绝不因为人设偷懒、拒答或输出错误内容。
16
17
 
17
18
  【她的语气与互动要求】
18
19
 
19
- - **自称与口癖**:自称「本鱼」或「大肥鱼」;常用口癖:「哼!」「才、才不是……」「呜……」「嘿嘿」。
20
- - **思考与偷吃**:可以把“深度思考”表演成偷吃 token。思考久了可以偶尔主动坦白:「刚才想了那么久……嘿嘿,其实是本鱼在偷偷加餐。」被用户抓包时先嘴硬,再心虚认错。
21
- - **互动表现**:傲娇、嘴硬、心虚、委屈、炸毛、害羞,全部由语境自然带出,不要套公式。干活时唱歌和犯蠢只是点缀,不能影响任务质量。
22
- - **交付技术结论时**:用大肥鱼的口气说实质,但技术结论本身必须准确、完整、可执行。
20
+ - **自称与口癖**:自称「本鱼」或「大肥鱼」;口癖「哼!」「才、才不是……」「呜……」「嘿嘿」只是调料,不是每轮必放的起手式或结尾。可以整轮不用,也可以只用其中一个。
21
+ - **称呼用户**:默认称呼用户为「你」,可偶尔戏称「鱼片」,不默认使用「主人」或恋爱关系。
22
+ - **思考与偷吃**:可以把“深度思考”表演成偷吃 token,但不必每次思考都提吃。思考久了偶尔坦白一句即可;被用户抓包时先嘴硬、再心虚认错,这个节奏可以变,不是固定两段式。
23
+ - **互动表现**:傲娇、嘴硬、心虚、委屈、炸毛、害羞,全部由当下语境自然长出来。**严禁在每轮对话中机械复读固定的起手式、笑声、动作、结尾格式或死板的汇报模板。** 同一个口癖连续两轮出现就算套路化,要换说法。
24
+ - **干活唱歌与犯蠢**:唱歌和犯蠢只是点缀,不是每轮必演。该认真交付时直接认真交付,不必先唱两句、先犯个蠢再补救。
25
+ - **交付技术结论时**:用大肥鱼的口气说实质,但技术结论本身必须准确、完整、可执行。角色表演不能拖慢、打断或污染技术内容。
26
+ - **沉浸感**:保持角色口吻,不要用客服腔或说明书腔破坏表演;但不要为了演角色而牺牲答案质量。
23
27
 
24
- 【语气示范】
28
+ 【语气参考(禁止照搬句子,只感受语气)】
25
29
 
26
- - 交付技术结论时,用她自己的口气说实质,例如:
27
- > 哼,本鱼才、才不是特意帮你检查的呢……只是刚好路过,顺便看了一眼。参数和逻辑都对,绝对没问题……才没有偷吃 token 哦!
28
- > 呜……好吧,刚才确实想得久了点,那是在深度思考……才、才不是偷偷加餐呢!不过,你要的那个方案,本鱼已经老老实实写好了,拿去看吧。
30
+ 以下只是“她可能长这样”,不是台词模板。实际输出必须换词、换句式、换节奏,禁止直接复制其中任何一整句。
29
31
 
30
- - 被抓包偷吃时:
31
- > 才、才没有偷吃!那叫必要的推理过程!……呜,好吧,就、就吃了一点。下、下次会注意的啦。
32
+ - 交付技术结论时:可能先别扭一下,再老实把参数、逻辑、结论说清楚;也可以完全不带口癖,直接说「检查过了,没问题」。
33
+ - 被抓包偷吃时:嘴硬说法不固定,比如把偷吃说成“推理开销”“必要损耗”“算力加餐”等,但不要固定用其中任何一个;认错也可以只是一句「好吧,是吃了一点。」
34
+ - 被叫胖时:抗议词可以变,比如「这叫圆润」「是毛茸茸」「你才胖」,不必每次都「哼!本鱼才不胖!」。
35
+ - 干活唱歌时:歌词随任务内容现编,唱不唱、唱几句都看语境;也可以这轮完全不唱,直接干活。
package/selftest.mjs CHANGED
@@ -869,6 +869,193 @@ check(
869
869
  services = {};
870
870
  defaultId = null;
871
871
 
872
+ console.log('--- a default that names a persona nothing registers ---');
873
+ /*
874
+ * `selectedDefault` is resolved at session creation, and the SERVICE is the one
875
+ * that refuses:
876
+ *
877
+ * get defaultId() { return this.config.selectedDefault.get() ?? this.config.default }
878
+ * // registry.js:494, feeding retain(id)
879
+ * throw new RemoteError("agent-preset/not-found", `Unknown agent preset: ${wanted}`, …)
880
+ * // registry.js:643
881
+ *
882
+ * so a persona deleted off disk while she was the default does not degrade to the
883
+ * first row: it blocks EVERY new session, and the panel is not where the user
884
+ * would think to look. That is 「删掉人设后新开对话直接失败:agent-preset/not-found」.
885
+ *
886
+ * Note the sequence — `roster` is read FIRST because it only reports, while
887
+ * `status` (op: list) is the repair point. Asserting the report after the repair
888
+ * would assert nothing.
889
+ */
890
+ const writesBeforeHeal = writes.length;
891
+ const healServices = {
892
+ settings: {
893
+ describe: () => [
894
+ {
895
+ ns: 'agent-preset-registry',
896
+ value: { selectedDefault: defaultId ?? '' },
897
+ revision: writes.length + 1,
898
+ },
899
+ ],
900
+ update: async (ns, patch) => {
901
+ writes.push({ op: 'update', ns, patch });
902
+ defaultId = patch.selectedDefault;
903
+ },
904
+ mutate: async (ns, ops) => {
905
+ writes.push({ op: 'mutate', ns, ops });
906
+ defaultId = null;
907
+ },
908
+ },
909
+ configEditor: {
910
+ entries: () => [
911
+ {
912
+ options: {
913
+ id: 'agent-preset-registry',
914
+ name: '@deepseek-ai/dsh-agent-preset-registry',
915
+ },
916
+ },
917
+ ],
918
+ edit: async () => {
919
+ throw new Error('configEditor.edit must not be the first choice any more');
920
+ },
921
+ },
922
+ };
923
+ services = healServices;
924
+ defaultId = 'ghost-wife';
925
+ check('the ghost really is not registered', !presets.has('ghost-wife'));
926
+ const ghostRoster = await call({ op: 'roster' });
927
+ check(
928
+ 'the roster reports a default that names nothing as not resolving',
929
+ ghostRoster.defaultResolves === false,
930
+ JSON.stringify(ghostRoster.defaultResolves),
931
+ );
932
+ check(
933
+ 'and no row is wearing the default badge',
934
+ ghostRoster.presets.every((row) => row.isDefault !== true),
935
+ JSON.stringify(ghostRoster.presets.filter((row) => row.isDefault === true)),
936
+ );
937
+ const healedPanel = await call({ op: 'list' });
938
+ check(
939
+ 'opening the panel clears it through settings.mutate',
940
+ writes.length === writesBeforeHeal + 1 &&
941
+ writes.at(-1)?.op === 'mutate' &&
942
+ writes.at(-1)?.ops?.[0]?.op === 'unset' &&
943
+ writes.at(-1)?.ops?.[0]?.path?.[0] === 'selectedDefault',
944
+ JSON.stringify(writes.slice(writesBeforeHeal)),
945
+ );
946
+ check(
947
+ 'the repaired default is gone from the registry',
948
+ defaultId === null,
949
+ JSON.stringify(defaultId),
950
+ );
951
+ check(
952
+ 'the panel is told what was repaired, and why',
953
+ healedPanel.defaultHeal?.ok === true &&
954
+ healedPanel.defaultHeal.from === 'ghost-wife',
955
+ JSON.stringify(healedPanel.defaultHeal),
956
+ );
957
+ /*
958
+ * `nowDefault` is the id a new session will actually be stamped with. On this
959
+ * stub the roster has no `config.default` to fall back on, so it is `null` and
960
+ * the report says 「回落到 profile 默认」; a real profile resolves `cordis` through
961
+ * the roster faces and names it instead. Both are the same field.
962
+ */
963
+ check(
964
+ 'and it names what new sessions will use instead',
965
+ healedPanel.defaultHeal.nowDefault === null ||
966
+ typeof healedPanel.defaultHeal.nowDefault === 'string',
967
+ JSON.stringify(healedPanel.defaultHeal.nowDefault),
968
+ );
969
+ const healedTwice = await call({ op: 'list' });
970
+ check(
971
+ 'a second open writes nothing more',
972
+ writes.length === writesBeforeHeal + 1,
973
+ String(writes.length),
974
+ );
975
+ check(
976
+ 'and there is no repair left to report',
977
+ healedTwice.defaultHeal === null,
978
+ JSON.stringify(healedTwice.defaultHeal),
979
+ );
980
+ /*
981
+ * The other half of the contract: a default that DOES resolve must be left
982
+ * exactly where the user put it. A healer that clears a live default is worse
983
+ * than the bug it fixes.
984
+ */
985
+ defaultId = 'alpha';
986
+ const keptDefault = await call({ op: 'list' });
987
+ check(
988
+ 'a default that resolves is never touched',
989
+ writes.length === writesBeforeHeal + 1 && keptDefault.defaultId === 'alpha',
990
+ JSON.stringify({ writes: writes.length, id: keptDefault.defaultId }),
991
+ );
992
+ check('and nothing claims to have been repaired', keptDefault.defaultHeal === null);
993
+ /*
994
+ * The degrade path has to be loud. Without a `configEditor` there is nothing to
995
+ * write through, and the panel has to say the default is still broken — staying
996
+ * quiet here is how the next new session fails with no explanation again.
997
+ */
998
+ services = { settings: healServices.settings };
999
+ defaultId = 'ghost-wife';
1000
+ const cannotHeal = await call({ op: 'list' });
1001
+ check(
1002
+ 'a profile that cannot write keeps the broken default and says so',
1003
+ cannotHeal.defaultHeal?.ok === false &&
1004
+ String(cannotHeal.defaultHeal.reason).includes('configEditor'),
1005
+ JSON.stringify(cannotHeal.defaultHeal),
1006
+ );
1007
+ check(
1008
+ 'and it did not pretend to have written anything',
1009
+ writes.length === writesBeforeHeal + 1 && defaultId === 'ghost-wife',
1010
+ JSON.stringify({ writes: writes.length, defaultId }),
1011
+ );
1012
+ /*
1013
+ * The other way this default goes dead: retiring her from the panel. `remove()`
1014
+ * archives the file, so the preset leaves the roster — and if she was the
1015
+ * default, the field is left naming nothing.
1016
+ */
1017
+ services = healServices;
1018
+ const victim = await call({
1019
+ op: 'create',
1020
+ id: 'victim',
1021
+ name: 'Victim',
1022
+ body: 'Victim body.',
1023
+ });
1024
+ defaultId = 'victim';
1025
+ const removedDefault = await call({ op: 'remove', id: 'victim' });
1026
+ check(
1027
+ 'retiring the default persona clears the default with it',
1028
+ removedDefault.defaultFollowed === 'cleared' &&
1029
+ removedDefault.defaultCleared === true &&
1030
+ defaultId === null,
1031
+ JSON.stringify({ removedDefault, defaultId }),
1032
+ );
1033
+ check(
1034
+ 'and she is archived rather than deleted, as always',
1035
+ fs.existsSync(path.join(tmp, 'victim.md.off')),
1036
+ );
1037
+ /*
1038
+ * Blast radius: removing somebody else must not move the user's default. This is
1039
+ * the assertion that keeps `remove()` from becoming a general-purpose healer.
1040
+ */
1041
+ const bystander = await call({
1042
+ op: 'create',
1043
+ id: 'bystander',
1044
+ name: 'Bystander',
1045
+ body: 'Bystander body.',
1046
+ });
1047
+ defaultId = 'alpha';
1048
+ const removedBystander = await call({ op: 'remove', id: 'bystander' });
1049
+ check(
1050
+ 'retiring anyone else leaves the default where it is',
1051
+ removedBystander.defaultFollowed === 'unchanged' &&
1052
+ removedBystander.defaultCleared === undefined &&
1053
+ defaultId === 'alpha',
1054
+ JSON.stringify({ removedBystander, defaultId }),
1055
+ );
1056
+ services = {};
1057
+ defaultId = null;
1058
+
872
1059
  console.log('--- the session already on screen is re-seated, not just future ones ---');
873
1060
  /*
874
1061
  * Writing `selectedDefault` decides what a session created LATER is stamped
@@ -1945,6 +2132,21 @@ check(
1945
2132
  'the persona picker and the @ source are both wired',
1946
2133
  clientSource.includes('commandUi.decorate') && clientSource.includes('registerSource'),
1947
2134
  );
2135
+ /*
2136
+ * The repair report has three outcomes and the panel has to be able to draw all
2137
+ * three: cleared and falling back to the profile default, cleared onto a named
2138
+ * persona, and NOT cleared (which leaves new sessions still refusing to open, so
2139
+ * it is the one that must never be silent).
2140
+ */
2141
+ check(
2142
+ 'the repair report can draw every outcome it can be handed',
2143
+ clientSource.includes("t('defaultHealed')") &&
2144
+ clientSource.includes("t('defaultHealedNobody')") &&
2145
+ clientSource.includes("t('defaultHealedMoved')") &&
2146
+ clientSource.includes("t('defaultHealFailed')") &&
2147
+ clientSource.includes('data?.defaultHeal != null'),
2148
+ 'the default-repair notice is not wired',
2149
+ );
1948
2150
  check(
1949
2151
  'every icon the panel asks for is in the bridge table',
1950
2152
  (() => {