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/CHANGELOG.md CHANGED
@@ -4,6 +4,72 @@
4
4
 
5
5
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
6
6
 
7
+ ## 1.1.0 (2026-09-23)
8
+
9
+ 新增**积分自动化**:插件可以在后台替你完成成长中心的日常动作,不用每天手动点一遍。
10
+
11
+ ### 它是怎么工作的
12
+
13
+ 开启后,调度器每分钟醒一次,到点就跑当天的任务,每个任务每天只跑一次(按本地日期记「今天已跑」,过零点自动重置)。四个任务各有独立的执行时段,默认这样安排:
14
+
15
+ | 任务 | 默认时段 | 做什么 |
16
+ | --- | --- | --- |
17
+ | 每日签到 | 09:00 | 领取当天签到奖励 |
18
+ | 活跃上报 | 10:00 | 上报一次对话活跃 |
19
+ | 任务奖励 | 11:00 | 报名开放任务 + 领取已达标的奖励 |
20
+ | 连登奖励 | 12:00 | 连登档位兑换 + 抽奖(本轮先占位,下轮接入) |
21
+
22
+ **为什么上报排在任务之前**:上报会点亮成长中心的任务进度,后跑的任务那一趟才能读到刚涨上去的计数。顺序反了就会出现「进度明明到了却没领到」。这不是随手排的,是实测出来的。
23
+
24
+ ### 一、任务中心自动领取
25
+
26
+ 每个账号走一遍:拉任务列表 → 把还没报名的任务批量报名 → 把已达标的奖励逐个领掉。卡片上汇总显示本次领取了几个任务、加了多少积分。
27
+
28
+ 两个动作都是**幂等**的,重复跑不会重复入账:报名对已报名的任务返回成功,领取对已领过的返回 `already_claimed` 并记为成功。所以某个账号中途失败、下一轮重跑是安全的。
29
+
30
+ ### 二、活跃上报
31
+
32
+ 按客户端 `chat_request_send` 事件的完整形状上报(37 个字段),而不是只发最小几个字段——上游对「字段不全」的表现是**返回 200 然后静默丢弃**,所以字段必须发全,且必须带 `userId`。
33
+
34
+ 上报成功后会回读连登天数做验证。这里有两个实测踩到的坑,都已规避:
35
+
36
+ - 连登接口的路径**不带 `/v2` 前缀**(和任务的 `/v2/activity/growth/*` 不一样)。用错前缀不会 404,而是返回一个没有 `streak` 字段的响应;
37
+ - 天数藏在 `data.streak.days` 里,读平铺的 `data.days` 会永远得到 0,从而把每一次成功的上报都误报成「静默丢弃」。
38
+
39
+ 另外,连登计数是**异步写入**的:上报后立刻回读可能还是旧值(实测某账号上报瞬间读到 0、一分钟后读到 1)。所以回读为 0 时只记一条提示日志,**不重试**——上报本身按天幂等,反复重发只会浪费每天一次的名额。
40
+
41
+ ### 三、只碰该碰的账号
42
+
43
+ - **国际版账号全部跳过**:global 域没有成长任务体系,调用只会产生噪声。
44
+ - **你在卡片上关掉的账号跳过**。
45
+ - **正在冷却的账号跳过**。
46
+ - **一个账号失败不影响其他账号**:出错的那个跳过,继续跑下一个,并在汇总里记下失败数量。
47
+
48
+ 账号之间默认间隔 800ms 串行执行,避免一排账号同时打上游。
49
+
50
+ ### 四、卡片上的自动化面板
51
+
52
+ 账号卡片新增「积分自动化」区块:一个开关控制总开关,开启后才展开显示各任务的执行时段和上次执行结果(日期 · 成功数/失败数)。
53
+
54
+ 开关默认**关闭**——后台自动发请求这件事应该是你主动开启的。关闭状态下不会发起任何后台请求。
55
+
56
+ ### 配置
57
+
58
+ 配置项 `automation`,对应卡片上的开关:
59
+
60
+ | 字段 | 默认 | 说明 |
61
+ | --- | --- | --- |
62
+ | `enabled` | `false` | 总开关 |
63
+ | `checkinHours` | `[9]` | 签到时段 |
64
+ | `reportHours` | `[10]` | 上报时段 |
65
+ | `taskHours` | `[11]` | 任务时段 |
66
+ | `streakHours` | `[12]` | 连登时段 |
67
+ | `exhaustCooldownMs` | 30 分钟 | 账号额度用尽后休息多久 |
68
+
69
+ ### 实测结果
70
+
71
+ 在真实账号上验证了完整链路:4 个国内账号各领到 1 个任务,合计 **+600 积分 +23 能量**;紧接着再跑一次,四个账号全部领取 0、入账 0,确认幂等。上报后任务进度确实从 3/5 涨到 5/5,验证了「上报点亮任务」这一步是真的。
72
+
7
73
  ## 1.0.1 (2026-09-23)
