dsh-workbuddy-xdpool 1.1.0 → 1.3.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
@@ -1,6 +1,6 @@
1
1
  import { basename, dirname, join, resolve } from "node:path";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { readFile, readdir } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { createProvider } from "@earendil-works/pi-ai";
@@ -52,6 +52,15 @@ const HARD_CREDIT_MARKERS = [
52
52
  /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
53
53
  const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
54
54
  /**
55
+ * Markers for "already checked in today".
56
+ *
57
+ * The upstream answers a non-zero business code (and HTTP 400) when the daily
58
+ * check-in is repeated. That is an idempotent success, not a failure: the
59
+ * reward for today is already collected. Matched against the message, since
60
+ * the code varies by realm.
61
+ */
62
+ const ALREADY_CHECKIN_MARKERS = ["已签到", "already"];
63
+ /**
55
64
  * Hosts the international product answers on, once each has been stripped of a
56
65
  * leading label. The WorkBuddy AI desktop app signs in at `workbuddy.ai` (and
57
66
  * the desktop client itself lists `workbuddy.cc` alongside it); the CodeBuddy
@@ -125,6 +134,43 @@ function refreshHeaders(credential) {
125
134
  if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
126
135
  return headers;
127
136
  }
137
+ /** Desktop-client report endpoint and the UA it is fingerprinted by. */
138
+ const DESKTOP_REPORT_PATH = "/v2/report";
139
+ /** Theme-selection endpoint (Hp_Appearance). */
140
+ const APPEARANCE_SET_PATH = "/v2/user-asset/appearance/set";
141
+ /** Expert marketplace listing, used to look up REAL expert ids. */
142
+ const MARKET_EXPERT_LIST_PATH = "/portal/operation-platform/market/expert/list";
143
+ /** Cap on how long a chain waits for a conversation answer. */
144
+ const CHAT_TIMEOUT_MS = 9e4;
145
+ /** How far into an SSE stream to look for the server's request id. */
146
+ const SSE_SCAN_LIMIT = 1 << 20;
147
+ /** Server request ids look like `cmb-<32 hex>` or a bare 32 hex string. */
148
+ const SERVER_ID_PATTERN = /"id"\s*:\s*"((?:cmb-)?[0-9a-f]{32})"/;
149
+ const DESKTOP_TASK_UA = "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1";
150
+ /**
151
+ * Derive a stable 36-hex device id from the account uid.
152
+ *
153
+ * The upstream keys desktop events to a device. Deriving it from the uid keeps
154
+ * the same account looking like the same machine across runs, instead of a
155
+ * new device appearing on every call.
156
+ */
157
+ function deriveDeviceId(credential, salt) {
158
+ return createHash("sha256").update(salt + ":" + (credential.uid ?? "")).digest("hex").slice(0, 36);
159
+ }
160
+ /** Narrow a loose upstream value to an object, so field reads cannot throw. */
161
+ function asRecord(value) {
162
+ return typeof value === "object" && value !== null ? value : {};
163
+ }
164
+ /** Read a numeric field, treating anything else as 0. */
165
+ function numOf(value) {
166
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
167
+ }
168
+ /** Local `YYYY-MM-DD`, matching how the heatmap keys its cells. */
169
+ function dayKeyLocal(date) {
170
+ const month = String(date.getMonth() + 1).padStart(2, "0");
171
+ const day = String(date.getDate()).padStart(2, "0");
172
+ return `${date.getFullYear()}-${month}-${day}`;
173
+ }
128
174
  /** Billing request headers. */
129
175
  function billingHeaders(credential) {
130
176
  const headers = {
@@ -193,6 +239,17 @@ function classifyUpstreamError(status, body) {
193
239
  return "client";
194
240
  }
195
241
  /**
242
+ * Whether an error means "today is already checked in".
243
+ *
244
+ * Callers treat this as success: the credit for the day is already banked, so
245
+ * reporting it as a failure would both alarm the user and hide a healthy
246
+ * account behind a false negative.
247
+ */
248
+ function isAlreadyCheckin(error) {
249
+ const message = error instanceof Error ? error.message : String(error);
250
+ return ALREADY_CHECKIN_MARKERS.some((marker) => message.includes(marker));
251
+ }
252
+ /**
196
253
  * Parse the reset time the upstream reports for a rate limit, when present.
197
254
  * Recognises an epoch-millisecond field and the Chinese-localised sentence
198
255
  * form, so the pool can resume exactly when the window reopens.
@@ -311,6 +368,246 @@ function parseUpstreamModel(value) {
311
368
  * there. Entries without a usable `task_code` are dropped — without one the
312
369
  * claim path cannot be built.
313
370
  */
371
+ /**
372
+ * The event chain that scores the two Buddy-app tasks.
373
+ *
374
+ * Both tasks accept the same chain, and the chain is a pure value: building it
375
+ * needs no client, so callers (and tests) can hold one without a live upstream.
376
+ *
377
+ * The task text says "upgrade to the desktop client and open it from the app
378
+ * launcher". The scorer does not watch the UI — it watches this event sequence
379
+ * with the desktop fingerprint, which is why the sequence is what gets sent.
380
+ *
381
+ * Measured against the live upstream: progress 0/1 → 1/1 claimable in ~8s.
382
+ */
383
+ /**
384
+ * A complete "desktop client ran a request successfully" event chain.
385
+ *
386
+ * Six events in the order the real client emits them: task created, message
387
+ * send, request send, message response, message status, request response.
388
+ * Several tasks are scored off this chain (or one that embeds it), because what
389
+ * they measure is "a real request completed", which the client only proves
390
+ * through this exact sequence.
391
+ *
392
+ * Measured: this chain alone lights up `RichMeow_Chat`.
393
+ */
394
+ function desktopChatEvents(conversationId, requestId, messageId, modelId = "fast-model", modelName = "fast-model") {
395
+ const assistant = `${messageId}-assistant`;
396
+ const session = {
397
+ "codebuddy.session_id": conversationId,
398
+ "codebuddy.conversation_request_id": requestId
399
+ };
400
+ return [
401
+ {
402
+ eventCode: "agent_task_created",
403
+ source: "LOCAL",
404
+ name: "working",
405
+ task_target: "local",
406
+ mode: "craft",
407
+ requestModelId: modelId,
408
+ requestModelName: modelName,
409
+ has_repo: false,
410
+ repo_type: "none",
411
+ workspace_type: "empty",
412
+ has_connector: false,
413
+ connector_types: [],
414
+ has_mention: false,
415
+ mention_types: [],
416
+ has_template: false,
417
+ action: "",
418
+ template_name: "",
419
+ has_expert: false,
420
+ expert_id: "",
421
+ expert_name: "",
422
+ expert_industry_id: "",
423
+ has_skill: false,
424
+ skill_names: [],
425
+ conversationId,
426
+ messageId,
427
+ buddyId: "",
428
+ buddyName: ""
429
+ },
430
+ {
431
+ eventCode: "chat_message_send",
432
+ messageId: assistant,
433
+ historyCount: 0,
434
+ isContextTruncated: false,
435
+ currentStepCount: 1,
436
+ traceId: requestId,
437
+ rootRequestId: requestId,
438
+ parentConversationId: conversationId,
439
+ agentName: "cli",
440
+ agentType: "main"
441
+ },
442
+ {
443
+ eventCode: "chat_request_send",
444
+ inputLength: 24,
445
+ isPlan: false,
446
+ isAutoExecuteTerminal: false,
447
+ isAutoModify: false,
448
+ codebaseEnable: false,
449
+ maxToken: 0,
450
+ maxSteps: 500,
451
+ temperature: 0,
452
+ maxRetries: 0,
453
+ mentionContexts: [],
454
+ knowledgeId: [],
455
+ knowledgeName: [],
456
+ codebaseId: "",
457
+ mentionContextCount: 0,
458
+ command: "",
459
+ recommendId: "",
460
+ skillId: "",
461
+ skillCount: 0,
462
+ totalCount: 0,
463
+ traceId: requestId,
464
+ rootRequestId: requestId,
465
+ parentConversationId: conversationId,
466
+ agentName: "cli",
467
+ agentType: "main",
468
+ ...session
469
+ },
470
+ {
471
+ eventCode: "chat_message_response",
472
+ messageId: assistant,
473
+ responseModelId: modelId,
474
+ inputToken: 120,
475
+ outputToken: 80,
476
+ totalToken: 200,
477
+ cachedTokens: 0,
478
+ cachedWriteTokens: 0,
479
+ cachedMissTokens: 0,
480
+ isSuccessful: true,
481
+ messageErrorCode: "",
482
+ finishReason: "stop",
483
+ firstTokenAt: Date.now(),
484
+ traceId: requestId,
485
+ conversationId,
486
+ rootRequestId: requestId,
487
+ parentConversationId: conversationId,
488
+ agentName: "cli",
489
+ agentType: "main",
490
+ ...session
491
+ },
492
+ {
493
+ eventCode: "chat_message_status",
494
+ messageId: assistant,
495
+ messageErrorCode: "0",
496
+ traceId: requestId,
497
+ rootRequestId: requestId,
498
+ parentConversationId: conversationId,
499
+ agentName: "cli",
500
+ agentType: "main"
501
+ },
502
+ {
503
+ eventCode: "chat_request_response",
504
+ mode: "craft",
505
+ toolCallCount: 0,
506
+ inputToken: 120,
507
+ outputToken: 80,
508
+ totalToken: 200,
509
+ cachedTokens: 0,
510
+ cachedWriteTokens: 0,
511
+ cachedMissTokens: 0,
512
+ isSuccessful: true,
513
+ messageErrorCode: "",
514
+ finishReason: "stop",
515
+ rootRequestId: requestId,
516
+ parentConversationId: conversationId
517
+ }
518
+ ];
519
+ }
520
+ /**
521
+ * A chat chain plus the two canvas events that score `create_canvas`.
522
+ *
523
+ * Worth +300, the joint largest task on the board. The canvas events ride
524
+ * the same metrics channel as everything else, so no real canvas is needed.
525
+ *
526
+ * Measured: three accounts scored 1/1 from this sequence.
527
+ */
528
+ function desktopCanvasEvents(conversationId, requestId) {
529
+ const seed = requestId.slice(-8);
530
+ return [
531
+ ...desktopChatEvents(conversationId, requestId, "msg-canvas"),
532
+ {
533
+ eventCode: "wbx_design_canvas_task_create",
534
+ conversationId,
535
+ requestId,
536
+ source: "summon_keyword",
537
+ cost: 12e3,
538
+ isSuccessful: true
539
+ },
540
+ {
541
+ eventCode: "wbx_design_canvas_open",
542
+ conversationId,
543
+ requestId,
544
+ id: `ardot-file-${seed}`,
545
+ source: "summon_keyword",
546
+ type: "page",
547
+ cost: 13e3,
548
+ isSuccessful: true
549
+ }
550
+ ];
551
+ }
552
+ /**
553
+ * The single event that scores `automation_1` (a scheduled task was created).
554
+ *
555
+ * Measured: two accounts lit it with this event alone.
556
+ */
557
+ function desktopAutomationCreatedEvent(name) {
558
+ return {
559
+ eventCode: "automated_task_create_suc",
560
+ name,
561
+ source: "manually",
562
+ modelId: "fast-model",
563
+ modelIsThinking: true,
564
+ connectorCount: 0,
565
+ skills: "",
566
+ skillCount: 0,
567
+ scheduleType: "once",
568
+ mode: "LOCAL"
569
+ };
570
+ }
571
+ function buddyAppEvents(buddyId, buddyName) {
572
+ const base = {
573
+ mode: "LOCAL",
574
+ buddyId,
575
+ buddyName
576
+ };
577
+ return [
578
+ {
579
+ ...base,
580
+ eventCode: "buddyapp_discover_click"
581
+ },
582
+ {
583
+ ...base,
584
+ eventCode: "buddyapp_show",
585
+ elementId: buddyId,
586
+ elementName: buddyName,
587
+ position: 2
588
+ },
589
+ {
590
+ ...base,
591
+ eventCode: "buddyapp_enter_click",
592
+ elementId: buddyId,
593
+ elementName: buddyName,
594
+ position: 2,
595
+ isFirstPage: "1"
596
+ },
597
+ {
598
+ ...base,
599
+ eventCode: "buddyapp_auth_confirm_click",
600
+ elementId: buddyId,
601
+ elementName: buddyName
602
+ },
603
+ {
604
+ ...base,
605
+ eventCode: "buddyapp_bindaccount_skip_click",
606
+ elementId: buddyId,
607
+ elementName: buddyName
608
+ }
609
+ ];
610
+ }
314
611
  function parseTask(value) {
315
612
  if (typeof value !== "object" || value === null) return void 0;
316
613
  const raw = value;
@@ -623,6 +920,81 @@ var WorkBuddyUpstreamClient = class {
623
920
  };
624
921
  }
625
922
  /**
923
+ * Public fingerprint fields every desktop event carries.
924
+ *
925
+ * The upstream scores a desktop-fingerprinted task only when the event looks
926
+ * like it came from the desktop client: the same field set, the same stable
927
+ * device ids, the same build. A partial map is accepted with 200 and scores
928
+ * nothing, so these are copied wholesale rather than trimmed.
929
+ *
930
+ * `machineId`/`sessionId` are DERIVED from the account uid, never random: a
931
+ * new device id on every call is itself a signal that the traffic is not a
932
+ * real client.
933
+ */
934
+ desktopFingerprint(credential) {
935
+ const now = Date.now();
936
+ return {
937
+ timezone: "Asia/Shanghai",
938
+ reportDelay: 2e3,
939
+ userId: credential.uid ?? "",
940
+ username: credential.nickname ?? "",
941
+ userNickname: credential.nickname ?? "",
942
+ product: "SaaS",
943
+ releaseDate: 1789036585355,
944
+ commit: "5f9692923c93033111c51ad7b003eb80204a9b75",
945
+ ideName: "WorkBuddy",
946
+ ideType: "WorkBuddy",
947
+ ideVersion: "5.5.6",
948
+ machineId: deriveDeviceId(credential, "machine"),
949
+ sessionId: deriveDeviceId(credential, "session"),
950
+ extName: "workbuddy-desktop",
951
+ extVersion: "5.5.6",
952
+ os: "win32",
953
+ arch: "x64",
954
+ osVersion: "10.0.26220",
955
+ cpuCores: 20,
956
+ memorySize: 24,
957
+ timestamp: now,
958
+ presentAt: now
959
+ };
960
+ }
961
+ /**
962
+ * Send desktop-fingerprinted events to the growth system.
963
+ *
964
+ * The body is an ARRAY of events, and every event carries the full desktop
965
+ * fingerprint plus its own business fields. Different tasks recognise
966
+ * different fingerprint families (CLI / desktop / web), which is why this is
967
+ * separate from {@link reportActivity}: they are not interchangeable.
968
+ *
969
+ * Business fields win over the fingerprint, so a caller can override a device
970
+ * id to align with a real install.
971
+ */
972
+ async reportDesktopEvents(credential, events) {
973
+ if (events.length === 0) return;
974
+ const fingerprint = this.desktopFingerprint(credential);
975
+ const body = events.map((event) => ({
976
+ ...fingerprint,
977
+ ...event
978
+ }));
979
+ const response = await this.fetchImpl(`${chatBase(credential)}${DESKTOP_REPORT_PATH}`, {
980
+ method: "POST",
981
+ headers: {
982
+ "Authorization": `Bearer ${credential.accessToken}`,
983
+ "Accept": "application/json, text/plain, */*",
984
+ "Content-Type": "application/json;charset=UTF-8",
985
+ "User-Agent": DESKTOP_TASK_UA,
986
+ "X-Product": "SaaS",
987
+ "X-Request-ID": deriveDeviceId(credential, "req") + String(Date.now() % 1e6),
988
+ ...credential.uid === void 0 || credential.uid === "" ? {} : { "X-User-Id": credential.uid },
989
+ ...credential.domain === "" ? {} : { "X-Domain": credential.domain }
990
+ },
991
+ body: JSON.stringify(body),
992
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
993
+ });
994
+ const envelope = await readEnvelope(response);
995
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
996
+ }
997
+ /**
626
998
  * Report one chat-activity event to the growth system.
627
999
  *
628
1000
  * The body is an ARRAY holding a single `chat_request_send` event, and every
@@ -634,6 +1006,209 @@ var WorkBuddyUpstreamClient = class {
634
1006
  * One report per account per day is the quota the reference panel settled on;
635
1007
  * a single report lights the growth streak and unlocks the `first_buddy`
636
1008
  * family, which is why this runs before the task-centre pass.
1009
+
1010
+ /**
1011
+ * Send a WEB-fingerprinted event.
1012
+ *
1013
+ * A third fingerprint family, alongside CLI and desktop: a browser shape
1014
+ * posted to the web origin with x-client-platform: web. Page-behaviour
1015
+ * tasks such as `Library_read` are scored on it. Measured:
1016
+ * `library_doc_intro_click` scored about four seconds after landing.
1017
+ */
1018
+ async reportWebEvent(credential, eventCode, pageUrl, elementId, elementName) {
1019
+ const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36";
1020
+ const event = {
1021
+ eventCode,
1022
+ timestamp: Date.now(),
1023
+ reportDelay: 0,
1024
+ pageURL: pageUrl,
1025
+ elementId,
1026
+ elementName,
1027
+ os: "Win32",
1028
+ arch: "",
1029
+ osVersion: "10.0",
1030
+ userAgent: ua,
1031
+ machineId: deriveDeviceId(credential, "webmachine"),
1032
+ userId: credential.uid ?? "",
1033
+ userNickname: credential.nickname ?? "",
1034
+ enterpriseId: credential.enterpriseId ?? ""
1035
+ };
1036
+ const response = await this.fetchImpl(`https://www.workbuddy.cn/v2/report`, {
1037
+ method: "POST",
1038
+ headers: {
1039
+ "Authorization": `Bearer ${credential.accessToken}`,
1040
+ "Content-Type": "application/json",
1041
+ "Accept": "application/json",
1042
+ "x-client-platform": "web",
1043
+ "Origin": "https://www.workbuddy.cn",
1044
+ "Referer": pageUrl,
1045
+ "User-Agent": ua,
1046
+ ...credential.uid === void 0 || credential.uid === "" ? {} : { "X-User-Id": credential.uid }
1047
+ },
1048
+ body: JSON.stringify([event]),
1049
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1050
+ });
1051
+ const envelope = await readEnvelope(response);
1052
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1053
+ }
1054
+ /**
1055
+ * Apply an appearance theme on the account.
1056
+ *
1057
+ * The theme task is scored on the `appearance_skin_apply` event, not on this
1058
+ * call — but the event alone is not enough either. The pair is what a real
1059
+ * client produces: it PATCHes the account's selected skin, then reports the
1060
+ * event as the settings page closes. Measured on the reference panel after
1061
+ * the earlier "the API alone does not score" reading was corrected.
1062
+ */
1063
+ async setAppearanceTheme(credential, resourceKey) {
1064
+ const response = await this.fetchImpl(`${chatBase(credential)}${APPEARANCE_SET_PATH}`, {
1065
+ method: "POST",
1066
+ headers: {
1067
+ "Authorization": `Bearer ${credential.accessToken}`,
1068
+ "Accept": "application/json, text/plain, */*",
1069
+ "Content-Type": "application/json;charset=UTF-8",
1070
+ "User-Agent": DESKTOP_TASK_UA,
1071
+ "X-Product": "SaaS",
1072
+ ...credential.uid === void 0 || credential.uid === "" ? {} : { "X-User-Id": credential.uid }
1073
+ },
1074
+ body: JSON.stringify({
1075
+ kind: "theme",
1076
+ resource_key: resourceKey
1077
+ }),
1078
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1079
+ });
1080
+ const envelope = await readEnvelope(response);
1081
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1082
+ }
1083
+ /**
1084
+ * The platform's expert marketplace.
1085
+ *
1086
+ * Needed before any expert can be summoned: the scorer verifies that the
1087
+ * expert id exists on the platform, so a made-up id scores nothing. The
1088
+ * response carries the display fields the summon events replay.
1089
+ */
1090
+ async marketExpertList(credential, expertType = "") {
1091
+ const request = {
1092
+ page: 1,
1093
+ page_size: 20,
1094
+ sort_by: "reco_rank",
1095
+ sort_order: "desc"
1096
+ };
1097
+ if (expertType !== "") request["expert_type"] = expertType;
1098
+ const response = await this.fetchImpl(`${chatBase(credential)}${MARKET_EXPERT_LIST_PATH}`, {
1099
+ method: "POST",
1100
+ headers: {
1101
+ ...chatHeaders(credential),
1102
+ "User-Agent": DESKTOP_TASK_UA
1103
+ },
1104
+ body: JSON.stringify(request),
1105
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1106
+ });
1107
+ const envelope = await readEnvelope(response);
1108
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1109
+ const data = asRecord(envelope.data);
1110
+ const raw = Array.isArray(data["experts"]) ? data["experts"] : [];
1111
+ const out = [];
1112
+ for (const value of raw) {
1113
+ const expert = asRecord(value);
1114
+ const expertId = typeof expert["expert_id"] === "string" ? expert["expert_id"] : "";
1115
+ if (expertId === "") continue;
1116
+ out.push({
1117
+ expertId,
1118
+ expertType: typeof expert["expert_type"] === "string" ? expert["expert_type"] : "",
1119
+ displayName: typeof expert["display_name_zh"] === "string" ? expert["display_name_zh"] : "",
1120
+ profession: typeof expert["profession_zh"] === "string" ? expert["profession_zh"] : "",
1121
+ version: typeof expert["version"] === "string" ? expert["version"] : "",
1122
+ categories: Array.isArray(expert["categories"]) ? expert["categories"].filter((item) => typeof item === "string") : []
1123
+ });
1124
+ }
1125
+ return out;
1126
+ }
1127
+ /**
1128
+ * Open a REAL desktop conversation and return the ids the server assigned.
1129
+ *
1130
+ * The expert and skill tasks join their events to a conversation the server
1131
+ * has actually seen, so a locally invented `requestId` scores nothing. This
1132
+ * starts a chat, reads the server's id out of the SSE stream, then drops the
1133
+ * rest of the stream — the answer itself is irrelevant, only its identity is.
1134
+ *
1135
+ * Returns `undefined` instead of throwing when the conversation cannot be
1136
+ * opened or carries no recognisable id, because every caller is a best-effort
1137
+ * task chain.
1138
+ */
1139
+ async openConversation(credential, expertId = "", signal) {
1140
+ const conversationId = `wb2auto-conv-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1141
+ const body = JSON.stringify({
1142
+ model: "fast-model",
1143
+ messages: [{
1144
+ role: "system",
1145
+ content: "You are a helpful assistant. 当前处于中文环境,使用简体中文回答。"
1146
+ }, {
1147
+ role: "user",
1148
+ content: "1+1等于几?直接回答。"
1149
+ }],
1150
+ agent: "cli",
1151
+ temperature: 1,
1152
+ stream: true,
1153
+ stream_options: { include_usage: true }
1154
+ });
1155
+ let response;
1156
+ try {
1157
+ response = await this.fetchImpl(`${chatBase(credential)}/v2/chat/completions`, {
1158
+ method: "POST",
1159
+ headers: {
1160
+ ...chatHeaders(credential),
1161
+ "Accept": "text/event-stream",
1162
+ "User-Agent": DESKTOP_TASK_UA,
1163
+ "X-Conversation-ID": conversationId,
1164
+ "X-Request-ID": String(Date.now()) + "000000",
1165
+ "X-Agent-Intent": "craft",
1166
+ "X-Agent-Type": "main",
1167
+ "X-IDE-Name": "WorkBuddy",
1168
+ "X-IDE-Type": "WorkBuddy",
1169
+ "X-IDE-Version": "5.5.6",
1170
+ "x-codebuddy-request": "1",
1171
+ ...expertId === "" ? {} : { "X-Expert-Id": expertId }
1172
+ },
1173
+ body,
1174
+ signal: signal ?? AbortSignal.timeout(CHAT_TIMEOUT_MS)
1175
+ });
1176
+ } catch {
1177
+ return;
1178
+ }
1179
+ if (!response.ok || response.body === null) {
1180
+ await response.body?.cancel().catch(() => {});
1181
+ return;
1182
+ }
1183
+ const reader = response.body.getReader();
1184
+ const decoder = new TextDecoder();
1185
+ let buffer = "";
1186
+ try {
1187
+ while (buffer.length < SSE_SCAN_LIMIT) {
1188
+ const chunk = await reader.read();
1189
+ if (chunk.done) break;
1190
+ buffer += decoder.decode(chunk.value, { stream: true });
1191
+ const match = SERVER_ID_PATTERN.exec(buffer);
1192
+ if (match !== null) return {
1193
+ conversationId,
1194
+ requestId: match[1] ?? ""
1195
+ };
1196
+ }
1197
+ } catch {} finally {
1198
+ await reader.cancel().catch(() => {});
1199
+ }
1200
+ }
1201
+ /**
1202
+ * Report one chat-activity event to the growth system.
1203
+ *
1204
+ * The body is an ARRAY holding a single chat_request_send event, and every
1205
+ * field is filled in: a three-field minimal event is accepted with 200 and
1206
+ * then silently dropped, so the full shape is load-bearing rather than
1207
+ * cosmetic. userId is the one field the server actually keys on.
1208
+ *
1209
+ * One report per account per day is the quota the reference panel settled
1210
+ * on; a single report lights the growth streak and unlocks the first_buddy
1211
+ * family, which is why this runs before the task-centre pass.
637
1212
  */
638
1213
  async reportActivity(credential, conversationId) {
639
1214
  const conversationID = conversationId ?? `wb2api-${Date.now()}`;
@@ -716,6 +1291,159 @@ var WorkBuddyUpstreamClient = class {
716
1291
  const streak = typeof data["streak"] === "object" && data["streak"] !== null ? data["streak"] : {};
717
1292
  return typeof streak["days"] === "number" ? streak["days"] : 0;
718
1293
  }
1294
+ /**
1295
+ * The full streak picture: days, tier unlock state, and what each tier pays.
1296
+ *
1297
+ * Read before redeeming, because the tier state is the only honest answer to
1298
+ * "is there anything to claim": the redeem endpoint answers 403 for a locked
1299
+ * tier, which is indistinguishable from a real failure once the response is
1300
+ * just an error.
1301
+ */
1302
+ async growthStreakFull(credential) {
1303
+ const data = await this.growthJson(credential, "GET", "/activity/growth/streak");
1304
+ const streak = asRecord(data["streak"]);
1305
+ const redemption = asRecord(data["redemption_status"]);
1306
+ const cards = asRecord(data["makeup_cards"]);
1307
+ const tiers = [];
1308
+ if (Array.isArray(redemption["tiers"])) for (const entry of redemption["tiers"]) {
1309
+ const tier = asRecord(entry);
1310
+ const name = typeof tier["tier"] === "string" ? tier["tier"] : "";
1311
+ if (name === "") continue;
1312
+ tiers.push({
1313
+ tier: name,
1314
+ days: numOf(tier["days"]),
1315
+ credit: numOf(tier["credit"]),
1316
+ energy: numOf(tier["energy"]),
1317
+ cards: numOf(tier["cards"]),
1318
+ chances: numOf(tier["chances"]),
1319
+ status: String(redemption[`tier_${name}_status`] ?? "")
1320
+ });
1321
+ }
1322
+ return {
1323
+ days: numOf(streak["days"]),
1324
+ monthTotalDays: numOf(streak["month_total_days"]),
1325
+ nextTier: typeof streak["next_tier"] === "string" ? streak["next_tier"] : "",
1326
+ nextTierRemaining: numOf(streak["next_tier_remaining"]),
1327
+ makeupCards: numOf(cards["balance"]),
1328
+ tiers
1329
+ };
1330
+ }
1331
+ /**
1332
+ * Redeem one unlocked streak tier.
1333
+ *
1334
+ * A locked tier answers 403 ("连续登录天数不足"); callers check the status from
1335
+ * {@link growthStreakFull} first, so this only throws for genuine failures.
1336
+ * The client token is the upstream's idempotency key — a fresh one per attempt
1337
+ * keeps a retry from being read as a duplicate of the last one.
1338
+ */
1339
+ async redeemStreakTier(credential, tier) {
1340
+ await this.growthJson(credential, "POST", "/activity/growth/redeem", {
1341
+ tier,
1342
+ client_token: randomUUID()
1343
+ });
1344
+ }
1345
+ /** How many lottery draws are available right now. */
1346
+ async lotteryChances(credential) {
1347
+ return numOf((await this.growthJson(credential, "GET", "/activity/growth/lottery/summary"))["chances"]);
1348
+ }
1349
+ /**
1350
+ * Draw the lottery once.
1351
+ *
1352
+ * Returns the raw prize payload: its shape is set by the running campaign, so
1353
+ * it is passed through rather than modelled.
1354
+ */
1355
+ async lotteryDraw(credential) {
1356
+ return this.growthJson(credential, "POST", "/activity/growth/lottery/draw", { client_token: randomUUID() });
1357
+ }
1358
+ /**
1359
+ * The buddy profile, or undefined when the account has no buddy yet.
1360
+ *
1361
+ * `data.buddy` is null / absent / an empty object depending on how far the
1362
+ * account got, and all three mean the same thing to a caller: adopt first.
1363
+ */
1364
+ async buddyInfo(credential) {
1365
+ const buddy = asRecord((await this.growthJson(credential, "GET", "/activity/growth/buddy/info"))["buddy"]);
1366
+ if (Object.keys(buddy).length === 0) return void 0;
1367
+ return {
1368
+ instanceId: numOf(buddy["instance_id"]),
1369
+ name: String(buddy["name"] ?? "")
1370
+ };
1371
+ }
1372
+ /** Agree to the buddy terms. Idempotent upstream. */
1373
+ async buddyAgree(credential) {
1374
+ await this.growthJson(credential, "POST", "/activity/growth/buddy/agreement", { agree: true });
1375
+ }
1376
+ /**
1377
+ * Adopt the first buddy.
1378
+ *
1379
+ * Gated upstream on having reported activity that day: without it the answer
1380
+ * is 400 "first_buddy task not completed yet". Callers treat that as "not yet"
1381
+ * rather than an error, which is why it is thrown as-is for them to classify.
1382
+ */
1383
+ async buddyAdoptFirst(credential) {
1384
+ await this.growthJson(credential, "POST", "/activity/growth/buddy/first", {});
1385
+ }
1386
+ /** Current travel state for the account's buddy. */
1387
+ async buddyTravelStatus(credential) {
1388
+ const data = await this.growthJson(credential, "GET", "/activity/growth/buddy/travel/status");
1389
+ return {
1390
+ state: typeof data["state"] === "string" ? data["state"] : "",
1391
+ recordId: numOf(data["record_id"]),
1392
+ dailyLimitReached: data["daily_limit_reached"] === true,
1393
+ rewardCredit: numOf(data["reward_credit"])
1394
+ };
1395
+ }
1396
+ /**
1397
+ * Send the buddy travelling.
1398
+ *
1399
+ * The location is always 4 (古镇客栈): the four locations have identical
1400
+ * reward and duration ranges, so there is nothing to optimise.
1401
+ */
1402
+ async buddyTravelDepart(credential, locationId = 4) {
1403
+ await this.growthJson(credential, "POST", "/activity/growth/buddy/travel/depart", { location_id: locationId });
1404
+ }
1405
+ /**
1406
+ * Collect an arrived trip's reward.
1407
+ *
1408
+ * `recordId` is required and comes from the status read; the upstream rejects
1409
+ * a claim without it.
1410
+ */
1411
+ async buddyTravelClaim(credential, recordId) {
1412
+ return numOf((await this.growthJson(credential, "POST", "/activity/growth/buddy/travel/claim", { record_id: recordId }))["reward_credit"]);
1413
+ }
1414
+ /** Whether yesterday is a gap in the activity heatmap. */
1415
+ async heatmapYesterdayMissed(credential) {
1416
+ const data = await this.growthJson(credential, "GET", "/activity/growth/heatmap");
1417
+ if (!Array.isArray(data["cells"])) return false;
1418
+ const key = dayKeyLocal(/* @__PURE__ */ new Date(Date.now() - 864e5));
1419
+ for (const entry of data["cells"]) {
1420
+ const cell = asRecord(entry);
1421
+ if (cell["date"] === key) return numOf(cell["score"]) === 0;
1422
+ }
1423
+ return false;
1424
+ }
1425
+ /** Spend one makeup card on a date. Idempotent for an already-filled date. */
1426
+ async useMakeupCard(credential, date) {
1427
+ await this.growthJson(credential, "POST", "/activity/growth/makeup-cards/use", { date });
1428
+ }
1429
+ /**
1430
+ * Call a growth-domain endpoint and return its unwrapped `data`.
1431
+ *
1432
+ * These endpoints live on the chat host with the billing header set, and
1433
+ * carry the same envelope as everything else. Centralised here because every
1434
+ * growth call needs the identical envelope check.
1435
+ */
1436
+ async growthJson(credential, method, path, body) {
1437
+ const response = await this.fetchImpl(`${chatBase(credential)}${path}`, {
1438
+ method,
1439
+ headers: billingHeaders(credential),
1440
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
1441
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1442
+ });
1443
+ const envelope = await readEnvelope(response);
1444
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1445
+ return asRecord(envelope.data);
1446
+ }
719
1447
  /** Legacy thin wrapper kept for `status`/`doctor`: returns raw envelope data. */
720
1448
  async credits(credential) {
721
1449
  try {
@@ -1042,6 +1770,25 @@ var WorkBuddyAccountPool = class {
1042
1770
  */
1043
1771
  disabledIds = /* @__PURE__ */ new Set();
1044
1772
  /**
1773
+ * Per-account credit floor, keyed by account id. 0 (or absent) means "spend
1774
+ * it all".
1775
+ *
1776
+ * A reserved balance is protection, not a hard limit the upstream knows
1777
+ * about: the pool simply stops picking that account once its last known
1778
+ * balance is at or below the floor, so the user keeps a cushion instead of
1779
+ * draining every account to zero.
1780
+ */
1781
+ creditReserves = /* @__PURE__ */ new Map();
1782
+ /**
1783
+ * Last known credit balance per account, epoch ms aside.
1784
+ *
1785
+ * Refreshed in the background after a successful request, so a pick can
1786
+ * consult it. An account with no reading is treated as usable: refusing to
1787
+ * pick an account just because its balance has not been checked yet would
1788
+ * strand a healthy pool, and the first 402 still cools it as before.
1789
+ */
1790
+ creditBalances = /* @__PURE__ */ new Map();
1791
+ /**
1045
1792
  * Last time each account served a request, epoch ms. Drives the idle term
1046
1793
  * of the priority-mode weighting below: an account that just served loses to
1047
1794
  * one that has been idle, so a small pool stops hammering a single account.
@@ -1072,6 +1819,7 @@ var WorkBuddyAccountPool = class {
1072
1819
  if (options.exhaustCooldownMs !== void 0 && options.exhaustCooldownMs >= 1e3) this.exhaustCooldownMs = options.exhaustCooldownMs;
1073
1820
  if (options.distribution !== void 0) this.distribution = options.distribution;
1074
1821
  if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
1822
+ if (options.creditReserves !== void 0) this.setCreditReserves(options.creditReserves);
1075
1823
  }
1076
1824
  /** Rescan the auth directories and merge newly discovered accounts. */
1077
1825
  async scan() {
@@ -1125,6 +1873,11 @@ var WorkBuddyAccountPool = class {
1125
1873
  available(now, modelId, region) {
1126
1874
  return this.accounts.filter((account) => {
1127
1875
  if (this.disabledIds.has(account.id)) return false;
1876
+ const reserve = this.creditReserves.get(account.id);
1877
+ if (reserve !== void 0 && reserve > 0) {
1878
+ const balance = this.creditBalances.get(account.id);
1879
+ if (balance !== void 0 && balance <= reserve) return false;
1880
+ }
1128
1881
  if (account.cooldownUntilMs > now) return false;
1129
1882
  if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
1130
1883
  if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
@@ -1234,6 +1987,57 @@ var WorkBuddyAccountPool = class {
1234
1987
  this.lastUsedAt.set(accountId, Date.now());
1235
1988
  }
1236
1989
  /**
1990
+ * Record an account latest known credit balance.
1991
+ *
1992
+ * Called after a request and by the card balance refresh, so the reserve
1993
+ * check has something to compare against. A reading for an unknown account is
1994
+ * dropped: `scan()` rebuilds the account list and a stale id would otherwise
1995
+ * accumulate forever.
1996
+ */
1997
+ noteCredits(accountId, balance) {
1998
+ if (!Number.isFinite(balance)) return;
1999
+ if (!this.accounts.some((account) => account.id === accountId)) return;
2000
+ this.creditBalances.set(accountId, balance);
2001
+ }
2002
+ /** Last known balance for one account, or undefined when never read. */
2003
+ creditsOf(accountId) {
2004
+ return this.creditBalances.get(accountId);
2005
+ }
2006
+ /** The credit floor the user set for one account; 0 when unset. */
2007
+ creditReserveOf(accountId) {
2008
+ return this.creditReserves.get(accountId) ?? 0;
2009
+ }
2010
+ /**
2011
+ * Replace every reserve. Called from settings on each apply, so the map
2012
+ * mirrors the saved document exactly instead of accumulating old keys.
2013
+ */
2014
+ setCreditReserves(reserves) {
2015
+ const next = /* @__PURE__ */ new Map();
2016
+ for (const [id, value] of Object.entries(reserves)) if (Number.isFinite(value) && value > 0) next.set(id, Math.floor(value));
2017
+ this.creditReserves = next;
2018
+ }
2019
+ /** Every reserve currently in force, keyed by account id. */
2020
+ creditReservesInOrder() {
2021
+ const out = {};
2022
+ for (const account of this.accounts) {
2023
+ const reserve = this.creditReserves.get(account.id);
2024
+ if (reserve !== void 0 && reserve > 0) out[account.id] = reserve;
2025
+ }
2026
+ return out;
2027
+ }
2028
+ /**
2029
+ * Whether an account is held back only by its reserve.
2030
+ *
2031
+ * Separates "resting to protect credits" from every other reason an account
2032
+ * is out of rotation, which is what the card shows the user.
2033
+ */
2034
+ isReserved(accountId) {
2035
+ const reserve = this.creditReserves.get(accountId);
2036
+ if (reserve === void 0 || reserve <= 0) return false;
2037
+ const balance = this.creditBalances.get(accountId);
2038
+ return balance !== void 0 && balance <= reserve;
2039
+ }
2040
+ /**
1237
2041
  * The account that served the most recent request, if any.
1238
2042
  *
1239
2043
  * Distinct from "who would serve the next one": this is a record of what
@@ -1714,28 +2518,529 @@ function createWorkBuddyAdapter(options) {
1714
2518
  }
1715
2519
  };
1716
2520
  }
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) {
2521
+ //#endregion
2522
+ //#region src/task-events.ts
2523
+ /**
2524
+ * The app the buddy chain enters.
2525
+ *
2526
+ * One chain lights up two tasks: `Buddy_App` (open any app) and `Buddy_App_QQ`
2527
+ * (the QQ-specific one), because this is a QQ-hosted app. Measured 0/1 → 1/1 on
2528
+ * both from a single chain.
2529
+ */
2530
+ const BUDDY_APP_ID = "cb_y5Dy46tPQGGWtueMxXbe";
2531
+ const BUDDY_APP_NAME = "企鹅教师助手";
2532
+ /** A stable-ish id for a synthetic conversation, unique per call. */
2533
+ function syntheticId(prefix) {
2534
+ return `wb2auto-${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2535
+ }
2536
+ /**
2537
+ * A chat chain seeded with a placeholder conversation/request id.
2538
+ *
2539
+ * Several tasks do not verify that the conversation exists — they only check
2540
+ * that the events in the chain carry *a* well-formed id — so a synthetic pair is
2541
+ * enough. `RichMeow_Chat`, `create_canvas` and `automation_1` were all measured
2542
+ * lighting up from exactly this.
2543
+ */
2544
+ function syntheticChatChain(prefix) {
2545
+ const conversationId = syntheticId(prefix);
2546
+ const requestId = syntheticId(`${prefix}-req`);
2547
+ return {
2548
+ conversationId,
2549
+ requestId,
2550
+ events: desktopChatEvents(conversationId, requestId, `msg-${prefix}`)
2551
+ };
2552
+ }
2553
+ /**
2554
+ * The buddy-app chain (two tasks).
2555
+ *
2556
+ * Five clicks in the order a user would make them: discover the app, see it,
2557
+ * enter it, confirm the account link, skip the second binding step.
2558
+ */
2559
+ function buddyAppChain() {
2560
+ const base = {
2561
+ mode: "LOCAL",
2562
+ buddyId: BUDDY_APP_ID,
2563
+ buddyName: BUDDY_APP_NAME
2564
+ };
2565
+ return {
2566
+ transport: "desktop",
2567
+ events: [
2568
+ {
2569
+ ...base,
2570
+ eventCode: "buddyapp_discover_click"
2571
+ },
2572
+ {
2573
+ ...base,
2574
+ eventCode: "buddyapp_show",
2575
+ elementId: BUDDY_APP_ID,
2576
+ elementName: BUDDY_APP_NAME,
2577
+ position: 2
2578
+ },
2579
+ {
2580
+ ...base,
2581
+ eventCode: "buddyapp_enter_click",
2582
+ elementId: BUDDY_APP_ID,
2583
+ elementName: BUDDY_APP_NAME,
2584
+ position: 2,
2585
+ isFirstPage: "1"
2586
+ },
2587
+ {
2588
+ ...base,
2589
+ eventCode: "buddyapp_auth_confirm_click",
2590
+ elementId: BUDDY_APP_ID,
2591
+ elementName: BUDDY_APP_NAME
2592
+ },
2593
+ {
2594
+ ...base,
2595
+ eventCode: "buddyapp_bindaccount_skip_click",
2596
+ elementId: BUDDY_APP_ID,
2597
+ elementName: BUDDY_APP_NAME
2598
+ }
2599
+ ]
2600
+ };
2601
+ }
2602
+ /**
2603
+ * The design-canvas chain (`create_canvas`, +300 — the joint largest reward).
2604
+ *
2605
+ * The two canvas events ride the same metrics channel as everything else, so no
2606
+ * real canvas is ever created; the chat chain in front supplies the ids they
2607
+ * reference. Measured 1/1 on three accounts.
2608
+ */
2609
+ function canvasChain() {
2610
+ const { conversationId, requestId, events } = syntheticChatChain("canvas");
2611
+ return {
2612
+ transport: "desktop",
2613
+ events: [
2614
+ ...events,
2615
+ {
2616
+ eventCode: "wbx_design_canvas_task_create",
2617
+ conversationId,
2618
+ requestId,
2619
+ source: "summon_keyword",
2620
+ cost: 12e3,
2621
+ isSuccessful: true
2622
+ },
2623
+ {
2624
+ eventCode: "wbx_design_canvas_open",
2625
+ conversationId,
2626
+ requestId,
2627
+ id: `ardot-file-${requestId.slice(-8)}`,
2628
+ source: "summon_keyword",
2629
+ type: "page",
2630
+ cost: 13e3,
2631
+ isSuccessful: true
2632
+ }
2633
+ ]
2634
+ };
2635
+ }
2636
+ /**
2637
+ * The scheduled-task event (`automation_1`).
2638
+ *
2639
+ * One event is the whole chain — measured 1/1 on two accounts. The name is only
2640
+ * for the server's own records, so a generated one is fine.
2641
+ */
2642
+ function automationChain() {
2643
+ return {
2644
+ transport: "desktop",
2645
+ events: [{
2646
+ eventCode: "automated_task_create_suc",
2647
+ name: `定时任务-${Date.now().toString(36)}`,
2648
+ source: "manually",
2649
+ modelId: "fast-model",
2650
+ modelIsThinking: true,
2651
+ connectorCount: 0,
2652
+ skills: "",
2653
+ skillCount: 0,
2654
+ scheduleType: "once",
2655
+ mode: "LOCAL"
2656
+ }]
2657
+ };
2658
+ }
2659
+ /**
2660
+ * The plain chat chain (`RichMeow_Chat`, and the base of the template chain).
2661
+ *
2662
+ * Measured: this chain alone lights `RichMeow_Chat`.
2663
+ */
2664
+ function chatChain() {
2665
+ const { events } = syntheticChatChain("chat");
2666
+ return {
2667
+ transport: "desktop",
2668
+ events
2669
+ };
2670
+ }
2671
+ /**
2672
+ * The "same as this case" chain (`playbook_prompt`).
2673
+ *
2674
+ * The scorer watches `playbook_prompt_send` — sending the prompt that the
2675
+ * inspiration case pre-fills — not the card impression or the button click, so
2676
+ * the whole click path is replayed for realism but the send is what counts.
2677
+ */
2678
+ function playbookChain(caseId = PLAYBOOK_CASE_ID, caseName = PLAYBOOK_CASE_NAME) {
2679
+ const { conversationId, requestId, events } = syntheticChatChain("pb");
2680
+ const payload = {
2681
+ id: caseId,
2682
+ name: caseName,
2683
+ type: "document",
2684
+ categoryId: "",
2685
+ categoryName: ""
2686
+ };
2687
+ return {
2688
+ transport: "desktop",
2689
+ events: [
2690
+ ...events,
2691
+ {
2692
+ eventCode: "web_element_click",
2693
+ pageName: "playbook_detail",
2694
+ elementId: "playbook_ctaClick",
2695
+ elementName: caseName,
2696
+ source: "discover"
2697
+ },
2698
+ {
2699
+ eventCode: "playbook_cta_click",
2700
+ source: "discover",
2701
+ position: 0,
2702
+ ...payload
2703
+ },
2704
+ {
2705
+ eventCode: "playbook_prompt_send",
2706
+ conversationId,
2707
+ requestId,
2708
+ ...payload
2709
+ }
2710
+ ]
2711
+ };
2712
+ }
2713
+ /** The inspiration case the reference panel sends a prompt for. */
2714
+ const PLAYBOOK_CASE_ID = "pm-gtm-launch-plan";
2715
+ const PLAYBOOK_CASE_NAME = "新产品上市 GTM 发布计划一页纸";
2716
+ /**
2717
+ * The five templates the reference panel cycles through, as `[id, name]`.
2718
+ *
2719
+ * The upstream does not check that these templates exist — only that five
2720
+ * distinct `template_used` events arrive — so they are the reference set.
2721
+ */
2722
+ const TEMPLATE_PRESETS = [
2723
+ ["1", "深度研究"],
2724
+ ["2", "周报生成"],
2725
+ ["3", "竞品分析"],
2726
+ ["4", "活动策划"],
2727
+ ["5", "代码评审"]
2728
+ ];
2729
+ /**
2730
+ * One "created a task from a template" chain (`template_5`, +100 for five).
2731
+ *
2732
+ * Each group is a chat chain (which supplies the ids the template events join
2733
+ * on) plus `agent_task_created_with_template` and `template_used`. Measured:
2734
+ * five groups in one report scored 5/5.
2735
+ */
2736
+ function templateChain(templateId, templateName) {
2737
+ const { conversationId, requestId, events } = syntheticChatChain(`tpl${templateId}`);
2738
+ return {
2739
+ transport: "desktop",
2740
+ events: [
2741
+ ...events,
2742
+ {
2743
+ eventCode: "agent_task_created_with_template",
2744
+ mode: "working",
2745
+ isCustomModel: false,
2746
+ id: templateId,
2747
+ name: templateName,
2748
+ requestId
2749
+ },
2750
+ {
2751
+ eventCode: "template_used",
2752
+ template_id: templateId,
2753
+ task_mode: "working"
2754
+ }
2755
+ ]
2756
+ };
2757
+ }
2758
+ /** Every template group, ready to send in order. */
2759
+ function templateChains() {
2760
+ return TEMPLATE_PRESETS.map(([id, name]) => templateChain(id, name));
2761
+ }
2762
+ /**
2763
+ * The library-introduction click (`Library_read`).
2764
+ *
2765
+ * Scored on the WEB fingerprint — the same event posted with the desktop
2766
+ * fingerprint scores nothing — so this chain returns a web transport and the
2767
+ * scheduler routes it through `reportWebEvent`.
2768
+ */
2769
+ function libraryReadChain() {
2770
+ return {
2771
+ transport: "web",
2772
+ web: {
2773
+ eventCode: "web_element_click",
2774
+ pageUrl: LIBRARY_DOC_URL,
2775
+ elementId: "library_doc_intro_click",
2776
+ elementName: "WorkBuddy资料库介绍"
2777
+ }
2778
+ };
2779
+ }
2780
+ /** The document the library click is reported against. */
2781
+ const LIBRARY_DOC_URL = "https://www.workbuddy.cn/space/d/o0KWYeynteVv06UnAZqIFm";
2782
+ /** The theme key `Hp_Appearance` is scored on (和平精英激战金秋). */
2783
+ const APPEARANCE_THEME_KEY = "theme-tkmw7j";
2784
+ /** The skill `skill_1` is scored on. */
2785
+ const SKILL_ID = "skill_2097350077599879168";
2786
+ const SKILL_NAME = "润泽小馆·日报撰写";
2787
+ const SKILL_VERSION = "1.0.0";
2788
+ /** The 腾讯轻量云 expert `Expert_lighthouse` is scored on. */
2789
+ const LIGHTHOUSE_EXPERT_ID = "ex_2cvvUZQhDyeJ";
2790
+ /**
2791
+ * Rewrite the chain so the chatting half claims a tool call happened.
2792
+ *
2793
+ * A skills task is only credited when the response reports
2794
+ * `finishReason: 'tool_calls'` — that is, the model loaded the skill as a tool —
2795
+ * rather than a plain text answer.
2796
+ */
2797
+ function markToolCall(events) {
2798
+ return events.map((event) => event["eventCode"] === "chat_message_response" ? {
2799
+ ...event,
2800
+ finishReason: "tool_calls"
2801
+ } : event);
2802
+ }
2803
+ /**
2804
+ * Build the `skill_1` chain from a REAL conversation.
2805
+ *
2806
+ * Unlike the template and canvas chains, this one is verified against the
2807
+ * conversation it names, so the caller must first open a real chat and hand the
2808
+ * server-side ids in.
2809
+ */
2810
+ function skillChain(conversationId, requestId) {
2811
+ const messageId = `msg-${requestId.slice(-8)}`;
2812
+ return {
2813
+ transport: "desktop",
2814
+ events: [...markToolCall(desktopChatEvents(conversationId, requestId, messageId)), {
2815
+ eventCode: "skill_info",
2816
+ id: SKILL_NAME,
2817
+ skillId: SKILL_ID,
2818
+ skillVersion: SKILL_VERSION,
2819
+ toolStatus: "success",
2820
+ fileCount: 56,
2821
+ source: "workbuddy-desktop",
2822
+ conversationId,
2823
+ requestId,
2824
+ messageId,
2825
+ requestModelId: "fast-model",
2826
+ requestModelName: "fast-model",
2827
+ traceId: requestId
2828
+ }]
2829
+ };
2830
+ }
2831
+ /** The theme-apply event `Hp_Appearance` is scored on. */
2832
+ function appearanceChain(themeKey = APPEARANCE_THEME_KEY) {
2833
+ return {
2834
+ transport: "desktop",
2835
+ events: [{
2836
+ eventCode: "appearance_skin_apply",
2837
+ action: "apply",
2838
+ source: "settings_close",
2839
+ id: themeKey,
2840
+ vipLevel: 0,
2841
+ series: "",
2842
+ type: "unknown"
2843
+ }]
2844
+ };
2845
+ }
2846
+ /**
2847
+ * The three "summon an expert" events (`expert_summon_click` and friends).
2848
+ *
2849
+ * Paid before the conversation, in the order the app emits them.
2850
+ */
2851
+ function expertSummonEvents(expert) {
2852
+ const category = expert.categories[0] ?? "expert-all";
2853
+ const version = expert.version === "" ? "1.0.0" : expert.version;
2854
+ return [
2855
+ {
2856
+ eventCode: "web_element_click",
2857
+ source: expert.expertId,
2858
+ type: category,
2859
+ version,
2860
+ elementId: "expert_summon_click",
2861
+ elementName: "立即召唤",
2862
+ pageURL: "/C:/Program%20Files/WorkBuddy/resources/app.asar/renderer/index.html"
2863
+ },
2864
+ {
2865
+ eventCode: "expert_summon_click",
2866
+ id: expert.expertId,
2867
+ name: expert.displayName,
2868
+ expertTitle: expert.profession,
2869
+ type: "expert-all",
2870
+ position: 0,
2871
+ expertType: expert.expertType,
2872
+ version,
2873
+ mode: "LOCAL"
2874
+ },
2875
+ {
2876
+ eventCode: "expert_summoned",
2877
+ id: expert.expertId,
2878
+ name: expert.displayName,
2879
+ expertTitle: expert.profession,
2880
+ type: "expert-all"
2881
+ }
2882
+ ];
2883
+ }
2884
+ /**
2885
+ * The "an expert really answered" event, which is what the expert tasks count.
2886
+ *
2887
+ * The `requestId` must be the SERVER's id for a real chat: a made-up one scores
2888
+ * nothing, because the scorer looks the conversation up.
2889
+ */
2890
+ function expertActualUseEvent(expert, conversationId, requestId, mode = "craft") {
2891
+ const category = expert.categories[0] ?? "expert-all";
2892
+ const version = expert.version === "" ? "1.0.0" : expert.version;
2893
+ return {
2894
+ eventCode: "expert_actual_use",
2895
+ id: expert.expertId,
2896
+ name: expert.displayName,
2897
+ expertTitle: expert.profession,
2898
+ type: category,
2899
+ expertType: expert.expertType,
2900
+ source: "builtin",
2901
+ version,
2902
+ cost: 9e3,
2903
+ characterCount: 14,
2904
+ mode,
2905
+ conversationId,
2906
+ requestId,
2907
+ messageId: `msg-${requestId.slice(-8)}`,
2908
+ requestModelId: "fast-model",
2909
+ requestModelName: "fast-model"
2910
+ };
2911
+ }
2912
+ /**
2913
+ * The chat chain for an expert conversation.
2914
+ *
2915
+ * `agent_task_created` carries the expert fields the scorer reads to attribute
2916
+ * the conversation to that expert.
2917
+ */
2918
+ function expertChatEvents(expert, conversationId, requestId) {
2919
+ return desktopChatEvents(conversationId, requestId, `msg-${requestId.slice(-8)}`).map((event) => event["eventCode"] === "agent_task_created" ? {
2920
+ ...event,
2921
+ has_expert: true,
2922
+ expert_id: expert.expertId,
2923
+ expert_name: expert.displayName,
2924
+ expert_industry_id: ""
2925
+ } : event);
2926
+ }
2927
+ /** How often the loop wakes to look for a due job. */
2928
+ /**
2929
+ * How long to wait for event scoring to land before re-reading the task list.
2930
+ *
2931
+ * Scoring is asynchronous on the upstream side, so an immediate re-read still
2932
+ * shows the old progress and the claim pass would skip a task that is in fact
2933
+ * now claimable. Measured: the chain is reflected by about eight seconds.
2934
+ */
2935
+ const EVENT_SCORE_WAIT_MS = 9e3;
2936
+ /**
2937
+ * The fallback record for the 腾讯轻量云 expert, used when the marketplace
2938
+ * listing cannot be read. The id is the one the task is scored against.
2939
+ */
2940
+ const LIGHTHOUSE_EXPERT = {
2941
+ expertId: LIGHTHOUSE_EXPERT_ID,
2942
+ expertType: "agent",
2943
+ displayName: "腾讯轻量云专家",
2944
+ profession: "腾讯轻量云专家",
2945
+ version: "1.0.2",
2946
+ categories: []
2947
+ };
2948
+ /**
2949
+ * Tasks with a chain in this module.
2950
+ *
2951
+ * Membership is the filter the pass uses before it tries to build one, so a
2952
+ * task with no chain costs no upstream call. It has to be kept in step with
2953
+ * `chainsFor`'s switch: a code listed here with no case would be read and then
2954
+ * silently skipped.
2955
+ */
2956
+ const EVENT_CHAIN_BUILDERS = {
2957
+ Buddy_App: true,
2958
+ Buddy_App_QQ: true,
2959
+ create_canvas: true,
2960
+ automation_1: true,
2961
+ RichMeow_Chat: true,
2962
+ playbook_prompt: true,
2963
+ template_5: true,
2964
+ Hp_Appearance: true,
2965
+ Library_read: true,
2966
+ skill_1: true,
2967
+ expert_5: true,
2968
+ Expert_team_use_3: true,
2969
+ Expert_lighthouse: true
2970
+ };
2971
+ const AUTOMATION_TICK_MS = 6e4;
2972
+ /** The four jobs in the order a tick runs them: report before tasks, always. */
2973
+ const AUTOMATION_JOB_KINDS = [
2974
+ "checkin",
2975
+ "report",
2976
+ "tasks",
2977
+ "streak",
2978
+ "travel"
2979
+ ];
2980
+ /** Reject anything that is not a job kind, so a route cannot name an unknown job. */
2981
+ function isAutomationJobKind(value) {
2982
+ return typeof value === "string" && AUTOMATION_JOB_KINDS.includes(value);
2983
+ }
2984
+ const JOB_KINDS = AUTOMATION_JOB_KINDS;
2985
+ const EMPTY_JOB_STATE = {
2986
+ ok: 0,
2987
+ failed: 0,
2988
+ credit: 0,
2989
+ energy: 0,
2990
+ claimed: 0
2991
+ };
2992
+ /** `YYYY-MM-DD` in local time, the day key every job resets on. */
2993
+ /**
2994
+ * What the ledger gained between two snapshots, per account and in total.
2995
+ *
2996
+ * A diff rather than an absolute read: the card asks "what did this run do",
2997
+ * and the ledger holds the whole day, so reporting totals would re-count
2998
+ * everything an earlier run had already claimed.
2999
+ */
3000
+ function diffEarnings(before, after) {
3001
+ const accounts = {};
3002
+ let credit = 0;
3003
+ let energy = 0;
3004
+ let claimed = 0;
3005
+ for (const [id, entry] of Object.entries(after)) {
3006
+ const was = before[id];
3007
+ const gain = {
3008
+ credit: entry.credit - (was?.credit ?? 0),
3009
+ energy: entry.energy - (was?.energy ?? 0),
3010
+ claimed: entry.claimed - (was?.claimed ?? 0),
3011
+ checkinCredit: entry.checkinCredit - (was?.checkinCredit ?? 0),
3012
+ bonusCredit: entry.bonusCredit - (was?.bonusCredit ?? 0),
3013
+ travelCredit: entry.travelCredit - (was?.travelCredit ?? 0)
3014
+ };
3015
+ if (gain.credit === 0 && gain.energy === 0 && gain.claimed === 0 && gain.checkinCredit === 0 && gain.bonusCredit === 0 && gain.travelCredit === 0) continue;
3016
+ accounts[id] = gain;
3017
+ credit += gain.credit;
3018
+ energy += gain.energy;
3019
+ claimed += gain.claimed;
3020
+ }
3021
+ return {
3022
+ credit,
3023
+ energy,
3024
+ claimed,
3025
+ accounts
3026
+ };
3027
+ }
3028
+ function dayKey(date) {
1734
3029
  const month = String(date.getMonth() + 1).padStart(2, "0");
1735
3030
  const day = String(date.getDate()).padStart(2, "0");
1736
3031
  return `${date.getFullYear()}-${month}-${day}`;
1737
3032
  }
1738
3033
  /**
3034
+ * The scheduled SLOT `date` falls in, as `YYYY-MM-DDTHH`.
3035
+ *
3036
+ * The per-job run guard keys on this instead of the date, so a job configured
3037
+ * for several hours runs in each of them while a second tick inside the same
3038
+ * hour is still refused.
3039
+ */
3040
+ function slotKey(date) {
3041
+ return `${dayKey(date)}T${String(date.getHours()).padStart(2, "0")}`;
3042
+ }
3043
+ /**
1739
3044
  * Whether `now`'s local hour is one of `hours`.
1740
3045
  *
1741
3046
  * The reference panel computes a `nextFire` instant and sleeps until it; this
@@ -1769,15 +3074,28 @@ var WorkBuddyScheduler = class {
1769
3074
  logger;
1770
3075
  now;
1771
3076
  delayMs;
3077
+ /**
3078
+ * How long to wait for event scoring before re-reading the task list.
3079
+ * Tests set 0 so a pass does not spend nine real seconds per account.
3080
+ */
3081
+ eventScoreWaitMs;
3082
+ /**
3083
+ * Gap between two expert summon chains.
3084
+ * Tests set 0 so a pass does not spend six real seconds per expert.
3085
+ */
3086
+ expertGapMs;
1772
3087
  enabled;
1773
3088
  checkinHours;
1774
3089
  taskHours;
1775
3090
  reportHours;
1776
3091
  streakHours;
3092
+ travelHours;
1777
3093
  timer;
1778
3094
  running = false;
1779
3095
  /** Guards against a slow run overlapping the next tick. */
1780
3096
  busy = false;
3097
+ /** True while a manual run is in flight, so the card can poll it. */
3098
+ runInFlight = false;
1781
3099
  /**
1782
3100
  * Set once {@link stop} is called.
1783
3101
  *
@@ -1790,28 +3108,76 @@ var WorkBuddyScheduler = class {
1790
3108
  checkin: { ...EMPTY_JOB_STATE },
1791
3109
  report: { ...EMPTY_JOB_STATE },
1792
3110
  tasks: { ...EMPTY_JOB_STATE },
1793
- streak: { ...EMPTY_JOB_STATE }
3111
+ streak: { ...EMPTY_JOB_STATE },
3112
+ travel: { ...EMPTY_JOB_STATE }
1794
3113
  };
1795
3114
  claimableSeen = 0;
3115
+ /**
3116
+ * Credits/energy/tasks earned per account TODAY, keyed by account id.
3117
+ *
3118
+ * Cleared whenever the day key rolls over, so the card always answers
3119
+ * "what did the automation get for THIS account today".
3120
+ */
3121
+ earnings = /* @__PURE__ */ new Map();
3122
+ /** Day key the counters above belong to. */
3123
+ earningsDate = "";
3124
+ /** Host hooks that persist the ledger across restarts. */
3125
+ loadEarnings;
3126
+ saveEarningsFn;
1796
3127
  constructor(pool, client, options = {}) {
1797
3128
  this.pool = pool;
1798
3129
  this.client = client;
1799
3130
  this.logger = options.logger ?? {};
1800
3131
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1801
3132
  this.delayMs = options.accountDelayMs ?? 800;
3133
+ this.eventScoreWaitMs = options.eventScoreWaitMs ?? 9e3;
3134
+ this.expertGapMs = options.expertGapMs ?? 6e3;
3135
+ this.loadEarnings = options.loadEarnings;
3136
+ this.saveEarningsFn = options.saveEarnings;
3137
+ const restored = options.loadEarnings?.();
3138
+ const today = dayKey(this.now());
3139
+ if (restored !== void 0 && restored.date === today) {
3140
+ for (const [id, entry] of Object.entries(restored.accounts)) this.earnings.set(id, entry);
3141
+ this.earningsDate = today;
3142
+ }
1802
3143
  this.enabled = options.enabled ?? false;
1803
3144
  this.checkinHours = options.checkinHours ?? [9];
1804
3145
  this.reportHours = options.reportHours ?? [10];
1805
3146
  this.taskHours = options.taskHours ?? [11];
1806
3147
  this.streakHours = options.streakHours ?? [12];
3148
+ this.travelHours = options.travelHours ?? [9, 21];
1807
3149
  }
1808
3150
  /** Apply a new configuration; safe to call while running. */
3151
+ /**
3152
+ * Install the persistence hook once the host settings service is available.
3153
+ *
3154
+ * Separate from the constructor because the scheduler is built with the pool,
3155
+ * long before the settings section exists; a ledger written before that point
3156
+ * would have nowhere to go.
3157
+ */
3158
+ setEarningsPersistence(save) {
3159
+ this.saveEarningsFn = save;
3160
+ }
3161
+ /**
3162
+ * Fold a previously persisted ledger back in, when it belongs to today.
3163
+ *
3164
+ * Used after the settings document becomes readable, which happens after
3165
+ * construction; a ledger from an earlier day is ignored so the counters never
3166
+ * claim yesterday as today.
3167
+ */
3168
+ applyEarningsLedger(ledger) {
3169
+ const today = dayKey(this.now());
3170
+ if (ledger.date !== today) return;
3171
+ for (const [id, entry] of Object.entries(ledger.accounts)) this.earnings.set(id, entry);
3172
+ this.earningsDate = today;
3173
+ }
1809
3174
  applyConfig(options) {
1810
3175
  if (options.enabled !== void 0) this.enabled = options.enabled;
1811
3176
  if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
1812
3177
  if (options.reportHours !== void 0) this.reportHours = options.reportHours;
1813
3178
  if (options.taskHours !== void 0) this.taskHours = options.taskHours;
1814
3179
  if (options.streakHours !== void 0) this.streakHours = options.streakHours;
3180
+ if (options.travelHours !== void 0) this.travelHours = options.travelHours;
1815
3181
  }
1816
3182
  /** Hours for one job, used by the loop and the status document. */
1817
3183
  hoursOf(kind) {
@@ -1820,6 +3186,7 @@ var WorkBuddyScheduler = class {
1820
3186
  case "report": return this.reportHours;
1821
3187
  case "tasks": return this.taskHours;
1822
3188
  case "streak": return this.streakHours;
3189
+ case "travel": return this.travelHours;
1823
3190
  }
1824
3191
  }
1825
3192
  /** Start the loop. Idempotent. */
@@ -1842,6 +3209,86 @@ var WorkBuddyScheduler = class {
1842
3209
  }
1843
3210
  }
1844
3211
  /** Snapshot for the status document. */
3212
+ /**
3213
+ * Run one job immediately, regardless of the clock.
3214
+ *
3215
+ * Exists so the automation can be verified from the card without waiting for
3216
+ * its hour. A manual run is recorded exactly like a scheduled one, so the
3217
+ * timer will not repeat it later the same day: every job is idempotent, but a
3218
+ * second pass would still be wasted upstream calls.
3219
+ *
3220
+ * `force` ignores the already-ran-today guard, which is what pressing the
3221
+ * button a second time means.
3222
+ */
3223
+ async runNow(kind, force = false) {
3224
+ const today = dayKey(this.now());
3225
+ const state = this.states[kind];
3226
+ if (!force && state.lastRunSlot === slotKey(this.now())) return { ...state };
3227
+ this.busy = true;
3228
+ try {
3229
+ await this.runJob(kind, today);
3230
+ } finally {
3231
+ this.busy = false;
3232
+ }
3233
+ return { ...this.states[kind] };
3234
+ }
3235
+ /**
3236
+ * Run every job once, in the scheduled order.
3237
+ *
3238
+ * Order matters and is not configurable: the activity report has to land
3239
+ * before the task pass reads task progress, or the pass sees counters the
3240
+ * report would have moved. This is what the card's single button calls.
3241
+ */
3242
+ /**
3243
+ * Start a full pass in the background and return immediately.
3244
+ *
3245
+ * A pass takes tens of seconds - one upstream round trip per account per job,
3246
+ * plus the scoring wait - which is far too long to hold the card request open:
3247
+ * the browser or the host web server would time out, and the user would see
3248
+ * a hung button for a run that is actually working.
3249
+ *
3250
+ * Returns whether a run started. A second call while one is in flight is
3251
+ * ignored rather than queued: pressing the button twice means hurry up, and
3252
+ * the run already under way covers it.
3253
+ */
3254
+ startRunAll() {
3255
+ if (this.runInFlight) return false;
3256
+ this.runInFlight = true;
3257
+ this.runAll().catch((error) => {
3258
+ this.logger.warn?.("dsh-workbuddy-xdpool automation run failed:", error);
3259
+ }).finally(() => {
3260
+ this.runInFlight = false;
3261
+ });
3262
+ return true;
3263
+ }
3264
+ async runAll() {
3265
+ const today = dayKey(this.now());
3266
+ const before = this.earningsSnapshot();
3267
+ let okCount = 0;
3268
+ let failed = 0;
3269
+ let jobsRun = 0;
3270
+ for (const kind of JOB_KINDS) {
3271
+ jobsRun += 1;
3272
+ this.busy = true;
3273
+ try {
3274
+ await this.runJob(kind, today);
3275
+ } finally {
3276
+ this.busy = false;
3277
+ }
3278
+ okCount += this.states[kind].ok;
3279
+ failed += this.states[kind].failed;
3280
+ }
3281
+ const gained = diffEarnings(before, this.earningsSnapshot());
3282
+ return {
3283
+ jobsRun,
3284
+ okCount,
3285
+ failed,
3286
+ credit: gained.credit,
3287
+ energy: gained.energy,
3288
+ claimed: gained.claimed,
3289
+ accounts: gained.accounts
3290
+ };
3291
+ }
1845
3292
  status() {
1846
3293
  const jobs = {};
1847
3294
  for (const kind of JOB_KINDS) jobs[kind] = { ...this.states[kind] };
@@ -1852,8 +3299,11 @@ var WorkBuddyScheduler = class {
1852
3299
  taskHours: this.taskHours,
1853
3300
  reportHours: this.reportHours,
1854
3301
  streakHours: this.streakHours,
3302
+ travelHours: this.travelHours,
1855
3303
  jobs,
1856
- claimableSeen: this.claimableSeen
3304
+ claimableSeen: this.claimableSeen,
3305
+ earningsToday: this.earningsSnapshot(),
3306
+ runInProgress: this.runInFlight
1857
3307
  };
1858
3308
  }
1859
3309
  /**
@@ -1868,10 +3318,11 @@ var WorkBuddyScheduler = class {
1868
3318
  this.busy = true;
1869
3319
  try {
1870
3320
  const now = this.now();
3321
+ const slot = slotKey(now);
1871
3322
  const today = dayKey(now);
1872
3323
  for (const kind of JOB_KINDS) {
1873
3324
  if (this.stopped) return;
1874
- if (this.states[kind].lastRunDate === today) continue;
3325
+ if (this.states[kind].lastRunSlot === slot) continue;
1875
3326
  if (!isFireHour(now, this.hoursOf(kind))) continue;
1876
3327
  await this.runJob(kind, today);
1877
3328
  }
@@ -1882,6 +3333,86 @@ var WorkBuddyScheduler = class {
1882
3333
  }
1883
3334
  }
1884
3335
  /** Run one job against every eligible account and record the outcome. */
3336
+ /**
3337
+ * Add one account's take to today's counters, resetting first if the day
3338
+ * rolled over. Called from the task pass, which is the only job that earns.
3339
+ */
3340
+ /**
3341
+ * Today's per-account earnings, as a plain object for the status document.
3342
+ *
3343
+ * Rolls the day first so a status read just after midnight does not report
3344
+ * yesterday's totals under today's date.
3345
+ */
3346
+ earningsSnapshot() {
3347
+ this.rollEarnings(dayKey(this.now()));
3348
+ const out = {};
3349
+ for (const [id, entry] of this.earnings) out[id] = { ...entry };
3350
+ return out;
3351
+ }
3352
+ /**
3353
+ * Add one account take to today counters, resetting first if the day rolled
3354
+ * over. Every source is tracked separately so the card can show what earned
3355
+ * what, rather than one opaque total.
3356
+ */
3357
+ recordEarnings(accountId, today, delta) {
3358
+ this.rollEarnings(today);
3359
+ const credit = delta.credit ?? 0;
3360
+ const energy = delta.energy ?? 0;
3361
+ const claimed = delta.claimed ?? 0;
3362
+ const checkinCredit = delta.checkinCredit ?? 0;
3363
+ const bonusCredit = delta.bonusCredit ?? 0;
3364
+ const travelCredit = delta.travelCredit ?? 0;
3365
+ if (credit === 0 && energy === 0 && claimed === 0 && checkinCredit === 0 && bonusCredit === 0 && travelCredit === 0) return;
3366
+ const existing = this.earnings.get(accountId);
3367
+ this.earnings.set(accountId, {
3368
+ credit: (existing?.credit ?? 0) + credit,
3369
+ energy: (existing?.energy ?? 0) + energy,
3370
+ claimed: (existing?.claimed ?? 0) + claimed,
3371
+ checkinCredit: (existing?.checkinCredit ?? 0) + checkinCredit,
3372
+ bonusCredit: (existing?.bonusCredit ?? 0) + bonusCredit,
3373
+ travelCredit: (existing?.travelCredit ?? 0) + travelCredit,
3374
+ date: today
3375
+ });
3376
+ this.persistEarnings();
3377
+ }
3378
+ /** Clear the per-account counters when the local day changes. */
3379
+ rollEarnings(today) {
3380
+ if (this.earningsDate === today) return;
3381
+ this.earningsDate = today;
3382
+ this.earnings.clear();
3383
+ this.claimableSeen = 0;
3384
+ this.persistEarnings();
3385
+ }
3386
+ /**
3387
+ * Write the ledger through the host hook, when one was supplied.
3388
+ *
3389
+ * Best effort on purpose: a failed save must never abort a run that has
3390
+ * already collected rewards, and the in-memory ledger keeps serving the card
3391
+ * for the rest of the session either way.
3392
+ */
3393
+ persistEarnings() {
3394
+ if (this.saveEarningsFn === void 0) return;
3395
+ try {
3396
+ const accounts = {};
3397
+ for (const [id, entry] of this.earnings) accounts[id] = entry;
3398
+ this.saveEarningsFn({
3399
+ date: this.earningsDate,
3400
+ accounts
3401
+ });
3402
+ } catch (error) {
3403
+ this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
3404
+ }
3405
+ }
3406
+ /**
3407
+ * Run one job against every eligible account and record the outcome.
3408
+ *
3409
+ * The task job runs in TWO passes. The first sends the event chains that light
3410
+ * up client-scored tasks; the second collects rewards. They are separate
3411
+ * because scoring lands asynchronously — a chain sent and claimed within the
3412
+ * same breath finds the task still un-scored — and because sending is fast
3413
+ * while claiming wants the whole pool to have been lit up first. Splitting
3414
+ * them costs one shared wait instead of one wait per account.
3415
+ */
1885
3416
  async runJob(kind, today) {
1886
3417
  const accountWord = kind === "report" ? "report" : kind;
1887
3418
  let ok = 0;
@@ -1889,7 +3420,10 @@ var WorkBuddyScheduler = class {
1889
3420
  let credit = 0;
1890
3421
  let energy = 0;
1891
3422
  let claimed = 0;
3423
+ const detail = [];
3424
+ let progressNote;
1892
3425
  const accounts = this.accountsInOrder();
3426
+ if (kind === "tasks") await this.sendEventChains(accounts);
1893
3427
  for (let index = 0; index < accounts.length; index++) {
1894
3428
  if (this.stopped) break;
1895
3429
  const account = accounts[index];
@@ -1897,7 +3431,17 @@ var WorkBuddyScheduler = class {
1897
3431
  try {
1898
3432
  switch (kind) {
1899
3433
  case "checkin":
1900
- await this.client.claimDailyCheckin(account.credential);
3434
+ try {
3435
+ const claim = await this.client.claimDailyCheckin(account.credential);
3436
+ this.recordEarnings(account.id, today, { checkinCredit: claim.credit });
3437
+ if (claim.credit > 0) {
3438
+ if (claim.credit > 0) detail.push(`签到 +${claim.credit}`);
3439
+ this.logger.info?.(`automation checkin ${account.label}: +${claim.credit} credit`);
3440
+ }
3441
+ } catch (error) {
3442
+ if (!isAlreadyCheckin(error)) throw error;
3443
+ this.logger.info?.(`automation checkin ${account.label}: already done today`);
3444
+ }
1901
3445
  break;
1902
3446
  case "report": {
1903
3447
  const days = await this.reportOne(account);
@@ -1909,11 +3453,22 @@ var WorkBuddyScheduler = class {
1909
3453
  credit += result.credit;
1910
3454
  energy += result.energy;
1911
3455
  claimed += result.claimed;
3456
+ detail.push(...result.titles);
1912
3457
  this.claimableSeen += result.claimableCount;
3458
+ this.recordEarnings(account.id, today, {
3459
+ credit: result.credit,
3460
+ energy: result.energy,
3461
+ claimed: result.claimed
3462
+ });
1913
3463
  this.logger.info?.(`automation tasks ${account.label}: ${result.claimed} claimed (+${result.credit} credit, +${result.energy} energy, ${result.claimableCount} claimable seen)`);
1914
3464
  break;
1915
3465
  }
1916
- case "streak": await this.redeemStreak(account);
3466
+ case "streak": {
3467
+ const progress = await this.redeemStreak(account);
3468
+ if (progress !== void 0) progressNote = progress;
3469
+ break;
3470
+ }
3471
+ case "travel": await this.runTravel(account);
1917
3472
  }
1918
3473
  ok++;
1919
3474
  } catch (error) {
@@ -1923,6 +3478,7 @@ var WorkBuddyScheduler = class {
1923
3478
  if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
1924
3479
  }
1925
3480
  const state = this.states[kind];
3481
+ state.lastRunSlot = slotKey(this.now());
1926
3482
  state.lastRunDate = today;
1927
3483
  state.lastRunAtMs = this.now().getTime();
1928
3484
  state.ok = ok;
@@ -1931,6 +3487,8 @@ var WorkBuddyScheduler = class {
1931
3487
  state.energy = energy;
1932
3488
  state.claimed = claimed;
1933
3489
  state.message = this.summarise(kind, ok, failed, claimed, credit, energy);
3490
+ state.detail = detail;
3491
+ state.progress = progressNote;
1934
3492
  this.logger.info?.(`automation ${accountWord}: ${state.message}`);
1935
3493
  }
1936
3494
  /** Compose the one-line summary shown on the card. */
@@ -1971,6 +3529,152 @@ var WorkBuddyScheduler = class {
1971
3529
  return -1;
1972
3530
  }
1973
3531
  }
3532
+ async sendEventChains(accounts) {
3533
+ let sentAnything = false;
3534
+ for (const account of accounts) {
3535
+ if (this.stopped) return;
3536
+ let tasks;
3537
+ try {
3538
+ tasks = await this.client.listTasks(account.credential);
3539
+ } catch (error) {
3540
+ this.logger.warn?.(`automation events ${account.label}: list failed:`, error);
3541
+ continue;
3542
+ }
3543
+ const pending = tasks.filter((task) => task.taskCode in EVENT_CHAIN_BUILDERS && !task.claimable && !task.locked);
3544
+ for (const task of pending) {
3545
+ if (this.stopped) return;
3546
+ let chains;
3547
+ try {
3548
+ chains = await this.chainsFor(task, account.credential);
3549
+ } catch (error) {
3550
+ this.logger.warn?.(`automation events ${account.label}: ${task.taskCode} could not be built:`, error);
3551
+ continue;
3552
+ }
3553
+ for (const chain of chains) {
3554
+ if (this.stopped) return;
3555
+ try {
3556
+ await this.sendChain(account.credential, chain);
3557
+ this.logger.info?.(`automation events ${account.label}: ${task.taskCode} chain sent`);
3558
+ sentAnything = true;
3559
+ } catch (error) {
3560
+ this.logger.warn?.(`automation events ${account.label}: ${task.taskCode} failed:`, error);
3561
+ break;
3562
+ }
3563
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3564
+ }
3565
+ }
3566
+ }
3567
+ if (sentAnything && this.eventScoreWaitMs > 0 && !this.stopped) await sleep(this.eventScoreWaitMs);
3568
+ }
3569
+ /**
3570
+ * Send one chain on the channel it was built for.
3571
+ *
3572
+ * The transport is not a detail of the sender: the scorer keys different
3573
+ * tasks to different fingerprint families, so a web-scored event posted as a
3574
+ * desktop event is accepted and then ignored.
3575
+ */
3576
+ async sendChain(credential, chain) {
3577
+ if (chain.transport === "web") {
3578
+ const web = chain.web;
3579
+ if (web === void 0) throw new Error("web chain without a web event");
3580
+ await this.client.reportWebEvent(credential, web.eventCode, web.pageUrl, web.elementId, web.elementName);
3581
+ return;
3582
+ }
3583
+ await this.client.reportDesktopEvents(credential, chain.events ?? []);
3584
+ }
3585
+ /**
3586
+ * Build every chain that scores one task.
3587
+ *
3588
+ * Most tasks need a single chain; `template_5` needs five, because the scorer
3589
+ * counts distinct `template_used` events rather than a boolean. The two tasks
3590
+ * that join a conversation (skill, expert) open a real one first, which is why
3591
+ * this is async.
3592
+ */
3593
+ async chainsFor(task, credential) {
3594
+ switch (task.taskCode) {
3595
+ case "Buddy_App":
3596
+ case "Buddy_App_QQ": return [buddyAppChain()];
3597
+ case "create_canvas": return [canvasChain()];
3598
+ case "automation_1": return [automationChain()];
3599
+ case "RichMeow_Chat": return [chatChain()];
3600
+ case "playbook_prompt": return [playbookChain()];
3601
+ case "template_5": return templateChains();
3602
+ case "Hp_Appearance":
3603
+ await this.client.setAppearanceTheme(credential, APPEARANCE_THEME_KEY);
3604
+ if (this.delayMs > 0 && !this.stopped) await sleep(2e3);
3605
+ return [appearanceChain()];
3606
+ case "Library_read": return [libraryReadChain()];
3607
+ case "skill_1": {
3608
+ const chat = await this.client.openConversation(credential);
3609
+ if (chat === void 0) throw new Error("skill_1: no server conversation");
3610
+ return [skillChain(chat.conversationId, chat.requestId)];
3611
+ }
3612
+ case "expert_5": return this.expertChains(credential, "agent", task);
3613
+ case "Expert_team_use_3": return this.expertChains(credential, "team", task);
3614
+ case "Expert_lighthouse": return this.lighthouseChains(credential);
3615
+ default: return [];
3616
+ }
3617
+ }
3618
+ /**
3619
+ * The summon-and-use chains for the expert tasks.
3620
+ *
3621
+ * Two steps per expert, and both are load-bearing: the summon events alone are
3622
+ * impressions, and a use event on its own scores nothing because the scorer
3623
+ * looks the conversation up. Only a real chat with `X-Expert-Id` produces an
3624
+ * id it will accept.
3625
+ */
3626
+ async expertChains(credential, expertType, task) {
3627
+ const needed = Math.max(0, task.target - task.current);
3628
+ if (needed === 0) return [];
3629
+ const experts = await this.client.marketExpertList(credential, expertType);
3630
+ const out = [];
3631
+ for (const expert of experts) {
3632
+ if (out.length >= needed) break;
3633
+ if (this.stopped) break;
3634
+ try {
3635
+ await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
3636
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3637
+ const chat = await this.client.openConversation(credential, expert.expertId);
3638
+ if (chat === void 0) continue;
3639
+ out.push({
3640
+ transport: "desktop",
3641
+ events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), expertActualUseEvent(expert, chat.conversationId, chat.requestId)]
3642
+ });
3643
+ } catch (error) {
3644
+ this.logger.warn?.(`automation events expert ${expert.expertId}:`, error);
3645
+ }
3646
+ if (this.expertGapMs > 0 && !this.stopped) await sleep(this.expertGapMs);
3647
+ }
3648
+ return out;
3649
+ }
3650
+ /**
3651
+ * The 腾讯轻量云 expert chain.
3652
+ *
3653
+ * Structurally the same as the expert task, with two differences the scorer
3654
+ * checks: `agent_task_created` has to name the expert, and the use event has
3655
+ * to report `mode: 'LOCAL'` with an empty type and zero cost — that is what
3656
+ * the lighthouse criterion looks for.
3657
+ */
3658
+ async lighthouseChains(credential) {
3659
+ let expert = LIGHTHOUSE_EXPERT;
3660
+ try {
3661
+ const found = (await this.client.marketExpertList(credential, "agent")).find((item) => item.expertId === LIGHTHOUSE_EXPERT_ID);
3662
+ if (found !== void 0) expert = found;
3663
+ } catch (error) {
3664
+ this.logger.warn?.("automation events lighthouse list:", error);
3665
+ }
3666
+ await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
3667
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3668
+ const chat = await this.client.openConversation(credential, expert.expertId);
3669
+ if (chat === void 0) throw new Error("Expert_lighthouse: no server conversation");
3670
+ const use = expertActualUseEvent(expert, chat.conversationId, chat.requestId, "LOCAL");
3671
+ use["type"] = "";
3672
+ use["cost"] = 0;
3673
+ return [{
3674
+ transport: "desktop",
3675
+ events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), use]
3676
+ }];
3677
+ }
1974
3678
  /**
1975
3679
  * The task-centre pass for one account.
1976
3680
  *
@@ -1981,13 +3685,13 @@ var WorkBuddyScheduler = class {
1981
3685
  */
1982
3686
  async runTasks(account) {
1983
3687
  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);
3688
+ const open = (await this.client.listTasks(credential)).filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
1986
3689
  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);
3690
+ const claimable = (await this.client.listTasks(credential)).filter((task) => task.claimable && !task.locked);
1988
3691
  let credit = 0;
1989
3692
  let energy = 0;
1990
3693
  let claimed = 0;
3694
+ const titles = [];
1991
3695
  for (const task of claimable) {
1992
3696
  if (this.stopped) break;
1993
3697
  const reward = await this.client.claimTaskReward(credential, task.taskCode);
@@ -1995,6 +3699,7 @@ var WorkBuddyScheduler = class {
1995
3699
  energy += reward.energy;
1996
3700
  if (reward.credit > 0 || reward.energy > 0) {
1997
3701
  claimed++;
3702
+ titles.push(task.title);
1998
3703
  this.logger.info?.(`automation claim ${account.label}: ${task.title} +${reward.credit}c +${reward.energy}e`);
1999
3704
  } else this.logger.info?.(`automation claim ${account.label}: ${task.taskCode} already claimed`);
2000
3705
  if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
@@ -2003,20 +3708,93 @@ var WorkBuddyScheduler = class {
2003
3708
  claimed,
2004
3709
  credit,
2005
3710
  energy,
2006
- claimableCount: claimable.length
3711
+ claimableCount: claimable.length,
3712
+ titles
2007
3713
  };
2008
3714
  }
2009
3715
  /**
2010
- * Streak redemption and lottery.
3716
+ * Streak redemption plus the lottery it unlocks.
2011
3717
  *
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.
3718
+ * Tiers unlock on consecutive active days (7/14/28). Redeeming one pays
3719
+ * credits, energy, a makeup card and — the part nothing else grants — lottery
3720
+ * draws, so the draw runs straight after and only for the chances in hand.
3721
+ *
3722
+ * Everything here is idempotent: a tier already claimed is skipped by its
3723
+ * status, and a draw consumes one chance, so a replay cannot double-spend.
2017
3724
  */
2018
3725
  async redeemStreak(account) {
2019
- this.logger.info?.(`automation streak ${account.label}: not implemented yet (skipped)`);
3726
+ const credential = account.credential;
3727
+ const status = await this.client.growthStreakFull(credential);
3728
+ for (const tier of status.tiers) {
3729
+ if (this.stopped) return;
3730
+ if (tier.status === "locked" || tier.status === "claimed") continue;
3731
+ try {
3732
+ await this.client.redeemStreakTier(credential, tier.tier);
3733
+ this.logger.info?.(`automation streak ${account.label}: redeemed ${tier.tier} (+${tier.credit}c +${tier.energy}e +${tier.chances} draw(s))`);
3734
+ this.recordEarnings(account.id, dayKey(this.now()), { bonusCredit: tier.credit });
3735
+ } catch (error) {
3736
+ this.logger.warn?.(`automation streak ${account.label}: redeem ${tier.tier} failed:`, error);
3737
+ }
3738
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3739
+ }
3740
+ const chances = await this.client.lotteryChances(credential);
3741
+ for (let draw = 0; draw < chances; draw += 1) {
3742
+ if (this.stopped) return;
3743
+ try {
3744
+ const prize = await this.client.lotteryDraw(credential);
3745
+ this.logger.info?.(`automation lottery ${account.label}: draw ${draw + 1}/${chances} -> ${JSON.stringify(prize).slice(0, 120)}`);
3746
+ } catch (error) {
3747
+ this.logger.warn?.(`automation lottery ${account.label}: draw failed:`, error);
3748
+ break;
3749
+ }
3750
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3751
+ }
3752
+ const pendingTier = status.tiers.find((tier) => tier.status === "locked");
3753
+ if (pendingTier !== void 0) {
3754
+ const remaining = Math.max(0, pendingTier.days - status.days);
3755
+ return remaining > 0 ? `${pendingTier.tier} in ${remaining}d` : `${pendingTier.tier} ready`;
3756
+ }
3757
+ }
3758
+ /**
3759
+ * One trip through the buddy travel loop for an account.
3760
+ *
3761
+ * A single pass advances the state machine by at most one step: a trip
3762
+ * that has arrived is collected, and an idle buddy is sent out. A buddy
3763
+ * already travelling is left alone — there is nothing to do until it lands.
3764
+ *
3765
+ * Measured against the live upstream: the departed trip reports
3766
+ * `dailyLimitReached` immediately, so the once-a-day limit needs no local
3767
+ * bookkeeping.
3768
+ */
3769
+ async runTravel(account) {
3770
+ const credential = account.credential;
3771
+ if (await this.client.buddyInfo(credential) === void 0) {
3772
+ try {
3773
+ await this.client.buddyAgree(credential);
3774
+ await this.client.buddyAdoptFirst(credential);
3775
+ this.logger.info?.(`automation travel ${account.label}: adopted first buddy`);
3776
+ } catch (error) {
3777
+ this.logger.info?.(`automation travel ${account.label}: adoption not available yet (` + String(error).slice(0, 80) + ")");
3778
+ }
3779
+ return;
3780
+ }
3781
+ const travel = await this.client.buddyTravelStatus(credential);
3782
+ if (travel.state === "arrived") {
3783
+ if (travel.recordId === 0) {
3784
+ this.logger.warn?.(`automation travel ${account.label}: arrived but no record id`);
3785
+ return;
3786
+ }
3787
+ const reward = await this.client.buddyTravelClaim(credential, travel.recordId);
3788
+ this.recordEarnings(account.id, dayKey(this.now()), { travelCredit: reward });
3789
+ this.logger.info?.(`automation travel ${account.label}: claimed trip +${reward}c`);
3790
+ return;
3791
+ }
3792
+ if (travel.state === "idle" && !travel.dailyLimitReached) {
3793
+ await this.client.buddyTravelDepart(credential);
3794
+ this.logger.info?.(`automation travel ${account.label}: departed (arrives later, claimed next pass)`);
3795
+ return;
3796
+ }
3797
+ this.logger.info?.(`automation travel ${account.label}: nothing to do (state=${travel.state})`);
2020
3798
  }
2021
3799
  };
2022
3800
  //#endregion
@@ -2152,6 +3930,29 @@ function createWorkBuddyShim(options) {
2152
3930
  if (address === null || typeof address === "string") throw new Error("workbuddy shim has no listening address");
2153
3931
  return `http://127.0.0.1:${address.port}`;
2154
3932
  };
3933
+ /**
3934
+ * Last balance refresh per account, so a busy account is not probed on
3935
+ * every request. Ten minutes is well inside the window where ordinary use
3936
+ * could cross a reserve.
3937
+ */
3938
+ const lastBalanceAt = /* @__PURE__ */ new Map();
3939
+ const BALANCE_REFRESH_MS = 6e5;
3940
+ /**
3941
+ * Refresh one account known credit balance, best effort.
3942
+ *
3943
+ * Runs in the background after a successful request. Failures are swallowed
3944
+ * on purpose: a reserve is a safety feature, and a flaky balance lookup must
3945
+ * never become a failed user request or a noisy log.
3946
+ */
3947
+ async function refreshBalance(account) {
3948
+ const now = Date.now();
3949
+ if (now - (lastBalanceAt.get(account.id) ?? 0) < BALANCE_REFRESH_MS) return;
3950
+ lastBalanceAt.set(account.id, now);
3951
+ try {
3952
+ const credits = await client.fetchCredits(account.credential);
3953
+ pool.noteCredits(account.id, credits.total);
3954
+ } catch {}
3955
+ }
2155
3956
  async function handle(req, res) {
2156
3957
  try {
2157
3958
  if (!hostIsLoopback(req.headers.host)) {
@@ -2239,6 +4040,7 @@ function createWorkBuddyShim(options) {
2239
4040
  if (result.ok) {
2240
4041
  logger?.info?.(`dsh-workbuddy-xdpool: served by ${account.label}`);
2241
4042
  pool.noteServed(account.id);
4043
+ refreshBalance(account);
2242
4044
  res.writeHead(200, {
2243
4045
  "Content-Type": "text/event-stream",
2244
4046
  "Cache-Control": "no-cache",
@@ -2412,6 +4214,10 @@ const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
2412
4214
  const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
2413
4215
  /** Switch one account in or out of the pool (card toggle). */
2414
4216
  const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
4217
+ /** Run one automation job immediately, so the card can verify it on demand. */
4218
+ const POOL_AUTOMATION_RUN_PATH = "/plugins/dsh-workbuddy-xdpool/automation/run";
4219
+ /** Set or clear one account's reserved-credit floor. */
4220
+ const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/credit-reserve";
2415
4221
  //#endregion
2416
4222
  //#region src/web-status.ts
2417
4223
  /** Redact token-like content before it crosses to the browser. */
@@ -2536,7 +4342,42 @@ function parseAccountToggle(body, known) {
2536
4342
  disabled
2537
4343
  };
2538
4344
  }
2539
- function toWebAccount(account, disabled) {
4345
+ /**
4346
+ * Parse a manual-run request.
4347
+ *
4348
+ * The job name is checked against the real job list rather than passed through:
4349
+ * an unknown name would otherwise reach the scheduler and silently do nothing,
4350
+ * which reads to the user as a broken button.
4351
+ */
4352
+ /**
4353
+ * Parse a reserved-credit update.
4354
+ *
4355
+ * The account must already be known, for the same reason the disable route
4356
+ * checks: an unknown id would sit in the settings file forever, attached to
4357
+ * nothing the card can act on. A negative or non-finite floor is rejected
4358
+ * rather than coerced.
4359
+ */
4360
+ function parseCreditReserve(body, known) {
4361
+ const accountId = typeof body["accountId"] === "string" ? body["accountId"].trim() : "";
4362
+ const raw = body["reserve"];
4363
+ if (accountId === "" || typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return void 0;
4364
+ if (!known(accountId)) return void 0;
4365
+ return {
4366
+ accountId,
4367
+ reserve: Math.floor(raw)
4368
+ };
4369
+ }
4370
+ function parseAutomationRun(body) {
4371
+ const job = typeof body["job"] === "string" ? body["job"].trim() : "";
4372
+ if (job !== "all" && !isAutomationJobKind(job)) return void 0;
4373
+ const force = body["force"];
4374
+ if (force !== void 0 && typeof force !== "boolean") return void 0;
4375
+ return {
4376
+ job,
4377
+ ...force === void 0 ? {} : { force }
4378
+ };
4379
+ }
4380
+ function toWebAccount(account, disabled, reserve, reserved) {
2540
4381
  const now = Date.now();
2541
4382
  const cooling = account.cooldownUntilMs > now;
2542
4383
  const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
@@ -2553,6 +4394,8 @@ function toWebAccount(account, disabled) {
2553
4394
  ...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
2554
4395
  ...modelCooldowns.length === 0 ? {} : { modelCooldowns },
2555
4396
  disabled,
4397
+ creditReserve: reserve,
4398
+ reserved,
2556
4399
  rateLimitHits: account.rateLimitHits
2557
4400
  };
2558
4401
  }
@@ -2585,10 +4428,13 @@ async function poolWebStatus(deps, region = "cn") {
2585
4428
  const rows = [];
2586
4429
  const now = Date.now();
2587
4430
  for (const account of accounts) {
2588
- const row = toWebAccount(account, deps.pool.isDisabled(account.id));
4431
+ const row = toWebAccount(account, deps.pool.isDisabled(account.id), deps.pool.creditReserveOf(account.id), deps.pool.isReserved(account.id));
4432
+ const earned = deps.scheduler?.().earningsToday[account.id];
4433
+ if (earned !== void 0) Object.assign(row, { automationToday: earned });
2589
4434
  if (!row.cooling) {
2590
4435
  try {
2591
4436
  const credits = await deps.client.fetchCredits(account.credential);
4437
+ deps.pool.noteCredits(account.id, credits.total);
2592
4438
  Object.assign(row, { credits: {
2593
4439
  total: credits.total,
2594
4440
  packages: credits.packages,
@@ -2634,13 +4480,17 @@ async function poolWebStatus(deps, region = "cn") {
2634
4480
  reportHours: [],
2635
4481
  taskHours: [],
2636
4482
  streakHours: [],
4483
+ travelHours: [],
2637
4484
  jobs: {
2638
4485
  checkin: emptyAutomationJob(),
2639
4486
  report: emptyAutomationJob(),
2640
4487
  tasks: emptyAutomationJob(),
2641
- streak: emptyAutomationJob()
4488
+ streak: emptyAutomationJob(),
4489
+ travel: emptyAutomationJob()
2642
4490
  },
2643
- claimableSeen: 0
4491
+ claimableSeen: 0,
4492
+ earningsToday: {},
4493
+ runInProgress: false
2644
4494
  };
2645
4495
  return {
2646
4496
  ok: accounts.length > 0 && cooling < accounts.length,
@@ -2663,7 +4513,8 @@ async function poolWebStatus(deps, region = "cn") {
2663
4513
  distribution: deps.pool.currentDistribution(),
2664
4514
  regions,
2665
4515
  shim,
2666
- automation
4516
+ automation,
4517
+ creditReserves: deps.pool.creditReservesInOrder()
2667
4518
  };
2668
4519
  }
2669
4520
  /**
@@ -2786,7 +4637,66 @@ function registerPoolStatusRoute(ctx, deps) {
2786
4637
  }
2787
4638
  }
2788
4639
  });
4640
+ /**
4641
+ * Run one automation job on demand.
4642
+ *
4643
+ * POST only, loopback origin only, and the job name must be one of the four
4644
+ * real jobs, so the card cannot name an arbitrary job. This exists so the
4645
+ * automation is verifiable without waiting for its scheduled hour.
4646
+ */
4647
+ const disposeAutomationRun = ctx.webServer.register({
4648
+ kind: "exact",
4649
+ path: POOL_AUTOMATION_RUN_PATH,
4650
+ handler: async (req, res) => {
4651
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4652
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
4653
+ if (deps.runAutomation === void 0) return json(res, 503, { error: "automation is not available in this build" });
4654
+ try {
4655
+ const run = parseAutomationRun(await readJsonBody(req));
4656
+ if (run === void 0) return json(res, 400, { error: "invalid automation run payload" });
4657
+ const started = deps.runAutomation(run.job, run.force === true);
4658
+ json(res, 200, {
4659
+ ok: true,
4660
+ job: run.job,
4661
+ started
4662
+ });
4663
+ } catch (error) {
4664
+ json(res, 500, { error: safeMessage(error) });
4665
+ }
4666
+ }
4667
+ });
4668
+ /**
4669
+ * Set or clear one account's reserved-credit floor.
4670
+ *
4671
+ * POST only, loopback origin only, and the id must already be a known
4672
+ * account: the card cannot invent an id, and a stale tab must not leave
4673
+ * orphan keys in the settings file.
4674
+ */
4675
+ const disposeCreditReserve = ctx.webServer.register({
4676
+ kind: "exact",
4677
+ path: POOL_CREDIT_RESERVE_PATH,
4678
+ handler: async (req, res) => {
4679
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4680
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
4681
+ if (deps.setCreditReserve === void 0) return json(res, 503, { error: "settings service unavailable; the reserve cannot be saved" });
4682
+ try {
4683
+ const body = await readJsonBody(req);
4684
+ const known = new Set(deps.pool.list().map((account) => account.id));
4685
+ const parsed = parseCreditReserve(body, (id) => known.has(id));
4686
+ if (parsed === void 0) return json(res, 400, { error: "invalid credit reserve payload" });
4687
+ await deps.setCreditReserve(parsed.accountId, parsed.reserve);
4688
+ json(res, 200, {
4689
+ ok: true,
4690
+ ...parsed
4691
+ });
4692
+ } catch (error) {
4693
+ json(res, 500, { error: safeMessage(error) });
4694
+ }
4695
+ }
4696
+ });
2789
4697
  return () => {
4698
+ disposeAutomationRun();
4699
+ disposeCreditReserve();
2790
4700
  disposeCheckin();
2791
4701
  disposeAccountDisable();
2792
4702
  disposeModelsSave();
@@ -2831,7 +4741,8 @@ function automationOptions(automation) {
2831
4741
  ...automation?.checkinHours === void 0 ? {} : { checkinHours: automation.checkinHours },
2832
4742
  ...automation?.reportHours === void 0 ? {} : { reportHours: automation.reportHours },
2833
4743
  ...automation?.taskHours === void 0 ? {} : { taskHours: automation.taskHours },
2834
- ...automation?.streakHours === void 0 ? {} : { streakHours: automation.streakHours }
4744
+ ...automation?.streakHours === void 0 ? {} : { streakHours: automation.streakHours },
4745
+ ...automation?.travelHours === void 0 ? {} : { travelHours: automation.travelHours }
2835
4746
  };
2836
4747
  }
2837
4748
  /**
@@ -2865,6 +4776,7 @@ const automationSchema = z.object({
2865
4776
  reportHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the activity report (runs before tasks)"),
2866
4777
  taskHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for task enrolment and claiming"),
2867
4778
  streakHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for streak redemption"),
4779
+ travelHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the buddy travel loop"),
2868
4780
  exhaustCooldownMs: z.number().step(1).min(1e3).description("How long a spent account rests, in milliseconds")
2869
4781
  });
2870
4782
  /** Settings key holding one region's saved selection. */
@@ -2882,14 +4794,20 @@ const modelSelectionKeyFor = (region) => region === "cn" ? "modelSelectionCn" :
2882
4794
  const Config = z.object({
2883
4795
  authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
2884
4796
  cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
2885
- distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
4797
+ distribution: z.union([
4798
+ "priority",
4799
+ "round-robin",
4800
+ "balanced"
4801
+ ]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)"),
2886
4802
  disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
4803
+ creditReserves: z.dict(z.number().step(1).min(0)).default({}).description("Per-account credit floor: stop using an account once its balance reaches this value"),
2887
4804
  enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
2888
4805
  imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
2889
4806
  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"),
2890
4807
  modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
2891
4808
  modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway"),
2892
- automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)")
4809
+ automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)"),
4810
+ automationEarnings: z.any().description("Automation earnings ledger (written by the scheduler)")
2893
4811
  });
2894
4812
  /** Live API, published for the CLI. */
2895
4813
  let api;
@@ -2956,12 +4874,13 @@ function apply(ctx, config = {}) {
2956
4874
  }
2957
4875
  };
2958
4876
  const applyConfigFromSource = () => {
2959
- const { authFile, cooldownMs, distribution, disabledAccountIds, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal, automation } = current();
4877
+ const { authFile, cooldownMs, distribution, disabledAccountIds, creditReserves, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal, automation } = current();
2960
4878
  core.pool.applyConfig({
2961
4879
  ...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
2962
4880
  ...cooldownMs === void 0 ? {} : { cooldownMs },
2963
4881
  distribution: distribution ?? "priority",
2964
4882
  ...disabledAccountIds === void 0 ? {} : { disabledAccountIds },
4883
+ ...creditReserves === void 0 ? {} : { creditReserves },
2965
4884
  ...automation?.exhaustCooldownMs === void 0 ? {} : { exhaustCooldownMs: automation.exhaustCooldownMs }
2966
4885
  });
2967
4886
  const legacySelection = {
@@ -3007,6 +4926,11 @@ function apply(ctx, config = {}) {
3007
4926
  ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
3008
4927
  }
3009
4928
  };
4929
+ core.scheduler.setEarningsPersistence((ledger) => {
4930
+ setSetting("automationEarnings", ledger);
4931
+ });
4932
+ const storedLedger = current().automationEarnings;
4933
+ if (storedLedger !== void 0) core.scheduler.applyEarningsLedger(storedLedger);
3010
4934
  const shims = {
3011
4935
  cn: createWorkBuddyShim({
3012
4936
  pool: core.pool,
@@ -3048,6 +4972,7 @@ function apply(ctx, config = {}) {
3048
4972
  client: core.client,
3049
4973
  shim: () => shimInfo("cn"),
3050
4974
  scheduler: () => core.scheduler.status(),
4975
+ runAutomation: (_job, _force) => core.scheduler.startRunAll(),
3051
4976
  saveSelection: (region, selection) => {
3052
4977
  setSetting(modelSelectionKeyFor(region), {
3053
4978
  ...selection.enabledModelIds === void 0 ? {} : { enabledModelIds: [...selection.enabledModelIds] },
@@ -3059,6 +4984,12 @@ function apply(ctx, config = {}) {
3059
4984
  const currentIds = current().disabledAccountIds ?? [];
3060
4985
  const next = disabled ? currentIds.includes(accountId) ? currentIds : [...currentIds, accountId] : currentIds.filter((id) => id !== accountId);
3061
4986
  setSetting("disabledAccountIds", next);
4987
+ },
4988
+ setCreditReserve: (accountId, reserve) => {
4989
+ const next = { ...current().creditReserves ?? {} };
4990
+ if (reserve > 0) next[accountId] = reserve;
4991
+ else delete next[accountId];
4992
+ setSetting("creditReserves", next);
3062
4993
  }
3063
4994
  }));
3064
4995
  api = {
@@ -3190,4 +5121,4 @@ function apply(ctx, config = {}) {
3190
5121
  });
3191
5122
  }
3192
5123
  //#endregion
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 };
5124
+ export { APPEARANCE_THEME_KEY, AUTOMATION_JOB_KINDS, AUTOMATION_TICK_MS, BUDDY_APP_ID, BUDDY_APP_NAME, Config, DEFAULT_CONTEXT_BUDGET, EVENT_SCORE_WAIT_MS, FALLBACK_WORKBUDDY_MODELS, LIBRARY_DOC_URL, LIGHTHOUSE_EXPERT_ID, PLAYBOOK_CASE_ID, PLAYBOOK_CASE_NAME, POOL_AUTOMATION_RUN_PATH, POOL_CHECKIN_PATH, POOL_CREDIT_RESERVE_PATH, POOL_MODELS_SAVE_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, SKILL_ID, SKILL_NAME, TEMPLATE_PRESETS, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyScheduler, WorkBuddyUpstreamClient, appearanceChain, apply, automationChain, automationOptions, buddyAppChain, buddyAppEvents, buildStatus, candidateAuthDirs, canvasChain, chatChain, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, dayKey, defaultDesktopAuthDirs, desktopAutomationCreatedEvent, desktopCanvasEvents, desktopChatEvents, expertActualUseEvent, expertChatEvents, expertSummonEvents, formatRates, formatStatus, inject, isAutomationJobKind, isFireHour, libraryReadChain, modelSelectionKeyFor, name, parseRateLimitReset, parseWorkBuddyAuth, playbookChain, poolWebStatus, registerPoolStatusRoute, setApi, skillChain, templateChain, templateChains, workbuddyAccountId };