dsh-connect-workbuddy 3.0.0 → 3.0.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 +62 -1
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/lib/bin.js +1 -1
- package/lib/client.js +35 -2
- package/lib/{host-heartbeat-JODdwFRx.js → host-heartbeat-Dwmfxuyw.js} +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +94 -6
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.0.2 (2026-09-30)
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
- **修复 issue #24:宿主 pi-ai 0.87 下所有 WorkBuddy 模型立即失败**(`Cannot read properties of undefined (reading 'length')`)。
|
|
8
|
+
- **根因**:pi-ai 0.87 的 `normalizeContext` 把 system 提示词搬进 `messages`,成了 `{ role: 'system', content: '<字符串>' }`。而 pi-ai 0.85 的 `Message` 联合类型里**根本没有 `system` 变体**(只有 `UserMessage | AssistantMessage | ToolResultMessage`),于是它的 `estimateMessageTokens` 没有对应分支:`for (const block of message.content)` 会把**字符串逐字符**迭代,`block` 变成单个字符,`block.name.length` 抛错。崩溃发生在 `buildBaseOptions → clampMaxTokensToContext → estimateContextTokens`,即**构造请求体之前**——所以每个模型都在十几毫秒内失败,且**没有任何上游请求发出**。
|
|
9
|
+
- **修法**:新增 `adaptLegacyPiAiContext()`,在把 context 交给本插件的 pi-ai 之前,把 `messages` 里的 system 消息折回 `Context.systemPrompt`(0.85 期望的位置),并用 `withLegacyContext()` 包住 `openAICompletionsApi()` 的 `stream`/`streamSimple` 两个入口。
|
|
10
|
+
- **按形状适配,不按版本判断**:判断依据是「context 里有没有 system 消息」,不是「加载的是哪个 pi-ai 版本」。版本判断会在任一侧升级时立刻失效;本插件必须在**两代宿主**下都能活。对原生 0.85 形状(已有 `systemPrompt`)返回**同一个对象**,原路径零改动。
|
|
11
|
+
- **实测**(用插件真正加载的那份 pi-ai 0.85.1):修复前 0.87 形状 → `Cannot read properties of undefined (reading 'length')`;修复后 → `Connection error.`(走到传输层,即请求已构造完成)。旧形状恒等,`system` 用 block 内容时也能正确合并。
|
|
12
|
+
- **守卫**:`tests/adapter.spec.ts` 新增 8 例,其中**接线用例**(`vi.mock` 掉 stream API,断言 provider 收到的 context 已被折平)是必要的——只测纯函数时,把修复从 provider 上摘掉的变异**不会被打红**(已实测),等于留了一个「修复被摘掉而测试全绿」的缺口。变异验证:去掉 `withLegacyContext` → 该用例变红。
|
|
13
|
+
- **顺带核实**:宿主 asar 声明 `@earendil-works/pi-ai ^0.87.1` 且含 `normalizeContext`;磁盘上四份 pi-ai(profiles / 插件嵌套 / 仓库 / 旧安装树)**全是 0.85.1**。issue 建议的「插件包内放一份 0.87.1」绕行在本机并未生效——嵌套副本是 0.85.1,漂移依旧,只是被当前加载的旧 `dsh-llm-pi-ai`(0.1.5-rc.2,不含 `normalizeContext`)掩盖了。
|
|
14
|
+
- **peer 范围放宽**:`@earendil-works/pi-ai` 由 `>=0.85.0 <0.86.0` 改为 `>=0.85.0 <0.88.0`(peer 与 dev 两处)。原范围**不承认 0.87**,与实际已验证的兼容性不符:适配按形状生效,代码在 0.85 与 0.87 下都已验证可用。放宽后 `pnpm install` 报 "Lockfile is up to date",未产生 lockfile 变更。
|
|
15
|
+
|
|
16
|
+
## 3.0.1 (2026-09-30)
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
#### 保存账号池会把该区域的模型目录整段删掉,于是「保存后目标模型就下架了」
|
|
21
|
+
|
|
22
|
+
**用户报告**:在账号池里点保存后,目标模型立刻显示「已下架」,并且「一键测试所有账号」被停用;**macOS 上不出现**。
|
|
23
|
+
|
|
24
|
+
**根因(真机取证)**:`__save` 的路由此前做的是 `{ ...current, ...incoming }` —— **区域级整体替换**。而客户端只能按自己的 settings 镜像拼出槽位,这个镜像**本仓库自己注释过对本命名空间不可靠**(经本端点写入不会刷新它,在 0.1.7 线上可长期陈旧)。于是:
|
|
25
|
+
|
|
26
|
+
1. 保存账号池时客户端 POST 的是 `{ cn: { pool } }`;
|
|
27
|
+
2. Host 用这一个键**替换掉整个 cn 槽** → `lastCatalog`、`enabledModelIds`、`imageModelIds`、`contextBudgets`、`enabled` **全被删除**;
|
|
28
|
+
3. `displayModels` 见 `lastCatalog` 为空即回落到**静态 CN 目录**(`src/catalog.ts`),而它只有 `deepseek-v4-flash`(第 39 行),**没有 `deepseek-v4.1-flash`**(那在第 59 行,属国际版);
|
|
29
|
+
4. 于是判「已下架」,`resolveTargetModel` 返回 `stale`,测试随之停用。
|
|
30
|
+
|
|
31
|
+
**证据**:同一 profile 下留有 1:58 的配置备份(20711 B,`cn.lastCatalog` 含 `auto`/`hy4-preview`/… 完整清单),而当前配置(14873 B)的 `cn` 槽**只剩 `pool`**——差出的约 6 KB 正是被删掉的目录。本项目审计 `docs/audit/D-verification.md` L-4 记录过同一机制(当时是 `toggleRegion` 的过期整槽写入),但只修了写入交错,没有修「整体替换」这一步。
|
|
32
|
+
|
|
33
|
+
**修法**:`__save` 的合并**下沉一层**——字段的键做一层(保兄弟区域),被触及区域槽内的键再做一层。**只保留客户端未提及的键**;客户端显式发来的字段(含空数组)依旧覆盖,因此「主动清空」不受影响。刻意不再更深:`contextBudgets` 这类嵌套 map 由其所有者整体发送,继续递归会让单个条目无法删除。
|
|
34
|
+
|
|
35
|
+
**为什么不动 macOS**:该合并只在「客户端漏发了某个键」时才有差别;macOS 上的槽位是完整的,因此对它是恒等操作。
|
|
36
|
+
|
|
37
|
+
守卫:`tests/web-status.spec.ts` 新增 4 例——① 只发 `{cn:{pool}}` 时 `contextBudgets`/`enabled` 必须存活(**这条直接复现本次数据丢失**);② 国际版同理(`{global:{pool}}` 不得删掉 global 的目录与预算);③ 显式发空数组仍能清空;④ `accounts` 的账号 id 是字符串,不得被对象化。**变异验证**:把合并改回一层展开 → ① 与 ② 同时变红(已实测)。
|
|
38
|
+
|
|
39
|
+
**即时补救**:卡片上的「切回自动」会把目标换成该区域的免费模型(`hy3`,静态目录里也有),立即可用;被删掉的模型勾选与上下文预算需在「模型管理」里重新保存一次以重建 `lastCatalog`。
|
|
40
|
+
|
|
41
|
+
**国际版(global)同样已覆盖,而且它更需要这个修复**:
|
|
42
|
+
|
|
43
|
+
- **为什么覆盖**:合并遍历的是**字段自己的键**,不认区域名——`{cn:{…}}` 与 `{global:{…}}` 走的是同一段代码,没有区域分支。有专门用例钉住(`preserves omitted fields in the INTERNATIONAL region too`)。
|
|
44
|
+
- **为什么它更需要**:同一个删除在国际版**不表现出任何症状**。`displayModels` 在 `lastCatalog` 为空时回落到静态目录,而**国际版静态目录里有 `deepseek-v4.1-flash`(`src/catalog.ts:59`,倍率 0)**,国内版静态目录没有(只有 `deepseek-v4-flash`,L39)。所以国际版用户的目标模型照旧解析成功——目录、勾选、上下文预算**被默默删掉却毫无提示**。国内版至少还会喊「已下架」。
|
|
45
|
+
- 这条区域差异已用 `tests/catalog.spec.ts` 的断言钉住(`cn.has('deepseek-v4.1-flash') === false` 且 `cn.has('deepseek-v4-flash') === true`),避免日后改动任一份目录时悄悄改变「哪些故障可见」。
|
|
46
|
+
|
|
47
|
+
**另说明**:本次两处修复都不区分区域。`isFileContentionWriteError` 是**平台**维度的(只在 Windows 上会命中 `EPERM/EBUSY/EACCES`),两条区域共用同一段客户端代码,因此国际版的保存失败提示也一并改善。
|
|
48
|
+
|
|
49
|
+
- **Windows 上文件被占用时,两条保存路径只甩原始 EPERM、不给做法**(模型列表与账号池;账号路径本来就有)。真机实测(3.0.0 宿主 + 锁住 profile 的 `cordis.patch.yml`):写入返回 HTTP 500,报文是
|
|
50
|
+
`EPERM: operation not permitted, rename 'C:\…\cordis.patch.yml.79498ff8fb27.tmp' -> '…\cordis.patch.yml'`
|
|
51
|
+
——只报了一个临时文件路径,**没有说明原因、也没告诉用户怎么办**。而 `row.accountsWriteFailed` 早就把「杀毒软件/同步盘占用 → 关闭占用者后重试」写进了文案。
|
|
52
|
+
- **修法**:新增 `isFileContentionWriteError()`(匹配 `EPERM`/`EBUSY`/`EACCES` 三个**词边界**标记——正是 `dsh-atomic-write` 在 win32 上重试的那三个),命中时在原因后追加 `row.saveContentionHint`;模型列表与账号池两条路径接上。
|
|
53
|
+
- **只对文件占用生效,不做无条件提示**:校验类失败走的是同一个 catch,对那种失败说「去关闭占用文件的程序」会把用户引向不存在的原因。有专门用例钉住「无关失败不得出现该提示」。
|
|
54
|
+
- **macOS 行为不变**:POSIX 的 `rename` 无论目标是否被打开都直接替换,所以这三个错误码在 macOS/Linux 上基本不会出现——检测器天然只在 Windows 上生效。
|
|
55
|
+
- **顺带记录的核验结论:Windows 保存逻辑本身没有「静默成功」**。锁住文件时 `regions` 与 `accounts` 两条写入都返回 500 并带 `EPERM`,客户端随即抛 `WorkBuddySettingsWriteError`,卡片明确报错并**保留草稿/原选择**(三条保存路径都不会丢弃用户改动)。回退路径的回读校验读的是「被拒后重新加载的 Host 状态」,不会把乐观写入误判为已落盘。因此本次修的是**报错文案的可操作性**,不是落盘判定。
|
|
56
|
+
|
|
57
|
+
### Docs
|
|
58
|
+
|
|
59
|
+
- **改正 3.0.0 里一处过期的免费模型举例**(README 两份 + 上方 3.0.0 条目):原文写「该区域倍率为 0 的免费模型(**国内版即 `deepseek-v4.1-flash`**)」。2026-09-30 在真机上核对 live catalog(16 个模型)后,`deepseek-v4.1-flash` 的倍率是 **0.11**,国内版**唯一**倍率为 0 的是 **`hy3`**(ctx 192000)。
|
|
60
|
+
- **机制本来是对的,错的只是举例**:`pickFreeModel` 严格按 `creditMultiplier === 0` 现挑、多个候选取上下文窗口最大者,实测输入真实目录时返回 `hy3`,规则成立;`docs/ACCOUNT_POOL_PLAN.md` §4.4 早就写明「不写死 `deepseek-v4.1-flash`」并解释了原因。所以这条只是文档里的名字腐坏了,不是行为缺陷。
|
|
61
|
+
- **为什么不改成另一个写死的名字**:上游会改倍率(本次观察到的就是一次改动),任何名字都会再次过期。现改为陈述「按 `creditMultiplier === 0` 现挑」并附本次实测值,同时说明为何不写死。
|
|
62
|
+
- **顺带印证的一条正确设计**:`auto` 在上游目录里**没有** `creditMultiplier` 字段,`pickFreeModel` 不把它当免费——这正是该模块注释警告过的陷阱(缺失 ≠ 0),实测确认未踩。
|
|
63
|
+
|
|
3
64
|
## 3.0.0 (2026-09-30)
|
|
4
65
|
|
|
5
66
|
> **主版本号升级的理由**:账号池是第一个会改变「哪个账号被计费」的功能。在此之前插件只有一条硬规则——**绝不为了找积分而重排账号**(`src/auth.ts` 的原注释:*This is NOT credit-seeking*)。账号池把这条规则变成**用户显式授权的例外**:打开「按积分自动轮换」后,被计费的账号可能不是你手动选的那个。这是语义级的变化,因此走主版本号。
|
|
@@ -9,7 +70,7 @@
|
|
|
9
70
|
#### 账号池:多账号批量操作与自动轮换
|
|
10
71
|
|
|
11
72
|
- **一键签到所有账号**:逐个账号签到,**签到前先查当天状态**,已签到的直接标记、**不发写入请求**(幂等,不重复领取);单个账号失败只标记该行,**不中断整批**。
|
|
12
|
-
- **一键测试所有账号**:对每个成员发一次**真实体积**的探测(约 25k 输入 token,复用模型行的 `probeModel`),得出该账号此刻能否用这个模型。默认目标是**该区域倍率为 0
|
|
73
|
+
- **一键测试所有账号**:对每个成员发一次**真实体积**的探测(约 25k 输入 token,复用模型行的 `probeModel`),得出该账号此刻能否用这个模型。默认目标是**该区域倍率为 0 的免费模型**(**不写死名字**:实测国内版当前只有 `hy3` 一个免费模型,而 `deepseek-v4.1-flash` 已从 0 变为 0.11——上游会改倍率,所以按目录里的 `creditMultiplier === 0` 现挑),也可指定具体模型;指定的模型若离开目录,手动动作**明确拒绝**(`target-model-stale`)而不是偷换一个。
|
|
13
74
|
- **按积分自动轮换**:在可用成员中按 **① 积分余额高者优先 → ② 快过期的积分优先用完 → ③ 平手时凭据更新鲜者优先** 选出计费账号。**与手动选择互斥**:开启后手动下拉立即置灰并就地给出原因与关闭按钮(否则会出现「下拉写着 A、实际计费 B」)。**轮换只在运行时决定用谁,绝不写回 `config.accounts[region]`**,关掉开关即原样恢复你上次的手动选择;每次换号都在卡片上留一条记录。
|
|
14
75
|
- **成员是显式勾选、默认空**:「本机有登录」不等于「要拿它去签到和花积分」。**空池不会退化成「全部账号」**——未勾选时两个批量按钮直接拒绝。
|
|
15
76
|
- **定时自动测试**:间隔 **5 ~ 1440 分钟(默认 30)**,定时器每 60 秒走一次。下限 5 分钟是刻意的:更密只会把整池打成限流。
|
package/README.en.md
CHANGED
|
@@ -71,7 +71,7 @@ A machine usually carries more than one WorkBuddy sign-in. The **account pool**
|
|
|
71
71
|
- **Check in all accounts** — signs in account by account. It **reads today's status first**, so an account that is already checked in is marked as such and receives **no write request at all** (idempotent — no duplicate claims); a single account failing marks only that row and **never aborts the batch**.
|
|
72
72
|
- **Test all accounts** — sends one **real-volume** probe per member (about 25k input tokens; the same `probeModel` the model rows use, so it inherits both measured facts: throttling is triggered by request **size**, and the reset time is written into the response **body** rather than a header), reporting whether that account can serve that model right now.
|
|
73
73
|
|
|
74
|
-
**Which model is tested:** by default the **zero-multiplier free model in that region's catalog** (`deepseek-v4.1-flash`
|
|
74
|
+
**Which model is tested:** by default the **zero-multiplier free model in that region's catalog** (measured: the domestic catalog currently has exactly one, `hy3`; the name is deliberately **not** hard-coded, because the upstream changes multipliers — `deepseek-v4.1-flash` has already moved from 0 to 0.11), or a specific model you pick in the dropdown. If the model you named has left the catalog, the **manual action refuses outright** (`target-model-stale`) instead of quietly substituting another one — you asked to test *that* model, and swapping it answers a question you did not ask.
|
|
75
75
|
|
|
76
76
|
**Cost and boundaries:** testing is a real request. Pointed at the free model (multiplier 0) it costs virtually nothing, but it is **not a free no-op**; check-in claims that day's real reward. Both actions run **only when you press the button** — the plugin **does not offer automatic check-in**, and the card does not show a switch that can never be flipped.
|
|
77
77
|
|
package/README.md
CHANGED
|
@@ -78,7 +78,7 @@ DSH PiAiAdapter(每个 provider 一套)
|
|
|
78
78
|
- **一键签到所有账号** —— 逐个账号签到。**签到前先查当天状态**,已签到的直接标记为「已签到」而**不发写入请求**(幂等,不重复领取);任何单个账号失败只标记该行,**不中断整批**。
|
|
79
79
|
- **一键测试所有账号** —— 对每个成员发一次**真实体积**的探测请求(约 25k 输入 token;与模型行的「测试」是同一套 `probeModel`,因而同样继承两个实测结论:限流**按请求体积**触发、恢复时间写在**响应体**里而不是响应头),得出该账号此刻能否用这个模型。
|
|
80
80
|
|
|
81
|
-
**测试目标是哪个模型**:默认是**该区域目录里倍率为 0
|
|
81
|
+
**测试目标是哪个模型**:默认是**该区域目录里倍率为 0 的免费模型**(实测国内版当前只有 `hy3` 一个;**不写死名字**,因为上游会改倍率——`deepseek-v4.1-flash` 就已从 0 变成 0.11),也可以在下拉里指定一个具体模型。指定的模型若已离开目录,**手动动作会明确拒绝**(`target-model-stale`),而不是偷偷换一个——你点的是「测这个模型」,换掉就是答非所问。
|
|
82
82
|
|
|
83
83
|
**成本与边界**:测试是真实请求,指向免费模型时(倍率 0)几乎不花钱,但它**不是零成本的空转**;签到则会真实领取当日奖励。两个动作都**只在你点按钮时执行**——插件**不提供自动签到**,卡片上也不放一个永远点不动的开关。
|
|
84
84
|
|
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { O as workbuddyOwnAuthPath, T as legacyWorkbuddyOwnAuthPath, W as findWorkbuddyAppExecutable, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, g as WORKBUDDY_AUTH_FILE_ENV, j as WorkBuddyUpstreamClient, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, x as defaultDesktopAuthDirs, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-
|
|
2
|
+
import { O as workbuddyOwnAuthPath, T as legacyWorkbuddyOwnAuthPath, W as findWorkbuddyAppExecutable, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, g as WORKBUDDY_AUTH_FILE_ENV, j as WorkBuddyUpstreamClient, l as FALLBACK_WORKBUDDY_MODELS, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, x as defaultDesktopAuthDirs, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-Dwmfxuyw.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
|
@@ -264,6 +264,31 @@ window.__ModuleLoader__.load({
|
|
|
264
264
|
}
|
|
265
265
|
};
|
|
266
266
|
/**
|
|
267
|
+
* Whether a save failure is a FILE-CONTENTION refusal.
|
|
268
|
+
*
|
|
269
|
+
* On Windows, replacing a file that another process holds open fails with
|
|
270
|
+
* `EPERM` / `EBUSY` / `EACCES` — an antivirus scanner, a sync client (OneDrive),
|
|
271
|
+
* or an editor with the profile's patch file open. POSIX `rename` replaces
|
|
272
|
+
* outright regardless of open handles, so this branch effectively never fires
|
|
273
|
+
* on macOS/Linux: the diagnosis is Windows-shaped because the failure is.
|
|
274
|
+
*
|
|
275
|
+
* It exists so a save failure can say WHAT TO DO. Without it the card pasted the
|
|
276
|
+
* raw error at the user — `EPERM: operation not permitted, rename
|
|
277
|
+
* 'C:\…\cordis.patch.yml.79498ff8fb27.tmp' -> '…\cordis.patch.yml'` — which
|
|
278
|
+
* names a temp path and no remedy. Measured live against the 3.0.0 host with the
|
|
279
|
+
* file held open: both the `regions` and `accounts` writes came back HTTP 500
|
|
280
|
+
* with exactly that text.
|
|
281
|
+
*
|
|
282
|
+
* Deliberately matched on the message rather than on `WorkBuddySettingsWriteError`
|
|
283
|
+
* alone: a validation refusal is also that class but has nothing to do with file
|
|
284
|
+
* contention, and telling such a user to close their editor would send them
|
|
285
|
+
* chasing a cause that is not there.
|
|
286
|
+
*/
|
|
287
|
+
function isFileContentionWriteError(error) {
|
|
288
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
289
|
+
return /\b(?:EPERM|EBUSY|EACCES)\b/u.test(message);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
267
292
|
* Read one settings field's current object value from the scope snapshot.
|
|
268
293
|
*
|
|
269
294
|
* The snapshot holds the RESOLVED section, where a volatile field
|
|
@@ -1113,7 +1138,10 @@ window.__ModuleLoader__.load({
|
|
|
1113
1138
|
}
|
|
1114
1139
|
committed = true;
|
|
1115
1140
|
} catch (error) {
|
|
1116
|
-
if (mounted.current)
|
|
1141
|
+
if (mounted.current) {
|
|
1142
|
+
const reason = error instanceof Error ? error.message : t("row.requestFailed");
|
|
1143
|
+
setSaveError(isFileContentionWriteError(error) ? `${reason}${t("row.saveContentionHint")}` : reason);
|
|
1144
|
+
}
|
|
1117
1145
|
} finally {
|
|
1118
1146
|
if (committed) discard();
|
|
1119
1147
|
if (mounted.current) setSaving(false);
|
|
@@ -2542,7 +2570,10 @@ window.__ModuleLoader__.load({
|
|
|
2542
2570
|
discardModels();
|
|
2543
2571
|
await refreshUsage(activeRegion);
|
|
2544
2572
|
} catch (error) {
|
|
2545
|
-
if (mounted.current)
|
|
2573
|
+
if (mounted.current) {
|
|
2574
|
+
const reason = error instanceof Error ? error.message : t("row.requestFailed");
|
|
2575
|
+
setSaveError(isFileContentionWriteError(error) ? `${reason}${t("row.saveContentionHint")}` : reason);
|
|
2576
|
+
}
|
|
2546
2577
|
} finally {
|
|
2547
2578
|
if (mounted.current) setSaving(false);
|
|
2548
2579
|
}
|
|
@@ -3132,6 +3163,7 @@ window.__ModuleLoader__.load({
|
|
|
3132
3163
|
"row.save": "Save",
|
|
3133
3164
|
"row.saving": "Saving…",
|
|
3134
3165
|
"row.saveError": "Save failed: {message}",
|
|
3166
|
+
"row.saveContentionHint": " (The file was locked, so the write was refused — on Windows this is usually an antivirus scanner, a sync client such as OneDrive, or an editor with that file open. Close whatever holds it and save again; your edits are still here.)",
|
|
3135
3167
|
"row.modelContext": "Maximum context {context}",
|
|
3136
3168
|
"row.contextBudget": "DSH context budget",
|
|
3137
3169
|
"row.modelOutput": "Output {output}",
|
|
@@ -3324,6 +3356,7 @@ window.__ModuleLoader__.load({
|
|
|
3324
3356
|
"row.save": "保存",
|
|
3325
3357
|
"row.saving": "保存中…",
|
|
3326
3358
|
"row.saveError": "保存失败:{message}",
|
|
3359
|
+
"row.saveContentionHint": "(该文件被占用,写入被拒——Windows 上通常是杀毒软件、OneDrive 这类同步盘,或正打开该文件的编辑器。请先关闭占用者再保存一次;你的改动仍然还在。)",
|
|
3327
3360
|
"row.modelContext": "最大上下文 {context}",
|
|
3328
3361
|
"row.contextBudget": "DSH 上下文预算",
|
|
3329
3362
|
"row.modelOutput": "最大输出 {output}",
|
|
@@ -2286,7 +2286,7 @@ var WorkBuddyCatalog = class {
|
|
|
2286
2286
|
//#endregion
|
|
2287
2287
|
//#region src/version.ts
|
|
2288
2288
|
/** The npm package version this build was produced from. */
|
|
2289
|
-
const WORKBUDDY_CONNECT_VERSION = "3.0.
|
|
2289
|
+
const WORKBUDDY_CONNECT_VERSION = "3.0.2";
|
|
2290
2290
|
//#endregion
|
|
2291
2291
|
//#region src/host-heartbeat.ts
|
|
2292
2292
|
/**
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as WorkBuddyCredentialRejectedError, B as clearAtRestKeyCache, C as hasEncryptedCredentialFields, D as workbuddyAccountId, E as parseWorkBuddyAuth, F as parseReasoning, G as isEncryptedFieldWrapper, H as deriveAtRestKeyId, I as parseUpstreamModel, J as macosNestedAppBundles, K as isWorkbuddyBundle, L as prepareChatBody, M as classifyUpstreamError, N as isCredentialRejectedError, O as workbuddyOwnAuthPath, P as parseCreditMultiplier, R as regionOf, S as defaultDesktopAuthPath, T as legacyWorkbuddyOwnAuthPath, U as fetchAtRestKeyPayload, V as deriveAtRestKey, W as findWorkbuddyAppExecutable, X as readAtRestKey, Y as openEncryptedField, Z as workbuddyAppExecutableCandidates, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WORKBUDDY_AUTH_FILE_ENV, h as WORKBUDDY_AUTH_FILENAME, i as processStartTimeMs, j as WorkBuddyUpstreamClient, k as CREDENTIAL_REJECTED_CODE, l as FALLBACK_WORKBUDDY_MODELS, m as ENCRYPTED_CREDENTIAL_CODE, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, q as macosBundleExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as WorkBuddyEncryptedCredentialError, w as isEncryptedCredentialError, x as defaultDesktopAuthDirs, y as authFileName, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-
|
|
1
|
+
import { A as WorkBuddyCredentialRejectedError, B as clearAtRestKeyCache, C as hasEncryptedCredentialFields, D as workbuddyAccountId, E as parseWorkBuddyAuth, F as parseReasoning, G as isEncryptedFieldWrapper, H as deriveAtRestKeyId, I as parseUpstreamModel, J as macosNestedAppBundles, K as isWorkbuddyBundle, L as prepareChatBody, M as classifyUpstreamError, N as isCredentialRejectedError, O as workbuddyOwnAuthPath, P as parseCreditMultiplier, R as regionOf, S as defaultDesktopAuthPath, T as legacyWorkbuddyOwnAuthPath, U as fetchAtRestKeyPayload, V as deriveAtRestKey, W as findWorkbuddyAppExecutable, X as readAtRestKey, Y as openEncryptedField, Z as workbuddyAppExecutableCandidates, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WORKBUDDY_AUTH_FILE_ENV, h as WORKBUDDY_AUTH_FILENAME, i as processStartTimeMs, j as WorkBuddyUpstreamClient, k as CREDENTIAL_REJECTED_CODE, l as FALLBACK_WORKBUDDY_MODELS, m as ENCRYPTED_CREDENTIAL_CODE, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, q as macosBundleExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as WorkBuddyEncryptedCredentialError, w as isEncryptedCredentialError, x as defaultDesktopAuthDirs, y as authFileName, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-Dwmfxuyw.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
@@ -213,6 +213,86 @@ function toPiModel(info, baseUrl, providerId) {
|
|
|
213
213
|
};
|
|
214
214
|
}
|
|
215
215
|
/**
|
|
216
|
+
* Rewrite a pi-ai 0.87-shaped context into the 0.85 shape this plugin's pi-ai
|
|
217
|
+
* understands.
|
|
218
|
+
*
|
|
219
|
+
* WHY THIS EXISTS (issue #24). pi-ai 0.87's `normalizeContext` moves the system
|
|
220
|
+
* prompt INTO `messages` as `{ role: 'system', content: '<string>' }`. pi-ai
|
|
221
|
+
* 0.85's own `Message` union has no `system` variant
|
|
222
|
+
* (`UserMessage | AssistantMessage | ToolResultMessage`), so its
|
|
223
|
+
* `estimateMessageTokens` has no branch for one: `for (const block of
|
|
224
|
+
* message.content)` iterates the CONTENT STRING character by character, `block`
|
|
225
|
+
* is a single character, and `block.name.length` throws
|
|
226
|
+
* `Cannot read properties of undefined (reading 'length')`.
|
|
227
|
+
*
|
|
228
|
+
* That crash happens inside the library, in `buildBaseOptions ->
|
|
229
|
+
* clampMaxTokensToContext -> estimateContextTokens`, i.e. BEFORE any request is
|
|
230
|
+
* built — so every model fails instantly and no upstream traffic is sent. A
|
|
231
|
+
* host running 0.87 hands us the normalized transcript while our own provider
|
|
232
|
+
* is 0.85, and the plugin cannot patch the library. What it CAN do is not hand
|
|
233
|
+
* a 0.87 transcript to a 0.85 API object: fold the system text back into
|
|
234
|
+
* `Context.systemPrompt`, which is where 0.85 expects it.
|
|
235
|
+
*
|
|
236
|
+
* Deliberately SHAPE-based, not version-based: it asks "does this context carry
|
|
237
|
+
* a system message inside `messages`?" rather than "which pi-ai version is
|
|
238
|
+
* loaded?". A version check would be wrong the moment either side moves, and
|
|
239
|
+
* this plugin has to survive both host generations.
|
|
240
|
+
*
|
|
241
|
+
* Returns the SAME object when there is nothing to adapt — in particular when
|
|
242
|
+
* `systemPrompt` is already set (the native 0.85 shape), so the ordinary path
|
|
243
|
+
* is byte-for-byte untouched.
|
|
244
|
+
*/
|
|
245
|
+
function adaptLegacyPiAiContext(context) {
|
|
246
|
+
if (context === null || typeof context !== "object") return context;
|
|
247
|
+
if (context.systemPrompt !== void 0) return context;
|
|
248
|
+
const messages = Array.isArray(context.messages) ? context.messages : void 0;
|
|
249
|
+
if (messages === void 0) return context;
|
|
250
|
+
const systemTexts = [];
|
|
251
|
+
const rest = [];
|
|
252
|
+
let sawSystemMessage = false;
|
|
253
|
+
for (const message of messages) {
|
|
254
|
+
if (message?.role !== "system") {
|
|
255
|
+
rest.push(message);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
sawSystemMessage = true;
|
|
259
|
+
const text = systemTextOf(message);
|
|
260
|
+
if (text !== "") systemTexts.push(text);
|
|
261
|
+
}
|
|
262
|
+
if (!sawSystemMessage) return context;
|
|
263
|
+
return {
|
|
264
|
+
...context,
|
|
265
|
+
...systemTexts.length === 0 ? {} : { systemPrompt: systemTexts.join("\n\n") },
|
|
266
|
+
messages: rest
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** The text of one system message, whether it is a string or text blocks. */
|
|
270
|
+
function systemTextOf(message) {
|
|
271
|
+
const content = message.content;
|
|
272
|
+
if (typeof content === "string") return content;
|
|
273
|
+
if (!Array.isArray(content)) return "";
|
|
274
|
+
const texts = [];
|
|
275
|
+
for (const block of content) {
|
|
276
|
+
if (typeof block !== "object" || block === null) continue;
|
|
277
|
+
const candidate = block;
|
|
278
|
+
if (candidate.type === "text" && typeof candidate.text === "string") texts.push(candidate.text);
|
|
279
|
+
}
|
|
280
|
+
return texts.join("\n\n");
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Wrap one pi-ai API module so both stream entry points receive a context this
|
|
284
|
+
* build can actually consume (see {@link adaptLegacyPiAiContext}).
|
|
285
|
+
*
|
|
286
|
+
* The deferred-fetch entry points take no context and are passed through as-is.
|
|
287
|
+
*/
|
|
288
|
+
function withLegacyContext(api) {
|
|
289
|
+
return {
|
|
290
|
+
...api,
|
|
291
|
+
stream: (model, context, options) => api.stream(model, adaptLegacyPiAiContext(context), options),
|
|
292
|
+
streamSimple: (model, context, options) => api.streamSimple(model, adaptLegacyPiAiContext(context), options)
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
216
296
|
* Assemble the adapter. The provider's `getModels` reads the live catalog,
|
|
217
297
|
* and every model's `baseUrl` is re-resolved per read so the shim's
|
|
218
298
|
* ephemeral port applies from the first snapshot after startup.
|
|
@@ -240,7 +320,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
240
320
|
}
|
|
241
321
|
} },
|
|
242
322
|
models: buildModels(),
|
|
243
|
-
api: openAICompletionsApi()
|
|
323
|
+
api: withLegacyContext(openAICompletionsApi())
|
|
244
324
|
}),
|
|
245
325
|
getModels: () => buildModels()
|
|
246
326
|
};
|
|
@@ -1540,6 +1620,10 @@ const WORKBUDDY_SETTINGS_NS = "workbuddy";
|
|
|
1540
1620
|
function safeMessage(error) {
|
|
1541
1621
|
return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
|
|
1542
1622
|
}
|
|
1623
|
+
/** A non-null, non-array object: the only shape the slot merge applies to. */
|
|
1624
|
+
function isPlainRecord(value) {
|
|
1625
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1626
|
+
}
|
|
1543
1627
|
function json(res, status, body) {
|
|
1544
1628
|
const payload = JSON.stringify(body);
|
|
1545
1629
|
res.writeHead(status, {
|
|
@@ -2044,10 +2128,14 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
2044
2128
|
if (row === void 0) return json(res, 503, { error: "workbuddy namespace missing from describe()" });
|
|
2045
2129
|
const current = unwrapVolatileDeep(row.value?.[field] ?? {});
|
|
2046
2130
|
const incoming = body.value ?? {};
|
|
2047
|
-
const merged = {
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2131
|
+
const merged = { ...current };
|
|
2132
|
+
for (const [key, slot] of Object.entries(incoming)) {
|
|
2133
|
+
const prior = current[key];
|
|
2134
|
+
merged[key] = isPlainRecord(prior) && isPlainRecord(slot) ? {
|
|
2135
|
+
...prior,
|
|
2136
|
+
...slot
|
|
2137
|
+
} : slot;
|
|
2138
|
+
}
|
|
2051
2139
|
await settings.mutate(row.ns, [{
|
|
2052
2140
|
op: "set",
|
|
2053
2141
|
path: [field],
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-workbuddy",
|
|
3
3
|
"displayName": "DSH Connect WorkBuddy",
|
|
4
4
|
"description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
|
|
5
|
-
"version": "3.0.
|
|
5
|
+
"version": "3.0.2",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.3.0-0",
|
|
92
92
|
"@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.3.0-0",
|
|
93
93
|
"@deepseek-ai/schemastery": ">=3.18.1-rc.1 <4.0.0 || >=3.18.2 <4.0.0",
|
|
94
|
-
"@earendil-works/pi-ai": ">=0.85.0 <0.
|
|
94
|
+
"@earendil-works/pi-ai": ">=0.85.0 <0.88.0",
|
|
95
95
|
"react": "^18.2.0"
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.3.0-0",
|
|
111
111
|
"@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.3.0-0",
|
|
112
112
|
"@deepseek-ai/schemastery": ">=3.18.1 <4.0.0 || >=3.18.2 <4.0.0",
|
|
113
|
-
"@earendil-works/pi-ai": ">=0.85.0 <0.
|
|
113
|
+
"@earendil-works/pi-ai": ">=0.85.0 <0.88.0",
|
|
114
114
|
"@types/node": "^22.20.0",
|
|
115
115
|
"@types/react": "~18.3.31",
|
|
116
116
|
"@types/react-dom": "18.3.7",
|