8
74
 
9
75
  修复两个用号逻辑上的缺陷,并把账号卡片重新排了一遍。
package/lib/bin.js CHANGED
@@ -286,6 +286,50 @@ function parseUpstreamModel(value) {
286
286
  ...supportsImages === void 0 ? {} : { supportsImages }
287
287
  };
288
288
  }
289
+ /**
290
+ * Flatten one upstream task entry.
291
+ *
292
+ * Progress is reported two ways depending on the task: flat `current`/`target`
293
+ * fields, or a nested `progress: {current, target}`. The nested form wins when
294
+ * it carries anything, because a task that reports both puts the live counter
295
+ * there. Entries without a usable `task_code` are dropped — without one the
296
+ * claim path cannot be built.
297
+ */
298
+ function parseTask(value) {
299
+ if (typeof value !== "object" || value === null) return void 0;
300
+ const raw = value;
301
+ const taskCode = typeof raw["task_code"] === "string" ? raw["task_code"] : "";
302
+ if (taskCode === "") return void 0;
303
+ const num = (key) => typeof raw[key] === "number" ? raw[key] : 0;
304
+ let current = num("current");
305
+ let target = num("target");
306
+ const progress = raw["progress"];
307
+ if (typeof progress === "object" && progress !== null) {
308
+ const nested = progress;
309
+ const nestedCurrent = typeof nested["current"] === "number" ? nested["current"] : 0;
310
+ const nestedTarget = typeof nested["target"] === "number" ? nested["target"] : 0;
311
+ if (nestedTarget > 0 || nestedCurrent > 0) {
312
+ current = nestedCurrent;
313
+ target = nestedTarget;
314
+ }
315
+ }
316
+ const acceptStatus = typeof raw["accept_status"] === "string" ? raw["accept_status"] : "";
317
+ const claimed = acceptStatus === "claimed";
318
+ return {
319
+ taskCode,
320
+ title: typeof raw["title"] === "string" && raw["title"] !== "" ? raw["title"] : taskCode,
321
+ credit: num("reward_credit"),
322
+ energy: num("reward_energy"),
323
+ hasReward: raw["has_reward"] === true,
324
+ target,
325
+ current,
326
+ acceptStatus,
327
+ status: typeof raw["status"] === "string" ? raw["status"] : "",
328
+ claimable: !claimed && target > 0 && current >= target,
329
+ claimed,
330
+ locked: raw["locked"] === true
331
+ };
332
+ }
289
333
  var WorkBuddyUpstreamClient = class {
290
334
  fetchImpl;
291
335
  clientVersion;
@@ -562,6 +606,100 @@ var WorkBuddyUpstreamClient = class {
562
606
  isStreakDay: data["is_streak_day"] === true
563
607
  };
564
608
  }
