dsh-workbuddy-xdpool 1.0.1 → 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/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
@@ -1495,6 +1714,311 @@ function createWorkBuddyAdapter(options) {
1495
1714
  }
1496
1715
  };
1497
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
+ };
1498
2022
  //#endregion
1499
2023
  //#region src/shim.ts
1500
2024
  /**
@@ -1891,6 +2415,21 @@ const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabl
1891
2415
  //#endregion
1892
2416
  //#region src/web-status.ts
1893
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
+ }
1894
2433
  function safeMessage(error) {
1895
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);
1896
2435
  }
@@ -2088,6 +2627,21 @@ async function poolWebStatus(deps, region = "cn") {
2088
2627
  } catch {
2089
2628
  shim = { running: false };
2090
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
+ };
2091
2645
  return {
2092
2646
  ok: accounts.length > 0 && cooling < accounts.length,
2093
2647
  accounts: rows,
@@ -2108,7 +2662,8 @@ async function poolWebStatus(deps, region = "cn") {
2108
2662
  region,
2109
2663
  distribution: deps.pool.currentDistribution(),
2110
2664
  regions,
2111
- shim
2665
+ shim,
2666
+ automation
2112
2667
  };
2113
2668
  }
2114
2669
  /**
@@ -2264,6 +2819,22 @@ const WORKBUDDY_POOL_SETTINGS_NS = "workbuddy-xdpool";
2264
2819
  /** Upper bound the card offers as the "default" context window, in tokens. */
2265
2820
  const DEFAULT_CONTEXT_BUDGET = 2e5;
2266
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
+ /**
2267
2838
  * One region's model-selection schema.
2268
2839
  *
2269
2840
  * Every field is optional on purpose: an absent field keeps its documented
@@ -2276,6 +2847,26 @@ const modelSelectionSchema = z.object({
2276
2847
  imageModelIds: z.array(z.string()).description("Model ids accepting image input in this region (absent = follow upstream)"),
2277
2848
  contextBudgets: z.dict(z.number().step(1).min(1)).description("Per-model context-window override for this region")
2278
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
+ });
2279
2870
  /** Settings key holding one region's saved selection. */
2280
2871
  const modelSelectionKeyFor = (region) => region === "cn" ? "modelSelectionCn" : "modelSelectionGlobal";
2281
2872
  /**
@@ -2297,7 +2888,8 @@ const Config = z.object({
2297
2888
  imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
2298
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"),
2299
2890
  modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
2300
- 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)")
2301
2893
  });
2302
2894
  /** Live API, published for the CLI. */
2303
2895
  let api;
@@ -2320,16 +2912,18 @@ function setApi(next) {
2320
2912
  */
2321
2913
  function createCore(logger) {
2322
2914
  const client = new WorkBuddyUpstreamClient();
2915
+ const pool = new WorkBuddyAccountPool({
2916
+ ...logger === void 0 ? {} : { logger },
2917
+ client
2918
+ });
2323
2919
  return {
2324
- pool: new WorkBuddyAccountPool({
2325
- ...logger === void 0 ? {} : { logger },
2326
- client
2327
- }),
2920
+ pool,
2328
2921
  catalogs: {
2329
2922
  cn: new WorkBuddyCatalog(),
2330
2923
  global: new WorkBuddyCatalog()
2331
2924
  },
2332
- client
2925
+ client,
2926
+ scheduler: new WorkBuddyScheduler(pool, client, { ...logger === void 0 ? {} : { logger } })
2333
2927
  };
2334
2928
  }
2335
2929
  /**
@@ -2362,12 +2956,13 @@ function apply(ctx, config = {}) {
2362
2956
  }
2363
2957
  };
2364
2958
  const applyConfigFromSource = () => {
2365
- const { authFile, cooldownMs, distribution, disabledAccountIds, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal } = current();
2959
+ const { authFile, cooldownMs, distribution, disabledAccountIds, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal, automation } = current();
2366
2960
  core.pool.applyConfig({
2367
2961
  ...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
2368
2962
  ...cooldownMs === void 0 ? {} : { cooldownMs },
2369
2963
  distribution: distribution ?? "priority",
2370
- ...disabledAccountIds === void 0 ? {} : { disabledAccountIds }
2964
+ ...disabledAccountIds === void 0 ? {} : { disabledAccountIds },
2965
+ ...automation?.exhaustCooldownMs === void 0 ? {} : { exhaustCooldownMs: automation.exhaustCooldownMs }
2371
2966
  });
2372
2967
  const legacySelection = {
2373
2968
  ...enabledModelIds === void 0 ? {} : { enabledModelIds },
@@ -2376,6 +2971,7 @@ function apply(ctx, config = {}) {
2376
2971
  };
2377
2972
  core.catalogs.cn.applySelection(modelSelectionCn ?? legacySelection);
2378
2973
  core.catalogs.global.applySelection(modelSelectionGlobal ?? legacySelection);
2974
+ core.scheduler.applyConfig(automationOptions(automation));
2379
2975
  };
2380
2976
  const settingsService = ctx.settings;
2381
2977
  if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, config, sectionHooks);
@@ -2444,12 +3040,14 @@ function apply(ctx, config = {}) {
2444
3040
  stopped = true;
2445
3041
  shims.cn.close();
2446
3042
  shims.global.close();
3043
+ core.scheduler.stop();
2447
3044
  });
2448
3045
  ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
2449
3046
  pool: core.pool,
2450
3047
  catalogs: core.catalogs,
2451
3048
  client: core.client,
2452
3049
  shim: () => shimInfo("cn"),
3050
+ scheduler: () => core.scheduler.status(),
2453
3051
  saveSelection: (region, selection) => {
2454
3052
  setSetting(modelSelectionKeyFor(region), {
2455
3053
  ...selection.enabledModelIds === void 0 ? {} : { enabledModelIds: [...selection.enabledModelIds] },
@@ -2487,6 +3085,7 @@ function apply(ctx, config = {}) {
2487
3085
  core.pool.resetCooldowns();
2488
3086
  }
2489
3087
  };
3088
+ core.scheduler.start();
2490
3089
  Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {
2491
3090
  if (stopped) return;
2492
3091
  try {
@@ -2591,4 +3190,4 @@ function apply(ctx, config = {}) {
2591
3190
  });
2592
3191
  }
2593
3192
  //#endregion
2594
- 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 };