dsh-connect-workbuddy 5.12.0 → 5.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.12.2 (2026-10-27)
4
+
5
+ > 两处修复:**① 装完重启插件消失**(#151)——把宿主提供的 `@earendil-works/pi-ai` 标成 optional
6
+ > peer,使安装期不再因「闭包里解析不到它」而拒绝整个 generation;**② DSML 工具名一字之差**
7
+ > (#152)——`pwdsh` → `pwsh` 这类与本次声明工具只差一个字符的名字,现在按那个工具恢复,
8
+ > 不再整段回退成正文。两处都保持 fail-closed 的默认:候选集只来自请求自己的 `body.tools`,
9
+ > 不凭空造名。
10
+
11
+ ### Fixes
12
+
13
+ - **安装成功但重启后插件消失:generation peer validation 拒绝了安装(#151)**
14
+ - **现象**:插件市场安装显示 `generation-install: promoted to …`,接着
15
+ `generation peer validation failed: @earendil-works/pi-ai does not resolve from the generation or
16
+ installation closure`,`exit=1`;重启后该 generation 被扫掉(`swept N unreferenced plugin generation(s)`),
17
+ 用户得重装。
18
+ - **根因不是"插件依赖没装",而是"它不可能被装上"**:pi-ai 由**宿主**提供 —— `dsh-llm-pi-ai` 把它列为自己的
19
+ `dependencies`(`^1.1.0`),宿主用运行期拦截层应答这个裸包名;而 profile 是按 `autoInstallPeers: false` 建的,
20
+ peer 从不进入 profile 的 `node_modules`。所以一份「必需 peer」在安装闭包里**永远解析不到**,校验必然失败。
21
+ - **修法**:`package.json` 增加 `peerDependenciesMeta`,把 `@earendil-works/pi-ai` 标为 `optional: true`。
22
+ **范围一个字符都没动**(仍是 `>=0.85.0 <0.88.0 || >=1.0.0-0 <2.0.0`)——`optional` 说的是「不要
23
+ 求我自己带一份」,不是「我不支持这些版本」;运行期版本探测(`pi-ai-runtime.ts`)与语义边界仍以该范围为准。
24
+ - 为什么不是干脆删掉这条 peer:插件**静态 import** 了 pi-ai(`adapter.ts:19–21`),它不是可选依赖,
25
+ 声明必须留着;只有"安装期强制"这一层需要让开。
26
+ - 报告里那句「`@deepseek-ai/cordis` 已有同样处理,照此办理即可」**不成立** —— 本仓库此前
27
+ `peerDependenciesMeta` 是空的,cordis 也从未标 optional。修法本身没问题,但依据是上面那条,不是先例。
28
+
29
+ - **DSML 泄漏时工具名一字之差,整段标记回退成正文(#152)**
30
+ - **现象**:模型把工具调用写进正文时把名字写成 `pwdsh`,而请求声明的是 `pwsh`。第二道闸按名字拒绝
31
+ (这是对的:那个名字从未被提供过),于是整块按原文显示 —— 用户看到一堵乱码墙。
32
+ - **新证据**:幻觉名与真实名之间存在**可预测的一字变形**(`pwdsh` = `pwsh` 中间多插一个 `d`,
33
+ 编辑距离 1)。这跟此前记录过的 `validate` 那种「名字整段被换掉」不同,是可以低成本兜底的。
34
+ - **修法**:第二道闸在拒绝之前先试一次受限纠正(`repairNearMissName`,由 shim 开启):
35
+ - 候选集**只来自本次请求的 `body.tools`** —— 永远不会选中一个没被提供的名字,更不会凭空造名;
36
+ - 写出名与候选名的**编辑距离必须 ≤ 1**,且**恰好一个**候选落在这个距离内(两个就放弃);
37
+ - 双方长度都 **≥ 4**(`cat`/`cut`、`bash`/`dash` 这类三字母的一字之差不是证据);
38
+ - 写出名若**已精确命中**声明名,直接用它,绝不"纠正"到邻居;
39
+ - **只纠正 `invoke name=`**;`parameter name=` 一律不动(改参数名等于改调用语义,比显示正文更糟);
40
+ - `tool_choice` 的钉选、第四道闸(必需参数缺失即截断)**都在纠正后的名字上照旧生效**。
41
+ - 默认仍是 fail-closed:`repairNearMissName` 不传就是关闭,只有 shim 的请求路径打开它。
42
+
43
+ ### Tests
44
+
45
+ - `tests/dsml-recovery.spec.ts` 新增 10 条(#152):真实捕获形态 `pwdsh` → `pwsh` 被恢复;不开关时仍拒绝;
46
+ 两个候选在距离内则放弃;短名(写出名 3 字 / 候选 3 字)两种方向都拒绝;已精确命中的名字不被"纠正";
47
+ 不在声明集里的近邻不被凭空选中;钉选与必需参数两道闸在纠正后仍生效;**参数名不被纠正**。
48
+ - `tests/shim-recovery.spec.ts` 新增 1 条(#152 的**接线**):走真实 shim 与真实响应流,断言 `pwdsh`
49
+ 在声明 `pwsh` 时被恢复成 `pwsh`、在只声明 `bash` 时**仍回退成正文**。这条单独存在的理由与 #149 相同:
50
+ 纯函数用例自己传开关,摘掉 `shim.ts` 里的 `repairNearMissName: true` 时它们全绿 —— 实测该变异只让这条变红。
51
+ - `tests/dsh-line.spec.ts` 新增 1 条(#151):断言 `peerDependenciesMeta['@earendil-works/pi-ai'].optional === true`,
52
+ 且 peer **范围**未因此改动。
53
+ - 变异验证(每条都确认过会让对应用例变红):去掉编辑距离判断 → 1 条红;去掉唯一性判断 → 1 条红;
54
+ 去掉两个长度下限 → 2 条红;忽略 opt-in(默认开启)→ 1 条红;摘掉 shim 的接线 → 1 条红;移除 `optional` → 1 条红。
55
+ - 全量:`tsc` 双配置 0 错误,**143 文件 / 2491 通过(2 条按平台跳过)**,`tsdown` 构建通过。
56
+
57
+ ## 5.12.1 (2026-10-26)
58
+
59
+ > 修正 5.12.0 的 `dropForeignThinking` 漏判的一条腿:`isSameModel` 判据是 provider **且** api
60
+ > **且** model id 三者全等,而 5.12.0 的 `isForeignAssistant` 只比了前两条。于是**在同一个区域内
61
+ > 换模型**(`hy3` → `deepseek-v4.1-flash`,两者都由本插件提供、都盖 `provider: workbuddy`)时
62
+ > 一个块也检不出,开关看起来无效,而 pi-ai 照样把历史 thinking 摊平成正文。修复只补判据与传参,
63
+ > 不传时行为与 5.12.0 完全一致;开关默认仍为关闭。
64
+
65
+ ### Fixes
66
+
67
+ - **同一区域内换模型时,`dropForeignThinking` 检不出任何块(#147 的第三条腿)**
68
+ - **病因是判据少了一条腿。** pi-ai 的 `isSameModel` 是 `provider === … && api === … && model === model.id`(`transform-messages.js:68–70`,0.85.1 与 1.1.0 行号一致);5.12.0 的 `isForeignAssistant` 只比较 provider 与 api。换区场景(provider 变了)能检出,**换模型场景(只有 model 变了)检不出**——症状与 #147 一模一样,却没有诊断、开关也无效。
69
+ - **修法**:`isForeignAssistant` 增加可选的 `requestModelId`,补上 model 腿;`foreignThinkingOf` / `dropForeignThinking` 透传它;`withLegacyContext` 的 `prepare()` 在 `stream` / `streamSimple` 两个入口、legacy / modern 两个分支共四处传 `model.id`。**不传时行为完全不变**(只判前两条腿),所以卡片等只读 surface 与既有调用点零影响。
70
+ - 传 `model.id` 而**不是** `record['model']`:`dsh-llm-pi-ai` 的 `replayedAssistant` 在 `response.api === 'anthropic-messages'` 时会取 `responseModel ?? model`(`lib/index.js:218`),而 `isSameModel` 比的是 `model.id`,这里与它对齐。
71
+ - **顺带澄清一处不成立的顾虑**:卡片那条路径**不需要**单独传 model id。卡片读的是 status 端点的 `lastForeignThinking`,该值由 `onForeignThinking` 在**请求路径**里填充,因此天然带着 `model.id`——「换模型导致的摊平」在卡片上同样可见,无需 follow-up。
72
+ - **两处已失效的表述同步改正**:`alignAssistantIdentities` 的文档(`src/adapter.ts:671`)原写「换模型仍会降级……(有测试覆盖)」,但当时并无任何测试覆盖这条腿;README 中「同一区域内换模型仍会降级」一句现在补明「降级本身消除不了,但它变成正文这件事可以」。
73
+
74
+ ### Tests
75
+
76
+ - 新增 `tests/issue147-model-leg.spec.ts`(9 条),走**真实 pi-ai 的 `transformMessages`**:缺陷本身可复现 / 旧两腿调用检不出(盲区钉成对照)/ 三腿调用检出并删除 / 外来 provider 仍检出(第三条腿是追加不是替换)/ 同 model 的消息一个不动且返回同一对象 / 无 `model` 字段不误报 / 外来轮次 model id 恰好相同也不洗白 / 一次真实切换里只删旧模型的轮次 / 幂等。
77
+ - 新增 `tests/issue147-model-leg-wiring.spec.ts`(12 条),补上**接线**而不是判据:legacy 与 modern 两个分支 × `stream` / `streamSimple` 两个入口 × 三种状态(换模型时删、同名模型时留、开关关闭时原样),都经过真实 `createWorkBuddyAdapter`,断言 api 收到的上下文里 thinking 块的去留、以及 `onForeignThinking` 是否触发。
78
+ - **这条为什么必须单独存在**:把四处 `prepare(context, model.id)` 全部改回 `prepare(context)`(即修复被静默摘掉),**整套 2467 条测试依然全绿**——纯函数测试自己调判据,没有任何东西发现 wrapper 没传 id。补上后该变异使其中 4 条全红。这与 `tests/adapter.spec.ts` 当初为 issue #24 建的洞同型。
79
+ - 后两种状态是防止「一概删除」也能通过的对照:若判据退化成「凡 assistant 轮次都算外来」,删除会把**当前模型自己的**推理一起删掉(#102 的反向回归),所以同 model 与开关关闭两条必须看到 thinking 保留。
80
+ - 变异验证:model 腿恒 `false` → 2 条红;恒 `true` → 3 条红(含 #147 原有 suite 1 条);摘掉 `model.id` 传参 → 接线测试 4 条红;把修复整体短路(`if (true) return aligned`)→ 接线测试 4 条红。
81
+ - 全量:`tsc` 双配置 0 错误,**143 文件 / 2479 通过(2 条按平台跳过)**,`tsdown` 构建通过。
82
+ - 真实会话数据复核(`session-356f6889`,5,158 事件):同网关换模型场景(80 条本插件自己的轮次)下,旧的两腿调用检出 **0 块**,三腿调用检出 **76 块 / 134,943 字符**,带正文消息 72 → 56,正文字符 195,867 → 60,984;单模型转录检出 0 块并返回同一对象(no-op 契约成立)。
83
+
3
84
  ## 5.12.0 (2026-10-26)
4
85
 
5
86
  > 特性版本:新增 `dropForeignThinking`(**默认关闭**),修 issue #147——跨 provider 切换后
package/README.en.md CHANGED
@@ -481,12 +481,13 @@ Marketplace screenshots and GitHub README badges are two separate mechanisms:
481
481
 
482
482
 
483
483
  - **Switching region makes the context "grow", and can hit `code 11115` immediately — the gateway is not over-charging you.** The same history is counted under different buckets in the two regions, and the root cause is how the host's bundled `@earendil-works/pi-ai` replays history. It evaluates `isSameModel` for every assistant message (`transform-messages.js:68`, keyed on provider **and** api **and** model id all being equal): for a message that fails, its thinking is **downgraded to ordinary content** (`:88–96` — not dropped), and content is what the gateways count, while thinking goes out in a different field. This plugin registers the domestic and international editions as **two provider ids** (`workbuddy` / `workbuddy-global`, see [How it works](#how-it-works)) that carry the **same model id** — so one region switch moves **all the other side's thinking into the content bucket at once**. That is the cause of issue #59's "the same conversation history is charged 1.374M by the international gateway and 708K by the domestic one (1.94×)"; switching back is the same formula read the other way, 0.696×.
484
- - Three consequences you can see: a region switch can move the token count by ±30%–94%, and **how far depends only on the other side's thinking character count, not on total history length**; **switching models inside one region** triggers the same downgrade (the model leg of `isSameModel`), so the count can move without a region switch at all; and that jump lands on top of the host's current compaction threshold (`min(0.8 × window, window − 128000 − 65536)`), so a session that was comfortably under the limit can hit `code 11115 prompt is too long` the moment you switch region.
484
+ - Three consequences you can see: a region switch can move the token count by ±30%–94%, and **how far depends only on the other side's thinking character count, not on total history length**; **switching models inside one region** triggers the same downgrade (the model leg of `isSameModel`), so the count can move without a region switch at all — the downgrade itself cannot be removed, but **its becoming prose can**, see `dropForeignThinking` below; and that jump lands on top of the host's current compaction threshold (`min(0.8 × window, window − 128000 − 65536)`), so a session that was comfortably under the limit can hit `code 11115 prompt is too long` the moment you switch region.
485
485
  - **The downgrade is not only a counting problem: the reasoning becomes the answer, and the model then copies that shape (issue #147).** The bullet above covers what the downgrade does to BILLING; this one covers what it does to CONTENT. For a message where `isSameModel` fails, pi-ai does not drop its thinking — it rewrites each block to `{type:'text'}` (`transform-messages.js:87–90`), i.e. the answer channel. So after a provider switch, **another model's internal working notes are presented as this turn's reply**, and a model that reads "all of my past reasoning was plain text" keeps writing its plan into the text channel from then on. The reporter measured one real turn: 56 of 80 assistant messages carried an answer while their own `reasoning` was ≤ 1 character, 20 of those held English working notes totalling 58,960 characters, and CJK fell to 1.37% of the 60,984 visible characters where the previous six turns had been entirely Chinese. It also pushed the session from 696,966 to 758,005 total tokens against an 800,000 compaction threshold.
486
486
  - **`dropForeignThinking` (off by default)**: when on, this plugin **removes** those foreign thinking blocks before the request goes out, instead of letting them be flattened into prose. Removal rather than preservation, because these blocks are built by the host's `foreignAssistant()` and carry **no `thinkingSignature`** — even claimed as our own turn they could never be replayed as signed reasoning, so their only possible contribution to the wire was the prose they became. Text blocks and tool calls are never touched, and neither is any message's identity.
487
487
  - With the switch off the request is **byte-for-byte** what earlier builds sent: it changes bytes the upstream counts, so it is an explicit opt-in on the same rule as `usageIdentity`.
488
488
  - The ORDER is load-bearing: the repair runs **after** `alignAssistantIdentities`. A global-region turn of this plugin's own arrives carrying `api: 'dsh-foreign'` too (the host's replay-degrade path), so diagnosing first would classify it as foreign and delete its reasoning — exactly issue #102 in reverse. `tests/issue147-foreign-thinking.spec.ts` pins the order through pi-ai's real `transformMessages`.
489
489
  - Turns already recorded in history are **not** retroactively repaired: they are already stored as prose, and the switch governs later requests only. Starting a new session after the provider switch is the direct way out.
490
+ - **It also covers switching MODELS inside one region** (`hy3` → `deepseek-v4.1-flash`, both served by this plugin, both stamped `provider: workbuddy`). The provider and api legs are equal there, so only the `model` leg separates the turn from the request — and 5.12.0's predicate compared just the first two, so that switch detected **nothing** and the switch looked broken. The model id comes from the request entry points (`model.id` on `stream` / `streamSimple`); omitting it keeps 5.12.0's behaviour exactly, which is why the card's read-only path is unaffected (it reads `lastForeignThinking`, which the request path fills).
490
491
  - **This is a mismatch between the host and pi-ai, not a bug in this plugin**: not replaying thinking across providers/models is right in itself (another model's train of thought is not context this model should be fed), and the mistake is treating `provider` as a necessary condition for "the same model" when the two provider ids here are **two access regions of one model**. The fix belongs upstream (same api + same model id, providers as an equivalence group); on the plugin side the backstop is `src/context-rescue.ts` since 4.8.0 — **once the upstream confirms an overflow, it folds history in chunks and resends**, instead of replaying the entire range the host meant to compact (which is exactly what makes a compaction request overflow itself and deadlock). To see where it happened: set `WORKBUDDY_BODY_REPORT` to capture a body-composition report — it records counts only, never text. **It appends continuously**: one `<region>.jsonl` per region, rolled to `.1` past 32 MB (one old copy kept); unset the variable and delete the files when you are done — do not leave it on for long sessions.
491
492
 
492
493
  ## Disclaimer
package/README.md CHANGED
@@ -496,12 +496,13 @@ else { await envelope.response.text() /* 或者按 SSE 消费 */ }
496
496
  - 由此带来三个用户可见的后果:换区后 token 计数可能跳 ±30%~94%,**跳多少只取决于另一侧 thinking 的字符数,与历史总长无关**;在**同一个区域内换模型**也会触发同样的降级(`isSameModel` 的 model 那条腿),所以不换区也可能看到计数变化;跳变叠加在宿主当前的压缩阈值(`min(0.8×窗口, 窗口−128000−65536)`)上,就会让本来不超限的会话一换区立刻撞上 `code 11115 prompt is too long`。
497
497
  - **这是宿主与 pi-ai 之间的一处错配,不是本插件的 bug**:不跨 provider/模型重放 thinking 这件事本身是对的(别家模型的思考过程对当前模型没有上下文价值),错在把 `provider` 当作「是不是同一个模型」的必要条件,而这里两个 provider 字符串指的是**同一个模型的两个接入区域**。
498
498
  - **本插件已在 4.9.0 起从插件侧绕开它**:两条路由注册的 **model 身份统一为 `workbuddy`**(`src/adapter.ts` 的 `WORKBUDDY_MODEL_IDENTITY`),也就是模型自己声明的 `provider` 字段两边相同,于是 `isSameModel` 成立、换区不再把 thinking 降级成正文。**路由 id 没有合并**——它仍是 `workbuddy` / `workbuddy-global`,各自指向自己的 shim、目录与凭据,所以一侧故障不会带走另一侧,模型选择器也仍是两组。**4.9.0/4.9.1 的这版修复漏了一半,国际版整区不可用(issue #75)**:上面的说法只对 `getModel(provider, id)` 这一条按**参数**查表的路径成立;真正跑聊天的 `models.streamSimple(model, …)` 第一步是 `requireProvider(model)` = `providers.get(model.provider)`(`models.js:351–355`),查的是**模型上盖的那个字段**。于是国内版(路由 `workbuddy`、盖 `workbuddy`)自洽,国际版(路由 `workbuddy-global`、盖 `workbuddy`)在只注册了 `workbuddy-global` 的注册表里查不到,**每一个请求都在发出前就抛 `Unknown provider: workbuddy`**——一次也会失败,且报错里不带任何 WorkBuddy 字样,看起来像宿主坏了。**4.9.2 起改为:每条路由把自己的注册表同时登记上「路由 id」和「共享身份」两个键**(`src/adapter.ts` 的 `modelIdentityKeysOf`),模型上盖的身份仍然两边相同(#59 不回退),而 `requireProvider` 在国际版自己的注册表里也能查到、并拿到**国际版自己的** provider 对象(含它自己的 shim 地址与凭据)。pi-ai 的注册表本来就是 `Map`(`models.js:37` 的 `providers.set(provider.id, provider)`,键取的是 **provider 对象的 `id`**,不是 profiles 映射的键),所以这是注册表原生支持的事;**路由集合、模型选择器、目录与凭据都没有变化**,多出来的那个键不出现在任何 UI 里。回归测试 `tests/issue75-global-provider-dispatch.spec.ts` 走的是宿主的真实构造(`createModels` + `setProvider` + `streamSimple`),并保留一个反证用例固定住旧的失败文案。详见 `docs/audit/ISSUE59-OPTIONS.md` 的「#75 CORRECTION」一节。
499
- - 升级后仍可能看到计数变化的部分:**在同一区域内换模型**仍会降级(`model` 那条腿是真的换了模型,thinking 签名未必对新模型有效,保留才是错的);**升级之前就录在历史里的消息**带着旧的按区域 id,重放时仍按旧规则降级——这种历史只会越聊越少,不会反过来影响新录的消息。
499
+ - 升级后仍可能看到计数变化的部分:**在同一区域内换模型**仍会降级(`model` 那条腿是真的换了模型,thinking 签名未必对新模型有效,保留才是错的)——降级本身无法消除,但**它变成正文这件事可以**,见下面 `dropForeignThinking` 那一条;**升级之前就录在历史里的消息**带着旧的按区域 id,重放时仍按旧规则降级——这种历史只会越聊越少,不会反过来影响新录的消息。
500
500
  - **降级不只是计数问题:思考会变成正文,模型随后照抄这个格式(issue #147)。** 上一条说的是「thinking 被降级」对计费的影响,这条说的是它对**内容**的影响。pi-ai 对 `isSameModel` 不成立的消息**不是丢弃** thinking,而是把它改写成 `{type:'text'}`(`transform-messages.js:87–90`)——也就是答案通道。于是跨 provider 切换后,**另一个模型的内部工作笔记会作为本轮回答呈现**,模型读到「我过去的思考都是正文」这个形状后,接下来的每一轮都继续把计划写进正文。报告者在一轮真实会话里量到:80 条 assistant 消息中 56 条带正文而自身 `reasoning` ≤ 1 字符,其中 20 条是英文工作笔记、合计 58,960 字符;整轮 60,984 字符正文里 CJK 只剩 1.37%,而此前 6 轮的可见回复全是中文。它同时把会话从 696,966 推到 758,005 total token(压缩阈值 800,000)。
501
501
  - **开关 `dropForeignThinking`(默认关闭)**:打开后,本插件在请求发出前**删掉**那些「本来会被压成正文」的外来 thinking 块。之所以是删而不是保留:这些块由宿主以 `foreignAssistant()` 构造,**不带 `thinkingSignature`**,即使被当成自己的轮次也无法作为带签名的 reasoning 重放——它们对线上唯一的贡献就是被压成的那段正文。正文块与工具调用一律不动,每条消息的身份也不动。
502
502
  - 关闭时**一个字节都不变**,与之前版本完全一致——它改的是上游会数到的字节,所以按 `usageIdentity` 的同一条规矩做成显式 opt-in。
503
503
  - 顺序很关键:修复跑在 `alignAssistantIdentities` **之后**。国际区自己的轮次到达时也带 `api: 'dsh-foreign'`(宿主的重放降级路径),先诊断会把它误判成外来轮次并删掉它自己的推理——那正是 #102 的反向回归。`tests/issue147-foreign-thinking.spec.ts` 用真实 pi-ai 的 `transformMessages` 钉住了这个顺序。
504
504
  - 已经录进历史的坏轮次**不会**被追溯修复:它们已经作为正文存在会话里,开关只管之后的请求。想彻底摆脱,切换 provider 后新开会话是最直接的办法。
505
+ - **它同时覆盖「同一个区域内换模型」**(`hy3` → `deepseek-v4.1-flash`,两者都由本插件提供、都盖 `provider: workbuddy`)。这时 provider 与 api 两条腿都相等,判据只差 `model` 一条腿——5.12.0 的判定只比前两条,因此这种切换**一个块也检不出**,开关看起来无效。判断所需的 model id 由请求入口(`stream` / `streamSimple` 的 `model.id`)传入;不传时行为与 5.12.0 完全一致,卡片那条只读路径因此不受影响(它读的本来就是请求路径填好的 `lastForeignThinking`)。
505
506
  - 真正的修法仍在上游(让 `isSameModel` 认「provider 等价组」声明,而不是让插件去伪装自己的身份);插件侧的兜底是 4.8.0 起的 `src/context-rescue.ts`——**一旦上游确认超限,就按块折叠历史后重发**,而不是把宿主要压缩的整段历史原样重放一遍(原样重放正是「压缩请求自己超限 → 压缩死锁」的成因)。想亲眼看到这件事发生在哪条消息上:设 `WORKBUDDY_BODY_REPORT` 抓一次 body 组成报告,它只记计数、不记正文。**注意它会持续追加**:每个区一份 `<region>.jsonl`,超过 32 MB 自动滚成 `.1`(只保留一份旧档);排查完请关闭该环境变量并删除报告文件,不要开着它长期跑。
506
507
 
507
508
  ## 免责声明
package/lib/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as WorkBuddyCredentialStore, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, S as WORKBUDDY_AUTH_FILE_ENV, Ut as findWorkbuddyAppExecutableWithSource, V as WorkBuddyUpstreamClient, Zt as hostPeersInfo, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as rosterSeenPath, f as piAiRuntimeInfo, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, o as workbuddyHostHeartbeatPath, p as FALLBACK_WORKBUDDY_MODELS, r as isHeartbeatProcessAlive, u as describeRosterSeenPayload } from "./host-heartbeat-xEJRdYaC.js";
2
+ import { C as WorkBuddyCredentialStore, D as defaultDesktopAuthDirs, E as defaultDesktopAuthCandidates, I as workbuddyOwnAuthPath, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, S as WORKBUDDY_AUTH_FILE_ENV, Ut as findWorkbuddyAppExecutableWithSource, V as WorkBuddyUpstreamClient, Zt as hostPeersInfo, a as readHostHeartbeat, c as WORKBUDDY_CONNECT_VERSION, d as rosterSeenPath, f as piAiRuntimeInfo, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, o as workbuddyHostHeartbeatPath, p as FALLBACK_WORKBUDDY_MODELS, r as isHeartbeatProcessAlive, u as describeRosterSeenPayload } from "./host-heartbeat-BdTV4Aoi.js";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { realpathSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
@@ -7566,7 +7566,7 @@ function describeRosterSeenPayload(raw) {
7566
7566
  //#endregion
7567
7567
  //#region src/version.ts
7568
7568
  /** The npm package version this build was produced from. */
7569
- const WORKBUDDY_CONNECT_VERSION = "5.12.0";
7569
+ const WORKBUDDY_CONNECT_VERSION = "5.12.2";
7570
7570
  //#endregion
7571
7571
  //#region src/host-heartbeat.ts
7572
7572
  /**
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as parseCreditMultiplier, $t as withFileLock, A as isEncryptedCredentialError, At as pickMediaModel, B as WorkBuddyCredentialRejectedError, Bt as deriveAtRestKeyId, C as WorkBuddyCredentialStore, Ct as IMAGE_TAG_TEXT_TO_IMAGE, D as defaultDesktopAuthDirs, Dt as imageMimeForPath, E as defaultDesktopAuthCandidates, Et as imageDataUrl, F as workbuddyAccountId, Ft as imageDefaultFor, G as forceNeutralHead, Gt as isWorkbuddyBundle, H as channelGateMemberOf, Ht as findWorkbuddyAppExecutable, I as workbuddyOwnAuthPath, It as nativeModalityOf, J as isCredentialRejectedError, Jt as openEncryptedField, K as isContextTooLongBody, Kt as macosBundleExecutable, L as CONTEXT_OVERFLOW_MARKER, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, Mt as parseQueueAnswer, N as parseWorkBuddyAuth, Nt as queueNoticeText, O as defaultDesktopAuthPath, Ot as isBareBase64Image, P as preferredAccountName, Pt as NATIVE_MODALITY_BY_MODEL_ID, Q as openingEvidenceOfBody, Qt as resolveDshHome, R as CREDENTIAL_REJECTED_CODE, Rt as clearAtRestKeyCache, S as WORKBUDDY_AUTH_FILE_ENV, St as IMAGE_TAG_IMAGE_TO_IMAGE, T as authFileName, Tt as WorkBuddyImageError, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, Vt as fetchAtRestKeyPayload, W as declaredTools, Wt as isEncryptedFieldWrapper, X as knownRegionOf, Xt as workbuddyAppExecutableCandidates, Y as isFirstMessageGateBody, Yt as readAtRestKey, Z as modelIdOfBody, _ as deriveCatalog, _t as WorkBuddyVideoError, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as resolveVideoParams, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, en as writeFileAtomic, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as VIDEO_TAG_TEXT_TO_VIDEO, h as WorkBuddyCatalog, ht as VIDEO_TAG_IMAGE_TO_VIDEO, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as sniffImageMime, k as hasEncryptedCredentialFields, kt as parseImageDataUrl, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, qt as macosNestedAppBundles, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseSupportedSecondsRange, w as WorkBuddyEncryptedCredentialError, wt as MAX_INPUT_IMAGE_BYTES, x as WORKBUDDY_AUTH_FILENAME, xt as DEFAULT_IMAGE_SIZE, y as withoutRouters, yt as pickVideoModel, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as deriveAtRestKey } from "./host-heartbeat-xEJRdYaC.js";
1
+ import { $ as parseCreditMultiplier, $t as withFileLock, A as isEncryptedCredentialError, At as pickMediaModel, B as WorkBuddyCredentialRejectedError, Bt as deriveAtRestKeyId, C as WorkBuddyCredentialStore, Ct as IMAGE_TAG_TEXT_TO_IMAGE, D as defaultDesktopAuthDirs, Dt as imageMimeForPath, E as defaultDesktopAuthCandidates, Et as imageDataUrl, F as workbuddyAccountId, Ft as imageDefaultFor, G as forceNeutralHead, Gt as isWorkbuddyBundle, H as channelGateMemberOf, Ht as findWorkbuddyAppExecutable, I as workbuddyOwnAuthPath, It as nativeModalityOf, J as isCredentialRejectedError, Jt as openEncryptedField, K as isContextTooLongBody, Kt as macosBundleExecutable, L as CONTEXT_OVERFLOW_MARKER, Lt as WORKBUDDY_APP_EXECUTABLE_ENV, M as legacyWorkbuddyOwnAuthPath, Mt as parseQueueAnswer, N as parseWorkBuddyAuth, Nt as queueNoticeText, O as defaultDesktopAuthPath, Ot as isBareBase64Image, P as preferredAccountName, Pt as NATIVE_MODALITY_BY_MODEL_ID, Q as openingEvidenceOfBody, Qt as resolveDshHome, R as CREDENTIAL_REJECTED_CODE, Rt as clearAtRestKeyCache, S as WORKBUDDY_AUTH_FILE_ENV, St as IMAGE_TAG_IMAGE_TO_IMAGE, T as authFileName, Tt as WorkBuddyImageError, U as classifyUpstreamError, V as WorkBuddyUpstreamClient, Vt as fetchAtRestKeyPayload, W as declaredTools, Wt as isEncryptedFieldWrapper, X as knownRegionOf, Xt as workbuddyAppExecutableCandidates, Y as isFirstMessageGateBody, Yt as readAtRestKey, Z as modelIdOfBody, _ as deriveCatalog, _t as WorkBuddyVideoError, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as resolveVideoParams, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, en as writeFileAtomic, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as VIDEO_TAG_TEXT_TO_VIDEO, h as WorkBuddyCatalog, ht as VIDEO_TAG_IMAGE_TO_VIDEO, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as sniffImageMime, k as hasEncryptedCredentialFields, kt as parseImageDataUrl, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, qt as macosNestedAppBundles, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseSupportedSecondsRange, w as WorkBuddyEncryptedCredentialError, wt as MAX_INPUT_IMAGE_BYTES, x as WORKBUDDY_AUTH_FILENAME, xt as DEFAULT_IMAGE_SIZE, y as withoutRouters, yt as pickVideoModel, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as deriveAtRestKey } from "./host-heartbeat-BdTV4Aoi.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1543,8 +1543,10 @@ function transcript(context) {
1543
1543
  * The message's own `api` field is not read by the request builder at all
1544
1544
  * (grep: only `model.api` is), so this changes no wire shape.
1545
1545
  *
1546
- * A genuinely different model still degrades: `isSameModel` continues to
1547
- * require the model id to match (covered by a test).
1546
+ * A genuinely different model still degrades, because `isSameModel` requires the
1547
+ * model id to match and this function deliberately does not rewrite it: the
1548
+ * message really was produced by another model. {@link dropForeignThinking}
1549
+ * covers that leg separately, through its `requestModelId` argument.
1548
1550
  *
1549
1551
  * Returns the same object when nothing changed, so the common case (an
1550
1552
  * already-normalized transcript) allocates nothing.
@@ -1580,14 +1582,33 @@ function blocksOf$1(message) {
1580
1582
  const content = message["content"];
1581
1583
  return Array.isArray(content) ? content : [];
1582
1584
  }
1583
- /** Whether this assistant message's identity proves a DIFFERENT model produced it. */
1584
- function isForeignAssistant(message, identity) {
1585
+ /**
1586
+ * Whether this assistant message's identity proves a DIFFERENT model produced it.
1587
+ *
1588
+ * `requestModelId` closes the THIRD leg of pi-ai's own predicate. `isSameModel`
1589
+ * is `provider === … && api === … && model === model.id`
1590
+ * (`transform-messages.js:68–70`); this function used to compare only the first
1591
+ * two. The consequence was a blind spot the size of one whole route: a session
1592
+ * that switches MODELS inside one region — `hy3` → `deepseek-v4.1-flash`, both
1593
+ * served by this bundle, both stamped `provider: workbuddy` — keeps the provider
1594
+ * and api legs equal, so nothing here noticed, while pi-ai flattened every
1595
+ * historical thinking block into the answer channel exactly as in issue #147.
1596
+ * The user-visible symptom is #147's, with none of its diagnostics.
1597
+ *
1598
+ * Passing `undefined` keeps the provider/api-only behaviour, which is what the
1599
+ * read-only surfaces and the older call sites expect. Production passes the id
1600
+ * of the model the request is actually addressed to.
1601
+ */
1602
+ function isForeignAssistant(message, identity, requestModelId) {
1585
1603
  if (typeof message !== "object" || message === null) return false;
1586
1604
  const record = message;
1587
1605
  if (record["role"] !== "assistant") return false;
1588
1606
  if (record["api"] === "dsh-foreign") return true;
1589
1607
  const provider = record["provider"];
1590
- return typeof provider === "string" && provider !== identity && provider !== "workbuddy" && provider !== "workbuddy-global";
1608
+ if (typeof provider !== "string" || provider !== identity && provider !== "workbuddy" && provider !== "workbuddy-global") return true;
1609
+ if (requestModelId === void 0) return false;
1610
+ const model = record["model"];
1611
+ return typeof model === "string" && model !== requestModelId;
1591
1612
  }
1592
1613
  /**
1593
1614
  * Every foreign thinking block pi-ai would turn into visible prose (issue #147).
@@ -1614,14 +1635,17 @@ function isForeignAssistant(message, identity) {
1614
1635
  *
1615
1636
  * @param context - a pi-ai context, before or after `alignAssistantIdentities`.
1616
1637
  * @param identity - the identity this adapter stamps on its own models.
1638
+ * @param requestModelId - model id the request is addressed to, so the model
1639
+ * leg of pi-ai's predicate is covered too (see {@link isForeignAssistant}).
1640
+ * Omitted, only the provider/api legs are judged.
1617
1641
  * @returns one entry per affected block, in transcript order.
1618
1642
  */
1619
- function foreignThinkingOf(context, identity = WORKBUDDY_MODEL_IDENTITY) {
1643
+ function foreignThinkingOf(context, identity = WORKBUDDY_MODEL_IDENTITY, requestModelId) {
1620
1644
  const messages = context.messages;
1621
1645
  if (!Array.isArray(messages)) return [];
1622
1646
  const found = [];
1623
1647
  for (const [messageIndex, message] of messages.entries()) {
1624
- if (!isForeignAssistant(message, identity)) continue;
1648
+ if (!isForeignAssistant(message, identity, requestModelId)) continue;
1625
1649
  const stopReason = message["stopReason"];
1626
1650
  const variant = stopReason === "error" || stopReason === "aborted" ? "dropped" : "flattened";
1627
1651
  for (const [blockIndex, block] of blocksOf$1(message).entries()) {
@@ -1669,10 +1693,12 @@ function foreignThinkingOf(context, identity = WORKBUDDY_MODEL_IDENTITY) {
1669
1693
  *
1670
1694
  * @param context - a pi-ai context.
1671
1695
  * @param identity - the identity this adapter stamps on its own models.
1696
+ * @param requestModelId - model id the request is addressed to (see
1697
+ * {@link isForeignAssistant}); omitted, only the provider/api legs are judged.
1672
1698
  * @returns the repaired context, or the input when no block qualified.
1673
1699
  */
1674
- function dropForeignThinking(context, identity = WORKBUDDY_MODEL_IDENTITY) {
1675
- const affected = foreignThinkingOf(context, identity);
1700
+ function dropForeignThinking(context, identity = WORKBUDDY_MODEL_IDENTITY, requestModelId) {
1701
+ const affected = foreignThinkingOf(context, identity, requestModelId);
1676
1702
  if (affected.length === 0) return context;
1677
1703
  const messages = context.messages;
1678
1704
  const byMessage = /* @__PURE__ */ new Map();
@@ -1698,25 +1724,25 @@ function dropForeignThinking(context, identity = WORKBUDDY_MODEL_IDENTITY) {
1698
1724
  }
1699
1725
  function withLegacyContext(api, generation, identity = WORKBUDDY_MODEL_IDENTITY, ownedRoutes = [WORKBUDDY_PROVIDER, WORKBUDDY_GLOBAL_PROVIDER], repair) {
1700
1726
  /** One place so both branches and both entry points agree on the order. */
1701
- const prepare = (context) => {
1727
+ const prepare = (context, requestModelId) => {
1702
1728
  const aligned = alignAssistantIdentities(generation === "modern" ? foldModernContext(context) : adaptLegacyPiAiContext(context), identity, ownedRoutes);
1703
1729
  if (repair?.dropForeignThinking !== true) return aligned;
1704
- const affected = foreignThinkingOf(aligned, identity);
1730
+ const affected = foreignThinkingOf(aligned, identity, requestModelId);
1705
1731
  if (affected.length === 0) return aligned;
1706
1732
  try {
1707
1733
  repair.onForeignThinking?.(affected);
1708
1734
  } catch {}
1709
- return dropForeignThinking(aligned, identity);
1735
+ return dropForeignThinking(aligned, identity, requestModelId);
1710
1736
  };
1711
1737
  if (generation === "modern") return {
1712
1738
  ...api,
1713
- stream: (model, context, options) => api.stream(model, transcript(prepare(context)), options),
1714
- streamSimple: (model, context, options) => api.streamSimple(model, transcript(prepare(context)), options)
1739
+ stream: (model, context, options) => api.stream(model, transcript(prepare(context, model.id)), options),
1740
+ streamSimple: (model, context, options) => api.streamSimple(model, transcript(prepare(context, model.id)), options)
1715
1741
  };
1716
1742
  return {
1717
1743
  ...api,
1718
- stream: (model, context, options) => api.stream(model, transcript(prepare(context)), options),
1719
- streamSimple: (model, context, options) => api.streamSimple(model, transcript(prepare(context)), options)
1744
+ stream: (model, context, options) => api.stream(model, transcript(prepare(context, model.id)), options),
1745
+ streamSimple: (model, context, options) => api.streamSimple(model, transcript(prepare(context, model.id)), options)
1720
1746
  };
1721
1747
  }
1722
1748
  /**
@@ -2767,6 +2793,56 @@ const MARKER_ONLY_TAG_NAMES = /* @__PURE__ */ new Set(["tool-calls", "toolcalls"
2767
2793
  /** Fence markers at the start of a line. */
2768
2794
  const FENCE_MARKERS = ["```", "~~~"];
2769
2795
  /**
2796
+ * Shortest tool name a near-miss repair may act on (issue #152).
2797
+ *
2798
+ * Below this, "one edit apart" stops being evidence: `dash` and `bash` are one
2799
+ * edit apart, and so are any two four-letter names sharing a prefix. At 4 the
2800
+ * reported `pwsh` → `pwdsh` still qualifies (both are 4+), which is the shape
2801
+ * this exists for.
2802
+ */
2803
+ const NEAR_NAME_MIN_LENGTH = 4;
2804
+ /**
2805
+ * Whether `a` and `b` are at most one edit apart (insertion, deletion, or
2806
+ * substitution).
2807
+ *
2808
+ * Only ever asked for a ceiling of 1, where the general Levenshtein matrix is
2809
+ * unnecessary: the lengths must differ by at most one, and after skipping the
2810
+ * first position where they differ, the remaining tails must still agree.
2811
+ *
2812
+ * The equal-length case is a SUBSTITUTION, not an insertion, so it is checked
2813
+ * separately — skipping the differing character on one side only would leave
2814
+ * tails of different length and silently miss every one-character typo of the
2815
+ * same length (`pwdsh`/`pwdss`, `bash`/`dash`), which is the whole point here.
2816
+ */
2817
+ function withinOneEdit(a, b) {
2818
+ if (a === b) return true;
2819
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a];
2820
+ if (long.length - short.length > 1) return false;
2821
+ let i = 0;
2822
+ while (i < short.length && short[i] === long[i]) i++;
2823
+ if (short.length === long.length) return short.slice(i + 1) === long.slice(i + 1);
2824
+ return long.slice(i + 1) === short.slice(i);
2825
+ }
2826
+ /**
2827
+ * The one declared tool within a single edit of `written`, or `undefined`.
2828
+ *
2829
+ * Refuses when the name is already declared (nothing to repair), when either
2830
+ * side is shorter than {@link NEAR_NAME_MIN_LENGTH}, and when more than one
2831
+ * declared tool is in reach — ambiguity is exactly what gate 2 exists to stop.
2832
+ */
2833
+ function nearMissNameFor(written, declaredNames) {
2834
+ if (declaredNames.has(written)) return void 0;
2835
+ if (written.length < NEAR_NAME_MIN_LENGTH) return void 0;
2836
+ let fit;
2837
+ for (const candidate of declaredNames) {
2838
+ if (candidate.length < NEAR_NAME_MIN_LENGTH) continue;
2839
+ if (!withinOneEdit(written, candidate)) continue;
2840
+ if (fit !== void 0) return void 0;
2841
+ fit = candidate;
2842
+ }
2843
+ return fit;
2844
+ }
2845
+ /**
2770
2846
  * Find every span that must not be scanned for markup.
2771
2847
  *
2772
2848
  * One pass, not a predicate call per character: the reference asks
@@ -3182,8 +3258,11 @@ function parseInvoke(open, close, block, gate) {
3182
3258
  const parameters = parseParameters(block.slice(open.end + 1, close.start));
3183
3259
  let name;
3184
3260
  if (declared !== void 0 && declared !== "") {
3185
- if (!gate.declaredNames.has(declared)) return void 0;
3186
- name = declared;
3261
+ if (!gate.declaredNames.has(declared)) {
3262
+ const repaired = gate.repairNearMissName === true ? nearMissNameFor(declared, gate.declaredNames) : void 0;
3263
+ if (repaired === void 0) return void 0;
3264
+ name = repaired;
3265
+ } else name = declared;
3187
3266
  } else if (open.attributes.trim() === "") {
3188
3267
  name = inferNameFromSchema(parameters, gate);
3189
3268
  if (name === void 0) return void 0;
@@ -4969,6 +5048,7 @@ function createWorkBuddyShim(options) {
4969
5048
  requiredParameters: declared.requiredParameters,
4970
5049
  acceptedParameters: declared.acceptedParameters,
4971
5050
  inferFromSchema: true,
5051
+ repairNearMissName: true,
4972
5052
  ...declared.pinnedToolName === void 0 ? {} : { pinnedToolName: declared.pinnedToolName }
4973
5053
  };
4974
5054
  const notice = switchedFrom === void 0 || switchedBecause === void 0 ? void 0 : failoverNoticeText(switchedFrom, switchedBecause);
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": "5.12.0",
5
+ "version": "5.12.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",
@@ -96,6 +96,11 @@
96
96
  "@earendil-works/pi-ai": ">=0.85.0 <0.88.0 || >=1.0.0-0 <2.0.0",
97
97
  "react": "^18.2.0"
98
98
  },
99
+ "peerDependenciesMeta": {
100
+ "@earendil-works/pi-ai": {
101
+ "optional": true
102
+ }
103
+ },
99
104
  "devDependencies": {
100
105
  "@deepseek-ai/cordis": ">=4.0.1 <5.0.0 || >=4.0.2 <5.0.0",
101
106
  "@deepseek-ai/dsh-atomic-write": ">=0.1.7-rc.1 <0.3.0-0",