dsh-workbuddy-xdpool 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +169 -84
- package/lib/bin.js +444 -42
- package/lib/client.js +168 -10
- package/lib/index.d.ts +270 -4
- package/lib/index.js +485 -68
- package/package.json +1 -1
package/lib/bin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createDecipheriv, createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { copyFile, mkdir, readFile, readdir, rename, writeFile, writeFile as writeFile$1 } from "node:fs/promises";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
|
-
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { execFile, execFileSync } from "node:child_process";
|
|
6
6
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
7
7
|
import z from "@deepseek-ai/schemastery";
|
|
@@ -427,6 +427,7 @@ function parseUpstreamModel(value) {
|
|
|
427
427
|
const reasoning = parseReasoning(raw["reasoning"]);
|
|
428
428
|
const supportsToolCall = typeof raw["supportsToolCall"] === "boolean" ? raw["supportsToolCall"] : void 0;
|
|
429
429
|
const supportsImages = typeof raw["supportsImages"] === "boolean" ? raw["supportsImages"] : void 0;
|
|
430
|
+
const tags = Array.isArray(raw["tags"]) ? raw["tags"].filter((tag) => typeof tag === "string" && tag !== "") : void 0;
|
|
430
431
|
return {
|
|
431
432
|
id,
|
|
432
433
|
name,
|
|
@@ -437,7 +438,8 @@ function parseUpstreamModel(value) {
|
|
|
437
438
|
...descriptionZh === void 0 ? {} : { descriptionZh },
|
|
438
439
|
...descriptionEn === void 0 ? {} : { descriptionEn },
|
|
439
440
|
...supportsToolCall === void 0 ? {} : { supportsToolCall },
|
|
440
|
-
...supportsImages === void 0 ? {} : { supportsImages }
|
|
441
|
+
...supportsImages === void 0 ? {} : { supportsImages },
|
|
442
|
+
...tags === void 0 || tags.length === 0 ? {} : { tags }
|
|
441
443
|
};
|
|
442
444
|
}
|
|
443
445
|
/**
|
|
@@ -2321,6 +2323,41 @@ async function readCredential(path) {
|
|
|
2321
2323
|
}
|
|
2322
2324
|
}
|
|
2323
2325
|
/**
|
|
2326
|
+
* The pool id a document would receive, WITHOUT decrypting anything.
|
|
2327
|
+
*
|
|
2328
|
+
* Used to honour the ignore list before the at-rest key lookup runs: `uin` and
|
|
2329
|
+
* `uid` are read as plain strings by {@link parseWorkBuddyAuth}, while
|
|
2330
|
+
* `nickname` is commonly encrypted — so this returns undefined for an account
|
|
2331
|
+
* whose identity lives only in the encrypted nickname, and the caller falls
|
|
2332
|
+
* back to the full parse for those.
|
|
2333
|
+
*/
|
|
2334
|
+
function cheapIdentityId(text) {
|
|
2335
|
+
let parsed;
|
|
2336
|
+
try {
|
|
2337
|
+
parsed = JSON.parse(text);
|
|
2338
|
+
} catch {
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
2342
|
+
const document = parsed;
|
|
2343
|
+
const identity = typeof document["account"] === "object" && document["account"] !== null ? document["account"] : document;
|
|
2344
|
+
const uin = typeof identity["uin"] === "string" && identity["uin"] !== "" ? identity["uin"] : void 0;
|
|
2345
|
+
const uid = typeof identity["uid"] === "string" && identity["uid"] !== "" ? identity["uid"] : void 0;
|
|
2346
|
+
if (uin === void 0 && uid === void 0) return void 0;
|
|
2347
|
+
return workbuddyAccountId({
|
|
2348
|
+
...uin === void 0 ? {} : { uin },
|
|
2349
|
+
...uid === void 0 ? {} : { uid }
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
/** {@link cheapIdentityId} for a file path; undefined when it cannot be read. */
|
|
2353
|
+
async function cheapIdentityIdFromFile(path) {
|
|
2354
|
+
try {
|
|
2355
|
+
return cheapIdentityId(await readFile(path, "utf8"));
|
|
2356
|
+
} catch {
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2324
2361
|
* Build the field opener, or undefined when the app cannot supply its key.
|
|
2325
2362
|
*
|
|
2326
2363
|
* Split out so the key lookup is testable without a real desktop install, and so
|
|
@@ -2399,6 +2436,21 @@ var WorkBuddyAccountPool = class {
|
|
|
2399
2436
|
*/
|
|
2400
2437
|
disabledIds = /* @__PURE__ */ new Set();
|
|
2401
2438
|
/**
|
|
2439
|
+
* Account ids the user threw out of the pool for good.
|
|
2440
|
+
*
|
|
2441
|
+
* Enforced BEFORE the credential is parsed: `scan()` skips a file whose
|
|
2442
|
+
* identity is already ignored, so an ignored account costs no at-rest key
|
|
2443
|
+
* lookup (which spawns the desktop app on 5.6.0+) and cannot re-enter the pool
|
|
2444
|
+
* when the app writes a fresh sign-in for it. That is the difference from
|
|
2445
|
+
* {@link disabledIds}, which only filters at pick time and leaves the account
|
|
2446
|
+
* listed, readable and re-discoverable.
|
|
2447
|
+
*
|
|
2448
|
+
* The set is supplied by the host from the plugin's own ignore file, and is
|
|
2449
|
+
* replaced wholesale on every {@link applyIgnored} so removing an entry takes
|
|
2450
|
+
* effect on the next scan without a restart.
|
|
2451
|
+
*/
|
|
2452
|
+
ignoredIds = /* @__PURE__ */ new Set();
|
|
2453
|
+
/**
|
|
2402
2454
|
* Per-account credit floor, keyed by account id. 0 (or absent) means "spend
|
|
2403
2455
|
* it all".
|
|
2404
2456
|
*
|
|
@@ -2450,12 +2502,39 @@ var WorkBuddyAccountPool = class {
|
|
|
2450
2502
|
if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
|
|
2451
2503
|
if (options.creditReserves !== void 0) this.setCreditReserves(options.creditReserves);
|
|
2452
2504
|
}
|
|
2505
|
+
/**
|
|
2506
|
+
* Replace the permanent ignore list.
|
|
2507
|
+
*
|
|
2508
|
+
* Also drops any already-discovered account that is now ignored, so the change
|
|
2509
|
+
* is visible without waiting for the next scan: the card refreshes its status
|
|
2510
|
+
* document right after the write, and an account still sitting in `accounts`
|
|
2511
|
+
* would keep showing up there.
|
|
2512
|
+
*/
|
|
2513
|
+
applyIgnored(ids) {
|
|
2514
|
+
this.ignoredIds = new Set(ids);
|
|
2515
|
+
if (this.ignoredIds.size === 0) return;
|
|
2516
|
+
this.accounts = this.accounts.filter((account) => !this.ignoredIds.has(account.id));
|
|
2517
|
+
}
|
|
2518
|
+
/** Whether this account has been thrown out of the pool for good. */
|
|
2519
|
+
isIgnored(accountId) {
|
|
2520
|
+
return this.ignoredIds.has(accountId);
|
|
2521
|
+
}
|
|
2522
|
+
/** Every ignored id currently in force, in insertion order. */
|
|
2523
|
+
ignoredIdsInOrder() {
|
|
2524
|
+
return [...this.ignoredIds];
|
|
2525
|
+
}
|
|
2453
2526
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
2454
2527
|
async scan() {
|
|
2455
2528
|
const found = [];
|
|
2456
2529
|
for (const dir of this.authDirs) for (const file of await authFilesIn(dir)) {
|
|
2530
|
+
if (this.ignoredIds.size > 0) {
|
|
2531
|
+
const cheapId = await cheapIdentityIdFromFile(file);
|
|
2532
|
+
if (cheapId !== void 0 && this.ignoredIds.has(cheapId)) continue;
|
|
2533
|
+
}
|
|
2457
2534
|
const credential = await readCredential(file);
|
|
2458
|
-
if (credential
|
|
2535
|
+
if (credential === void 0) continue;
|
|
2536
|
+
if (this.ignoredIds.size > 0 && this.ignoredIds.has(workbuddyAccountId(credential))) continue;
|
|
2537
|
+
found.push(credential);
|
|
2459
2538
|
}
|
|
2460
2539
|
const byId = /* @__PURE__ */ new Map();
|
|
2461
2540
|
for (const account of this.accounts) byId.set(account.id, account);
|
|
@@ -2796,28 +2875,44 @@ ${new Date(until).toISOString()}
|
|
|
2796
2875
|
};
|
|
2797
2876
|
//#endregion
|
|
2798
2877
|
//#region src/catalog.ts
|
|
2799
|
-
/**
|
|
2878
|
+
/**
|
|
2879
|
+
* Static fallback used before the first live catalog fetch, and whenever the
|
|
2880
|
+
* upstream cannot be reached.
|
|
2881
|
+
*
|
|
2882
|
+
* The multipliers are carried on purpose. Without them the provider's model
|
|
2883
|
+
* picker silently loses every rate and every free badge the moment the live
|
|
2884
|
+
* fetch fails — which reads to the user as "the plugin broke my model list"
|
|
2885
|
+
* rather than "the upstream is unreachable". The values are the ones the two
|
|
2886
|
+
* gateways actually advertise for these ids (`credits: "x0.79 credits"` and so
|
|
2887
|
+
* on), so a fallback row looks the same as a live one.
|
|
2888
|
+
*
|
|
2889
|
+
* `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
|
|
2890
|
+
* which is what turns on the free badge.
|
|
2891
|
+
*/
|
|
2800
2892
|
const FALLBACK_WORKBUDDY_MODELS = [
|
|
2801
2893
|
{
|
|
2802
2894
|
id: "glm-5.3",
|
|
2803
2895
|
name: "GLM-5.3",
|
|
2804
2896
|
contextWindow: 2e5,
|
|
2805
2897
|
maxOutputTokens: 128e3,
|
|
2806
|
-
supportsImages: true
|
|
2898
|
+
supportsImages: true,
|
|
2899
|
+
multiplier: .79
|
|
2807
2900
|
},
|
|
2808
2901
|
{
|
|
2809
2902
|
id: "glm-5.3-flash",
|
|
2810
2903
|
name: "GLM-5.3-Flash",
|
|
2811
2904
|
contextWindow: 2e5,
|
|
2812
2905
|
maxOutputTokens: 128e3,
|
|
2813
|
-
supportsImages: true
|
|
2906
|
+
supportsImages: true,
|
|
2907
|
+
multiplier: .06
|
|
2814
2908
|
},
|
|
2815
2909
|
{
|
|
2816
2910
|
id: "glm-5.2",
|
|
2817
2911
|
name: "GLM-5.2",
|
|
2818
2912
|
contextWindow: 2e5,
|
|
2819
2913
|
maxOutputTokens: 128e3,
|
|
2820
|
-
supportsImages: true
|
|
2914
|
+
supportsImages: true,
|
|
2915
|
+
multiplier: .79
|
|
2821
2916
|
},
|
|
2822
2917
|
{
|
|
2823
2918
|
id: "glm-5.1",
|
|
@@ -2845,7 +2940,8 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
2845
2940
|
name: "Kimi-K3",
|
|
2846
2941
|
contextWindow: 2e5,
|
|
2847
2942
|
maxOutputTokens: 128e3,
|
|
2848
|
-
supportsImages: true
|
|
2943
|
+
supportsImages: true,
|
|
2944
|
+
multiplier: 1.62
|
|
2849
2945
|
},
|
|
2850
2946
|
{
|
|
2851
2947
|
id: "minimax-m3",
|
|
@@ -2859,14 +2955,16 @@ const FALLBACK_WORKBUDDY_MODELS = [
|
|
|
2859
2955
|
name: "Hy3",
|
|
2860
2956
|
contextWindow: 32e3,
|
|
2861
2957
|
maxOutputTokens: 8e3,
|
|
2862
|
-
supportsImages: true
|
|
2958
|
+
supportsImages: true,
|
|
2959
|
+
multiplier: 0
|
|
2863
2960
|
},
|
|
2864
2961
|
{
|
|
2865
2962
|
id: "hy4-preview",
|
|
2866
2963
|
name: "Hy4-Preview",
|
|
2867
2964
|
contextWindow: 1e6,
|
|
2868
2965
|
maxOutputTokens: 128e3,
|
|
2869
|
-
supportsImages: true
|
|
2966
|
+
supportsImages: true,
|
|
2967
|
+
multiplier: .29
|
|
2870
2968
|
}
|
|
2871
2969
|
];
|
|
2872
2970
|
/** Live catalog with a static fallback behind it. */
|
|
@@ -2944,7 +3042,8 @@ function toModelInfo(model) {
|
|
|
2944
3042
|
maxOutputTokens: model.maxTokens,
|
|
2945
3043
|
supportsImages: model.supportsImages ?? false,
|
|
2946
3044
|
...model.creditMultiplier === void 0 ? {} : { multiplier: model.creditMultiplier },
|
|
2947
|
-
...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts }
|
|
3045
|
+
...model.reasoning?.supportedEfforts === void 0 ? {} : { supportedEfforts: model.reasoning.supportedEfforts },
|
|
3046
|
+
...model.tags === void 0 || model.tags.length === 0 ? {} : { tags: model.tags }
|
|
2948
3047
|
};
|
|
2949
3048
|
}
|
|
2950
3049
|
/** Map the live upstream list, falling back to the static list when empty. */
|
|
@@ -3358,6 +3457,30 @@ function expertChatEvents(expert, conversationId, requestId) {
|
|
|
3358
3457
|
expert_industry_id: ""
|
|
3359
3458
|
} : event);
|
|
3360
3459
|
}
|
|
3460
|
+
//#endregion
|
|
3461
|
+
//#region src/status-paths.ts
|
|
3462
|
+
/**
|
|
3463
|
+
* The schedule every automation job falls back to.
|
|
3464
|
+
*
|
|
3465
|
+
* Shared by both halves on purpose. The host uses it when a configured hour
|
|
3466
|
+
* list arrives empty (the settings schema materializes "never configured" into
|
|
3467
|
+
* `[]`), and the card uses it when it writes the `automation` block back, so a
|
|
3468
|
+
* document that already holds an empty list is healed instead of being saved
|
|
3469
|
+
* back as an unrunnable schedule.
|
|
3470
|
+
*
|
|
3471
|
+
* This lives here rather than in `scheduler.ts` because the browser half cannot
|
|
3472
|
+
* import the host module: `scheduler.ts` pulls in `node:crypto` and the whole
|
|
3473
|
+
* upstream client, none of which exists in the browser bundle. Two hand-written
|
|
3474
|
+
* copies would drift, and the drift is invisible — the card would write a
|
|
3475
|
+
* schedule the scheduler does not run.
|
|
3476
|
+
*/
|
|
3477
|
+
const DEFAULT_AUTOMATION_HOURS = {
|
|
3478
|
+
checkin: [9],
|
|
3479
|
+
report: [10],
|
|
3480
|
+
tasks: [11],
|
|
3481
|
+
streak: [12],
|
|
3482
|
+
travel: [9, 21]
|
|
3483
|
+
};
|
|
3361
3484
|
/**
|
|
3362
3485
|
* The fallback record for the 腾讯轻量云 expert, used when the marketplace
|
|
3363
3486
|
* listing cannot be read. The id is the one the task is scored against.
|
|
@@ -3401,6 +3524,23 @@ const JOB_KINDS = [
|
|
|
3401
3524
|
"streak",
|
|
3402
3525
|
"travel"
|
|
3403
3526
|
];
|
|
3527
|
+
/**
|
|
3528
|
+
* Pick the hour list to run on: the configured one, or the default.
|
|
3529
|
+
*
|
|
3530
|
+
* An EMPTY list is treated as "not configured" rather than as "never run".
|
|
3531
|
+
* That distinction is the whole point: the settings schema materializes an
|
|
3532
|
+
* absent list into `[]` (a bare `z.array` with no `.default()`), so a config
|
|
3533
|
+
* that never mentioned the schedule arrives here looking exactly like one
|
|
3534
|
+
* deliberately set to nothing — and honouring the empty reading silently
|
|
3535
|
+
* disabled every job while the card still showed the switch as ON.
|
|
3536
|
+
*
|
|
3537
|
+
* A user who genuinely wants a job skipped can leave it out of the schedule
|
|
3538
|
+
* they save; they cannot express "off" with an empty array through the card
|
|
3539
|
+
* either way, because the card always writes a non-empty list.
|
|
3540
|
+
*/
|
|
3541
|
+
function hoursOrDefault(configured, fallback) {
|
|
3542
|
+
return configured !== void 0 && configured.length > 0 ? configured : fallback;
|
|
3543
|
+
}
|
|
3404
3544
|
const EMPTY_JOB_STATE = {
|
|
3405
3545
|
ok: 0,
|
|
3406
3546
|
failed: 0,
|
|
@@ -3576,11 +3716,11 @@ var WorkBuddyScheduler = class {
|
|
|
3576
3716
|
this.earningsDate = today;
|
|
3577
3717
|
}
|
|
3578
3718
|
this.enabled = options.enabled ?? false;
|
|
3579
|
-
this.checkinHours = options.checkinHours
|
|
3580
|
-
this.reportHours = options.reportHours
|
|
3581
|
-
this.taskHours = options.taskHours
|
|
3582
|
-
this.streakHours = options.streakHours
|
|
3583
|
-
this.travelHours = options.travelHours
|
|
3719
|
+
this.checkinHours = hoursOrDefault(options.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin);
|
|
3720
|
+
this.reportHours = hoursOrDefault(options.reportHours, DEFAULT_AUTOMATION_HOURS.report);
|
|
3721
|
+
this.taskHours = hoursOrDefault(options.taskHours, DEFAULT_AUTOMATION_HOURS.tasks);
|
|
3722
|
+
this.streakHours = hoursOrDefault(options.streakHours, DEFAULT_AUTOMATION_HOURS.streak);
|
|
3723
|
+
this.travelHours = hoursOrDefault(options.travelHours, DEFAULT_AUTOMATION_HOURS.travel);
|
|
3584
3724
|
}
|
|
3585
3725
|
/** Apply a new configuration; safe to call while running. */
|
|
3586
3726
|
/**
|
|
@@ -3608,11 +3748,11 @@ var WorkBuddyScheduler = class {
|
|
|
3608
3748
|
}
|
|
3609
3749
|
applyConfig(options) {
|
|
3610
3750
|
if (options.enabled !== void 0) this.enabled = options.enabled;
|
|
3611
|
-
if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
|
|
3612
|
-
if (options.reportHours !== void 0) this.reportHours = options.reportHours;
|
|
3613
|
-
if (options.taskHours !== void 0) this.taskHours = options.taskHours;
|
|
3614
|
-
if (options.streakHours !== void 0) this.streakHours = options.streakHours;
|
|
3615
|
-
if (options.travelHours !== void 0) this.travelHours = options.travelHours;
|
|
3751
|
+
if (options.checkinHours !== void 0) this.checkinHours = hoursOrDefault(options.checkinHours, DEFAULT_AUTOMATION_HOURS.checkin);
|
|
3752
|
+
if (options.reportHours !== void 0) this.reportHours = hoursOrDefault(options.reportHours, DEFAULT_AUTOMATION_HOURS.report);
|
|
3753
|
+
if (options.taskHours !== void 0) this.taskHours = hoursOrDefault(options.taskHours, DEFAULT_AUTOMATION_HOURS.tasks);
|
|
3754
|
+
if (options.streakHours !== void 0) this.streakHours = hoursOrDefault(options.streakHours, DEFAULT_AUTOMATION_HOURS.streak);
|
|
3755
|
+
if (options.travelHours !== void 0) this.travelHours = hoursOrDefault(options.travelHours, DEFAULT_AUTOMATION_HOURS.travel);
|
|
3616
3756
|
}
|
|
3617
3757
|
/** Hours for one job, used by the loop and the status document. */
|
|
3618
3758
|
hoursOf(kind) {
|
|
@@ -3658,7 +3798,7 @@ var WorkBuddyScheduler = class {
|
|
|
3658
3798
|
async runNow(kind, force = false) {
|
|
3659
3799
|
const today = dayKey(this.now());
|
|
3660
3800
|
const state = this.states[kind];
|
|
3661
|
-
if (!force && state.
|
|
3801
|
+
if (!force && state.lastFiredHour === slotKey(this.now())) return { ...state };
|
|
3662
3802
|
this.busy = true;
|
|
3663
3803
|
try {
|
|
3664
3804
|
await this.runJob(kind, today);
|
|
@@ -3756,13 +3896,46 @@ var WorkBuddyScheduler = class {
|
|
|
3756
3896
|
* two hours still runs twice a day — but a job whose hour passed while DSH was
|
|
3757
3897
|
* closed runs immediately on the next tick instead of waiting for tomorrow.
|
|
3758
3898
|
*/
|
|
3759
|
-
|
|
3899
|
+
/**
|
|
3900
|
+
* The configured slot `kind` should consume at `now`, or undefined when none.
|
|
3901
|
+
*
|
|
3902
|
+
* Returns the SLOT STRING (not a boolean) because the caller must record the
|
|
3903
|
+
* same value it acted on. Returning a boolean was the original bug's enabler:
|
|
3904
|
+
* the tick asked "is it due", then `runJob` independently wrote "what time is
|
|
3905
|
+
* it now" — and those two answers were almost never equal.
|
|
3906
|
+
*
|
|
3907
|
+
* CATCH-UP semantics are deliberate: a candidate fires once its hour has
|
|
3908
|
+
* PASSED and its slot is still unconsumed, so a laptop that slept through
|
|
3909
|
+
* 10:00 still runs the job when it wakes, on the same day.
|
|
3910
|
+
*
|
|
3911
|
+
* The EARLIEST unconsumed candidate wins, which is what keeps a two-hour job
|
|
3912
|
+
* (`travelHours: [9, 21]`) whole: consuming the earliest due slot leaves the
|
|
3913
|
+
* later one for its own hour.
|
|
3914
|
+
*/
|
|
3915
|
+
dueSlot(kind, now) {
|
|
3760
3916
|
const hours = this.hoursOf(kind);
|
|
3761
|
-
if (hours.length === 0) return
|
|
3762
|
-
const
|
|
3763
|
-
const current = Number(hour);
|
|
3917
|
+
if (hours.length === 0) return void 0;
|
|
3918
|
+
const current = Number(zonedParts(now).hour);
|
|
3764
3919
|
const today = dayKey(now);
|
|
3765
|
-
|
|
3920
|
+
const fired = this.firedSlotsOf(kind);
|
|
3921
|
+
for (const candidate of [...hours].sort((a, b) => a - b)) {
|
|
3922
|
+
if (candidate > current) break;
|
|
3923
|
+
const slot = `${today}T${String(candidate).padStart(2, "0")}`;
|
|
3924
|
+
if (!fired.has(slot)) return slot;
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
/** Today's consumed slots for one job, as a set. */
|
|
3928
|
+
firedSlotsOf(kind) {
|
|
3929
|
+
const stored = this.states[kind].firedSlots;
|
|
3930
|
+
if (stored === void 0) return /* @__PURE__ */ new Set();
|
|
3931
|
+
const today = dayKey(this.now());
|
|
3932
|
+
return new Set(stored.filter((slot) => slot.startsWith(`${today}T`)));
|
|
3933
|
+
}
|
|
3934
|
+
/** Record one consumed slot on a job's state. */
|
|
3935
|
+
consumeSlot(kind, slot) {
|
|
3936
|
+
const state = this.states[kind];
|
|
3937
|
+
const today = dayKey(this.now());
|
|
3938
|
+
state.firedSlots = [.../* @__PURE__ */ new Set([...this.firedSlotsOf(kind), slot])].filter((entry) => entry.startsWith(`${today}T`)).sort();
|
|
3766
3939
|
}
|
|
3767
3940
|
async tick() {
|
|
3768
3941
|
if (!this.enabled || this.stopped || this.busy) return;
|
|
@@ -3773,9 +3946,10 @@ var WorkBuddyScheduler = class {
|
|
|
3773
3946
|
const today = dayKey(now);
|
|
3774
3947
|
for (const kind of JOB_KINDS) {
|
|
3775
3948
|
if (this.stopped) return;
|
|
3776
|
-
if (this.states[kind].
|
|
3777
|
-
|
|
3778
|
-
|
|
3949
|
+
if (this.states[kind].lastFiredHour === slot) continue;
|
|
3950
|
+
const consumed = this.dueSlot(kind, now);
|
|
3951
|
+
if (consumed === void 0) continue;
|
|
3952
|
+
await this.runJob(kind, today, consumed);
|
|
3779
3953
|
}
|
|
3780
3954
|
} catch (error) {
|
|
3781
3955
|
this.logger.warn?.("dsh-workbuddy-xdpool automation tick failed:", error);
|
|
@@ -3861,6 +4035,12 @@ var WorkBuddyScheduler = class {
|
|
|
3861
4035
|
/**
|
|
3862
4036
|
* Run one job against every eligible account and record the outcome.
|
|
3863
4037
|
*
|
|
4038
|
+
* `consumedSlot` is the configured slot this run spends (`dueSlot`'s answer).
|
|
4039
|
+
* It is passed IN rather than recomputed here so the value recorded is exactly
|
|
4040
|
+
* the value the schedule decided on — the defect this replaces wrote
|
|
4041
|
+
* `slotKey(now)` instead, i.e. the clock time the run finished at, which
|
|
4042
|
+
* almost never equals the configured candidate the gate had compared against.
|
|
4043
|
+
*
|
|
3864
4044
|
* The task job runs in TWO passes. The first sends the event chains that light
|
|
3865
4045
|
* up client-scored tasks; the second collects rewards. They are separate
|
|
3866
4046
|
* because scoring lands asynchronously — a chain sent and claimed within the
|
|
@@ -3868,7 +4048,7 @@ var WorkBuddyScheduler = class {
|
|
|
3868
4048
|
* while claiming wants the whole pool to have been lit up first. Splitting
|
|
3869
4049
|
* them costs one shared wait instead of one wait per account.
|
|
3870
4050
|
*/
|
|
3871
|
-
async runJob(kind, today) {
|
|
4051
|
+
async runJob(kind, today, consumedSlot) {
|
|
3872
4052
|
const accountWord = kind === "report" ? "report" : kind;
|
|
3873
4053
|
let ok = 0;
|
|
3874
4054
|
let failed = 0;
|
|
@@ -3933,7 +4113,8 @@ var WorkBuddyScheduler = class {
|
|
|
3933
4113
|
if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
|
|
3934
4114
|
}
|
|
3935
4115
|
const state = this.states[kind];
|
|
3936
|
-
|
|
4116
|
+
if (consumedSlot !== void 0) this.consumeSlot(kind, consumedSlot);
|
|
4117
|
+
state.lastFiredHour = slotKey(this.now());
|
|
3937
4118
|
state.lastRunDate = today;
|
|
3938
4119
|
state.lastRunAtMs = this.now().getTime();
|
|
3939
4120
|
state.ok = ok;
|
|
@@ -4275,6 +4456,137 @@ function formatRates(status) {
|
|
|
4275
4456
|
return lines.join("\n");
|
|
4276
4457
|
}
|
|
4277
4458
|
//#endregion
|
|
4459
|
+
//#region src/ignored.ts
|
|
4460
|
+
/**
|
|
4461
|
+
* The permanent ignore list: accounts the user has thrown out of the pool.
|
|
4462
|
+
*
|
|
4463
|
+
* Distinct from `disabledAccountIds`, which is a ROTATION preference. A disabled
|
|
4464
|
+
* account stays on the card, can be switched back on, and its credential keeps
|
|
4465
|
+
* being read on every scan. Ignoring is a statement about the ACCOUNT — "this
|
|
4466
|
+
* one is not mine any more" — so an ignored account is skipped BEFORE its
|
|
4467
|
+
* credential is decrypted, never appears among the pool's accounts, and does not
|
|
4468
|
+
* come back when the desktop app writes a fresh `.info` file for it.
|
|
4469
|
+
*
|
|
4470
|
+
* It lives in a plugin-owned file rather than in the settings document for one
|
|
4471
|
+
* practical reason: the CLI has no settings service. `dsh-workbuddy-xdpool
|
|
4472
|
+
* ignore <id>` runs without a host, so a settings-only list could be written by
|
|
4473
|
+
* the card and never by the CLI. One file, read by both halves, keeps the two
|
|
4474
|
+
* views of "which accounts are ignored" identical by construction.
|
|
4475
|
+
*
|
|
4476
|
+
* @module dsh-workbuddy-xdpool/ignored
|
|
4477
|
+
*/
|
|
4478
|
+
/** Directory holding this plugin's own state (imported snapshots, ignore list). */
|
|
4479
|
+
const PLUGIN_DATA_DIR_NAME = ".workbuddy-xdpool";
|
|
4480
|
+
/** File holding the permanent ignore list, inside {@link pluginDataDir}. */
|
|
4481
|
+
const IGNORED_FILE_NAME = "ignored.json";
|
|
4482
|
+
/**
|
|
4483
|
+
* The DSH home directory, honouring the same override the host uses.
|
|
4484
|
+
*
|
|
4485
|
+
* Shared by the CLI and the host so both halves resolve the same file: an
|
|
4486
|
+
* `ignore` written from the terminal has to be visible to the running plugin,
|
|
4487
|
+
* which is only true if they agree on where "home" is.
|
|
4488
|
+
*/
|
|
4489
|
+
function dshHome$1(env = process.env) {
|
|
4490
|
+
const fromEnv = env["DSH_HOME"];
|
|
4491
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
|
|
4492
|
+
return join(homedir(), ".dsh");
|
|
4493
|
+
}
|
|
4494
|
+
/** This plugin's own state directory. */
|
|
4495
|
+
function pluginDataDir(env = process.env) {
|
|
4496
|
+
return join(dshHome$1(env), PLUGIN_DATA_DIR_NAME);
|
|
4497
|
+
}
|
|
4498
|
+
/** Absolute path of the ignore list. */
|
|
4499
|
+
function ignoredIdsPath(env = process.env) {
|
|
4500
|
+
return join(pluginDataDir(env), IGNORED_FILE_NAME);
|
|
4501
|
+
}
|
|
4502
|
+
/**
|
|
4503
|
+
* Normalize whatever the file holds into a clean list.
|
|
4504
|
+
*
|
|
4505
|
+
* Tolerant on purpose: this file is hand-editable and a malformed entry must not
|
|
4506
|
+
* take the pool down. An entry without a usable id is dropped; a missing label
|
|
4507
|
+
* falls back to the id so the card always has something to print.
|
|
4508
|
+
*/
|
|
4509
|
+
function normalize(raw) {
|
|
4510
|
+
if (typeof raw !== "object" || raw === null) return [];
|
|
4511
|
+
const entries = raw.accounts;
|
|
4512
|
+
if (!Array.isArray(entries)) return [];
|
|
4513
|
+
const out = [];
|
|
4514
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4515
|
+
for (const entry of entries) {
|
|
4516
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
4517
|
+
const record = entry;
|
|
4518
|
+
const id = typeof record["id"] === "string" ? record["id"].trim() : "";
|
|
4519
|
+
if (id === "" || seen.has(id)) continue;
|
|
4520
|
+
seen.add(id);
|
|
4521
|
+
const label = typeof record["label"] === "string" && record["label"].trim() !== "" ? record["label"] : id;
|
|
4522
|
+
const ignoredAt = typeof record["ignoredAt"] === "string" ? record["ignoredAt"] : "";
|
|
4523
|
+
out.push({
|
|
4524
|
+
id,
|
|
4525
|
+
label,
|
|
4526
|
+
ignoredAt
|
|
4527
|
+
});
|
|
4528
|
+
}
|
|
4529
|
+
return out;
|
|
4530
|
+
}
|
|
4531
|
+
/**
|
|
4532
|
+
* Read the ignore list, tolerating every "no list yet" shape.
|
|
4533
|
+
*
|
|
4534
|
+
* A missing file, unreadable file, or invalid JSON all mean the same thing to
|
|
4535
|
+
* the caller — nothing is ignored — so none of them throws. The pool must be
|
|
4536
|
+
* able to start on a machine that has never ignored anything.
|
|
4537
|
+
*/
|
|
4538
|
+
async function readIgnoredAccounts(path = ignoredIdsPath()) {
|
|
4539
|
+
try {
|
|
4540
|
+
return normalize(JSON.parse(await readFile(path, "utf8")));
|
|
4541
|
+
} catch {
|
|
4542
|
+
return [];
|
|
4543
|
+
}
|
|
4544
|
+
}
|
|
4545
|
+
/**
|
|
4546
|
+
* Replace the ignore list, atomically.
|
|
4547
|
+
*
|
|
4548
|
+
* Written to a sibling temp file and renamed over the target so a crash (or a
|
|
4549
|
+
* concurrent reader) can never observe a half-written document — the ignore
|
|
4550
|
+
* list is the only thing standing between a dead account and the rotation, and
|
|
4551
|
+
* a truncated file reads as "nothing is ignored", which would quietly put every
|
|
4552
|
+
* discarded account back in the pool.
|
|
4553
|
+
*/
|
|
4554
|
+
async function writeIgnoredAccounts(accounts, path = ignoredIdsPath()) {
|
|
4555
|
+
const payload = JSON.stringify({
|
|
4556
|
+
version: 1,
|
|
4557
|
+
accounts
|
|
4558
|
+
}, null, 2);
|
|
4559
|
+
await mkdir(dirname(path), { recursive: true });
|
|
4560
|
+
const temp = `${path}.tmp`;
|
|
4561
|
+
await writeFile$1(temp, `${payload}\n`, "utf8");
|
|
4562
|
+
await rename(temp, path);
|
|
4563
|
+
}
|
|
4564
|
+
/**
|
|
4565
|
+
* Add one account to the ignore list, preserving the rest.
|
|
4566
|
+
*
|
|
4567
|
+
* A read-modify-write rather than a wholesale replace: the card and the CLI can
|
|
4568
|
+
* both be open, and each request names exactly one account, so re-writing the
|
|
4569
|
+
* whole list from a stale view would drop the other side's edits.
|
|
4570
|
+
*/
|
|
4571
|
+
async function ignoreAccount(account, path = ignoredIdsPath()) {
|
|
4572
|
+
const current = await readIgnoredAccounts(path);
|
|
4573
|
+
if (current.some((entry) => entry.id === account.id)) return current;
|
|
4574
|
+
const next = [...current, {
|
|
4575
|
+
id: account.id,
|
|
4576
|
+
label: account.label ?? account.id,
|
|
4577
|
+
ignoredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4578
|
+
}];
|
|
4579
|
+
await writeIgnoredAccounts(next, path);
|
|
4580
|
+
return next;
|
|
4581
|
+
}
|
|
4582
|
+
/** Drop one account from the ignore list. Returns the resulting list. */
|
|
4583
|
+
async function unignoreAccount(accountId, path = ignoredIdsPath()) {
|
|
4584
|
+
const current = await readIgnoredAccounts(path);
|
|
4585
|
+
const next = current.filter((entry) => entry.id !== accountId);
|
|
4586
|
+
if (next.length !== current.length) await writeIgnoredAccounts(next, path);
|
|
4587
|
+
return next;
|
|
4588
|
+
}
|
|
4589
|
+
//#endregion
|
|
4278
4590
|
//#region src/index.ts
|
|
4279
4591
|
/**
|
|
4280
4592
|
* One region's model-selection schema.
|
|
@@ -4376,6 +4688,20 @@ function createCore(logger) {
|
|
|
4376
4688
|
*/
|
|
4377
4689
|
/** Directory holding imported account snapshots. */
|
|
4378
4690
|
const ACCOUNT_DIR_NAME = ".workbuddy-xdpool";
|
|
4691
|
+
/**
|
|
4692
|
+
* Assemble the runtime for a CLI command, with the ignore list applied.
|
|
4693
|
+
*
|
|
4694
|
+
* Every command goes through here rather than calling `createCore()` directly,
|
|
4695
|
+
* so a command can never accidentally act on an account the user has thrown out
|
|
4696
|
+
* — `checkin all` collecting a reward for a discarded account would be exactly
|
|
4697
|
+
* the kind of silent surprise the ignore feature exists to prevent.
|
|
4698
|
+
*/
|
|
4699
|
+
async function cliCore() {
|
|
4700
|
+
const core = createCore();
|
|
4701
|
+
const ignored = await readIgnoredAccounts();
|
|
4702
|
+
core.pool.applyIgnored(ignored.map((entry) => entry.id));
|
|
4703
|
+
return core;
|
|
4704
|
+
}
|
|
4379
4705
|
/** Snapshot files are named by the md5 prefix of their key, so any key is safe. */
|
|
4380
4706
|
function snapshotPath(key, dir) {
|
|
4381
4707
|
return join(dir, `${createHash("md5").update(key).digest("hex").slice(0, 8)}.json`);
|
|
@@ -4400,6 +4726,9 @@ function usage() {
|
|
|
4400
4726
|
" accounts List discovered accounts (add --json)",
|
|
4401
4727
|
" import <key> Snapshot the current desktop login as <key> (add --force)",
|
|
4402
4728
|
" remove <key> Delete one imported snapshot",
|
|
4729
|
+
" ignore <acct> Drop an account from the pool for good (id or label)",
|
|
4730
|
+
" unignore <acct> Put an ignored account back into the pool",
|
|
4731
|
+
" ignored List the accounts dropped from the pool (add --json)",
|
|
4403
4732
|
" login Guide for adding another account (desktop app is single-sign-in)",
|
|
4404
4733
|
" checkin [all|<acct>] Daily check-in: report status, or collect with `all` / a label",
|
|
4405
4734
|
" reset Clear all rate-limit cooldowns immediately",
|
|
@@ -4428,7 +4757,7 @@ async function commandStatus(args) {
|
|
|
4428
4757
|
const asJson = args.includes("--json");
|
|
4429
4758
|
const withCredits = args.includes("--credits");
|
|
4430
4759
|
const withRates = args.includes("--rates");
|
|
4431
|
-
const core =
|
|
4760
|
+
const core = await cliCore();
|
|
4432
4761
|
const accounts = await core.pool.scan();
|
|
4433
4762
|
const status = {
|
|
4434
4763
|
ok: accounts.length > 0,
|
|
@@ -4489,7 +4818,7 @@ async function commandStatus(args) {
|
|
|
4489
4818
|
async function commandCheckin(args) {
|
|
4490
4819
|
const asJson = args.includes("--json");
|
|
4491
4820
|
const target = args.find((arg) => !arg.startsWith("--"));
|
|
4492
|
-
const core =
|
|
4821
|
+
const core = await cliCore();
|
|
4493
4822
|
if ((await core.pool.scan()).length === 0) {
|
|
4494
4823
|
console.error("No WorkBuddy account discovered. Sign in with the WorkBuddy desktop app first.");
|
|
4495
4824
|
return 1;
|
|
@@ -4573,7 +4902,7 @@ async function commandDoctor() {
|
|
|
4573
4902
|
lines.push(` ✓ ${dir} → ${snapshots.length} snapshot(s)`);
|
|
4574
4903
|
}
|
|
4575
4904
|
lines.push("");
|
|
4576
|
-
const accounts = await
|
|
4905
|
+
const accounts = await (await cliCore()).pool.scan();
|
|
4577
4906
|
lines.push(`Accounts discovered: ${accounts.length}`);
|
|
4578
4907
|
if (accounts.length === 0) {
|
|
4579
4908
|
healthy = false;
|
|
@@ -4651,6 +4980,66 @@ async function commandRemove(args) {
|
|
|
4651
4980
|
return 1;
|
|
4652
4981
|
}
|
|
4653
4982
|
}
|
|
4983
|
+
/**
|
|
4984
|
+
* Throw one account out of the pool for good, or take it back.
|
|
4985
|
+
*
|
|
4986
|
+
* The target may be a full account id (as `accounts --json` prints it) or any
|
|
4987
|
+
* unambiguous fragment of the label, so the user does not have to copy a hash.
|
|
4988
|
+
* The id is what gets stored — a label can change when the account is renamed,
|
|
4989
|
+
* and an ignore list keyed by a mutable label would silently stop matching.
|
|
4990
|
+
*/
|
|
4991
|
+
async function commandIgnore(args, ignored) {
|
|
4992
|
+
const verb = ignored ? "ignore" : "unignore";
|
|
4993
|
+
const target = args.filter((arg) => !arg.startsWith("--"))[0];
|
|
4994
|
+
if (target === void 0) {
|
|
4995
|
+
console.error(`usage: dsh-workbuddy-xdpool ${verb} <account-id|label>`);
|
|
4996
|
+
return 2;
|
|
4997
|
+
}
|
|
4998
|
+
const accounts = await (await cliCore()).pool.scan();
|
|
4999
|
+
const current = await readIgnoredAccounts();
|
|
5000
|
+
if (!ignored) {
|
|
5001
|
+
const match = current.find((entry) => entry.id === target) ?? current.find((entry) => entry.label === target) ?? current.find((entry) => entry.id.startsWith(target));
|
|
5002
|
+
if (match === void 0) {
|
|
5003
|
+
console.error(`No ignored account matches "${target}". Run \`ignored\` to list them.`);
|
|
5004
|
+
return 1;
|
|
5005
|
+
}
|
|
5006
|
+
await unignoreAccount(match.id);
|
|
5007
|
+
console.log(`Restored ${match.label} (${match.id}). It rejoins the pool on the next scan.`);
|
|
5008
|
+
return 0;
|
|
5009
|
+
}
|
|
5010
|
+
const matches = accounts.filter((account) => account.id === target || account.label === target || account.id.startsWith(target) || account.label.includes(target));
|
|
5011
|
+
if (matches.length === 0) {
|
|
5012
|
+
console.error(`No account matches "${target}". Run \`accounts\` to list them.`);
|
|
5013
|
+
return 1;
|
|
5014
|
+
}
|
|
5015
|
+
if (matches.length > 1) {
|
|
5016
|
+
console.error(`"${target}" matches ${matches.length} accounts; use the full id:\n` + matches.map((account) => ` ${account.id} ${account.label}`).join("\n"));
|
|
5017
|
+
return 1;
|
|
5018
|
+
}
|
|
5019
|
+
const account = matches[0];
|
|
5020
|
+
await ignoreAccount({
|
|
5021
|
+
id: account.id,
|
|
5022
|
+
label: account.label
|
|
5023
|
+
});
|
|
5024
|
+
console.log(`Ignored ${account.label} (${account.id}).\n Its credential is no longer read and it will not rejoin the pool, even if the
|
|
5025
|
+
desktop app signs it in again. Undo with: dsh-workbuddy-xdpool unignore ${account.id}`);
|
|
5026
|
+
return 0;
|
|
5027
|
+
}
|
|
5028
|
+
/** List the accounts currently thrown out of the pool. */
|
|
5029
|
+
async function commandIgnored(args) {
|
|
5030
|
+
const asJson = args.includes("--json");
|
|
5031
|
+
const ignored = await readIgnoredAccounts();
|
|
5032
|
+
if (asJson) {
|
|
5033
|
+
console.log(JSON.stringify(ignored, null, 2));
|
|
5034
|
+
return 0;
|
|
5035
|
+
}
|
|
5036
|
+
if (ignored.length === 0) {
|
|
5037
|
+
console.log("No accounts are ignored. Use `ignore <account-id|label>` to drop one.");
|
|
5038
|
+
return 0;
|
|
5039
|
+
}
|
|
5040
|
+
console.log(ignored.map((entry) => `⛔ ${entry.label} (${entry.id})`).join("\n"));
|
|
5041
|
+
return 0;
|
|
5042
|
+
}
|
|
4654
5043
|
function commandLogin() {
|
|
4655
5044
|
console.log([
|
|
4656
5045
|
"Adding another WorkBuddy account",
|
|
@@ -4670,7 +5059,7 @@ function commandLogin() {
|
|
|
4670
5059
|
return 0;
|
|
4671
5060
|
}
|
|
4672
5061
|
async function commandReset() {
|
|
4673
|
-
const core =
|
|
5062
|
+
const core = await cliCore();
|
|
4674
5063
|
await core.pool.scan();
|
|
4675
5064
|
core.pool.resetCooldowns();
|
|
4676
5065
|
console.log("Cleared all rate-limit cooldowns.");
|
|
@@ -4678,15 +5067,25 @@ async function commandReset() {
|
|
|
4678
5067
|
}
|
|
4679
5068
|
async function commandAccounts(args) {
|
|
4680
5069
|
const asJson = args.includes("--json");
|
|
4681
|
-
const accounts = await
|
|
4682
|
-
|
|
5070
|
+
const accounts = await (await cliCore()).pool.scan();
|
|
5071
|
+
const ignored = await readIgnoredAccounts();
|
|
5072
|
+
if (asJson) console.log(JSON.stringify([...accounts.map((account) => ({
|
|
4683
5073
|
id: account.id,
|
|
4684
5074
|
label: account.label,
|
|
4685
5075
|
cooling: account.cooldownUntilMs > Date.now(),
|
|
4686
5076
|
rateLimitHits: account.rateLimitHits
|
|
4687
|
-
})),
|
|
4688
|
-
|
|
4689
|
-
|
|
5077
|
+
})), ...ignored.map((entry) => ({
|
|
5078
|
+
id: entry.id,
|
|
5079
|
+
label: entry.label,
|
|
5080
|
+
ignored: true,
|
|
5081
|
+
ignoredAt: entry.ignoredAt
|
|
5082
|
+
}))], null, 2));
|
|
5083
|
+
else if (accounts.length === 0 && ignored.length === 0) console.log("No imported accounts. Run `import <key>` after signing in on the desktop app.");
|
|
5084
|
+
else {
|
|
5085
|
+
const lines = accounts.map((account) => `${account.cooldownUntilMs > Date.now() ? "⏸" : "▶"} ${account.label}`);
|
|
5086
|
+
if (ignored.length > 0) lines.push(...ignored.map((entry) => `⛔ ${entry.label} (removed; \`unignore ${entry.id}\` to restore)`));
|
|
5087
|
+
console.log(lines.join("\n"));
|
|
5088
|
+
}
|
|
4690
5089
|
return 0;
|
|
4691
5090
|
}
|
|
4692
5091
|
/** Entry point; returns the process exit code. */
|
|
@@ -4704,6 +5103,9 @@ async function main(argv) {
|
|
|
4704
5103
|
case "accounts": return commandAccounts(rest);
|
|
4705
5104
|
case "import": return commandImport(rest);
|
|
4706
5105
|
case "remove": return commandRemove(rest);
|
|
5106
|
+
case "ignore": return commandIgnore(rest, true);
|
|
5107
|
+
case "unignore": return commandIgnore(rest, false);
|
|
5108
|
+
case "ignored": return commandIgnored(rest);
|
|
4707
5109
|
case "login": return commandLogin();
|
|
4708
5110
|
case "logout": return commandRemove(["default", ...rest]);
|
|
4709
5111
|
case "reset": return commandReset();
|