dsh-workbuddy-xdpool 1.7.4 → 1.7.6
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 +103 -0
- package/lib/bin.js +72 -9
- package/lib/client.js +196 -12
- package/lib/index.d.ts +42 -0
- package/lib/index.js +199 -8
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,109 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 1.7.6 — 一个读不出来的凭据文件,不再带走整个账号池
|
|
8
|
+
|
|
9
|
+
> 你切了 4 个账号登录,界面显示 4 个;重启之后只剩 2 个 —— 不是账号没了,是它们被一个文件拖累了。
|
|
10
|
+
|
|
11
|
+
**你会看到**:用 WorkBuddy Switch 切换登录几个国内版账号后,卡片上账号数对不上——重启少了两个。
|
|
12
|
+
|
|
13
|
+
**为什么**:认证目录里**同时存在好几种文件**:明文的老格式、5.6.0+ 起的加密格式、写到一半的、还有插件不认识的版本。这些**混在一起是常态**,不是错误。
|
|
14
|
+
|
|
15
|
+
但旧代码里,只要**碰到一个加密又打不开的文件**(WorkBuddy 没运行、或者取不到它的密钥),就会**抛异常终止整个扫描** —— 于是那些**明明读得好好的账号也被一起丢掉**。一个坏文件,带走整个池。
|
|
16
|
+
|
|
17
|
+
我复现了这个行为:3 个正常文件 + 1 个打不开的,结果**不是返回 3 个,而是整个扫描抛错、0 个账号**。
|
|
18
|
+
|
|
19
|
+
**怎么修的**:
|
|
20
|
+
|
|
21
|
+
- **单个文件失败只跳过它自己**,不会连累其他账号。这是核心修复;
|
|
22
|
+
- **跳过的文件会被记录并显示在卡片上**:现在账号区会明确写「有 N 个凭据文件读不出来」,并**列出文件名和原因**。其中「已加密 — 启动一次 WorkBuddy 才能取到密钥」是可操作的;而"不是凭据文件"就只是个无关文件。区分开很重要,不然你会去修一个压根修不好的东西。
|
|
23
|
+
|
|
24
|
+
这样账号数就**可信了**:不会再出现「磁盘上 4 个文件,界面显示 2 个账号」这种说不清的落差。
|
|
25
|
+
|
|
26
|
+
### 顺带修的第二个问题:pi-ai 跨代误用
|
|
27
|
+
|
|
28
|
+
另一份反馈报的是「每轮对话必崩,报 `Cannot read properties of undefined` 且不可重试」。根因是**同一条调用链上混用了两代 pi-ai** —— 插件用 0.85.x 组装 provider,宿主适配器用 0.87.x 消费事件流,两代对终态消息的形状约定不同,接缝处直接抛异常。而宿主把它归为不可重试的 `PI_AI_ERROR`,栈还被丢弃,所以极难排查。
|
|
29
|
+
|
|
30
|
+
**为什么 `package.json` 的范围拦不住**:旧的 peer 声明是 `>=0.82.1 <0.85.0`,**上界把宿主的 0.87 代际整个排除在外** —— 等于把「跨代混用」变成了一种**合法的安装组合**。版本范围只能描述"我能接受什么",描述不了"机器上实际装的是哪一份"。
|
|
31
|
+
|
|
32
|
+
**所以改成两条**:
|
|
33
|
+
|
|
34
|
+
- **放开上界**(`>=0.82.1`),不再把宿主代际排除成非法组合;
|
|
35
|
+
- **加载时实测并告警**:比对插件这份与宿主那份 pi-ai 的代际,**不一致就在日志里点名两个版本并给出修法**(用 pnpm `overrides` 把代际对齐宿主)。
|
|
36
|
+
|
|
37
|
+
只告警、**不阻止启动** —— 版本不一致也可能是无害的,因为一个猜测就让插件拒绝加载会更糟。
|
|
38
|
+
|
|
39
|
+
### 验证
|
|
40
|
+
|
|
41
|
+
- 类型检查全绿,测试 **313 项全绿**(+9),**连续两次全量运行稳定**。
|
|
42
|
+
- 新增 `tests/account-discovery.test.ts`(7 项):单个坏文件不拖垮扫描、**全部文件都坏时也不抛错**、跳过的文件带原因上报、坏文件不会被算成账号、非 JSON / 结构不对的文件同样只跳过。
|
|
43
|
+
- 新增 `tests/pi-ai-generation.test.ts`(9 项):代际归约(补丁差异不算跨代)、**0.85 / 0.87 分裂能被识别**、任一侧解析不到时判为未知、告警文案包含两个版本与修法。
|
|
44
|
+
- **反向验证**:把「加密错误重新抛出」放回 → 4 项变红,确认测试确实能抓住这个缺陷。
|
|
45
|
+
- 构建后校验产物**已内联**代际检测模块(无外部 `.ts` 引用),并直接加载 `lib/index.js` 确认可运行。
|
|
46
|
+
|
|
47
|
+
### 已知未覆盖
|
|
48
|
+
|
|
49
|
+
「每轮必崩」的那份报告里,还建议了**宿主侧**两处加固(`mapStopReason` 对空 `content` 加守卫、`classifyPiAiError` 保留原始 `error.stack`)。那是 DSH 宿主的代码,不在这个插件仓库内,需要另开 issue 到 DSH。
|
|
50
|
+
|
|
51
|
+
## 1.7.5 — 模型列表会告诉你「现在用哪个不花钱」
|
|
52
|
+
|
|
53
|
+
> Hy4 preview 晚上 23 点到早上 8 点免费,Hy3 一直在免费期 —— 现在卡片上直接看得出来。
|
|
54
|
+
|
|
55
|
+
**你会看到**:模型列表里,**当前免费的排在最上面**并带标记:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
[免费] hy3 x0.00
|
|
59
|
+
auto
|
|
60
|
+
(23:00 起免费 · 活动至 10-31) hy4-preview x0.29
|
|
61
|
+
hy3-x x0.05
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
到了晚上 23 点,同一行会变成:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
[免费至 08:00 · 活动至 10-31] hy4-preview x0.29
|
|
68
|
+
[免费] hy3 x0.00
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**为什么需要专门做**:因为**上游接口不说这件事**。我拉了真实的国内版接口逐字段核对:
|
|
72
|
+
|
|
73
|
+
| 信息 | 上游提供吗 |
|
|
74
|
+
|---|---|
|
|
75
|
+
| Hy3 限免 | ✅ 提供 —— `credits: "x0.00"`,倍率本身就是 0,我们一直在正确显示 |
|
|
76
|
+
| Hy4 preview **夜间**限免 | ❌ **不提供** —— 倍率恒为 `x0.29`,**没有任何时段字段** |
|
|
77
|
+
| Hy4 preview **新用户 14 天**额度 | ❌ **不提供** —— 积分包里也没有这类条目 |
|
|
78
|
+
|
|
79
|
+
所以夜间限免这条规则,只能由插件自己带。我把它写成一张**规则表**(`src/promo.ts`),包含「哪个平台、哪些 id 前缀、哪些小时、有效期到哪天」。
|
|
80
|
+
|
|
81
|
+
**几个刻意的设计**:
|
|
82
|
+
|
|
83
|
+
- **规则区分平台**。这是**国内版活动**,所以规则里带 `region: 'cn'`,**国际版完全不受影响** —— 国际版的 `hy4-preview-f` 显示「免费」是因为**上游倍率真的是 0**(客观事实),不是我们套了活动规则;
|
|
84
|
+
- **免费状态是「此刻」的,不是模型固有属性**。所以同一天里 `hy4-preview` 的标记会变 —— 这才是真实情况;
|
|
85
|
+
- **窗口外只给一个低调的虚线提示**(`23:00 起免费`),**不给「免费」徽章**。因为它此刻**确实要花积分**,标成免费会让你花错钱;
|
|
86
|
+
- **只有「此刻免费」的才置顶**。窗口外的模型不置顶,否则就是把一个要花钱的模型举到最前面,误导性更强;
|
|
87
|
+
- **标出活动截止日**(`活动至 10-31`)。方便你安排什么时候用 —— 而且活动一旦延期,改日期就能看到,不用翻更新日志;
|
|
88
|
+
- **规则带过期日期**,到 10 月 31 日之后**自动失效**,不需要有人记得来删代码;
|
|
89
|
+
- **`hy4-preview` 用前缀匹配**,因为上游有 `hy4-preview` 和 `hy4-preview-x` 两个 id,是同一款产品。
|
|
90
|
+
|
|
91
|
+
### 关于「新用户 14 天免费额度」——明确没做
|
|
92
|
+
|
|
93
|
+
你提到「新用户没用过 Hy4 preview,还能免费 14 天,要区分出来」。**这个我做不到,而且硬做会更糟**:
|
|
94
|
+
|
|
95
|
+
上游**完全不记录**「这个账户有没有用过 Hy4 preview」—— 模型目录里没有、积分包里也没有(我实测过,44 个积分包里没有一个试用额度条目)。
|
|
96
|
+
|
|
97
|
+
唯一能猜的办法是「插件自己记住第一次调用的时间」。但这有个致命问题:**它只知道「用本插件之后」的历史**。你之前要是在官方客户端用过 Hy4 preview,插件照样会以为你是新用户,然后给你标一个**假的「免费」** —— 而你会因此放心去用,结果**扣了积分**。
|
|
98
|
+
|
|
99
|
+
**给你一个假的「免费」,比不给你标记更糟**,因为它直接改变你的花钱决定。所以这条留白,不做推测。
|
|
100
|
+
|
|
101
|
+
如果你确实需要,可行的做法是**你自己确认**:卡片上显示的是客观事实(倍率 + 夜间窗口),你清楚自己是不是新用户,看一眼就知道该不该用。
|
|
102
|
+
|
|
103
|
+
### 验证
|
|
104
|
+
|
|
105
|
+
- 类型检查全绿,测试 **297 项全绿**(+19)。
|
|
106
|
+
- 新增 `tests/promo.test.ts`:23:00–08:00 窗口内/外的判定、跨午夜(23 点在窗口内、8 点不在)、窗口关闭时间(`免费至 08:00`)、下次开启时间(`23:00 起免费`)、同族 id(`hy4-preview` 与 `hy4-preview-x`)、零倍率模型全天免费且不误入夜间规则、**到期自动失效**(10/31 有效、11/1 失效)、**免费置顶只认「此刻」免费**、**活动截止日随状态带出**、**国际版不受活动影响**(但零倍率模型在两边都算免费)。
|
|
107
|
+
- **反向验证**:清空夜间小时 → 7 项变红;删掉过期日期 → 2 项变红;**移除平台过滤 → 「国际版不受影响」变红**。确认测试真的能抓住这三类缺陷。
|
|
108
|
+
- 用**两个平台的真实清单**(国内版 16 个 / 国际版 24 个)跑卡片渲染逻辑,在 12:00 与 23:30 核对:国内版 `hy4-preview` 正确显示活动徽章与截止日,**国际版完全不受活动规则影响**。
|
|
109
|
+
|
|
7
110
|
## 1.7.4 — 装在中文文件夹里的 WorkBuddy,终于找得到了
|
|
8
111
|
|
|
9
112
|
> 「腾讯」两个字,让插件整整看不见一个装好、登录着、正在运行的 WorkBuddy。
|
package/lib/bin.js
CHANGED
|
@@ -9,6 +9,7 @@ import "@earendil-works/pi-ai";
|
|
|
9
9
|
import "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
10
10
|
import "@deepseek-ai/dsh-llm";
|
|
11
11
|
import "@deepseek-ai/dsh-llm-pi-ai";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
12
13
|
//#region src/upstream.ts
|
|
13
14
|
/**
|
|
14
15
|
* Backoff between catalog-fetch attempts.
|
|
@@ -2347,10 +2348,6 @@ var WorkBuddyEncryptedCredentialError = class extends Error {
|
|
|
2347
2348
|
this.name = "WorkBuddyEncryptedCredentialError";
|
|
2348
2349
|
}
|
|
2349
2350
|
};
|
|
2350
|
-
/** True when a thrown value is the encrypted-credential marker (cross-bundle safe). */
|
|
2351
|
-
function isEncryptedCredentialError(value) {
|
|
2352
|
-
return typeof value === "object" && value !== null && value.code === "ENCRYPTED_CREDENTIAL";
|
|
2353
|
-
}
|
|
2354
2351
|
function decryptableString(value, decrypt) {
|
|
2355
2352
|
if (typeof value === "string") return {
|
|
2356
2353
|
value,
|
|
@@ -2504,6 +2501,18 @@ async function authFilesIn(dir) {
|
|
|
2504
2501
|
files.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
|
|
2505
2502
|
return files.map((name) => join(dir, name));
|
|
2506
2503
|
}
|
|
2504
|
+
/**
|
|
2505
|
+
* One credential file, or undefined when it cannot be used.
|
|
2506
|
+
*
|
|
2507
|
+
* Returns undefined — rather than throwing — for every failure mode, so ONE bad
|
|
2508
|
+
* file cannot empty the pool. That is the whole point: an auth directory blends
|
|
2509
|
+
* plain files, encrypted files, half-written files and files from an app version
|
|
2510
|
+
* this plugin cannot read, and a scan that aborts on the first unusable one
|
|
2511
|
+
* reports "you have 2 accounts" when it means "I could not open the other two".
|
|
2512
|
+
*
|
|
2513
|
+
* The caller surfaces the ones it skipped, so the count never silently
|
|
2514
|
+
* disagrees with what is on disk.
|
|
2515
|
+
*/
|
|
2507
2516
|
async function readCredential(path) {
|
|
2508
2517
|
let text;
|
|
2509
2518
|
try {
|
|
@@ -2514,8 +2523,7 @@ async function readCredential(path) {
|
|
|
2514
2523
|
const decrypt = text.includes("\"$wbEncrypted\"") ? await encryptedFieldOpener() : void 0;
|
|
2515
2524
|
try {
|
|
2516
2525
|
return parseWorkBuddyAuth(text, path, decrypt);
|
|
2517
|
-
} catch
|
|
2518
|
-
if (isEncryptedCredentialError(error)) throw error;
|
|
2526
|
+
} catch {
|
|
2519
2527
|
return;
|
|
2520
2528
|
}
|
|
2521
2529
|
}
|
|
@@ -2555,6 +2563,29 @@ async function cheapIdentityIdFromFile(path) {
|
|
|
2555
2563
|
}
|
|
2556
2564
|
}
|
|
2557
2565
|
/**
|
|
2566
|
+
* Why a file could not become an account.
|
|
2567
|
+
*
|
|
2568
|
+
* Distinguishing "encrypted" matters most: it is actionable (start the desktop
|
|
2569
|
+
* app, or set the executable override), whereas "malformed" means the file is
|
|
2570
|
+
* simply not a credential. Reporting them alike would send the user looking for
|
|
2571
|
+
* a fix that cannot work.
|
|
2572
|
+
*/
|
|
2573
|
+
async function skipReasonFor(path) {
|
|
2574
|
+
let text;
|
|
2575
|
+
try {
|
|
2576
|
+
text = await readFile(path, "utf8");
|
|
2577
|
+
} catch {
|
|
2578
|
+
return "unreadable";
|
|
2579
|
+
}
|
|
2580
|
+
if (text.includes("\"$wbEncrypted\"")) return "encrypted";
|
|
2581
|
+
try {
|
|
2582
|
+
JSON.parse(text);
|
|
2583
|
+
return "malformed";
|
|
2584
|
+
} catch {
|
|
2585
|
+
return "malformed";
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2558
2589
|
* Build the field opener, or undefined when the app cannot supply its key.
|
|
2559
2590
|
*
|
|
2560
2591
|
* Split out so the key lookup is testable without a real desktop install, and so
|
|
@@ -2619,6 +2650,13 @@ var WorkBuddyAccountPool = class {
|
|
|
2619
2650
|
client;
|
|
2620
2651
|
refreshMarginMs;
|
|
2621
2652
|
accounts = [];
|
|
2653
|
+
/**
|
|
2654
|
+
* Files the last scan could not read, with the reason.
|
|
2655
|
+
*
|
|
2656
|
+
* Surfaced so "2 accounts" can be told apart from "4 files, 2 unreadable" —
|
|
2657
|
+
* the difference between accounts being gone and files being unopenable.
|
|
2658
|
+
*/
|
|
2659
|
+
skippedFiles = [];
|
|
2622
2660
|
distribution;
|
|
2623
2661
|
/** Cursor for round-robin mode; unused under priority distribution. */
|
|
2624
2662
|
cursor = 0;
|
|
@@ -2720,19 +2758,38 @@ var WorkBuddyAccountPool = class {
|
|
|
2720
2758
|
ignoredIdsInOrder() {
|
|
2721
2759
|
return [...this.ignoredIds];
|
|
2722
2760
|
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Credential files the last scan could not read, with the reason.
|
|
2763
|
+
*
|
|
2764
|
+
* Exposed because a short account list is otherwise indistinguishable from a
|
|
2765
|
+
* broken one: with this, the card can say "2 accounts, 2 files unreadable"
|
|
2766
|
+
* instead of silently showing half a pool.
|
|
2767
|
+
*/
|
|
2768
|
+
skippedFilesInOrder() {
|
|
2769
|
+
return this.skippedFiles;
|
|
2770
|
+
}
|
|
2723
2771
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
2724
2772
|
async scan() {
|
|
2725
2773
|
const found = [];
|
|
2774
|
+
const skipped = [];
|
|
2726
2775
|
for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
|
|
2727
2776
|
if (this.ignoredIds.size > 0) {
|
|
2728
2777
|
const cheapId = await cheapIdentityIdFromFile(file);
|
|
2729
2778
|
if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
|
|
2730
2779
|
}
|
|
2731
2780
|
const credential = await readCredential(file);
|
|
2732
|
-
if (credential === void 0)
|
|
2781
|
+
if (credential === void 0) {
|
|
2782
|
+
skipped.push({
|
|
2783
|
+
path: file,
|
|
2784
|
+
reason: await skipReasonFor(file)
|
|
2785
|
+
});
|
|
2786
|
+
continue;
|
|
2787
|
+
}
|
|
2733
2788
|
if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
|
|
2734
2789
|
found.push(credential);
|
|
2735
2790
|
}
|
|
2791
|
+
this.skippedFiles = skipped;
|
|
2792
|
+
if (skipped.length > 0) this.logger?.warn?.(`dsh-workbuddy-xdpool: ${skipped.length} credential file(s) could not be read; ${found.length} account(s) still loaded. Reasons: ` + [...new Set(skipped.map((s) => s.reason))].join("; "));
|
|
2736
2793
|
const byId = /* @__PURE__ */ new Map();
|
|
2737
2794
|
for (const account of this.accounts) byId.set(account.id, account);
|
|
2738
2795
|
for (const credential of found) {
|
|
@@ -4899,8 +4956,14 @@ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
|
|
|
4899
4956
|
if (next.length !== current.length) await writeIgnoredAccounts(next, path);
|
|
4900
4957
|
return next;
|
|
4901
4958
|
}
|
|
4902
|
-
|
|
4903
|
-
|
|
4959
|
+
fileURLToPath(new URL("..", import.meta.url));
|
|
4960
|
+
(() => {
|
|
4961
|
+
try {
|
|
4962
|
+
return fileURLToPath(new URL("../../node_modules/@deepseek-ai/dsh-llm-pi-ai/", import.meta.url));
|
|
4963
|
+
} catch {
|
|
4964
|
+
return "";
|
|
4965
|
+
}
|
|
4966
|
+
})();
|
|
4904
4967
|
/**
|
|
4905
4968
|
* One region's model-selection schema.
|
|
4906
4969
|
*
|
package/lib/client.js
CHANGED
|
@@ -273,6 +273,19 @@ window.__ModuleLoader__.load({
|
|
|
273
273
|
.dsm-workbuddy-xdpool-model-id{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
274
274
|
.dsm-workbuddy-xdpool-model-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px}
|
|
275
275
|
.dsm-workbuddy-xdpool-model-meta-tag{padding:1px 8px;border-radius:999px;font-size:11px;line-height:16px;background:rgba(174,179,187,.11);color:var(--dsw-alias-label-secondary,#c6c9d0)}
|
|
276
|
+
/* "free from HH:00": quieter than the badge on purpose — the model costs
|
|
277
|
+
credits right now, so it must not read as a promo the user can spend against. */
|
|
278
|
+
.dsm-workbuddy-xdpool-model-meta-later{padding:1px 8px;border-radius:999px;font-size:11px;line-height:16px;border:1px dashed color-mix(in oklab, var(--dsw-alias-label-dimmed,#9aa0a8) 55%, transparent);color:var(--dsw-alias-label-tertiary,#9aa0a8)}
|
|
279
|
+
/* "活动至 10-31": the campaign's end date. Muted — it is context for planning,
|
|
280
|
+
not a claim about the current price. */
|
|
281
|
+
.dsm-workbuddy-xdpool-model-meta-promo{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;opacity:.85}
|
|
282
|
+
/* Unreadable credential files: a warning tint, since it explains a smaller pool
|
|
283
|
+
and "encrypted" is actionable (start the app once). */
|
|
284
|
+
.dsm-workbuddy-xdpool-skipped{margin:6px 0 0;padding:7px 10px;border-radius:8px;background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.12));display:flex;flex-direction:column;gap:3px}
|
|
285
|
+
.dsm-workbuddy-xdpool-skipped-summary{margin:0;font-size:12px;line-height:18px;font-weight:600;color:var(--dsw-alias-state-warning-primary,#d97706)}
|
|
286
|
+
.dsm-workbuddy-xdpool-skipped-row{margin:0;display:flex;gap:8px;align-items:baseline;font-size:11px;line-height:16px;flex-wrap:wrap}
|
|
287
|
+
.dsm-workbuddy-xdpool-skipped-file{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--dsw-alias-label-secondary,#c6c9d0);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
288
|
+
.dsm-workbuddy-xdpool-skipped-reason{color:var(--dsw-alias-label-tertiary,#9aa0a8)}
|
|
276
289
|
.dsm-workbuddy-xdpool-model-cap{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
|
|
277
290
|
/* Model row: checkbox + image toggle + context-budget radios. */
|
|
278
291
|
.dsm-workbuddy-xdpool-model-off{opacity:.55}
|
|
@@ -366,6 +379,104 @@ window.__ModuleLoader__.load({
|
|
|
366
379
|
.dsm-workbuddy-xdpool-reserve-note-ok{color:var(--dsw-alias-state-success-primary,#22a06b)}
|
|
367
380
|
.dsm-workbuddy-xdpool-reserve-note-bad{color:var(--dsw-alias-state-error-primary,#ef4444);font-weight:600}
|
|
368
381
|
`.trim();
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/promo.ts
|
|
384
|
+
/**
|
|
385
|
+
* Promotions in force.
|
|
386
|
+
*
|
|
387
|
+
* Source: the WorkBuddy announcement extending Hy3's free tier and
|
|
388
|
+
* Hy4-preview's night window to **2026-10-31**. Hy3 needs no entry here (its
|
|
389
|
+
* CN price is already zero upstream); only Hy4-preview's time window does,
|
|
390
|
+
* because the gateway charges for it around the clock.
|
|
391
|
+
*
|
|
392
|
+
* When a promotion is extended or a new one starts, edit this table. When one
|
|
393
|
+
* ends, either delete the row or let `until` lapse — both stop the badge.
|
|
394
|
+
*/
|
|
395
|
+
const PROMO_RULES = [{
|
|
396
|
+
idPrefix: "hy4-preview",
|
|
397
|
+
region: "cn",
|
|
398
|
+
hours: [
|
|
399
|
+
23,
|
|
400
|
+
0,
|
|
401
|
+
1,
|
|
402
|
+
2,
|
|
403
|
+
3,
|
|
404
|
+
4,
|
|
405
|
+
5,
|
|
406
|
+
6,
|
|
407
|
+
7
|
|
408
|
+
],
|
|
409
|
+
until: "2026-10-31",
|
|
410
|
+
label: "night"
|
|
411
|
+
}];
|
|
412
|
+
/** `YYYY-MM-DD` for a Date in LOCAL time (the promotions are announced locally). */
|
|
413
|
+
function localDayKey(date) {
|
|
414
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
415
|
+
return `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}`;
|
|
416
|
+
}
|
|
417
|
+
/** Whether a rule is still in force on `date`. */
|
|
418
|
+
function ruleActive(rule, date) {
|
|
419
|
+
return localDayKey(date) <= rule.until;
|
|
420
|
+
}
|
|
421
|
+
/** The first hour the window reopens, searching forward from `hour`. */
|
|
422
|
+
function nextWindowHour(rule, hour) {
|
|
423
|
+
for (let step = 1; step <= 24; step += 1) {
|
|
424
|
+
const candidate = (hour + step) % 24;
|
|
425
|
+
if (rule.hours.includes(candidate)) return candidate;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** The hour the CURRENT window closes, searching forward from `hour`. */
|
|
429
|
+
function windowEndHour(rule, hour) {
|
|
430
|
+
for (let step = 1; step <= 24; step += 1) {
|
|
431
|
+
const candidate = (hour + step) % 24;
|
|
432
|
+
if (!rule.hours.includes(candidate)) return candidate;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* The promotion status of one model at `now`, for one region.
|
|
437
|
+
*
|
|
438
|
+
* `now` is injected so the behaviour is testable without freezing the clock —
|
|
439
|
+
* a time-dependent badge that can only be tested by waiting is a badge nobody
|
|
440
|
+
* tests. `region` is required for the same reason the rules carry one: these
|
|
441
|
+
* campaigns are regional, and applying a domestic rule to the international
|
|
442
|
+
* roster would invent a discount that gateway does not offer.
|
|
443
|
+
*/
|
|
444
|
+
function promoStatusFor(model, now = /* @__PURE__ */ new Date(), region = "cn") {
|
|
445
|
+
if (model.multiplier === 0) return { kind: "free" };
|
|
446
|
+
for (const rule of PROMO_RULES) {
|
|
447
|
+
if (rule.region !== region) continue;
|
|
448
|
+
if (!model.id.startsWith(rule.idPrefix)) continue;
|
|
449
|
+
if (!ruleActive(rule, now)) continue;
|
|
450
|
+
const hour = now.getHours();
|
|
451
|
+
if (rule.hours.includes(hour)) {
|
|
452
|
+
const untilHour = windowEndHour(rule, hour);
|
|
453
|
+
return {
|
|
454
|
+
kind: "night",
|
|
455
|
+
label: rule.label,
|
|
456
|
+
untilHour: untilHour ?? hour,
|
|
457
|
+
promoUntil: rule.until
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
const nextHour = nextWindowHour(rule, hour);
|
|
461
|
+
if (nextHour !== void 0) return {
|
|
462
|
+
kind: "night-later",
|
|
463
|
+
label: rule.label,
|
|
464
|
+
nextHour,
|
|
465
|
+
promoUntil: rule.until
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Whether a model is free RIGHT NOW, for sorting.
|
|
471
|
+
*
|
|
472
|
+
* Only the two "currently free" kinds count. `night-later` deliberately does
|
|
473
|
+
* NOT sort to the top: it costs credits at this moment, and floating a
|
|
474
|
+
* credit-priced model above cheaper ones would misrepresent the list.
|
|
475
|
+
*/
|
|
476
|
+
function isFreeNow(model, now = /* @__PURE__ */ new Date(), region = "cn") {
|
|
477
|
+
const status = promoStatusFor(model, now, region);
|
|
478
|
+
return status?.kind === "free" || status?.kind === "night";
|
|
479
|
+
}
|
|
369
480
|
//#endregion
|
|
370
481
|
//#region src/client/PoolCard.tsx
|
|
371
482
|
/**
|
|
@@ -546,17 +657,23 @@ window.__ModuleLoader__.load({
|
|
|
546
657
|
/**
|
|
547
658
|
* The promo badge for one model, or undefined when it has none.
|
|
548
659
|
*
|
|
549
|
-
*
|
|
550
|
-
* sends a literal `free` tag: the global roster marks its zero-cost models
|
|
551
|
-
* (`hy3`, `hy4-preview-f`, `deepseek-v4.1-flash`) as `credits: "x0.00"`, so
|
|
552
|
-
* waiting for a tag meant the badge NEVER appeared — including on models that
|
|
553
|
-
* genuinely cost nothing.
|
|
660
|
+
* Two sources, because the gateway models one of them and not the other:
|
|
554
661
|
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
662
|
+
* - `free` comes from the CREDIT MULTIPLIER. Neither gateway ever sends a
|
|
663
|
+
* literal `free` tag, but it does price its free models at `credits: "x0.00"`
|
|
664
|
+
* (CN `hy3`), so the multiplier is the honest signal.
|
|
665
|
+
* - the NIGHT window comes from {@link promoStatusFor}, because the gateway
|
|
666
|
+
* charges `x0.29` for `hy4-preview` around the clock and has no field for a
|
|
667
|
+
* time-of-day rule. See that module for why the 14-day newcomer allowance is
|
|
668
|
+
* deliberately not modelled.
|
|
669
|
+
*
|
|
670
|
+
* `region` is threaded through because the campaigns are REGIONAL: the
|
|
671
|
+
* Hy3 / Hy4-preview extension is a domestic promotion, so a global roster must
|
|
672
|
+
* not pick up a discount that gateway does not offer.
|
|
557
673
|
*/
|
|
558
|
-
function tagFor(model) {
|
|
674
|
+
function tagFor(model, now, region) {
|
|
559
675
|
const tags = model.tags ?? [];
|
|
676
|
+
if (promoStatusFor(model, now, region)?.kind === "night") return "night";
|
|
560
677
|
if (tags.includes("free") || model.multiplier === 0) return "free";
|
|
561
678
|
if (tags.includes("limited-free")) return "limited";
|
|
562
679
|
if (tags.includes("night-discount")) return "night";
|
|
@@ -837,6 +954,17 @@ window.__ModuleLoader__.load({
|
|
|
837
954
|
const modelsEditable = settingsWritable;
|
|
838
955
|
const modelsDirty = draft !== void 0 && status !== void 0 && draftIsDirty(status, draft);
|
|
839
956
|
const enabledCount = Object.values(modelDraft).filter((entry) => entry.enabled).length;
|
|
957
|
+
/**
|
|
958
|
+
* One clock reading per render, shared by the sort and every row.
|
|
959
|
+
*
|
|
960
|
+
* Taken once so the list cannot disagree with itself: calling `new Date()`
|
|
961
|
+
* per row would let a model be "free" for the badge and "not free" for the
|
|
962
|
+
* sort if the window boundary happened to fall between two rows.
|
|
963
|
+
*
|
|
964
|
+
* A promotion boundary is not a re-render trigger — the card refreshes on its
|
|
965
|
+
* own poll, which is frequent enough for a badge that changes twice a day.
|
|
966
|
+
*/
|
|
967
|
+
const now = /* @__PURE__ */ new Date();
|
|
840
968
|
const toggleModel = (id) => {
|
|
841
969
|
if (status === void 0) return;
|
|
842
970
|
const base = draft ?? draftFromStatus(status);
|
|
@@ -1383,6 +1511,22 @@ window.__ModuleLoader__.load({
|
|
|
1383
1511
|
}) ?? `${accountCount} account(s) · ${cooling} cooling`
|
|
1384
1512
|
})]
|
|
1385
1513
|
}),
|
|
1514
|
+
status?.skippedFiles === void 0 || status.skippedFiles.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1515
|
+
className: "dsm-workbuddy-xdpool-skipped",
|
|
1516
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1517
|
+
className: "dsm-workbuddy-xdpool-skipped-summary",
|
|
1518
|
+
children: t?.("row.skippedFiles", { count: status.skippedFiles.length }) ?? `${status.skippedFiles.length} credential file(s) could not be read`
|
|
1519
|
+
}), status.skippedFiles.slice(0, 4).map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
1520
|
+
className: "dsm-workbuddy-xdpool-skipped-row",
|
|
1521
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1522
|
+
className: "dsm-workbuddy-xdpool-skipped-file",
|
|
1523
|
+
children: entry.file
|
|
1524
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1525
|
+
className: "dsm-workbuddy-xdpool-skipped-reason",
|
|
1526
|
+
children: entry.reason === "encrypted" ? t?.("row.skippedEncrypted") ?? "encrypted — start WorkBuddy once" : entry.reason === "unreadable" ? t?.("row.skippedUnreadable") ?? "file could not be read" : t?.("row.skippedMalformed") ?? "not a credential file"
|
|
1527
|
+
})]
|
|
1528
|
+
}, entry.file))]
|
|
1529
|
+
}),
|
|
1386
1530
|
(() => {
|
|
1387
1531
|
const active = status?.activeAccountId === void 0 ? void 0 : status.accounts.find((account) => account.id === status.activeAccountId);
|
|
1388
1532
|
if (active === void 0) return null;
|
|
@@ -1510,8 +1654,9 @@ window.__ModuleLoader__.load({
|
|
|
1510
1654
|
})]
|
|
1511
1655
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1512
1656
|
className: "dsm-workbuddy-xdpool-model-list",
|
|
1513
|
-
children: status?.models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelRow, {
|
|
1657
|
+
children: [...status?.models ?? []].sort((a, b) => Number(isFreeNow(b, now, activeRegion)) - Number(isFreeNow(a, now, activeRegion))).map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelRow, {
|
|
1514
1658
|
model,
|
|
1659
|
+
region: activeRegion,
|
|
1515
1660
|
t,
|
|
1516
1661
|
draft: modelDraft[model.id] ?? {
|
|
1517
1662
|
enabled: model.enabled,
|
|
@@ -1865,9 +2010,26 @@ window.__ModuleLoader__.load({
|
|
|
1865
2010
|
* context radios stay disabled rather than pretending an edit took hold. The
|
|
1866
2011
|
* draft lives in the parent, so this component only ever reports intent.
|
|
1867
2012
|
*/
|
|
1868
|
-
function ModelRow({ model, t, draft, editable, onToggle, onToggleImage, onBudget }) {
|
|
1869
|
-
const
|
|
1870
|
-
const
|
|
2013
|
+
function ModelRow({ model, region, t, draft, editable, onToggle, onToggleImage, onBudget }) {
|
|
2014
|
+
const now = /* @__PURE__ */ new Date();
|
|
2015
|
+
const tag = tagFor(model, now, region);
|
|
2016
|
+
const promo = promoStatusFor(model, now, region);
|
|
2017
|
+
const tagText = tag === "free" ? t?.("row.free") ?? "free" : tag === "limited" ? t?.("row.limitedFree") ?? "limited free" : tag === "night" ? t?.("row.nightFreeNow", { time: `${String(promo?.kind === "night" ? promo.untilHour : 0).padStart(2, "0")}:00` }) ?? "free until 08:00" : null;
|
|
2018
|
+
/**
|
|
2019
|
+
* The "cheaper later" hint: the model is on a promotion but the window is
|
|
2020
|
+
* closed. Deliberately NOT a "free" badge — it costs credits at this moment,
|
|
2021
|
+
* and telling the user otherwise would change what they spend.
|
|
2022
|
+
*/
|
|
2023
|
+
const laterHint = promo?.kind === "night-later" ? t?.("row.nightFreeLater", { time: `${String(promo.nextHour).padStart(2, "0")}:00` }) ?? `free from ${String(promo.nextHour).padStart(2, "0")}:00` : null;
|
|
2024
|
+
/**
|
|
2025
|
+
* How long the campaign itself runs, e.g. "活动至 10-31".
|
|
2026
|
+
*
|
|
2027
|
+
* Shown on both the free and the not-yet-free row, because the useful question
|
|
2028
|
+
* is not only "is it free now" but "until when is this offer good at all" —
|
|
2029
|
+
* an extension changes that date, and a user planning around the promotion
|
|
2030
|
+
* needs it visible rather than buried in a changelog.
|
|
2031
|
+
*/
|
|
2032
|
+
const promoUntil = promo === void 0 || promo.kind === "free" ? null : t?.("row.promoUntil", { date: promo.promoUntil.slice(5).replace("-", "-") }) ?? `promo until ${promo.promoUntil}`;
|
|
1871
2033
|
const native = model.nativeContextWindow;
|
|
1872
2034
|
const capped = native > DEFAULT_CONTEXT_BUDGET;
|
|
1873
2035
|
const currentBudget = draft.budget ?? native;
|
|
@@ -1939,6 +2101,14 @@ window.__ModuleLoader__.load({
|
|
|
1939
2101
|
className: "dsm-workbuddy-xdpool-model-meta-tag",
|
|
1940
2102
|
children: tagText
|
|
1941
2103
|
}),
|
|
2104
|
+
laterHint === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2105
|
+
className: "dsm-workbuddy-xdpool-model-meta-later",
|
|
2106
|
+
children: laterHint
|
|
2107
|
+
}),
|
|
2108
|
+
promoUntil === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2109
|
+
className: "dsm-workbuddy-xdpool-model-meta-promo",
|
|
2110
|
+
children: promoUntil
|
|
2111
|
+
}),
|
|
1942
2112
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1943
2113
|
className: "dsm-workbuddy-xdpool-model-cap",
|
|
1944
2114
|
children: t?.("row.modelOutput", { size: formatCapacity(model.maxOutputTokens) }) ?? `out ${formatCapacity(model.maxOutputTokens)}`
|
|
@@ -2094,6 +2264,13 @@ window.__ModuleLoader__.load({
|
|
|
2094
2264
|
"row.free": "free",
|
|
2095
2265
|
"row.limitedFree": "limited free",
|
|
2096
2266
|
"row.nightDiscount": "night",
|
|
2267
|
+
"row.nightFreeNow": "free until {time}",
|
|
2268
|
+
"row.nightFreeLater": "free from {time}",
|
|
2269
|
+
"row.promoUntil": "promo to {date}",
|
|
2270
|
+
"row.skippedFiles": "{count} credential file(s) could not be read",
|
|
2271
|
+
"row.skippedEncrypted": "encrypted — start WorkBuddy once so its key can be read",
|
|
2272
|
+
"row.skippedUnreadable": "file could not be read",
|
|
2273
|
+
"row.skippedMalformed": "not a credential file",
|
|
2097
2274
|
"row.imageCapable": "image input",
|
|
2098
2275
|
"row.rate": "{rate}x credits",
|
|
2099
2276
|
"row.accountsRescan": "Detect accounts again",
|
|
@@ -2239,6 +2416,13 @@ window.__ModuleLoader__.load({
|
|
|
2239
2416
|
"row.free": "免费",
|
|
2240
2417
|
"row.limitedFree": "限量免费",
|
|
2241
2418
|
"row.nightDiscount": "夜间",
|
|
2419
|
+
"row.nightFreeNow": "免费至 {time}",
|
|
2420
|
+
"row.nightFreeLater": "{time} 起免费",
|
|
2421
|
+
"row.promoUntil": "活动至 {date}",
|
|
2422
|
+
"row.skippedFiles": "有 {count} 个凭据文件读不出来",
|
|
2423
|
+
"row.skippedEncrypted": "已加密 —— 启动一次 WorkBuddy 才能取到密钥",
|
|
2424
|
+
"row.skippedUnreadable": "文件读不出来",
|
|
2425
|
+
"row.skippedMalformed": "不是凭据文件",
|
|
2242
2426
|
"row.imageCapable": "图片输入",
|
|
2243
2427
|
"row.rate": "{rate}x 积分",
|
|
2244
2428
|
"row.accountsRescan": "重新检测账号",
|
package/lib/index.d.ts
CHANGED
|
@@ -752,6 +752,18 @@ export declare const WORKBUDDY_LIVE_FILENAME = "workbuddy-desktop.info";
|
|
|
752
752
|
/** Env override for the auth file or its directory. */
|
|
753
753
|
export declare const WORKBUDDY_AUTH_FILE_ENV = "WORKBUDDY_AUTH_FILE";
|
|
754
754
|
/** One parsed WorkBuddy credential. */
|
|
755
|
+
/**
|
|
756
|
+
* A credential file the pool could not turn into an account.
|
|
757
|
+
*
|
|
758
|
+
* Reported (not swallowed) because the count is otherwise a lie: a directory
|
|
759
|
+
* holding four files that yields two accounts looks like two accounts were
|
|
760
|
+
* deleted, when in fact two files were unreadable. Naming the file and the
|
|
761
|
+
* reason is what makes the difference visible.
|
|
762
|
+
*/
|
|
763
|
+
interface WorkBuddySkippedFile {
|
|
764
|
+
path: string;
|
|
765
|
+
reason: 'encrypted' | 'unreadable' | 'malformed';
|
|
766
|
+
}
|
|
755
767
|
interface WorkBuddyCredential {
|
|
756
768
|
accessToken: string;
|
|
757
769
|
refreshToken: string;
|
|
@@ -862,6 +874,13 @@ export declare class WorkBuddyAccountPool {
|
|
|
862
874
|
private readonly client;
|
|
863
875
|
private readonly refreshMarginMs;
|
|
864
876
|
private accounts;
|
|
877
|
+
/**
|
|
878
|
+
* Files the last scan could not read, with the reason.
|
|
879
|
+
*
|
|
880
|
+
* Surfaced so "2 accounts" can be told apart from "4 files, 2 unreadable" —
|
|
881
|
+
* the difference between accounts being gone and files being unopenable.
|
|
882
|
+
*/
|
|
883
|
+
private skippedFiles;
|
|
865
884
|
private distribution;
|
|
866
885
|
/** Cursor for round-robin mode; unused under priority distribution. */
|
|
867
886
|
private cursor;
|
|
@@ -948,6 +967,14 @@ export declare class WorkBuddyAccountPool {
|
|
|
948
967
|
isIgnored(accountId: string): boolean;
|
|
949
968
|
/** Every ignored id currently in force, in insertion order. */
|
|
950
969
|
ignoredIdsInOrder(): string[];
|
|
970
|
+
/**
|
|
971
|
+
* Credential files the last scan could not read, with the reason.
|
|
972
|
+
*
|
|
973
|
+
* Exposed because a short account list is otherwise indistinguishable from a
|
|
974
|
+
* broken one: with this, the card can say "2 accounts, 2 files unreadable"
|
|
975
|
+
* instead of silently showing half a pool.
|
|
976
|
+
*/
|
|
977
|
+
skippedFilesInOrder(): readonly WorkBuddySkippedFile[];
|
|
951
978
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
952
979
|
scan(): Promise<WorkBuddyAccount[]>;
|
|
953
980
|
/** All accounts, cooldown state included. */
|
|
@@ -1345,6 +1372,21 @@ interface PoolWebStatus {
|
|
|
1345
1372
|
catalogUpdatedAt?: string;
|
|
1346
1373
|
/** Why the last fetch failed, when it did. Redacted and length-capped. */
|
|
1347
1374
|
catalogError?: string;
|
|
1375
|
+
/**
|
|
1376
|
+
* Credential files on disk that did NOT become accounts.
|
|
1377
|
+
*
|
|
1378
|
+
* Reported so the account count can be trusted: without this, a directory
|
|
1379
|
+
* holding four files that yields two accounts looks like two accounts were
|
|
1380
|
+
* deleted, when really two files could not be opened (most often an encrypted
|
|
1381
|
+
* credential the desktop app was not running to unlock).
|
|
1382
|
+
*/
|
|
1383
|
+
skippedFiles?: readonly PoolWebSkippedFile[];
|
|
1384
|
+
}
|
|
1385
|
+
/** A credential file the pool could not read. */
|
|
1386
|
+
interface PoolWebSkippedFile {
|
|
1387
|
+
/** Basename only; the full path lives in the desktop app's auth directory. */
|
|
1388
|
+
file: string;
|
|
1389
|
+
reason: 'encrypted' | 'unreadable' | 'malformed';
|
|
1348
1390
|
}
|
|
1349
1391
|
/** How a region's model list was obtained. */
|
|
1350
1392
|
type PoolWebCatalogSource = 'live' | 'fallback';
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
2
3
|
import z from "@deepseek-ai/schemastery";
|
|
3
4
|
import { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -11,6 +12,7 @@ import { resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/d
|
|
|
11
12
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
12
13
|
import { createServer } from "node:http";
|
|
13
14
|
import { Readable } from "node:stream";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
14
16
|
//#region src/upstream.ts
|
|
15
17
|
/**
|
|
16
18
|
* Backoff between catalog-fetch attempts.
|
|
@@ -2454,10 +2456,6 @@ var WorkBuddyEncryptedCredentialError = class extends Error {
|
|
|
2454
2456
|
this.name = "WorkBuddyEncryptedCredentialError";
|
|
2455
2457
|
}
|
|
2456
2458
|
};
|
|
2457
|
-
/** True when a thrown value is the encrypted-credential marker (cross-bundle safe). */
|
|
2458
|
-
function isEncryptedCredentialError(value) {
|
|
2459
|
-
return typeof value === "object" && value !== null && value.code === "ENCRYPTED_CREDENTIAL";
|
|
2460
|
-
}
|
|
2461
2459
|
function decryptableString(value, decrypt) {
|
|
2462
2460
|
if (typeof value === "string") return {
|
|
2463
2461
|
value,
|
|
@@ -2611,6 +2609,18 @@ async function authFilesIn(dir) {
|
|
|
2611
2609
|
files.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
|
|
2612
2610
|
return files.map((name) => join(dir, name));
|
|
2613
2611
|
}
|
|
2612
|
+
/**
|
|
2613
|
+
* One credential file, or undefined when it cannot be used.
|
|
2614
|
+
*
|
|
2615
|
+
* Returns undefined — rather than throwing — for every failure mode, so ONE bad
|
|
2616
|
+
* file cannot empty the pool. That is the whole point: an auth directory blends
|
|
2617
|
+
* plain files, encrypted files, half-written files and files from an app version
|
|
2618
|
+
* this plugin cannot read, and a scan that aborts on the first unusable one
|
|
2619
|
+
* reports "you have 2 accounts" when it means "I could not open the other two".
|
|
2620
|
+
*
|
|
2621
|
+
* The caller surfaces the ones it skipped, so the count never silently
|
|
2622
|
+
* disagrees with what is on disk.
|
|
2623
|
+
*/
|
|
2614
2624
|
async function readCredential(path) {
|
|
2615
2625
|
let text;
|
|
2616
2626
|
try {
|
|
@@ -2621,8 +2631,7 @@ async function readCredential(path) {
|
|
|
2621
2631
|
const decrypt = text.includes("\"$wbEncrypted\"") ? await encryptedFieldOpener() : void 0;
|
|
2622
2632
|
try {
|
|
2623
2633
|
return parseWorkBuddyAuth(text, path, decrypt);
|
|
2624
|
-
} catch
|
|
2625
|
-
if (isEncryptedCredentialError(error)) throw error;
|
|
2634
|
+
} catch {
|
|
2626
2635
|
return;
|
|
2627
2636
|
}
|
|
2628
2637
|
}
|
|
@@ -2662,6 +2671,29 @@ async function cheapIdentityIdFromFile(path) {
|
|
|
2662
2671
|
}
|
|
2663
2672
|
}
|
|
2664
2673
|
/**
|
|
2674
|
+
* Why a file could not become an account.
|
|
2675
|
+
*
|
|
2676
|
+
* Distinguishing "encrypted" matters most: it is actionable (start the desktop
|
|
2677
|
+
* app, or set the executable override), whereas "malformed" means the file is
|
|
2678
|
+
* simply not a credential. Reporting them alike would send the user looking for
|
|
2679
|
+
* a fix that cannot work.
|
|
2680
|
+
*/
|
|
2681
|
+
async function skipReasonFor(path) {
|
|
2682
|
+
let text;
|
|
2683
|
+
try {
|
|
2684
|
+
text = await readFile(path, "utf8");
|
|
2685
|
+
} catch {
|
|
2686
|
+
return "unreadable";
|
|
2687
|
+
}
|
|
2688
|
+
if (text.includes("\"$wbEncrypted\"")) return "encrypted";
|
|
2689
|
+
try {
|
|
2690
|
+
JSON.parse(text);
|
|
2691
|
+
return "malformed";
|
|
2692
|
+
} catch {
|
|
2693
|
+
return "malformed";
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
/**
|
|
2665
2697
|
* Build the field opener, or undefined when the app cannot supply its key.
|
|
2666
2698
|
*
|
|
2667
2699
|
* Split out so the key lookup is testable without a real desktop install, and so
|
|
@@ -2726,6 +2758,13 @@ var WorkBuddyAccountPool = class {
|
|
|
2726
2758
|
client;
|
|
2727
2759
|
refreshMarginMs;
|
|
2728
2760
|
accounts = [];
|
|
2761
|
+
/**
|
|
2762
|
+
* Files the last scan could not read, with the reason.
|
|
2763
|
+
*
|
|
2764
|
+
* Surfaced so "2 accounts" can be told apart from "4 files, 2 unreadable" —
|
|
2765
|
+
* the difference between accounts being gone and files being unopenable.
|
|
2766
|
+
*/
|
|
2767
|
+
skippedFiles = [];
|
|
2729
2768
|
distribution;
|
|
2730
2769
|
/** Cursor for round-robin mode; unused under priority distribution. */
|
|
2731
2770
|
cursor = 0;
|
|
@@ -2827,19 +2866,38 @@ var WorkBuddyAccountPool = class {
|
|
|
2827
2866
|
ignoredIdsInOrder() {
|
|
2828
2867
|
return [...this.ignoredIds];
|
|
2829
2868
|
}
|
|
2869
|
+
/**
|
|
2870
|
+
* Credential files the last scan could not read, with the reason.
|
|
2871
|
+
*
|
|
2872
|
+
* Exposed because a short account list is otherwise indistinguishable from a
|
|
2873
|
+
* broken one: with this, the card can say "2 accounts, 2 files unreadable"
|
|
2874
|
+
* instead of silently showing half a pool.
|
|
2875
|
+
*/
|
|
2876
|
+
skippedFilesInOrder() {
|
|
2877
|
+
return this.skippedFiles;
|
|
2878
|
+
}
|
|
2830
2879
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
2831
2880
|
async scan() {
|
|
2832
2881
|
const found = [];
|
|
2882
|
+
const skipped = [];
|
|
2833
2883
|
for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
|
|
2834
2884
|
if (this.ignoredIds.size > 0) {
|
|
2835
2885
|
const cheapId = await cheapIdentityIdFromFile(file);
|
|
2836
2886
|
if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
|
|
2837
2887
|
}
|
|
2838
2888
|
const credential = await readCredential(file);
|
|
2839
|
-
if (credential === void 0)
|
|
2889
|
+
if (credential === void 0) {
|
|
2890
|
+
skipped.push({
|
|
2891
|
+
path: file,
|
|
2892
|
+
reason: await skipReasonFor(file)
|
|
2893
|
+
});
|
|
2894
|
+
continue;
|
|
2895
|
+
}
|
|
2840
2896
|
if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
|
|
2841
2897
|
found.push(credential);
|
|
2842
2898
|
}
|
|
2899
|
+
this.skippedFiles = skipped;
|
|
2900
|
+
if (skipped.length > 0) this.logger?.warn?.(`dsh-workbuddy-xdpool: ${skipped.length} credential file(s) could not be read; ${found.length} account(s) still loaded. Reasons: ` + [...new Set(skipped.map((s) => s.reason))].join("; "));
|
|
2843
2901
|
const byId = /* @__PURE__ */ new Map();
|
|
2844
2902
|
for (const account of this.accounts) byId.set(account.id, account);
|
|
2845
2903
|
for (const credential of found) {
|
|
@@ -6275,7 +6333,11 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
6275
6333
|
ignored: deps.ignoredAccounts?.() ?? [],
|
|
6276
6334
|
catalogSource: deps.catalogs[region].currentSource(),
|
|
6277
6335
|
...deps.catalogs[region].catalogUpdatedAt() === void 0 ? {} : { catalogUpdatedAt: deps.catalogs[region].catalogUpdatedAt() },
|
|
6278
|
-
...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) }
|
|
6336
|
+
...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) },
|
|
6337
|
+
...deps.pool.skippedFilesInOrder().length === 0 ? {} : { skippedFiles: deps.pool.skippedFilesInOrder().map((entry) => ({
|
|
6338
|
+
file: basename(entry.path),
|
|
6339
|
+
reason: entry.reason
|
|
6340
|
+
})) }
|
|
6279
6341
|
};
|
|
6280
6342
|
}
|
|
6281
6343
|
/**
|
|
@@ -6682,6 +6744,95 @@ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
|
|
|
6682
6744
|
return next;
|
|
6683
6745
|
}
|
|
6684
6746
|
//#endregion
|
|
6747
|
+
//#region pi-ai-generation.ts
|
|
6748
|
+
/**
|
|
6749
|
+
* Cross-generation guard for `@earendil-works/pi-ai`.
|
|
6750
|
+
*
|
|
6751
|
+
* The failure this exists to catch: the plugin assembles its provider with ONE
|
|
6752
|
+
* copy of pi-ai while the host's `PiAiAdapter` consumes the event stream with
|
|
6753
|
+
* ANOTHER. The two generations disagree on the shape of the terminal message,
|
|
6754
|
+
* and the seam between them throws `Cannot read properties of undefined
|
|
6755
|
+
* (reading 'length')` inside the host adapter — which the host then classifies
|
|
6756
|
+
* as a non-retryable `PI_AI_ERROR`. From the user's side it is "every turn
|
|
6757
|
+
* fails, immediately, with no content and no useful error".
|
|
6758
|
+
*
|
|
6759
|
+
* A `package.json` range cannot prevent this. The old peer range was
|
|
6760
|
+
* `>=0.82.1 <0.85.0` while the host shipped 0.87.x: the upper bound excluded the
|
|
6761
|
+
* host's generation outright, which turned a cross-generation mix into a
|
|
6762
|
+
* legitimate install. Ranges describe what a package tolerates; they cannot
|
|
6763
|
+
* describe what the OTHER resolved copy on the same machine happens to be.
|
|
6764
|
+
*
|
|
6765
|
+
* So this checks the resolved reality at load time and says so, loudly, in the
|
|
6766
|
+
* log. It does NOT refuse to start: a version mismatch might well be benign, and
|
|
6767
|
+
* a plugin that hard-fails on a guess would be worse than one that warns.
|
|
6768
|
+
*
|
|
6769
|
+
* @module dsh-workbuddy-xdpool/pi-ai-generation
|
|
6770
|
+
*/
|
|
6771
|
+
/** The package whose generation must agree between plugin and host. */
|
|
6772
|
+
const PI_AI_PACKAGE = "@earendil-works/pi-ai";
|
|
6773
|
+
/**
|
|
6774
|
+
* The major.minor of a version string, which is what "generation" means here.
|
|
6775
|
+
*
|
|
6776
|
+
* Patch differences are expected and harmless; two copies differing in
|
|
6777
|
+
* minor — 0.85 vs 0.87 — is exactly the split that broke the adapter seam.
|
|
6778
|
+
*/
|
|
6779
|
+
function generationOf(version) {
|
|
6780
|
+
const trimmed = version.trim();
|
|
6781
|
+
const parts = /^(\d+)\.(\d+)/u.exec(trimmed);
|
|
6782
|
+
if (parts === null || parts === void 0 || parts[1] === void 0 || parts[2] === void 0) return trimmed;
|
|
6783
|
+
return `${parts[1]}.${parts[2]}`;
|
|
6784
|
+
}
|
|
6785
|
+
/**
|
|
6786
|
+
* Read the version of a resolved pi-ai, starting the lookup from `fromDir`.
|
|
6787
|
+
*
|
|
6788
|
+
* Using `require.resolve` with an explicit anchor is what makes the two sides
|
|
6789
|
+
* distinguishable: resolving plainly would return whichever copy this module
|
|
6790
|
+
* happens to see, the same one for both, and the comparison would always pass.
|
|
6791
|
+
*/
|
|
6792
|
+
function piAiGenerationFrom(fromDir) {
|
|
6793
|
+
try {
|
|
6794
|
+
const require_ = createRequire(`${fromDir.replace(/[\\/]+$/u, "")}/`);
|
|
6795
|
+
const pkgPath = require_.resolve(`${PI_AI_PACKAGE}/package.json`);
|
|
6796
|
+
const pkg = require_(pkgPath);
|
|
6797
|
+
if (typeof pkg.version !== "string") return void 0;
|
|
6798
|
+
return {
|
|
6799
|
+
version: pkg.version,
|
|
6800
|
+
resolvedFrom: pkgPath
|
|
6801
|
+
};
|
|
6802
|
+
} catch {
|
|
6803
|
+
return;
|
|
6804
|
+
}
|
|
6805
|
+
}
|
|
6806
|
+
/**
|
|
6807
|
+
* Compare the copy the plugin assembled with against the copy the host adapter
|
|
6808
|
+
* will use.
|
|
6809
|
+
*
|
|
6810
|
+
* `hostDir` should be a directory inside the host install (its own
|
|
6811
|
+
* `node_modules`), so the two resolutions walk different trees.
|
|
6812
|
+
*/
|
|
6813
|
+
function checkPiAiGeneration(pluginDir, hostDir) {
|
|
6814
|
+
const plugin = piAiGenerationFrom(pluginDir);
|
|
6815
|
+
const host = piAiGenerationFrom(hostDir);
|
|
6816
|
+
if (plugin === void 0 || host === void 0) return { kind: "unknown" };
|
|
6817
|
+
if (generationOf(plugin.version) === generationOf(host.version)) return {
|
|
6818
|
+
kind: "aligned",
|
|
6819
|
+
generation: generationOf(plugin.version)
|
|
6820
|
+
};
|
|
6821
|
+
return {
|
|
6822
|
+
kind: "mismatched",
|
|
6823
|
+
plugin,
|
|
6824
|
+
host
|
|
6825
|
+
};
|
|
6826
|
+
}
|
|
6827
|
+
/**
|
|
6828
|
+
* A warning message for a mismatched pair, or undefined when there is nothing
|
|
6829
|
+
* to say.
|
|
6830
|
+
*/
|
|
6831
|
+
function piAiMismatchMessage(check) {
|
|
6832
|
+
if (check.kind !== "mismatched") return void 0;
|
|
6833
|
+
return `dsh-workbuddy-xdpool: @earendil-works/pi-ai generation mismatch — this plugin assembled its provider with ${check.plugin.version} while the host adapter resolves ${check.host.version}. Two generations on one call chain produce a TypeError at the adapter seam ("Cannot read properties of undefined"), which the host reports as a non-retryable PI_AI_ERROR, so every turn fails with no content. Align the two by pinning pi-ai to the host generation (for example a pnpm \`overrides\` entry in the profile) and restart DSH.`;
|
|
6834
|
+
}
|
|
6835
|
+
//#endregion
|
|
6685
6836
|
//#region src/index.ts
|
|
6686
6837
|
/**
|
|
6687
6838
|
* Host-side plugin entry. Registers the `workbuddy-xdpool` provider into the
|
|
@@ -6690,6 +6841,28 @@ async function unignoreAccount(accountId, path = ignoredIdsPath()) {
|
|
|
6690
6841
|
*
|
|
6691
6842
|
* @module dsh-workbuddy-xdpool/index
|
|
6692
6843
|
*/
|
|
6844
|
+
/**
|
|
6845
|
+
* This plugin's install root, the tree its own provider is assembled from.
|
|
6846
|
+
*
|
|
6847
|
+
* Two directories up from `lib/` — the built bundle sits in `lib/`, so the
|
|
6848
|
+
* package root is its parent. Used as one side of the pi-ai generation check.
|
|
6849
|
+
*/
|
|
6850
|
+
const PLUGIN_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
6851
|
+
/**
|
|
6852
|
+
* The host's own module directory, the tree its `PiAiAdapter` resolves from.
|
|
6853
|
+
*
|
|
6854
|
+
* Reached from the host package this plugin is loaded by: walking up from a
|
|
6855
|
+
* `@deepseek-ai/dsh-llm-pi-ai` import lands in the host's `node_modules`, which
|
|
6856
|
+
* is where the HOST generation actually lives. The plugin's own tree is the
|
|
6857
|
+
* other side; comparing one tree with itself would always agree.
|
|
6858
|
+
*/
|
|
6859
|
+
const HOST_MODULE_ROOT = (() => {
|
|
6860
|
+
try {
|
|
6861
|
+
return fileURLToPath(new URL("../../node_modules/@deepseek-ai/dsh-llm-pi-ai/", import.meta.url));
|
|
6862
|
+
} catch {
|
|
6863
|
+
return "";
|
|
6864
|
+
}
|
|
6865
|
+
})();
|
|
6693
6866
|
/** Stable Cordis plugin name. */
|
|
6694
6867
|
const name = "llm-workbuddy-xdpool";
|
|
6695
6868
|
/** The model registry required before the provider can register. */
|
|
@@ -6878,12 +7051,30 @@ function createCore(logger) {
|
|
|
6878
7051
|
};
|
|
6879
7052
|
}
|
|
6880
7053
|
/**
|
|
7054
|
+
* Log a warning when the plugin and the host resolve different pi-ai
|
|
7055
|
+
* generations.
|
|
7056
|
+
*
|
|
7057
|
+
* Never throws and never blocks startup: the check is a filesystem lookup, and
|
|
7058
|
+
* a plugin that refused to load because of a version guess would be worse than
|
|
7059
|
+
* one that merely warns. The two directories are THIS plugin's install root and
|
|
7060
|
+
* the host's own module directory, so the two resolutions walk different trees —
|
|
7061
|
+
* resolving from one place only would compare a copy with itself and always
|
|
7062
|
+
* agree.
|
|
7063
|
+
*/
|
|
7064
|
+
function warnOnPiAiGenerationMismatch(ctx) {
|
|
7065
|
+
try {
|
|
7066
|
+
const message = piAiMismatchMessage(checkPiAiGeneration(PLUGIN_ROOT, HOST_MODULE_ROOT));
|
|
7067
|
+
if (message !== void 0) ctx.logger.warn(message);
|
|
7068
|
+
} catch {}
|
|
7069
|
+
}
|
|
7070
|
+
/**
|
|
6881
7071
|
* Start the loopback endpoint, register the `workbuddy-xdpool` provider, and
|
|
6882
7072
|
* discover accounts. The provider registers only after `shim.ready` resolves,
|
|
6883
7073
|
* because its models read the shim origin at construction time.
|
|
6884
7074
|
*/
|
|
6885
7075
|
function apply(ctx, config = {}) {
|
|
6886
7076
|
const core = createCore(ctx.logger);
|
|
7077
|
+
warnOnPiAiGenerationMismatch(ctx);
|
|
6887
7078
|
const ignoredPath = ignoredIdsPath();
|
|
6888
7079
|
let ignoredAccounts = readIgnoredAccountsSync(ignoredPath);
|
|
6889
7080
|
core.pool.applyIgnored(ignoredAccounts.map((entry) => entry.id));
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-workbuddy-xdpool",
|
|
3
3
|
"displayName": "DSH WorkBuddy XD Pool",
|
|
4
4
|
"description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
|
|
5
|
-
"version": "1.7.
|
|
5
|
+
"version": "1.7.6",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"@deepseek-ai/dsh-llm-pi-ai": ">=0.1.1-rc.1 <0.2.0",
|
|
104
104
|
"@deepseek-ai/dsh-settings": ">=0.1.1-rc.1 <0.2.0",
|
|
105
105
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
106
|
-
"@earendil-works/pi-ai": ">=0.82.1
|
|
106
|
+
"@earendil-works/pi-ai": ">=0.82.1"
|
|
107
107
|
},
|
|
108
108
|
"peerDependenciesMeta": {
|
|
109
109
|
"@deepseek-ai/dsh-llm": {
|