dsh-workbuddy-xdpool 1.0.0 → 1.1.0
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 +96 -0
- package/lib/bin.js +614 -8
- package/lib/client.js +303 -123
- package/lib/index.d.ts +377 -0
- package/lib/index.js +697 -16
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -302,6 +302,50 @@ function parseUpstreamModel(value) {
|
|
|
302
302
|
...supportsImages === void 0 ? {} : { supportsImages }
|
|
303
303
|
};
|
|
304
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Flatten one upstream task entry.
|
|
307
|
+
*
|
|
308
|
+
* Progress is reported two ways depending on the task: flat `current`/`target`
|
|
309
|
+
* fields, or a nested `progress: {current, target}`. The nested form wins when
|
|
310
|
+
* it carries anything, because a task that reports both puts the live counter
|
|
311
|
+
* there. Entries without a usable `task_code` are dropped — without one the
|
|
312
|
+
* claim path cannot be built.
|
|
313
|
+
*/
|
|
314
|
+
function parseTask(value) {
|
|
315
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
316
|
+
const raw = value;
|
|
317
|
+
const taskCode = typeof raw["task_code"] === "string" ? raw["task_code"] : "";
|
|
318
|
+
if (taskCode === "") return void 0;
|
|
319
|
+
const num = (key) => typeof raw[key] === "number" ? raw[key] : 0;
|
|
320
|
+
let current = num("current");
|
|
321
|
+
let target = num("target");
|
|
322
|
+
const progress = raw["progress"];
|
|
323
|
+
if (typeof progress === "object" && progress !== null) {
|
|
324
|
+
const nested = progress;
|
|
325
|
+
const nestedCurrent = typeof nested["current"] === "number" ? nested["current"] : 0;
|
|
326
|
+
const nestedTarget = typeof nested["target"] === "number" ? nested["target"] : 0;
|
|
327
|
+
if (nestedTarget > 0 || nestedCurrent > 0) {
|
|
328
|
+
current = nestedCurrent;
|
|
329
|
+
target = nestedTarget;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const acceptStatus = typeof raw["accept_status"] === "string" ? raw["accept_status"] : "";
|
|
333
|
+
const claimed = acceptStatus === "claimed";
|
|
334
|
+
return {
|
|
335
|
+
taskCode,
|
|
336
|
+
title: typeof raw["title"] === "string" && raw["title"] !== "" ? raw["title"] : taskCode,
|
|
337
|
+
credit: num("reward_credit"),
|
|
338
|
+
energy: num("reward_energy"),
|
|
339
|
+
hasReward: raw["has_reward"] === true,
|
|
340
|
+
target,
|
|
341
|
+
current,
|
|
342
|
+
acceptStatus,
|
|
343
|
+
status: typeof raw["status"] === "string" ? raw["status"] : "",
|
|
344
|
+
claimable: !claimed && target > 0 && current >= target,
|
|
345
|
+
claimed,
|
|
346
|
+
locked: raw["locked"] === true
|
|
347
|
+
};
|
|
348
|
+
}
|
|
305
349
|
var WorkBuddyUpstreamClient = class {
|
|
306
350
|
fetchImpl;
|
|
307
351
|
clientVersion;
|
|
@@ -578,6 +622,100 @@ var WorkBuddyUpstreamClient = class {
|
|
|
578
622
|
isStreakDay: data["is_streak_day"] === true
|
|
579
623
|
};
|
|
580
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* Report one chat-activity event to the growth system.
|
|
627
|
+
*
|
|
628
|
+
* The body is an ARRAY holding a single `chat_request_send` event, and every
|
|
629
|
+
* field is filled in: a three-field minimal event is accepted with 200 and
|
|
630
|
+
* then silently dropped, so the full shape is load-bearing rather than
|
|
631
|
+
* cosmetic. `userId` is the one field the server actually keys on — without
|
|
632
|
+
* it the request still answers 200 and scores nothing.
|
|
633
|
+
*
|
|
634
|
+
* One report per account per day is the quota the reference panel settled on;
|
|
635
|
+
* a single report lights the growth streak and unlocks the `first_buddy`
|
|
636
|
+
* family, which is why this runs before the task-centre pass.
|
|
637
|
+
*/
|
|
638
|
+
async reportActivity(credential, conversationId) {
|
|
639
|
+
const conversationID = conversationId ?? `wb2api-${Date.now()}`;
|
|
640
|
+
const requestID = conversationID;
|
|
641
|
+
const now = Date.now();
|
|
642
|
+
const event = {
|
|
643
|
+
eventCode: "chat_request_send",
|
|
644
|
+
timestamp: now,
|
|
645
|
+
reportDelay: 0,
|
|
646
|
+
mode: "craft",
|
|
647
|
+
conversationId: conversationID,
|
|
648
|
+
requestId: requestID,
|
|
649
|
+
inputLength: 12,
|
|
650
|
+
requestModelId: "deepseek-v4-flash",
|
|
651
|
+
requestModelName: "DeepSeek V4 Flash",
|
|
652
|
+
isPlan: false,
|
|
653
|
+
isAutoExecuteTerminal: false,
|
|
654
|
+
isAutoModify: false,
|
|
655
|
+
codebaseEnable: false,
|
|
656
|
+
maxToken: 0,
|
|
657
|
+
maxSteps: 0,
|
|
658
|
+
temperature: 0,
|
|
659
|
+
maxRetries: 0,
|
|
660
|
+
mentionContexts: [],
|
|
661
|
+
knowledgeId: [],
|
|
662
|
+
knowledgeName: [],
|
|
663
|
+
codebaseId: "",
|
|
664
|
+
mentionContextCount: 0,
|
|
665
|
+
command: "",
|
|
666
|
+
expertId: "",
|
|
667
|
+
recommendId: "",
|
|
668
|
+
skillId: "",
|
|
669
|
+
skillCount: 0,
|
|
670
|
+
totalCount: 0,
|
|
671
|
+
fileUri: "",
|
|
672
|
+
presentAt: now,
|
|
673
|
+
traceId: "",
|
|
674
|
+
rootRequestId: requestID,
|
|
675
|
+
parentConversationId: conversationID,
|
|
676
|
+
agentName: "default",
|
|
677
|
+
agentType: "conversation",
|
|
678
|
+
userId: credential.uid ?? ""
|
|
679
|
+
};
|
|
680
|
+
const response = await this.fetchImpl(`${billingBase(credential)}/v2/report`, {
|
|
681
|
+
method: "POST",
|
|
682
|
+
headers: billingHeaders(credential),
|
|
683
|
+
body: JSON.stringify([event]),
|
|
684
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
685
|
+
});
|
|
686
|
+
const envelope = await readEnvelope(response);
|
|
687
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Read back the growth streak in days.
|
|
691
|
+
*
|
|
692
|
+
* This is the read-only oracle for {@link reportActivity}: a report that
|
|
693
|
+
* returned 200 yet left the streak untouched was silently dropped (a missing
|
|
694
|
+
* `userId` is the usual cause), so callers verify instead of trusting the
|
|
695
|
+
* status code.
|
|
696
|
+
*
|
|
697
|
+
* Two shape traps, both measured against the live upstream:
|
|
698
|
+
*
|
|
699
|
+
* - The path carries NO `/v2` prefix, unlike its sibling task endpoints under
|
|
700
|
+
* `/v2/activity/growth/*`. Asking for the `/v2` form does not 404; it
|
|
701
|
+
* answers with a body that carries no `streak` object at all.
|
|
702
|
+
* - The counter is nested as `data.streak.days`, not `data.days`. Reading the
|
|
703
|
+
* flat field yields a constant 0, which would make every successful report
|
|
704
|
+
* look like a silent drop.
|
|
705
|
+
*
|
|
706
|
+
* Returns 0 only when the field is genuinely absent.
|
|
707
|
+
*/
|
|
708
|
+
async growthStreakDays(credential) {
|
|
709
|
+
const response = await this.fetchImpl(`${chatBase(credential)}/activity/growth/streak`, {
|
|
710
|
+
headers: billingHeaders(credential),
|
|
711
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
712
|
+
});
|
|
713
|
+
const envelope = await readEnvelope(response);
|
|
714
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
715
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
716
|
+
const streak = typeof data["streak"] === "object" && data["streak"] !== null ? data["streak"] : {};
|
|
717
|
+
return typeof streak["days"] === "number" ? streak["days"] : 0;
|
|
718
|
+
}
|
|
581
719
|
/** Legacy thin wrapper kept for `status`/`doctor`: returns raw envelope data. */
|
|
582
720
|
async credits(credential) {
|
|
583
721
|
try {
|
|
@@ -592,6 +730,87 @@ var WorkBuddyUpstreamClient = class {
|
|
|
592
730
|
};
|
|
593
731
|
}
|
|
594
732
|
}
|
|
733
|
+
/**
|
|
734
|
+
* Fetch the growth task list for one account.
|
|
735
|
+
*
|
|
736
|
+
* The upstream answers `data.tasks[]`, and `claimable` is derived locally —
|
|
737
|
+
* the upstream does not mark it. Only a task whose progress reached its
|
|
738
|
+
* target and that is not already claimed counts as eligible.
|
|
739
|
+
*/
|
|
740
|
+
async listTasks(credential) {
|
|
741
|
+
const response = await this.fetchImpl(`${chatBase(credential)}/v2/activity/growth/tasks`, {
|
|
742
|
+
headers: chatHeaders(credential),
|
|
743
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
744
|
+
});
|
|
745
|
+
const envelope = await readEnvelope(response);
|
|
746
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
747
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
748
|
+
const raw = Array.isArray(data["tasks"]) ? data["tasks"] : [];
|
|
749
|
+
const out = [];
|
|
750
|
+
for (const entry of raw) {
|
|
751
|
+
const parsed = parseTask(entry);
|
|
752
|
+
if (parsed !== void 0) out.push(parsed);
|
|
753
|
+
}
|
|
754
|
+
return out;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Accept (enrol in) tasks by code.
|
|
758
|
+
*
|
|
759
|
+
* Accepting is the "sign up" half: it produces no progress by itself, and the
|
|
760
|
+
* upstream answers success for an already-accepted task, so replaying this is
|
|
761
|
+
* safe. Progress is lit by real activity (a chat, an activity report).
|
|
762
|
+
*/
|
|
763
|
+
async acceptTasks(credential, taskCodes) {
|
|
764
|
+
if (taskCodes.length === 0) return;
|
|
765
|
+
const response = await this.fetchImpl(`${chatBase(credential)}/v2/activity/growth/tasks/accept`, {
|
|
766
|
+
method: "POST",
|
|
767
|
+
headers: chatHeaders(credential),
|
|
768
|
+
body: JSON.stringify({ task_codes: [...taskCodes] }),
|
|
769
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
770
|
+
});
|
|
771
|
+
const envelope = await readEnvelope(response);
|
|
772
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Claim one task's reward.
|
|
776
|
+
*
|
|
777
|
+
* Two details differ from list/accept and are load-bearing:
|
|
778
|
+
*
|
|
779
|
+
* - The task code rides the PATH, not the body.
|
|
780
|
+
* - It is served by the web origin, not the chat host, and only when the
|
|
781
|
+
* request carries the growth-centre Origin/Referer plus
|
|
782
|
+
* `x-client-platform: web`. The chat host's `/reward/claim` path does not
|
|
783
|
+
* exist and answers 400 "task not completed".
|
|
784
|
+
*
|
|
785
|
+
* A repeat claim answers `already_claimed` with zero credit, which is treated
|
|
786
|
+
* as success so the caller can stay idempotent.
|
|
787
|
+
*/
|
|
788
|
+
async claimTaskReward(credential, taskCode) {
|
|
789
|
+
const headers = {
|
|
790
|
+
...billingHeaders(credential),
|
|
791
|
+
"Accept": "application/json, text/plain, */*",
|
|
792
|
+
"Origin": "https://www.workbuddy.cn",
|
|
793
|
+
"Referer": "https://www.workbuddy.cn/profile/growth-center",
|
|
794
|
+
"x-client-platform": "web",
|
|
795
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
796
|
+
};
|
|
797
|
+
const response = await this.fetchImpl(`https://www.workbuddy.cn/activity/growth/tasks/${encodeURIComponent(taskCode)}/claim`, {
|
|
798
|
+
method: "POST",
|
|
799
|
+
headers,
|
|
800
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
801
|
+
});
|
|
802
|
+
const envelope = await readEnvelope(response);
|
|
803
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
804
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
805
|
+
if (data["already_claimed"] === true) return {
|
|
806
|
+
credit: 0,
|
|
807
|
+
energy: 0
|
|
808
|
+
};
|
|
809
|
+
return {
|
|
810
|
+
credit: typeof data["credit"] === "number" ? data["credit"] : 0,
|
|
811
|
+
energy: typeof data["energy"] === "number" ? data["energy"] : 0
|
|
812
|
+
};
|
|
813
|
+
}
|
|
595
814
|
};
|
|
596
815
|
//#endregion
|
|
597
816
|
//#region src/accounts.ts
|
|
@@ -794,10 +1013,18 @@ function idleWeight(lastUsedAt, now) {
|
|
|
794
1013
|
const hours = (now - lastUsedAt) / 36e5;
|
|
795
1014
|
return 1 + Math.min(Math.max(hours, 0) * IDLE_WEIGHT_PER_HOUR, IDLE_WEIGHT_MAX);
|
|
796
1015
|
}
|
|
1016
|
+
/** Default rest for an account whose credits ran out (packs reset on their own schedule). */
|
|
1017
|
+
const EXHAUST_COOLDOWN_MS = 18e5;
|
|
797
1018
|
var WorkBuddyAccountPool = class {
|
|
798
1019
|
logger;
|
|
799
1020
|
authDirs;
|
|
800
1021
|
cooldownMs;
|
|
1022
|
+
/**
|
|
1023
|
+
* How long an account stays out of rotation after the upstream reports its
|
|
1024
|
+
* credits are spent. Credit packs reset on their own schedule rather than on a
|
|
1025
|
+
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
1026
|
+
*/
|
|
1027
|
+
exhaustCooldownMs;
|
|
801
1028
|
client;
|
|
802
1029
|
refreshMarginMs;
|
|
803
1030
|
accounts = [];
|
|
@@ -829,6 +1056,7 @@ var WorkBuddyAccountPool = class {
|
|
|
829
1056
|
this.logger = options.logger;
|
|
830
1057
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
831
1058
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
1059
|
+
this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
|
|
832
1060
|
this.client = options.client;
|
|
833
1061
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
834
1062
|
this.distribution = options.distribution ?? "priority";
|
|
@@ -841,6 +1069,7 @@ var WorkBuddyAccountPool = class {
|
|
|
841
1069
|
applyConfig(options) {
|
|
842
1070
|
if (options.authDirs !== void 0 && options.authDirs.length > 0) this.authDirs = options.authDirs;
|
|
843
1071
|
if (options.cooldownMs !== void 0 && options.cooldownMs >= 1e3) this.cooldownMs = options.cooldownMs;
|
|
1072
|
+
if (options.exhaustCooldownMs !== void 0 && options.exhaustCooldownMs >= 1e3) this.exhaustCooldownMs = options.exhaustCooldownMs;
|
|
844
1073
|
if (options.distribution !== void 0) this.distribution = options.distribution;
|
|
845
1074
|
if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
|
|
846
1075
|
}
|
|
@@ -967,14 +1196,12 @@ var WorkBuddyAccountPool = class {
|
|
|
967
1196
|
if (this.preferredId !== void 0) {
|
|
968
1197
|
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
969
1198
|
if (preferred !== void 0) {
|
|
970
|
-
this.lastUsedAt.set(preferred.id, Date.now());
|
|
971
1199
|
await this.ensureFresh(preferred);
|
|
972
1200
|
return preferred;
|
|
973
1201
|
}
|
|
974
1202
|
}
|
|
975
1203
|
const account = this.distribution === "round-robin" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
976
1204
|
if (account === void 0) return void 0;
|
|
977
|
-
this.lastUsedAt.set(account.id, Date.now());
|
|
978
1205
|
await this.ensureFresh(account);
|
|
979
1206
|
return account;
|
|
980
1207
|
}
|
|
@@ -994,6 +1221,40 @@ var WorkBuddyAccountPool = class {
|
|
|
994
1221
|
disabledIdsInOrder() {
|
|
995
1222
|
return this.accounts.filter((account) => this.disabledIds.has(account.id)).map((account) => account.id);
|
|
996
1223
|
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Record that an account actually served a request.
|
|
1226
|
+
*
|
|
1227
|
+
* Called by the shim once the upstream answers 200 — only then is the account
|
|
1228
|
+
* the one the user is really being served by. `balanced` mode reads the same map
|
|
1229
|
+
* for its idle weighting, so a request that failed over to another account must
|
|
1230
|
+
* not count as used for the account that was merely tried.
|
|
1231
|
+
*/
|
|
1232
|
+
noteServed(accountId) {
|
|
1233
|
+
if (!this.accounts.some((account) => account.id === accountId)) return;
|
|
1234
|
+
this.lastUsedAt.set(accountId, Date.now());
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* The account that served the most recent request, if any.
|
|
1238
|
+
*
|
|
1239
|
+
* Distinct from "who would serve the next one": this is a record of what
|
|
1240
|
+
* actually happened, which is what the card needs to answer "which account am
|
|
1241
|
+
* I using right now?". Under `balanced` there is no deterministic next account
|
|
1242
|
+
* at all, so a recorded fact is the only honest answer.
|
|
1243
|
+
*
|
|
1244
|
+
* Returns undefined before the first request of the process, and after every
|
|
1245
|
+
* known account has been re-scanned away (a login swapped out under us).
|
|
1246
|
+
*/
|
|
1247
|
+
lastServedId() {
|
|
1248
|
+
let newest;
|
|
1249
|
+
for (const [id, at] of this.lastUsedAt) {
|
|
1250
|
+
if (!this.accounts.some((account) => account.id === id)) continue;
|
|
1251
|
+
if (newest === void 0 || at > newest.at) newest = {
|
|
1252
|
+
id,
|
|
1253
|
+
at
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
return newest?.id;
|
|
1257
|
+
}
|
|
997
1258
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
998
1259
|
async refreshAccount(accountId) {
|
|
999
1260
|
const account = this.accounts.find((item) => item.id === accountId);
|
|
@@ -1044,6 +1305,23 @@ var WorkBuddyAccountPool = class {
|
|
|
1044
1305
|
}
|
|
1045
1306
|
}
|
|
1046
1307
|
/**
|
|
1308
|
+
* Cool a whole account after the upstream reports its credits are spent.
|
|
1309
|
+
*
|
|
1310
|
+
* Credit exhaustion is an ACCOUNT condition, unlike a model rate limit: every
|
|
1311
|
+
* model on that account is unusable until the quota resets, so this cools the
|
|
1312
|
+
* account as a whole (no `modelId`) for the configured exhaustion window. The
|
|
1313
|
+
* shim then rotates to a different account instead of failing the request.
|
|
1314
|
+
*/
|
|
1315
|
+
penalizeExhausted(accountId) {
|
|
1316
|
+
const until = Date.now() + this.exhaustCooldownMs;
|
|
1317
|
+
this.penalize(accountId, until);
|
|
1318
|
+
this.logger?.warn(`
|
|
1319
|
+
dsh-workbuddy-xdpool: account credits exhausted; cooling the whole account until
|
|
1320
|
+
|
|
1321
|
+
${new Date(until).toISOString()}
|
|
1322
|
+
`);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1047
1325
|
* Mark an account (or one of its models) rate-limited.
|
|
1048
1326
|
*
|
|
1049
1327
|
* With `modelId`, only that model on the account is cooled — the account's
|
|
@@ -1436,6 +1714,311 @@ function createWorkBuddyAdapter(options) {
|
|
|
1436
1714
|
}
|
|
1437
1715
|
};
|
|
1438
1716
|
}
|
|
1717
|
+
/** How often the loop wakes to look for a due job. */
|
|
1718
|
+
const AUTOMATION_TICK_MS = 6e4;
|
|
1719
|
+
const JOB_KINDS = [
|
|
1720
|
+
"checkin",
|
|
1721
|
+
"report",
|
|
1722
|
+
"tasks",
|
|
1723
|
+
"streak"
|
|
1724
|
+
];
|
|
1725
|
+
const EMPTY_JOB_STATE = {
|
|
1726
|
+
ok: 0,
|
|
1727
|
+
failed: 0,
|
|
1728
|
+
credit: 0,
|
|
1729
|
+
energy: 0,
|
|
1730
|
+
claimed: 0
|
|
1731
|
+
};
|
|
1732
|
+
/** `YYYY-MM-DD` in local time, the day key every job resets on. */
|
|
1733
|
+
function dayKey(date) {
|
|
1734
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
1735
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
1736
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Whether `now`'s local hour is one of `hours`.
|
|
1740
|
+
*
|
|
1741
|
+
* The reference panel computes a `nextFire` instant and sleeps until it; this
|
|
1742
|
+
* loop instead wakes every minute and asks "is any job due now". Both fire at
|
|
1743
|
+
* the top of a configured hour, but the polling form cannot miss a slot to a
|
|
1744
|
+
* suspended process — a laptop that slept through 10:00 still runs the job the
|
|
1745
|
+
* moment it wakes, on the same day.
|
|
1746
|
+
*/
|
|
1747
|
+
function isFireHour(now, hours) {
|
|
1748
|
+
return hours.includes(now.getHours());
|
|
1749
|
+
}
|
|
1750
|
+
/** Whether this account may be used: not switched off, not cooling. */
|
|
1751
|
+
function eligible(account, now) {
|
|
1752
|
+
if (account.cooldownUntilMs > now) return false;
|
|
1753
|
+
if (regionOf(account.credential.domain) === "global") return false;
|
|
1754
|
+
return true;
|
|
1755
|
+
}
|
|
1756
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
1757
|
+
setTimeout(resolve, ms);
|
|
1758
|
+
});
|
|
1759
|
+
/**
|
|
1760
|
+
* The points automation.
|
|
1761
|
+
*
|
|
1762
|
+
* Owns a single timer loop. Construction is inert — nothing runs until
|
|
1763
|
+
* {@link start}, and {@link stop} is idempotent so a plugin teardown that fires
|
|
1764
|
+
* twice is harmless.
|
|
1765
|
+
*/
|
|
1766
|
+
var WorkBuddyScheduler = class {
|
|
1767
|
+
pool;
|
|
1768
|
+
client;
|
|
1769
|
+
logger;
|
|
1770
|
+
now;
|
|
1771
|
+
delayMs;
|
|
1772
|
+
enabled;
|
|
1773
|
+
checkinHours;
|
|
1774
|
+
taskHours;
|
|
1775
|
+
reportHours;
|
|
1776
|
+
streakHours;
|
|
1777
|
+
timer;
|
|
1778
|
+
running = false;
|
|
1779
|
+
/** Guards against a slow run overlapping the next tick. */
|
|
1780
|
+
busy = false;
|
|
1781
|
+
/**
|
|
1782
|
+
* Set once {@link stop} is called.
|
|
1783
|
+
*
|
|
1784
|
+
* Deliberately false before `start`: the loop is not running yet, but a
|
|
1785
|
+
* manual `tick` must still work. `stop` is what makes a run abandon the
|
|
1786
|
+
* accounts it has not reached yet.
|
|
1787
|
+
*/
|
|
1788
|
+
stopped = false;
|
|
1789
|
+
states = {
|
|
1790
|
+
checkin: { ...EMPTY_JOB_STATE },
|
|
1791
|
+
report: { ...EMPTY_JOB_STATE },
|
|
1792
|
+
tasks: { ...EMPTY_JOB_STATE },
|
|
1793
|
+
streak: { ...EMPTY_JOB_STATE }
|
|
1794
|
+
};
|
|
1795
|
+
claimableSeen = 0;
|
|
1796
|
+
constructor(pool, client, options = {}) {
|
|
1797
|
+
this.pool = pool;
|
|
1798
|
+
this.client = client;
|
|
1799
|
+
this.logger = options.logger ?? {};
|
|
1800
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
1801
|
+
this.delayMs = options.accountDelayMs ?? 800;
|
|
1802
|
+
this.enabled = options.enabled ?? false;
|
|
1803
|
+
this.checkinHours = options.checkinHours ?? [9];
|
|
1804
|
+
this.reportHours = options.reportHours ?? [10];
|
|
1805
|
+
this.taskHours = options.taskHours ?? [11];
|
|
1806
|
+
this.streakHours = options.streakHours ?? [12];
|
|
1807
|
+
}
|
|
1808
|
+
/** Apply a new configuration; safe to call while running. */
|
|
1809
|
+
applyConfig(options) {
|
|
1810
|
+
if (options.enabled !== void 0) this.enabled = options.enabled;
|
|
1811
|
+
if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
|
|
1812
|
+
if (options.reportHours !== void 0) this.reportHours = options.reportHours;
|
|
1813
|
+
if (options.taskHours !== void 0) this.taskHours = options.taskHours;
|
|
1814
|
+
if (options.streakHours !== void 0) this.streakHours = options.streakHours;
|
|
1815
|
+
}
|
|
1816
|
+
/** Hours for one job, used by the loop and the status document. */
|
|
1817
|
+
hoursOf(kind) {
|
|
1818
|
+
switch (kind) {
|
|
1819
|
+
case "checkin": return this.checkinHours;
|
|
1820
|
+
case "report": return this.reportHours;
|
|
1821
|
+
case "tasks": return this.taskHours;
|
|
1822
|
+
case "streak": return this.streakHours;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
/** Start the loop. Idempotent. */
|
|
1826
|
+
start() {
|
|
1827
|
+
if (this.timer !== void 0) return;
|
|
1828
|
+
this.stopped = false;
|
|
1829
|
+
this.running = true;
|
|
1830
|
+
this.timer = setInterval(() => {
|
|
1831
|
+
this.tick();
|
|
1832
|
+
}, AUTOMATION_TICK_MS);
|
|
1833
|
+
this.timer.unref?.();
|
|
1834
|
+
}
|
|
1835
|
+
/** Stop the loop. Idempotent, and safe before `start`. */
|
|
1836
|
+
stop() {
|
|
1837
|
+
this.stopped = true;
|
|
1838
|
+
this.running = false;
|
|
1839
|
+
if (this.timer !== void 0) {
|
|
1840
|
+
clearInterval(this.timer);
|
|
1841
|
+
this.timer = void 0;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
/** Snapshot for the status document. */
|
|
1845
|
+
status() {
|
|
1846
|
+
const jobs = {};
|
|
1847
|
+
for (const kind of JOB_KINDS) jobs[kind] = { ...this.states[kind] };
|
|
1848
|
+
return {
|
|
1849
|
+
enabled: this.enabled,
|
|
1850
|
+
running: this.running,
|
|
1851
|
+
checkinHours: this.checkinHours,
|
|
1852
|
+
taskHours: this.taskHours,
|
|
1853
|
+
reportHours: this.reportHours,
|
|
1854
|
+
streakHours: this.streakHours,
|
|
1855
|
+
jobs,
|
|
1856
|
+
claimableSeen: this.claimableSeen
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* One poll: run every due job, serially.
|
|
1861
|
+
*
|
|
1862
|
+
* Serial by design — the jobs share the same accounts and the upstream
|
|
1863
|
+
* rate-limits per account, so overlapping passes would only trip that limit.
|
|
1864
|
+
* A job that throws is recorded and the loop continues.
|
|
1865
|
+
*/
|
|
1866
|
+
async tick() {
|
|
1867
|
+
if (!this.enabled || this.stopped || this.busy) return;
|
|
1868
|
+
this.busy = true;
|
|
1869
|
+
try {
|
|
1870
|
+
const now = this.now();
|
|
1871
|
+
const today = dayKey(now);
|
|
1872
|
+
for (const kind of JOB_KINDS) {
|
|
1873
|
+
if (this.stopped) return;
|
|
1874
|
+
if (this.states[kind].lastRunDate === today) continue;
|
|
1875
|
+
if (!isFireHour(now, this.hoursOf(kind))) continue;
|
|
1876
|
+
await this.runJob(kind, today);
|
|
1877
|
+
}
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
this.logger.warn?.("dsh-workbuddy-xdpool automation tick failed:", error);
|
|
1880
|
+
} finally {
|
|
1881
|
+
this.busy = false;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
/** Run one job against every eligible account and record the outcome. */
|
|
1885
|
+
async runJob(kind, today) {
|
|
1886
|
+
const accountWord = kind === "report" ? "report" : kind;
|
|
1887
|
+
let ok = 0;
|
|
1888
|
+
let failed = 0;
|
|
1889
|
+
let credit = 0;
|
|
1890
|
+
let energy = 0;
|
|
1891
|
+
let claimed = 0;
|
|
1892
|
+
const accounts = this.accountsInOrder();
|
|
1893
|
+
for (let index = 0; index < accounts.length; index++) {
|
|
1894
|
+
if (this.stopped) break;
|
|
1895
|
+
const account = accounts[index];
|
|
1896
|
+
if (account === void 0) continue;
|
|
1897
|
+
try {
|
|
1898
|
+
switch (kind) {
|
|
1899
|
+
case "checkin":
|
|
1900
|
+
await this.client.claimDailyCheckin(account.credential);
|
|
1901
|
+
break;
|
|
1902
|
+
case "report": {
|
|
1903
|
+
const days = await this.reportOne(account);
|
|
1904
|
+
this.logger.info?.(`automation report ${account.label}: streak days=${days}`);
|
|
1905
|
+
break;
|
|
1906
|
+
}
|
|
1907
|
+
case "tasks": {
|
|
1908
|
+
const result = await this.runTasks(account);
|
|
1909
|
+
credit += result.credit;
|
|
1910
|
+
energy += result.energy;
|
|
1911
|
+
claimed += result.claimed;
|
|
1912
|
+
this.claimableSeen += result.claimableCount;
|
|
1913
|
+
this.logger.info?.(`automation tasks ${account.label}: ${result.claimed} claimed (+${result.credit} credit, +${result.energy} energy, ${result.claimableCount} claimable seen)`);
|
|
1914
|
+
break;
|
|
1915
|
+
}
|
|
1916
|
+
case "streak": await this.redeemStreak(account);
|
|
1917
|
+
}
|
|
1918
|
+
ok++;
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
failed++;
|
|
1921
|
+
this.logger.warn?.(`automation ${accountWord} ${account.label} failed:`, error);
|
|
1922
|
+
}
|
|
1923
|
+
if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
|
|
1924
|
+
}
|
|
1925
|
+
const state = this.states[kind];
|
|
1926
|
+
state.lastRunDate = today;
|
|
1927
|
+
state.lastRunAtMs = this.now().getTime();
|
|
1928
|
+
state.ok = ok;
|
|
1929
|
+
state.failed = failed;
|
|
1930
|
+
state.credit = credit;
|
|
1931
|
+
state.energy = energy;
|
|
1932
|
+
state.claimed = claimed;
|
|
1933
|
+
state.message = this.summarise(kind, ok, failed, claimed, credit, energy);
|
|
1934
|
+
this.logger.info?.(`automation ${accountWord}: ${state.message}`);
|
|
1935
|
+
}
|
|
1936
|
+
/** Compose the one-line summary shown on the card. */
|
|
1937
|
+
summarise(kind, ok, failed, claimed, credit, energy) {
|
|
1938
|
+
if (kind === "tasks") return `${ok} accounts, ${claimed} tasks claimed (+${credit} credit, +${energy} energy)${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
1939
|
+
return `${ok} accounts ok${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
1940
|
+
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Accounts to run against, in pool order.
|
|
1943
|
+
*
|
|
1944
|
+
* Disabled accounts are excluded here rather than filtered by the caller so a
|
|
1945
|
+
* card switch takes effect on the next pass without any event plumbing.
|
|
1946
|
+
*/
|
|
1947
|
+
accountsInOrder() {
|
|
1948
|
+
const now = Date.now();
|
|
1949
|
+
return this.pool.list().filter((account) => {
|
|
1950
|
+
if (this.pool.isDisabled(account.id)) return false;
|
|
1951
|
+
return eligible(account, now);
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Send one activity report, then verify it landed.
|
|
1956
|
+
*
|
|
1957
|
+
* The upstream answers 200 even when it drops the event, so the streak is
|
|
1958
|
+
* read back as the oracle: `days > 0` means it counted. A failed read-back is
|
|
1959
|
+
* logged and treated as a suspicious result, never as a retry — the report is
|
|
1960
|
+
* idempotent per day, and hammering it is exactly what the one-a-day quota
|
|
1961
|
+
* exists to avoid.
|
|
1962
|
+
*/
|
|
1963
|
+
async reportOne(account) {
|
|
1964
|
+
await this.client.reportActivity(account.credential);
|
|
1965
|
+
try {
|
|
1966
|
+
const days = await this.client.growthStreakDays(account.credential);
|
|
1967
|
+
if (days === 0) this.logger.warn?.(`automation report ${account.label}: streak days=0 right after report (new account or scoring lag?)`);
|
|
1968
|
+
return days;
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
this.logger.warn?.(`automation report ${account.label}: streak read-back failed:`, error);
|
|
1971
|
+
return -1;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
/**
|
|
1975
|
+
* The task-centre pass for one account.
|
|
1976
|
+
*
|
|
1977
|
+
* Order matters: enrich first (enrol in everything open), then claim. Both
|
|
1978
|
+
* halves are idempotent — accepting an already-accepted task succeeds, and a
|
|
1979
|
+
* repeat claim answers `already_claimed` — so a pass that dies halfway is
|
|
1980
|
+
* safe to replay on the next tick.
|
|
1981
|
+
*/
|
|
1982
|
+
async runTasks(account) {
|
|
1983
|
+
const credential = account.credential;
|
|
1984
|
+
const tasks = await this.client.listTasks(credential);
|
|
1985
|
+
const open = tasks.filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
|
|
1986
|
+
if (open.length > 0) await this.client.acceptTasks(credential, open);
|
|
1987
|
+
const claimable = (open.length > 0 ? await this.client.listTasks(credential) : tasks).filter((task) => task.claimable && !task.locked);
|
|
1988
|
+
let credit = 0;
|
|
1989
|
+
let energy = 0;
|
|
1990
|
+
let claimed = 0;
|
|
1991
|
+
for (const task of claimable) {
|
|
1992
|
+
if (this.stopped) break;
|
|
1993
|
+
const reward = await this.client.claimTaskReward(credential, task.taskCode);
|
|
1994
|
+
credit += reward.credit;
|
|
1995
|
+
energy += reward.energy;
|
|
1996
|
+
if (reward.credit > 0 || reward.energy > 0) {
|
|
1997
|
+
claimed++;
|
|
1998
|
+
this.logger.info?.(`automation claim ${account.label}: ${task.title} +${reward.credit}c +${reward.energy}e`);
|
|
1999
|
+
} else this.logger.info?.(`automation claim ${account.label}: ${task.taskCode} already claimed`);
|
|
2000
|
+
if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
|
|
2001
|
+
}
|
|
2002
|
+
return {
|
|
2003
|
+
claimed,
|
|
2004
|
+
credit,
|
|
2005
|
+
energy,
|
|
2006
|
+
claimableCount: claimable.length
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* Streak redemption and lottery.
|
|
2011
|
+
*
|
|
2012
|
+
* Left as a deliberate no-op placeholder: the tier/lottery endpoints need
|
|
2013
|
+
* their own round of probing against the live upstream before they can be
|
|
2014
|
+
* wired safely, and a wrong call here could burn a redemption. The job slot,
|
|
2015
|
+
* scheduling and status plumbing already exist, so filling it in is a
|
|
2016
|
+
* self-contained change.
|
|
2017
|
+
*/
|
|
2018
|
+
async redeemStreak(account) {
|
|
2019
|
+
this.logger.info?.(`automation streak ${account.label}: not implemented yet (skipped)`);
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
1439
2022
|
//#endregion
|
|
1440
2023
|
//#region src/shim.ts
|
|
1441
2024
|
/**
|
|
@@ -1655,6 +2238,7 @@ function createWorkBuddyShim(options) {
|
|
|
1655
2238
|
const result = await client.chatStream(account.credential, prepared, controller.signal);
|
|
1656
2239
|
if (result.ok) {
|
|
1657
2240
|
logger?.info?.(`dsh-workbuddy-xdpool: served by ${account.label}`);
|
|
2241
|
+
pool.noteServed(account.id);
|
|
1658
2242
|
res.writeHead(200, {
|
|
1659
2243
|
"Content-Type": "text/event-stream",
|
|
1660
2244
|
"Cache-Control": "no-cache",
|
|
@@ -1683,6 +2267,11 @@ function createWorkBuddyShim(options) {
|
|
|
1683
2267
|
await pool.refreshAccount(account.id);
|
|
1684
2268
|
continue;
|
|
1685
2269
|
}
|
|
2270
|
+
if (result.kind === "hard_credit") {
|
|
2271
|
+
pool.penalizeExhausted(account.id);
|
|
2272
|
+
logger?.warn(`dsh-workbuddy-xdpool: ${account.label} has no credits left (attempt ${attempt + 1}/${maxAttempts}); rotating`);
|
|
2273
|
+
continue;
|
|
2274
|
+
}
|
|
1686
2275
|
if (result.kind !== "soft_rate") break;
|
|
1687
2276
|
exhaustedByRateLimit = true;
|
|
1688
2277
|
pool.penalize(account.id, parseRateLimitReset(result.message), modelId);
|
|
@@ -1826,6 +2415,21 @@ const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabl
|
|
|
1826
2415
|
//#endregion
|
|
1827
2416
|
//#region src/web-status.ts
|
|
1828
2417
|
/** Redact token-like content before it crosses to the browser. */
|
|
2418
|
+
/**
|
|
2419
|
+
* A zeroed-out automation job record.
|
|
2420
|
+
*
|
|
2421
|
+
* Used when no scheduler is wired: the card renders the same shape either way,
|
|
2422
|
+
* so an unwired profile shows zeroes rather than a missing panel.
|
|
2423
|
+
*/
|
|
2424
|
+
function emptyAutomationJob() {
|
|
2425
|
+
return {
|
|
2426
|
+
ok: 0,
|
|
2427
|
+
failed: 0,
|
|
2428
|
+
credit: 0,
|
|
2429
|
+
energy: 0,
|
|
2430
|
+
claimed: 0
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
1829
2433
|
function safeMessage(error) {
|
|
1830
2434
|
return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
|
|
1831
2435
|
}
|
|
@@ -2014,7 +2618,8 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
2014
2618
|
rows.push(row);
|
|
2015
2619
|
}
|
|
2016
2620
|
const cooling = rows.filter((row) => row.cooling).length;
|
|
2017
|
-
const
|
|
2621
|
+
const lastServed = deps.pool.lastServedId();
|
|
2622
|
+
const firstUsable = lastServed !== void 0 ? accounts.find((account) => account.id === lastServed) : accounts.find((account) => account.cooldownUntilMs <= now);
|
|
2018
2623
|
let shim;
|
|
2019
2624
|
if (deps.shim === void 0) shim = { running: false };
|
|
2020
2625
|
else try {
|
|
@@ -2022,6 +2627,21 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
2022
2627
|
} catch {
|
|
2023
2628
|
shim = { running: false };
|
|
2024
2629
|
}
|
|
2630
|
+
const automation = deps.scheduler?.() ?? {
|
|
2631
|
+
enabled: false,
|
|
2632
|
+
running: false,
|
|
2633
|
+
checkinHours: [],
|
|
2634
|
+
reportHours: [],
|
|
2635
|
+
taskHours: [],
|
|
2636
|
+
streakHours: [],
|
|
2637
|
+
jobs: {
|
|
2638
|
+
checkin: emptyAutomationJob(),
|
|
2639
|
+
report: emptyAutomationJob(),
|
|
2640
|
+
tasks: emptyAutomationJob(),
|
|
2641
|
+
streak: emptyAutomationJob()
|
|
2642
|
+
},
|
|
2643
|
+
claimableSeen: 0
|
|
2644
|
+
};
|
|
2025
2645
|
return {
|
|
2026
2646
|
ok: accounts.length > 0 && cooling < accounts.length,
|
|
2027
2647
|
accounts: rows,
|
|
@@ -2042,7 +2662,8 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
2042
2662
|
region,
|
|
2043
2663
|
distribution: deps.pool.currentDistribution(),
|
|
2044
2664
|
regions,
|
|
2045
|
-
shim
|
|
2665
|
+
shim,
|
|
2666
|
+
automation
|
|
2046
2667
|
};
|
|
2047
2668
|
}
|
|
2048
2669
|
/**
|
|
@@ -2198,6 +2819,22 @@ const WORKBUDDY_POOL_SETTINGS_NS = "workbuddy-xdpool";
|
|
|
2198
2819
|
/** Upper bound the card offers as the "default" context window, in tokens. */
|
|
2199
2820
|
const DEFAULT_CONTEXT_BUDGET = 2e5;
|
|
2200
2821
|
/**
|
|
2822
|
+
* Fold a saved automation block into scheduler options.
|
|
2823
|
+
*
|
|
2824
|
+
* Absent means off, stated once here so every caller agrees: the card writes
|
|
2825
|
+
* `enabled` as a real boolean, and a config that never touched the section must
|
|
2826
|
+
* not accidentally arm background upstream traffic.
|
|
2827
|
+
*/
|
|
2828
|
+
function automationOptions(automation) {
|
|
2829
|
+
return {
|
|
2830
|
+
enabled: automation?.enabled === true,
|
|
2831
|
+
...automation?.checkinHours === void 0 ? {} : { checkinHours: automation.checkinHours },
|
|
2832
|
+
...automation?.reportHours === void 0 ? {} : { reportHours: automation.reportHours },
|
|
2833
|
+
...automation?.taskHours === void 0 ? {} : { taskHours: automation.taskHours },
|
|
2834
|
+
...automation?.streakHours === void 0 ? {} : { streakHours: automation.streakHours }
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2201
2838
|
* One region's model-selection schema.
|
|
2202
2839
|
*
|
|
2203
2840
|
* Every field is optional on purpose: an absent field keeps its documented
|
|
@@ -2210,6 +2847,26 @@ const modelSelectionSchema = z.object({
|
|
|
2210
2847
|
imageModelIds: z.array(z.string()).description("Model ids accepting image input in this region (absent = follow upstream)"),
|
|
2211
2848
|
contextBudgets: z.dict(z.number().step(1).min(1)).description("Per-model context-window override for this region")
|
|
2212
2849
|
});
|
|
2850
|
+
/**
|
|
2851
|
+
* Automation schema.
|
|
2852
|
+
*
|
|
2853
|
+
* `enabled` carries a real default (false) because the scheduler reads it on
|
|
2854
|
+
* every tick and a missing field must mean "off" rather than "undefined".
|
|
2855
|
+
* The hour lists fall back in the scheduler itself, so they stay optional here
|
|
2856
|
+
* and an absent list keeps the documented schedule.
|
|
2857
|
+
*
|
|
2858
|
+
* `exhaustCooldownMs` is mirrored from the pool options: the card offers it as
|
|
2859
|
+
* part of the automation block, since how long a spent account rests only
|
|
2860
|
+
* matters to the automation that has to work around it.
|
|
2861
|
+
*/
|
|
2862
|
+
const automationSchema = z.object({
|
|
2863
|
+
enabled: z.boolean().default(false).description("Run the daily points automation"),
|
|
2864
|
+
checkinHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the daily check-in"),
|
|
2865
|
+
reportHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the activity report (runs before tasks)"),
|
|
2866
|
+
taskHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for task enrolment and claiming"),
|
|
2867
|
+
streakHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for streak redemption"),
|
|
2868
|
+
exhaustCooldownMs: z.number().step(1).min(1e3).description("How long a spent account rests, in milliseconds")
|
|
2869
|
+
});
|
|
2213
2870
|
/** Settings key holding one region's saved selection. */
|
|
2214
2871
|
const modelSelectionKeyFor = (region) => region === "cn" ? "modelSelectionCn" : "modelSelectionGlobal";
|
|
2215
2872
|
/**
|
|
@@ -2231,7 +2888,8 @@ const Config = z.object({
|
|
|
2231
2888
|
imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
|
|
2232
2889
|
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"),
|
|
2233
2890
|
modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
|
|
2234
|
-
modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway")
|
|
2891
|
+
modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway"),
|
|
2892
|
+
automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)")
|
|
2235
2893
|
});
|
|
2236
2894
|
/** Live API, published for the CLI. */
|
|
2237
2895
|
let api;
|
|
@@ -2254,16 +2912,18 @@ function setApi(next) {
|
|
|
2254
2912
|
*/
|
|
2255
2913
|
function createCore(logger) {
|
|
2256
2914
|
const client = new WorkBuddyUpstreamClient();
|
|
2915
|
+
const pool = new WorkBuddyAccountPool({
|
|
2916
|
+
...logger === void 0 ? {} : { logger },
|
|
2917
|
+
client
|
|
2918
|
+
});
|
|
2257
2919
|
return {
|
|
2258
|
-
pool
|
|
2259
|
-
...logger === void 0 ? {} : { logger },
|
|
2260
|
-
client
|
|
2261
|
-
}),
|
|
2920
|
+
pool,
|
|
2262
2921
|
catalogs: {
|
|
2263
2922
|
cn: new WorkBuddyCatalog(),
|
|
2264
2923
|
global: new WorkBuddyCatalog()
|
|
2265
2924
|
},
|
|
2266
|
-
client
|
|
2925
|
+
client,
|
|
2926
|
+
scheduler: new WorkBuddyScheduler(pool, client, { ...logger === void 0 ? {} : { logger } })
|
|
2267
2927
|
};
|
|
2268
2928
|
}
|
|
2269
2929
|
/**
|
|
@@ -2296,12 +2956,13 @@ function apply(ctx, config = {}) {
|
|
|
2296
2956
|
}
|
|
2297
2957
|
};
|
|
2298
2958
|
const applyConfigFromSource = () => {
|
|
2299
|
-
const { authFile, cooldownMs, distribution, disabledAccountIds, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal } = current();
|
|
2959
|
+
const { authFile, cooldownMs, distribution, disabledAccountIds, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal, automation } = current();
|
|
2300
2960
|
core.pool.applyConfig({
|
|
2301
2961
|
...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
|
|
2302
2962
|
...cooldownMs === void 0 ? {} : { cooldownMs },
|
|
2303
2963
|
distribution: distribution ?? "priority",
|
|
2304
|
-
...disabledAccountIds === void 0 ? {} : { disabledAccountIds }
|
|
2964
|
+
...disabledAccountIds === void 0 ? {} : { disabledAccountIds },
|
|
2965
|
+
...automation?.exhaustCooldownMs === void 0 ? {} : { exhaustCooldownMs: automation.exhaustCooldownMs }
|
|
2305
2966
|
});
|
|
2306
2967
|
const legacySelection = {
|
|
2307
2968
|
...enabledModelIds === void 0 ? {} : { enabledModelIds },
|
|
@@ -2310,6 +2971,7 @@ function apply(ctx, config = {}) {
|
|
|
2310
2971
|
};
|
|
2311
2972
|
core.catalogs.cn.applySelection(modelSelectionCn ?? legacySelection);
|
|
2312
2973
|
core.catalogs.global.applySelection(modelSelectionGlobal ?? legacySelection);
|
|
2974
|
+
core.scheduler.applyConfig(automationOptions(automation));
|
|
2313
2975
|
};
|
|
2314
2976
|
const settingsService = ctx.settings;
|
|
2315
2977
|
if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, config, sectionHooks);
|
|
@@ -2319,14 +2981,30 @@ function apply(ctx, config = {}) {
|
|
|
2319
2981
|
* the three model-selection keys, so the settings file cannot be steered from
|
|
2320
2982
|
* the browser; the catalog re-reads through `onChange` either way.
|
|
2321
2983
|
*/
|
|
2984
|
+
/**
|
|
2985
|
+
* Write one key of the plugin's own settings section. Only ever called with the
|
|
2986
|
+
* model-selection keys, the distribution, and the disabled-account list, so the
|
|
2987
|
+
* settings file cannot be steered from the browser; the catalogs and the pool
|
|
2988
|
+
* re-read through `onChange` either way.
|
|
2989
|
+
*
|
|
2990
|
+
* `set` returns a promise, so a rejection must be caught explicitly: a bare
|
|
2991
|
+
* `void write.call(...)` swallows it, and the card then looks like it saved
|
|
2992
|
+
* while the value never reached the settings file.
|
|
2993
|
+
*/
|
|
2322
2994
|
const setSetting = (key, value) => {
|
|
2323
2995
|
if (value === void 0) return;
|
|
2324
2996
|
const write = settingsService?.set;
|
|
2325
|
-
if (write === void 0)
|
|
2997
|
+
if (write === void 0) {
|
|
2998
|
+
ctx.logger.warn?.(`dsh-workbuddy-xdpool: no settings writer; ` + key + ` was not saved`);
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
2326
3001
|
try {
|
|
2327
|
-
write.call(settingsService, key, value);
|
|
3002
|
+
const result = write.call(settingsService, key, value);
|
|
3003
|
+
if (result !== void 0 && typeof result.then === "function") result.catch((error) => {
|
|
3004
|
+
ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
|
|
3005
|
+
});
|
|
2328
3006
|
} catch (error) {
|
|
2329
|
-
ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist
|
|
3007
|
+
ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
|
|
2330
3008
|
}
|
|
2331
3009
|
};
|
|
2332
3010
|
const shims = {
|
|
@@ -2362,12 +3040,14 @@ function apply(ctx, config = {}) {
|
|
|
2362
3040
|
stopped = true;
|
|
2363
3041
|
shims.cn.close();
|
|
2364
3042
|
shims.global.close();
|
|
3043
|
+
core.scheduler.stop();
|
|
2365
3044
|
});
|
|
2366
3045
|
ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
|
|
2367
3046
|
pool: core.pool,
|
|
2368
3047
|
catalogs: core.catalogs,
|
|
2369
3048
|
client: core.client,
|
|
2370
3049
|
shim: () => shimInfo("cn"),
|
|
3050
|
+
scheduler: () => core.scheduler.status(),
|
|
2371
3051
|
saveSelection: (region, selection) => {
|
|
2372
3052
|
setSetting(modelSelectionKeyFor(region), {
|
|
2373
3053
|
...selection.enabledModelIds === void 0 ? {} : { enabledModelIds: [...selection.enabledModelIds] },
|
|
@@ -2405,6 +3085,7 @@ function apply(ctx, config = {}) {
|
|
|
2405
3085
|
core.pool.resetCooldowns();
|
|
2406
3086
|
}
|
|
2407
3087
|
};
|
|
3088
|
+
core.scheduler.start();
|
|
2408
3089
|
Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {
|
|
2409
3090
|
if (stopped) return;
|
|
2410
3091
|
try {
|
|
@@ -2509,4 +3190,4 @@ function apply(ctx, config = {}) {
|
|
|
2509
3190
|
});
|
|
2510
3191
|
}
|
|
2511
3192
|
//#endregion
|
|
2512
|
-
export { Config, DEFAULT_CONTEXT_BUDGET, FALLBACK_WORKBUDDY_MODELS, POOL_CHECKIN_PATH, POOL_MODELS_SAVE_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyUpstreamClient, apply, buildStatus, candidateAuthDirs, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, defaultDesktopAuthDirs, formatRates, formatStatus, inject, modelSelectionKeyFor, name, parseRateLimitReset, parseWorkBuddyAuth, setApi, workbuddyAccountId };
|
|
3193
|
+
export { Config, DEFAULT_CONTEXT_BUDGET, FALLBACK_WORKBUDDY_MODELS, POOL_CHECKIN_PATH, POOL_MODELS_SAVE_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyUpstreamClient, apply, automationOptions, buildStatus, candidateAuthDirs, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, defaultDesktopAuthDirs, formatRates, formatStatus, inject, modelSelectionKeyFor, name, parseRateLimitReset, parseWorkBuddyAuth, setApi, workbuddyAccountId };
|