609
+ /**
610
+ * Report one chat-activity event to the growth system.
611
+ *
612
+ * The body is an ARRAY holding a single `chat_request_send` event, and every
613
+ * field is filled in: a three-field minimal event is accepted with 200 and
614
+ * then silently dropped, so the full shape is load-bearing rather than
615
+ * cosmetic. `userId` is the one field the server actually keys on — without
616
+ * it the request still answers 200 and scores nothing.
617
+ *
618
+ * One report per account per day is the quota the reference panel settled on;
619
+ * a single report lights the growth streak and unlocks the `first_buddy`
620
+ * family, which is why this runs before the task-centre pass.
621
+ */
622
+ async reportActivity(credential, conversationId) {
623
+ const conversationID = conversationId ?? `wb2api-${Date.now()}`;
624
+ const requestID = conversationID;
625
+ const now = Date.now();
626
+ const event = {
627
+ eventCode: "chat_request_send",
628
+ timestamp: now,
629
+ reportDelay: 0,
630
+ mode: "craft",
631
+ conversationId: conversationID,
632
+ requestId: requestID,
633
+ inputLength: 12,
634
+ requestModelId: "deepseek-v4-flash",
635
+ requestModelName: "DeepSeek V4 Flash",
636
+ isPlan: false,
637
+ isAutoExecuteTerminal: false,
638
+ isAutoModify: false,
639
+ codebaseEnable: false,
640
+ maxToken: 0,
641
+ maxSteps: 0,
642
+ temperature: 0,
643
+ maxRetries: 0,
644
+ mentionContexts: [],
645
+ knowledgeId: [],
646
+ knowledgeName: [],
647
+ codebaseId: "",
648
+ mentionContextCount: 0,
649
+ command: "",
650
+ expertId: "",
651
+ recommendId: "",
652
+ skillId: "",
653
+ skillCount: 0,
654
+ totalCount: 0,
655
+ fileUri: "",
656
+ presentAt: now,
657
+ traceId: "",
658
+ rootRequestId: requestID,
659
+ parentConversationId: conversationID,
660
+ agentName: "default",
661
+ agentType: "conversation",
662
+ userId: credential.uid ?? ""
663
+ };
664
+ const response = await this.fetchImpl(`${billingBase(credential)}/v2/report`, {
665
+ method: "POST",
666
+ headers: billingHeaders(credential),
667
+ body: JSON.stringify([event]),
668
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
669
+ });
670
+ const envelope = await readEnvelope(response);
671
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
672
+ }
673
+ /**
674
+ * Read back the growth streak in days.
675
+ *
676
+ * This is the read-only oracle for {@link reportActivity}: a report that
677
+ * returned 200 yet left the streak untouched was silently dropped (a missing
678
+ * `userId` is the usual cause), so callers verify instead of trusting the
679
+ * status code.
680
+ *
681
+ * Two shape traps, both measured against the live upstream:
682
+ *
683
+ * - The path carries NO `/v2` prefix, unlike its sibling task endpoints under
684
+ * `/v2/activity/growth/*`. Asking for the `/v2` form does not 404; it
685
+ * answers with a body that carries no `streak` object at all.
686
+ * - The counter is nested as `data.streak.days`, not `data.days`. Reading the
687
+ * flat field yields a constant 0, which would make every successful report
688
+ * look like a silent drop.
689
+ *
690
+ * Returns 0 only when the field is genuinely absent.
691
+ */
692
+ async growthStreakDays(credential) {
693
+ const response = await this.fetchImpl(`${chatBase(credential)}/activity/growth/streak`, {
694
+ headers: billingHeaders(credential),
695
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
696
+ });
697
+ const envelope = await readEnvelope(response);
698
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
699
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
700
+ const streak = typeof data["streak"] === "object" && data["streak"] !== null ? data["streak"] : {};
701
+ return typeof streak["days"] === "number" ? streak["days"] : 0;
702
+ }
565
703
  /** Legacy thin wrapper kept for `status`/`doctor`: returns raw envelope data. */
566
704
  async credits(credential) {
567
705
  try {
@@ -576,6 +714,87 @@ var WorkBuddyUpstreamClient = class {
576
714
  };
577
715
  }
578
716
  }
