dsh-connect-workbuddy 5.11.0 → 5.12.1

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,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.12.1 (2026-10-26)
4
+
5
+ > 修正 5.12.0 的 `dropForeignThinking` 漏判的一条腿:`isSameModel` 判据是 provider **且** api
6
+ > **且** model id 三者全等,而 5.12.0 的 `isForeignAssistant` 只比了前两条。于是**在同一个区域内
7
+ > 换模型**(`hy3` → `deepseek-v4.1-flash`,两者都由本插件提供、都盖 `provider: workbuddy`)时
8
+ > 一个块也检不出,开关看起来无效,而 pi-ai 照样把历史 thinking 摊平成正文。修复只补判据与传参,
9
+ > 不传时行为与 5.12.0 完全一致;开关默认仍为关闭。
10
+
11
+ ### Fixes
12
+
13
+ - **同一区域内换模型时,`dropForeignThinking` 检不出任何块(#147 的第三条腿)**
14
+ - **病因是判据少了一条腿。** 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 一模一样,却没有诊断、开关也无效。
15
+ - **修法**:`isForeignAssistant` 增加可选的 `requestModelId`,补上 model 腿;`foreignThinkingOf` / `dropForeignThinking` 透传它;`withLegacyContext` 的 `prepare()` 在 `stream` / `streamSimple` 两个入口、legacy / modern 两个分支共四处传 `model.id`。**不传时行为完全不变**(只判前两条腿),所以卡片等只读 surface 与既有调用点零影响。
16
+ - 传 `model.id` 而**不是** `record['model']`:`dsh-llm-pi-ai` 的 `replayedAssistant` 在 `response.api === 'anthropic-messages'` 时会取 `responseModel ?? model`(`lib/index.js:218`),而 `isSameModel` 比的是 `model.id`,这里与它对齐。
17
+ - **顺带澄清一处不成立的顾虑**:卡片那条路径**不需要**单独传 model id。卡片读的是 status 端点的 `lastForeignThinking`,该值由 `onForeignThinking` 在**请求路径**里填充,因此天然带着 `model.id`——「换模型导致的摊平」在卡片上同样可见,无需 follow-up。
18
+ - **两处已失效的表述同步改正**:`alignAssistantIdentities` 的文档(`src/adapter.ts:671`)原写「换模型仍会降级……(有测试覆盖)」,但当时并无任何测试覆盖这条腿;README 中「同一区域内换模型仍会降级」一句现在补明「降级本身消除不了,但它变成正文这件事可以」。
19
+
20
+ ### Tests
21
+
22
+ - 新增 `tests/issue147-model-leg.spec.ts`(9 条),走**真实 pi-ai 的 `transformMessages`**:缺陷本身可复现 / 旧两腿调用检不出(盲区钉成对照)/ 三腿调用检出并删除 / 外来 provider 仍检出(第三条腿是追加不是替换)/ 同 model 的消息一个不动且返回同一对象 / 无 `model` 字段不误报 / 外来轮次 model id 恰好相同也不洗白 / 一次真实切换里只删旧模型的轮次 / 幂等。
23
+ - 新增 `tests/issue147-model-leg-wiring.spec.ts`(12 条),补上**接线**而不是判据:legacy 与 modern 两个分支 × `stream` / `streamSimple` 两个入口 × 三种状态(换模型时删、同名模型时留、开关关闭时原样),都经过真实 `createWorkBuddyAdapter`,断言 api 收到的上下文里 thinking 块的去留、以及 `onForeignThinking` 是否触发。
24
+ - **这条为什么必须单独存在**:把四处 `prepare(context, model.id)` 全部改回 `prepare(context)`(即修复被静默摘掉),**整套 2467 条测试依然全绿**——纯函数测试自己调判据,没有任何东西发现 wrapper 没传 id。补上后该变异使其中 4 条全红。这与 `tests/adapter.spec.ts` 当初为 issue #24 建的洞同型。
25
+ - 后两种状态是防止「一概删除」也能通过的对照:若判据退化成「凡 assistant 轮次都算外来」,删除会把**当前模型自己的**推理一起删掉(#102 的反向回归),所以同 model 与开关关闭两条必须看到 thinking 保留。
26
+ - 变异验证:model 腿恒 `false` → 2 条红;恒 `true` → 3 条红(含 #147 原有 suite 1 条);摘掉 `model.id` 传参 → 接线测试 4 条红;把修复整体短路(`if (true) return aligned`)→ 接线测试 4 条红。
27
+ - 全量:`tsc` 双配置 0 错误,**143 文件 / 2479 通过(2 条按平台跳过)**,`tsdown` 构建通过。
28
+ - 真实会话数据复核(`session-356f6889`,5,158 事件):同网关换模型场景(80 条本插件自己的轮次)下,旧的两腿调用检出 **0 块**,三腿调用检出 **76 块 / 134,943 字符**,带正文消息 72 → 56,正文字符 195,867 → 60,984;单模型转录检出 0 块并返回同一对象(no-op 契约成立)。
29
+
30
+ ## 5.12.0 (2026-10-26)
31
+
32
+ > 特性版本:新增 `dropForeignThinking`(**默认关闭**),修 issue #147——跨 provider 切换后
33
+ > pi-ai 把外来轮次的 thinking 改写成正文,模型随后照抄这个格式,整轮回答变成英文工作笔记。
34
+ > 关闭时请求字节与 5.11.0 完全一致;打开后只删「本来会被压成正文、且不可能作为带签名 reasoning
35
+ > 重放」的外来 thinking 块,正文块、工具调用与每条消息的身份都不动。请求路径、provider 路由、
36
+ > 计费与凭据处理一律未变。
37
+
38
+ ### Fixes
39
+
40
+ - **思考变正文:跨 provider 切换后模型把工作笔记写进回答(#147)**
41
+ - **病因在 pi-ai,不在本插件。** `transform-messages.js:68` 用 `isSameModel`(provider **且** api **且** model id)决定一条历史 thinking 能否按 reasoning 重放;不成立时它**不丢弃**该块,而是改写成 `{type:'text', text: thinking}`(`:87–90`)——也就是答案通道。会话一旦跨 provider 切换过,另一个模型的内部工作笔记就以「本轮回答」的身份出现,模型读到「我过去的思考全是正文」这个形状后持续照抄。
42
+ - **实测复现(本机 pi-ai 0.85.1 + 真实 `transformMessages`)**:`api: 'dsh-foreign'` 的外来轮次 → `[text]`(200/300 字符全部进正文);本插件自己的轮次 → `[thinking]`;把该块丢掉 → `[]`(不产生任何正文)。报告者的一轮真实会话里,80 条 assistant 消息中 56 条带正文而 `reasoning` ≤ 1 字符,20 条英文工作笔记合计 58,960 字符,CJK 占比跌到 1.37%;会话从 696,966 涨到 758,005 total token。
43
+ - **为什么是「删除」而不是「保留为 thinking」**:`isSameModel` 只比对消息自身字段,唯一的满足方式是把这个外来轮次宣称为本模型的输出——那正是 #111 刚删掉的来源谎言。而这些块由宿主 `foreignAssistant()` 构造、**不带 `thinkingSignature`**,即便被宣称也无法作为带签名 reasoning 重放,它们对线上唯一的贡献就是被压成的那段正文。
44
+ - **修法**:新增 `src/adapter.ts` 的 `foreignThinkingOf()`(只读诊断)与 `dropForeignThinking()`(实施删除),在 `withLegacyContext` 的 `prepare` 里、**`alignAssistantIdentities` 之后**执行。顺序是承重的:国际区自己的轮次到达时同样带 `api: 'dsh-foreign'`,先诊断会把它误判成外来轮次并删掉它自己的推理(#102 的反向回归)。
45
+ - **开关**:`dropForeignThinking`(volatile,默认 `false`)。用 getter 读活值,拨动后**下一个请求**即生效,不需要重载插件——用户正是在看着那条工作笔记时打开它。
46
+ - **诊断可见**:被删的块数与位置写入 status 端点的 `lastForeignThinking`(进程内、last-wins、重启即忘),`dropForeignThinking` 开关本身也随 status 下发,供卡片渲染提示。
47
+
48
+ ### Tests
49
+
50
+ - 新增 `tests/issue147-foreign-thinking.spec.ts`(15 条),全部经过**真实 pi-ai 的 `transformMessages`**,而不是桩:缺陷本身可复现、开启后不产生正文、关闭时原对象返回(零改动)、本插件国内外两区轮次都保留 thinking、正文与工具调用不被触碰、redacted 与空块不误报、错误轮次报 `dropped` 变体、顺序必须在 alignment 之后、幂等。
51
+ - 变异验证:把 `dropForeignThinking` 改成空操作 → 4 条变红;把判断放宽到所有 assistant 轮次 → 7 条变红。
52
+ - 全量:**142 文件 / 2458 通过(2 条按平台跳过)**;两个 tsconfig 均 exit 0。
53
+
3
54
  ## 5.11.0 (2026-10-26)
4
55
 
5
56
  > 特性版本:新增 `workbuddy_draw_video`(文生视频 / 图生视频,异步任务「提交 → 轮询 → 下载」,
package/README.en.md CHANGED
@@ -481,7 +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
+ - **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
+ - **`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
+ - 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
+ - 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
+ - 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).
485
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.
486
492
 
487
493
  ## Disclaimer
package/README.md CHANGED
@@ -496,7 +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
+ - **降级不只是计数问题:思考会变成正文,模型随后照抄这个格式(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
+ - **开关 `dropForeignThinking`(默认关闭)**:打开后,本插件在请求发出前**删掉**那些「本来会被压成正文」的外来 thinking 块。之所以是删而不是保留:这些块由宿主以 `foreignAssistant()` 构造,**不带 `thinkingSignature`**,即使被当成自己的轮次也无法作为带签名的 reasoning 重放——它们对线上唯一的贡献就是被压成的那段正文。正文块与工具调用一律不动,每条消息的身份也不动。
502
+ - 关闭时**一个字节都不变**,与之前版本完全一致——它改的是上游会数到的字节,所以按 `usageIdentity` 的同一条规矩做成显式 opt-in。
503
+ - 顺序很关键:修复跑在 `alignAssistantIdentities` **之后**。国际区自己的轮次到达时也带 `api: 'dsh-foreign'`(宿主的重放降级路径),先诊断会把它误判成外来轮次并删掉它自己的推理——那正是 #102 的反向回归。`tests/issue147-foreign-thinking.spec.ts` 用真实 pi-ai 的 `transformMessages` 钉住了这个顺序。
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`)。
500
506
  - 真正的修法仍在上游(让 `isSameModel` 认「provider 等价组」声明,而不是让插件去伪装自己的身份);插件侧的兜底是 4.8.0 起的 `src/context-rescue.ts`——**一旦上游确认超限,就按块折叠历史后重发**,而不是把宿主要压缩的整段历史原样重放一遍(原样重放正是「压缩请求自己超限 → 压缩死锁」的成因)。想亲眼看到这件事发生在哪条消息上:设 `WORKBUDDY_BODY_REPORT` 抓一次 body 组成报告,它只记计数、不记正文。**注意它会持续追加**:每个区一份 `<region>.jsonl`,超过 32 MB 自动滚成 `.1`(只保留一份旧档);排查完请关闭该环境变量并删除报告文件,不要开着它长期跑。
501
507
 
502
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-BYdr-pVq.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-DKKM63s5.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.11.0";
7569
+ const WORKBUDDY_CONNECT_VERSION = "5.12.1";
7570
7570
  //#endregion
7571
7571
  //#region src/host-heartbeat.ts
7572
7572
  /**
package/lib/index.d.ts CHANGED
@@ -2333,6 +2333,22 @@ interface WorkBuddyAdapterOptions {
2333
2333
  * them. Production callers leave it unset.
2334
2334
  */
2335
2335
  piAiGeneration?: PiAiGeneration;
2336
+ /**
2337
+ * Drop foreign thinking blocks pi-ai would publish as prose (issue #147).
2338
+ *
2339
+ * OFF by default, and read LIVE (a getter, not a boolean) so the switch takes
2340
+ * effect on the next request without reloading the plugin: turning it on
2341
+ * mid-session is the whole point — the user sees the working-notes turn,
2342
+ * flips the switch, and the next turn is answered normally.
2343
+ */
2344
+ dropForeignThinking?: () => boolean;
2345
+ /**
2346
+ * Told which foreign thinking blocks a request dropped, so the reply can name
2347
+ * the condition instead of leaving the user to conclude the model broke
2348
+ * (issue #147 asks for at least a visible hint). Called at most once per
2349
+ * request; failures are swallowed.
2350
+ */
2351
+ onForeignThinking?: (blocks: readonly ForeignThinkingBlock[]) => void;
2336
2352
  }
2337
2353
  /** What {@link createWorkBuddyAdapter} hands back. */
2338
2354
  interface WorkBuddyAdapter {
@@ -2384,6 +2400,32 @@ declare function workBuddyDisplayName(info: WorkBuddyModelInfo): string;
2384
2400
  * treats undeclared capability.
2385
2401
  */
2386
2402
  declare function workBuddyThinkingLevelMap(info: WorkBuddyModelInfo): WorkBuddyThinkingLevelMap | undefined;
2403
+ /**
2404
+ * One foreign thinking block that pi-ai would publish as PROSE, and the reason it
2405
+ * would (issue #147).
2406
+ *
2407
+ * `variant` is not cosmetic: the two are handled by the same repair but they
2408
+ * mean different things, and the card explains them differently.
2409
+ *
2410
+ * - `flattened`: `stopReason` is a normal completion, so `transform-messages`
2411
+ * turns the block into `{type:'text', text: thinking}` — the whole reasoning
2412
+ * body lands in the visible answer.
2413
+ * - `dropped`: `stopReason` is `error`/`aborted`, so the entire message is
2414
+ * skipped; nothing reaches the wire and nothing reaches the user. It is
2415
+ * reported anyway, because it still costs context: the model's answer to
2416
+ * "why did my last turn say nothing" is this line, and a caller cannot see it
2417
+ * from the transcript.
2418
+ */
2419
+ interface ForeignThinkingBlock {
2420
+ /** Index of the message in the context's `messages`. */
2421
+ messageIndex: number;
2422
+ /** Index of the block within that message's `content`. */
2423
+ blockIndex: number;
2424
+ /** How many characters of reasoning the block carried. */
2425
+ characters: number;
2426
+ /** Whether pi-ai would flatten it into prose or drop the whole message. */
2427
+ variant: 'flattened' | 'dropped';
2428
+ }
2387
2429
  /**
2388
2430
  * Assemble the adapter. The provider's `getModels` reads the live catalog,
2389
2431
  * and every model's `baseUrl` is re-resolved per read so the shim's
@@ -3663,6 +3705,30 @@ type WorkBuddyWebUsage =
3663
3705
  * GLOBAL and Host-carried for the same reasons as `cliBridgeModel`.
3664
3706
  */
3665
3707
  cliBridgeRegionOrder?: string;
3708
+ /**
3709
+ * Whether the request path drops foreign thinking blocks (issue #147).
3710
+ *
3711
+ * Optional and absent reads as OFF: the repair changes bytes the upstream
3712
+ * counts, so a Host that cannot answer must not be read as consent.
3713
+ */
3714
+ dropForeignThinking?: boolean;
3715
+ /**
3716
+ * What the LAST request had to drop, for the card's explanation of a turn
3717
+ * that arrived looking like working notes (issue #147).
3718
+ *
3719
+ * Absent means "no report", not "feature off" — the two are different facts
3720
+ * and the card says different things about them. Process-local and
3721
+ * last-wins: it describes the turn the reader is looking at.
3722
+ */
3723
+ lastForeignThinking?: {
3724
+ blocks: readonly {
3725
+ messageIndex: number;
3726
+ blockIndex: number;
3727
+ characters: number;
3728
+ variant: 'flattened' | 'dropped';
3729
+ }[];
3730
+ atMs: number;
3731
+ };
3666
3732
  /**
3667
3733
  * The LIVE load level per model id, for the card's icon.
3668
3734
  *
@@ -3762,6 +3828,30 @@ type WorkBuddyWebUsage =
3762
3828
  * GLOBAL and Host-carried for the same reasons as `cliBridgeModel`.
3763
3829
  */
3764
3830
  cliBridgeRegionOrder?: string;
3831
+ /**
3832
+ * Whether the request path drops foreign thinking blocks (issue #147).
3833
+ *
3834
+ * Optional and absent reads as OFF: the repair changes bytes the upstream
3835
+ * counts, so a Host that cannot answer must not be read as consent.
3836
+ */
3837
+ dropForeignThinking?: boolean;
3838
+ /**
3839
+ * What the LAST request had to drop, for the card's explanation of a turn
3840
+ * that arrived looking like working notes (issue #147).
3841
+ *
3842
+ * Absent means "no report", not "feature off" — the two are different facts
3843
+ * and the card says different things about them. Process-local and
3844
+ * last-wins: it describes the turn the reader is looking at.
3845
+ */
3846
+ lastForeignThinking?: {
3847
+ blocks: readonly {
3848
+ messageIndex: number;
3849
+ blockIndex: number;
3850
+ characters: number;
3851
+ variant: 'flattened' | 'dropped';
3852
+ }[];
3853
+ atMs: number;
3854
+ };
3765
3855
  /**
3766
3856
  * The LIVE load level per model id, for the card's icon.
3767
3857
  *
@@ -4377,6 +4467,30 @@ interface WorkBuddyStatusRouteOptions {
4377
4467
  * as {@link cliBridgeModel}.
4378
4468
  */
4379
4469
  cliBridgeRegionOrder?(): string;
4470
+ /**
4471
+ * Whether the request path drops foreign thinking blocks (issue #147).
4472
+ *
4473
+ * Optional, absent reads as OFF: the repair changes bytes the upstream counts,
4474
+ * so a Host that cannot answer must not be read as consent.
4475
+ */
4476
+ dropForeignThinking?(): boolean;
4477
+ /**
4478
+ * What the LAST request had to drop, for the card's explanation of a turn that
4479
+ * arrived looking like working notes (issue #147).
4480
+ *
4481
+ * Optional, and absent means "nothing to report" — the card then renders no
4482
+ * warning rather than a permanent one. Process-local and last-wins: it
4483
+ * describes the turn the user is looking at, not a historical tally.
4484
+ */
4485
+ latestForeignThinking?(): {
4486
+ blocks: readonly {
4487
+ messageIndex: number;
4488
+ blockIndex: number;
4489
+ characters: number;
4490
+ variant: 'flattened' | 'dropped';
4491
+ }[];
4492
+ atMs: number;
4493
+ } | undefined;
4380
4494
  /**
4381
4495
  * The LIVE load level per model id, for the card's icon.
4382
4496
  *
@@ -5045,6 +5159,26 @@ interface Config {
5045
5159
  * entry is matched literally at the start of a trailing `user` message.
5046
5160
  */
5047
5161
  extraInjectedPrefixes?: string[];
5162
+ /**
5163
+ * Drop foreign thinking blocks pi-ai would publish as prose (issue #147).
5164
+ *
5165
+ * When a session moves between providers, `dsh-llm-pi-ai` rebuilds the other
5166
+ * provider's assistant turns with `api: 'dsh-foreign'`, and pi-ai's
5167
+ * `isSameModel` then fails — so it does not DROP their `thinking` blocks, it
5168
+ * rewrites each one to plain `text` (`transform-messages.js:87–90`). The
5169
+ * other model's working notes therefore arrive as this turn's answer, and the
5170
+ * model copies that shape on every later turn.
5171
+ *
5172
+ * On, those blocks are removed from the request instead: they carry no
5173
+ * `thinkingSignature` (the host builds them without one), so they could never
5174
+ * be replayed as reasoning anyway — their only possible contribution to the
5175
+ * wire was the prose they turned into. Text blocks and tool calls are
5176
+ * untouched, and so is every message's identity.
5177
+ *
5178
+ * OFF by default: it changes bytes the upstream counts, so it is an explicit
5179
+ * opt-in on the same rule as {@link Config.usageIdentity}.
5180
+ */
5181
+ dropForeignThinking?: boolean;
5048
5182
  /**
5049
5183
  * Move the host's session-title request onto the hidden lightweight model
5050
5184
  * (issues #132/#133). Off by default: it is a real upstream request either
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-BYdr-pVq.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-DKKM63s5.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.
@@ -1574,18 +1576,173 @@ function alignAssistantIdentities(context, identity = WORKBUDDY_MODEL_IDENTITY,
1574
1576
  messages: next
1575
1577
  } : context;
1576
1578
  }
1577
- function withLegacyContext(api, generation, identity = WORKBUDDY_MODEL_IDENTITY, ownedRoutes = [WORKBUDDY_PROVIDER, WORKBUDDY_GLOBAL_PROVIDER]) {
1579
+ /** Read `content` as a block array without assuming the shape. */
1580
+ function blocksOf$1(message) {
1581
+ if (typeof message !== "object" || message === null) return [];
1582
+ const content = message["content"];
1583
+ return Array.isArray(content) ? content : [];
1584
+ }
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) {
1603
+ if (typeof message !== "object" || message === null) return false;
1604
+ const record = message;
1605
+ if (record["role"] !== "assistant") return false;
1606
+ if (record["api"] === "dsh-foreign") return true;
1607
+ const provider = record["provider"];
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;
1612
+ }
1613
+ /**
1614
+ * Every foreign thinking block pi-ai would turn into visible prose (issue #147).
1615
+ *
1616
+ * THE DEFECT. pi-ai keeps a historical `thinking` block only while
1617
+ * `isSameModel` holds — provider AND api AND model id all equal to the request
1618
+ * (`transform-messages.js:68`). For everything else it does NOT drop the block:
1619
+ * `:87–90` rewrites it to `{type:'text', text: thinking}`, which is the answer
1620
+ * channel. A session that moved between providers therefore arrives with its
1621
+ * other provider's working notes already spelled as prose, and the model reads
1622
+ * that shape and copies it.
1623
+ *
1624
+ * Issue #147 measured the consequence on a real 80-step turn: 56 assistant
1625
+ * messages carried an answer while their own `reasoning` was ≤ 1 character,
1626
+ * 20 of them held English working notes totalling 58,960 characters, and CJK
1627
+ * fell to 1.37% of the visible text where the preceding six turns had been
1628
+ * entirely Chinese. That is not a formatting complaint — it also pushed the
1629
+ * session from 696,966 to 758,005 total tokens against an 800,000 compression
1630
+ * threshold.
1631
+ *
1632
+ * WHY THIS IS READ-ONLY. Nothing here rewrites the transcript:
1633
+ * {@link dropForeignThinking} does, and the split keeps the DIAGNOSIS available
1634
+ * to surfaces that must not change bytes (the card, the reply notice).
1635
+ *
1636
+ * @param context - a pi-ai context, before or after `alignAssistantIdentities`.
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.
1641
+ * @returns one entry per affected block, in transcript order.
1642
+ */
1643
+ function foreignThinkingOf(context, identity = WORKBUDDY_MODEL_IDENTITY, requestModelId) {
1644
+ const messages = context.messages;
1645
+ if (!Array.isArray(messages)) return [];
1646
+ const found = [];
1647
+ for (const [messageIndex, message] of messages.entries()) {
1648
+ if (!isForeignAssistant(message, identity, requestModelId)) continue;
1649
+ const stopReason = message["stopReason"];
1650
+ const variant = stopReason === "error" || stopReason === "aborted" ? "dropped" : "flattened";
1651
+ for (const [blockIndex, block] of blocksOf$1(message).entries()) {
1652
+ if (typeof block !== "object" || block === null) continue;
1653
+ const fields = block;
1654
+ if (fields["type"] !== "thinking") continue;
1655
+ if (fields["redacted"] === true) continue;
1656
+ const thinking = fields["thinking"];
1657
+ const characters = typeof thinking === "string" ? thinking.length : 0;
1658
+ if (characters === 0) continue;
1659
+ found.push({
1660
+ messageIndex,
1661
+ blockIndex,
1662
+ characters,
1663
+ variant
1664
+ });
1665
+ }
1666
+ }
1667
+ return found;
1668
+ }
1669
+ /**
1670
+ * Remove the foreign thinking blocks {@link foreignThinkingOf} finds.
1671
+ *
1672
+ * WHY REMOVAL IS THE HONEST REPAIR. The alternatives are both worse:
1673
+ *
1674
+ * - LEAVE IT: pi-ai publishes the other provider's private working notes as
1675
+ * this turn's answer (the #147 defect), so the user reads reasoning instead
1676
+ * of a reply and the model imitates that shape forever after.
1677
+ * - KEEP IT AS THINKING: impossible from here. `isSameModel` is pi-ai's own
1678
+ * predicate over fields this plugin does not control (`model.provider` /
1679
+ * `model.api` / `model.id` are compared against the MESSAGE's), and the
1680
+ * only way to satisfy it is to claim the foreign turn as this model's own
1681
+ * output — which is exactly the provenance lie issue #111 removed. These
1682
+ * blocks carry no `thinkingSignature` (the host builds them that way), so
1683
+ * they could not be replayed as signed reasoning even if claimed.
1684
+ *
1685
+ * Dropping is what the request builder already does with them in every other
1686
+ * branch: an unsigned foreign thinking block is never sent as
1687
+ * `reasoning_content`, so its only possible contribution to the wire was the
1688
+ * flattened prose this function removes. Every other block — text, tool calls —
1689
+ * is left exactly where it is, and so is every message's identity.
1690
+ *
1691
+ * Idempotent: a second pass finds nothing to remove. Returns the SAME context
1692
+ * object when nothing changed, so the common case allocates nothing.
1693
+ *
1694
+ * @param context - a pi-ai context.
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.
1698
+ * @returns the repaired context, or the input when no block qualified.
1699
+ */
1700
+ function dropForeignThinking(context, identity = WORKBUDDY_MODEL_IDENTITY, requestModelId) {
1701
+ const affected = foreignThinkingOf(context, identity, requestModelId);
1702
+ if (affected.length === 0) return context;
1703
+ const messages = context.messages;
1704
+ const byMessage = /* @__PURE__ */ new Map();
1705
+ for (const block of affected) {
1706
+ const set = byMessage.get(block.messageIndex) ?? /* @__PURE__ */ new Set();
1707
+ set.add(block.blockIndex);
1708
+ byMessage.set(block.messageIndex, set);
1709
+ }
1710
+ const next = messages.map((message, index) => {
1711
+ const remove = byMessage.get(index);
1712
+ if (remove === void 0) return message;
1713
+ const record = message;
1714
+ const content = blocksOf$1(message).filter((_, blockIndex) => !remove.has(blockIndex));
1715
+ return {
1716
+ ...record,
1717
+ content
1718
+ };
1719
+ });
1720
+ return {
1721
+ ...context,
1722
+ messages: next
1723
+ };
1724
+ }
1725
+ function withLegacyContext(api, generation, identity = WORKBUDDY_MODEL_IDENTITY, ownedRoutes = [WORKBUDDY_PROVIDER, WORKBUDDY_GLOBAL_PROVIDER], repair) {
1578
1726
  /** One place so both branches and both entry points agree on the order. */
1579
- const prepare = (context) => alignAssistantIdentities(generation === "modern" ? foldModernContext(context) : adaptLegacyPiAiContext(context), identity, ownedRoutes);
1727
+ const prepare = (context, requestModelId) => {
1728
+ const aligned = alignAssistantIdentities(generation === "modern" ? foldModernContext(context) : adaptLegacyPiAiContext(context), identity, ownedRoutes);
1729
+ if (repair?.dropForeignThinking !== true) return aligned;
1730
+ const affected = foreignThinkingOf(aligned, identity, requestModelId);
1731
+ if (affected.length === 0) return aligned;
1732
+ try {
1733
+ repair.onForeignThinking?.(affected);
1734
+ } catch {}
1735
+ return dropForeignThinking(aligned, identity, requestModelId);
1736
+ };
1580
1737
  if (generation === "modern") return {
1581
1738
  ...api,
1582
- stream: (model, context, options) => api.stream(model, transcript(prepare(context)), options),
1583
- 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)
1584
1741
  };
1585
1742
  return {
1586
1743
  ...api,
1587
- stream: (model, context, options) => api.stream(model, transcript(prepare(context)), options),
1588
- 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)
1589
1746
  };
1590
1747
  }
1591
1748
  /**
@@ -1763,7 +1920,12 @@ function createWorkBuddyAdapter(options) {
1763
1920
  }
1764
1921
  } },
1765
1922
  models: buildModels(),
1766
- api: withContextRescue(withLegacyContext(openAICompletionsApi(), generation, modelIdentity), options.resolveContextRescue)
1923
+ api: withContextRescue(withLegacyContext(openAICompletionsApi(), generation, modelIdentity, void 0, {
1924
+ get dropForeignThinking() {
1925
+ return options.dropForeignThinking?.() === true;
1926
+ },
1927
+ ...options.onForeignThinking === void 0 ? {} : { onForeignThinking: options.onForeignThinking }
1928
+ }), options.resolveContextRescue)
1767
1929
  }),
1768
1930
  getModels: () => buildModels()
1769
1931
  };
@@ -9276,7 +9438,8 @@ const UPSTREAM_SWITCH_FIELDS = /* @__PURE__ */ new Set([
9276
9438
  "usageIdentity",
9277
9439
  "keepPromptLast",
9278
9440
  "extraInjectedPrefixes",
9279
- "sessionTitle"
9441
+ "sessionTitle",
9442
+ "dropForeignThinking"
9280
9443
  ]);
9281
9444
  /** A non-null, non-array object: the only shape the slot merge applies to. */
9282
9445
  function isPlainRecord(value) {
@@ -9568,6 +9731,8 @@ async function workBuddyWebStatus(deps, region, options = {}) {
9568
9731
  const cliBridgeModel = deps.cliBridgeModel?.() ?? "";
9569
9732
  const cliBridgeEffort = deps.cliBridgeEffort?.() ?? "";
9570
9733
  const cliBridgeRegionOrder = deps.cliBridgeRegionOrder?.() ?? "";
9734
+ const dropForeignThinking = deps.dropForeignThinking?.() === true;
9735
+ const lastForeignThinking = deps.latestForeignThinking?.();
9571
9736
  const busyLevels = deps.busyLevels?.(region) ?? {};
9572
9737
  const authStatus = await store.status();
9573
9738
  const selectionExplicit = store.hasExplicitSelection();
@@ -9607,6 +9772,8 @@ async function workBuddyWebStatus(deps, region, options = {}) {
9607
9772
  cliBridgeModel,
9608
9773
  cliBridgeEffort,
9609
9774
  cliBridgeRegionOrder,
9775
+ dropForeignThinking,
9776
+ ...lastForeignThinking === void 0 ? {} : { lastForeignThinking },
9610
9777
  busyLevels,
9611
9778
  ...await store.selectionLost() ? { selectionLost: true } : {},
9612
9779
  ...webAccounts.length === 0 ? await searchedPaths(store) : {}
@@ -9631,6 +9798,8 @@ async function workBuddyWebStatus(deps, region, options = {}) {
9631
9798
  cliBridgeModel,
9632
9799
  cliBridgeEffort,
9633
9800
  cliBridgeRegionOrder,
9801
+ dropForeignThinking,
9802
+ ...lastForeignThinking === void 0 ? {} : { lastForeignThinking },
9634
9803
  busyLevels,
9635
9804
  accounts: accounts.map(toWebAccount),
9636
9805
  models: deps.displayModels(region).map((model) => toWebModel(model, deps.contextBudgets(region))).map((model) => model.reasoning === void 0 || lastRefusalFor(model.id) === void 0 ? model : {
@@ -10179,7 +10348,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
10179
10348
  req.on("error", reject);
10180
10349
  });
10181
10350
  const field = body.field;
10182
- if (field !== "regions" && field !== "accounts" && field !== "cliBridge" && !(typeof field === "string" && UPSTREAM_SWITCH_FIELDS.has(field))) return json(res, 400, { error: "field must be regions, accounts, cliBridge, usageIdentity, keepPromptLast, extraInjectedPrefixes or sessionTitle" });
10351
+ if (field !== "regions" && field !== "accounts" && field !== "cliBridge" && !(typeof field === "string" && UPSTREAM_SWITCH_FIELDS.has(field))) return json(res, 400, { error: "field must be regions, accounts, cliBridge, usageIdentity, keepPromptLast, extraInjectedPrefixes, sessionTitle or dropForeignThinking" });
10183
10352
  const row = settings.describe().find((r) => String(r.ns).includes("workbuddy"));
10184
10353
  if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
10185
10354
  const current = field === "regions" ? regionsMapOf(row.value?.[field]) : unwrapVolatileDeep(row.value?.[field] ?? {});
@@ -10590,6 +10759,7 @@ const Config = z.object({
10590
10759
  usageIdentity: asVolatile(z.boolean().default(false).description("Make the upstream's usage page record these calls like the official client's: add the X-IDE-* identity headers (which fill the 使用端 column) and send the conversation ids as 32-hex UUIDv7. Off by default — it is a display alignment measured by a reporter, not by this plugin, and the chat channel identity (User-Agent) and request body never change.")),
10591
10760
  keepPromptLast: asVolatile(z.boolean().default(false).description("Move the harness-injected runtime context above the real prompt, so the WorkBuddy usage page records what you actually typed instead of the injected block. Message count and text are preserved; the one edit is the recorded prompt's own leading <acp> marker. Off by default because it changes the message order the upstream counts.")),
10592
10761
  extraInjectedPrefixes: asVolatile(z.array(z.string()).default([]).description("Extra literal prefixes (issue #133) treated like the harness-injected context markers when keepPromptLast is on — for blocks a companion plugin injects that the built-in list does not recognize. Each entry matches the start of a trailing user message; read only while keepPromptLast is enabled. Sampled when the plugin mounts: reload the plugin (or restart the host) after changing it.")),
10762
+ dropForeignThinking: asVolatile(z.boolean().default(false).description("Drop foreign thinking blocks that pi-ai would publish as prose (issue #147). After a provider switch the host rebuilds the other provider's assistant turns as dsh-foreign, and pi-ai then rewrites their thinking blocks into visible text, so the model's working notes become the answer and it copies that shape on later turns. On, those unsigned blocks are removed from the request (they could never be replayed as reasoning anyway); text and tool calls are untouched. Off by default because it changes bytes the upstream counts.")),
10593
10763
  sessionTitle: asVolatile(z.object({
10594
10764
  enabled: z.boolean().default(false),
10595
10765
  model: z.string().default("wb-lite")
@@ -10883,6 +11053,21 @@ function apply(ctx, config) {
10883
11053
  for (const model of live) if (model.busyLevel !== void 0) levels[model.id] = model.busyLevel;
10884
11054
  return levels;
10885
11055
  };
11056
+ /**
11057
+ * The most recent request whose foreign thinking blocks were dropped
11058
+ * (issue #147).
11059
+ *
11060
+ * Process-local and last-wins, deliberately: this is DIAGNOSTIC state that
11061
+ * answers "why did my answer look like working notes", and it is only read by
11062
+ * the status endpoint. Persisting it would let a stale turn from yesterday
11063
+ * explain a session that has since been fixed — the whole point is that the
11064
+ * reader is looking at the turn that just happened. It forgets on restart,
11065
+ * which is the natural expiry for "what this process removed recently".
11066
+ *
11067
+ * Declared HERE rather than inside the adapter mount because the status route
11068
+ * is registered outside that callback and must be able to read it.
11069
+ */
11070
+ let latestForeignThinking;
10886
11071
  let current = () => config;
10887
11072
  let invalidateCatalog = () => {};
10888
11073
  /**
@@ -11406,6 +11591,19 @@ function apply(ctx, config) {
11406
11591
  cliBridgeEffort: () => unwrapVolatile(current().cliBridge)?.effort ?? "",
11407
11592
  cliBridgeRegionOrder: () => unwrapVolatile(current().cliBridge)?.regionOrder ?? "",
11408
11593
  /**
11594
+ * Whether the request path drops foreign thinking blocks (issue #147), read
11595
+ * live so the card's switch reflects the value the NEXT request will use
11596
+ * rather than the one captured at mount.
11597
+ */
11598
+ dropForeignThinking: () => unwrapVolatile(current().dropForeignThinking) === true,
11599
+ /**
11600
+ * What the last request had to drop, so the card can explain a turn that
11601
+ * arrived looking like working notes instead of leaving the user to conclude
11602
+ * the model broke. `undefined` until a request actually removes something —
11603
+ * an empty report would render as a permanent warning.
11604
+ */
11605
+ latestForeignThinking: () => latestForeignThinking,
11606
+ /**
11409
11607
  * WHERE the readout sits, resolved here rather than read from the browser
11410
11608
  * settings mirror, for the same measured reason as the flag above: on the
11411
11609
  * affected 0.1.7 deployments that mirror never picks up this plugin's
@@ -12279,7 +12477,14 @@ function apply(ctx, config) {
12279
12477
  provider: WORKBUDDY_PROVIDERS[region],
12280
12478
  displayName: WORKBUDDY_PROVIDER_DISPLAY_NAMES[region],
12281
12479
  resolveAttachments: () => ctx.get("attachments"),
12282
- resolveContextRescue: () => contextRescue
12480
+ resolveContextRescue: () => contextRescue,
12481
+ dropForeignThinking: () => unwrapVolatile(current().dropForeignThinking) === true,
12482
+ onForeignThinking: (blocks) => {
12483
+ latestForeignThinking = {
12484
+ blocks,
12485
+ atMs: Date.now()
12486
+ };
12487
+ }
12283
12488
  });
12284
12489
  invalidateCatalog = () => {
12285
12490
  for (const region of REGION_KEYS) adapters[region].invalidate();
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.11.0",
5
+ "version": "5.12.1",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",