dsh-connect-workbuddy 4.4.5 → 4.5.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,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.5.0 (2026-10-14)
4
+
5
+ > 版本号是 **minor** 而不是 patch:这一版新增了一个**用户可见的设置**(积分读数的位置)和一个**新的渲染席位**,而且配置 schema 多了一个字段 —— 按语义化版本这是特性,不是修错。`4.4.6` 之后没有任何以 4.4.7 发布过的产物或 Release(tag 已撤回重打)。
6
+
7
+ ### Features
8
+
9
+ - **积分读数只有一个固定位置:装了别的插件后它就不显示了 —— 现在位置可选(输入框工具栏 / 输入框下方)**(issue #62 报告者)
10
+ - **现象**:报告者装了第三方插件(`dsh-auto-continue`)之后,对话输入框里的 WorkBuddy 积分余额消失。根因是那个位置本身:`conversation.input.left`(输入框工具栏)是一个**共享的 list 插槽**,shell 自己的控件和任何其他 bundle 都注册在那里 —— 而 list 插槽无法保证每个占用者都可见,别人挤进来就可能把本插件的读数挤没。原注释里其实已经记着同类问题(更早放在侧边栏底部时,两个连接器插件互相挤压,才迁到输入框这一行)。
11
+ - **修法**:把「位置」做成**每区域一个选项**,两个席位都注册、由 gate 决定在哪个席位渲染:
12
+ - `composer`(默认)—— 输入框工具栏 `conversation.input.left`,与模型选择同一行;
13
+ - `stats` —— 输入框下方 `conversation.composer.dock`,shell 自己渲染「轮次 / 步数 · tok/s · token · 缓存命中」那一行;`order: 10` 排在 shell 的统计之后,所以官方那几颗统计仍在最左,读数是**追加**而不是顶替。
14
+ - 选默认值是为了**升级不动任何人的界面**:设置在 schema 里默认 `composer`,升级前写下的 profile 读出来仍是 `composer`。
15
+ - **为什么两个席位都注册**:位置这种东西用户是「一边看着结果一边改」的,注册哪个席位取决于设置就意味着每次改设置都要**重新注册插槽**;而本仓库的注释里已经写明这种绑定是脆的(晚到的 settings 命名空间必须产生新的注册而不是改闭包)。现在**决定权在 gate 里一处**:席位不符就 `return null`,另一个席位照常渲染。
16
+ - **判定的顺序**:先归属(是不是本插件的 provider)、再两个开关(区域是否启用、读数是否显示)、**最后**才是席位。所以一个被关掉的区域在**两个席位都不渲染**,而不是先在非当前席位画一帧再被拒。
17
+ - **卡片新增一个两选一控件**(就在「对话框显示积分」那个勾选旁边,只作用于当前标签页的区域):读取走 Host 的 usage 应答(与那个勾选同源,因为浏览器 settings 镜像在本命名空间上是过期的那份),写完**重新读一次 Host** —— 否则控件会一直显示旧席位,看起来像没生效。
18
+ - **守卫**:`tests/issue62-credit-position.spec.tsx`(9 例)覆盖:默认 `composer`(含升级前的 profile 与手改的未知值)、按区域而非全局读取、写入只改该区域该字段(模型列表/预算/兄弟区域都不动)、席位与显示开关相互独立、gate 在**指定席位渲染 / 另一席位渲染 0 个 / 两个席位都不渲染(隐藏或区域关闭)/ 别的 provider 两个席位都不渲染**。**变异验证**:把 gate 的席位判定删掉(两个席位都渲染)→ 2 例红;把读取的默认值去掉(升级前的 profile 读成 stats)→ 2 例红。
19
+
20
+
21
+ - **新增「上游请求体构成」诊断(`WORKBUDDY_BODY_REPORT`),用于定案 issue #59:两份请求体到底是不是同一份历史。**(issue #59)
22
+ - **为什么需要**:报告里同一份历史被国内网关算约 70.8 万 token、国际网关算约 137.4 万(1.94 倍),切区域即 `code 11115 prompt is too long`。两条独立测量已经把范围压到唯一一个问题:报告者在**两个区域**跑本仓库的内容类别探针,逐字节相同的散文/代码/JSON 六格比值全在 **0.98~1.06**(同一段字节两边计价一致);代码链路核对又表明**历史 reasoning 根本没进请求体**(pi-ai 只在 `compat.requiresThinkingAsText` 为真时把 `thinking` 当文本转发,本插件从未设置;`reasoning_details` 那条路需要流式解析从不写出的签名)。**剩下唯一可能是两份请求体不同**,而这件事**不需要任何区域有积分**就能查:请求体是本地拼好的,`14018 Credits exhausted` 是发出去之后才可能发生的。
23
+ - **做法**:`src/body-report.ts` 在 shim 把 body 交给上游**之前**记录它的**构成** —— 每条消息的 `role`、逐块的类型(`system`/`text`/`tool-call`/`tool-result`/`reasoning`/`image`)、字符数与其中**中日韩字符**数(实测中文 0.669、英文 0.224 token/字符,差 3 倍,混在一起看不出来)、`tools` 数组、`messages.length` 与 body 总字节数。按区域写 `<dir>/<region>.jsonl`,一次请求一行(一轮对话有很多次上游调用,**最后一次**才是要比的那份)。
24
+ - **只记计数、不记文本**:不写任何消息内容、账号名或令牌 —— 一个要被贴进公开 issue 的诊断不能有泄漏用户会话的能力。`tests/issue59-body-report.spec.ts` 里有一条专门的断言:用带特征串的 body 生成报告后,序列化结果**不含**该特征串(变异验证:让 `block()` 把 `text` 一起带上 → 该例与 reasoning 那例同时变红)。
25
+ - **开关**:环境变量 `WORKBUDDY_BODY_REPORT` 未设置或为空白即**关闭**;`1`/`true` 写到 `<DSH home>/body-reports/`;其他值按**目录路径**解释。写文件失败只走日志,**绝不影响聊天请求**(有专门断言)。
26
+ - **守卫**:`tests/issue59-body-report.spec.ts`(16 例)。
27
+ - **配套**:`scripts/probe-region-token-accounting.mjs` 新增 `--language en|zh` 与 `--kind prose|code|json`(六种组合确定性填充、跨区域逐字节相同),并补上国内侧实测基线:prose 214.0 / code 287.0 / json 288.3(en),prose 671.0 / code 480.7 / json 397.3(zh),单位 tokens/1k chars。
28
+
29
+ ## 4.4.6 (2026-10-14)
30
+
31
+ ### Fixes
32
+
33
+ - **面板的「测试」只测一个模型,却把结果当成账号级结论:账号池说「可用」,一切到实际在用的模型就撞 `429 / 6004`。**(issue #61 报告者报告,维护者在 issue 下确认「测试的是某一个模型……不能换其他模型」)
34
+ - **现象与证据**:报告者同一时刻、同一脚本测两个国内账号 —— `hy4-preview-f`(当时的 `pool.targetModelId`,面板测的就是它)两个号都可用,而 `step-5-preview`(当时实际在用的模型)两个号都 `429 / 6004`。池文件里这两个号还被更早的一次批量测试记成 `outcome=ok source=test-batch`,而池开着时那份「ok」正是选号依据。**`6004` 是「账号 × 模型」维度的条件**(本项目 10-13 也在三个国际账号上量到 `deepseek-v4.1-flash` 挂、其余模型全通),所以单一模型探测在结构上无法回答「这个账号能不能服务」。
35
+ - **根因(报告者给出代码位置,本地逐条复核属实)**:`resolveTargetModel()` 只解析出**一个** `modelId`(来自区域级 `pool.targetModelId`),一条路径同时服务批量与逐行测试;而 `probeModel()` 的三个返回分支**早就带着 `modelId`**,落盘时被丢掉了 —— 记录里只有 `outcome/atMs/source/retryAtMs/message`,没有任何字段说明这条 `ok` 是**哪个模型**上的。
36
+ - **修法(三处,各自成立)**:
37
+ 1. **测量记录记住模型**:`WorkBuddyPoolProbe` 新增可选 `modelId`,两个写入方(批量测试、实时聊天失败)在拿到模型的那一刻写下它。
38
+ 2. **每个账号可单独指定测试模型**:新增设置 `pool.targetModelIdByAccount`(账号 id → 模型 id),读取时先查 map、查不到回落 `targetModelId`。**批量测试与逐行测试走同一条解析**(报告者指出这正是「改一处两处同时生效」的地方),所以「测试所有账号」也会按每个号自己的模型去测。解析仍然过 `resolveTargetModel`:指向本区域已下架模型时**报 `stale`、绝不静默换成另一个模型**(换一个等于花积分测一个用户没选的模型,还把它当作这个号的结论)。
39
+ 3. **卡片显示适用范围**:池表每行的测量结论旁写上「模型 {model}」(记录里没有该字段的旧数据写「未记录模型」,不猜);每行新增一个测试模型下拉框(留空 = 跟随区域目标),随「保存」一起写入。
40
+ - **为什么 `modelId` 落盘时不继承**:`accountName` 与 `retryAtMs` 描述的是**账号**(后来者看不到的名字仍然是真的,未来的刷新时刻仍然在未来),而模型描述的是**这一次测量**。把旧的 model id 继承到一次新测量上,等于用不是它的模型给它贴标签 —— 正是这个字段要终结的那种混淆。所以**缺失即「范围未知」,不是猜测**。
41
+ - **配置向后兼容**:`targetModelIdByAccount` 默认空对象,且**只有非空时才写入** settings(与 `probeInputTokens` 同一条规则)—— 从未用过这个设置的 profile 写出来的槽位与升级前逐字节相同,升级不会看起来像用户改过设置。
42
+ - **守卫**:`tests/issue61-probe-model-scope.spec.ts`(9 例)覆盖:逐账号优先/回落、空串视为「跟随区域目标」、override 下架时解析为 `stale` 且不带 `modelId`、批量按各账号自己的模型探测、单一模型的旧调用形态不变、解析不出模型时产出 `unavailable` 行并指名那个模型(**没有**回退到别的模型)、批量写入 `modelId`、**不继承**、新测量覆盖旧模型。`tests/pool-route.spec.ts` 新增一条路由级断言:批量路由交给 Host 的是**逐账号解析器**(`a` 用区域目标、`b` 用 `b` 自己的)。**变异验证**:记录不再写 `modelId` → 1 例红;合并改成继承 → 1 例红;批量改回「整批一个模型」→ 1 例红;路由改回传单一 modelId → 路由断言红。
43
+
44
+ ### Features
45
+
3
46
  ## 4.4.5 (2026-10-14)
4
47
 
5
48
  ### Fixes
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, S as defaultDesktopAuthCandidates, Z as findWorkbuddyAppExecutableWithSource, a as readHostHeartbeat, at as hostPeersInfo, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-Bj7JvSYJ.js";
2
+ import { C as defaultDesktopAuthDirs, F as WorkBuddyUpstreamClient, K as WORKBUDDY_APP_EXECUTABLE_ENV, O as legacyWorkbuddyOwnAuthPath, Q as findWorkbuddyAppExecutableWithSource, S as defaultDesktopAuthCandidates, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, j as workbuddyOwnAuthPath, l as piAiRuntimeInfo, o as workbuddyHostHeartbeatPath, ot as hostPeersInfo, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, y as WorkBuddyCredentialStore } from "./host-heartbeat-DvcOd-3C.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/bin.ts
package/lib/client.js CHANGED
@@ -233,6 +233,23 @@ window.__ModuleLoader__.load({
233
233
  function regionCreditsShownOf(value, region) {
234
234
  return regionSlotOf(value, region)["showCreditsInMainUi"] !== false;
235
235
  }
236
+ /** The seat a region's readout uses, defaulting to the composer row. */
237
+ function regionCreditPositionOf(value, region) {
238
+ return regionSlotOf(value, region)["creditPosition"] === "stats" ? "stats" : "composer";
239
+ }
240
+ /**
241
+ * Build the next `regions` settings value for one region's credit seat.
242
+ *
243
+ * Same merge rule as {@link nextRegionCreditsShown}: only the target region's
244
+ * field changes, so every other field of that slot and every other region's slot
245
+ * survive the write.
246
+ */
247
+ function nextRegionCreditPosition(value, region, position) {
248
+ return nextRegionSlots(regionsMapOf(value), region, {
249
+ ...regionSlotOf(value, region),
250
+ creditPosition: position
251
+ });
252
+ }
236
253
  /**
237
254
  * Build the next `regions` settings value for the sidebar-credit on/off toggle.
238
255
  *
@@ -632,6 +649,20 @@ window.__ModuleLoader__.load({
632
649
  });
633
650
  }
634
651
  /**
652
+ * Write one region's credit SEAT (issue #62), verifying it landed.
653
+ *
654
+ * Sibling of {@link writeRegionCreditsShown}: same whole-slot merge, same
655
+ * landed check, same reason for both. The seat decides only WHERE the readout
656
+ * renders, so a lost write would leave the user looking at the old position
657
+ * while the control claimed the new one.
658
+ */
659
+ async function writeRegionCreditPosition(scope, region, position, slot) {
660
+ await writeField(scope, "regions", region, slot, (readBack) => {
661
+ const stored = readBack;
662
+ return stored !== null && stored !== void 0 && stored["creditPosition"] === position;
663
+ });
664
+ }
665
+ /**
635
666
  * Write one region's account-pool PREFERENCES, verifying they landed.
636
667
  *
637
668
  * Routed through {@link writeField} like every other settings write, so it gets
@@ -661,6 +692,8 @@ window.__ModuleLoader__.load({
661
692
  targetModelId: preferences.targetModelId,
662
693
  memberAccountIds: preferences.memberAccountIds
663
694
  };
695
+ const overrides = Object.entries(preferences.targetModelIdByAccount ?? {}).filter(([, modelId]) => modelId !== "");
696
+ if (overrides.length > 0) slot["targetModelIdByAccount"] = Object.fromEntries(overrides);
664
697
  if (preferences.probeInputTokens !== void 0) slot["probeInputTokens"] = preferences.probeInputTokens;
665
698
  const next = nextRegionPool(scope.getSnapshot().value, region, slot)[region];
666
699
  await writeField(scope, "regions", region, next, (readBack) => {
@@ -672,7 +705,12 @@ window.__ModuleLoader__.load({
672
705
  const stored = pool["memberAccountIds"];
673
706
  if (!Array.isArray(stored) || stored.length !== preferences.memberAccountIds.length) return false;
674
707
  const wanted = new Set(preferences.memberAccountIds);
675
- return stored.every((id) => typeof id === "string" && wanted.has(id));
708
+ if (!stored.every((id) => typeof id === "string" && wanted.has(id))) return false;
709
+ const storedModels = pool["targetModelIdByAccount"];
710
+ if (overrides.length === 0) return storedModels === void 0 || typeof storedModels === "object" && storedModels !== null && !Array.isArray(storedModels) && Object.keys(storedModels).length === 0;
711
+ if (typeof storedModels !== "object" || storedModels === null || Array.isArray(storedModels)) return false;
712
+ const storedMap = storedModels;
713
+ return Object.keys(storedMap).length === overrides.length && overrides.every(([accountId, modelId]) => storedMap[accountId] === modelId);
676
714
  });
677
715
  }
678
716
  //#endregion
@@ -1259,6 +1297,21 @@ window.__ModuleLoader__.load({
1259
1297
  const wanted = new Set(right);
1260
1298
  return left.every((id) => wanted.has(id));
1261
1299
  }
1300
+ /**
1301
+ * Whether two per-account test-model maps are equal (issue #61).
1302
+ *
1303
+ * By CONTENT, not by size: moving an account from one model to another keeps the
1304
+ * count and changes the meaning, and clearing an entry is a change a size
1305
+ * comparison would miss entirely when another account was added at the same
1306
+ * time. Order is irrelevant for the same reason it is in {@link sameIds} — the
1307
+ * map is written by key, not by click sequence.
1308
+ */
1309
+ function sameModelOverrides(left, right) {
1310
+ const leftKeys = Object.keys(left);
1311
+ const rightKeys = Object.keys(right);
1312
+ if (leftKeys.length !== rightKeys.length) return false;
1313
+ return leftKeys.every((key) => left[key] === right[key]);
1314
+ }
1262
1315
  /** Whether a probe outcome means the model answered. */
1263
1316
  function outcomeOk(outcome) {
1264
1317
  return outcome === "ok";
@@ -1345,7 +1398,8 @@ window.__ModuleLoader__.load({
1345
1398
  enabled: pool?.enabled ?? false,
1346
1399
  targetModelId: pool?.targetModelId || pool?.staleTargetModelId || "",
1347
1400
  probeInputTokens: pool?.probeInputTokens ?? 0,
1348
- memberAccountIds: pool?.memberAccountIds ?? []
1401
+ memberAccountIds: pool?.memberAccountIds ?? [],
1402
+ targetModelIdByAccount: { ...pool?.targetModelIdByAccount }
1349
1403
  };
1350
1404
  const active = draftRegion === region && draft !== null ? draft : saved;
1351
1405
  /**
@@ -1366,7 +1420,7 @@ window.__ModuleLoader__.load({
1366
1420
  * the pending edit.
1367
1421
  */
1368
1422
  const poolRunning = saved.enabled;
1369
- const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
1423
+ const dirty = draftRegion === region && draft !== null && (draft.enabled !== saved.enabled || draft.targetModelId !== saved.targetModelId || draft.probeInputTokens !== saved.probeInputTokens || !sameModelOverrides(draft.targetModelIdByAccount, saved.targetModelIdByAccount) || !sameIds(draft.memberAccountIds, saved.memberAccountIds));
1370
1424
  /**
1371
1425
  * Membership that actually resolves to a local sign-in — what a batch runs on.
1372
1426
  *
@@ -1527,6 +1581,25 @@ window.__ModuleLoader__.load({
1527
1581
  };
1528
1582
  });
1529
1583
  }, [editDraft]);
1584
+ /**
1585
+ * Set ONE account's test model (issue #61).
1586
+ *
1587
+ * `''` REMOVES the entry rather than storing an empty string: "follow the
1588
+ * region target" is the absence of a choice, which is the same shape the host
1589
+ * reads and the same shape an untouched profile has — so nothing about an
1590
+ * older profile changes when this setting is never used.
1591
+ */
1592
+ const setRowModel = (0, react.useCallback)((accountId, modelId) => {
1593
+ editDraft((current) => {
1594
+ const next = { ...current.targetModelIdByAccount };
1595
+ if (modelId === "") delete next[accountId];
1596
+ else next[accountId] = modelId;
1597
+ return {
1598
+ ...current,
1599
+ targetModelIdByAccount: next
1600
+ };
1601
+ });
1602
+ }, [editDraft]);
1530
1603
  /** Check every listed account, or none of them. */
1531
1604
  const selectAll = (0, react.useCallback)((next) => {
1532
1605
  editDraft((current) => ({
@@ -1847,7 +1920,10 @@ window.__ModuleLoader__.load({
1847
1920
  },
1848
1921
  onRemove: () => {
1849
1922
  runRowRemove(account.accountId, account.accountName);
1850
- }
1923
+ },
1924
+ modelOverride: active.targetModelIdByAccount[account.accountId] ?? "",
1925
+ catalog: pool.catalog ?? [],
1926
+ onModelChange: (modelId) => setRowModel(account.accountId, modelId)
1851
1927
  }))), effectiveMembers.length === 0 ? (0, react.createElement)("p", {
1852
1928
  className: "dsm-workbuddy-pool-warn",
1853
1929
  role: "status"
@@ -1915,6 +1991,7 @@ window.__ModuleLoader__.load({
1915
1991
  /** One account's row. */
1916
1992
  function renderAccountRow(input) {
1917
1993
  const { t, account, checked, canEdit, checkinSupported, onToggle, busy, testDisabled, targetHint, onTest, onRemove } = input;
1994
+ const { modelOverride, catalog, onModelChange } = input;
1918
1995
  const name = account.accountName === "" ? t("row.accountUnnamed") : account.accountName;
1919
1996
  const excluded = exclusionText(t, account.excludedBy);
1920
1997
  const probe = account.probe;
@@ -1952,10 +2029,23 @@ window.__ModuleLoader__.load({
1952
2029
  }), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-account-name" }, (0, react.createElement)("b", null, name), account.current ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-current-tag" }, t("row.poolCurrentBadge")) : null, (0, react.createElement)("span", { className: checked ? "dsm-workbuddy-pool-member" : "dsm-workbuddy-pool-not-member" }, checked ? t("row.poolMember") : t("row.poolNotMember")), excluded === void 0 ? probe === void 0 ? null : (0, react.createElement)("span", null, t("row.poolTestedAt", { at: formatShort(probe.atMs) })) : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-excluded" }, excluded))), (0, react.createElement)("span", { className: "dsm-workbuddy-pool-credits" }, account.credits === void 0 ? (0, react.createElement)("span", null, "—") : (0, react.createElement)("b", null, formatNumber$1(account.credits)), account.expiringSoon !== void 0 && account.expiringSoon > 0 ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon" }, t("row.poolCreditSoon", { count: formatNumber$1(account.expiringSoon) })) : account.nearestExpiryMs === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-soon-plain" }, t("row.poolCreditNearest", { at: formatShort(account.nearestExpiryMs) }))), (0, react.createElement)("span", {
1953
2030
  className: "dsm-workbuddy-pool-probe",
1954
2031
  ...probe?.message === void 0 ? {} : { title: probe.message }
1955
- }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-main" }, probeLine), probeLabel === void 0 && age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-prov" }, probeLabel === void 0 ? null : (0, react.createElement)("span", { className: probeLabel.className }, probeLabel.text), probeLabel === void 0 || age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-age" }, age))), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
2032
+ }, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-main" }, probeLine), probeLabel === void 0 && age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-probe-prov" }, probeLabel === void 0 ? null : (0, react.createElement)("span", { className: probeLabel.className }, probeLabel.text), probeLabel === void 0 || age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-sep" }, "·"), age === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-age" }, age), probe === void 0 ? null : (0, react.createElement)("span", { className: "dsm-workbuddy-pool-prov-model" }, probe.modelId === void 0 ? t("row.poolProbeModelUnknown") : t("row.poolProbeModel", { model: probe.modelId })))), checkinSupported ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checkin" }, account.checkedInToday === void 0 ? (0, react.createElement)("span", {
1956
2033
  className: "dsm-workbuddy-pool-unknown",