717
+ /**
718
+ * Fetch the growth task list for one account.
719
+ *
720
+ * The upstream answers `data.tasks[]`, and `claimable` is derived locally —
721
+ * the upstream does not mark it. Only a task whose progress reached its
722
+ * target and that is not already claimed counts as eligible.
723
+ */
724
+ async listTasks(credential) {
725
+ const response = await this.fetchImpl(`${chatBase(credential)}/v2/activity/growth/tasks`, {
726
+ headers: chatHeaders(credential),
727
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
728
+ });
729
+ const envelope = await readEnvelope(response);
730
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
731
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
732
+ const raw = Array.isArray(data["tasks"]) ? data["tasks"] : [];
733
+ const out = [];
734
+ for (const entry of raw) {
735
+ const parsed = parseTask(entry);
736
+ if (parsed !== void 0) out.push(parsed);
737
+ }
738
+ return out;
739
+ }
740
+ /**
741
+ * Accept (enrol in) tasks by code.
742
+ *
743
+ * Accepting is the "sign up" half: it produces no progress by itself, and the
744
+ * upstream answers success for an already-accepted task, so replaying this is
745
+ * safe. Progress is lit by real activity (a chat, an activity report).
746
+ */
747
+ async acceptTasks(credential, taskCodes) {
748
+ if (taskCodes.length === 0) return;
749
+ const response = await this.fetchImpl(`${chatBase(credential)}/v2/activity/growth/tasks/accept`, {
750
+ method: "POST",
751
+ headers: chatHeaders(credential),
752
+ body: JSON.stringify({ task_codes: [...taskCodes] }),
753
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
754
+ });
755
+ const envelope = await readEnvelope(response);
756
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
757
+ }
758
+ /**
759
+ * Claim one task's reward.
760
+ *
761
+ * Two details differ from list/accept and are load-bearing:
762
+ *
763
+ * - The task code rides the PATH, not the body.
764
+ * - It is served by the web origin, not the chat host, and only when the
765
+ * request carries the growth-centre Origin/Referer plus
766
+ * `x-client-platform: web`. The chat host's `/reward/claim` path does not
767
+ * exist and answers 400 "task not completed".
768
+ *
769
+ * A repeat claim answers `already_claimed` with zero credit, which is treated
770
+ * as success so the caller can stay idempotent.
771
+ */
772
+ async claimTaskReward(credential, taskCode) {
773
+ const headers = {
774
+ ...billingHeaders(credential),
775
+ "Accept": "application/json, text/plain, */*",
776
+ "Origin": "https://www.workbuddy.cn",
777
+ "Referer": "https://www.workbuddy.cn/profile/growth-center",
778
+ "x-client-platform": "web",
779
+ "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"
780
+ };
781
+ const response = await this.fetchImpl(`https://www.workbuddy.cn/activity/growth/tasks/${encodeURIComponent(taskCode)}/claim`, {
782
+ method: "POST",
783
+ headers,
784
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
785
+ });
786
+ const envelope = await readEnvelope(response);
787
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
788
+ const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
789
+ if (data["already_claimed"] === true) return {
790
+ credit: 0,
791
+ energy: 0
792
+ };
793
+ return {
794
+ credit: typeof data["credit"] === "number" ? data["credit"] : 0,
795
+ energy: typeof data["energy"] === "number" ? data["energy"] : 0
796
+ };
797
+ }
579
798
  };
580
799
  //#endregion
581
800
  //#region src/accounts.ts
@@ -1284,6 +1503,311 @@ function catalogFromUpstream(models) {
1284
1503
  if (models.length === 0) return FALLBACK_WORKBUDDY_MODELS;
1285
1504
  return models.map(toModelInfo);
1286
1505
  }
