dsh-connect-workbuddy 3.0.1 → 3.1.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.1.0 (2026-10-01)
4
+
5
+ ### Bug Fixes
6
+
7
+ - **修复国际版每一步都 400 / 业务码 11128(`first message is not system prompt`),而国内版正常。**
8
+ - **现象**:用户切到 `workbuddy-global` 后,每一步都失败;同一会话里国内版 18 个 turn 全部成功。响应体里带的是 `displayMsg: "The request was blocked by security policy"`,看起来像风控,实际不是。
9
+ - **根因(真机复现)**:两个网关都要求会话**以 `system` 消息开头**,**国际网关强制、国内网关容忍**——这就是「只有国际版报」的全部原因。而 system 消息会在到达本插件的 shim 之前就消失:`dsh-llm-pi-ai` 把首条 `system` 消息抽成 `Context.systemPrompt`,pi-ai 只在 `if (context.systemPrompt)` 为真时才发它——**空串或空文本是 falsy**,于是它一条 system 都不发,并把 `messages` 里那条 `system` 降级成 `user`。出站因此以 user 打头。
10
+ - **实测矩阵**(真实国际网关,插件真实 shim + adapter 路径):首条 system 文本为空 → 线上 `["user"]` → 11128;`system=""` 且 `messages` 首条是 system → 线上 `["user","user"]` → 11128;system 是空格 `" "` → 线上 `["system","user"]` → 200。**空格能过、空串不能**,正好卡在 pi-ai 的真值判断上。
11
+ - **修法**(两道,都在出站前):
12
+ 1. `src/adapter.ts` 的 `adaptLegacyPiAiContext` 把 `systemPrompt !== undefined` 改成真值判断。空串不再被当作「已经设好了」而提前返回,于是 `messages` 里真实存在的那条 system 会被折回 `systemPrompt`——**把用户真正的提示词救回来**,而不是替换掉。
13
+ 2. 兜底:确实一条 system 都没有时,补一条最小占位(`WORKBUDDY_FALLBACK_SYSTEM_PROMPT`,定义在 `src/upstream.ts`)。同时 `prepareChatBody` 增加 `ensureSystemHead()` 作为最后一道:出站前首条不是 `system` 就补。
14
+ - **占位提示词刻意极短**(`You are a helpful assistant.`):它是最后手段,不是人格设定;编一段长的会悄悄改变所有受影响请求的模型行为。
15
+ - **与探测路径对齐**:`src/probe.ts` 早就知道这条规则(注释写着「探测请求总是带一条 system 消息,两个网关都接受」),但聊天这条路一直没有这道防护——本次补齐。
16
+ - **守卫**:`tests/adapter.spec.ts` 新增「空 `systemPrompt` 不吞掉真实 system 文本」与「无 system 时补占位」两例,并**修正了一条编码旧契约的用例**(原先断言「没有 system 消息时返回同一个对象」,而这正是必须改动的输入);`tests/upstream.spec.ts` 新增三例:user 打头时补占位、已是 system 打头时不重复补、无历史时一条都不补。
17
+ - **未验证**:生产宿主里究竟是哪条路径进入该分支(空文本被丢弃,还是 `options.system=''` 导致降级)——两者都被兜底覆盖,且都已在真实国际网关上端到端验证返回 200。
18
+
19
+ - **修复 issue #26(以及 #25 的完整根因):3.0.2 的 `adaptLegacyPiAiContext` 折叠会丢掉 system prompt 与全部工具声明——上一条只治了 400 症状,本条治根。**
20
+ - **现象(#26)**:升级 3.0.2 后 desktop profile 下所有 WorkBuddy 模型都无法调用工具:模型不产生 `tool_calls`,而是在正文里用 Markdown 代码块**编造**工具调用与"执行结果",`stopReason` 恒为 `stop`(报告者实测 15 个请求 `calls=0`;编造的工具名是 `Bash` 而非 DSH 真实的 `pwsh`——连工具清单都没收到)。国际版则先撞上网关校验表现为 #25 的硬 400。
21
+ - **根因(逐条对照两版 pi-ai 源码核实)**:pi-ai 0.87 的 `normalizeContext` 把 system prompt 与工具声明**只**放在 `messages` 首条 system 消息上(`content` 与 `toolsAdded`),顶层 `systemPrompt`/`tools` 字段就此消失;而 0.87 的 `openai-completions` **全文没有一处读顶层 `systemPrompt`**,工具只从首条 system 消息的 `toolsAdded` 取(宿主 asar 里的 0.87.1 已逐行核实)。3.0.2 的 `withLegacyContext` 按 **context 形状**判断"要修",把首条 system 消息删掉、文本折回顶层——恰好删掉了两个代次 api 各自唯一的载体:
22
+ - 插件解析到 **0.87**(干净安装,无磁盘 0.85 副本,#26 报告者的机器):顶层 `systemPrompt` 没人读 → **提示词丢**;`toolsAdded` 被删 → **工具丢** → 模型编造工具调用;
23
+ - 插件解析到 **0.85**(本机:`~/.dsh/profiles/desktop` 里的安装是指向本仓库的符号链接,嵌套 dev 依赖 0.85.1,而宿主 asar 是 0.87.1):折回的文本 0.85 认,但折叠**只搬文本不搬工具**——0.85 只读顶层 `context.tools`,而 `toolsAdded` 被丢弃 → **工具同样丢**。两个方向都是错的,区别只是症状。
24
+ - **为什么不能照 #26 的"最小修复"直接删掉 wrapper**:本机插件解析的正是 0.85.1,宿主却用 0.87.1 归一化 context——删掉 wrapper 会立刻复活 issue #24 的崩溃(0.85 的 `estimateMessageTokens` 对字符串 content 的 system 消息抛 `Cannot read properties of undefined (reading 'length')`,已在本机复现为流内 error、请求未发出)。报告者机器与作者机器**环境互补**,任何基于单一环境的修法都会弄坏另一台。
25
+ - **修法(按代次门控,而不是按 context 形状猜)**:新增 `src/pi-ai-runtime.ts`,判定**插件自己解析到的那份 pi-ai**:
26
+ 1. **modern(0.87+)**:`withLegacyContext` 原样返回 api,context **字节级透传**——0.87 自己就能消费归一化 transcript(#26 的复现脚本证明了这条路 tools 与 prompt 都在);
27
+ 2. **legacy(0.85)**:折叠照旧,但**把工具状态一并提升**——按 0.87 自己 `getCurrentTools` 的语义重放每条 system 消息的 `toolsRemoved`/`toolsAdded`(先删后加、按名去重),写回顶层 `context.tools`(顶层已有 `tools` 数组时不覆盖)。
28
+ - **检测刻意不 import pi-ai 本体**:用 `import.meta.resolve` 定位入口文件(不执行模块)+ 读取**所属 manifest 的版本号**判定代次(0.87+ → modern;peer 范围内版本与 `normalizeContext` 特征一致)。原因是 standalone CLI 必须在**市场安装**(本地无 pi-ai 副本、模块只由宿主提供)下也能跑 `doctor`——静态 import 会让它在加载期就 `ERR_MODULE_NOT_FOUND`(已用 3.0.2 官方包在裸目录复现同一现象:缺 peer 时谁都跑不起来,本次新增的这条依赖则被彻底避免)。
29
+ - **解析失败 = 宿主提供**(市场安装的常态):此时默认走 modern 透传,因为**会归一化 context 的宿主(0.2.0+)都提供 0.87.1**;该默认对旧宿主同样安全——旧宿主从不归一化,原生 0.85 形状在两个分支下都是恒等。
30
+ - **实测(本机 0.85.1,真实 api + fetch 注入捕获出站 body)**:修复前有 wrapper → `tools = MISSING`、system 文本在;修复后 → **`tools = 1`(pwsh)、首条 `system` 消息文本完整**。无 wrapper 的对照 → system 也丢、且 #24 崩溃以流内 error 出现。
31
+ - **doctor 可见性(#26 的附加建议)**:`doctor` 输出新增 `piAiRuntime` 块(generation / version / resolvedFrom / **resolvedLocally** / adapter 行为说明),人类可读行同步打印;legacy 时追加 hint 提示"插件解析到 0.85 而 DSH 0.2.0+ 宿主用 0.87 归一化,适配器在桥接,若工具异常请清理嵌套 pi-ai 副本"。JSON schemaVersion 2 → 3。**两种环境的真实输出已分别实测**:仓库检出(嵌套 0.85.1)→ `legacy 0.85.1 (<pnpm 真实路径>) — folds…`;市场式安装(有 peer、无 pi-ai)→ `modern, host-provided (no local copy; DSH 0.2.0+ ships 0.87.1) — passes…`。
32
+ - **守卫**:接线用例从一条拆成**三条**——legacy 折叠并提升工具(含 `tools` 断言)、modern **同一对象引用**透传(`toBe`,最强度断言)、默认跟随实际解析代次;新增 `tests/adapter-outgoing-body.spec.ts` 按 #26 建议的测法**直接断言出站请求体**(`body.tools` 与首条 system 文本,并在失败信息里带出流内错误);新增 `tests/pi-ai-runtime.spec.ts` 四例钉住检测自洽(generation 与模块特征一致、路径与版本来自同一份 manifest、结果缓存稳定)——**不钉具体版本号**,peer 范围内换版本时检测必须跟着走而不是被测试冻住。
33
+ - **对上一条(#25 修复)的关系**:`ensureSystemHead`/兜底占位保留——它们是 wire 层最后防线(空文本 system、`options.system=''` 等退化输入仍需兜底),但 400 消失后若不修本条,国际版会立刻从"报错"变成"不报错但没工具"(#26 原文预测,本机复现坐实)。
34
+ - **跨机真机验证(发布前)**:Windows 11 与 macOS 两台机器、国内版与国际版两个区域,均完成真实工具调用(`pwsh` 执行 `echo`,输出逐字一致;模型列出的工具名是 DSH 真名而非编造的 `Bash`)。Windows 那台先复现了 #25 的 400 报文与 #26 的「我没有可用的 shell 工具」,部署修复后同一路径通过——红绿对照。存档见 `docs/ISSUE-25-26-WINDOWS-VERIFY.md`。
35
+ - **本次未覆盖**:两台机器都是 junction 到仓库本体(解析到嵌套 0.85.1)→ 都走 legacy 桥接分支;**modern(市场安装、宿主提供 pi-ai)分支尚无真机端到端观测**,仅有单元测试的恒等断言与模拟安装树上的 `doctor` 选中验证。
36
+
37
+ ### Features
38
+
39
+ - **模型区可折叠**:模型列表现在折在标题行下面(标题、已启用数量、「从 WorkBuddy 刷新」按钮留在外面),点标题行即可收起,把下方的账号池一次露出来。用原生 `<details>` 实现,因此键盘可达、助记语义正确,且不引入任何状态库。
40
+ - **标题左侧有折叠箭头**:收起时朝右、展开时旋转 90° 朝下(0.16s 过渡,与卡片既有过渡一致)。**原生三角被隐藏、改为自绘**——否则会出现两个方向相反的状态指示器。旋转由 `details[open]` 驱动,不需要额外状态,也就不可能与实际显示不一致;箭头本身是纯装饰(`<details>` 已向助记技术播报状态),故标记 `aria-hidden`。
41
+ - **默认展开**:这是用户原本就看得到的内容,默认折起来会让人以为它没了。
42
+ - **折叠状态不持久化**:卡片只挂载当前 tab,所以「记住」需要新增一个设置字段——一个纯外观偏好不值得。切 tab 或重开卡片会回到默认。
43
+ - **折叠态标题行会显示「有未保存的修改」**:保存/放弃按钮在折叠区里面,且只在 `dirty` 时可点;没有这个提示,用户勾了几个模型再折起来,就把自己的未保存改动藏在了折叠后面而界面上一言不发。
44
+ - **标题行中间加了一句淡色动作提示**(「点击展开」/「点击收起」):只有箭头时,「这里能点」这件事不够明确。提示说的是**此刻能做的那个动作**,而不是固定的「展开或收起」——后者会让读者自己去想该按哪个,正是这句提示要消除的疑问。它用与标题下方摘要行相同的淡色 token,读起来是提示而不是第二个标题;窄屏(≤760px)下隐藏,因为箭头已足以表明这是折叠控件,先让位给标题与按钮。
45
+ - **刷新按钮不会误触发折叠**:按钮位于 `<summary>` 内,而点击 summary 内任何位置都会激活这个 disclosure——所以 summary 的 `onClick` 对落在操作区的点击调用了 `preventDefault()`,否则「刷新模型」会顺手把列表收起来。这条有专门用例钉住。
46
+
47
+ ## 3.0.2 (2026-09-30)
48
+
49
+ ### Bug Fixes
50
+
51
+ - **修复 issue #24:宿主 pi-ai 0.87 下所有 WorkBuddy 模型立即失败**(`Cannot read properties of undefined (reading 'length')`)。
52
+ - **根因**:pi-ai 0.87 的 `normalizeContext` 把 system 提示词搬进 `messages`,成了 `{ role: 'system', content: '<字符串>' }`。而 pi-ai 0.85 的 `Message` 联合类型里**根本没有 `system` 变体**(只有 `UserMessage | AssistantMessage | ToolResultMessage`),于是它的 `estimateMessageTokens` 没有对应分支:`for (const block of message.content)` 会把**字符串逐字符**迭代,`block` 变成单个字符,`block.name.length` 抛错。崩溃发生在 `buildBaseOptions → clampMaxTokensToContext → estimateContextTokens`,即**构造请求体之前**——所以每个模型都在十几毫秒内失败,且**没有任何上游请求发出**。
53
+ - **修法**:新增 `adaptLegacyPiAiContext()`,在把 context 交给本插件的 pi-ai 之前,把 `messages` 里的 system 消息折回 `Context.systemPrompt`(0.85 期望的位置),并用 `withLegacyContext()` 包住 `openAICompletionsApi()` 的 `stream`/`streamSimple` 两个入口。
54
+ - **按形状适配,不按版本判断**:判断依据是「context 里有没有 system 消息」,不是「加载的是哪个 pi-ai 版本」。版本判断会在任一侧升级时立刻失效;本插件必须在**两代宿主**下都能活。对原生 0.85 形状(已有 `systemPrompt`)返回**同一个对象**,原路径零改动。
55
+ - **实测**(用插件真正加载的那份 pi-ai 0.85.1):修复前 0.87 形状 → `Cannot read properties of undefined (reading 'length')`;修复后 → `Connection error.`(走到传输层,即请求已构造完成)。旧形状恒等,`system` 用 block 内容时也能正确合并。
56
+ - **守卫**:`tests/adapter.spec.ts` 新增 8 例,其中**接线用例**(`vi.mock` 掉 stream API,断言 provider 收到的 context 已被折平)是必要的——只测纯函数时,把修复从 provider 上摘掉的变异**不会被打红**(已实测),等于留了一个「修复被摘掉而测试全绿」的缺口。变异验证:去掉 `withLegacyContext` → 该用例变红。
57
+ - **顺带核实**:宿主 asar 声明 `@earendil-works/pi-ai ^0.87.1` 且含 `normalizeContext`;磁盘上四份 pi-ai(profiles / 插件嵌套 / 仓库 / 旧安装树)**全是 0.85.1**。issue 建议的「插件包内放一份 0.87.1」绕行在本机并未生效——嵌套副本是 0.85.1,漂移依旧,只是被当前加载的旧 `dsh-llm-pi-ai`(0.1.5-rc.2,不含 `normalizeContext`)掩盖了。
58
+ - **peer 范围放宽**:`@earendil-works/pi-ai` 由 `>=0.85.0 <0.86.0` 改为 `>=0.85.0 <0.88.0`(peer 与 dev 两处)。原范围**不承认 0.87**,与实际已验证的兼容性不符:适配按形状生效,代码在 0.85 与 0.87 下都已验证可用。放宽后 `pnpm install` 报 "Lockfile is up to date",未产生 lockfile 变更。
59
+
3
60
  ## 3.0.1 (2026-09-30)
4
61
 
5
62
  ### Bug Fixes
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { O as workbuddyOwnAuthPath, T as legacyWorkbuddyOwnAuthPath, W as findWorkbuddyAppExecutable, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, g as WORKBUDDY_AUTH_FILE_ENV, j as WorkBuddyUpstreamClient, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, x as defaultDesktopAuthDirs, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-CfXJht7R.js";
2
+ import { E as legacyWorkbuddyOwnAuthPath, K as findWorkbuddyAppExecutable, N as WorkBuddyUpstreamClient, S as defaultDesktopAuthDirs, V as WORKBUDDY_APP_EXECUTABLE_ENV, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, x as defaultDesktopAuthCandidates } from "./host-heartbeat-DZnPggVT.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
@@ -19,7 +19,7 @@ import { fileURLToPath } from "node:url";
19
19
  *
20
20
  * @module dsh-connect-workbuddy/bin
21
21
  */
22
- const JSON_SCHEMA_VERSION = 2;
22
+ const JSON_SCHEMA_VERSION = 3;
23
23
  /** Both regions, in reporting order. */
24
24
  const REGIONS = ["cn", "global"];
25
25
  /** Region labels for human output. */
@@ -69,11 +69,29 @@ async function doctor(jsonOutput) {
69
69
  accounts: await makeStore(region).accounts()
70
70
  })));
71
71
  const anySignedIn = regionLists.some(({ accounts }) => accounts.length > 0);
72
+ /**
73
+ * Which pi-ai copy the PROVIDER resolves, and hence which context shape its
74
+ * api consumes. This is the #24/#26 gate made visible: a legacy (0.85)
75
+ * resolution while the DSH host normalizes with 0.87 is exactly the mixed
76
+ * generation the adapter has to bridge, and the only way to SEE it is to
77
+ * report it. `resolvedLocally: false` is the normal state of a market
78
+ * install probed outside the host — inside DSH the host supplies pi-ai
79
+ * (0.2.0+ ships 0.87.1, i.e. the pass-through branch). Nothing here reads
80
+ * or reports key material.
81
+ */
82
+ const piAi = piAiRuntimeInfo();
72
83
  const report = {
73
84
  schemaVersion: JSON_SCHEMA_VERSION,
74
85
  package: "dsh-connect-workbuddy",
75
86
  version: WORKBUDDY_CONNECT_VERSION,
76
87
  node: process.version,
88
+ piAiRuntime: {
89
+ generation: piAi.generation,
90
+ version: piAi.version ?? (piAi.resolvedLocally ? "(unreadable)" : "(host-provided)"),
91
+ resolvedFrom: piAi.resolvedFrom ?? "(host-provided; not resolvable outside DSH)",
92
+ resolvedLocally: piAi.resolvedLocally,
93
+ adapterBehaviour: piAi.generation === "legacy" ? "folds 0.87 transcripts (system text + tool state) into the 0.85 context shape" : piAi.resolvedLocally ? "passes provider contexts through unchanged" : "passes provider contexts through unchanged (pi-ai comes from the DSH host; 0.2.0+ ships 0.87.1)"
94
+ },
77
95
  desktopAuthFile: {
78
96
  path: anyStore.desktopAuthPath() ?? "(no platform default; set WORKBUDDY_AUTH_FILE)",
79
97
  dir: defaultDesktopAuthDirs()[0] ?? "(no platform default)",
@@ -120,12 +138,14 @@ async function doctor(jsonOutput) {
120
138
  ...anySignedIn ? [] : ["Sign in once in the WorkBuddy desktop app (either region), then run status again."],
121
139
  ...desktopPresent ? [] : [`No WorkBuddy desktop auth file at the expected path; set ${WORKBUDDY_AUTH_FILE_ENV} if it lives elsewhere.`],
122
140
  ...appExecutable === void 0 ? [`The WorkBuddy desktop app was not found, so encrypted credential fields cannot be opened; set ${WORKBUDDY_APP_EXECUTABLE_ENV} to its executable if it is installed elsewhere.`] : [],
123
- ...hostAlive ? [] : ["Host bundle not running in this DSH profile (or the process exited). The browser card and providers are unavailable until DSH starts the plugin."]
141
+ ...hostAlive ? [] : ["Host bundle not running in this DSH profile (or the process exited). The browser card and providers are unavailable until DSH starts the plugin."],
142
+ ...piAi.generation === "legacy" && piAi.resolvedLocally ? [`This plugin resolves pi-ai ${piAi.version ?? "(unreadable version)"} (the 0.85 context shape) while DSH 0.2.0+ hosts normalize with 0.87; the adapter bridges that gap, but if tools still misbehave, remove any nested pi-ai copy so the plugin resolves the host's.`] : []
124
143
  ]
125
144
  };
126
145
  if (jsonOutput) printJson(report);
127
146
  else process.stdout.write([
128
147
  `WorkBuddy Connect ${WORKBUDDY_CONNECT_VERSION} on ${process.version}`,
148
+ ...piAi.resolvedLocally ? [`pi-ai runtime: ${piAi.generation}${piAi.version === void 0 ? "" : ` ${piAi.version}`} (${piAi.resolvedFrom}) — ${report.piAiRuntime.adapterBehaviour}`] : [`pi-ai runtime: ${piAi.generation}, host-provided (no local copy; DSH 0.2.0+ ships 0.87.1) — passes provider contexts through unchanged`],
129
149
  `Desktop auth file: ${report.desktopAuthFile.present ? "present" : "missing"} (${report.desktopAuthFile.path})`,
130
150
  `Encrypted-credential support: ${report.atRestDecryption.available ? "available" : "unavailable"} (${report.atRestDecryption.appExecutable})`,
131
151
  `Host bundle: ${hostAlive ? `running (pid ${heartbeat.pid})` : heartbeat !== void 0 ? "stale heartbeat (process exited)" : "not started"}`,
package/lib/client.js CHANGED
@@ -1647,8 +1647,34 @@ window.__ModuleLoader__.load({
1647
1647
  .dsm-workbuddy-checkin-error{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:11px;line-height:16px;text-align:center}
1648
1648
  @media (max-width:760px){.dsm-workbuddy-credits-panels{grid-template-columns:1fr}.dsm-workbuddy-credit-panel-total{align-items:flex-start;text-align:left}.dsm-workbuddy-credit-total-body{align-items:flex-start}}
1649
1649
  .dsm-workbuddy-models{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);padding-top:14px}
1650
- .dsm-workbuddy-models-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
1650
+ .dsm-workbuddy-models-fold{display:flex;flex-direction:column;gap:10px}
1651
+ /* The head doubles as the <details> summary. list-style:none plus the marker
1652
+ rule removes the NATIVE triangle, because the chevron below is drawn instead;
1653
+ without this the header would show two indicators in opposite corners. */
1654
+ .dsm-workbuddy-models-head{display:flex;align-items:center;justify-content:space-between;gap:12px;cursor:pointer;list-style:none;user-select:none}
1655
+ .dsm-workbuddy-models-head::-webkit-details-marker{display:none}
1656
+ .dsm-workbuddy-models-head::marker{content:""}
1657
+ .dsm-workbuddy-models-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:3px;border-radius:6px}
1658
+ .dsm-workbuddy-models-title-row{display:flex;align-items:center;gap:6px;min-width:0}
1659
+ /* The chevron points RIGHT when collapsed and rotates to DOWN when expanded.
1660
+ The rotation keys off the details element's own open attribute, so it needs
1661
+ no state and cannot disagree with what the browser actually shows. */
1662
+ .dsm-workbuddy-models-chevron{display:inline-flex;align-items:center;justify-content:center;flex:none;width:14px;height:14px;color:var(--dsw-alias-label-tertiary,#999);transition:transform .16s}
1663
+ /* The faint affordance hint, centred in the space between the section title and
1664
+ the refresh button. Same faint token as the summary line under the title, so
1665
+ it reads as a hint rather than as a second heading. flex:1 is what makes it
1666
+ land in the actual middle instead of wherever space-between drops it. */
1667
+ .dsm-workbuddy-models-fold-hint{flex:1 1 auto;min-width:0;overflow:hidden;text-align:center;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
1668
+ /* Too narrow to hold title + hint + button on one line: the chevron already
1669
+ marks the header as a disclosure, so the words are the part that can go. */
1670
+ @media (max-width:760px){.dsm-workbuddy-models-fold-hint{display:none}}
1671
+ .dsm-workbuddy-models-fold[open] .dsm-workbuddy-models-chevron{transform:rotate(90deg)}
1672
+ .dsm-workbuddy-models-head:hover .dsm-workbuddy-models-chevron{color:var(--dsw-alias-label-secondary,#c6c9d0)}
1651
1673
  .dsm-workbuddy-models-title{margin:0;color:var(--dsw-alias-label-primary,#e6e6e6);font-size:14px;font-weight:600;line-height:20px}
1674
+ /* Unsaved-changes marker. It sits in the HEADER, outside the fold, because the
1675
+ save/discard buttons are inside it: without this the user could collapse the
1676
+ list and hide their own pending edits with nothing on screen saying so. */
1677
+ .dsm-workbuddy-models-dirty{margin-left:8px;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-state-warn-primary,#f59e0b);color:#1b1d22;font-size:11px;font-weight:600;line-height:16px;vertical-align:1px;white-space:nowrap}
1652
1678
  .dsm-workbuddy-models-summary{margin:2px 0 0;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:18px}
1653
1679
  .dsm-workbuddy-model-list{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2,#36373b);border-radius:10px;overflow:hidden}
1654
1680
  .dsm-workbuddy-model{display:grid;grid-template-columns:minmax(0,1fr);gap:7px;padding:10px 12px;background:var(--dsw-alias-bg-layer-2,#232529);transition:opacity .16s}
@@ -2101,6 +2127,16 @@ window.__ModuleLoader__.load({
2101
2127
  /** The region whose tab is on screen; each tab is its own provider stack. */
2102
2128
  const [activeRegion, setActiveRegion] = (0, react.useState)("cn");
2103
2129
  /**
2130
+ * Whether the model list is unfolded.
2131
+ *
2132
+ * Mirrored in state purely to supply the INITIAL value: a bare `<details>` is
2133
+ * COLLAPSED by default, and the list is what users already see, so unfolding
2134
+ * has to be the starting point. Deliberately NOT persisted — only the active
2135
+ * region is mounted, so remembering it would need a settings field for a
2136
+ * cosmetic preference; it resets when the card is closed or the tab changes.
2137
+ */
2138
+ const [modelsOpen, setModelsOpen] = (0, react.useState)(true);
2139
+ /**
2104
2140
  * Last-known usage per region, so tab dots survive tab switches.
2105
2141
  *
2106
2142
  * The placeholder carries `selectionExplicit: false` because that is the
@@ -2869,183 +2905,225 @@ window.__ModuleLoader__.load({
2869
2905
  role: "alert",
2870
2906
  children: t("row.requestFailedHint", { message: status.refreshError })
2871
2907
  }),
2872
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2908
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
2873
2909
  className: "dsm-workbuddy-models",
2874
2910
  "aria-label": t("row.modelsTitle"),
2875
- children: [
2876
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2877
- className: "dsm-workbuddy-models-head",
2878
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
2879
- className: "dsm-workbuddy-models-title",
2880
- children: t("row.modelsTitle")
2881
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2882
- className: "dsm-workbuddy-models-summary",
2883
- children: t("row.modelsSummary", { count: activeEnabledIds.size })
2884
- })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2885
- className: "dsm-workbuddy-models-head-actions",
2886
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2887
- type: "button",
2888
- className: "dsm-btn dsm-btn-outline",
2889
- disabled: busy,
2890
- onClick: () => {
2891
- refreshModels();
2892
- },
2893
- children: busy ? t("row.modelsRefreshing") : t("row.modelsRefresh")
2894
- })
2895
- })]
2896
- }),
2897
- probeError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2898
- className: "dsm-workbuddy-model-probe-result dsm-workbuddy-model-probe-result-bad",
2899
- role: "alert",
2900
- children: t("row.probeError", { message: probeError })
2901
- }),
2902
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2903
- className: "dsm-workbuddy-model-list",
2904
- children: visibleModels.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2905
- className: `dsm-workbuddy-model${activeEnabledIds.has(model.id) ? "" : " dsm-workbuddy-model-disabled"}`,
2911
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("details", {
2912
+ className: "dsm-workbuddy-models-fold",
2913
+ open: modelsOpen,
2914
+ onToggle: (event) => {
2915
+ setModelsOpen(event.currentTarget.open);
2916
+ },
2917
+ children: [
2918
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("summary", {
2919
+ className: "dsm-workbuddy-models-head",
2920
+ title: t("row.modelsFoldHint"),
2921
+ onClick: (event) => {
2922
+ if (event.target instanceof Element && event.target.closest(".dsm-workbuddy-models-head-actions") !== null) event.preventDefault();
2923
+ },
2906
2924
  children: [
2907
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2908
- className: "dsm-workbuddy-model-head",
2909
- children: [
2910
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2911
- className: "dsm-workbuddy-model-enabled",
2912
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2913
- type: "checkbox",
2914
- checked: activeEnabledIds.has(model.id),
2915
- disabled: !canWrite || saving,
2916
- onChange: () => {
2917
- toggleModel(model.id);
2918
- }
2919
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2920
- className: "dsm-workbuddy-model-copy",
2921
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2922
- className: "dsm-workbuddy-model-name",
2923
- children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2924
- className: "dsm-workbuddy-model-name-rate",
2925
- children: [
2926
- "(",
2927
- model.creditMultiplier.toFixed(2),
2928
- "x)"
2929
- ]
2930
- })]
2931
- })
2932
- })]
2933
- }),
2934
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2935
- className: "dsm-workbuddy-model-image",
2936
- title: imageCheckboxHint(model, t),
2937
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2938
- type: "checkbox",
2939
- checked: activeImageIds.has(model.id),
2940
- disabled: !canWrite || saving,
2941
- onChange: () => {
2942
- toggleImage(model.id);
2943
- }
2944
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelImage") })]
2945
- }),
2946
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
2947
- className: "dsm-workbuddy-context-budget",
2948
- "aria-label": t("row.contextBudget"),
2949
- children: [model.nativeContextWindow > 2e5 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2950
- type: "radio",
2951
- name: `context-${model.id}`,
2952
- checked: (activeContextBudgets[model.id] ?? 2e5) === 2e5,
2953
- disabled: !canWrite || saving,
2954
- onChange: () => {
2955
- setContextBudget(model.id, 2e5);
2956
- }
2957
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "200K" })] }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2958
- type: "radio",
2959
- name: `context-${model.id}`,
2960
- checked: model.nativeContextWindow <= 2e5 || activeContextBudgets[model.id] === model.nativeContextWindow,
2961
- disabled: model.nativeContextWindow <= 2e5 || !canWrite || saving,
2962
- onChange: () => {
2963
- setContextBudget(model.id, model.nativeContextWindow);
2964
- }
2965
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatCapacity(model.nativeContextWindow, t("row.modelUnknown")) })] })]
2966
- }),
2967
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2968
- className: "dsm-workbuddy-model-actions-buttons",
2969
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2970
- type: "button",
2971
- className: "dsm-btn dsm-btn-outline dsm-workbuddy-model-probe",
2972
- disabled: probing[model.id] === true || status.status !== "signed-in",
2973
- title: t("row.probeHint"),
2974
- onClick: () => {
2975
- probeModels([model.id]);
2976
- },
2977
- children: probing[model.id] === true ? t("row.probing") : t("row.probe")
2925
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2926
+ className: "dsm-workbuddy-models-title-row",
2927
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2928
+ className: "dsm-workbuddy-models-chevron",
2929
+ "aria-hidden": "true",
2930
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
2931
+ viewBox: "0 0 12 12",
2932
+ width: "12",
2933
+ height: "12",
2934
+ focusable: "false",
2935
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
2936
+ d: "M4.5 2.5 L8 6 L4.5 9.5",
2937
+ fill: "none",
2938
+ stroke: "currentColor",
2939
+ strokeWidth: "1.6",
2940
+ strokeLinecap: "round",
2941
+ strokeLinejoin: "round"
2978
2942
  })
2979
2943
  })
2980
- ]
2944
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
2945
+ className: "dsm-workbuddy-models-title",
2946
+ children: [t("row.modelsTitle"), dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2947
+ className: "dsm-workbuddy-models-dirty",
2948
+ children: t("row.modelsDirty")
2949
+ }) : null]
2950
+ })]
2951
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2952
+ className: "dsm-workbuddy-models-summary",
2953
+ children: t("row.modelsSummary", { count: activeEnabledIds.size })
2954
+ })] }),
2955
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2956
+ className: "dsm-workbuddy-models-fold-hint",
2957
+ "aria-hidden": "true",
2958
+ children: modelsOpen ? t("row.modelsFoldHide") : t("row.modelsFoldShow")
2981
2959
  }),
2982
2960
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2983
- className: "dsm-workbuddy-model-details",
2984
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2985
- className: "dsm-workbuddy-model-meta",
2961
+ className: "dsm-workbuddy-models-head-actions",
2962
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2963
+ type: "button",
2964
+ className: "dsm-btn dsm-btn-outline",
2965
+ disabled: busy,
2966
+ onClick: () => {
2967
+ refreshModels();
2968
+ },
2969
+ children: busy ? t("row.modelsRefreshing") : t("row.modelsRefresh")
2970
+ })
2971
+ })
2972
+ ]
2973
+ }),
2974
+ probeError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2975
+ className: "dsm-workbuddy-model-probe-result dsm-workbuddy-model-probe-result-bad",
2976
+ role: "alert",
2977
+ children: t("row.probeError", { message: probeError })
2978
+ }),
2979
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2980
+ className: "dsm-workbuddy-model-list",
2981
+ children: visibleModels.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2982
+ className: `dsm-workbuddy-model${activeEnabledIds.has(model.id) ? "" : " dsm-workbuddy-model-disabled"}`,
2983
+ children: [
2984
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2985
+ className: "dsm-workbuddy-model-head",
2986
2986
  children: [
2987
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelContext", { context: formatCapacity(model.nativeContextWindow, t("row.modelUnknown")) }) }),
2988
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelOutput", { output: formatCapacity(model.maxTokens, t("row.modelUnknown")) }) }),
2989
- model.reasoning === void 0 || model.reasoning.supportedEfforts === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelReasoning", { efforts: model.reasoning.supportedEfforts.join(" / ") }) })
2987
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2988
+ className: "dsm-workbuddy-model-enabled",
2989
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2990
+ type: "checkbox",
2991
+ checked: activeEnabledIds.has(model.id),
2992
+ disabled: !canWrite || saving,
2993
+ onChange: () => {
2994
+ toggleModel(model.id);
2995
+ }
2996
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2997
+ className: "dsm-workbuddy-model-copy",
2998
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2999
+ className: "dsm-workbuddy-model-name",
3000
+ children: [model.name, model.creditMultiplier === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3001
+ className: "dsm-workbuddy-model-name-rate",
3002
+ children: [
3003
+ "(",
3004
+ model.creditMultiplier.toFixed(2),
3005
+ "x)"
3006
+ ]
3007
+ })]
3008
+ })
3009
+ })]
3010
+ }),
3011
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
3012
+ className: "dsm-workbuddy-model-image",
3013
+ title: imageCheckboxHint(model, t),
3014
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3015
+ type: "checkbox",
3016
+ checked: activeImageIds.has(model.id),
3017
+ disabled: !canWrite || saving,
3018
+ onChange: () => {
3019
+ toggleImage(model.id);
3020
+ }
3021
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelImage") })]
3022
+ }),
3023
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
3024
+ className: "dsm-workbuddy-context-budget",
3025
+ "aria-label": t("row.contextBudget"),
3026
+ children: [model.nativeContextWindow > 2e5 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3027
+ type: "radio",
3028
+ name: `context-${model.id}`,
3029
+ checked: (activeContextBudgets[model.id] ?? 2e5) === 2e5,
3030
+ disabled: !canWrite || saving,
3031
+ onChange: () => {
3032
+ setContextBudget(model.id, 2e5);
3033
+ }
3034
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "200K" })] }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
3035
+ type: "radio",
3036
+ name: `context-${model.id}`,
3037
+ checked: model.nativeContextWindow <= 2e5 || activeContextBudgets[model.id] === model.nativeContextWindow,
3038
+ disabled: model.nativeContextWindow <= 2e5 || !canWrite || saving,
3039
+ onChange: () => {
3040
+ setContextBudget(model.id, model.nativeContextWindow);
3041
+ }
3042
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatCapacity(model.nativeContextWindow, t("row.modelUnknown")) })] })]
3043
+ }),
3044
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3045
+ className: "dsm-workbuddy-model-actions-buttons",
3046
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3047
+ type: "button",
3048
+ className: "dsm-btn dsm-btn-outline dsm-workbuddy-model-probe",
3049
+ disabled: probing[model.id] === true || status.status !== "signed-in",
3050
+ title: t("row.probeHint"),
3051
+ onClick: () => {
3052
+ probeModels([model.id]);
3053
+ },
3054
+ children: probing[model.id] === true ? t("row.probing") : t("row.probe")
3055
+ })
3056
+ })
2990
3057
  ]
2991
- })
3058
+ }),
3059
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3060
+ className: "dsm-workbuddy-model-details",
3061
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3062
+ className: "dsm-workbuddy-model-meta",
3063
+ children: [
3064
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelContext", { context: formatCapacity(model.nativeContextWindow, t("row.modelUnknown")) }) }),
3065
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelOutput", { output: formatCapacity(model.maxTokens, t("row.modelUnknown")) }) }),
3066
+ model.reasoning === void 0 || model.reasoning.supportedEfforts === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.modelReasoning", { efforts: model.reasoning.supportedEfforts.join(" / ") }) })
3067
+ ]
3068
+ })
3069
+ }),
3070
+ (() => {
3071
+ const result = probes[activeRegion]?.[model.id];
3072
+ if (result === void 0) return null;
3073
+ const view = probeResultView(result, t);
3074
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
3075
+ className: `dsm-workbuddy-model-probe-result dsm-workbuddy-model-probe-result-${view.tone}`,
3076
+ role: "status",
3077
+ children: [view.text, result.message === void 0 ? null : " · " + result.message]
3078
+ });
3079
+ })()
3080
+ ]
3081
+ }, model.id))
3082
+ }),
3083
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3084
+ className: "dsm-workbuddy-model-capability-note",
3085
+ children: t("row.modelCapabilityPending")
3086
+ }),
3087
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3088
+ className: "dsm-workbuddy-model-actions",
3089
+ children: [
3090
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3091
+ className: "dsm-workbuddy-usage-cheer",
3092
+ href: WORKBUDDY_GITHUB_URL,
3093
+ target: "_blank",
3094
+ rel: "noopener noreferrer",
3095
+ children: [t("row.cheer"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3096
+ className: "dsm-workbuddy-usage-cheer-star",
3097
+ "aria-hidden": "true",
3098
+ children: "★"
3099
+ })]
2992
3100
  }),
2993
- (() => {
2994
- const result = probes[activeRegion]?.[model.id];
2995
- if (result === void 0) return null;
2996
- const view = probeResultView(result, t);
2997
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2998
- className: `dsm-workbuddy-model-probe-result dsm-workbuddy-model-probe-result-${view.tone}`,
2999
- role: "status",
3000
- children: [view.text, result.message === void 0 ? null : " · " + result.message]
3001
- });
3002
- })()
3101
+ saveError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3102
+ className: "dsm-workbuddy-model-save-error",
3103
+ children: t("row.saveError", { message: saveError })
3104
+ }),
3105
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3106
+ className: "dsm-workbuddy-model-actions-buttons",
3107
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3108
+ type: "button",
3109
+ className: "dsm-btn dsm-btn-outline",
3110
+ disabled: !dirty || saving || poolBusy || togglingRegion !== void 0,
3111
+ onClick: discardModels,
3112
+ children: t("row.discard")
3113
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3114
+ type: "button",
3115
+ className: "dsm-btn dsm-btn-primary",
3116
+ disabled: !dirty || saving || poolBusy || togglingRegion !== void 0 || activeEnabledIds.size === 0,
3117
+ onClick: () => {
3118
+ saveModels();
3119
+ },
3120
+ children: saving ? t("row.saving") : t("row.save")
3121
+ })]
3122
+ })
3003
3123
  ]
3004
- }, model.id))
3005
- }),
3006
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3007
- className: "dsm-workbuddy-model-capability-note",
3008
- children: t("row.modelCapabilityPending")
3009
- }),
3010
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3011
- className: "dsm-workbuddy-model-actions",
3012
- children: [
3013
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("a", {
3014
- className: "dsm-workbuddy-usage-cheer",
3015
- href: WORKBUDDY_GITHUB_URL,
3016
- target: "_blank",
3017
- rel: "noopener noreferrer",
3018
- children: [t("row.cheer"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3019
- className: "dsm-workbuddy-usage-cheer-star",
3020
- "aria-hidden": "true",
3021
- children: "★"
3022
- })]
3023
- }),
3024
- saveError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3025
- className: "dsm-workbuddy-model-save-error",
3026
- children: t("row.saveError", { message: saveError })
3027
- }),
3028
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3029
- className: "dsm-workbuddy-model-actions-buttons",
3030
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3031
- type: "button",
3032
- className: "dsm-btn dsm-btn-outline",
3033
- disabled: !dirty || saving || poolBusy || togglingRegion !== void 0,
3034
- onClick: discardModels,
3035
- children: t("row.discard")
3036
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3037
- type: "button",
3038
- className: "dsm-btn dsm-btn-primary",
3039
- disabled: !dirty || saving || poolBusy || togglingRegion !== void 0 || activeEnabledIds.size === 0,
3040
- onClick: () => {
3041
- saveModels();
3042
- },
3043
- children: saving ? t("row.saving") : t("row.save")
3044
- })]
3045
- })
3046
- ]
3047
- })
3048
- ]
3124
+ })
3125
+ ]
3126
+ })
3049
3127
  }),
3050
3128
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccountPool, {
3051
3129
  t,
@@ -3157,6 +3235,10 @@ window.__ModuleLoader__.load({
3157
3235
  "row.selectionLostMessage": "The account saved for this region is no longer among the local sign-ins (WorkBuddy replaced its login or cleaned up backups). Your other sign-ins are fine — pick a current one from the list above. Signing in again in the desktop app will not fix this.",
3158
3236
  "row.modelsTitle": "Models",
3159
3237
  "row.modelsSummary": "{count} enabled",
3238
+ "row.modelsDirty": "unsaved changes",
3239
+ "row.modelsFoldHint": "Expand or collapse the model list",
3240
+ "row.modelsFoldShow": "Click to expand",
3241
+ "row.modelsFoldHide": "Click to collapse",
3160
3242
  "row.modelsRefresh": "Refresh from WorkBuddy",
3161
3243
  "row.modelsRefreshing": "Refreshing models…",
3162
3244
  "row.discard": "Discard changes",
@@ -3350,6 +3432,10 @@ window.__ModuleLoader__.load({
3350
3432
  "row.selectionLostMessage": "该区域保存的账号已不在本机登录列表中(WorkBuddy 更换了登录或清理了备份文件)。本机其他登录仍然可用——请在上方列表中重新选择一个当前账号。重新登录桌面端 App 无法修复此问题。",
3351
3433
  "row.modelsTitle": "模型",
3352
3434
  "row.modelsSummary": "已启用 {count} 个",
3435
+ "row.modelsDirty": "有未保存的修改",
3436
+ "row.modelsFoldHint": "展开或收起模型列表",
3437
+ "row.modelsFoldShow": "点击展开",
3438
+ "row.modelsFoldHide": "点击收起",
3353
3439
  "row.modelsRefresh": "从 WorkBuddy 刷新",
3354
3440
  "row.modelsRefreshing": "正在刷新模型…",
3355
3441
  "row.discard": "放弃修改",
@@ -6,6 +6,7 @@ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
6
6
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
7
7
  import { execFile, execFileSync } from "node:child_process";
8
8
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
9
10
  //#region src/at-rest.ts
10
11
  /**
11
12
  * WorkBuddy desktop "at-rest" credential decryption.
@@ -535,9 +536,53 @@ function billingHeaders(credential) {
535
536
  return headers;
536
537
  }
537
538
  /**
539
+ * Stand-in system message for a request that reached the wire carrying none.
540
+ *
541
+ * Not a stylistic default: both WorkBuddy gateways want the conversation to
542
+ * OPEN with a system message, and the international one enforces it — a
543
+ * user-first body there is refused with business code 11128
544
+ * (`first message is not system prompt`), which the gateway surfaces as
545
+ * "blocked by security policy". The domestic gateway tolerates the same body,
546
+ * so the fault only ever shows up on the international route (issue: a global
547
+ * model failing every step while the CN one is fine).
548
+ *
549
+ * A system message can go missing before this module ever sees the body:
550
+ * `dsh-llm-pi-ai` folds a leading `system` message into `Context.systemPrompt`
551
+ * and pi-ai only emits that prompt `if (context.systemPrompt)` — so an EMPTY
552
+ * prompt emits no system message at all, and pi-ai demotes any `system` entry
553
+ * left in `messages` to `user`. By the time the shim holds the JSON, the real
554
+ * prompt is no longer recoverable, and a minimal placeholder is strictly better
555
+ * than a guaranteed 400.
556
+ *
557
+ * Deliberately tiny: this is a last-resort placeholder, not a persona. Inventing
558
+ * a longer one would quietly change model behaviour on every affected request.
559
+ */
560
+ const WORKBUDDY_FALLBACK_SYSTEM_PROMPT = "You are a helpful assistant.";
561
+ /**
562
+ * Make the conversation open with a system message, prepending the fallback
563
+ * when nothing else supplies one.
564
+ *
565
+ * `developer` is normalized to `system` first (the gateways reject
566
+ * `developer`), so an ordinary DSH request already satisfies this and the
567
+ * function is a no-op for it.
568
+ */
569
+ function ensureSystemHead(obj) {
570
+ const messages = obj["messages"];
571
+ if (!Array.isArray(messages) || messages.length === 0) return;
572
+ const head = messages[0];
573
+ if (typeof head !== "object" || head === null || Array.isArray(head)) return;
574
+ const role = head["role"];
575
+ if (typeof role === "string" && role.trim().toLowerCase() === "system") return;
576
+ messages.unshift({
577
+ role: "system",
578
+ content: WORKBUDDY_FALLBACK_SYSTEM_PROMPT
579
+ });
580
+ }
581
+ /**
538
582
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
539
- * force `stream: true` (the upstream rejects non-streaming) and flatten
540
- * `tool_choice` (the upstream's field is a string; object forms return 400).
583
+ * force `stream: true` (the upstream rejects non-streaming), flatten
584
+ * `tool_choice` (the upstream's field is a string; object forms return 400),
585
+ * and guarantee a leading system message.
541
586
  */
542
587
  function prepareChatBody(source) {
543
588
  let body;
@@ -554,6 +599,7 @@ function prepareChatBody(source) {
554
599
  const message = value;
555
600
  if (message["role"] === "developer") message["role"] = "system";
556
601
  }
602
+ ensureSystemHead(obj);
557
603
  normalizeToolChoice(obj);
558
604
  return JSON.stringify(obj);
559
605
  }
@@ -2284,9 +2330,136 @@ var WorkBuddyCatalog = class {
2284
2330
  }
2285
2331
  };
2286
2332
  //#endregion
2333
+ //#region src/pi-ai-runtime.ts
2334
+ /**
2335
+ * Which `@earendil-works/pi-ai` copy THIS plugin actually resolves at runtime.
2336
+ *
2337
+ * WHY THIS EXISTS (issues #24 / #25 / #26). The plugin declares pi-ai as a
2338
+ * peer (`>=0.85.0 <0.88.0`), so the module it imports is whatever the host
2339
+ * environment provides — and the two generations consume DIFFERENT context
2340
+ * shapes:
2341
+ *
2342
+ * - 0.87 hands providers a normalized transcript: `{ messages: [ {role:
2343
+ * 'system', content, toolsAdded}, … ] }`. The system prompt and the tool
2344
+ * declarations live ONLY on that leading system message; the top-level
2345
+ * `systemPrompt` / `tools` fields are gone, and 0.87's api never reads them
2346
+ * back.
2347
+ * - 0.85 expects `{ systemPrompt, tools, messages }` and has no branch for a
2348
+ * `system` message inside `messages` at all: its token estimator crashes on
2349
+ * one (issue #24), and its wire converter silently drops it.
2350
+ *
2351
+ * Which copy resolves is NOT predictable from the host version alone: a
2352
+ * nested 0.85.1 on disk shadows the host's 0.87.1 (the plugin author's own
2353
+ * symlinked checkout does exactly that), while a clean market install has no
2354
+ * local copy at all and resolves the host's (issue #26's reporter). The
2355
+ * adapter therefore gates on what THIS plugin resolves.
2356
+ *
2357
+ * HOW it is detected — deliberately WITHOUT importing pi-ai:
2358
+ * `import.meta.resolve` locates the entry file (executing nothing), the
2359
+ * package manifest sitting beside it names the version, and the generation is
2360
+ * decided from that version (0.87+ → modern). A static import would drag
2361
+ * pi-ai into the standalone `doctor` CLI, which must also run from market
2362
+ * installs where NO local pi-ai exists and the module is only provided inside
2363
+ * the DSH host — a static import there is a load-time crash. Resolution
2364
+ * itself does not need the module: within the peer range, the manifest
2365
+ * version and the `normalizeContext` feature (present from 0.87 on, not
2366
+ * before) agree.
2367
+ *
2368
+ * When resolution FAILS — the normal state of a market install probed outside
2369
+ * the host — there is no local copy, so inside DSH the host supplies pi-ai.
2370
+ * Every DSH host that normalizes contexts (0.2.0+, via 0.87.1) also supplies
2371
+ * 0.87.1, so the reported generation defaults to `modern` (pass-through).
2372
+ * That default is safe on older hosts too: they never normalize, and a native
2373
+ * 0.85-shaped context is an identity under BOTH branches of the adapter gate.
2374
+ *
2375
+ * `doctor` prints the resolved version and path so mixed-generation setups —
2376
+ * the silent kind that produced #24 and #26 — become visible on demand.
2377
+ *
2378
+ * @module dsh-connect-workbuddy/pi-ai-runtime
2379
+ */
2380
+ let cached;
2381
+ /** Resolve (once) which pi-ai copy this plugin runs against. */
2382
+ function piAiRuntimeInfo() {
2383
+ cached ??= piAiRuntimeFor(resolveModuleFile());
2384
+ return cached;
2385
+ }
2386
+ /**
2387
+ * The decision table for one resolution result, exported so the branch that
2388
+ * only ever runs on OTHER machines (a market install with no local pi-ai) is
2389
+ * pinned by tests rather than by this comment.
2390
+ *
2391
+ * `undefined` means nothing local resolved — inside DSH the host supplies the
2392
+ * module, and every host that normalizes contexts (0.2.0+, via 0.87.1) ships
2393
+ * the modern generation, so that is the default. Older hosts never normalize,
2394
+ * and their native 0.85-shaped context is an identity under either gate
2395
+ * branch, so the default cannot hurt them.
2396
+ */
2397
+ function piAiRuntimeFor(resolvedFrom) {
2398
+ if (resolvedFrom === void 0) return {
2399
+ generation: "modern",
2400
+ resolvedFrom: void 0,
2401
+ version: void 0,
2402
+ resolvedLocally: false
2403
+ };
2404
+ const version = readVersion(resolvedFrom);
2405
+ return {
2406
+ generation: generationOf(version),
2407
+ resolvedFrom,
2408
+ version,
2409
+ resolvedLocally: true
2410
+ };
2411
+ }
2412
+ /**
2413
+ * The file the main entry resolved to; undefined when resolution itself fails.
2414
+ *
2415
+ * `import.meta.resolve` (not `require.resolve`): pi-ai's exports map exposes
2416
+ * "." under the `import` condition only, so the CJS resolver refuses the bare
2417
+ * specifier outright — the ESM resolver is the one this plugin actually
2418
+ * imports with, so it is also the one whose answer is meaningful here.
2419
+ */
2420
+ function resolveModuleFile() {
2421
+ try {
2422
+ return fileURLToPath(import.meta.resolve("@earendil-works/pi-ai"));
2423
+ } catch {
2424
+ return;
2425
+ }
2426
+ }
2427
+ /**
2428
+ * The version of the package owning `moduleFile`. pi-ai's exports map does
2429
+ * not expose `./package.json`, so the manifest is found by walking up from
2430
+ * the resolved entry — `dist/index.js` sits directly inside the package —
2431
+ * and claiming it only when its `name` matches, so a same-named ancestor
2432
+ * (a pnpm virtual-store parent, for instance) cannot be misread.
2433
+ */
2434
+ function readVersion(moduleFile) {
2435
+ if (moduleFile === void 0) return void 0;
2436
+ let directory = dirname(moduleFile);
2437
+ for (let depth = 0; depth < 4; depth++) {
2438
+ try {
2439
+ const parsed = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
2440
+ if (parsed.name === "@earendil-works/pi-ai" && typeof parsed.version === "string") return parsed.version;
2441
+ } catch {}
2442
+ const parent = dirname(directory);
2443
+ if (parent === directory) return void 0;
2444
+ directory = parent;
2445
+ }
2446
+ }
2447
+ /**
2448
+ * The generation a manifest version belongs to. Within the peer range
2449
+ * (<0.88.0) the split is exactly `0.87+` versus earlier; an unreadable
2450
+ * version conservatively reports `legacy` (and doctor shows it as
2451
+ * unreadable, so the guess is visible rather than silent).
2452
+ */
2453
+ function generationOf(version) {
2454
+ if (version === void 0) return "legacy";
2455
+ const match = /^0\.(\d+)\./u.exec(version);
2456
+ if (match === null) return "legacy";
2457
+ return Number(match[1]) >= 87 ? "modern" : "legacy";
2458
+ }
2459
+ //#endregion
2287
2460
  //#region src/version.ts
2288
2461
  /** The npm package version this build was produced from. */
2289
- const WORKBUDDY_CONNECT_VERSION = "3.0.1";
2462
+ const WORKBUDDY_CONNECT_VERSION = "3.1.0";
2290
2463
  //#endregion
2291
2464
  //#region src/host-heartbeat.ts
2292
2465
  /**
@@ -2444,4 +2617,4 @@ async function clearHostHeartbeat() {
2444
2617
  await rm(workbuddyHostHeartbeatPath(), { force: true });
2445
2618
  }
2446
2619
  //#endregion
2447
- export { WorkBuddyCredentialRejectedError as A, clearAtRestKeyCache as B, hasEncryptedCredentialFields as C, workbuddyAccountId as D, parseWorkBuddyAuth as E, parseReasoning as F, isEncryptedFieldWrapper as G, deriveAtRestKeyId as H, parseUpstreamModel as I, macosNestedAppBundles as J, isWorkbuddyBundle as K, prepareChatBody as L, classifyUpstreamError as M, isCredentialRejectedError as N, workbuddyOwnAuthPath as O, parseCreditMultiplier as P, regionOf as R, defaultDesktopAuthPath as S, legacyWorkbuddyOwnAuthPath as T, fetchAtRestKeyPayload as U, deriveAtRestKey as V, findWorkbuddyAppExecutable as W, readAtRestKey as X, openEncryptedField as Y, workbuddyAppExecutableCandidates as Z, WorkBuddyCredentialStore as _, readHostHeartbeat as a, defaultDesktopAuthCandidates as b, WORKBUDDY_CONNECT_VERSION as c, WorkBuddyCatalog as d, deriveCatalog as f, WORKBUDDY_AUTH_FILE_ENV as g, WORKBUDDY_AUTH_FILENAME as h, processStartTimeMs as i, WorkBuddyUpstreamClient as j, CREDENTIAL_REJECTED_CODE as k, FALLBACK_WORKBUDDY_MODELS as l, ENCRYPTED_CREDENTIAL_CODE as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, fallbackModelsFor as p, macosBundleExecutable as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS_GLOBAL as u, WorkBuddyEncryptedCredentialError as v, isEncryptedCredentialError as w, defaultDesktopAuthDirs as x, authFileName as y, WORKBUDDY_APP_EXECUTABLE_ENV as z };
2620
+ export { workbuddyAppExecutableCandidates as $, CREDENTIAL_REJECTED_CODE as A, regionOf as B, defaultDesktopAuthPath as C, parseWorkBuddyAuth as D, legacyWorkbuddyOwnAuthPath as E, isCredentialRejectedError as F, fetchAtRestKeyPayload as G, clearAtRestKeyCache as H, parseCreditMultiplier as I, isWorkbuddyBundle as J, findWorkbuddyAppExecutable as K, parseReasoning as L, WorkBuddyCredentialRejectedError as M, WorkBuddyUpstreamClient as N, workbuddyAccountId as O, classifyUpstreamError as P, readAtRestKey as Q, parseUpstreamModel as R, defaultDesktopAuthDirs as S, isEncryptedCredentialError as T, deriveAtRestKey as U, WORKBUDDY_APP_EXECUTABLE_ENV as V, deriveAtRestKeyId as W, macosNestedAppBundles as X, macosBundleExecutable as Y, openEncryptedField as Z, WORKBUDDY_AUTH_FILE_ENV as _, readHostHeartbeat as a, authFileName as b, WORKBUDDY_CONNECT_VERSION as c, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, WorkBuddyCatalog as f, WORKBUDDY_AUTH_FILENAME as g, ENCRYPTED_CREDENTIAL_CODE as h, processStartTimeMs as i, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as j, workbuddyOwnAuthPath as k, piAiRuntimeInfo as l, fallbackModelsFor as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, deriveCatalog as p, isEncryptedFieldWrapper as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, WorkBuddyCredentialStore as v, hasEncryptedCredentialFields as w, defaultDesktopAuthCandidates as x, WorkBuddyEncryptedCredentialError as y, prepareChatBody as z };
package/lib/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
+ import "@earendil-works/pi-ai";
2
3
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
3
4
  import { Context } from "@deepseek-ai/cordis";
4
5
  import { SettingsNamespace } from "@deepseek-ai/dsh-settings";
@@ -568,8 +569,9 @@ declare function classifyUpstreamError(status: number, body: string): UpstreamEr
568
569
  declare function regionOf(domain: string): WorkBuddyRegion;
569
570
  /**
570
571
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
571
- * force `stream: true` (the upstream rejects non-streaming) and flatten
572
- * `tool_choice` (the upstream's field is a string; object forms return 400).
572
+ * force `stream: true` (the upstream rejects non-streaming), flatten
573
+ * `tool_choice` (the upstream's field is a string; object forms return 400),
574
+ * and guarantee a leading system message.
573
575
  */
574
576
  declare function prepareChatBody(source: string): string;
575
577
  /** Marker carried by {@link WorkBuddyCredentialRejectedError}; survives bundling. */
@@ -804,6 +806,56 @@ interface WorkBuddyShimOptions {
804
806
  */
805
807
  declare function createWorkBuddyShim(options: WorkBuddyShimOptions): WorkBuddyShim;
806
808
  //#endregion
809
+ //#region src/pi-ai-runtime.d.ts
810
+ /**
811
+ * Which `@earendil-works/pi-ai` copy THIS plugin actually resolves at runtime.
812
+ *
813
+ * WHY THIS EXISTS (issues #24 / #25 / #26). The plugin declares pi-ai as a
814
+ * peer (`>=0.85.0 <0.88.0`), so the module it imports is whatever the host
815
+ * environment provides — and the two generations consume DIFFERENT context
816
+ * shapes:
817
+ *
818
+ * - 0.87 hands providers a normalized transcript: `{ messages: [ {role:
819
+ * 'system', content, toolsAdded}, … ] }`. The system prompt and the tool
820
+ * declarations live ONLY on that leading system message; the top-level
821
+ * `systemPrompt` / `tools` fields are gone, and 0.87's api never reads them
822
+ * back.
823
+ * - 0.85 expects `{ systemPrompt, tools, messages }` and has no branch for a
824
+ * `system` message inside `messages` at all: its token estimator crashes on
825
+ * one (issue #24), and its wire converter silently drops it.
826
+ *
827
+ * Which copy resolves is NOT predictable from the host version alone: a
828
+ * nested 0.85.1 on disk shadows the host's 0.87.1 (the plugin author's own
829
+ * symlinked checkout does exactly that), while a clean market install has no
830
+ * local copy at all and resolves the host's (issue #26's reporter). The
831
+ * adapter therefore gates on what THIS plugin resolves.
832
+ *
833
+ * HOW it is detected — deliberately WITHOUT importing pi-ai:
834
+ * `import.meta.resolve` locates the entry file (executing nothing), the
835
+ * package manifest sitting beside it names the version, and the generation is
836
+ * decided from that version (0.87+ → modern). A static import would drag
837
+ * pi-ai into the standalone `doctor` CLI, which must also run from market
838
+ * installs where NO local pi-ai exists and the module is only provided inside
839
+ * the DSH host — a static import there is a load-time crash. Resolution
840
+ * itself does not need the module: within the peer range, the manifest
841
+ * version and the `normalizeContext` feature (present from 0.87 on, not
842
+ * before) agree.
843
+ *
844
+ * When resolution FAILS — the normal state of a market install probed outside
845
+ * the host — there is no local copy, so inside DSH the host supplies pi-ai.
846
+ * Every DSH host that normalizes contexts (0.2.0+, via 0.87.1) also supplies
847
+ * 0.87.1, so the reported generation defaults to `modern` (pass-through).
848
+ * That default is safe on older hosts too: they never normalize, and a native
849
+ * 0.85-shaped context is an identity under BOTH branches of the adapter gate.
850
+ *
851
+ * `doctor` prints the resolved version and path so mixed-generation setups —
852
+ * the silent kind that produced #24 and #26 — become visible on demand.
853
+ *
854
+ * @module dsh-connect-workbuddy/pi-ai-runtime
855
+ */
856
+ /** The resolved pi-ai generation, by context-shape capability. */
857
+ type PiAiGeneration = 'modern' | 'legacy';
858
+ //#endregion
807
859
  //#region src/adapter.d.ts
808
860
  /** Provider route this bundle owns for the domestic (CN) gateway. */
809
861
  declare const WORKBUDDY_PROVIDER = "workbuddy";
@@ -828,6 +880,12 @@ interface WorkBuddyAdapterOptions {
828
880
  displayName?: string;
829
881
  /** Resolve the durable attachment service at request time, when present. */
830
882
  resolveAttachments?: () => AttachmentStore | undefined;
883
+ /**
884
+ * Override the pi-ai generation gate (see {@link piAiRuntimeInfo}); tests
885
+ * inject both branches because this checkout only ever RESOLVES one of
886
+ * them. Production callers leave it unset.
887
+ */
888
+ piAiGeneration?: PiAiGeneration;
831
889
  }
832
890
  /** What {@link createWorkBuddyAdapter} hands back. */
833
891
  interface WorkBuddyAdapter {
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { A as WorkBuddyCredentialRejectedError, B as clearAtRestKeyCache, C as hasEncryptedCredentialFields, D as workbuddyAccountId, E as parseWorkBuddyAuth, F as parseReasoning, G as isEncryptedFieldWrapper, H as deriveAtRestKeyId, I as parseUpstreamModel, J as macosNestedAppBundles, K as isWorkbuddyBundle, L as prepareChatBody, M as classifyUpstreamError, N as isCredentialRejectedError, O as workbuddyOwnAuthPath, P as parseCreditMultiplier, R as regionOf, S as defaultDesktopAuthPath, T as legacyWorkbuddyOwnAuthPath, U as fetchAtRestKeyPayload, V as deriveAtRestKey, W as findWorkbuddyAppExecutable, X as readAtRestKey, Y as openEncryptedField, Z as workbuddyAppExecutableCandidates, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WORKBUDDY_AUTH_FILE_ENV, h as WORKBUDDY_AUTH_FILENAME, i as processStartTimeMs, j as WorkBuddyUpstreamClient, k as CREDENTIAL_REJECTED_CODE, l as FALLBACK_WORKBUDDY_MODELS, m as ENCRYPTED_CREDENTIAL_CODE, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, q as macosBundleExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as WorkBuddyEncryptedCredentialError, w as isEncryptedCredentialError, x as defaultDesktopAuthDirs, y as authFileName, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-CfXJht7R.js";
1
+ import { $ as workbuddyAppExecutableCandidates, A as CREDENTIAL_REJECTED_CODE, B as regionOf, C as defaultDesktopAuthPath, D as parseWorkBuddyAuth, E as legacyWorkbuddyOwnAuthPath, F as isCredentialRejectedError, G as fetchAtRestKeyPayload, H as clearAtRestKeyCache, I as parseCreditMultiplier, J as isWorkbuddyBundle, K as findWorkbuddyAppExecutable, L as parseReasoning, M as WorkBuddyCredentialRejectedError, N as WorkBuddyUpstreamClient, O as workbuddyAccountId, P as classifyUpstreamError, Q as readAtRestKey, R as parseUpstreamModel, S as defaultDesktopAuthDirs, T as isEncryptedCredentialError, U as deriveAtRestKey, V as WORKBUDDY_APP_EXECUTABLE_ENV, W as deriveAtRestKeyId, X as macosNestedAppBundles, Y as macosBundleExecutable, Z as openEncryptedField, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as authFileName, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, f as WorkBuddyCatalog, g as WORKBUDDY_AUTH_FILENAME, h as ENCRYPTED_CREDENTIAL_CODE, i as processStartTimeMs, j as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, m as fallbackModelsFor, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as deriveCatalog, q as isEncryptedFieldWrapper, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, w as hasEncryptedCredentialFields, x as defaultDesktopAuthCandidates, y as WorkBuddyEncryptedCredentialError, z as prepareChatBody } from "./host-heartbeat-DZnPggVT.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { randomBytes, timingSafeEqual } from "node:crypto";
@@ -213,6 +213,152 @@ function toPiModel(info, baseUrl, providerId) {
213
213
  };
214
214
  }
215
215
  /**
216
+ * Rewrite a pi-ai 0.87-shaped transcript into the 0.85 shape this plugin's
217
+ * pi-ai understands — prompt AND tools.
218
+ *
219
+ * WHY THIS EXISTS (issue #24). pi-ai 0.87's `normalizeContext` moves the system
220
+ * prompt INTO `messages` as `{ role: 'system', content: '<string>' }`. pi-ai
221
+ * 0.85's own `Message` union has no `system` variant
222
+ * (`UserMessage | AssistantMessage | ToolResultMessage`), so its
223
+ * `estimateMessageTokens` has no branch for one: `for (const block of
224
+ * message.content)` iterates the CONTENT STRING character by character, `block`
225
+ * is a single character, and `block.name.length` throws
226
+ * `Cannot read properties of undefined (reading 'length')`.
227
+ *
228
+ * That crash happens inside the library, in `buildBaseOptions ->
229
+ * clampMaxTokensToContext -> estimateContextTokens`, i.e. BEFORE any request is
230
+ * built — so every model fails instantly and no upstream traffic is sent. A
231
+ * host running 0.87 hands us the normalized transcript while our own provider
232
+ * is 0.85, and the plugin cannot patch the library. What it CAN do is not hand
233
+ * a 0.87 transcript to a 0.85 API object.
234
+ *
235
+ * The 0.87 transcript carries MORE than the prompt on that leading system
236
+ * message: `toolsAdded` (and `toolsRemoved`) are the ONLY place the tool
237
+ * declarations live — `normalizeContext` deleted the top-level `tools` field,
238
+ * and 0.85's api reads tools ONLY from `context.tools`. So folding the text
239
+ * without promoting the tool state would trade issue #24's loud crash for
240
+ * issue #26's silent one: models that can never emit a `toolCall` because the
241
+ * request never declared any tools. The fold here promotes both, replaying
242
+ * every system message's `toolsRemoved`/`toolsAdded` in order — exactly the
243
+ * merge pi-ai 0.87's own `getCurrentTools` performs — and leaves an existing
244
+ * top-level `tools` array untouched.
245
+ *
246
+ * Deliberately SHAPE-based within the legacy branch, not version-based: it
247
+ * asks "does this context carry a system message inside `messages`?" rather
248
+ * than "which pi-ai version is loaded?". A version check would be wrong the
249
+ * moment either side moves. Which BRANCH runs at all IS generation-gated —
250
+ * see {@link withLegacyContext}: a modern (0.87+) api consumes this very
251
+ * transcript natively, and folding for it would destroy its only carrier of
252
+ * prompt and tools (issues #25/#26).
253
+ *
254
+ * Returns the SAME object when there is nothing to adapt — in particular when
255
+ * `systemPrompt` is already set (the native 0.85 shape), so the ordinary path
256
+ * is byte-for-byte untouched.
257
+ *
258
+ * Two guards beyond the 0.87 fold, both about a request reaching the upstream
259
+ * with NO system message — which the international gateway refuses outright
260
+ * (business code 11128) while the domestic one tolerates:
261
+ *
262
+ * - an EMPTY `systemPrompt` is treated as absent rather than "already set".
263
+ * pi-ai emits the prompt under `if (context.systemPrompt)`, so an empty one
264
+ * produces no system message at all and pi-ai demotes any `system` entry still
265
+ * in `messages` to `user`. Falling through here lets a real system message
266
+ * that IS present in `messages` be folded up and preserved.
267
+ * - when nothing supplies a prompt, {@link WORKBUDDY_FALLBACK_SYSTEM_PROMPT} is
268
+ * used, because a placeholder beats a guaranteed 400. A system message whose
269
+ * text is empty but which carries `toolsAdded` still folds its TOOLS up; the
270
+ * placeholder for the missing prompt then comes from `ensureSystemHead` at
271
+ * the wire (the last of the three layers).
272
+ */
273
+ function adaptLegacyPiAiContext(context) {
274
+ if (context === null || typeof context !== "object") return context;
275
+ if (context.systemPrompt) return context;
276
+ const messages = Array.isArray(context.messages) ? context.messages : void 0;
277
+ if (messages === void 0) return context;
278
+ const systemTexts = [];
279
+ const rest = [];
280
+ const toolState = /* @__PURE__ */ new Map();
281
+ let sawSystemMessage = false;
282
+ for (const message of messages) {
283
+ if (message?.role !== "system") {
284
+ rest.push(message);
285
+ continue;
286
+ }
287
+ sawSystemMessage = true;
288
+ const text = systemTextOf(message);
289
+ if (text !== "") systemTexts.push(text);
290
+ replayToolState(message, toolState);
291
+ }
292
+ const head = rest[0];
293
+ const needsFallback = !sawSystemMessage && head !== void 0 && head.role !== "system";
294
+ if (!sawSystemMessage && !needsFallback) return context;
295
+ const systemPrompt = systemTexts.length > 0 ? systemTexts.join("\n\n") : sawSystemMessage ? void 0 : WORKBUDDY_FALLBACK_SYSTEM_PROMPT;
296
+ const tools = Array.isArray(context.tools) ? void 0 : [...toolState.values()];
297
+ return {
298
+ ...context,
299
+ ...systemPrompt === void 0 ? {} : { systemPrompt },
300
+ ...tools !== void 0 && tools.length > 0 ? { tools } : {},
301
+ messages: rest
302
+ };
303
+ }
304
+ /**
305
+ * Replay one 0.87 system message's tool deltas into `state`, the same merge
306
+ * pi-ai 0.87's `getCurrentTools` performs: removals first, then additions,
307
+ * keyed by name so a mid-conversation redeclaration replaces the original.
308
+ */
309
+ function replayToolState(message, state) {
310
+ const removed = message.toolsRemoved;
311
+ if (Array.isArray(removed)) for (const tool of removed) {
312
+ const name = tool?.name;
313
+ if (typeof name === "string") state.delete(name);
314
+ }
315
+ const added = message.toolsAdded;
316
+ if (Array.isArray(added)) for (const tool of added) {
317
+ const name = tool?.name;
318
+ if (typeof name === "string") state.set(name, tool);
319
+ }
320
+ }
321
+ /** The text of one system message, whether it is a string or text blocks. */
322
+ function systemTextOf(message) {
323
+ const content = message.content;
324
+ if (typeof content === "string") return content;
325
+ if (!Array.isArray(content)) return "";
326
+ const texts = [];
327
+ for (const block of content) {
328
+ if (typeof block !== "object" || block === null) continue;
329
+ const candidate = block;
330
+ if (candidate.type === "text" && typeof candidate.text === "string") texts.push(candidate.text);
331
+ }
332
+ return texts.join("\n\n");
333
+ }
334
+ /**
335
+ * Wrap one pi-ai API module so both stream entry points receive a context this
336
+ * build can actually consume (see {@link adaptLegacyPiAiContext}) — but only
337
+ * when the module RESOLVED for this plugin is the legacy generation.
338
+ *
339
+ * The gate is the plugin's own module, resolved via {@link piAiRuntimeInfo}
340
+ * (manifest version of the copy this plugin resolves — no pi-ai import): a
341
+ * modern (0.87+) api consumes the normalized
342
+ * transcript natively — its request builder reads the prompt from the leading
343
+ * system message and the tools from that message's `toolsAdded`, and never
344
+ * reads the top-level `systemPrompt`/`tools` fields at all. Folding for it
345
+ * would delete the only carrier of both (issues #25/#26: the international
346
+ * gateway answers the prompt-less body with 400 / 11128, the domestic one
347
+ * accepts it and the model fabricates tool calls it can never actually make).
348
+ * The unwrapped modern api is returned AS-IS — byte-for-byte pass-through.
349
+ *
350
+ * The deferred-fetch entry points take no context and are passed through as-is
351
+ * in both branches.
352
+ */
353
+ function withLegacyContext(api, generation) {
354
+ if (generation === "modern") return api;
355
+ return {
356
+ ...api,
357
+ stream: (model, context, options) => api.stream(model, adaptLegacyPiAiContext(context), options),
358
+ streamSimple: (model, context, options) => api.streamSimple(model, adaptLegacyPiAiContext(context), options)
359
+ };
360
+ }
361
+ /**
216
362
  * Assemble the adapter. The provider's `getModels` reads the live catalog,
217
363
  * and every model's `baseUrl` is re-resolved per read so the shim's
218
364
  * ephemeral port applies from the first snapshot after startup.
@@ -221,6 +367,7 @@ function createWorkBuddyAdapter(options) {
221
367
  const { shim, store, catalog, resolveAttachments } = options;
222
368
  const providerId = options.provider ?? "workbuddy";
223
369
  const providerName = options.displayName ?? "WorkBuddy";
370
+ const generation = options.piAiGeneration ?? piAiRuntimeInfo().generation;
224
371
  const buildModels = () => {
225
372
  const baseUrl = `${shim.baseUrl()}/v1`;
226
373
  return catalog.current().map((info) => toPiModel(info, baseUrl, providerId));
@@ -240,7 +387,7 @@ function createWorkBuddyAdapter(options) {
240
387
  }
241
388
  } },
242
389
  models: buildModels(),
243
- api: openAICompletionsApi()
390
+ api: withLegacyContext(openAICompletionsApi(), generation)
244
391
  }),
245
392
  getModels: () => buildModels()
246
393
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "3.0.1",
5
+ "version": "3.1.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",
@@ -91,7 +91,7 @@
91
91
  "@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.3.0-0",
92
92
  "@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.3.0-0",
93
93
  "@deepseek-ai/schemastery": ">=3.18.1-rc.1 <4.0.0 || >=3.18.2 <4.0.0",
94
- "@earendil-works/pi-ai": ">=0.85.0 <0.86.0",
94
+ "@earendil-works/pi-ai": ">=0.85.0 <0.88.0",
95
95
  "react": "^18.2.0"
96
96
  },
97
97
  "devDependencies": {
@@ -110,7 +110,7 @@
110
110
  "@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.3.0-0",
111
111
  "@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.3.0-0",
112
112
  "@deepseek-ai/schemastery": ">=3.18.1 <4.0.0 || >=3.18.2 <4.0.0",
113
- "@earendil-works/pi-ai": ">=0.85.0 <0.86.0",
113
+ "@earendil-works/pi-ai": ">=0.85.0 <0.88.0",
114
114
  "@types/node": "^22.20.0",
115
115
  "@types/react": "~18.3.31",
116
116
  "@types/react-dom": "18.3.7",