dsh-workbuddy-xdpool 1.7.5 → 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 +44 -0
- package/lib/bin.js +72 -9
- package/lib/client.js +31 -0
- 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,50 @@
|
|
|
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
|
+
|
|
7
51
|
## 1.7.5 — 模型列表会告诉你「现在用哪个不花钱」
|
|
8
52
|
|
|
9
53
|
> Hy4 preview 晚上 23 点到早上 8 点免费,Hy3 一直在免费期 —— 现在卡片上直接看得出来。
|
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
|
@@ -279,6 +279,13 @@ window.__ModuleLoader__.load({
|
|
|
279
279
|
/* "活动至 10-31": the campaign's end date. Muted — it is context for planning,
|
|
280
280
|
not a claim about the current price. */
|
|
281
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)}
|
|
282
289
|
.dsm-workbuddy-xdpool-model-cap{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
|
|
283
290
|
/* Model row: checkbox + image toggle + context-budget radios. */
|
|
284
291
|
.dsm-workbuddy-xdpool-model-off{opacity:.55}
|
|
@@ -1504,6 +1511,22 @@ window.__ModuleLoader__.load({
|
|
|
1504
1511
|
}) ?? `${accountCount} account(s) · ${cooling} cooling`
|
|
1505
1512
|
})]
|
|
1506
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
|
+
}),
|
|
1507
1530
|
(() => {
|
|
1508
1531
|
const active = status?.activeAccountId === void 0 ? void 0 : status.accounts.find((account) => account.id === status.activeAccountId);
|
|
1509
1532
|
if (active === void 0) return null;
|
|
@@ -2244,6 +2267,10 @@ window.__ModuleLoader__.load({
|
|
|
2244
2267
|
"row.nightFreeNow": "free until {time}",
|
|
2245
2268
|
"row.nightFreeLater": "free from {time}",
|
|
2246
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",
|
|
2247
2274
|
"row.imageCapable": "image input",
|
|
2248
2275
|
"row.rate": "{rate}x credits",
|
|
2249
2276
|
"row.accountsRescan": "Detect accounts again",
|
|
@@ -2392,6 +2419,10 @@ window.__ModuleLoader__.load({
|
|
|
2392
2419
|
"row.nightFreeNow": "免费至 {time}",
|
|
2393
2420
|
"row.nightFreeLater": "{time} 起免费",
|
|
2394
2421
|
"row.promoUntil": "活动至 {date}",
|
|
2422
|
+
"row.skippedFiles": "有 {count} 个凭据文件读不出来",
|
|
2423
|
+
"row.skippedEncrypted": "已加密 —— 启动一次 WorkBuddy 才能取到密钥",
|
|
2424
|
+
"row.skippedUnreadable": "文件读不出来",
|
|
2425
|
+
"row.skippedMalformed": "不是凭据文件",
|
|
2395
2426
|
"row.imageCapable": "图片输入",
|
|
2396
2427
|
"row.rate": "{rate}x 积分",
|
|
2397
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": {
|