1506
+ /** How often the loop wakes to look for a due job. */
1507
+ const AUTOMATION_TICK_MS = 6e4;
1508
+ const JOB_KINDS = [
1509
+ "checkin",
1510
+ "report",
1511
+ "tasks",
1512
+ "streak"
1513
+ ];
1514
+ const EMPTY_JOB_STATE = {
1515
+ ok: 0,
1516
+ failed: 0,
1517
+ credit: 0,
1518
+ energy: 0,
1519
+ claimed: 0
1520
+ };
1521
+ /** `YYYY-MM-DD` in local time, the day key every job resets on. */
1522
+ function dayKey(date) {
1523
+ const month = String(date.getMonth() + 1).padStart(2, "0");
1524
+ const day = String(date.getDate()).padStart(2, "0");
1525
+ return `${date.getFullYear()}-${month}-${day}`;
1526
+ }
1527
+ /**
1528
+ * Whether `now`'s local hour is one of `hours`.
1529
+ *
1530
+ * The reference panel computes a `nextFire` instant and sleeps until it; this
1531
+ * loop instead wakes every minute and asks "is any job due now". Both fire at
1532
+ * the top of a configured hour, but the polling form cannot miss a slot to a
1533
+ * suspended process — a laptop that slept through 10:00 still runs the job the
1534
+ * moment it wakes, on the same day.
1535
+ */
1536
+ function isFireHour(now, hours) {
1537
+ return hours.includes(now.getHours());
1538
+ }
1539
+ /** Whether this account may be used: not switched off, not cooling. */
1540
+ function eligible(account, now) {
1541
+ if (account.cooldownUntilMs > now) return false;
1542
+ if (regionOf(account.credential.domain) === "global") return false;
1543
+ return true;
1544
+ }
1545
+ const sleep = (ms) => new Promise((resolve) => {
1546
+ setTimeout(resolve, ms);
1547
+ });
1548
+ /**
1549
+ * The points automation.
1550
+ *
1551
+ * Owns a single timer loop. Construction is inert — nothing runs until
1552
+ * {@link start}, and {@link stop} is idempotent so a plugin teardown that fires
1553
+ * twice is harmless.
1554
+ */
1555
+ var WorkBuddyScheduler = class {
1556
+ pool;
1557
+ client;
1558
+ logger;
1559
+ now;
1560
+ delayMs;
1561
+ enabled;
1562
+ checkinHours;
1563
+ taskHours;
1564
+ reportHours;
1565
+ streakHours;
1566
+ timer;
1567
+ running = false;
1568
+ /** Guards against a slow run overlapping the next tick. */
1569
+ busy = false;
1570
+ /**
1571
+ * Set once {@link stop} is called.
1572
+ *
1573
+ * Deliberately false before `start`: the loop is not running yet, but a
1574
+ * manual `tick` must still work. `stop` is what makes a run abandon the
1575
+ * accounts it has not reached yet.
1576
+ */
1577
+ stopped = false;
1578
+ states = {
1579
+ checkin: { ...EMPTY_JOB_STATE },
1580
+ report: { ...EMPTY_JOB_STATE },
1581
+ tasks: { ...EMPTY_JOB_STATE },
1582
+ streak: { ...EMPTY_JOB_STATE }
1583
+ };
1584
+ claimableSeen = 0;
1585
+ constructor(pool, client, options = {}) {
1586
+ this.pool = pool;
1587
+ this.client = client;
1588
+ this.logger = options.logger ?? {};
1589
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1590
+ this.delayMs = options.accountDelayMs ?? 800;
1591
+ this.enabled = options.enabled ?? false;
1592
+ this.checkinHours = options.checkinHours ?? [9];
1593
+ this.reportHours = options.reportHours ?? [10];
1594
+ this.taskHours = options.taskHours ?? [11];
1595
+ this.streakHours = options.streakHours ?? [12];
1596
+ }
1597
+ /** Apply a new configuration; safe to call while running. */
1598
+ applyConfig(options) {
1599
+ if (options.enabled !== void 0) this.enabled = options.enabled;
1600
+ if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
1601
+ if (options.reportHours !== void 0) this.reportHours = options.reportHours;
1602
+ if (options.taskHours !== void 0) this.taskHours = options.taskHours;
1603
+ if (options.streakHours !== void 0) this.streakHours = options.streakHours;
1604
+ }
1605
+ /** Hours for one job, used by the loop and the status document. */
1606
+ hoursOf(kind) {
1607
+ switch (kind) {
1608
+ case "checkin": return this.checkinHours;
1609
+ case "report": return this.reportHours;
1610
+ case "tasks": return this.taskHours;
1611
+ case "streak": return this.streakHours;
1612
+ }
1613
+ }
1614
+ /** Start the loop. Idempotent. */
1615
+ start() {
1616
+ if (this.timer !== void 0) return;
1617
+ this.stopped = false;
1618
+ this.running = true;
1619
+ this.timer = setInterval(() => {
1620
+ this.tick();
1621
+ }, AUTOMATION_TICK_MS);
1622
+ this.timer.unref?.();
1623
+ }
1624
+ /** Stop the loop. Idempotent, and safe before `start`. */
1625
+ stop() {
1626
+ this.stopped = true;
1627
+ this.running = false;
1628
+ if (this.timer !== void 0) {
1629
+ clearInterval(this.timer);
1630
+ this.timer = void 0;
1631
+ }
1632
+ }
1633
+ /** Snapshot for the status document. */
1634
+ status() {
1635
+ const jobs = {};
1636
+ for (const kind of JOB_KINDS) jobs[kind] = { ...this.states[kind] };
1637
+ return {
1638
+ enabled: this.enabled,
1639
+ running: this.running,
1640
+ checkinHours: this.checkinHours,
1641
+ taskHours: this.taskHours,
1642
+ reportHours: this.reportHours,
1643
+ streakHours: this.streakHours,
1644
+ jobs,
1645
+ claimableSeen: this.claimableSeen
1646
+ };
1647
+ }
1648
+ /**
1649
+ * One poll: run every due job, serially.
1650
+ *
1651
+ * Serial by design — the jobs share the same accounts and the upstream
1652
+ * rate-limits per account, so overlapping passes would only trip that limit.
1653
+ * A job that throws is recorded and the loop continues.
1654
+ */
1655
+ async tick() {
1656
+ if (!this.enabled || this.stopped || this.busy) return;
1657
+ this.busy = true;
1658
+ try {
1659
+ const now = this.now();
1660
+ const today = dayKey(now);
1661
+ for (const kind of JOB_KINDS) {
1662
+ if (this.stopped) return;
1663
+ if (this.states[kind].lastRunDate === today) continue;
1664
+ if (!isFireHour(now, this.hoursOf(kind))) continue;
1665
+ await this.runJob(kind, today);
1666
+ }
1667
+ } catch (error) {
1668
+ this.logger.warn?.("dsh-workbuddy-xdpool automation tick failed:", error);
1669
+ } finally {
1670
+ this.busy = false;
1671
+ }
1672
+ }
1673
+ /** Run one job against every eligible account and record the outcome. */
1674
+ async runJob(kind, today) {
1675
+ const accountWord = kind === "report" ? "report" : kind;
1676
+ let ok = 0;
1677
+ let failed = 0;
1678
+ let credit = 0;
1679
+ let energy = 0;
1680
+ let claimed = 0;
1681
+ const accounts = this.accountsInOrder();
1682
+ for (let index = 0; index < accounts.length; index++) {
1683
+ if (this.stopped) break;
1684
+ const account = accounts[index];
1685
+ if (account === void 0) continue;
1686
+ try {
1687
+ switch (kind) {
1688
+ case "checkin":
1689
+ await this.client.claimDailyCheckin(account.credential);
1690
+ break;
1691
+ case "report": {
1692
+ const days = await this.reportOne(account);
1693
+ this.logger.info?.(`automation report ${account.label}: streak days=${days}`);
1694
+ break;
1695
+ }
1696
+ case "tasks": {
1697
+ const result = await this.runTasks(account);
1698
+ credit += result.credit;
1699
+ energy += result.energy;
1700
+ claimed += result.claimed;
1701
+ this.claimableSeen += result.claimableCount;
1702
+ this.logger.info?.(`automation tasks ${account.label}: ${result.claimed} claimed (+${result.credit} credit, +${result.energy} energy, ${result.claimableCount} claimable seen)`);
1703
+ break;
1704
+ }
1705
+ case "streak": await this.redeemStreak(account);
1706
+ }
1707
+ ok++;
1708
+ } catch (error) {
1709
+ failed++;
1710
+ this.logger.warn?.(`automation ${accountWord} ${account.label} failed:`, error);
1711
+ }
1712
+ if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
1713
+ }
1714
+ const state = this.states[kind];
1715
+ state.lastRunDate = today;
1716
+ state.lastRunAtMs = this.now().getTime();
1717
+ state.ok = ok;
1718
+ state.failed = failed;
1719
+ state.credit = credit;
1720
+ state.energy = energy;
1721
+ state.claimed = claimed;
1722
+ state.message = this.summarise(kind, ok, failed, claimed, credit, energy);
1723
+ this.logger.info?.(`automation ${accountWord}: ${state.message}`);
1724
+ }
1725
+ /** Compose the one-line summary shown on the card. */
1726
+ summarise(kind, ok, failed, claimed, credit, energy) {
1727
+ if (kind === "tasks") return `${ok} accounts, ${claimed} tasks claimed (+${credit} credit, +${energy} energy)${failed > 0 ? `, ${failed} failed` : ""}`;
1728
+ return `${ok} accounts ok${failed > 0 ? `, ${failed} failed` : ""}`;
1729
+ }
1730
+ /**
1731
+ * Accounts to run against, in pool order.
1732
+ *
1733
+ * Disabled accounts are excluded here rather than filtered by the caller so a
1734
+ * card switch takes effect on the next pass without any event plumbing.
1735
+ */
1736
+ accountsInOrder() {
1737
+ const now = Date.now();
1738
+ return this.pool.list().filter((account) => {
1739
+ if (this.pool.isDisabled(account.id)) return false;
1740
+ return eligible(account, now);
1741
+ });
1742
+ }
1743
+ /**
1744
+ * Send one activity report, then verify it landed.
1745
+ *
1746
+ * The upstream answers 200 even when it drops the event, so the streak is
1747
+ * read back as the oracle: `days > 0` means it counted. A failed read-back is
1748
+ * logged and treated as a suspicious result, never as a retry — the report is
1749
+ * idempotent per day, and hammering it is exactly what the one-a-day quota
1750
+ * exists to avoid.
1751
+ */
1752
+ async reportOne(account) {
1753
+ await this.client.reportActivity(account.credential);
1754
+ try {
1755
+ const days = await this.client.growthStreakDays(account.credential);
1756
+ if (days === 0) this.logger.warn?.(`automation report ${account.label}: streak days=0 right after report (new account or scoring lag?)`);
1757
+ return days;
1758
+ } catch (error) {
1759
+ this.logger.warn?.(`automation report ${account.label}: streak read-back failed:`, error);
1760
+ return -1;
1761
+ }
1762
+ }
1763
+ /**
1764
+ * The task-centre pass for one account.
1765
+ *
1766
+ * Order matters: enrich first (enrol in everything open), then claim. Both
1767
+ * halves are idempotent — accepting an already-accepted task succeeds, and a
1768
+ * repeat claim answers `already_claimed` — so a pass that dies halfway is
1769
+ * safe to replay on the next tick.
1770
+ */
1771
+ async runTasks(account) {
1772
+ const credential = account.credential;
1773
+ const tasks = await this.client.listTasks(credential);
1774
+ const open = tasks.filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
1775
+ if (open.length > 0) await this.client.acceptTasks(credential, open);
1776
+ const claimable = (open.length > 0 ? await this.client.listTasks(credential) : tasks).filter((task) => task.claimable && !task.locked);
1777
+ let credit = 0;
1778
+ let energy = 0;
1779
+ let claimed = 0;
1780
+ for (const task of claimable) {
1781
+ if (this.stopped) break;
1782
+ const reward = await this.client.claimTaskReward(credential, task.taskCode);
1783
+ credit += reward.credit;
1784
+ energy += reward.energy;
1785
+ if (reward.credit > 0 || reward.energy > 0) {
1786
+ claimed++;
1787
+ this.logger.info?.(`automation claim ${account.label}: ${task.title} +${reward.credit}c +${reward.energy}e`);
1788
+ } else this.logger.info?.(`automation claim ${account.label}: ${task.taskCode} already claimed`);
1789
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
1790
+ }
1791
+ return {
1792
+ claimed,
1793
+ credit,
1794
+ energy,
1795
+ claimableCount: claimable.length
1796
+ };
1797
+ }
1798
+ /**
1799
+ * Streak redemption and lottery.
1800
+ *
1801
+ * Left as a deliberate no-op placeholder: the tier/lottery endpoints need
1802
+ * their own round of probing against the live upstream before they can be
1803
+ * wired safely, and a wrong call here could burn a redemption. The job slot,
1804
+ * scheduling and status plumbing already exist, so filling it in is a
1805
+ * self-contained change.
1806
+ */
1807
+ async redeemStreak(account) {
1808
+ this.logger.info?.(`automation streak ${account.label}: not implemented yet (skipped)`);
1809
+ }
1810
+ };
1287
1811
  //#endregion
