dsh-connect-workbuddy 4.4.1 → 4.4.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 +32 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +81 -14
- package/lib/{host-heartbeat-DROtSemV.js → host-heartbeat-CBFAiYPg.js} +32 -8
- package/lib/index.d.ts +4 -3
- package/lib/index.js +57 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.4.2 (2026-10-13)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- **DSH 0.2.1-alpha.2 下 workbuddy 模型工具调用全失效:把 pi-ai 主版本 1 判为 modern。**(PR #52,issues #24/#25/#26 的同源延伸)
|
|
8
|
+
- **现象**:DSH `0.2.1-alpha.2` 起,**所有** workbuddy / workbuddy-global 模型都无法调用工具(本地模型与 deepseek 账号组不受影响),且**界面上没有任何报错**。会话事件流停在「只有思考块」→「整轮 completed」,全程没有 `tool-call` 事件;模型有时还会把 DSML 标记当**正文**吐出来。
|
|
9
|
+
- **根因**:`src/pi-ai-runtime.ts` 的 `generationOf()` 用 `/^0\.(\d+)\./u` 解析版本,**只认 `0.x`**。DSH 0.2.1-alpha.2 随包的是 `@earendil-works/pi-ai@1.0.2`,`'1.0.2'` 匹配不到 → 返回 `'legacy'`。`legacy` 会让 `withLegacyContext()` 用 `adaptLegacyPiAiContext()` 把上下文折成 pi-ai **0.85** 形状:system 消息抽出去当 `systemPrompt`,工具声明搬到**顶层 `tools`**。而 pi-ai 1.x 从 `context.messages` 里 system 消息的 `toolsAdded` 读工具(`resolveTranscriptTools` / `getCurrentTools`),**不看顶层 `tools`**——请求里一个工具声明都没有,模型自然发不出 `toolCall`。国内网关容忍无 system 的请求,模型就把 DSML 写进正文。更隐蔽的是 shim 的 DSML 恢复也一并失效(`gate.declaredNames` 为空 → 整段禁用),所以整套缺陷**完全静默**。
|
|
10
|
+
- **为什么在作者 checkout 上不复现**:源码注释里写着 "this checkout resolves 0.85.1 today"——解析到 0.85.1 时 `legacy` 分支是**正确**的。另外 `doctor` 在宿主**之外**跑会显示 `modern`,那是 `resolvedLocally: false` 的默认值,与宿主内的真实判定无关。
|
|
11
|
+
- **修法**:只特判**主版本 1**,`>= 87` 的 minor 判定保持不变。已逐包对照 **pi-ai 0.87.1、1.0.2、1.1.0、1.2.0** 四个版本:三者的 `openai-completions` 都从 `context.messages` 调 `resolveTranscriptTools()`,`getCurrentTools()` 都读 system 消息的 `toolsAdded`/`toolsRemoved`,契约一致。**刻意是 `=== 1` 而不是 `>= 1`**:未核验未来 2.x 的上下文契约,不替未验证的版本做兼容承诺;测试里 `2.0.0` 明确断言仍为 `legacy`。
|
|
12
|
+
- **顺带记录的边界事实**(本版**未**改动 peer 范围):宿主 `dsh-app-boot` 的 `evaluatePluginCompatibility()` 只校验 `@deepseek-ai/dsh*` 命名空间,所以 `"@earendil-works/pi-ai": ">=0.85.0 <0.88.0"` 这条 peer **从不参与校验**,插件会对着越界的 1.x 静默运行。peer 范围的取舍留给维护者单独决定。
|
|
13
|
+
- **守卫**:`tests/pi-ai-runtime.spec.ts` 的版本→代际用例改为数据驱动,并用临时目录里伪造的 `package.json` 驱动 `piAiRuntimeFor()`,**不依赖本机解析到哪个版本**,因此在解析到 0.85.1 的机器上也能跑。覆盖 `0.85.0/0.85.1/0.86.3 → legacy`、`0.87.0/0.87.1/1.0.0/1.0.2/1.1.0 → modern`、`2.0.0 → legacy`、不可解析版本保守判 `legacy`。变异验证(还原修复)后恰好 4 个 1.x 用例变红,其余 13 个不变。
|
|
14
|
+
|
|
15
|
+
- **上游流被截断时,恢复层把补写的 `[DONE]` 粘在半截帧之后,客户端 SSE 解析崩溃(PI_AI_ERROR)。**(PR #54,issue #53)
|
|
16
|
+
- **现象**:对话中途偶发整轮直接失败,报错文本 `Unexpected non-whitespace character after JSON at position 319 (line 2 column 1)`,宿主归到兜底码 `PI_AI_ERROR`。该码**不在宿主的原地重试集**里(只认 `EMPTY_RESPONSE`/`RATE_LIMIT`/`SERVER`/`TIMEOUT`/`TRANSPORT`),所以一旦出现,这一轮直接结束、不重试——用户看到的就是「突然断了」。已确认与具体模型、工作区无关,且**紧跟在一次 transport 故障之后**。
|
|
17
|
+
- **根因**:是恢复层自己制造了非法的 SSE 帧,不是上游发的。两处叠加——① `consume()` 把流尾巴的残余帧直接透传,不补帧分隔空行;② `finish()` 补写合成终止帧时,没有确保前面有帧分隔。于是客户端实际收到的是**同一个 SSE 事件里的两条 `data:` 行**(中间只有一个 `\n`,不是空行)。openai SDK 的 `SSEDecoder` 会把一个事件内的多条 `data:` 行用 `\n` 拼起来再解析,拼出来就是「一个完整 JSON + 换行 + 第二个 JSON」,`JSON.parse` 在第二行行首抛错。**报错位置恰好是 319,因为第一段 chunk 的长度正好是 318**——这不是巧合。
|
|
18
|
+
- **修法**:把「每个帧必须自带空行终止符」变成不变量,而不是在 `finish()` 里特判——`RecoveryStream` 有 6 处直接 `res.write`,逐个补分隔容易漏,而 `handleFrame` 里 JSON 解析失败的原帧透传路径同样会写出不带空行的帧。现在类内所有帧写出收敛到单一出口 `emitFrame()`,由它补齐帧尾;`consume()` 对 `carry` 剩下的半帧先归一化再交给 `handleFrame`。**缺换行和缺空行是两种情形**:前者补一个 `\n` 只结束「行」不结束「事件」,必须补 `\n\n` 才能让随后的 `[DONE]` 不粘上去。
|
|
19
|
+
- **顺带修掉一个静默丢内容**:`finish()` **不**补 `[DONE]` 时,半截帧没有空行收尾,SDK 永远等不到分隔,最后那一小段内容被静默丢弃。归一化帧尾后这段内容能正常 dispatch 出来。
|
|
20
|
+
- **不影响普通路径**:`push()` 切出的帧本就带终止符,经 `emitFrame` 原样通过,仍是 byte-for-byte 透传——「帧顺序永不改变」的既有承诺不受影响。
|
|
21
|
+
- **守卫**:新增 `tests/issue53-sse-terminator.spec.ts`,断言在**字节层**:按 SSE 规范切事件,每个事件内多条 `data:` 行拼接后必须是单个合法 JSON。缺陷本质是字节粘连,只有字节级断言能抓住它。变异验证(还原两处修复)后 3 个缺陷用例全部变红,「完整流字节不变」的守卫用例保持绿色——说明没有过度归一化。
|
|
22
|
+
|
|
23
|
+
- **保存/删除/导入时的忙碌遮罩:改用宿主主题令牌,spinner 换成宿主同款弧线。**
|
|
24
|
+
- **现象**:遮罩卡片是一块近黑的板子,上面的字(尤其是副标题「完成后列表会立即刷新。」)几乎看不清;spinner 的转法也和外壳里其他转圈明显不同拍。
|
|
25
|
+
- **根因**:三处都出在自造值上。① 卡片底色用 `--dsw-alias-bg-primary`,而这个变量在宿主的令牌集里**根本不存在**(宿主只有 `bg-base` / `bg-layer-1..3` / `bg-module-platform` / `bg-overlay` 等),于是一路回退到硬编码的近黑 `#1b1c20`。② 遮罩底色写死 `rgba(0,0,0,.38)`,既不跟随主题、也没有宿主的模糊。③ 副标题用 `label-tertiary`——那是宿主最暗的一档(亮色 `neutral-bluish-600`、暗色 `neutral-bluish-400`),给元数据用的,不该承载一句要等的人必须读的话。
|
|
26
|
+
- **修法**:卡片改用宿主给弹层用的 `--dsw-alias-bg-layer-2`(亮色白 / 暗色 `neutral-bluish-850`,各自成立);遮罩改用宿主自己遮罩用的 `--dsw-alias-bg-mask-1`(亮 `.24` / 暗 `.5`)+ `--dsw-mask-blur`,与外壳里其他遮罩一致;副标题改用 `label-secondary`。阴影也从手写的 `0 8px 28px rgba(0,0,0,.35)` 换成宿主的 `--dsw-elevation-prominent`。
|
|
27
|
+
- **spinner**:原本是 18px 的 CSS border 环、`.7s` 匀速转,比宿主自己的状态转圈(`_dsh-state-dot-spin`,SVG 弧线 + dash 呼吸、`1.5s`)快了近一倍。比起形状本身,**这个速度差才是它看着廉价的主因**。现在改为同款弧线几何与 `1.5s` 节奏,`currentColor` 继承所在层级,reduced-motion 降级为静态弧线(与宿主降级方式一致),并补 `aria-hidden` 免得 `role=status` 之外多报一次。
|
|
28
|
+
- **顺带记录一个会静默损坏产物的坑**:`WORKBUDDY_CARD_CSS` 是模板字符串,注释里写反引号会**提前截断整个样式表**,而 typecheck 与 build **都不报错**(截断后的尾部仍是合法 TS,被当成一堆散落的表达式语句)。本次改注释时真的踩到了。新增一条守卫:按顶层语句数断言(正常 2 条变量声明;一旦截断会炸成几十条表达式语句)。变异验证:塞回一个反引号后该守卫变红,恢复后全绿。
|
|
29
|
+
- **守卫**:`tests/client-host-contract.spec.ts` 由 8 例增至 10 例。
|
|
30
|
+
|
|
31
|
+
### Docs
|
|
32
|
+
|
|
33
|
+
- 无。本版含上述三处修复与配套测试。
|
|
34
|
+
|
|
3
35
|
## 4.4.1 (2026-10-12)
|
|
4
36
|
|
|
5
37
|
### 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-
|
|
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-CBFAiYPg.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
|
@@ -668,6 +668,45 @@ window.__ModuleLoader__.load({
|
|
|
668
668
|
//#endregion
|
|
669
669
|
//#region src/client/BusyOverlay.tsx
|
|
670
670
|
/**
|
|
671
|
+
* The shared busy veil's spinner: an arc that breathes while it turns.
|
|
672
|
+
*
|
|
673
|
+
* Deliberately the host's own state-spinner shape (dsh's `_dsh-state-dot-spin`)
|
|
674
|
+
* rather than a border-ring: a two-tone CSS ring at .7s read as a loading
|
|
675
|
+
* indicator from another decade and was out of step with every spinner in the
|
|
676
|
+
* shell. The motion lives in CSS (`dsm-workbuddy-veil-spin` /
|
|
677
|
+
* `-dash`), so this element only describes the geometry; `currentColor` lets it
|
|
678
|
+
* inherit whatever tier it sits in.
|
|
679
|
+
*
|
|
680
|
+
* `aria-hidden` because the veil's `role="status"` already announces the
|
|
681
|
+
* message — a second announcement from a decorative arc is noise.
|
|
682
|
+
*/
|
|
683
|
+
function VeilSpinner() {
|
|
684
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
685
|
+
className: "dsm-workbuddy-veil-spinner",
|
|
686
|
+
viewBox: "0 0 20 20",
|
|
687
|
+
"aria-hidden": "true",
|
|
688
|
+
focusable: "false",
|
|
689
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
690
|
+
className: "dsm-workbuddy-veil-spinner-track",
|
|
691
|
+
cx: "10",
|
|
692
|
+
cy: "10",
|
|
693
|
+
r: "8",
|
|
694
|
+
fill: "none",
|
|
695
|
+
stroke: "currentColor",
|
|
696
|
+
strokeWidth: "2"
|
|
697
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
|
|
698
|
+
className: "dsm-workbuddy-veil-spinner-arc",
|
|
699
|
+
cx: "10",
|
|
700
|
+
cy: "10",
|
|
701
|
+
r: "8",
|
|
702
|
+
fill: "none",
|
|
703
|
+
stroke: "currentColor",
|
|
704
|
+
strokeWidth: "2",
|
|
705
|
+
strokeLinecap: "round"
|
|
706
|
+
})]
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
671
710
|
* The veil. Deliberately NOT a modal dialog: it carries no focusable control
|
|
672
711
|
* and no dismissal — there is nothing for the user to decide, only a step to
|
|
673
712
|
* wait through. `aria-busy` plus a polite live region state that to screen
|
|
@@ -683,10 +722,7 @@ window.__ModuleLoader__.load({
|
|
|
683
722
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
684
723
|
className: "dsm-workbuddy-veil-card",
|
|
685
724
|
children: [
|
|
686
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
687
|
-
className: "dsm-workbuddy-veil-spinner",
|
|
688
|
-
"aria-hidden": "true"
|
|
689
|
-
}),
|
|
725
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(VeilSpinner, {}),
|
|
690
726
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
691
727
|
className: "dsm-workbuddy-veil-text",
|
|
692
728
|
children: message
|
|
@@ -3055,17 +3091,48 @@ window.__ModuleLoader__.load({
|
|
|
3055
3091
|
"here is why nothing moved yet". Fixed positioning so it covers the card
|
|
3056
3092
|
regardless of which section started the operation, and a semi-transparent
|
|
3057
3093
|
backdrop so the table stays visible underneath — the user is watching a
|
|
3058
|
-
specific row change, and hiding it would defeat the point.
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
.
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3094
|
+
specific row change, and hiding it would defeat the point.
|
|
3095
|
+
|
|
3096
|
+
The mask and the card surface are the HOST's own tokens, not this file's
|
|
3097
|
+
invention. --dsw-alias-bg-mask-1 + --dsw-mask-blur is exactly what the
|
|
3098
|
+
host's own overlays use (see dsh-client-ui-settings-general's Dws9Sa_mask),
|
|
3099
|
+
so the veil now dims the way every other overlay in the shell does instead
|
|
3100
|
+
of laying its own black wash on top. The previous hardcoded rgba(0,0,0,.38)
|
|
3101
|
+
was tuned against neither theme: too weak next to the host's .24 mask in
|
|
3102
|
+
light mode, and it ignored the host's blur entirely.
|
|
3103
|
+
|
|
3104
|
+
bg-layer-2 (not bg-primary, which does not exist in the host's token set
|
|
3105
|
+
at all and therefore always fell through to this file's near-black #1b1c20)
|
|
3106
|
+
is the same surface the host puts behind its panels — that near-black fallback
|
|
3107
|
+
is what made the veil read as a black slab with unreadable text on it. */
|
|
3108
|
+
.dsm-workbuddy-veil{position:fixed;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.32));backdrop-filter:var(--dsw-mask-blur);cursor:progress}
|
|
3109
|
+
/* --dsw-elevation-prominent is the host's own panel shadow (a hairline stroke
|
|
3110
|
+
plus two soft layers); the previous 0 8px 28px rgba(0,0,0,.35) was a
|
|
3111
|
+
hand-rolled drop shadow that read as a heavy grey box. */
|
|
3112
|
+
.dsm-workbuddy-veil-card{display:flex;flex-direction:column;align-items:center;gap:8px;max-width:min(340px,80vw);padding:16px 20px;border:1px solid var(--dsw-alias-border-l2,#0000001a);border-radius:12px;background:var(--dsw-alias-bg-layer-2,#fff);box-shadow:var(--dsw-elevation-prominent,0 8px 28px rgba(0,0,0,.35))}
|
|
3113
|
+
.dsm-workbuddy-veil-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary,#0f1115);text-align:center}
|
|
3114
|
+
/* label-secondary, not label-tertiary: this line carries the operation's
|
|
3115
|
+
detail and must stay legible. tertiary is the host's dimmest tier
|
|
3116
|
+
(neutral-bluish-600 on light, neutral-bluish-400 on dark) and is meant
|
|
3117
|
+
for metadata, not for a sentence the user has to read while waiting. */
|
|
3118
|
+
.dsm-workbuddy-veil-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#61666b);text-align:center}
|
|
3119
|
+
/* The spinner proves the UI is alive when the row under it has not changed yet
|
|
3120
|
+
— which is precisely the confusion the veil exists to remove. It follows the
|
|
3121
|
+
host's own state spinner (dsh's _dsh-state-dot-spin): an SVG arc whose
|
|
3122
|
+
dash breathes while it turns, currentColor so it inherits the tier it sits
|
|
3123
|
+
in, and the same 1.5s beat. The previous 18px border-ring at .7s was roughly
|
|
3124
|
+
twice the host's speed and visibly out of step with every other spinner in
|
|
3125
|
+
the shell — that mismatch, more than the shape itself, is what made it look
|
|
3126
|
+
cheap.
|
|
3127
|
+
|
|
3128
|
+
Reduced-motion users get the static arc rather than a frozen ring, matching
|
|
3129
|
+
how the host degrades its own spinner. */
|
|
3130
|
+
.dsm-workbuddy-veil-spinner{width:20px;height:20px;color:var(--dsw-alias-label-secondary,#61666b);animation:dsm-workbuddy-veil-spin 1.5s linear infinite}
|
|
3131
|
+
.dsm-workbuddy-veil-spinner-track{opacity:.25}
|
|
3132
|
+
.dsm-workbuddy-veil-spinner-arc{stroke-dasharray:12 150;animation:dsm-workbuddy-veil-dash 1.5s ease-in-out infinite}
|
|
3067
3133
|
@keyframes dsm-workbuddy-veil-spin{to{transform:rotate(360deg)}}
|
|
3068
|
-
@
|
|
3134
|
+
@keyframes dsm-workbuddy-veil-dash{0%{stroke-dasharray:12 150;stroke-dashoffset:0}50%{stroke-dasharray:24 150;stroke-dashoffset:-6}to{stroke-dasharray:12 150;stroke-dashoffset:0}}
|
|
3135
|
+
@media (prefers-reduced-motion: reduce){.dsm-workbuddy-veil-spinner{animation:none}.dsm-workbuddy-veil-spinner-arc{animation:none;stroke-dasharray:18 150;stroke-dashoffset:-3}}
|
|
3069
3136
|
`;
|
|
3070
3137
|
/**
|
|
3071
3138
|
* Composer credit readout styles, injected by the readout component itself.
|
|
@@ -3611,9 +3611,10 @@ var WorkBuddyCatalog = class {
|
|
|
3611
3611
|
* pi-ai into the standalone `doctor` CLI, which must also run from market
|
|
3612
3612
|
* installs where NO local pi-ai exists and the module is only provided inside
|
|
3613
3613
|
* the DSH host — a static import there is a load-time crash. Resolution
|
|
3614
|
-
* itself does not need the module:
|
|
3615
|
-
*
|
|
3616
|
-
*
|
|
3614
|
+
* itself does not need the module: the manifest version and the
|
|
3615
|
+
* `normalizeContext` feature (present from 0.87 on, not before) agree across
|
|
3616
|
+
* `0.87+` AND `1.x` — 1.x is 0.87's successor and keeps the same normalized
|
|
3617
|
+
* transcript, so the split is `>= 0.87` rather than "within 0.x".
|
|
3617
3618
|
*
|
|
3618
3619
|
* When resolution FAILS — the normal state of a market install probed outside
|
|
3619
3620
|
* the host — there is no local copy, so inside DSH the host supplies pi-ai.
|
|
@@ -3695,13 +3696,36 @@ function readVersion(moduleFile) {
|
|
|
3695
3696
|
}
|
|
3696
3697
|
}
|
|
3697
3698
|
/**
|
|
3698
|
-
* The generation a manifest version belongs to.
|
|
3699
|
-
*
|
|
3700
|
-
*
|
|
3701
|
-
*
|
|
3699
|
+
* The generation a manifest version belongs to.
|
|
3700
|
+
*
|
|
3701
|
+
* `0.87+` consumes normalized transcripts natively; earlier releases want the
|
|
3702
|
+
* 0.85 shape, so within the declared peer range the split is exactly `0.87+`
|
|
3703
|
+
* versus earlier. An unreadable version conservatively reports `legacy` (and
|
|
3704
|
+
* doctor shows it as unreadable, so the guess is visible rather than silent).
|
|
3705
|
+
*
|
|
3706
|
+
* The 1.x line MUST land on `modern`, and it is not a hypothetical: DSH
|
|
3707
|
+
* 0.2.1-alpha.2 ships `@earendil-works/pi-ai@1.0.2`, while npm currently
|
|
3708
|
+
* publishes 1.1.0. Both 0.87.1 and 1.0.2/1.1.0 read tools from the same place
|
|
3709
|
+
* (`context.messages` → the leading system message's `toolsAdded`; see
|
|
3710
|
+
* pi-ai's `resolveTranscriptTools`). Matching only `0.<minor>.` classified
|
|
3711
|
+
* 1.0.2 as `legacy`, so `withLegacyContext` folded the transcript into the
|
|
3712
|
+
* 0.85 shape — which moves the tool declarations to a top-level `tools` field
|
|
3713
|
+
* pi-ai 1.x never reads. Every request then went upstream with NO tools
|
|
3714
|
+
* declared: no model could emit a `toolCall`, and the domestic gateway, which
|
|
3715
|
+
* tolerates a prompt-less body, had the model write DSML markup into the answer
|
|
3716
|
+
* text instead. The host's compatibility gate does not catch this: it only
|
|
3717
|
+
* evaluates peers in the `@deepseek-ai/dsh*` namespace, so an out-of-range
|
|
3718
|
+
* `@earendil-works/pi-ai` peer is never enforced.
|
|
3719
|
+
*
|
|
3720
|
+
* Deliberately match major 1 only. This establishes compatibility for the
|
|
3721
|
+
* observed 1.x line (including current 1.1.0) without assuming an uninspected
|
|
3722
|
+
* future major such as 2.x keeps the same transcript contract. The declared
|
|
3723
|
+
* peer range remains the plugin's supported-version boundary.
|
|
3702
3724
|
*/
|
|
3703
3725
|
function generationOf(version) {
|
|
3704
3726
|
if (version === void 0) return "legacy";
|
|
3727
|
+
const major = /^(\d+)\./u.exec(version);
|
|
3728
|
+
if (major !== null && Number(major[1]) === 1) return "modern";
|
|
3705
3729
|
const match = /^0\.(\d+)\./u.exec(version);
|
|
3706
3730
|
if (match === null) return "legacy";
|
|
3707
3731
|
return Number(match[1]) >= 87 ? "modern" : "legacy";
|
|
@@ -3709,7 +3733,7 @@ function generationOf(version) {
|
|
|
3709
3733
|
//#endregion
|
|
3710
3734
|
//#region src/version.ts
|
|
3711
3735
|
/** The npm package version this build was produced from. */
|
|
3712
|
-
const WORKBUDDY_CONNECT_VERSION = "4.4.
|
|
3736
|
+
const WORKBUDDY_CONNECT_VERSION = "4.4.2";
|
|
3713
3737
|
//#endregion
|
|
3714
3738
|
//#region src/host-heartbeat.ts
|
|
3715
3739
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -1112,9 +1112,10 @@ declare function createWorkBuddyShim(options: WorkBuddyShimOptions): WorkBuddySh
|
|
|
1112
1112
|
* pi-ai into the standalone `doctor` CLI, which must also run from market
|
|
1113
1113
|
* installs where NO local pi-ai exists and the module is only provided inside
|
|
1114
1114
|
* the DSH host — a static import there is a load-time crash. Resolution
|
|
1115
|
-
* itself does not need the module:
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1115
|
+
* itself does not need the module: the manifest version and the
|
|
1116
|
+
* `normalizeContext` feature (present from 0.87 on, not before) agree across
|
|
1117
|
+
* `0.87+` AND `1.x` — 1.x is 0.87's successor and keeps the same normalized
|
|
1118
|
+
* transcript, so the split is `>= 0.87` rather than "within 0.x".
|
|
1118
1119
|
*
|
|
1119
1120
|
* When resolution FAILS — the normal state of a market install probed outside
|
|
1120
1121
|
* the host — there is no local copy, so inside DSH the host supplies pi-ai.
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-
|
|
1
|
+
import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-CBFAiYPg.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -1308,6 +1308,15 @@ function readBody(req) {
|
|
|
1308
1308
|
* Once a native `delta.tool_calls` appears, recovery switches off for the
|
|
1309
1309
|
* rest of the response — two live call channels for one answer is worse
|
|
1310
1310
|
* than either one alone.
|
|
1311
|
+
*
|
|
1312
|
+
* 5. EVERY FRAME LEAVES SELF-CONTAINED. An SSE event ends at a blank line,
|
|
1313
|
+
* and a frame written without its terminator merges into the NEXT event
|
|
1314
|
+
* the client decodes — whose `data:` lines are then joined and parsed as
|
|
1315
|
+
* one document (issue #53). Every write therefore goes through
|
|
1316
|
+
* {@link emitFrame}, which completes the terminator; `push()` cuts frames
|
|
1317
|
+
* that already carry it, and the one source of unterminated bytes — the
|
|
1318
|
+
* half-frame left in `carry` when the upstream dies — is normalized in
|
|
1319
|
+
* {@link consume}.
|
|
1311
1320
|
*/
|
|
1312
1321
|
var RecoveryStream = class {
|
|
1313
1322
|
res;
|
|
@@ -1382,7 +1391,7 @@ var RecoveryStream = class {
|
|
|
1382
1391
|
finish_reason: ""
|
|
1383
1392
|
};
|
|
1384
1393
|
this.windowOpened = true;
|
|
1385
|
-
this.
|
|
1394
|
+
this.emitFrame(contentFrame(chunk, choiceRecord, notice));
|
|
1386
1395
|
}
|
|
1387
1396
|
/** True when a call or real prose has reached the client. */
|
|
1388
1397
|
get delivered() {
|
|
@@ -1392,6 +1401,43 @@ var RecoveryStream = class {
|
|
|
1392
1401
|
get residueSeen() {
|
|
1393
1402
|
return this.sawResidue;
|
|
1394
1403
|
}
|
|
1404
|
+
/**
|
|
1405
|
+
* The single exit every byte of a FRAME passes through.
|
|
1406
|
+
*
|
|
1407
|
+
* SSE dispatches one event per blank-line-terminated run of `data:` lines.
|
|
1408
|
+
* A frame written WITHOUT its terminator leaves the client's decoder
|
|
1409
|
+
* waiting for a blank line that never arrives, so the NEXT write is
|
|
1410
|
+
* appended to the SAME event — and a client that joins the several `data:`
|
|
1411
|
+
* lines of one event with `\n` and parses the result as one JSON document
|
|
1412
|
+
* (openai's `SSEDecoder` does exactly that) then reads two payloads as one
|
|
1413
|
+
* and fails. Issue #53 is that failure: the upstream died between a `data:`
|
|
1414
|
+
* line and its blank line, `consume()` forwarded the half-frame verbatim,
|
|
1415
|
+
* and `finish()` glued its synthetic `[DONE]` onto it, producing
|
|
1416
|
+
* `data: {...}\ndata: [DONE]\n\n` — parsed as one event whose data is
|
|
1417
|
+
* `"{...}\n[DONE]"`, which throws "Unexpected non-whitespace character
|
|
1418
|
+
* after JSON at position N". The whole turn is then classified as a
|
|
1419
|
+
* non-retryable error, so the user simply sees the answer stop.
|
|
1420
|
+
*
|
|
1421
|
+
* Completing the terminator here fixes both halves at once: the truncated
|
|
1422
|
+
* frame becomes a self-contained event, and the `[DONE]` that follows can no
|
|
1423
|
+
* longer be joined to it. It is idempotent for the frames `push()` cuts —
|
|
1424
|
+
* those always carry the terminator already, so they pass through
|
|
1425
|
+
* byte-for-byte, which keeps guarantee 1 (frame order is never changed)
|
|
1426
|
+
* intact.
|
|
1427
|
+
*
|
|
1428
|
+
* Note that a missing NEWLINE is also completed: `data: {…}` cut mid-line
|
|
1429
|
+
* needs the full `\n\n`, not a single `\n`, to close the event. Such a frame
|
|
1430
|
+
* is upstream-corrupted JSON that cannot be repaired here; closing it at
|
|
1431
|
+
* least keeps the corruption confined to its own event instead of taking
|
|
1432
|
+
* the terminator down with it.
|
|
1433
|
+
*/
|
|
1434
|
+
emitFrame(frame) {
|
|
1435
|
+
if (frame.endsWith("\n\n")) {
|
|
1436
|
+
this.res.write(frame);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
this.res.write(frame.endsWith("\n") ? `${frame}\n` : `${frame}\n\n`);
|
|
1440
|
+
}
|
|
1395
1441
|
/** Record that text the gates refused was seen (markup residue, not prose). */
|
|
1396
1442
|
noteResidue(seen) {
|
|
1397
1443
|
this.sawResidue ||= seen;
|
|
@@ -1407,7 +1453,10 @@ var RecoveryStream = class {
|
|
|
1407
1453
|
}
|
|
1408
1454
|
const tail = this.carry;
|
|
1409
1455
|
this.carry = "";
|
|
1410
|
-
if (tail !== "")
|
|
1456
|
+
if (tail !== "") {
|
|
1457
|
+
const lineTerminated = tail.endsWith("\n");
|
|
1458
|
+
this.handleFrame(lineTerminated ? `${tail}\n` : `${tail}\n\n`);
|
|
1459
|
+
}
|
|
1411
1460
|
}
|
|
1412
1461
|
/**
|
|
1413
1462
|
* Close the response: emit the block still unfinished at end-of-stream, then
|
|
@@ -1420,7 +1469,7 @@ var RecoveryStream = class {
|
|
|
1420
1469
|
this.emitContent(flushed.text);
|
|
1421
1470
|
}
|
|
1422
1471
|
this.closeWindow();
|
|
1423
|
-
if (!this.sawDone && this.res.writable) this.
|
|
1472
|
+
if (!this.sawDone && this.res.writable) this.emitFrame("data: [DONE]\n\n");
|
|
1424
1473
|
if (this.res.writable) this.res.end();
|
|
1425
1474
|
}
|
|
1426
1475
|
/** Drop held bytes without writing them (used when a retry replaced them). */
|
|
@@ -1498,7 +1547,7 @@ var RecoveryStream = class {
|
|
|
1498
1547
|
this.windowOpened = true;
|
|
1499
1548
|
this.flushNotice();
|
|
1500
1549
|
this.closeWindow();
|
|
1501
|
-
this.
|
|
1550
|
+
this.emitFrame(frame);
|
|
1502
1551
|
this.wroteContent = true;
|
|
1503
1552
|
return;
|
|
1504
1553
|
}
|
|
@@ -1509,7 +1558,7 @@ var RecoveryStream = class {
|
|
|
1509
1558
|
this.send(callsFrame(chunk, choiceRecord, outcome.calls), false);
|
|
1510
1559
|
this.wroteContent = true;
|
|
1511
1560
|
this.closeWindow();
|
|
1512
|
-
if (outcome.text !== "") this.
|
|
1561
|
+
if (outcome.text !== "") this.emitFrame(contentFrame(chunk, choiceRecord, outcome.text));
|
|
1513
1562
|
return;
|
|
1514
1563
|
}
|
|
1515
1564
|
this.noteResidue(!outcome.prose && outcome.text !== "");
|
|
@@ -1573,7 +1622,7 @@ var RecoveryStream = class {
|
|
|
1573
1622
|
/** Queue a frame while the window is open, or write it once it has closed. */
|
|
1574
1623
|
send(frame, final) {
|
|
1575
1624
|
if (this.windowClosed || !this.windowOpened) {
|
|
1576
|
-
this.
|
|
1625
|
+
this.emitFrame(frame);
|
|
1577
1626
|
return;
|
|
1578
1627
|
}
|
|
1579
1628
|
const entry = {
|
|
@@ -1597,7 +1646,7 @@ var RecoveryStream = class {
|
|
|
1597
1646
|
this.windowClosed = true;
|
|
1598
1647
|
const pending = this.held;
|
|
1599
1648
|
this.held = [];
|
|
1600
|
-
for (const entry of pending) this.
|
|
1649
|
+
for (const entry of pending) this.emitFrame(entry.frame);
|
|
1601
1650
|
}
|
|
1602
1651
|
};
|
|
1603
1652
|
/** The SSE `data:` payload of one frame, or nothing when it carries none. */
|
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": "4.4.
|
|
5
|
+
"version": "4.4.2",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|