dsh-workbuddy-xdpool 1.6.1 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +285 -0
- package/lib/bin.js +441 -51
- package/lib/client.js +764 -425
- package/lib/index.d.ts +209 -4
- package/lib/index.js +571 -83
- package/package.json +3 -3
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { basename, dirname, join, resolve } from "node:path";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
4
|
+
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { execFile, execFileSync } from "node:child_process";
|
|
7
7
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
@@ -443,6 +443,7 @@ function parseUpstreamModel(value) {
|
|
|
443
443
|
const reasoning = parseReasoning(raw["reasoning"]);
|
|
444
444
|
const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
|
|
445
445
|
const supportsImages = typeof raw["supportsImages"] === "boolean" ? raw["supportsImages"] : void 0;
|
|
446
|
+
const tags = Array.isArray(raw["tags"]) ? raw["tags"].filter((tag) => typeof tag === "string" && tag !== "") : void 0;
|
|
446
447
|
return {
|
|
447
448
|
id,
|
|
448
449
|
name,
|
|
@@ -453,7 +454,8 @@ function parseUpstreamModel(value) {
|
|
|
453
454
|
...descriptionZh === void 0 ? {} : { descriptionZh },
|
|
454
455
|
...descriptionEn === void 0 ? {} : { descriptionEn },
|
|
455
456
|
...supportsToolCall === void 0 ? {} : { supportsToolCall },
|
|
456
|
-
...supportsImages === void 0 ? {} : { supportsImages }
|
|
457
|
+
...supportsImages === void 0 ? {} : { supportsImages },
|
|
458
|
+
...tags === void 0 || tags.length === 0 ? {} : { tags }
|
|
457
459
|
};
|
|
458
460
|
}
|
|
459
461
|
/**
|
|
@@ -1747,8 +1749,20 @@ const AAD_DOMAIN = Buffer.from("WB-AAD\0", "ascii");
|
|
|
1747
1749
|
const SYMMETRIC_SCHEME = "sym-v1";
|
|
1748
1750
|
/** Env override pointing at the WorkBuddy desktop executable. */
|
|
1749
1751
|
const WORKBUDDY_APP_EXECUTABLE_ENV = "WORKBUDDY_APP_EXECUTABLE";
|
|
1750
|
-
/**
|
|
1751
|
-
|
|
1752
|
+
/**
|
|
1753
|
+
* How long the app is given to answer with its key payload.
|
|
1754
|
+
*
|
|
1755
|
+
* 30s, not 10s: the child is the WorkBuddy Electron binary running as plain
|
|
1756
|
+
* Node, and its FIRST spawn on a cold machine costs several seconds on its own
|
|
1757
|
+
* (measured 4.5s here) before the endpoint security stack has warmed its scan
|
|
1758
|
+
* cache. Under load — a concurrent `pnpm install` from the market, a running
|
|
1759
|
+
* full-disk scan — that first spawn crosses a 10s budget, the fetch rejects,
|
|
1760
|
+
* `readAtRestKey` returns undefined, and every encrypted credential then reads
|
|
1761
|
+
* as `WorkBuddyEncryptedCredentialError` until the 60s negative cache expires.
|
|
1762
|
+
* A successful fetch is cached for the process lifetime, so the longer budget
|
|
1763
|
+
* is only ever paid once per process, and only when the app is present but slow.
|
|
1764
|
+
*/
|
|
1765
|
+
const KEY_FETCH_TIMEOUT_MS = 3e4;
|
|
1752
1766
|
/**
|
|
1753
1767
|
* Executable file names the desktop app ships under, in probe order.
|
|
1754
1768
|
*
|
|
@@ -2003,6 +2017,7 @@ function windowsFallbackAppPaths(env) {
|
|
|
2003
2017
|
}
|
|
2004
2018
|
roots.add(join(drive, "Program Files"));
|
|
2005
2019
|
roots.add(join(drive, "Program Files (x86)"));
|
|
2020
|
+
roots.add(drive);
|
|
2006
2021
|
}
|
|
2007
2022
|
for (const root of roots) for (const name of APP_EXECUTABLE_NAMES) {
|
|
2008
2023
|
out.push(join(root, "WorkBuddy", name));
|
|
@@ -2415,6 +2430,41 @@ async function readCredential(path) {
|
|
|
2415
2430
|
}
|
|
2416
2431
|
}
|
|
2417
2432
|
/**
|
|
2433
|
+
* The pool id a document would receive, WITHOUT decrypting anything.
|
|
2434
|
+
*
|
|
2435
|
+
* Used to honour the ignore list before the at-rest key lookup runs: `uin` and
|
|
2436
|
+
* `uid` are read as plain strings by {@link parseWorkBuddyAuth}, while
|
|
2437
|
+
* `nickname` is commonly encrypted — so this returns undefined for an account
|
|
2438
|
+
* whose identity lives only in the encrypted nickname, and the caller falls
|
|
2439
|
+
* back to the full parse for those.
|
|
2440
|
+
*/
|
|
2441
|
+
function cheapIdentityId(text) {
|
|
2442
|
+
let parsed;
|
|
2443
|
+
try {
|
|
2444
|
+
parsed = JSON.parse(text);
|
|
2445
|
+
} catch {
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2448
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
2449
|
+
const document = parsed;
|
|
2450
|
+
const identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : document;
|
|
2451
|
+
const uin = typeof identity["uin"] === "string" && identity["uin"] !== "" ? identity["uin"] : void 0;
|
|
2452
|
+
const uid = typeof identity["uid"] === "string" && identity["uid"] !== "" ? identity["uid"] : void 0;
|
|
2453
|
+
if (uin === void 0 && uid === void 0) return void 0;
|
|
2454
|
+
return workbuddyAccountId({
|
|
2455
|
+
...uin === void 0 ? {} : { uin },
|
|
2456
|
+
...uid === void 0 ? {} : { uid }
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
/** {@link cheapIdentityId} for a file path; undefined when it cannot be read. */
|
|
2460
|
+
async function cheapIdentityIdFromFile(path) {
|
|
2461
|
+
try {
|
|
2462
|
+
return cheapIdentityId(await readFile(path, "utf8"));
|
|
2463
|
+
} catch {
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2418
2468
|
* Build the field opener, or undefined when the app cannot supply its key.
|
|
2419
2469
|
*
|
|
2420
2470
|
* Split out so the key lookup is testable without a real desktop install, and so
|
|
@@ -2493,6 +2543,21 @@ var WorkBuddyAccountPool = class {
|
|
|
2493
2543
|
*/
|
|
2494
2544
|
disabledIds = /* @__PURE__ */ new Set();
|
|
2495
2545
|
/**
|
|
2546
|
+
* Account ids the user threw out of the pool for good.
|
|
2547
|
+
*
|
|
2548
|
+
* Enforced BEFORE the credential is parsed: `scan()` skips a file whose
|
|
2549
|
+
* identity is already ignored, so an ignored account costs no at-rest key
|
|
2550
|
+
* lookup (which spawns the desktop app on 5.6.0+) and cannot re-enter the pool
|
|
2551
|
+
* when the app writes a fresh sign-in for it. That is the difference from
|
|
2552
|
+
* {@link disabledIds}, which only filters at pick time and leaves the account
|
|
2553
|
+
* listed, readable and re-discoverable.
|
|
2554
|
+
*
|
|
2555
|
+
* The set is supplied by the host from the plugin's own ignore file, and is
|
|
2556
|
+
* replaced wholesale on every {@link applyIgnored} so removing an entry takes
|
|
2557
|
+
* effect on the next scan without a restart.
|
|
2558
|
+
*/
|
|
2559
|
+
ignoredIds = /* @__PURE__ */ new Set();
|
|
2560
|
+
/**
|
|
2496
2561
|
* Per-account credit floor, keyed by account id. 0 (or absent) means "spend
|
|
2497
2562
|
* it all".
|
|
2498
2563
|
*
|
|
@@ -2544,12 +2609,39 @@ var WorkBuddyAccountPool = class {
|
|
|
2544
2609
|
if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
|
|
2545
2610
|
if (options.creditReserves !== void 0) this.setCreditReserves(options.creditReserves);
|
|
2546
2611
|
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Replace the permanent ignore list.
|
|
2614
|
+
*
|
|
2615
|
+
* Also drops any already-discovered account that is now ignored, so the change
|
|
2616
|
+
* is visible without waiting for the next scan: the card refreshes its status
|
|
2617
|
+
* document right after the write, and an account still sitting in `accounts`
|
|
2618
|
+
* would keep showing up there.
|
|
2619
|
+
*/
|
|
2620
|
+
applyIgnored(ids) {
|
|
2621
|
+
this.ignoredIds = new Set(ids);
|
|
2622
|
+
if (this.ignoredIds.size === 0) return;
|
|
2623
|
+
this.accounts = this.accounts.filter((account) => !this.ignoredIds.has(account.id));
|
|
2624
|
+
}
|
|
2625
|
+
/** Whether this account has been thrown out of the pool for good. */
|
|
2626
|
+
isIgnored(accountId) {
|
|
2627
|
+
return this.ignoredIds.has(accountId);
|
|
2628
|
+
}
|
|
2629
|
+
/** Every ignored id currently in force, in insertion order. */
|
|
2630
|
+
ignoredIdsInOrder() {
|
|
2631
|
+
return [...this.ignoredIds];
|
|
2632
|
+
}
|
|
2547
2633
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
2548
2634
|
async scan() {
|
|
2549
2635
|
const found = [];
|
|
2550
2636
|
for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
|
|
2637
|
+
if (this.ignoredIds.size > 0) {
|
|
2638
|
+
const cheapId = await cheapIdentityIdFromFile(file);
|
|
2639
|
+
if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
|
|
2640
|
+
}
|
|
2551
2641
|
const credential = await readCredential(file);
|
|
2552
|
-
if (credential
|
|
2642
|
+
if (credential === void 0) continue;
|
|
2643
|
+
if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
|
|
2644
|
+
found.push(credential);
|
|
2553
2645
|
}
|
|
2554
2646
|
const byId = /* @__PURE__ */ new Map();
|
|
2555
2647
|
for (const account of this.accounts) byId.set(account.id, account);
|
|
@@ -2890,28 +2982,44 @@ ${new Date(until).toISOString()}
|
|
|
2890
2982
|
};
|
|
2891
2983
|
//#endregion
|
|
2892
2984
|
//#region src/catalog.ts
|
|
2893
|
-
/**
|
|
2985
|
+
/**
|
|
2986
|
+
* Static fallback used before the first live catalog fetch, and whenever the
|
|
2987
|
+
* upstream cannot be reached.
|
|
2988
|
+
*
|
|
2989
|
+
* The multipliers are carried on purpose. Without them the provider's model
|
|
2990
|
+
* picker silently loses every rate and every free badge the moment the live
|
|
2991
|
+
* fetch fails — which reads to the user as "the plugin broke my model list"
|
|
2992
|
+
* rather than "the upstream is unreachable". The values are the ones the two
|
|
2993
|
+
* gateways actually advertise for these ids (`credits: "x0.79 credits"` and so
|
|
2994
|
+
* on), so a fallback row looks the same as a live one.
|
|
2995
|
+
*
|
|
2996
|
+
* `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
|
|
2997
|
+
* which is what turns on the free badge.
|
|
2998
|
+
*/
|
|
2894
2999
|
const FALLBACK_WORKBUDDY_MODELS = [
|
|
2895
3000
|
{
|
|
2896
3001
|
id: "glm-5.3",
|
|
2897
3002
|
name: "GLM-5.3",
|
|
2898
3003
|
contextWindow: 2e5,
|
|
2899
3004
|
maxOutputTokens: 128e3,
|
|
2900
|
-
supportsImages: true
|
|
3005
|
+
supportsImages: true,
|
|
3006
|
+
multiplier: .79
|
|
2901
3007
|
},
|
|
2902
3008
|
{
|
|
2903
3009
|
id: "glm-5.3-flash",
|
|
2904
3010
|
name: "GLM-5.3-Flash",
|
|
2905
3011
|
contextWindow: 2e5,
|
|
2906
3012
|
maxOutputTokens: 128e3,
|
|
2907
|
-
supportsImages: true
|
|
3013
|
+
supportsImages: true,
|
|
3014
|
+
multiplier: .06
|
|
2908
3015
|
},
|
|
2909
3016
|
{
|
|
2910
3017
|
id: "glm-5.2",
|
|
2911
3018
|
name: "GLM-5.2",
|
|
2912
3019
|
contextWindow: 2e5,
|
|
2913
3020
|
maxOutputTokens: 128e3,
|
|
2914
|
-
supportsImages: true
|
|
3021
|
+
supportsImages: true,
|
|
3022
|
+
multiplier: .79
|
|
2915
3023
|
},
|
|
2916
3024
|
{
|
|
2917
3025
|
id: "glm-5.1",
|
|
@@ -2939,7 +3047,8 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
2939
3047
|
name: "Kimi-K3",
|
|
2940
3048
|
contextWindow: 2e5,
|
|
2941
3049
|
maxOutputTokens: 128e3,
|
|
2942
|
-
supportsImages: true
|
|
3050
|
+
supportsImages: true,
|
|
3051
|
+
multiplier: 1.62
|
|
2943
3052
|
},
|
|
2944
3053
|
{
|
|
2945
3054
|
id: "minimax-m3",
|
|
@@ -2953,14 +3062,16 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
2953
3062
|
name: "Hy3",
|
|
2954
3063
|
contextWindow: 32e3,
|
|
2955
3064
|
maxOutputTokens: 8e3,
|
|
2956
|
-
supportsImages: true
|
|
3065
|
+
supportsImages: true,
|
|
3066
|
+
multiplier: 0
|
|
2957
3067
|
},
|
|
2958
3068
|
{
|
|
2959
3069
|
id: "hy4-preview",
|
|
2960
3070
|
name: "Hy4-Preview",
|
|
2961
3071
|
contextWindow: 1e6,
|
|
2962
3072
|
maxOutputTokens: 128e3,
|
|
2963
|
-
supportsImages: true
|
|
3073
|
+
supportsImages: true,
|
|
3074
|
+
multiplier: .29
|
|
2964
3075
|
}
|
|
2965
3076
|
];
|
|
2966
3077
|
/** Live catalog with a static fallback behind it. */
|
|
@@ -3038,7 +3149,8 @@ function toModelInfo(model) {
|
|
|
3038
3149
|
maxOutputTokens: model.maxTokens,
|
|
3039
3150
|
supportsImages: model.supportsImages ?? false,
|
|
3040
3151
|
...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
|
|
3041
|
-
...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
|
|
3152
|
+
...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts },
|
|
3153
|
+
...model.tags === void 0 || model.tags.length === 0 ? {} : { tags: model.tags }
|
|
3042
3154
|
};
|
|
3043
3155
|
}
|
|
3044
3156
|
/** Map the live upstream list, falling back to the static list when empty. */
|
|
@@ -3148,7 +3260,8 @@ const TAG_LABEL = {
|
|
|
3148
3260
|
* so we don't tack a trailing separator on a plain model. */
|
|
3149
3261
|
function displaySuffix(info) {
|
|
3150
3262
|
const parts = [];
|
|
3151
|
-
if (
|
|
3263
|
+
if (info.multiplier === 0) parts.push("免费");
|
|
3264
|
+
else if (typeof info.multiplier === "number" && Number.isFinite(info.multiplier)) parts.push(`x${info.multiplier.toFixed(2)}`);
|
|
3152
3265
|
for (const tag of info.tags ?? []) {
|
|
3153
3266
|
const label = TAG_LABEL[tag];
|
|
3154
3267
|
if (label !== void 0 && !parts.includes(label)) parts.push(label);
|
|
@@ -3647,6 +3760,65 @@ function expertChatEvents(expert, conversationId, requestId) {
|
|
|
3647
3760
|
expert_industry_id: ""
|
|
3648
3761
|
} : event);
|
|
3649
3762
|
}
|
|
3763
|
+
//#endregion
|
|
3764
|
+
//#region src/status-paths.ts
|
|
3765
|
+
/**
|
|
3766
|
+
* Node-free constants and types shared by the Host and browser halves of the
|
|
3767
|
+
* WorkBuddy XD Pool settings card.
|
|
3768
|
+
*
|
|
3769
|
+
* Pool's runtime state already lives in `src/status.ts` (`buildStatus` /
|
|
3770
|
+
* `WorkBuddyStatus`); this module only carves the cross-domain (Host→browser)
|
|
3771
|
+
* JSON document into a shape that stays token-free and matches what the
|
|
3772
|
+
* browser card renders. Route paths are plugin-owned and mounted on the Host's
|
|
3773
|
+
* same-origin web server (see `src/web-status.ts`).
|
|
3774
|
+
*
|
|
3775
|
+
* @module dsh-workbuddy-xdpool/status-paths
|
|
3776
|
+
*/
|
|
3777
|
+
/** Plugin-owned read-only pool status endpoint (account rows + models + shim). */
|
|
3778
|
+
const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
|
|
3779
|
+
/** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
|
|
3780
|
+
const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
|
|
3781
|
+
/** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
|
|
3782
|
+
const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
|
|
3783
|
+
/** Plugin-owned daily check-in action endpoint (claim today's reward). */
|
|
3784
|
+
const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
|
|
3785
|
+
/** Plugin-owned model-selection save endpoint (writes the settings section). */
|
|
3786
|
+
const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
|
|
3787
|
+
/** Switch one account in or out of the pool (card toggle). */
|
|
3788
|
+
const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
|
|
3789
|
+
/**
|
|
3790
|
+
* Throw one account out of the pool for good, or take it back.
|
|
3791
|
+
*
|
|
3792
|
+
* Separate from the disable route because the semantics differ: disabling is a
|
|
3793
|
+
* rotation preference the account survives, ignoring survives the account.
|
|
3794
|
+
*/
|
|
3795
|
+
const POOL_ACCOUNT_IGNORE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/ignored";
|
|
3796
|
+
/** Run one automation job immediately, so the card can verify it on demand. */
|
|
3797
|
+
const POOL_AUTOMATION_RUN_PATH = "/plugins/dsh-workbuddy-xdpool/automation/run";
|
|
3798
|
+
/** Set or clear one account's reserved-credit floor. */
|
|
3799
|
+
const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/credit-reserve";
|
|
3800
|
+
/**
|
|
3801
|
+
* The schedule every automation job falls back to.
|
|
3802
|
+
*
|
|
3803
|
+
* Shared by both halves on purpose. The host uses it when a configured hour
|
|
3804
|
+
* list arrives empty (the settings schema materializes "never configured" into
|
|
3805
|
+
* `[]`), and the card uses it when it writes the `automation` block back, so a
|
|
3806
|
+
* document that already holds an empty list is healed instead of being saved
|
|
3807
|
+
* back as an unrunnable schedule.
|
|
3808
|
+
*
|
|
3809
|
+
* This lives here rather than in `scheduler.ts` because the browser half cannot
|
|
3810
|
+
* import the host module: `scheduler.ts` pulls in `node:crypto` and the whole
|
|
3811
|
+
* upstream client, none of which exists in the browser bundle. Two hand-written
|
|
3812
|
+
* copies would drift, and the drift is invisible — the card would write a
|
|
3813
|
+
* schedule the scheduler does not run.
|
|
3814
|
+
*/
|
|
3815
|
+
const DEFAULT_AUTOMATION_HOURS = {
|
|
3816
|
+
checkin: [9],
|
|
3817
|
+
report: [10],
|
|
3818
|
+
tasks: [11],
|
|
3819
|
+
streak: [12],
|
|
3820
|
+
travel: [9, 21]
|
|
3821
|
+
};
|
|
3650
3822
|
/** How often the loop wakes to look for a due job. */
|
|
3651
3823
|
/**
|
|
3652
3824
|
* How long to wait for event scoring to land before re-reading the task list.
|
|
@@ -3705,6 +3877,23 @@ function isAutomationJobKind(value) {
|
|
|
3705
3877
|
return typeof value === "string" && AUTOMATION_JOB_KINDS.includes(value);
|
|
3706
3878
|
}
|
|
3707
3879
|
const JOB_KINDS = AUTOMATION_JOB_KINDS;
|
|
3880
|
+
/**
|
|
3881
|
+
* Pick the hour list to run on: the configured one, or the default.
|
|
3882
|
+
*
|
|
3883
|
+
* An EMPTY list is treated as "not configured" rather than as "never run".
|
|
3884
|
+
* That distinction is the whole point: the settings schema materializes an
|
|
3885
|
+
* absent list into `[]` (a bare `z.array` with no `.default()`), so a config
|
|
3886
|
+
* that never mentioned the schedule arrives here looking exactly like one
|
|
3887
|
+
* deliberately set to nothing — and honouring the empty reading silently
|
|
3888
|
+
* disabled every job while the card still showed the switch as ON.
|
|
3889
|
+
*
|
|
3890
|
+
* A user who genuinely wants a job skipped can leave it out of the schedule
|
|
3891
|
+
* they save; they cannot express "off" with an empty array through the card
|
|
3892
|
+
* either way, because the card always writes a non-empty list.
|
|
3893
|
+
*/
|
|
3894
|
+
function hoursOrDefault(configured, fallback) {
|
|
3895
|
+
return configured !== void 0 && configured.length > 0 ? configured : fallback;
|
|
3896
|
+
}
|
|
3708
3897
|
const EMPTY_JOB_STATE = {
|
|
3709
3898
|
ok: 0,
|
|
3710
3899
|
failed: 0,
|
|
@@ -3893,11 +4082,11 @@ var WorkBuddyScheduler = class {
|
|
|
3893
4082
|
this.earningsDate = today;
|
|
3894
4083
|
}
|
|
3895
4084
|
this.enabled = options.enabled ?? false;
|
|
3896
|
-
this.checkinHours = options.checkinHours
|
|
3897
|
-
this.reportHours = options.reportHours
|
|
3898
|
-
this.taskHours = options.taskHours
|
|
3899
|
-
this.streakHours = options.streakHours
|
|
3900
|
-
this.travelHours = options.travelHours
|
|
4085
|
+
this.checkinHours = hoursOrDefault(options.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin);
|
|
4086
|
+
this.reportHours = hoursOrDefault(options.reportHours, DEFAULT_AUTOMATION_HOURS.report);
|
|
4087
|
+
this.taskHours = hoursOrDefault(options.taskHours, DEFAULT_AUTOMATION_HOURS.tasks);
|
|
4088
|
+
this.streakHours = hoursOrDefault(options.streakHours, DEFAULT_AUTOMATION_HOURS.streak);
|
|
4089
|
+
this.travelHours = hoursOrDefault(options.travelHours, DEFAULT_AUTOMATION_HOURS.travel);
|
|
3901
4090
|
}
|
|
3902
4091
|
/** Apply a new configuration; safe to call while running. */
|
|
3903
4092
|
/**
|
|
@@ -3925,11 +4114,11 @@ var WorkBuddyScheduler = class {
|
|
|
3925
4114
|
}
|
|
3926
4115
|
applyConfig(options) {
|
|
3927
4116
|
if (options.enabled !== void 0) this.enabled = options.enabled;
|
|
3928
|
-
if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
|
|
3929
|
-
if (options.reportHours !== void 0) this.reportHours = options.reportHours;
|
|
3930
|
-
if (options.taskHours !== void 0) this.taskHours = options.taskHours;
|
|
3931
|
-
if (options.streakHours !== void 0) this.streakHours = options.streakHours;
|
|
3932
|
-
if (options.travelHours !== void 0) this.travelHours = options.travelHours;
|
|
4117
|
+
if (options.checkinHours !== void 0) this.checkinHours = hoursOrDefault(options.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin);
|
|
4118
|
+
if (options.reportHours !== void 0) this.reportHours = hoursOrDefault(options.reportHours, DEFAULT_AUTOMATION_HOURS.report);
|
|
4119
|
+
if (options.taskHours !== void 0) this.taskHours = hoursOrDefault(options.taskHours, DEFAULT_AUTOMATION_HOURS.tasks);
|
|
4120
|
+
if (options.streakHours !== void 0) this.streakHours = hoursOrDefault(options.streakHours, DEFAULT_AUTOMATION_HOURS.streak);
|
|
4121
|
+
if (options.travelHours !== void 0) this.travelHours = hoursOrDefault(options.travelHours, DEFAULT_AUTOMATION_HOURS.travel);
|
|
3933
4122
|
}
|
|
3934
4123
|
/** Hours for one job, used by the loop and the status document. */
|
|
3935
4124
|
hoursOf(kind) {
|
|
@@ -4160,12 +4349,16 @@ var WorkBuddyScheduler = class {
|
|
|
4160
4349
|
*/
|
|
4161
4350
|
persistEarnings() {
|
|
4162
4351
|
if (this.saveEarningsFn === void 0) return;
|
|
4352
|
+
const accounts = {};
|
|
4353
|
+
for (const [id, entry] of this.earnings) accounts[id] = entry;
|
|
4354
|
+
const ledger = {
|
|
4355
|
+
date: this.earningsDate,
|
|
4356
|
+
accounts
|
|
4357
|
+
};
|
|
4163
4358
|
try {
|
|
4164
|
-
const
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
date: this.earningsDate,
|
|
4168
|
-
accounts
|
|
4359
|
+
const saved = this.saveEarningsFn(ledger);
|
|
4360
|
+
if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
|
|
4361
|
+
this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
|
|
4169
4362
|
});
|
|
4170
4363
|
} catch (error) {
|
|
4171
4364
|
this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
|
|
@@ -5398,36 +5591,6 @@ function formatRates(status) {
|
|
|
5398
5591
|
return lines.join("\n");
|
|
5399
5592
|
}
|
|
5400
5593
|
//#endregion
|
|
5401
|
-
//#region src/status-paths.ts
|
|
5402
|
-
/**
|
|
5403
|
-
* Node-free constants and types shared by the Host and browser halves of the
|
|
5404
|
-
* WorkBuddy XD Pool settings card.
|
|
5405
|
-
*
|
|
5406
|
-
* Pool's runtime state already lives in `src/status.ts` (`buildStatus` /
|
|
5407
|
-
* `WorkBuddyStatus`); this module only carves the cross-domain (Host→browser)
|
|
5408
|
-
* JSON document into a shape that stays token-free and matches what the
|
|
5409
|
-
* browser card renders. Route paths are plugin-owned and mounted on the Host's
|
|
5410
|
-
* same-origin web server (see `src/web-status.ts`).
|
|
5411
|
-
*
|
|
5412
|
-
* @module dsh-workbuddy-xdpool/status-paths
|
|
5413
|
-
*/
|
|
5414
|
-
/** Plugin-owned read-only pool status endpoint (account rows + models + shim). */
|
|
5415
|
-
const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
|
|
5416
|
-
/** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
|
|
5417
|
-
const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
|
|
5418
|
-
/** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
|
|
5419
|
-
const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
|
|
5420
|
-
/** Plugin-owned daily check-in action endpoint (claim today's reward). */
|
|
5421
|
-
const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
|
|
5422
|
-
/** Plugin-owned model-selection save endpoint (writes the settings section). */
|
|
5423
|
-
const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
|
|
5424
|
-
/** Switch one account in or out of the pool (card toggle). */
|
|
5425
|
-
const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
|
|
5426
|
-
/** Run one automation job immediately, so the card can verify it on demand. */
|
|
5427
|
-
const POOL_AUTOMATION_RUN_PATH = "/plugins/dsh-workbuddy-xdpool/automation/run";
|
|
5428
|
-
/** Set or clear one account's reserved-credit floor. */
|
|
5429
|
-
const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/credit-reserve";
|
|
5430
|
-
//#endregion
|
|
5431
5594
|
//#region src/web-status.ts
|
|
5432
5595
|
/** Redact token-like content before it crosses to the browser. */
|
|
5433
5596
|
/**
|
|
@@ -5552,6 +5715,27 @@ function parseAccountToggle(body, known) {
|
|
|
5552
5715
|
};
|
|
5553
5716
|
}
|
|
5554
5717
|
/**
|
|
5718
|
+
* Validate an ignore/unignore request.
|
|
5719
|
+
*
|
|
5720
|
+
* Two different notions of "known" apply, which is why the caller passes both:
|
|
5721
|
+
*
|
|
5722
|
+
* - ignoring requires the account to be IN THE POOL, because the card can only
|
|
5723
|
+
* name an account it was just shown;
|
|
5724
|
+
* - un-ignoring requires the account to be ON THE IGNORE LIST instead — by
|
|
5725
|
+
* definition it is not in the pool any more, so checking the pool would make
|
|
5726
|
+
* the undo button impossible to use.
|
|
5727
|
+
*/
|
|
5728
|
+
function parseAccountIgnore(body, knownInPool, knownIgnored) {
|
|
5729
|
+
const accountId = typeof body["accountId"] === "string" ? body["accountId"].trim() : "";
|
|
5730
|
+
const ignored = body["ignored"];
|
|
5731
|
+
if (accountId === "" || typeof ignored !== "boolean") return void 0;
|
|
5732
|
+
if (ignored ? !knownInPool(accountId) : !knownIgnored(accountId)) return void 0;
|
|
5733
|
+
return {
|
|
5734
|
+
accountId,
|
|
5735
|
+
ignored
|
|
5736
|
+
};
|
|
5737
|
+
}
|
|
5738
|
+
/**
|
|
5555
5739
|
* Parse a manual-run request.
|
|
5556
5740
|
*
|
|
5557
5741
|
* The job name is checked against the real job list rather than passed through:
|
|
@@ -5723,7 +5907,8 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
5723
5907
|
regions,
|
|
5724
5908
|
shim,
|
|
5725
5909
|
automation,
|
|
5726
|
-
creditReserves: deps.pool.creditReservesInOrder()
|
|
5910
|
+
creditReserves: deps.pool.creditReservesInOrder(),
|
|
5911
|
+
ignored: deps.ignoredAccounts?.() ?? []
|
|
5727
5912
|
};
|
|
5728
5913
|
}
|
|
5729
5914
|
/**
|
|
@@ -5847,6 +6032,42 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
5847
6032
|
}
|
|
5848
6033
|
});
|
|
5849
6034
|
/**
|
|
6035
|
+
* Throw one account out of the pool for good, or take it back.
|
|
6036
|
+
*
|
|
6037
|
+
* Same three guards as the disable route — POST only, loopback origin only,
|
|
6038
|
+
* and an explicit `accountId` — plus the semantic split in
|
|
6039
|
+
* {@link parseAccountIgnore}: ignoring checks the pool, un-ignoring checks
|
|
6040
|
+
* the ignore list.
|
|
6041
|
+
*
|
|
6042
|
+
* The credential FILE is deliberately left alone. Deleting it would sign the
|
|
6043
|
+
* desktop app out (the live file IS the app's current session) and would not
|
|
6044
|
+
* even stick, since the app rewrites it on the next sign-in. Ignoring is
|
|
6045
|
+
* enforced on the read side instead, which is both reversible and durable.
|
|
6046
|
+
*/
|
|
6047
|
+
const disposeAccountIgnore = ctx.webServer.register({
|
|
6048
|
+
kind: "exact",
|
|
6049
|
+
path: POOL_ACCOUNT_IGNORE_PATH,
|
|
6050
|
+
handler: async (req, res) => {
|
|
6051
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
6052
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
6053
|
+
if (deps.setAccountIgnored === void 0) return json(res, 503, { error: "settings service unavailable; the ignore list cannot be saved" });
|
|
6054
|
+
try {
|
|
6055
|
+
const body = await readJsonBody(req);
|
|
6056
|
+
const knownInPool = new Set(deps.pool.list().map((account) => account.id));
|
|
6057
|
+
const knownIgnored = new Set((deps.ignoredAccounts?.() ?? []).map((entry) => entry.id));
|
|
6058
|
+
const parsed = parseAccountIgnore(body, (id) => knownInPool.has(id), (id) => knownIgnored.has(id));
|
|
6059
|
+
if (parsed === void 0) return json(res, 400, { error: "invalid account ignore payload" });
|
|
6060
|
+
await deps.setAccountIgnored(parsed.accountId, parsed.ignored);
|
|
6061
|
+
json(res, 200, {
|
|
6062
|
+
ok: true,
|
|
6063
|
+
...parsed
|
|
6064
|
+
});
|
|
6065
|
+
} catch (error) {
|
|
6066
|
+
json(res, 500, { error: safeMessage(error) });
|
|
6067
|
+
}
|
|
6068
|
+
}
|
|
6069
|
+
});
|
|
6070
|
+
/**
|
|
5850
6071
|
* Run one automation job on demand.
|
|
5851
6072
|
*
|
|
5852
6073
|
* POST only, loopback origin only, and the job name must be one of the four
|
|
@@ -5906,6 +6127,7 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
5906
6127
|
return () => {
|
|
5907
6128
|
disposeAutomationRun();
|
|
5908
6129
|
disposeCreditReserve();
|
|
6130
|
+
disposeAccountIgnore();
|
|
5909
6131
|
disposeCheckin();
|
|
5910
6132
|
disposeAccountDisable();
|
|
5911
6133
|
disposeModelsSave();
|
|
@@ -5916,6 +6138,153 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
5916
6138
|
}, "dsh-workbuddy-xdpool: Web status route");
|
|
5917
6139
|
}
|
|
5918
6140
|
//#endregion
|
|
6141
|
+
//#region src/ignored.ts
|
|
6142
|
+
/**
|
|
6143
|
+
* The permanent ignore list: accounts the user has thrown out of the pool.
|
|
6144
|
+
*
|
|
6145
|
+
* Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
|
|
6146
|
+
* account stays on the card, can be switched back on, and its credential keeps
|
|
6147
|
+
* being read on every scan. Ignoring is a statement about the ACCOUNT — "this
|
|
6148
|
+
* one is not mine any more" — so an ignored account is skipped BEFORE its
|
|
6149
|
+
* credential is decrypted, never appears among the pool's accounts, and does not
|
|
6150
|
+
* come back when the desktop app writes a fresh `.info` file for it.
|
|
6151
|
+
*
|
|
6152
|
+
* It lives in a plugin-owned file rather than in the settings document for one
|
|
6153
|
+
* practical reason: the CLI has no settings service. `dsh-workbuddy-xdpool
|
|
6154
|
+
* ignore <id>` runs without a host, so a settings-only list could be written by
|
|
6155
|
+
* the card and never by the CLI. One file, read by both halves, keeps the two
|
|
6156
|
+
* views of "which accounts are ignored" identical by construction.
|
|
6157
|
+
*
|
|
6158
|
+
* @module dsh-workbuddy-xdpool/ignored
|
|
6159
|
+
*/
|
|
6160
|
+
/** Directory holding this plugin's own state (imported snapshots, ignore list). */
|
|
6161
|
+
const PLUGIN_DATA_DIR_NAME = ".workbuddy-xdpool";
|
|
6162
|
+
/** File holding the permanent ignore list, inside {@link pluginDataDir}. */
|
|
6163
|
+
const IGNORED_FILE_NAME = "ignored.json";
|
|
6164
|
+
/**
|
|
6165
|
+
* The DSH home directory, honouring the same override the host uses.
|
|
6166
|
+
*
|
|
6167
|
+
* Shared by the CLI and the host so both halves resolve the same file: an
|
|
6168
|
+
* `ignore` written from the terminal has to be visible to the running plugin,
|
|
6169
|
+
* which is only true if they agree on where "home" is.
|
|
6170
|
+
*/
|
|
6171
|
+
function dshHome(env = process.env) {
|
|
6172
|
+
const fromEnv = env["DSH_HOME"];
|
|
6173
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
|
|
6174
|
+
return join(homedir(), ".dsh");
|
|
6175
|
+
}
|
|
6176
|
+
/** This plugin's own state directory. */
|
|
6177
|
+
function pluginDataDir(env = process.env) {
|
|
6178
|
+
return join(dshHome(env), PLUGIN_DATA_DIR_NAME);
|
|
6179
|
+
}
|
|
6180
|
+
/** Absolute path of the ignore list. */
|
|
6181
|
+
function ignoredIdsPath(env = process.env) {
|
|
6182
|
+
return join(pluginDataDir(env), IGNORED_FILE_NAME);
|
|
6183
|
+
}
|
|
6184
|
+
/**
|
|
6185
|
+
* Normalize whatever the file holds into a clean list.
|
|
6186
|
+
*
|
|
6187
|
+
* Tolerant on purpose: this file is hand-editable and a malformed entry must not
|
|
6188
|
+
* take the pool down. An entry without a usable id is dropped; a missing label
|
|
6189
|
+
* falls back to the id so the card always has something to print.
|
|
6190
|
+
*/
|
|
6191
|
+
function normalize(raw) {
|
|
6192
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
6193
|
+
const entries = raw.accounts;
|
|
6194
|
+
if (!Array.isArray(entries)) return [];
|
|
6195
|
+
const out = [];
|
|
6196
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6197
|
+
for (const entry of entries) {
|
|
6198
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
6199
|
+
const record = entry;
|
|
6200
|
+
const id = typeof record["id"] === "string" ? record["id"].trim() : "";
|
|
6201
|
+
if (id === "" || seen.has(id)) continue;
|
|
6202
|
+
seen.add(id);
|
|
6203
|
+
const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
|
|
6204
|
+
const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
|
|
6205
|
+
out.push({
|
|
6206
|
+
id,
|
|
6207
|
+
label,
|
|
6208
|
+
ignoredAt
|
|
6209
|
+
});
|
|
6210
|
+
}
|
|
6211
|
+
return out;
|
|
6212
|
+
}
|
|
6213
|
+
/**
|
|
6214
|
+
* Read the ignore list, tolerating every "no list yet" shape.
|
|
6215
|
+
*
|
|
6216
|
+
* A missing file, unreadable file, or invalid JSON all mean the same thing to
|
|
6217
|
+
* the caller — nothing is ignored — so none of them throws. The pool must be
|
|
6218
|
+
* able to start on a machine that has never ignored anything.
|
|
6219
|
+
*/
|
|
6220
|
+
async function readIgnoredAccounts(path = ignoredIdsPath()) {
|
|
6221
|
+
try {
|
|
6222
|
+
return normalize(JSON.parse(await readFile(path, "utf8")));
|
|
6223
|
+
} catch {
|
|
6224
|
+
return [];
|
|
6225
|
+
}
|
|
6226
|
+
}
|
|
6227
|
+
/**
|
|
6228
|
+
* Synchronous read, for startup.
|
|
6229
|
+
*
|
|
6230
|
+
* The host applies the ignore list from inside `apply()`, which is synchronous,
|
|
6231
|
+
* and doing it there removes a startup race: an async load could resolve AFTER
|
|
6232
|
+
* the first account scan, which would let an ignored account slip into the pool
|
|
6233
|
+
* once per boot. The file is a few hundred bytes, so a blocking read at startup
|
|
6234
|
+
* costs nothing measurable.
|
|
6235
|
+
*/
|
|
6236
|
+
function readIgnoredAccountsSync(path = ignoredIdsPath()) {
|
|
6237
|
+
try {
|
|
6238
|
+
return normalize(JSON.parse(readFileSync(path, "utf8")));
|
|
6239
|
+
} catch {
|
|
6240
|
+
return [];
|
|
6241
|
+
}
|
|
6242
|
+
}
|
|
6243
|
+
/**
|
|
6244
|
+
* Replace the ignore list, atomically.
|
|
6245
|
+
*
|
|
6246
|
+
* Written to a sibling temp file and renamed over the target so a crash (or a
|
|
6247
|
+
* concurrent reader) can never observe a half-written document — the ignore
|
|
6248
|
+
* list is the only thing standing between a dead account and the rotation, and
|
|
6249
|
+
* a truncated file reads as "nothing is ignored", which would quietly put every
|
|
6250
|
+
* discarded account back in the pool.
|
|
6251
|
+
*/
|
|
6252
|
+
async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
|
|
6253
|
+
const payload = JSON.stringify({
|
|
6254
|
+
version: 1,
|
|
6255
|
+
accounts
|
|
6256
|
+
}, null, 2);
|
|
6257
|
+
await mkdir(dirname(path), { recursive: true });
|
|
6258
|
+
const temp = `${path}.tmp`;
|
|
6259
|
+
await writeFile(temp, `${payload}\n`, "utf8");
|
|
6260
|
+
await rename(temp, path);
|
|
6261
|
+
}
|
|
6262
|
+
/**
|
|
6263
|
+
* Add one account to the ignore list, preserving the rest.
|
|
6264
|
+
*
|
|
6265
|
+
* A read-modify-write rather than a wholesale replace: the card and the CLI can
|
|
6266
|
+
* both be open, and each request names exactly one account, so re-writing the
|
|
6267
|
+
* whole list from a stale view would drop the other side's edits.
|
|
6268
|
+
*/
|
|
6269
|
+
async function ignoreAccount(account, path = ignoredIdsPath()) {
|
|
6270
|
+
const current = await readIgnoredAccounts(path);
|
|
6271
|
+
if (current.some((entry) => entry.id === account.id)) return current;
|
|
6272
|
+
const next = [...current, {
|
|
6273
|
+
id: account.id,
|
|
6274
|
+
label: account.label ?? account.id,
|
|
6275
|
+
ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6276
|
+
}];
|
|
6277
|
+
await writeIgnoredAccounts(next, path);
|
|
6278
|
+
return next;
|
|
6279
|
+
}
|
|
6280
|
+
/** Drop one account from the ignore list. Returns the resulting list. */
|
|
6281
|
+
async function unignoreAccount(accountId, path = ignoredIdsPath()) {
|
|
6282
|
+
const current = await readIgnoredAccounts(path);
|
|
6283
|
+
const next = current.filter((entry) => entry.id !== accountId);
|
|
6284
|
+
if (next.length !== current.length) await writeIgnoredAccounts(next, path);
|
|
6285
|
+
return next;
|
|
6286
|
+
}
|
|
6287
|
+
//#endregion
|
|
5919
6288
|
//#region src/index.ts
|
|
5920
6289
|
/**
|
|
5921
6290
|
* Host-side plugin entry. Registers the `workbuddy-xdpool` provider into the
|
|
@@ -5962,6 +6331,56 @@ function automationOptions(automation) {
|
|
|
5962
6331
|
* region that has never been saved stays absent so `applyConfigFromSource` can
|
|
5963
6332
|
* fall back to the legacy flat keys.
|
|
5964
6333
|
*/
|
|
6334
|
+
function asVolatile(schema) {
|
|
6335
|
+
const candidate = schema;
|
|
6336
|
+
return typeof candidate.volatile === "function" ? candidate.volatile() : schema;
|
|
6337
|
+
}
|
|
6338
|
+
/**
|
|
6339
|
+
* Peel one live volatile reference.
|
|
6340
|
+
*
|
|
6341
|
+
* On 0.1.7 a volatile field is handed back as `{ get(): T }` rather than a
|
|
6342
|
+
* plain value, so a running instance observes a settings edit without being
|
|
6343
|
+
* remounted. Every read of a marked field therefore has to unwrap: passing the
|
|
6344
|
+
* reference onward compares an object against a string and reports the field as
|
|
6345
|
+
* unset. On 0.1.5 the field is already a plain value, so this is a no-op.
|
|
6346
|
+
*
|
|
6347
|
+
* The test is duck-typed on purpose — importing `isVolatile` would add a
|
|
6348
|
+
* dependency the 0.1.5 line does not carry.
|
|
6349
|
+
*/
|
|
6350
|
+
function unwrapVolatile(value) {
|
|
6351
|
+
if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
|
|
6352
|
+
return value;
|
|
6353
|
+
}
|
|
6354
|
+
/**
|
|
6355
|
+
* Deep copy of a config value with every live `{get(): T}` reference replaced
|
|
6356
|
+
* by the value it resolves to.
|
|
6357
|
+
*
|
|
6358
|
+
* {@link unwrapVolatile} peels only the one level an ordinary read needs. A
|
|
6359
|
+
* settings service is different: it validates and `structuredClone`s the WHOLE
|
|
6360
|
+
* object, so a reference surviving anywhere inside it fails schema validation
|
|
6361
|
+
* with a message that names the field but not the cause —
|
|
6362
|
+
* `$.authFile expected string but got [object Object]`. That is what makes the
|
|
6363
|
+
* namespace fail to register and the card silently disappear, which is why the
|
|
6364
|
+
* object handed to `installSection` goes through this first.
|
|
6365
|
+
*/
|
|
6366
|
+
function unwrapVolatileDeep(value) {
|
|
6367
|
+
const peeled = unwrapVolatile(value);
|
|
6368
|
+
if (Array.isArray(peeled)) return peeled.map((entry) => unwrapVolatileDeep(entry));
|
|
6369
|
+
if (peeled !== null && typeof peeled === "object") {
|
|
6370
|
+
const out = {};
|
|
6371
|
+
for (const [key, entry] of Object.entries(peeled)) out[key] = unwrapVolatileDeep(entry);
|
|
6372
|
+
return out;
|
|
6373
|
+
}
|
|
6374
|
+
return peeled;
|
|
6375
|
+
}
|
|
6376
|
+
/**
|
|
6377
|
+
* One region's model-selection schema.
|
|
6378
|
+
*
|
|
6379
|
+
* Every field is optional on purpose: an absent field keeps its documented
|
|
6380
|
+
* meaning ("all enabled" / "follow the upstream image flag" / "no cap"), and a
|
|
6381
|
+
* region that has never been saved stays absent so `applyConfigFromSource` can
|
|
6382
|
+
* fall back to the legacy flat keys.
|
|
6383
|
+
*/
|
|
5965
6384
|
const modelSelectionSchema = z.object({
|
|
5966
6385
|
enabledModelIds: z.array(z.string()).description("Model ids enabled in this region's picker (absent = all)"),
|
|
5967
6386
|
imageModelIds: z.array(z.string()).description("Model ids accepting image input in this region (absent = follow upstream)"),
|
|
@@ -5999,24 +6418,32 @@ const modelSelectionKeyFor = (region) => region === "cn" ? "modelSelectionCn" :
|
|
|
5999
6418
|
* also why `contextBudgets` is a real dictionary (`z.dict`) - an open object
|
|
6000
6419
|
* schema reads as "an object with no fields" and the fold then throws while
|
|
6001
6420
|
* the provider row is rendered.
|
|
6421
|
+
*
|
|
6422
|
+
* Every field is wrapped in {@link asVolatile}: on the 0.1.7 line the settings
|
|
6423
|
+
* write gate refuses an entry whose schema declares no volatile field at all
|
|
6424
|
+
* ("Plugin entry ... has no volatile fields") and `describe()` skips such an
|
|
6425
|
+
* entry — so an unmarked schema means the card can neither render nor save. On
|
|
6426
|
+
* the 0.1.5 line the wrapper degrades to an identity no-op (see its JSDoc), and
|
|
6427
|
+
* the value the running instance reads is a plain value either way once
|
|
6428
|
+
* unwrapped.
|
|
6002
6429
|
*/
|
|
6003
6430
|
const Config = z.object({
|
|
6004
|
-
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
|
|
6005
|
-
cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
|
|
6006
|
-
distribution: z.union([
|
|
6431
|
+
authFile: asVolatile(z.string().description("WorkBuddy desktop auth file (defaults to the app own location)")),
|
|
6432
|
+
cooldownMs: asVolatile(z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds")),
|
|
6433
|
+
distribution: asVolatile(z.union([
|
|
6007
6434
|
"priority",
|
|
6008
6435
|
"round-robin",
|
|
6009
6436
|
"balanced"
|
|
6010
|
-
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)"),
|
|
6011
|
-
disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
|
|
6012
|
-
creditReserves: z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value"),
|
|
6013
|
-
enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
|
|
6014
|
-
imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
|
|
6015
|
-
contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet"),
|
|
6016
|
-
modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
|
|
6017
|
-
modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway"),
|
|
6018
|
-
automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)"),
|
|
6019
|
-
automationEarnings: z.any().description("Automation earnings ledger (written by the scheduler)")
|
|
6437
|
+
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)")),
|
|
6438
|
+
disabledAccountIds: asVolatile(z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)")),
|
|
6439
|
+
creditReserves: asVolatile(z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value")),
|
|
6440
|
+
enabledModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet")),
|
|
6441
|
+
imageModelIds: asVolatile(z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet")),
|
|
6442
|
+
contextBudgets: asVolatile(z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet")),
|
|
6443
|
+
modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
|
|
6444
|
+
modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
|
|
6445
|
+
automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
|
|
6446
|
+
automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
|
|
6020
6447
|
});
|
|
6021
6448
|
/** Live API, published for the CLI. */
|
|
6022
6449
|
let api;
|
|
@@ -6060,6 +6487,14 @@ function createCore(logger) {
|
|
|
6060
6487
|
*/
|
|
6061
6488
|
function apply(ctx, config = {}) {
|
|
6062
6489
|
const core = createCore(ctx.logger);
|
|
6490
|
+
const ignoredPath = ignoredIdsPath();
|
|
6491
|
+
let ignoredAccounts = readIgnoredAccountsSync(ignoredPath);
|
|
6492
|
+
core.pool.applyIgnored(ignoredAccounts.map((entry) => entry.id));
|
|
6493
|
+
/** Re-read the ignore file and push it into the pool. */
|
|
6494
|
+
const refreshIgnored = () => {
|
|
6495
|
+
ignoredAccounts = readIgnoredAccountsSync(ignoredPath);
|
|
6496
|
+
core.pool.applyIgnored(ignoredAccounts.map((entry) => entry.id));
|
|
6497
|
+
};
|
|
6063
6498
|
/**
|
|
6064
6499
|
* Invalidate the provider snapshot so the picker re-reads the catalog.
|
|
6065
6500
|
*
|
|
@@ -6073,10 +6508,21 @@ function apply(ctx, config = {}) {
|
|
|
6073
6508
|
/** The domestic adapter, published for the CLI after registration. */
|
|
6074
6509
|
let builtAdapter;
|
|
6075
6510
|
let invalidateCatalog = () => {};
|
|
6076
|
-
|
|
6511
|
+
/**
|
|
6512
|
+
* The effective config with every live volatile reference peeled.
|
|
6513
|
+
*
|
|
6514
|
+
* On 0.1.7 a field marked volatile is handed to the plugin as `{get(): T}` so
|
|
6515
|
+
* a settings edit is observed without a remount, and the loader keeps the
|
|
6516
|
+
* reference live. Every read below therefore goes through this view: a raw
|
|
6517
|
+
* reference compared against a string reports the field as unset, which is
|
|
6518
|
+
* how a saved reserve would read back as "0" while the file holds the value.
|
|
6519
|
+
* On 0.1.5 there is nothing to peel and this is the identity.
|
|
6520
|
+
*/
|
|
6521
|
+
let rawCurrent = () => config;
|
|
6522
|
+
const current = () => unwrapVolatileDeep(rawCurrent());
|
|
6077
6523
|
const sectionHooks = {
|
|
6078
6524
|
setSource(source) {
|
|
6079
|
-
|
|
6525
|
+
rawCurrent = source;
|
|
6080
6526
|
},
|
|
6081
6527
|
onChange() {
|
|
6082
6528
|
applyConfigFromSource();
|
|
@@ -6097,13 +6543,21 @@ function apply(ctx, config = {}) {
|
|
|
6097
6543
|
...imageModelIds === void 0 ? {} : { imageModelIds },
|
|
6098
6544
|
...contextBudgets === void 0 ? {} : { contextBudgets }
|
|
6099
6545
|
};
|
|
6100
|
-
|
|
6101
|
-
|
|
6546
|
+
const fallbackSelection = (region) => {
|
|
6547
|
+
return legacySelection.enabledModelIds === void 0 && legacySelection.imageModelIds === void 0 && legacySelection.contextBudgets === void 0 ? core.catalogs[region].currentSelection() : legacySelection;
|
|
6548
|
+
};
|
|
6549
|
+
core.catalogs.cn.applySelection(modelSelectionCn ?? fallbackSelection("cn"));
|
|
6550
|
+
core.catalogs.global.applySelection(modelSelectionGlobal ?? fallbackSelection("global"));
|
|
6102
6551
|
core.scheduler.applyConfig(automationOptions(automation));
|
|
6103
6552
|
};
|
|
6104
6553
|
const settingsService = ctx.settings;
|
|
6105
|
-
if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, config, sectionHooks);
|
|
6106
|
-
|
|
6554
|
+
if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, unwrapVolatileDeep(config), sectionHooks);
|
|
6555
|
+
if (typeof settingsService.configure === "function") ctx.effect(() => settingsService.configure?.({ auto: true }, ctx.fiber) ?? (() => {}));
|
|
6556
|
+
if (typeof settingsService.installSection !== "function" && typeof settingsService.configure !== "function") ctx.logger.warn?.("dsh-workbuddy-xdpool: settings service exposes neither installSection nor configure; the card will not mount");
|
|
6557
|
+
ctx.on("loader/volatile-update", () => {
|
|
6558
|
+
applyConfigFromSource();
|
|
6559
|
+
});
|
|
6560
|
+
applyConfigFromSource();
|
|
6107
6561
|
/**
|
|
6108
6562
|
* Write one key of the plugin's own settings section. Only ever called with
|
|
6109
6563
|
* the three model-selection keys, so the settings file cannot be steered from
|
|
@@ -6139,11 +6593,31 @@ function apply(ctx, config = {}) {
|
|
|
6139
6593
|
if (typeof value === "object" && value !== null) return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
|
|
6140
6594
|
return JSON.stringify(value);
|
|
6141
6595
|
}
|
|
6596
|
+
/**
|
|
6597
|
+
* The namespace `settings.update` expects on the running line.
|
|
6598
|
+
*
|
|
6599
|
+
* 0.1.5 resolves writers by the NAMESPACE the plugin registered through
|
|
6600
|
+
* `installSection`, so the plugin picks it. 0.1.7 resolves them by the HOST
|
|
6601
|
+
* PLUGIN ENTRY ID instead (`configEditor.entries().find(row => row.options.id
|
|
6602
|
+
* === ns)`), and the profile patch chooses that id — the live Desktop host
|
|
6603
|
+
* mounts this plugin as `llm-workbuddy-xdpool`, but a marketplace install can
|
|
6604
|
+
* wrap it (`mkt-...`) or mount it through an `include` row. Asking the fiber
|
|
6605
|
+
* for its own entry id is therefore the only correct answer there; guessing
|
|
6606
|
+
* our own namespace makes every save throw `No configurable plugin entry`.
|
|
6607
|
+
*/
|
|
6608
|
+
const settingsWriteNs = typeof settingsService.installSection === "function" ? WORKBUDDY_POOL_SETTINGS_NS : (() => {
|
|
6609
|
+
try {
|
|
6610
|
+
const entryId = ctx.fiber?.entry?.options?.id;
|
|
6611
|
+
return entryId === void 0 || entryId === "" ? WORKBUDDY_POOL_SETTINGS_NS : entryId;
|
|
6612
|
+
} catch {
|
|
6613
|
+
return WORKBUDDY_POOL_SETTINGS_NS;
|
|
6614
|
+
}
|
|
6615
|
+
})();
|
|
6142
6616
|
const setSetting = async (key, value, expected) => {
|
|
6143
6617
|
if (value === void 0) return;
|
|
6144
6618
|
const update = settingsService?.update;
|
|
6145
6619
|
if (update === void 0) throw new Error(`settings service has no update(); ${key} was not saved`);
|
|
6146
|
-
await update.call(settingsService,
|
|
6620
|
+
await update.call(settingsService, settingsWriteNs, { [key]: value });
|
|
6147
6621
|
if (expected !== void 0) {
|
|
6148
6622
|
const stored = current()[key];
|
|
6149
6623
|
if (!stableJsonEqual(stored, expected)) throw new Error(`settings field "${key}" was not persisted`);
|
|
@@ -6214,7 +6688,19 @@ function apply(ctx, config = {}) {
|
|
|
6214
6688
|
if (reserve > 0) next[accountId] = reserve;
|
|
6215
6689
|
else delete next[accountId];
|
|
6216
6690
|
await setSetting("creditReserves", next, next);
|
|
6217
|
-
}
|
|
6691
|
+
},
|
|
6692
|
+
setAccountIgnored: async (accountId, ignored) => {
|
|
6693
|
+
if (ignored) {
|
|
6694
|
+
const known = core.pool.list().find((account) => account.id === accountId);
|
|
6695
|
+
await ignoreAccount({
|
|
6696
|
+
id: accountId,
|
|
6697
|
+
...known === void 0 ? {} : { label: known.label }
|
|
6698
|
+
}, ignoredPath);
|
|
6699
|
+
} else await unignoreAccount(accountId, ignoredPath);
|
|
6700
|
+
refreshIgnored();
|
|
6701
|
+
if (!ignored) await core.pool.scan();
|
|
6702
|
+
},
|
|
6703
|
+
ignoredAccounts: () => ignoredAccounts
|
|
6218
6704
|
}));
|
|
6219
6705
|
api = {
|
|
6220
6706
|
...core,
|
|
@@ -6339,10 +6825,12 @@ function apply(ctx, config = {}) {
|
|
|
6339
6825
|
ctx.logger.warn(`dsh-workbuddy-xdpool: ${region} model catalog unavailable; using static fallback`, error);
|
|
6340
6826
|
}
|
|
6341
6827
|
invalidateCatalog();
|
|
6342
|
-
})()
|
|
6828
|
+
})().catch((error) => {
|
|
6829
|
+
ctx.logger.warn("dsh-workbuddy-xdpool: account catalog seed failed", error);
|
|
6830
|
+
});
|
|
6343
6831
|
}, (error) => {
|
|
6344
6832
|
ctx.logger.error("dsh-workbuddy-xdpool: shim failed to listen", error);
|
|
6345
6833
|
});
|
|
6346
6834
|
}
|
|
6347
6835
|
//#endregion
|
|
6348
|
-
export { APPEARANCE_THEME_KEY, AUTOMATION_JOB_KINDS, AUTOMATION_TICK_MS, BUDDY_APP_ID, BUDDY_APP_NAME, Config, DEFAULT_CONTEXT_BUDGET, EVENT_SCORE_WAIT_MS, FALLBACK_WORKBUDDY_MODELS, LIBRARY_DOC_URL, LIGHTHOUSE_EXPERT_ID, PLAYBOOK_CASE_ID, PLAYBOOK_CASE_NAME, POOL_AUTOMATION_RUN_PATH, POOL_CHECKIN_PATH, POOL_CREDIT_RESERVE_PATH, POOL_MODELS_SAVE_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, SKILL_ID, SKILL_NAME, TEMPLATE_PRESETS, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyScheduler, WorkBuddyUpstreamClient, appearanceChain, apply, automationChain, automationOptions, buddyAppChain, buddyAppEvents, buildStatus, candidateAuthDirs, canvasChain, chatChain, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, dayKey, defaultDesktopAuthDirs, desktopAutomationCreatedEvent, desktopCanvasEvents, desktopChatEvents, expertActualUseEvent, expertChatEvents, expertSummonEvents, formatRates, formatStatus, inject, isAutomationJobKind, isFireHour, libraryReadChain, modelSelectionKeyFor, name, parseRateLimitReset, parseWorkBuddyAuth, playbookChain, poolWebStatus, registerPoolStatusRoute, setApi, skillChain, templateChain, templateChains, workbuddyAccountId };
|
|
6836
|
+
export { APPEARANCE_THEME_KEY, AUTOMATION_JOB_KINDS, AUTOMATION_TICK_MS, BUDDY_APP_ID, BUDDY_APP_NAME, Config, DEFAULT_AUTOMATION_HOURS, DEFAULT_CONTEXT_BUDGET, EVENT_SCORE_WAIT_MS, FALLBACK_WORKBUDDY_MODELS, IGNORED_FILE_NAME, LIBRARY_DOC_URL, LIGHTHOUSE_EXPERT_ID, PLAYBOOK_CASE_ID, PLAYBOOK_CASE_NAME, PLUGIN_DATA_DIR_NAME, POOL_ACCOUNT_IGNORE_PATH, POOL_AUTOMATION_RUN_PATH, POOL_CHECKIN_PATH, POOL_CREDIT_RESERVE_PATH, POOL_MODELS_SAVE_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, SKILL_ID, SKILL_NAME, TEMPLATE_PRESETS, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyScheduler, WorkBuddyUpstreamClient, appearanceChain, apply, automationChain, automationOptions, buddyAppChain, buddyAppEvents, buildStatus, candidateAuthDirs, canvasChain, chatChain, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, dayKey, defaultDesktopAuthDirs, desktopAutomationCreatedEvent, desktopCanvasEvents, desktopChatEvents, dshHome, expertActualUseEvent, expertChatEvents, expertSummonEvents, formatRates, formatStatus, ignoreAccount, ignoredIdsPath, inject, isAutomationJobKind, isFireHour, libraryReadChain, modelSelectionKeyFor, name, parseRateLimitReset, parseWorkBuddyAuth, playbookChain, pluginDataDir, poolWebStatus, readIgnoredAccounts, readIgnoredAccountsSync, registerPoolStatusRoute, setApi, skillChain, templateChain, templateChains, unignoreAccount, workbuddyAccountId, writeIgnoredAccounts };
|