1288
1812
  //#region src/status.ts
1289
1813
  /** Format the per-model credit multipliers. */
@@ -1310,6 +1834,26 @@ const modelSelectionSchema = z.object({
1310
1834
  imageModelIds: z.array(z.string()).description("Model ids accepting image input in this region (absent = follow upstream)"),
1311
1835
  contextBudgets: z.dict(z.number().step(1).min(1)).description("Per-model context-window override for this region")
1312
1836
  });
1837
+ /**
1838
+ * Automation schema.
1839
+ *
1840
+ * `enabled` carries a real default (false) because the scheduler reads it on
1841
+ * every tick and a missing field must mean "off" rather than "undefined".
1842
+ * The hour lists fall back in the scheduler itself, so they stay optional here
1843
+ * and an absent list keeps the documented schedule.
1844
+ *
1845
+ * `exhaustCooldownMs` is mirrored from the pool options: the card offers it as
1846
+ * part of the automation block, since how long a spent account rests only
1847
+ * matters to the automation that has to work around it.
1848
+ */
1849
+ const automationSchema = z.object({
1850
+ enabled: z.boolean().default(false).description("Run the daily points automation"),
1851
+ checkinHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the daily check-in"),
1852
+ reportHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the activity report (runs before tasks)"),
1853
+ taskHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for task enrolment and claiming"),
1854
+ streakHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for streak redemption"),
1855
+ exhaustCooldownMs: z.number().step(1).min(1e3).description("How long a spent account rests, in milliseconds")
1856
+ });
1313
1857
  z.object({
1314
1858
  authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
1315
1859
  cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
@@ -1319,7 +1863,8 @@ z.object({
1319
1863
  imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
1320
1864
  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"),
1321
1865
  modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
1322
- modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway")
1866
+ modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway"),
1867
+ automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)")
1323
1868
  });
1324
1869
  /** Assemble the runtime objects without registering anything. */
1325
1870
  /**
@@ -1332,16 +1877,18 @@ z.object({
1332
1877
  */
1333
1878
  function createCore(logger) {
1334
1879
  const client = new WorkBuddyUpstreamClient();
1880
+ const pool = new WorkBuddyAccountPool({
1881
+ ...logger === void 0 ? {} : { logger },
1882
+ client
1883
+ });
1335
1884
  return {
1336
- pool: new WorkBuddyAccountPool({
1337
- ...logger === void 0 ? {} : { logger },
1338
- client
1339
- }),
1885
+ pool,
1340
1886
  catalogs: {
1341
1887
  cn: new WorkBuddyCatalog(),
1342
1888
  global: new WorkBuddyCatalog()
1343
1889
  },
1344
- client
1890
+ client,
1891
+ scheduler: new WorkBuddyScheduler(pool, client, { ...logger === void 0 ? {} : { logger } })
1345
1892
  };
1346
1893
  }
1347
1894
  //#endregion