1957
2034
  title: t("row.poolNeverTested")
1958
- }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-row-actions" }, (0, react.createElement)("button", {
2035
+ }, t("row.poolCheckinUnknown")) : account.checkedInToday ? (0, react.createElement)("span", { className: "dsm-workbuddy-pool-checked" }, t("row.poolCheckedIn")) : t("row.poolNotCheckedIn")) : null, (0, react.createElement)("span", { className: "dsm-workbuddy-pool-row-actions" }, (0, react.createElement)("select", {
2036
+ className: "dsm-workbuddy-pool-select dsm-workbuddy-pool-row-model",
2037
+ value: modelOverride,
2038
+ disabled: !canEdit,
2039
+ title: t("row.poolRowModelHint"),
2040
+ "aria-label": t("row.poolRowModelAria", { accountName: name }),
2041
+ onChange: (event) => onModelChange(event.currentTarget.value)
2042
+ }, (0, react.createElement)("option", { value: "" }, t("row.poolRowModelInherit")), modelOverride !== "" && !catalog.some((model) => model.id === modelOverride) ? (0, react.createElement)("option", {
2043
+ value: modelOverride,
2044
+ disabled: true
2045
+ }, t("row.poolTargetStaleOption", { model: modelOverride })) : null, ...catalog.map((model) => (0, react.createElement)("option", {
2046
+ value: model.id,
2047
+ key: model.id
2048
+ }, model.name))), (0, react.createElement)("button", {
1959
2049
  type: "button",
1960
2050
  className: "dsm-btn dsm-btn-outline dsm-workbuddy-pool-small-btn",
1961
2051
  disabled: busy || testDisabled,
@@ -2985,6 +3075,15 @@ window.__ModuleLoader__.load({
2985
3075
  render (no Host pool state), so there is no heading row to share. This is the
2986
3076
  spacing the control carried before it moved beside that heading. */
2987
3077
  .dsm-workbuddy-credits-switch-row{display:flex;align-items:center;margin:8px 0 2px}
3078
+ /* The credit readout's seat picker (issue #62). Narrower than the pool's model
3079
+ select so the show/hide box and this control share one heading row, and it
3080
+ wraps onto its own line rather than squashing the pool title. */
3081
+ /* Its OWN label class, deliberately not the switch's: a query for "the
3082
+ sidebar-credit switch" must not be able to pick this control up, and the two
3083
+ are different widgets (a checkbox and a two-way picker). */
3084
+ .dsm-workbuddy-credit-position-switch{display:inline-flex;align-items:center;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
3085
+ .dsm-workbuddy-credit-position{max-width:150px;padding:2px 6px;font-size:12px}
3086
+ .dsm-workbuddy-credits-switch-row .dsm-workbuddy-credit-position-switch{margin-left:12px}
2988
3087
  .dsm-workbuddy-credits-switch input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
2989
3088
  .dsm-workbuddy-credits-switch input:disabled{opacity:.4;cursor:default}
2990
3089
  .dsm-workbuddy-tab-off{opacity:.55}
@@ -3140,6 +3239,10 @@ window.__ModuleLoader__.load({
3140
3239
  .dsm-workbuddy-pool-prov-unknown{background:var(--dsw-alias-bg-layer-3,#2a2c33);border:1px solid var(--dsw-alias-border-l1,#2c2d31);color:var(--dsw-alias-label-secondary,#c6c9d0)}
3141
3240
  .dsm-workbuddy-pool-prov-sep{color:var(--dsw-alias-label-dimmed,#9aa0a6);opacity:.7}
3142
3241
  .dsm-workbuddy-pool-prov-age{color:var(--dsw-alias-label-tertiary,#999);white-space:nowrap}
3242
+ /* WHICH model a verdict is about (issue #61). Deliberately quiet: it is scope
3243
+ information on the provenance line, not a status of its own, so an ok row and
3244
+ a rate-limited row must not look different because of it. */
3245
+ .dsm-workbuddy-pool-prov-model{color:var(--dsw-alias-label-tertiary,#999);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:190px}
3143
3246
  .dsm-workbuddy-pool-members{display:flex;flex-direction:column;gap:10px}
3144
3247
  .dsm-workbuddy-pool-members-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
3145
3248
  .dsm-workbuddy-pool-members-title{margin:0 0 3px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6)}
@@ -3193,6 +3296,9 @@ window.__ModuleLoader__.load({
3193
3296
  /* Per-row actions (test / remove), right side of each pool row. */
3194
3297
  .dsm-workbuddy-pool-row-actions{display:flex;gap:6px;align-items:center;justify-content:flex-end;flex:none}
3195
3298
  .dsm-workbuddy-pool-row-actions .dsm-workbuddy-pool-small-btn{padding:2px 10px;font-size:12px}
3299
+ /* This account's own test model (issue #61). Narrower than the section-level
3300
+ picker so a long model name cannot push the two buttons out of the row. */
3301
+ .dsm-workbuddy-pool-row-model{max-width:150px;padding:2px 6px;font-size:12px}
3196
3302
  @media (max-width:760px){.dsm-workbuddy-pool-row{grid-template-columns:1fr 1fr;row-gap:6px}.dsm-workbuddy-pool-select{max-width:180px}}
3197
3303
  /* Pool switch, namespaced under .dsm-workbuddy-pool so it cannot collide with host styles. */
3198
3304
  .dsm-workbuddy-pool .sw{position:relative;display:inline-block;width:38px;height:22px;flex:none;cursor:pointer}
@@ -4074,6 +4180,40 @@ window.__ModuleLoader__.load({
4074
4180
  * hide never drops the directory, model picks, budgets or pool preferences
4075
4181
  * that live alongside it.
4076
4182
  */
4183
+ /**
4184
+ * WHERE the active region's credit readout sits (issue #62).
4185
+ *
4186
+ * Read off the Host's usage answer, like {@link creditsShownOn} and for the
4187
+ * same measured reason: the browser's settings mirror for this namespace is
4188
+ * documented as stale on the affected 0.1.7 deployments, so a control reading
4189
+ * it would keep showing the previous seat after a successful write. Absent
4190
+ * means `composer`, which is what the Host resolves for a profile written
4191
+ * before the setting existed.
4192
+ */
4193
+ const creditPositionOn = (item) => {
4194
+ return statusByRegion[item]?.creditPosition === "stats" ? "stats" : "composer";
4195
+ };
4196
+ /**
4197
+ * Move one region's credit readout between its two seats.
4198
+ *
4199
+ * Same shape as {@link toggleCredits}: whole region slot in, landed check,
4200
+ * then a Host re-read. The re-read is load-bearing here for the same reason —
4201
+ * the control renders the Host's answer, so without it the picker keeps
4202
+ * showing the seat it had and the change looks like it did nothing.
4203
+ */
4204
+ const changeCreditPosition = async (item, position) => {
4205
+ if (settingsScope === void 0) return;
4206
+ setTogglingCredits(item);
4207
+ try {
4208
+ const slot = nextRegionCreditPosition(settingsScope.getSnapshot().value, item, position)[item];
4209
+ await writeRegionCreditPosition(settingsScope, item, position, typeof slot === "object" && slot !== null ? slot : {});
4210
+ await refreshUsage(item);
4211
+ } catch (error) {
4212
+ if (mounted.current) setAccountError(error instanceof Error ? error.message : t("row.requestFailed"));
4213
+ } finally {
4214
+ if (mounted.current) setTogglingCredits(void 0);
4215
+ }
4216
+ };
4077
4217
  const toggleCredits = async (item, shown) => {
4078
4218
  if (settingsScope === void 0) return;
4079
4219
  setTogglingCredits(item);
@@ -4380,6 +4520,40 @@ window.__ModuleLoader__.load({
4380
4520
  }
4381
4521
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: activeRegion === "cn" ? t("row.showCreditsCn") : t("row.showCreditsGlobal") })]
4382
4522
  });
4523
+ /**
4524
+ * WHERE the readout sits — a two-way choice, active tab's region only
4525
+ * (issue #62).
4526
+ *
4527
+ * It sits directly under the show/hide switch because the two answer one
4528
+ * question together ("show it, and where"), and because a user only reaches
4529
+ * for this when the default seat is unavailable — typically after installing
4530
+ * another plugin that occupies the composer row. Rendered as a `<select>`
4531
+ * rather than a pair of radios so it stays one compact row on the pool
4532
+ * heading, matching the switch beside it.
4533
+ *
4534
+ * Disabled with the region, exactly like the switch: a region that is off has
4535
+ * no readout to place.
4536
+ */
4537
+ const creditPositionPicker = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4538
+ className: "dsm-workbuddy-credit-position-switch",
4539
+ title: t("row.creditPositionHint"),
4540
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
4541
+ className: "dsm-workbuddy-pool-select dsm-workbuddy-credit-position",
4542
+ value: activeRegionOn ? creditPositionOn(activeRegion) : "composer",
4543
+ disabled: !activeRegionOn || togglingCredits === activeRegion || !canWrite || saving || poolBusy,
4544
+ "aria-label": t("row.creditPositionAria", { region: activeRegion === "cn" ? t("row.tabCn") : t("row.tabGlobal") }),
4545
+ onChange: (event) => {
4546
+ changeCreditPosition(activeRegion, event.currentTarget.value === "stats" ? "stats" : "composer");
4547
+ },
4548
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4549
+ value: "composer",
4550
+ children: t("row.creditPositionComposer")
4551
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
4552
+ value: "stats",
4553
+ children: t("row.creditPositionStats")
4554
+ })]
4555
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.creditPositionLabel") })]
4556
+ });
4383
4557
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
4384
4558
  className: `dsm-plugin-card${open ? " dsm-plugin-card-open" : ""}`,
4385
4559
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -4520,15 +4694,15 @@ window.__ModuleLoader__.load({
4520
4694
  },
4521
4695
  selectingAccount: switchingAccount,
4522
4696
  accountTools,
4523
- ...status.pool === void 0 ? {} : { headerExtra: creditsSwitch },
4697
+ ...status.pool === void 0 ? {} : { headerExtra: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [creditsSwitch, creditPositionPicker] }) },
4524
4698
  siblingBusy: saving || togglingRegion !== void 0,
4525
4699
  onBusyChange: setPoolBusy,
4526
4700
  onSaved: async () => await refreshUsage(activeRegion) !== void 0,
4527
4701
  onRefresh: () => refreshUsage(activeRegion)
4528
4702
  }),
4529
- status.pool === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4703
+ status.pool === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4530
4704
  className: "dsm-workbuddy-credits-switch-row",
4531
- children: creditsSwitch
4705
+ children: [creditsSwitch, creditPositionPicker]
4532
4706
  }) : null,
4533
4707
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
4534
4708
  className: "dsm-workbuddy-models",
@@ -5314,6 +5488,17 @@ window.__ModuleLoader__.load({
5314
5488
  * rows). The composer row is per-session and provider-scoped, so exactly one
5315
5489
  * plugin's credits are relevant at a time and nothing overlaps.
5316
5490
  *
5491
+ * WHY THERE IS A SECOND SEAT (issue #62). The composer tool row is a LIST seat,
5492
+ * so it is shared not only with the shell's own controls but with any other
5493
+ * plugin that registers there — and one report is exactly that: with a
5494
+ * third-party plugin installed the readout stopped appearing at all. A list seat
5495
+ * cannot promise that every occupant is visible, so the seat is the USER's
5496
+ * choice: `composer` (default, unchanged for anyone who does not choose) or
5497
+ * `stats`, the ambient dock below the composer card where the shell renders its
5498
+ * own round/token pills. The gate is registered in BOTH seats and renders in the
5499
+ * one the setting names, which keeps the decision in one function instead of
5500
+ * re-registering slots when the setting changes.
5501
+ *
5317
5502
  * Kept separate from {@link ComposerCredits} (and from the browser-plugin
5318
5503
  * entry) so the decision can be unit-tested without a Host: the entry imports
5319
5504
  * browser-only DSH packages the test environment cannot load, while this module
@@ -5350,6 +5535,16 @@ window.__ModuleLoader__.load({
5350
5535
  * the authority; the scope is the fallback for a Host line that predates the
5351
5536
  * field.
5352
5537
  */
5538
+ /**
5539
+ * The settings tree this plugin writes, unwrapped to a plain object.
5540
+ *
5541
+ * `unwrapVolatileDeep` first: the scope's value is a live cosmokit reference on
5542
+ * the 0.1.7 line, and reading fields off it without unwrapping yields proxies
5543
+ * whose `===` comparisons against a string are false.
5544
+ */
5545
+ function scopeValueOf(scope) {
5546
+ return scope === void 0 ? void 0 : unwrapVolatileDeep(scope.getSnapshot().value);
5547
+ }
5353
5548
  function composerCreditsRegionOf(projection, scope, hostValue) {
5354
5549
  const provider = selectedProviderOf(projection);
5355
5550
  const region = provider === void 0 ? void 0 : WORKBUDDY_COMPOSER_PROVIDERS[provider];
@@ -5361,7 +5556,7 @@ window.__ModuleLoader__.load({
5361
5556
  return region;
5362
5557
  }
5363
5558
  function ComposerCreditsGate(props) {
5364
- const { t, settingsScope, useProjection } = props;
5559
+ const { t, position, settingsScope, useProjection } = props;
5365
5560
  const [, setRevision] = (0, react.useState)(0);
5366
5561
  (0, react.useEffect)(() => settingsScope?.subscribe(() => {
5367
5562
  setRevision((value) => value + 1);
@@ -5369,6 +5564,7 @@ window.__ModuleLoader__.load({
5369
5564
  const projection = useProjection === void 0 ? void 0 : useProjection("modelSelection");
5370
5565
  const region = composerCreditsRegionOf(projection, settingsScope);
5371
5566
  if (region === void 0) return null;
5567
+ if (regionCreditPositionOf(scopeValueOf(settingsScope), region) !== (position ?? "composer")) return null;
5372
5568
  const provider = selectedProviderOf(projection);
5373
5569
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ComposerCredits, {
5374
5570
  t,
@@ -5571,6 +5767,11 @@ window.__ModuleLoader__.load({
5571
5767
  "row.poolProbeSourceTest": "tested",
5572
5768
  "row.poolProbeSourceLive": "hit by a live request",
5573
5769
  "row.poolProbeSourceUnknown": "source unknown",
5770
+ "row.poolProbeModel": "model {model}",
5771
+ "row.poolProbeModelUnknown": "model not recorded",
5772
+ "row.poolRowModelHint": "Which model this account's test uses. Leave it on \"region target\" to follow the pool's model; pick one when this account must be tested on the model you actually chat with.",
5773
+ "row.poolRowModelAria": "Test model for {accountName}",
5774
+ "row.poolRowModelInherit": "region target",
5574
5775
  "row.poolSettingsTitle": "Account pool settings",
5575
5776
  "row.poolSettingsHint": "These are preferences: they take effect when you save (same as the model list). Test results are written automatically and are not affected by this button.",
5576
5777
  "row.poolEnabled": "Enable the account pool",
@@ -5633,6 +5834,11 @@ window.__ModuleLoader__.load({
5633
5834
  "row.showCreditsCn": "WB CN credits in composer",
5634
5835
  "row.showCreditsGlobal": "WB AI credits in composer",
5635
5836
  "row.showCreditsCnHint": "Show the domestic (WB CN) credit balance in the composer toolbar, but only while this session uses a WB CN model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
5837
+ "row.creditPositionLabel": "position",
5838
+ "row.creditPositionComposer": "composer toolbar",
5839
+ "row.creditPositionStats": "below the input box",
5840
+ "row.creditPositionAria": "Credit readout position for {region}",
5841
+ "row.creditPositionHint": "Where the credit readout sits. \"composer toolbar\" is the default (the row with the model picker). Choose \"below the input box\" — the row carrying the round / step / token statistics — if another plugin occupies the composer toolbar and the readout does not appear.",
5636
5842
  "row.showCreditsGlobalHint": "Show the international (WB AI) credit balance in the composer toolbar, but only while this session uses a WB AI model. Click it for per-account balances and a manual refresh (refreshes by itself every 5 minutes).",
5637
5843
  "composer.pointsCn": "WB CN",
5638
5844
  "composer.pointsGlobal": "WB AI",
@@ -5837,6 +6043,11 @@ window.__ModuleLoader__.load({
5837
6043
  "row.poolProbeSourceTest": "测得",
5838
6044
  "row.poolProbeSourceLive": "聊天撞上",
5839
6045
  "row.poolProbeSourceUnknown": "来源未知",
6046
+ "row.poolProbeModel": "模型 {model}",
6047
+ "row.poolProbeModelUnknown": "未记录模型",
6048
+ "row.poolRowModelHint": "这个账号测试用哪个模型。留「区域目标」就跟随账号池的目标模型;当某个号必须用你实际在聊的模型来测时,在这里单独指定。",
6049
+ "row.poolRowModelAria": "{accountName} 的测试模型",
6050
+ "row.poolRowModelInherit": "区域目标",
5840
6051
  "row.poolSettingsTitle": "账号池设置",
5841
6052
  "row.poolSettingsHint": "下面是偏好设置:点保存才生效(与「模型管理」一致)。测试结果由插件自动写入,不受这个保存按钮影响。",
5842
6053
  "row.poolEnabled": "启用账号池",
@@ -5899,6 +6110,11 @@ window.__ModuleLoader__.load({
5899
6110
  "row.showCreditsCn": "对话框显示 WB CN 积分",
5900
6111
  "row.showCreditsGlobal": "对话框显示 WB AI 积分",
5901
6112
  "row.showCreditsCnHint": "在对话输入框工具栏显示国内版(WB CN)剩余积分,仅当本会话使用 WB CN 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
6113
+ "row.creditPositionLabel": "位置",
6114
+ "row.creditPositionComposer": "输入框工具栏",
6115
+ "row.creditPositionStats": "输入框下方",
6116
+ "row.creditPositionAria": "{region} 的积分显示位置",
6117
+ "row.creditPositionHint": "积分显示在哪里。「输入框工具栏」是默认位置(与模型选择同一行)。如果装了别的插件后读数不显示,改成「输入框下方」—— 那是「轮次 / 步数 / token」统计所在的那一行。",
5902
6118
  "row.showCreditsGlobalHint": "在对话输入框工具栏显示国际版(WB AI)剩余积分,仅当本会话使用 WB AI 模型时出现。点击可看各账号余额并手动刷新(每 5 分钟也会自动刷新)。",
5903
6119
  "composer.pointsCn": "WB CN",
5904
6120
  "composer.pointsGlobal": "WB AI",
@@ -6022,8 +6238,12 @@ window.__ModuleLoader__.load({
6022
6238
  id: "dsh-connect-workbuddy-composer-credits",
6023
6239
  order: 4,
6024
6240
  locale: namespace,
6025
- inject: () => settingsScope === void 0 ? { t } : {
6241
+ inject: () => settingsScope === void 0 ? {
6026
6242
  t,
6243
+ position: "composer"
6244
+ } : {
6245
+ t,
6246
+ position: "composer",
6027
6247
  settingsScope
6028
6248
  }
6029
6249
  }, ComposerCreditsGate));
@@ -6033,6 +6253,46 @@ window.__ModuleLoader__.load({
6033
6253
  }
6034
6254
  };
6035
6255
  /**
6256
+ * The SAME readout in the ambient dock below the composer card (issue #62).
6257
+ *
6258
+ * Registered unconditionally, like the composer seat, because the CHOICE
6259
+ * between the two seats lives in the gate: it renders in the seat the
6260
+ * setting names and returns null in the other. Registering only the chosen
6261
+ * seat would mean re-registering on every settings change, and the seat is
6262
+ * exactly the kind of thing a user flips once while looking at the result.
6263
+ *
6264
+ * WHY THIS SEAT EXISTS: the composer tool row is a list seat shared with any
6265
+ * other plugin that registers there, and issue #62 is one report of the
6266
+ * readout disappearing when another plugin was installed. This row is where
6267
+ * the shell renders its own round/step/token pills, so it is a place a user
6268
+ * already reads session numbers from.
6269
+ *
6270
+ * `order` places it after the shell's own pills (`stats` registers at 0), so
6271
+ * the shipped statistics stay leftmost and the readout reads as an addition
6272
+ * rather than a replacement.
6273
+ */
6274
+ const registerStatsCredits = () => {
6275
+ try {
6276
+ return ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
6277
+ name: "conversation.composer.dock",
6278
+ id: "dsh-connect-workbuddy-stats-credits",
6279
+ order: 10,
6280
+ locale: namespace,
6281
+ inject: () => settingsScope === void 0 ? {
6282
+ t,
6283
+ position: "stats"
6284
+ } : {
6285
+ t,
6286
+ position: "stats",
6287
+ settingsScope
6288
+ }
6289
+ }, ComposerCreditsGate));
6290
+ } catch (error) {
6291
+ console.error("[dsh-connect-workbuddy] stats credits slot failed to register (host provider unaffected):", error);
6292
+ return;
6293
+ }
6294
+ };
6295
+ /**
6036
6296
  * Every slot this plugin contributes, and how to (re)create them.
6037
6297
  *
6038
6298
  * Kept as one list because the binding of `settingsScope` and the slot
@@ -6050,7 +6310,8 @@ window.__ModuleLoader__.load({
6050
6310
  const registerAllSlots = () => [
6051
6311
  ...WORKBUDDY_CARD_KEYS.map((key) => registerCard("plugins.bundle.config", key)),
6052
6312
  ...WORKBUDDY_CARD_KEYS.map((key) => registerCard("plugins.row.config", `${key}#${key}`)),
6053
- registerComposerCredits()
6313
+ registerComposerCredits(),
6314
+ registerStatsCredits()
6054
6315
  ].filter((disposer) => disposer !== void 0);
6055
6316
  /**
6056
6317
  * Disposers of the slots registered for the CURRENT binding.
@@ -1220,6 +1220,24 @@ function ensureSystemHead(obj) {
1220
1220
  * `tool_choice` (the upstream's field is a string; object forms return 400),
1221
1221
  * and guarantee a leading system message.
1222
1222
  */
1223
+ /**
1224
+ * The model id a prepared body asks for, when it carries one (issue #61).
1225
+ *
1226
+ * Lives beside {@link prepareChatBody} because it reads the same shape: the
1227
+ * serialized OpenAI body, after this plugin's rewrites. Never throws — a
1228
+ * malformed body is the upstream's to reject, not a reason to lose the failure
1229
+ * measurement that this id is needed for.
1230
+ */
1231
+ function modelIdOfBody(bodyJson) {
1232
+ try {
1233
+ const parsed = JSON.parse(bodyJson);
1234
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
1235
+ const model = parsed["model"];
1236
+ return typeof model === "string" && model !== "" ? model : void 0;
1237
+ } catch {
1238
+ return;
1239
+ }
1240
+ }
1223
1241
  function prepareChatBody(source) {
1224
1242
  let body;
1225
1243
  try {
@@ -3807,7 +3825,7 @@ function generationOf(version) {
3807
3825
  //#endregion
3808
3826
  //#region src/version.ts
3809
3827
  /** The npm package version this build was produced from. */
3810
- const WORKBUDDY_CONNECT_VERSION = "4.4.5";
3828
+ const WORKBUDDY_CONNECT_VERSION = "4.5.0";
3811
3829
  //#endregion
3812
3830
  //#region src/host-heartbeat.ts
3813
3831
  /**
@@ -3965,4 +3983,4 @@ async function clearHostHeartbeat() {
3965
3983
  await rm(workbuddyHostHeartbeatPath(), { force: true });
3966
3984
  }
3967
3985
  //#endregion
3968
- export { isWorkbuddyBundle as $, workbuddyAccountId as A, parseCreditMultiplier as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, WORKBUDDY_APP_EXECUTABLE_ENV as G, parseUpstreamModel as H, classifyUpstreamError as I, deriveAtRestKeyId as J, clearAtRestKeyCache as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, isEncryptedFieldWrapper as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, prepareChatBody as U, parseReasoning as V, regionOf as W, findWorkbuddyAppExecutable as X, fetchAtRestKeyPayload as Y, findWorkbuddyAppExecutableWithSource as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, hostPeersInfo as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, writeFileAtomic as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, macosBundleExecutable as et, WorkBuddyCatalog as f, ENCRYPTED_CREDENTIAL_CODE as g, fallbackModelsFor as h, processStartTimeMs as i, workbuddyAppExecutableCandidates as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, deriveCatalog as m, clearHostHeartbeat as n, openEncryptedField as nt, workbuddyHostHeartbeatPath as o, resolveDshHome as ot, applyContextBudgets as p, deriveAtRestKey as q, isHeartbeatProcessAlive as r, readAtRestKey as rt, writeHostHeartbeat as s, withFileLock as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, macosNestedAppBundles as tt, FALLBACK_WORKBUDDY_MODELS as u, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };
3986
+ export { isEncryptedFieldWrapper as $, workbuddyAccountId as A, modelIdOfBody as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, regionOf as G, parseReasoning as H, classifyUpstreamError as I, deriveAtRestKey as J, WORKBUDDY_APP_EXECUTABLE_ENV as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, findWorkbuddyAppExecutableWithSource as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, fetchAtRestKeyPayload as X, deriveAtRestKeyId as Y, findWorkbuddyAppExecutable as Z, WORKBUDDY_AUTH_FILENAME as _, readHostHeartbeat as a, workbuddyAppExecutableCandidates as at, WorkBuddyEncryptedCredentialError as b, WORKBUDDY_CONNECT_VERSION as c, withFileLock as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, isWorkbuddyBundle as et, WorkBuddyCatalog as f, ENCRYPTED_CREDENTIAL_CODE as g, fallbackModelsFor as h, processStartTimeMs as i, readAtRestKey as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, writeFileAtomic as lt, deriveCatalog as m, clearHostHeartbeat as n, macosNestedAppBundles as nt, workbuddyHostHeartbeatPath as o, hostPeersInfo as ot, applyContextBudgets as p, clearAtRestKeyCache as q, isHeartbeatProcessAlive as r, openEncryptedField as rt, writeHostHeartbeat as s, resolveDshHome as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, macosBundleExecutable as tt, FALLBACK_WORKBUDDY_MODELS as u, WORKBUDDY_AUTH_FILE_ENV as v, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, knownRegionOf as z };