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/bin.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import { homedir, platform } from "node:os";
4
4
  import { basename, join, resolve } from "node:path";
@@ -50,6 +50,15 @@ const HARD_CREDIT_MARKERS = [
50
50
  /** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
51
51
  const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
52
52
  /**
53
+ * Markers for "already checked in today".
54
+ *
55
+ * The upstream answers a non-zero business code (and HTTP 400) when the daily
56
+ * check-in is repeated. That is an idempotent success, not a failure: the
57
+ * reward for today is already collected. Matched against the message, since
58
+ * the code varies by realm.
59
+ */
60
+ const ALREADY_CHECKIN_MARKERS = ["已签到", "already"];
61
+ /**
53
62
  * Hosts the international product answers on, once each has been stripped of a
54
63
  * leading label. The WorkBuddy AI desktop app signs in at `workbuddy.ai` (and
55
64
  * the desktop client itself lists `workbuddy.cc` alongside it); the CodeBuddy
@@ -123,6 +132,43 @@ function refreshHeaders(credential) {
123
132
  if (credential.enterpriseId !== void 0 && credential.enterpriseId !== "") headers["X-Enterprise-Id"] = credential.enterpriseId;
124
133
  return headers;
125
134
  }
135
+ /** Desktop-client report endpoint and the UA it is fingerprinted by. */
136
+ const DESKTOP_REPORT_PATH = "/v2/report";
137
+ /** Theme-selection endpoint (Hp_Appearance). */
138
+ const APPEARANCE_SET_PATH = "/v2/user-asset/appearance/set";
139
+ /** Expert marketplace listing, used to look up REAL expert ids. */
140
+ const MARKET_EXPERT_LIST_PATH = "/portal/operation-platform/market/expert/list";
141
+ /** Cap on how long a chain waits for a conversation answer. */
142
+ const CHAT_TIMEOUT_MS = 9e4;
143
+ /** How far into an SSE stream to look for the server's request id. */
144
+ const SSE_SCAN_LIMIT = 1 << 20;
145
+ /** Server request ids look like `cmb-<32 hex>` or a bare 32 hex string. */
146
+ const SERVER_ID_PATTERN = /"id"\s*:\s*"((?:cmb-)?[0-9a-f]{32})"/;
147
+ const DESKTOP_TASK_UA = "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1";
148
+ /**
149
+ * Derive a stable 36-hex device id from the account uid.
150
+ *
151
+ * The upstream keys desktop events to a device. Deriving it from the uid keeps
152
+ * the same account looking like the same machine across runs, instead of a
153
+ * new device appearing on every call.
154
+ */
155
+ function deriveDeviceId(credential, salt) {
156
+ return createHash("sha256").update(salt + ":" + (credential.uid ?? "")).digest("hex").slice(0, 36);
157
+ }
158
+ /** Narrow a loose upstream value to an object, so field reads cannot throw. */
159
+ function asRecord(value) {
160
+ return typeof value === "object" && value !== null ? value : {};
161
+ }
162
+ /** Read a numeric field, treating anything else as 0. */
163
+ function numOf(value) {
164
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
165
+ }
166
+ /** Local `YYYY-MM-DD`, matching how the heatmap keys its cells. */
167
+ function dayKeyLocal(date) {
168
+ const month = String(date.getMonth() + 1).padStart(2, "0");
169
+ const day = String(date.getDate()).padStart(2, "0");
170
+ return `${date.getFullYear()}-${month}-${day}`;
171
+ }
126
172
  /** Billing request headers. */
127
173
  function billingHeaders(credential) {
128
174
  const headers = {
@@ -190,6 +236,17 @@ function classifyUpstreamError(status, body) {
190
236
  if (status >= 500) return "server";
191
237
  return "client";
192
238
  }
239
+ /**
240
+ * Whether an error means "today is already checked in".
241
+ *
242
+ * Callers treat this as success: the credit for the day is already banked, so
243
+ * reporting it as a failure would both alarm the user and hide a healthy
244
+ * account behind a false negative.
245
+ */
246
+ function isAlreadyCheckin(error) {
247
+ const message = error instanceof Error ? error.message : String(error);
248
+ return ALREADY_CHECKIN_MARKERS.some((marker) => message.includes(marker));
249
+ }
193
250
  /** Parse the upstream's `credits` string into a multiplier. */
194
251
  function parseCreditMultiplier(value) {
195
252
  if (typeof value !== "string") return void 0;
@@ -295,6 +352,155 @@ function parseUpstreamModel(value) {
295
352
  * there. Entries without a usable `task_code` are dropped — without one the
296
353
  * claim path cannot be built.
297
354
  */
355
+ /**
356
+ * The event chain that scores the two Buddy-app tasks.
357
+ *
358
+ * Both tasks accept the same chain, and the chain is a pure value: building it
359
+ * needs no client, so callers (and tests) can hold one without a live upstream.
360
+ *
361
+ * The task text says "upgrade to the desktop client and open it from the app
362
+ * launcher". The scorer does not watch the UI — it watches this event sequence
363
+ * with the desktop fingerprint, which is why the sequence is what gets sent.
364
+ *
365
+ * Measured against the live upstream: progress 0/1 → 1/1 claimable in ~8s.
366
+ */
367
+ /**
368
+ * A complete "desktop client ran a request successfully" event chain.
369
+ *
370
+ * Six events in the order the real client emits them: task created, message
371
+ * send, request send, message response, message status, request response.
372
+ * Several tasks are scored off this chain (or one that embeds it), because what
373
+ * they measure is "a real request completed", which the client only proves
374
+ * through this exact sequence.
375
+ *
376
+ * Measured: this chain alone lights up `RichMeow_Chat`.
377
+ */
378
+ function desktopChatEvents(conversationId, requestId, messageId, modelId = "fast-model", modelName = "fast-model") {
379
+ const assistant = `${messageId}-assistant`;
380
+ const session = {
381
+ "codebuddy.session_id": conversationId,
382
+ "codebuddy.conversation_request_id": requestId
383
+ };
384
+ return [
385
+ {
386
+ eventCode: "agent_task_created",
387
+ source: "LOCAL",
388
+ name: "working",
389
+ task_target: "local",
390
+ mode: "craft",
391
+ requestModelId: modelId,
392
+ requestModelName: modelName,
393
+ has_repo: false,
394
+ repo_type: "none",
395
+ workspace_type: "empty",
396
+ has_connector: false,
397
+ connector_types: [],
398
+ has_mention: false,
399
+ mention_types: [],
400
+ has_template: false,
401
+ action: "",
402
+ template_name: "",
403
+ has_expert: false,
404
+ expert_id: "",
405
+ expert_name: "",
406
+ expert_industry_id: "",
407
+ has_skill: false,
408
+ skill_names: [],
409
+ conversationId,
410
+ messageId,
411
+ buddyId: "",
412
+ buddyName: ""
413
+ },
414
+ {
415
+ eventCode: "chat_message_send",
416
+ messageId: assistant,
417
+ historyCount: 0,
418
+ isContextTruncated: false,
419
+ currentStepCount: 1,
420
+ traceId: requestId,
421
+ rootRequestId: requestId,
422
+ parentConversationId: conversationId,
423
+ agentName: "cli",
424
+ agentType: "main"
425
+ },
426
+ {
427
+ eventCode: "chat_request_send",
428
+ inputLength: 24,
429
+ isPlan: false,
430
+ isAutoExecuteTerminal: false,
431
+ isAutoModify: false,
432
+ codebaseEnable: false,
433
+ maxToken: 0,
434
+ maxSteps: 500,
435
+ temperature: 0,
436
+ maxRetries: 0,
437
+ mentionContexts: [],
438
+ knowledgeId: [],
439
+ knowledgeName: [],
440
+ codebaseId: "",
441
+ mentionContextCount: 0,
442
+ command: "",
443
+ recommendId: "",
444
+ skillId: "",
445
+ skillCount: 0,
446
+ totalCount: 0,
447
+ traceId: requestId,
448
+ rootRequestId: requestId,
449
+ parentConversationId: conversationId,
450
+ agentName: "cli",
451
+ agentType: "main",
452
+ ...session
453
+ },
454
+ {
455
+ eventCode: "chat_message_response",
456
+ messageId: assistant,
457
+ responseModelId: modelId,
458
+ inputToken: 120,
459
+ outputToken: 80,
460
+ totalToken: 200,
461
+ cachedTokens: 0,
462
+ cachedWriteTokens: 0,
463
+ cachedMissTokens: 0,
464
+ isSuccessful: true,
465
+ messageErrorCode: "",
466
+ finishReason: "stop",
467
+ firstTokenAt: Date.now(),
468
+ traceId: requestId,
469
+ conversationId,
470
+ rootRequestId: requestId,
471
+ parentConversationId: conversationId,
472
+ agentName: "cli",
473
+ agentType: "main",
474
+ ...session
475
+ },
476
+ {
477
+ eventCode: "chat_message_status",
478
+ messageId: assistant,
479
+ messageErrorCode: "0",
480
+ traceId: requestId,
481
+ rootRequestId: requestId,
482
+ parentConversationId: conversationId,
483
+ agentName: "cli",
484
+ agentType: "main"
485
+ },
486
+ {
487
+ eventCode: "chat_request_response",
488
+ mode: "craft",
489
+ toolCallCount: 0,
490
+ inputToken: 120,
491
+ outputToken: 80,
492
+ totalToken: 200,
493
+ cachedTokens: 0,
494
+ cachedWriteTokens: 0,
495
+ cachedMissTokens: 0,
496
+ isSuccessful: true,
497
+ messageErrorCode: "",
498
+ finishReason: "stop",
499
+ rootRequestId: requestId,
500
+ parentConversationId: conversationId
501
+ }
502
+ ];
503
+ }
298
504
  function parseTask(value) {
299
505
  if (typeof value !== "object" || value === null) return void 0;
300
506
  const raw = value;
@@ -607,6 +813,81 @@ var WorkBuddyUpstreamClient = class {
607
813
  };
608
814
  }
609
815
  /**
816
+ * Public fingerprint fields every desktop event carries.
817
+ *
818
+ * The upstream scores a desktop-fingerprinted task only when the event looks
819
+ * like it came from the desktop client: the same field set, the same stable
820
+ * device ids, the same build. A partial map is accepted with 200 and scores
821
+ * nothing, so these are copied wholesale rather than trimmed.
822
+ *
823
+ * `machineId`/`sessionId` are DERIVED from the account uid, never random: a
824
+ * new device id on every call is itself a signal that the traffic is not a
825
+ * real client.
826
+ */
827
+ desktopFingerprint(credential) {
828
+ const now = Date.now();
829
+ return {
830
+ timezone: "Asia/Shanghai",
831
+ reportDelay: 2e3,
832
+ userId: credential.uid ?? "",
833
+ username: credential.nickname ?? "",
834
+ userNickname: credential.nickname ?? "",
835
+ product: "SaaS",
836
+ releaseDate: 1789036585355,
837
+ commit: "5f9692923c93033111c51ad7b003eb80204a9b75",
838
+ ideName: "WorkBuddy",
839
+ ideType: "WorkBuddy",
840
+ ideVersion: "5.5.6",
841
+ machineId: deriveDeviceId(credential, "machine"),
842
+ sessionId: deriveDeviceId(credential, "session"),
843
+ extName: "workbuddy-desktop",
844
+ extVersion: "5.5.6",
845
+ os: "win32",
846
+ arch: "x64",
847
+ osVersion: "10.0.26220",
848
+ cpuCores: 20,
849
+ memorySize: 24,
850
+ timestamp: now,
851
+ presentAt: now
852
+ };
853
+ }
854
+ /**
855
+ * Send desktop-fingerprinted events to the growth system.
856
+ *
857
+ * The body is an ARRAY of events, and every event carries the full desktop
858
+ * fingerprint plus its own business fields. Different tasks recognise
859
+ * different fingerprint families (CLI / desktop / web), which is why this is
860
+ * separate from {@link reportActivity}: they are not interchangeable.
861
+ *
862
+ * Business fields win over the fingerprint, so a caller can override a device
863
+ * id to align with a real install.
864
+ */
865
+ async reportDesktopEvents(credential, events) {
866
+ if (events.length === 0) return;
867
+ const fingerprint = this.desktopFingerprint(credential);
868
+ const body = events.map((event) => ({
869
+ ...fingerprint,
870
+ ...event
871
+ }));
872
+ const response = await this.fetchImpl(`${chatBase(credential)}${DESKTOP_REPORT_PATH}`, {
873
+ method: "POST",
874
+ headers: {
875
+ "Authorization": `Bearer ${credential.accessToken}`,
876
+ "Accept": "application/json, text/plain, */*",
877
+ "Content-Type": "application/json;charset=UTF-8",
878
+ "User-Agent": DESKTOP_TASK_UA,
879
+ "X-Product": "SaaS",
880
+ "X-Request-ID": deriveDeviceId(credential, "req") + String(Date.now() % 1e6),
881
+ ...credential.uid === void 0 || credential.uid === "" ? {} : { "X-User-Id": credential.uid },
882
+ ...credential.domain === "" ? {} : { "X-Domain": credential.domain }
883
+ },
884
+ body: JSON.stringify(body),
885
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
886
+ });
887
+ const envelope = await readEnvelope(response);
888
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
889
+ }
890
+ /**
610
891
  * Report one chat-activity event to the growth system.
611
892
  *
612
893
  * The body is an ARRAY holding a single `chat_request_send` event, and every
@@ -618,6 +899,209 @@ var WorkBuddyUpstreamClient = class {
618
899
  * One report per account per day is the quota the reference panel settled on;
619
900
  * a single report lights the growth streak and unlocks the `first_buddy`
620
901
  * family, which is why this runs before the task-centre pass.
902
+
903
+ /**
904
+ * Send a WEB-fingerprinted event.
905
+ *
906
+ * A third fingerprint family, alongside CLI and desktop: a browser shape
907
+ * posted to the web origin with x-client-platform: web. Page-behaviour
908
+ * tasks such as `Library_read` are scored on it. Measured:
909
+ * `library_doc_intro_click` scored about four seconds after landing.
910
+ */
911
+ async reportWebEvent(credential, eventCode, pageUrl, elementId, elementName) {
912
+ 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";
913
+ const event = {
914
+ eventCode,
915
+ timestamp: Date.now(),
916
+ reportDelay: 0,
917
+ pageURL: pageUrl,
918
+ elementId,
919
+ elementName,
920
+ os: "Win32",
921
+ arch: "",
922
+ osVersion: "10.0",
923
+ userAgent: ua,
924
+ machineId: deriveDeviceId(credential, "webmachine"),
925
+ userId: credential.uid ?? "",
926
+ userNickname: credential.nickname ?? "",
927
+ enterpriseId: credential.enterpriseId ?? ""
928
+ };
929
+ const response = await this.fetchImpl(`https://www.workbuddy.cn/v2/report`, {
930
+ method: "POST",
931
+ headers: {
932
+ "Authorization": `Bearer ${credential.accessToken}`,
933
+ "Content-Type": "application/json",
934
+ "Accept": "application/json",
935
+ "x-client-platform": "web",
936
+ "Origin": "https://www.workbuddy.cn",
937
+ "Referer": pageUrl,
938
+ "User-Agent": ua,
939
+ ...credential.uid === void 0 || credential.uid === "" ? {} : { "X-User-Id": credential.uid }
940
+ },
941
+ body: JSON.stringify([event]),
942
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
943
+ });
944
+ const envelope = await readEnvelope(response);
945
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
946
+ }
947
+ /**
948
+ * Apply an appearance theme on the account.
949
+ *
950
+ * The theme task is scored on the `appearance_skin_apply` event, not on this
951
+ * call — but the event alone is not enough either. The pair is what a real
952
+ * client produces: it PATCHes the account's selected skin, then reports the
953
+ * event as the settings page closes. Measured on the reference panel after
954
+ * the earlier "the API alone does not score" reading was corrected.
955
+ */
956
+ async setAppearanceTheme(credential, resourceKey) {
957
+ const response = await this.fetchImpl(`${chatBase(credential)}${APPEARANCE_SET_PATH}`, {
958
+ method: "POST",
959
+ headers: {
960
+ "Authorization": `Bearer ${credential.accessToken}`,
961
+ "Accept": "application/json, text/plain, */*",
962
+ "Content-Type": "application/json;charset=UTF-8",
963
+ "User-Agent": DESKTOP_TASK_UA,
964
+ "X-Product": "SaaS",
965
+ ...credential.uid === void 0 || credential.uid === "" ? {} : { "X-User-Id": credential.uid }
966
+ },
967
+ body: JSON.stringify({
968
+ kind: "theme",
969
+ resource_key: resourceKey
970
+ }),
971
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
972
+ });
973
+ const envelope = await readEnvelope(response);
974
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
975
+ }
976
+ /**
977
+ * The platform's expert marketplace.
978
+ *
979
+ * Needed before any expert can be summoned: the scorer verifies that the
980
+ * expert id exists on the platform, so a made-up id scores nothing. The
981
+ * response carries the display fields the summon events replay.
982
+ */
983
+ async marketExpertList(credential, expertType = "") {
984
+ const request = {
985
+ page: 1,
986
+ page_size: 20,
987
+ sort_by: "reco_rank",
988
+ sort_order: "desc"
989
+ };
990
+ if (expertType !== "") request["expert_type"] = expertType;
991
+ const response = await this.fetchImpl(`${chatBase(credential)}${MARKET_EXPERT_LIST_PATH}`, {
992
+ method: "POST",
993
+ headers: {
994
+ ...chatHeaders(credential),
995
+ "User-Agent": DESKTOP_TASK_UA
996
+ },
997
+ body: JSON.stringify(request),
998
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
999
+ });
1000
+ const envelope = await readEnvelope(response);
1001
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1002
+ const data = asRecord(envelope.data);
1003
+ const raw = Array.isArray(data["experts"]) ? data["experts"] : [];
1004
+ const out = [];
1005
+ for (const value of raw) {
1006
+ const expert = asRecord(value);
1007
+ const expertId = typeof expert["expert_id"] === "string" ? expert["expert_id"] : "";
1008
+ if (expertId === "") continue;
1009
+ out.push({
1010
+ expertId,
1011
+ expertType: typeof expert["expert_type"] === "string" ? expert["expert_type"] : "",
1012
+ displayName: typeof expert["display_name_zh"] === "string" ? expert["display_name_zh"] : "",
1013
+ profession: typeof expert["profession_zh"] === "string" ? expert["profession_zh"] : "",
1014
+ version: typeof expert["version"] === "string" ? expert["version"] : "",
1015
+ categories: Array.isArray(expert["categories"]) ? expert["categories"].filter((item) => typeof item === "string") : []
1016
+ });
1017
+ }
1018
+ return out;
1019
+ }
1020
+ /**
1021
+ * Open a REAL desktop conversation and return the ids the server assigned.
1022
+ *
1023
+ * The expert and skill tasks join their events to a conversation the server
1024
+ * has actually seen, so a locally invented `requestId` scores nothing. This
1025
+ * starts a chat, reads the server's id out of the SSE stream, then drops the
1026
+ * rest of the stream — the answer itself is irrelevant, only its identity is.
1027
+ *
1028
+ * Returns `undefined` instead of throwing when the conversation cannot be
1029
+ * opened or carries no recognisable id, because every caller is a best-effort
1030
+ * task chain.
1031
+ */
1032
+ async openConversation(credential, expertId = "", signal) {
1033
+ const conversationId = `wb2auto-conv-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1034
+ const body = JSON.stringify({
1035
+ model: "fast-model",
1036
+ messages: [{
1037
+ role: "system",
1038
+ content: "You are a helpful assistant. 当前处于中文环境,使用简体中文回答。"
1039
+ }, {
1040
+ role: "user",
1041
+ content: "1+1等于几?直接回答。"
1042
+ }],
1043
+ agent: "cli",
1044
+ temperature: 1,
1045
+ stream: true,
1046
+ stream_options: { include_usage: true }
1047
+ });
1048
+ let response;
1049
+ try {
1050
+ response = await this.fetchImpl(`${chatBase(credential)}/v2/chat/completions`, {
1051
+ method: "POST",
1052
+ headers: {
1053
+ ...chatHeaders(credential),
1054
+ "Accept": "text/event-stream",
1055
+ "User-Agent": DESKTOP_TASK_UA,
1056
+ "X-Conversation-ID": conversationId,
1057
+ "X-Request-ID": String(Date.now()) + "000000",
1058
+ "X-Agent-Intent": "craft",
1059
+ "X-Agent-Type": "main",
1060
+ "X-IDE-Name": "WorkBuddy",
1061
+ "X-IDE-Type": "WorkBuddy",
1062
+ "X-IDE-Version": "5.5.6",
1063
+ "x-codebuddy-request": "1",
1064
+ ...expertId === "" ? {} : { "X-Expert-Id": expertId }
1065
+ },
1066
+ body,
1067
+ signal: signal ?? AbortSignal.timeout(CHAT_TIMEOUT_MS)
1068
+ });
1069
+ } catch {
1070
+ return;
1071
+ }
1072
+ if (!response.ok || response.body === null) {
1073
+ await response.body?.cancel().catch(() => {});
1074
+ return;
1075
+ }
1076
+ const reader = response.body.getReader();
1077
+ const decoder = new TextDecoder();
1078
+ let buffer = "";
1079
+ try {
1080
+ while (buffer.length < SSE_SCAN_LIMIT) {
1081
+ const chunk = await reader.read();
1082
+ if (chunk.done) break;
1083
+ buffer += decoder.decode(chunk.value, { stream: true });
1084
+ const match = SERVER_ID_PATTERN.exec(buffer);
1085
+ if (match !== null) return {
1086
+ conversationId,
1087
+ requestId: match[1] ?? ""
1088
+ };
1089
+ }
1090
+ } catch {} finally {
1091
+ await reader.cancel().catch(() => {});
1092
+ }
1093
+ }
1094
+ /**
1095
+ * Report one chat-activity event to the growth system.
1096
+ *
1097
+ * The body is an ARRAY holding a single chat_request_send event, and every
1098
+ * field is filled in: a three-field minimal event is accepted with 200 and
1099
+ * then silently dropped, so the full shape is load-bearing rather than
1100
+ * cosmetic. userId is the one field the server actually keys on.
1101
+ *
1102
+ * One report per account per day is the quota the reference panel settled
1103
+ * on; a single report lights the growth streak and unlocks the first_buddy
1104
+ * family, which is why this runs before the task-centre pass.
621
1105
  */
622
1106
  async reportActivity(credential, conversationId) {
623
1107
  const conversationID = conversationId ?? `wb2api-${Date.now()}`;
@@ -700,6 +1184,159 @@ var WorkBuddyUpstreamClient = class {
700
1184
  const streak = typeof data["streak"] === "object" && data["streak"] !== null ? data["streak"] : {};
701
1185
  return typeof streak["days"] === "number" ? streak["days"] : 0;
702
1186
  }
1187
+ /**
1188
+ * The full streak picture: days, tier unlock state, and what each tier pays.
1189
+ *
1190
+ * Read before redeeming, because the tier state is the only honest answer to
1191
+ * "is there anything to claim": the redeem endpoint answers 403 for a locked
1192
+ * tier, which is indistinguishable from a real failure once the response is
1193
+ * just an error.
1194
+ */
1195
+ async growthStreakFull(credential) {
1196
+ const data = await this.growthJson(credential, "GET", "/activity/growth/streak");
1197
+ const streak = asRecord(data["streak"]);
1198
+ const redemption = asRecord(data["redemption_status"]);
1199
+ const cards = asRecord(data["makeup_cards"]);
1200
+ const tiers = [];
1201
+ if (Array.isArray(redemption["tiers"])) for (const entry of redemption["tiers"]) {
1202
+ const tier = asRecord(entry);
1203
+ const name = typeof tier["tier"] === "string" ? tier["tier"] : "";
1204
+ if (name === "") continue;
1205
+ tiers.push({
1206
+ tier: name,
1207
+ days: numOf(tier["days"]),
1208
+ credit: numOf(tier["credit"]),
1209
+ energy: numOf(tier["energy"]),
1210
+ cards: numOf(tier["cards"]),
1211
+ chances: numOf(tier["chances"]),
1212
+ status: String(redemption[`tier_${name}_status`] ?? "")
1213
+ });
1214
+ }
1215
+ return {
1216
+ days: numOf(streak["days"]),
1217
+ monthTotalDays: numOf(streak["month_total_days"]),
1218
+ nextTier: typeof streak["next_tier"] === "string" ? streak["next_tier"] : "",
1219
+ nextTierRemaining: numOf(streak["next_tier_remaining"]),
1220
+ makeupCards: numOf(cards["balance"]),
1221
+ tiers
1222
+ };
1223
+ }
1224
+ /**
1225
+ * Redeem one unlocked streak tier.
1226
+ *
1227
+ * A locked tier answers 403 ("连续登录天数不足"); callers check the status from
1228
+ * {@link growthStreakFull} first, so this only throws for genuine failures.
1229
+ * The client token is the upstream's idempotency key — a fresh one per attempt
1230
+ * keeps a retry from being read as a duplicate of the last one.
1231
+ */
1232
+ async redeemStreakTier(credential, tier) {
1233
+ await this.growthJson(credential, "POST", "/activity/growth/redeem", {
1234
+ tier,
1235
+ client_token: randomUUID()
1236
+ });
1237
+ }
1238
+ /** How many lottery draws are available right now. */
1239
+ async lotteryChances(credential) {
1240
+ return numOf((await this.growthJson(credential, "GET", "/activity/growth/lottery/summary"))["chances"]);
1241
+ }
1242
+ /**
1243
+ * Draw the lottery once.
1244
+ *
1245
+ * Returns the raw prize payload: its shape is set by the running campaign, so
1246
+ * it is passed through rather than modelled.
1247
+ */
1248
+ async lotteryDraw(credential) {
1249
+ return this.growthJson(credential, "POST", "/activity/growth/lottery/draw", { client_token: randomUUID() });
1250
+ }
1251
+ /**
1252
+ * The buddy profile, or undefined when the account has no buddy yet.
1253
+ *
1254
+ * `data.buddy` is null / absent / an empty object depending on how far the
1255
+ * account got, and all three mean the same thing to a caller: adopt first.
1256
+ */
1257
+ async buddyInfo(credential) {
1258
+ const buddy = asRecord((await this.growthJson(credential, "GET", "/activity/growth/buddy/info"))["buddy"]);
1259
+ if (Object.keys(buddy).length === 0) return void 0;
1260
+ return {
1261
+ instanceId: numOf(buddy["instance_id"]),
1262
+ name: String(buddy["name"] ?? "")
1263
+ };
1264
+ }
1265
+ /** Agree to the buddy terms. Idempotent upstream. */
1266
+ async buddyAgree(credential) {
1267
+ await this.growthJson(credential, "POST", "/activity/growth/buddy/agreement", { agree: true });
1268
+ }
1269
+ /**
1270
+ * Adopt the first buddy.
1271
+ *
1272
+ * Gated upstream on having reported activity that day: without it the answer
1273
+ * is 400 "first_buddy task not completed yet". Callers treat that as "not yet"
1274
+ * rather than an error, which is why it is thrown as-is for them to classify.
1275
+ */
1276
+ async buddyAdoptFirst(credential) {
1277
+ await this.growthJson(credential, "POST", "/activity/growth/buddy/first", {});
1278
+ }
1279
+ /** Current travel state for the account's buddy. */
1280
+ async buddyTravelStatus(credential) {
1281
+ const data = await this.growthJson(credential, "GET", "/activity/growth/buddy/travel/status");
1282
+ return {
1283
+ state: typeof data["state"] === "string" ? data["state"] : "",
1284
+ recordId: numOf(data["record_id"]),
1285
+ dailyLimitReached: data["daily_limit_reached"] === true,
1286
+ rewardCredit: numOf(data["reward_credit"])
1287
+ };
1288
+ }
1289
+ /**
1290
+ * Send the buddy travelling.
1291
+ *
1292
+ * The location is always 4 (古镇客栈): the four locations have identical
1293
+ * reward and duration ranges, so there is nothing to optimise.
1294
+ */
1295
+ async buddyTravelDepart(credential, locationId = 4) {
1296
+ await this.growthJson(credential, "POST", "/activity/growth/buddy/travel/depart", { location_id: locationId });
1297
+ }
1298
+ /**
1299
+ * Collect an arrived trip's reward.
1300
+ *
1301
+ * `recordId` is required and comes from the status read; the upstream rejects
1302
+ * a claim without it.
1303
+ */
1304
+ async buddyTravelClaim(credential, recordId) {
1305
+ return numOf((await this.growthJson(credential, "POST", "/activity/growth/buddy/travel/claim", { record_id: recordId }))["reward_credit"]);
1306
+ }
1307
+ /** Whether yesterday is a gap in the activity heatmap. */
1308
+ async heatmapYesterdayMissed(credential) {
1309
+ const data = await this.growthJson(credential, "GET", "/activity/growth/heatmap");
1310
+ if (!Array.isArray(data["cells"])) return false;
1311
+ const key = dayKeyLocal(/* @__PURE__ */ new Date(Date.now() - 864e5));
1312
+ for (const entry of data["cells"]) {
1313
+ const cell = asRecord(entry);
1314
+ if (cell["date"] === key) return numOf(cell["score"]) === 0;
1315
+ }
1316
+ return false;
1317
+ }
1318
+ /** Spend one makeup card on a date. Idempotent for an already-filled date. */
1319
+ async useMakeupCard(credential, date) {
1320
+ await this.growthJson(credential, "POST", "/activity/growth/makeup-cards/use", { date });
1321
+ }
1322
+ /**
1323
+ * Call a growth-domain endpoint and return its unwrapped `data`.
1324
+ *
1325
+ * These endpoints live on the chat host with the billing header set, and
1326
+ * carry the same envelope as everything else. Centralised here because every
1327
+ * growth call needs the identical envelope check.
1328
+ */
1329
+ async growthJson(credential, method, path, body) {
1330
+ const response = await this.fetchImpl(`${chatBase(credential)}${path}`, {
1331
+ method,
1332
+ headers: billingHeaders(credential),
1333
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
1334
+ signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
1335
+ });
1336
+ const envelope = await readEnvelope(response);
1337
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
1338
+ return asRecord(envelope.data);
1339
+ }
703
1340
  /** Legacy thin wrapper kept for `status`/`doctor`: returns raw envelope data. */
704
1341
  async credits(credential) {
705
1342
  try {
@@ -1026,6 +1663,25 @@ var WorkBuddyAccountPool = class {
1026
1663
  */
1027
1664
  disabledIds = /* @__PURE__ */ new Set();
1028
1665
  /**
1666
+ * Per-account credit floor, keyed by account id. 0 (or absent) means "spend
1667
+ * it all".
1668
+ *
1669
+ * A reserved balance is protection, not a hard limit the upstream knows
1670
+ * about: the pool simply stops picking that account once its last known
1671
+ * balance is at or below the floor, so the user keeps a cushion instead of
1672
+ * draining every account to zero.
1673
+ */
1674
+ creditReserves = /* @__PURE__ */ new Map();
1675
+ /**
1676
+ * Last known credit balance per account, epoch ms aside.
1677
+ *
1678
+ * Refreshed in the background after a successful request, so a pick can
1679
+ * consult it. An account with no reading is treated as usable: refusing to
1680
+ * pick an account just because its balance has not been checked yet would
1681
+ * strand a healthy pool, and the first 402 still cools it as before.
1682
+ */
1683
+ creditBalances = /* @__PURE__ */ new Map();
1684
+ /**
1029
1685
  * Last time each account served a request, epoch ms. Drives the idle term
1030
1686
  * of the priority-mode weighting below: an account that just served loses to
1031
1687
  * one that has been idle, so a small pool stops hammering a single account.
@@ -1056,6 +1712,7 @@ var WorkBuddyAccountPool = class {
1056
1712
  if (options.exhaustCooldownMs !== void 0 && options.exhaustCooldownMs >= 1e3) this.exhaustCooldownMs = options.exhaustCooldownMs;
1057
1713
  if (options.distribution !== void 0) this.distribution = options.distribution;
1058
1714
  if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
1715
+ if (options.creditReserves !== void 0) this.setCreditReserves(options.creditReserves);
1059
1716
  }
1060
1717
  /** Rescan the auth directories and merge newly discovered accounts. */
1061
1718
  async scan() {
@@ -1109,6 +1766,11 @@ var WorkBuddyAccountPool = class {
1109
1766
  available(now, modelId, region) {
1110
1767
  return this.accounts.filter((account) => {
1111
1768
  if (this.disabledIds.has(account.id)) return false;
1769
+ const reserve = this.creditReserves.get(account.id);
1770
+ if (reserve !== void 0 && reserve > 0) {
1771
+ const balance = this.creditBalances.get(account.id);
1772
+ if (balance !== void 0 && balance <= reserve) return false;
1773
+ }
1112
1774
  if (account.cooldownUntilMs > now) return false;
1113
1775
  if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
1114
1776
  if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
@@ -1218,6 +1880,57 @@ var WorkBuddyAccountPool = class {
1218
1880
  this.lastUsedAt.set(accountId, Date.now());
1219
1881
  }
1220
1882
  /**
1883
+ * Record an account latest known credit balance.
1884
+ *
1885
+ * Called after a request and by the card balance refresh, so the reserve
1886
+ * check has something to compare against. A reading for an unknown account is
1887
+ * dropped: `scan()` rebuilds the account list and a stale id would otherwise
1888
+ * accumulate forever.
1889
+ */
1890
+ noteCredits(accountId, balance) {
1891
+ if (!Number.isFinite(balance)) return;
1892
+ if (!this.accounts.some((account) => account.id === accountId)) return;
1893
+ this.creditBalances.set(accountId, balance);
1894
+ }
1895
+ /** Last known balance for one account, or undefined when never read. */
1896
+ creditsOf(accountId) {
1897
+ return this.creditBalances.get(accountId);
1898
+ }
1899
+ /** The credit floor the user set for one account; 0 when unset. */
1900
+ creditReserveOf(accountId) {
1901
+ return this.creditReserves.get(accountId) ?? 0;
1902
+ }
1903
+ /**
1904
+ * Replace every reserve. Called from settings on each apply, so the map
1905
+ * mirrors the saved document exactly instead of accumulating old keys.
1906
+ */
1907
+ setCreditReserves(reserves) {
1908
+ const next = /* @__PURE__ */ new Map();
1909
+ for (const [id, value] of Object.entries(reserves)) if (Number.isFinite(value) && value > 0) next.set(id, Math.floor(value));
1910
+ this.creditReserves = next;
1911
+ }
1912
+ /** Every reserve currently in force, keyed by account id. */
1913
+ creditReservesInOrder() {
1914
+ const out = {};
1915
+ for (const account of this.accounts) {
1916
+ const reserve = this.creditReserves.get(account.id);
1917
+ if (reserve !== void 0 && reserve > 0) out[account.id] = reserve;
1918
+ }
1919
+ return out;
1920
+ }
1921
+ /**
1922
+ * Whether an account is held back only by its reserve.
1923
+ *
1924
+ * Separates "resting to protect credits" from every other reason an account
1925
+ * is out of rotation, which is what the card shows the user.
1926
+ */
1927
+ isReserved(accountId) {
1928
+ const reserve = this.creditReserves.get(accountId);
1929
+ if (reserve === void 0 || reserve <= 0) return false;
1930
+ const balance = this.creditBalances.get(accountId);
1931
+ return balance !== void 0 && balance <= reserve;
1932
+ }
1933
+ /**
1221
1934
  * The account that served the most recent request, if any.
1222
1935
  *
1223
1936
  * Distinct from "who would serve the next one": this is a record of what
@@ -1503,13 +2216,454 @@ function catalogFromUpstream(models) {
1503
2216
  if (models.length === 0) return FALLBACK_WORKBUDDY_MODELS;
1504
2217
  return models.map(toModelInfo);
1505
2218
  }
1506
- /** How often the loop wakes to look for a due job. */
2219
+ //#endregion
2220
+ //#region src/task-events.ts
2221
+ /**
2222
+ * The app the buddy chain enters.
2223
+ *
2224
+ * One chain lights up two tasks: `Buddy_App` (open any app) and `Buddy_App_QQ`
2225
+ * (the QQ-specific one), because this is a QQ-hosted app. Measured 0/1 → 1/1 on
2226
+ * both from a single chain.
2227
+ */
2228
+ const BUDDY_APP_ID = "cb_y5Dy46tPQGGWtueMxXbe";
2229
+ const BUDDY_APP_NAME = "企鹅教师助手";
2230
+ /** A stable-ish id for a synthetic conversation, unique per call. */
2231
+ function syntheticId(prefix) {
2232
+ return `wb2auto-${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2233
+ }
2234
+ /**
2235
+ * A chat chain seeded with a placeholder conversation/request id.
2236
+ *
2237
+ * Several tasks do not verify that the conversation exists — they only check
2238
+ * that the events in the chain carry *a* well-formed id — so a synthetic pair is
2239
+ * enough. `RichMeow_Chat`, `create_canvas` and `automation_1` were all measured
2240
+ * lighting up from exactly this.
2241
+ */
2242
+ function syntheticChatChain(prefix) {
2243
+ const conversationId = syntheticId(prefix);
2244
+ const requestId = syntheticId(`${prefix}-req`);
2245
+ return {
2246
+ conversationId,
2247
+ requestId,
2248
+ events: desktopChatEvents(conversationId, requestId, `msg-${prefix}`)
2249
+ };
2250
+ }
2251
+ /**
2252
+ * The buddy-app chain (two tasks).
2253
+ *
2254
+ * Five clicks in the order a user would make them: discover the app, see it,
2255
+ * enter it, confirm the account link, skip the second binding step.
2256
+ */
2257
+ function buddyAppChain() {
2258
+ const base = {
2259
+ mode: "LOCAL",
2260
+ buddyId: BUDDY_APP_ID,
2261
+ buddyName: BUDDY_APP_NAME
2262
+ };
2263
+ return {
2264
+ transport: "desktop",
2265
+ events: [
2266
+ {
2267
+ ...base,
2268
+ eventCode: "buddyapp_discover_click"
2269
+ },
2270
+ {
2271
+ ...base,
2272
+ eventCode: "buddyapp_show",
2273
+ elementId: BUDDY_APP_ID,
2274
+ elementName: BUDDY_APP_NAME,
2275
+ position: 2
2276
+ },
2277
+ {
2278
+ ...base,
2279
+ eventCode: "buddyapp_enter_click",
2280
+ elementId: BUDDY_APP_ID,
2281
+ elementName: BUDDY_APP_NAME,
2282
+ position: 2,
2283
+ isFirstPage: "1"
2284
+ },
2285
+ {
2286
+ ...base,
2287
+ eventCode: "buddyapp_auth_confirm_click",
2288
+ elementId: BUDDY_APP_ID,
2289
+ elementName: BUDDY_APP_NAME
2290
+ },
2291
+ {
2292
+ ...base,
2293
+ eventCode: "buddyapp_bindaccount_skip_click",
2294
+ elementId: BUDDY_APP_ID,
2295
+ elementName: BUDDY_APP_NAME
2296
+ }
2297
+ ]
2298
+ };
2299
+ }
2300
+ /**
2301
+ * The design-canvas chain (`create_canvas`, +300 — the joint largest reward).
2302
+ *
2303
+ * The two canvas events ride the same metrics channel as everything else, so no
2304
+ * real canvas is ever created; the chat chain in front supplies the ids they
2305
+ * reference. Measured 1/1 on three accounts.
2306
+ */
2307
+ function canvasChain() {
2308
+ const { conversationId, requestId, events } = syntheticChatChain("canvas");
2309
+ return {
2310
+ transport: "desktop",
2311
+ events: [
2312
+ ...events,
2313
+ {
2314
+ eventCode: "wbx_design_canvas_task_create",
2315
+ conversationId,
2316
+ requestId,
2317
+ source: "summon_keyword",
2318
+ cost: 12e3,
2319
+ isSuccessful: true
2320
+ },
2321
+ {
2322
+ eventCode: "wbx_design_canvas_open",
2323
+ conversationId,
2324
+ requestId,
2325
+ id: `ardot-file-${requestId.slice(-8)}`,
2326
+ source: "summon_keyword",
2327
+ type: "page",
2328
+ cost: 13e3,
2329
+ isSuccessful: true
2330
+ }
2331
+ ]
2332
+ };
2333
+ }
2334
+ /**
2335
+ * The scheduled-task event (`automation_1`).
2336
+ *
2337
+ * One event is the whole chain — measured 1/1 on two accounts. The name is only
2338
+ * for the server's own records, so a generated one is fine.
2339
+ */
2340
+ function automationChain() {
2341
+ return {
2342
+ transport: "desktop",
2343
+ events: [{
2344
+ eventCode: "automated_task_create_suc",
2345
+ name: `定时任务-${Date.now().toString(36)}`,
2346
+ source: "manually",
2347
+ modelId: "fast-model",
2348
+ modelIsThinking: true,
2349
+ connectorCount: 0,
2350
+ skills: "",
2351
+ skillCount: 0,
2352
+ scheduleType: "once",
2353
+ mode: "LOCAL"
2354
+ }]
2355
+ };
2356
+ }
2357
+ /**
2358
+ * The plain chat chain (`RichMeow_Chat`, and the base of the template chain).
2359
+ *
2360
+ * Measured: this chain alone lights `RichMeow_Chat`.
2361
+ */
2362
+ function chatChain() {
2363
+ const { events } = syntheticChatChain("chat");
2364
+ return {
2365
+ transport: "desktop",
2366
+ events
2367
+ };
2368
+ }
2369
+ /**
2370
+ * The "same as this case" chain (`playbook_prompt`).
2371
+ *
2372
+ * The scorer watches `playbook_prompt_send` — sending the prompt that the
2373
+ * inspiration case pre-fills — not the card impression or the button click, so
2374
+ * the whole click path is replayed for realism but the send is what counts.
2375
+ */
2376
+ function playbookChain(caseId = PLAYBOOK_CASE_ID, caseName = PLAYBOOK_CASE_NAME) {
2377
+ const { conversationId, requestId, events } = syntheticChatChain("pb");
2378
+ const payload = {
2379
+ id: caseId,
2380
+ name: caseName,
2381
+ type: "document",
2382
+ categoryId: "",
2383
+ categoryName: ""
2384
+ };
2385
+ return {
2386
+ transport: "desktop",
2387
+ events: [
2388
+ ...events,
2389
+ {
2390
+ eventCode: "web_element_click",
2391
+ pageName: "playbook_detail",
2392
+ elementId: "playbook_ctaClick",
2393
+ elementName: caseName,
2394
+ source: "discover"
2395
+ },
2396
+ {
2397
+ eventCode: "playbook_cta_click",
2398
+ source: "discover",
2399
+ position: 0,
2400
+ ...payload
2401
+ },
2402
+ {
2403
+ eventCode: "playbook_prompt_send",
2404
+ conversationId,
2405
+ requestId,
2406
+ ...payload
2407
+ }
2408
+ ]
2409
+ };
2410
+ }
2411
+ /** The inspiration case the reference panel sends a prompt for. */
2412
+ const PLAYBOOK_CASE_ID = "pm-gtm-launch-plan";
2413
+ const PLAYBOOK_CASE_NAME = "新产品上市 GTM 发布计划一页纸";
2414
+ /**
2415
+ * The five templates the reference panel cycles through, as `[id, name]`.
2416
+ *
2417
+ * The upstream does not check that these templates exist — only that five
2418
+ * distinct `template_used` events arrive — so they are the reference set.
2419
+ */
2420
+ const TEMPLATE_PRESETS = [
2421
+ ["1", "深度研究"],
2422
+ ["2", "周报生成"],
2423
+ ["3", "竞品分析"],
2424
+ ["4", "活动策划"],
2425
+ ["5", "代码评审"]
2426
+ ];
2427
+ /**
2428
+ * One "created a task from a template" chain (`template_5`, +100 for five).
2429
+ *
2430
+ * Each group is a chat chain (which supplies the ids the template events join
2431
+ * on) plus `agent_task_created_with_template` and `template_used`. Measured:
2432
+ * five groups in one report scored 5/5.
2433
+ */
2434
+ function templateChain(templateId, templateName) {
2435
+ const { conversationId, requestId, events } = syntheticChatChain(`tpl${templateId}`);
2436
+ return {
2437
+ transport: "desktop",
2438
+ events: [
2439
+ ...events,
2440
+ {
2441
+ eventCode: "agent_task_created_with_template",
2442
+ mode: "working",
2443
+ isCustomModel: false,
2444
+ id: templateId,
2445
+ name: templateName,
2446
+ requestId
2447
+ },
2448
+ {
2449
+ eventCode: "template_used",
2450
+ template_id: templateId,
2451
+ task_mode: "working"
2452
+ }
2453
+ ]
2454
+ };
2455
+ }
2456
+ /** Every template group, ready to send in order. */
2457
+ function templateChains() {
2458
+ return TEMPLATE_PRESETS.map(([id, name]) => templateChain(id, name));
2459
+ }
2460
+ /**
2461
+ * The library-introduction click (`Library_read`).
2462
+ *
2463
+ * Scored on the WEB fingerprint — the same event posted with the desktop
2464
+ * fingerprint scores nothing — so this chain returns a web transport and the
2465
+ * scheduler routes it through `reportWebEvent`.
2466
+ */
2467
+ function libraryReadChain() {
2468
+ return {
2469
+ transport: "web",
2470
+ web: {
2471
+ eventCode: "web_element_click",
2472
+ pageUrl: LIBRARY_DOC_URL,
2473
+ elementId: "library_doc_intro_click",
2474
+ elementName: "WorkBuddy资料库介绍"
2475
+ }
2476
+ };
2477
+ }
2478
+ /** The document the library click is reported against. */
2479
+ const LIBRARY_DOC_URL = "https://www.workbuddy.cn/space/d/o0KWYeynteVv06UnAZqIFm";
2480
+ /** The theme key `Hp_Appearance` is scored on (和平精英激战金秋). */
2481
+ const APPEARANCE_THEME_KEY = "theme-tkmw7j";
2482
+ /** The skill `skill_1` is scored on. */
2483
+ const SKILL_ID = "skill_2097350077599879168";
2484
+ const SKILL_NAME = "润泽小馆·日报撰写";
2485
+ const SKILL_VERSION = "1.0.0";
2486
+ /** The 腾讯轻量云 expert `Expert_lighthouse` is scored on. */
2487
+ const LIGHTHOUSE_EXPERT_ID = "ex_2cvvUZQhDyeJ";
2488
+ /**
2489
+ * Rewrite the chain so the chatting half claims a tool call happened.
2490
+ *
2491
+ * A skills task is only credited when the response reports
2492
+ * `finishReason: 'tool_calls'` — that is, the model loaded the skill as a tool —
2493
+ * rather than a plain text answer.
2494
+ */
2495
+ function markToolCall(events) {
2496
+ return events.map((event) => event["eventCode"] === "chat_message_response" ? {
2497
+ ...event,
2498
+ finishReason: "tool_calls"
2499
+ } : event);
2500
+ }
2501
+ /**
2502
+ * Build the `skill_1` chain from a REAL conversation.
2503
+ *
2504
+ * Unlike the template and canvas chains, this one is verified against the
2505
+ * conversation it names, so the caller must first open a real chat and hand the
2506
+ * server-side ids in.
2507
+ */
2508
+ function skillChain(conversationId, requestId) {
2509
+ const messageId = `msg-${requestId.slice(-8)}`;
2510
+ return {
2511
+ transport: "desktop",
2512
+ events: [...markToolCall(desktopChatEvents(conversationId, requestId, messageId)), {
2513
+ eventCode: "skill_info",
2514
+ id: SKILL_NAME,
2515
+ skillId: SKILL_ID,
2516
+ skillVersion: SKILL_VERSION,
2517
+ toolStatus: "success",
2518
+ fileCount: 56,
2519
+ source: "workbuddy-desktop",
2520
+ conversationId,
2521
+ requestId,
2522
+ messageId,
2523
+ requestModelId: "fast-model",
2524
+ requestModelName: "fast-model",
2525
+ traceId: requestId
2526
+ }]
2527
+ };
2528
+ }
2529
+ /** The theme-apply event `Hp_Appearance` is scored on. */
2530
+ function appearanceChain(themeKey = APPEARANCE_THEME_KEY) {
2531
+ return {
2532
+ transport: "desktop",
2533
+ events: [{
2534
+ eventCode: "appearance_skin_apply",
2535
+ action: "apply",
2536
+ source: "settings_close",
2537
+ id: themeKey,
2538
+ vipLevel: 0,
2539
+ series: "",
2540
+ type: "unknown"
2541
+ }]
2542
+ };
2543
+ }
2544
+ /**
2545
+ * The three "summon an expert" events (`expert_summon_click` and friends).
2546
+ *
2547
+ * Paid before the conversation, in the order the app emits them.
2548
+ */
2549
+ function expertSummonEvents(expert) {
2550
+ const category = expert.categories[0] ?? "expert-all";
2551
+ const version = expert.version === "" ? "1.0.0" : expert.version;
2552
+ return [
2553
+ {
2554
+ eventCode: "web_element_click",
2555
+ source: expert.expertId,
2556
+ type: category,
2557
+ version,
2558
+ elementId: "expert_summon_click",
2559
+ elementName: "立即召唤",
2560
+ pageURL: "/C:/Program%20Files/WorkBuddy/resources/app.asar/renderer/index.html"
2561
+ },
2562
+ {
2563
+ eventCode: "expert_summon_click",
2564
+ id: expert.expertId,
2565
+ name: expert.displayName,
2566
+ expertTitle: expert.profession,
2567
+ type: "expert-all",
2568
+ position: 0,
2569
+ expertType: expert.expertType,
2570
+ version,
2571
+ mode: "LOCAL"
2572
+ },
2573
+ {
2574
+ eventCode: "expert_summoned",
2575
+ id: expert.expertId,
2576
+ name: expert.displayName,
2577
+ expertTitle: expert.profession,
2578
+ type: "expert-all"
2579
+ }
2580
+ ];
2581
+ }
2582
+ /**
2583
+ * The "an expert really answered" event, which is what the expert tasks count.
2584
+ *
2585
+ * The `requestId` must be the SERVER's id for a real chat: a made-up one scores
2586
+ * nothing, because the scorer looks the conversation up.
2587
+ */
2588
+ function expertActualUseEvent(expert, conversationId, requestId, mode = "craft") {
2589
+ const category = expert.categories[0] ?? "expert-all";
2590
+ const version = expert.version === "" ? "1.0.0" : expert.version;
2591
+ return {
2592
+ eventCode: "expert_actual_use",
2593
+ id: expert.expertId,
2594
+ name: expert.displayName,
2595
+ expertTitle: expert.profession,
2596
+ type: category,
2597
+ expertType: expert.expertType,
2598
+ source: "builtin",
2599
+ version,
2600
+ cost: 9e3,
2601
+ characterCount: 14,
2602
+ mode,
2603
+ conversationId,
2604
+ requestId,
2605
+ messageId: `msg-${requestId.slice(-8)}`,
2606
+ requestModelId: "fast-model",
2607
+ requestModelName: "fast-model"
2608
+ };
2609
+ }
2610
+ /**
2611
+ * The chat chain for an expert conversation.
2612
+ *
2613
+ * `agent_task_created` carries the expert fields the scorer reads to attribute
2614
+ * the conversation to that expert.
2615
+ */
2616
+ function expertChatEvents(expert, conversationId, requestId) {
2617
+ return desktopChatEvents(conversationId, requestId, `msg-${requestId.slice(-8)}`).map((event) => event["eventCode"] === "agent_task_created" ? {
2618
+ ...event,
2619
+ has_expert: true,
2620
+ expert_id: expert.expertId,
2621
+ expert_name: expert.displayName,
2622
+ expert_industry_id: ""
2623
+ } : event);
2624
+ }
2625
+ /**
2626
+ * The fallback record for the 腾讯轻量云 expert, used when the marketplace
2627
+ * listing cannot be read. The id is the one the task is scored against.
2628
+ */
2629
+ const LIGHTHOUSE_EXPERT = {
2630
+ expertId: LIGHTHOUSE_EXPERT_ID,
2631
+ expertType: "agent",
2632
+ displayName: "腾讯轻量云专家",
2633
+ profession: "腾讯轻量云专家",
2634
+ version: "1.0.2",
2635
+ categories: []
2636
+ };
2637
+ /**
2638
+ * Tasks with a chain in this module.
2639
+ *
2640
+ * Membership is the filter the pass uses before it tries to build one, so a
2641
+ * task with no chain costs no upstream call. It has to be kept in step with
2642
+ * `chainsFor`'s switch: a code listed here with no case would be read and then
2643
+ * silently skipped.
2644
+ */
2645
+ const EVENT_CHAIN_BUILDERS = {
2646
+ Buddy_App: true,
2647
+ Buddy_App_QQ: true,
2648
+ create_canvas: true,
2649
+ automation_1: true,
2650
+ RichMeow_Chat: true,
2651
+ playbook_prompt: true,
2652
+ template_5: true,
2653
+ Hp_Appearance: true,
2654
+ Library_read: true,
2655
+ skill_1: true,
2656
+ expert_5: true,
2657
+ Expert_team_use_3: true,
2658
+ Expert_lighthouse: true
2659
+ };
1507
2660
  const AUTOMATION_TICK_MS = 6e4;
1508
2661
  const JOB_KINDS = [
1509
2662
  "checkin",
1510
2663
  "report",
1511
2664
  "tasks",
1512
- "streak"
2665
+ "streak",
2666
+ "travel"
1513
2667
  ];
1514
2668
  const EMPTY_JOB_STATE = {
1515
2669
  ok: 0,
@@ -1519,12 +2673,57 @@ const EMPTY_JOB_STATE = {
1519
2673
  claimed: 0
1520
2674
  };
1521
2675
  /** `YYYY-MM-DD` in local time, the day key every job resets on. */
2676
+ /**
2677
+ * What the ledger gained between two snapshots, per account and in total.
2678
+ *
2679
+ * A diff rather than an absolute read: the card asks "what did this run do",
2680
+ * and the ledger holds the whole day, so reporting totals would re-count
2681
+ * everything an earlier run had already claimed.
2682
+ */
2683
+ function diffEarnings(before, after) {
2684
+ const accounts = {};
2685
+ let credit = 0;
2686
+ let energy = 0;
2687
+ let claimed = 0;
2688
+ for (const [id, entry] of Object.entries(after)) {
2689
+ const was = before[id];
2690
+ const gain = {
2691
+ credit: entry.credit - (was?.credit ?? 0),
2692
+ energy: entry.energy - (was?.energy ?? 0),
2693
+ claimed: entry.claimed - (was?.claimed ?? 0),
2694
+ checkinCredit: entry.checkinCredit - (was?.checkinCredit ?? 0),
2695
+ bonusCredit: entry.bonusCredit - (was?.bonusCredit ?? 0),
2696
+ travelCredit: entry.travelCredit - (was?.travelCredit ?? 0)
2697
+ };
2698
+ if (gain.credit === 0 && gain.energy === 0 && gain.claimed === 0 && gain.checkinCredit === 0 && gain.bonusCredit === 0 && gain.travelCredit === 0) continue;
2699
+ accounts[id] = gain;
2700
+ credit += gain.credit;
2701
+ energy += gain.energy;
2702
+ claimed += gain.claimed;
2703
+ }
2704
+ return {
2705
+ credit,
2706
+ energy,
2707
+ claimed,
2708
+ accounts
2709
+ };
2710
+ }
1522
2711
  function dayKey(date) {
1523
2712
  const month = String(date.getMonth() + 1).padStart(2, "0");
1524
2713
  const day = String(date.getDate()).padStart(2, "0");
1525
2714
  return `${date.getFullYear()}-${month}-${day}`;
1526
2715
  }
1527
2716
  /**
2717
+ * The scheduled SLOT `date` falls in, as `YYYY-MM-DDTHH`.
2718
+ *
2719
+ * The per-job run guard keys on this instead of the date, so a job configured
2720
+ * for several hours runs in each of them while a second tick inside the same
2721
+ * hour is still refused.
2722
+ */
2723
+ function slotKey(date) {
2724
+ return `${dayKey(date)}T${String(date.getHours()).padStart(2, "0")}`;
2725
+ }
2726
+ /**
1528
2727
  * Whether `now`'s local hour is one of `hours`.
1529
2728
  *
1530
2729
  * The reference panel computes a `nextFire` instant and sleeps until it; this
@@ -1558,15 +2757,28 @@ var WorkBuddyScheduler = class {
1558
2757
  logger;
1559
2758
  now;
1560
2759
  delayMs;
2760
+ /**
2761
+ * How long to wait for event scoring before re-reading the task list.
2762
+ * Tests set 0 so a pass does not spend nine real seconds per account.
2763
+ */
2764
+ eventScoreWaitMs;
2765
+ /**
2766
+ * Gap between two expert summon chains.
2767
+ * Tests set 0 so a pass does not spend six real seconds per expert.
2768
+ */
2769
+ expertGapMs;
1561
2770
  enabled;
1562
2771
  checkinHours;
1563
2772
  taskHours;
1564
2773
  reportHours;
1565
2774
  streakHours;
2775
+ travelHours;
1566
2776
  timer;
1567
2777
  running = false;
1568
2778
  /** Guards against a slow run overlapping the next tick. */
1569
2779
  busy = false;
2780
+ /** True while a manual run is in flight, so the card can poll it. */
2781
+ runInFlight = false;
1570
2782
  /**
1571
2783
  * Set once {@link stop} is called.
1572
2784
  *
@@ -1579,28 +2791,76 @@ var WorkBuddyScheduler = class {
1579
2791
  checkin: { ...EMPTY_JOB_STATE },
1580
2792
  report: { ...EMPTY_JOB_STATE },
1581
2793
  tasks: { ...EMPTY_JOB_STATE },
1582
- streak: { ...EMPTY_JOB_STATE }
2794
+ streak: { ...EMPTY_JOB_STATE },
2795
+ travel: { ...EMPTY_JOB_STATE }
1583
2796
  };
1584
2797
  claimableSeen = 0;
2798
+ /**
2799
+ * Credits/energy/tasks earned per account TODAY, keyed by account id.
2800
+ *
2801
+ * Cleared whenever the day key rolls over, so the card always answers
2802
+ * "what did the automation get for THIS account today".
2803
+ */
2804
+ earnings = /* @__PURE__ */ new Map();
2805
+ /** Day key the counters above belong to. */
2806
+ earningsDate = "";
2807
+ /** Host hooks that persist the ledger across restarts. */
2808
+ loadEarnings;
2809
+ saveEarningsFn;
1585
2810
  constructor(pool, client, options = {}) {
1586
2811
  this.pool = pool;
1587
2812
  this.client = client;
1588
2813
  this.logger = options.logger ?? {};
1589
2814
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
1590
2815
  this.delayMs = options.accountDelayMs ?? 800;
2816
+ this.eventScoreWaitMs = options.eventScoreWaitMs ?? 9e3;
2817
+ this.expertGapMs = options.expertGapMs ?? 6e3;
2818
+ this.loadEarnings = options.loadEarnings;
2819
+ this.saveEarningsFn = options.saveEarnings;
2820
+ const restored = options.loadEarnings?.();
2821
+ const today = dayKey(this.now());
2822
+ if (restored !== void 0 && restored.date === today) {
2823
+ for (const [id, entry] of Object.entries(restored.accounts)) this.earnings.set(id, entry);
2824
+ this.earningsDate = today;
2825
+ }
1591
2826
  this.enabled = options.enabled ?? false;
1592
2827
  this.checkinHours = options.checkinHours ?? [9];
1593
2828
  this.reportHours = options.reportHours ?? [10];
1594
2829
  this.taskHours = options.taskHours ?? [11];
1595
2830
  this.streakHours = options.streakHours ?? [12];
2831
+ this.travelHours = options.travelHours ?? [9, 21];
1596
2832
  }
1597
2833
  /** Apply a new configuration; safe to call while running. */
2834
+ /**
2835
+ * Install the persistence hook once the host settings service is available.
2836
+ *
2837
+ * Separate from the constructor because the scheduler is built with the pool,
2838
+ * long before the settings section exists; a ledger written before that point
2839
+ * would have nowhere to go.
2840
+ */
2841
+ setEarningsPersistence(save) {
2842
+ this.saveEarningsFn = save;
2843
+ }
2844
+ /**
2845
+ * Fold a previously persisted ledger back in, when it belongs to today.
2846
+ *
2847
+ * Used after the settings document becomes readable, which happens after
2848
+ * construction; a ledger from an earlier day is ignored so the counters never
2849
+ * claim yesterday as today.
2850
+ */
2851
+ applyEarningsLedger(ledger) {
2852
+ const today = dayKey(this.now());
2853
+ if (ledger.date !== today) return;
2854
+ for (const [id, entry] of Object.entries(ledger.accounts)) this.earnings.set(id, entry);
2855
+ this.earningsDate = today;
2856
+ }
1598
2857
  applyConfig(options) {
1599
2858
  if (options.enabled !== void 0) this.enabled = options.enabled;
1600
2859
  if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
1601
2860
  if (options.reportHours !== void 0) this.reportHours = options.reportHours;
1602
2861
  if (options.taskHours !== void 0) this.taskHours = options.taskHours;
1603
2862
  if (options.streakHours !== void 0) this.streakHours = options.streakHours;
2863
+ if (options.travelHours !== void 0) this.travelHours = options.travelHours;
1604
2864
  }
1605
2865
  /** Hours for one job, used by the loop and the status document. */
1606
2866
  hoursOf(kind) {
@@ -1609,6 +2869,7 @@ var WorkBuddyScheduler = class {
1609
2869
  case "report": return this.reportHours;
1610
2870
  case "tasks": return this.taskHours;
1611
2871
  case "streak": return this.streakHours;
2872
+ case "travel": return this.travelHours;
1612
2873
  }
1613
2874
  }
1614
2875
  /** Start the loop. Idempotent. */
@@ -1631,6 +2892,86 @@ var WorkBuddyScheduler = class {
1631
2892
  }
1632
2893
  }
1633
2894
  /** Snapshot for the status document. */
2895
+ /**
2896
+ * Run one job immediately, regardless of the clock.
2897
+ *
2898
+ * Exists so the automation can be verified from the card without waiting for
2899
+ * its hour. A manual run is recorded exactly like a scheduled one, so the
2900
+ * timer will not repeat it later the same day: every job is idempotent, but a
2901
+ * second pass would still be wasted upstream calls.
2902
+ *
2903
+ * `force` ignores the already-ran-today guard, which is what pressing the
2904
+ * button a second time means.
2905
+ */
2906
+ async runNow(kind, force = false) {
2907
+ const today = dayKey(this.now());
2908
+ const state = this.states[kind];
2909
+ if (!force && state.lastRunSlot === slotKey(this.now())) return { ...state };
2910
+ this.busy = true;
2911
+ try {
2912
+ await this.runJob(kind, today);
2913
+ } finally {
2914
+ this.busy = false;
2915
+ }
2916
+ return { ...this.states[kind] };
2917
+ }
2918
+ /**
2919
+ * Run every job once, in the scheduled order.
2920
+ *
2921
+ * Order matters and is not configurable: the activity report has to land
2922
+ * before the task pass reads task progress, or the pass sees counters the
2923
+ * report would have moved. This is what the card's single button calls.
2924
+ */
2925
+ /**
2926
+ * Start a full pass in the background and return immediately.
2927
+ *
2928
+ * A pass takes tens of seconds - one upstream round trip per account per job,
2929
+ * plus the scoring wait - which is far too long to hold the card request open:
2930
+ * the browser or the host web server would time out, and the user would see
2931
+ * a hung button for a run that is actually working.
2932
+ *
2933
+ * Returns whether a run started. A second call while one is in flight is
2934
+ * ignored rather than queued: pressing the button twice means hurry up, and
2935
+ * the run already under way covers it.
2936
+ */
2937
+ startRunAll() {
2938
+ if (this.runInFlight) return false;
2939
+ this.runInFlight = true;
2940
+ this.runAll().catch((error) => {
2941
+ this.logger.warn?.("dsh-workbuddy-xdpool automation run failed:", error);
2942
+ }).finally(() => {
2943
+ this.runInFlight = false;
2944
+ });
2945
+ return true;
2946
+ }
2947
+ async runAll() {
2948
+ const today = dayKey(this.now());
2949
+ const before = this.earningsSnapshot();
2950
+ let okCount = 0;
2951
+ let failed = 0;
2952
+ let jobsRun = 0;
2953
+ for (const kind of JOB_KINDS) {
2954
+ jobsRun += 1;
2955
+ this.busy = true;
2956
+ try {
2957
+ await this.runJob(kind, today);
2958
+ } finally {
2959
+ this.busy = false;
2960
+ }
2961
+ okCount += this.states[kind].ok;
2962
+ failed += this.states[kind].failed;
2963
+ }
2964
+ const gained = diffEarnings(before, this.earningsSnapshot());
2965
+ return {
2966
+ jobsRun,
2967
+ okCount,
2968
+ failed,
2969
+ credit: gained.credit,
2970
+ energy: gained.energy,
2971
+ claimed: gained.claimed,
2972
+ accounts: gained.accounts
2973
+ };
2974
+ }
1634
2975
  status() {
1635
2976
  const jobs = {};
1636
2977
  for (const kind of JOB_KINDS) jobs[kind] = { ...this.states[kind] };
@@ -1641,8 +2982,11 @@ var WorkBuddyScheduler = class {
1641
2982
  taskHours: this.taskHours,
1642
2983
  reportHours: this.reportHours,
1643
2984
  streakHours: this.streakHours,
2985
+ travelHours: this.travelHours,
1644
2986
  jobs,
1645
- claimableSeen: this.claimableSeen
2987
+ claimableSeen: this.claimableSeen,
2988
+ earningsToday: this.earningsSnapshot(),
2989
+ runInProgress: this.runInFlight
1646
2990
  };
1647
2991
  }
1648
2992
  /**
@@ -1657,10 +3001,11 @@ var WorkBuddyScheduler = class {
1657
3001
  this.busy = true;
1658
3002
  try {
1659
3003
  const now = this.now();
3004
+ const slot = slotKey(now);
1660
3005
  const today = dayKey(now);
1661
3006
  for (const kind of JOB_KINDS) {
1662
3007
  if (this.stopped) return;
1663
- if (this.states[kind].lastRunDate === today) continue;
3008
+ if (this.states[kind].lastRunSlot === slot) continue;
1664
3009
  if (!isFireHour(now, this.hoursOf(kind))) continue;
1665
3010
  await this.runJob(kind, today);
1666
3011
  }
@@ -1671,6 +3016,86 @@ var WorkBuddyScheduler = class {
1671
3016
  }
1672
3017
  }
1673
3018
  /** Run one job against every eligible account and record the outcome. */
3019
+ /**
3020
+ * Add one account's take to today's counters, resetting first if the day
3021
+ * rolled over. Called from the task pass, which is the only job that earns.
3022
+ */
3023
+ /**
3024
+ * Today's per-account earnings, as a plain object for the status document.
3025
+ *
3026
+ * Rolls the day first so a status read just after midnight does not report
3027
+ * yesterday's totals under today's date.
3028
+ */
3029
+ earningsSnapshot() {
3030
+ this.rollEarnings(dayKey(this.now()));
3031
+ const out = {};
3032
+ for (const [id, entry] of this.earnings) out[id] = { ...entry };
3033
+ return out;
3034
+ }
3035
+ /**
3036
+ * Add one account take to today counters, resetting first if the day rolled
3037
+ * over. Every source is tracked separately so the card can show what earned
3038
+ * what, rather than one opaque total.
3039
+ */
3040
+ recordEarnings(accountId, today, delta) {
3041
+ this.rollEarnings(today);
3042
+ const credit = delta.credit ?? 0;
3043
+ const energy = delta.energy ?? 0;
3044
+ const claimed = delta.claimed ?? 0;
3045
+ const checkinCredit = delta.checkinCredit ?? 0;
3046
+ const bonusCredit = delta.bonusCredit ?? 0;
3047
+ const travelCredit = delta.travelCredit ?? 0;
3048
+ if (credit === 0 && energy === 0 && claimed === 0 && checkinCredit === 0 && bonusCredit === 0 && travelCredit === 0) return;
3049
+ const existing = this.earnings.get(accountId);
3050
+ this.earnings.set(accountId, {
3051
+ credit: (existing?.credit ?? 0) + credit,
3052
+ energy: (existing?.energy ?? 0) + energy,
3053
+ claimed: (existing?.claimed ?? 0) + claimed,
3054
+ checkinCredit: (existing?.checkinCredit ?? 0) + checkinCredit,
3055
+ bonusCredit: (existing?.bonusCredit ?? 0) + bonusCredit,
3056
+ travelCredit: (existing?.travelCredit ?? 0) + travelCredit,
3057
+ date: today
3058
+ });
3059
+ this.persistEarnings();
3060
+ }
3061
+ /** Clear the per-account counters when the local day changes. */
3062
+ rollEarnings(today) {
3063
+ if (this.earningsDate === today) return;
3064
+ this.earningsDate = today;
3065
+ this.earnings.clear();
3066
+ this.claimableSeen = 0;
3067
+ this.persistEarnings();
3068
+ }
3069
+ /**
3070
+ * Write the ledger through the host hook, when one was supplied.
3071
+ *
3072
+ * Best effort on purpose: a failed save must never abort a run that has
3073
+ * already collected rewards, and the in-memory ledger keeps serving the card
3074
+ * for the rest of the session either way.
3075
+ */
3076
+ persistEarnings() {
3077
+ if (this.saveEarningsFn === void 0) return;
3078
+ try {
3079
+ const accounts = {};
3080
+ for (const [id, entry] of this.earnings) accounts[id] = entry;
3081
+ this.saveEarningsFn({
3082
+ date: this.earningsDate,
3083
+ accounts
3084
+ });
3085
+ } catch (error) {
3086
+ this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
3087
+ }
3088
+ }
3089
+ /**
3090
+ * Run one job against every eligible account and record the outcome.
3091
+ *
3092
+ * The task job runs in TWO passes. The first sends the event chains that light
3093
+ * up client-scored tasks; the second collects rewards. They are separate
3094
+ * because scoring lands asynchronously — a chain sent and claimed within the
3095
+ * same breath finds the task still un-scored — and because sending is fast
3096
+ * while claiming wants the whole pool to have been lit up first. Splitting
3097
+ * them costs one shared wait instead of one wait per account.
3098
+ */
1674
3099
  async runJob(kind, today) {
1675
3100
  const accountWord = kind === "report" ? "report" : kind;
1676
3101
  let ok = 0;
@@ -1678,7 +3103,10 @@ var WorkBuddyScheduler = class {
1678
3103
  let credit = 0;
1679
3104
  let energy = 0;
1680
3105
  let claimed = 0;
3106
+ const detail = [];
3107
+ let progressNote;
1681
3108
  const accounts = this.accountsInOrder();
3109
+ if (kind === "tasks") await this.sendEventChains(accounts);
1682
3110
  for (let index = 0; index < accounts.length; index++) {
1683
3111
  if (this.stopped) break;
1684
3112
  const account = accounts[index];
@@ -1686,7 +3114,17 @@ var WorkBuddyScheduler = class {
1686
3114
  try {
1687
3115
  switch (kind) {
1688
3116
  case "checkin":
1689
- await this.client.claimDailyCheckin(account.credential);
3117
+ try {
3118
+ const claim = await this.client.claimDailyCheckin(account.credential);
3119
+ this.recordEarnings(account.id, today, { checkinCredit: claim.credit });
3120
+ if (claim.credit > 0) {
3121
+ if (claim.credit > 0) detail.push(`签到 +${claim.credit}`);
3122
+ this.logger.info?.(`automation checkin ${account.label}: +${claim.credit} credit`);
3123
+ }
3124
+ } catch (error) {
3125
+ if (!isAlreadyCheckin(error)) throw error;
3126
+ this.logger.info?.(`automation checkin ${account.label}: already done today`);
3127
+ }
1690
3128
  break;
1691
3129
  case "report": {
1692
3130
  const days = await this.reportOne(account);
@@ -1698,11 +3136,22 @@ var WorkBuddyScheduler = class {
1698
3136
  credit += result.credit;
1699
3137
  energy += result.energy;
1700
3138
  claimed += result.claimed;
3139
+ detail.push(...result.titles);
1701
3140
  this.claimableSeen += result.claimableCount;
3141
+ this.recordEarnings(account.id, today, {
3142
+ credit: result.credit,
3143
+ energy: result.energy,
3144
+ claimed: result.claimed
3145
+ });
1702
3146
  this.logger.info?.(`automation tasks ${account.label}: ${result.claimed} claimed (+${result.credit} credit, +${result.energy} energy, ${result.claimableCount} claimable seen)`);
1703
3147
  break;
1704
3148
  }
1705
- case "streak": await this.redeemStreak(account);
3149
+ case "streak": {
3150
+ const progress = await this.redeemStreak(account);
3151
+ if (progress !== void 0) progressNote = progress;
3152
+ break;
3153
+ }
3154
+ case "travel": await this.runTravel(account);
1706
3155
  }
1707
3156
  ok++;
1708
3157
  } catch (error) {
@@ -1712,6 +3161,7 @@ var WorkBuddyScheduler = class {
1712
3161
  if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
1713
3162
  }
1714
3163
  const state = this.states[kind];
3164
+ state.lastRunSlot = slotKey(this.now());
1715
3165
  state.lastRunDate = today;
1716
3166
  state.lastRunAtMs = this.now().getTime();
1717
3167
  state.ok = ok;
@@ -1720,6 +3170,8 @@ var WorkBuddyScheduler = class {
1720
3170
  state.energy = energy;
1721
3171
  state.claimed = claimed;
1722
3172
  state.message = this.summarise(kind, ok, failed, claimed, credit, energy);
3173
+ state.detail = detail;
3174
+ state.progress = progressNote;
1723
3175
  this.logger.info?.(`automation ${accountWord}: ${state.message}`);
1724
3176
  }
1725
3177
  /** Compose the one-line summary shown on the card. */
@@ -1760,6 +3212,152 @@ var WorkBuddyScheduler = class {
1760
3212
  return -1;
1761
3213
  }
1762
3214
  }
3215
+ async sendEventChains(accounts) {
3216
+ let sentAnything = false;
3217
+ for (const account of accounts) {
3218
+ if (this.stopped) return;
3219
+ let tasks;
3220
+ try {
3221
+ tasks = await this.client.listTasks(account.credential);
3222
+ } catch (error) {
3223
+ this.logger.warn?.(`automation events ${account.label}: list failed:`, error);
3224
+ continue;
3225
+ }
3226
+ const pending = tasks.filter((task) => task.taskCode in EVENT_CHAIN_BUILDERS && !task.claimable && !task.locked);
3227
+ for (const task of pending) {
3228
+ if (this.stopped) return;
3229
+ let chains;
3230
+ try {
3231
+ chains = await this.chainsFor(task, account.credential);
3232
+ } catch (error) {
3233
+ this.logger.warn?.(`automation events ${account.label}: ${task.taskCode} could not be built:`, error);
3234
+ continue;
3235
+ }
3236
+ for (const chain of chains) {
3237
+ if (this.stopped) return;
3238
+ try {
3239
+ await this.sendChain(account.credential, chain);
3240
+ this.logger.info?.(`automation events ${account.label}: ${task.taskCode} chain sent`);
3241
+ sentAnything = true;
3242
+ } catch (error) {
3243
+ this.logger.warn?.(`automation events ${account.label}: ${task.taskCode} failed:`, error);
3244
+ break;
3245
+ }
3246
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3247
+ }
3248
+ }
3249
+ }
3250
+ if (sentAnything && this.eventScoreWaitMs > 0 && !this.stopped) await sleep(this.eventScoreWaitMs);
3251
+ }
3252
+ /**
3253
+ * Send one chain on the channel it was built for.
3254
+ *
3255
+ * The transport is not a detail of the sender: the scorer keys different
3256
+ * tasks to different fingerprint families, so a web-scored event posted as a
3257
+ * desktop event is accepted and then ignored.
3258
+ */
3259
+ async sendChain(credential, chain) {
3260
+ if (chain.transport === "web") {
3261
+ const web = chain.web;
3262
+ if (web === void 0) throw new Error("web chain without a web event");
3263
+ await this.client.reportWebEvent(credential, web.eventCode, web.pageUrl, web.elementId, web.elementName);
3264
+ return;
3265
+ }
3266
+ await this.client.reportDesktopEvents(credential, chain.events ?? []);
3267
+ }
3268
+ /**
3269
+ * Build every chain that scores one task.
3270
+ *
3271
+ * Most tasks need a single chain; `template_5` needs five, because the scorer
3272
+ * counts distinct `template_used` events rather than a boolean. The two tasks
3273
+ * that join a conversation (skill, expert) open a real one first, which is why
3274
+ * this is async.
3275
+ */
3276
+ async chainsFor(task, credential) {
3277
+ switch (task.taskCode) {
3278
+ case "Buddy_App":
3279
+ case "Buddy_App_QQ": return [buddyAppChain()];
3280
+ case "create_canvas": return [canvasChain()];
3281
+ case "automation_1": return [automationChain()];
3282
+ case "RichMeow_Chat": return [chatChain()];
3283
+ case "playbook_prompt": return [playbookChain()];
3284
+ case "template_5": return templateChains();
3285
+ case "Hp_Appearance":
3286
+ await this.client.setAppearanceTheme(credential, APPEARANCE_THEME_KEY);
3287
+ if (this.delayMs > 0 && !this.stopped) await sleep(2e3);
3288
+ return [appearanceChain()];
3289
+ case "Library_read": return [libraryReadChain()];
3290
+ case "skill_1": {
3291
+ const chat = await this.client.openConversation(credential);
3292
+ if (chat === void 0) throw new Error("skill_1: no server conversation");
3293
+ return [skillChain(chat.conversationId, chat.requestId)];
3294
+ }
3295
+ case "expert_5": return this.expertChains(credential, "agent", task);
3296
+ case "Expert_team_use_3": return this.expertChains(credential, "team", task);
3297
+ case "Expert_lighthouse": return this.lighthouseChains(credential);
3298
+ default: return [];
3299
+ }
3300
+ }
3301
+ /**
3302
+ * The summon-and-use chains for the expert tasks.
3303
+ *
3304
+ * Two steps per expert, and both are load-bearing: the summon events alone are
3305
+ * impressions, and a use event on its own scores nothing because the scorer
3306
+ * looks the conversation up. Only a real chat with `X-Expert-Id` produces an
3307
+ * id it will accept.
3308
+ */
3309
+ async expertChains(credential, expertType, task) {
3310
+ const needed = Math.max(0, task.target - task.current);
3311
+ if (needed === 0) return [];
3312
+ const experts = await this.client.marketExpertList(credential, expertType);
3313
+ const out = [];
3314
+ for (const expert of experts) {
3315
+ if (out.length >= needed) break;
3316
+ if (this.stopped) break;
3317
+ try {
3318
+ await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
3319
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3320
+ const chat = await this.client.openConversation(credential, expert.expertId);
3321
+ if (chat === void 0) continue;
3322
+ out.push({
3323
+ transport: "desktop",
3324
+ events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), expertActualUseEvent(expert, chat.conversationId, chat.requestId)]
3325
+ });
3326
+ } catch (error) {
3327
+ this.logger.warn?.(`automation events expert ${expert.expertId}:`, error);
3328
+ }
3329
+ if (this.expertGapMs > 0 && !this.stopped) await sleep(this.expertGapMs);
3330
+ }
3331
+ return out;
3332
+ }
3333
+ /**
3334
+ * The 腾讯轻量云 expert chain.
3335
+ *
3336
+ * Structurally the same as the expert task, with two differences the scorer
3337
+ * checks: `agent_task_created` has to name the expert, and the use event has
3338
+ * to report `mode: 'LOCAL'` with an empty type and zero cost — that is what
3339
+ * the lighthouse criterion looks for.
3340
+ */
3341
+ async lighthouseChains(credential) {
3342
+ let expert = LIGHTHOUSE_EXPERT;
3343
+ try {
3344
+ const found = (await this.client.marketExpertList(credential, "agent")).find((item) => item.expertId === LIGHTHOUSE_EXPERT_ID);
3345
+ if (found !== void 0) expert = found;
3346
+ } catch (error) {
3347
+ this.logger.warn?.("automation events lighthouse list:", error);
3348
+ }
3349
+ await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
3350
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3351
+ const chat = await this.client.openConversation(credential, expert.expertId);
3352
+ if (chat === void 0) throw new Error("Expert_lighthouse: no server conversation");
3353
+ const use = expertActualUseEvent(expert, chat.conversationId, chat.requestId, "LOCAL");
3354
+ use["type"] = "";
3355
+ use["cost"] = 0;
3356
+ return [{
3357
+ transport: "desktop",
3358
+ events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), use]
3359
+ }];
3360
+ }
1763
3361
  /**
1764
3362
  * The task-centre pass for one account.
1765
3363
  *
@@ -1770,13 +3368,13 @@ var WorkBuddyScheduler = class {
1770
3368
  */
1771
3369
  async runTasks(account) {
1772
3370
  const credential = account.credential;
1773
- const tasks = await this.client.listTasks(credential);
1774
- const open = tasks.filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
3371
+ const open = (await this.client.listTasks(credential)).filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
1775
3372
  if (open.length > 0) await this.client.acceptTasks(credential, open);
1776
- const claimable = (open.length > 0 ? await this.client.listTasks(credential) : tasks).filter((task) => task.claimable && !task.locked);
3373
+ const claimable = (await this.client.listTasks(credential)).filter((task) => task.claimable && !task.locked);
1777
3374
  let credit = 0;
1778
3375
  let energy = 0;
1779
3376
  let claimed = 0;
3377
+ const titles = [];
1780
3378
  for (const task of claimable) {
1781
3379
  if (this.stopped) break;
1782
3380
  const reward = await this.client.claimTaskReward(credential, task.taskCode);
@@ -1784,6 +3382,7 @@ var WorkBuddyScheduler = class {
1784
3382
  energy += reward.energy;
1785
3383
  if (reward.credit > 0 || reward.energy > 0) {
1786
3384
  claimed++;
3385
+ titles.push(task.title);
1787
3386
  this.logger.info?.(`automation claim ${account.label}: ${task.title} +${reward.credit}c +${reward.energy}e`);
1788
3387
  } else this.logger.info?.(`automation claim ${account.label}: ${task.taskCode} already claimed`);
1789
3388
  if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
@@ -1792,20 +3391,93 @@ var WorkBuddyScheduler = class {
1792
3391
  claimed,
1793
3392
  credit,
1794
3393
  energy,
1795
- claimableCount: claimable.length
3394
+ claimableCount: claimable.length,
3395
+ titles
1796
3396
  };
1797
3397
  }
1798
3398
  /**
1799
- * Streak redemption and lottery.
3399
+ * Streak redemption plus the lottery it unlocks.
3400
+ *
3401
+ * Tiers unlock on consecutive active days (7/14/28). Redeeming one pays
3402
+ * credits, energy, a makeup card and — the part nothing else grants — lottery
3403
+ * draws, so the draw runs straight after and only for the chances in hand.
1800
3404
  *
1801
- * Left as a deliberate no-op placeholder: the tier/lottery endpoints need
1802
- * their own round of probing against the live upstream before they can be
1803
- * wired safely, and a wrong call here could burn a redemption. The job slot,
1804
- * scheduling and status plumbing already exist, so filling it in is a
1805
- * self-contained change.
3405
+ * Everything here is idempotent: a tier already claimed is skipped by its
3406
+ * status, and a draw consumes one chance, so a replay cannot double-spend.
1806
3407
  */
1807
3408
  async redeemStreak(account) {
1808
- this.logger.info?.(`automation streak ${account.label}: not implemented yet (skipped)`);
3409
+ const credential = account.credential;
3410
+ const status = await this.client.growthStreakFull(credential);
3411
+ for (const tier of status.tiers) {
3412
+ if (this.stopped) return;
3413
+ if (tier.status === "locked" || tier.status === "claimed") continue;
3414
+ try {
3415
+ await this.client.redeemStreakTier(credential, tier.tier);
3416
+ this.logger.info?.(`automation streak ${account.label}: redeemed ${tier.tier} (+${tier.credit}c +${tier.energy}e +${tier.chances} draw(s))`);
3417
+ this.recordEarnings(account.id, dayKey(this.now()), { bonusCredit: tier.credit });
3418
+ } catch (error) {
3419
+ this.logger.warn?.(`automation streak ${account.label}: redeem ${tier.tier} failed:`, error);
3420
+ }
3421
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3422
+ }
3423
+ const chances = await this.client.lotteryChances(credential);
3424
+ for (let draw = 0; draw < chances; draw += 1) {
3425
+ if (this.stopped) return;
3426
+ try {
3427
+ const prize = await this.client.lotteryDraw(credential);
3428
+ this.logger.info?.(`automation lottery ${account.label}: draw ${draw + 1}/${chances} -> ${JSON.stringify(prize).slice(0, 120)}`);
3429
+ } catch (error) {
3430
+ this.logger.warn?.(`automation lottery ${account.label}: draw failed:`, error);
3431
+ break;
3432
+ }
3433
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
3434
+ }
3435
+ const pendingTier = status.tiers.find((tier) => tier.status === "locked");
3436
+ if (pendingTier !== void 0) {
3437
+ const remaining = Math.max(0, pendingTier.days - status.days);
3438
+ return remaining > 0 ? `${pendingTier.tier} in ${remaining}d` : `${pendingTier.tier} ready`;
3439
+ }
3440
+ }
3441
+ /**
3442
+ * One trip through the buddy travel loop for an account.
3443
+ *
3444
+ * A single pass advances the state machine by at most one step: a trip
3445
+ * that has arrived is collected, and an idle buddy is sent out. A buddy
3446
+ * already travelling is left alone — there is nothing to do until it lands.
3447
+ *
3448
+ * Measured against the live upstream: the departed trip reports
3449
+ * `dailyLimitReached` immediately, so the once-a-day limit needs no local
3450
+ * bookkeeping.
3451
+ */
3452
+ async runTravel(account) {
3453
+ const credential = account.credential;
3454
+ if (await this.client.buddyInfo(credential) === void 0) {
3455
+ try {
3456
+ await this.client.buddyAgree(credential);
3457
+ await this.client.buddyAdoptFirst(credential);
3458
+ this.logger.info?.(`automation travel ${account.label}: adopted first buddy`);
3459
+ } catch (error) {
3460
+ this.logger.info?.(`automation travel ${account.label}: adoption not available yet (` + String(error).slice(0, 80) + ")");
3461
+ }
3462
+ return;
3463
+ }
3464
+ const travel = await this.client.buddyTravelStatus(credential);
3465
+ if (travel.state === "arrived") {
3466
+ if (travel.recordId === 0) {
3467
+ this.logger.warn?.(`automation travel ${account.label}: arrived but no record id`);
3468
+ return;
3469
+ }
3470
+ const reward = await this.client.buddyTravelClaim(credential, travel.recordId);
3471
+ this.recordEarnings(account.id, dayKey(this.now()), { travelCredit: reward });
3472
+ this.logger.info?.(`automation travel ${account.label}: claimed trip +${reward}c`);
3473
+ return;
3474
+ }
3475
+ if (travel.state === "idle" && !travel.dailyLimitReached) {
3476
+ await this.client.buddyTravelDepart(credential);
3477
+ this.logger.info?.(`automation travel ${account.label}: departed (arrives later, claimed next pass)`);
3478
+ return;
3479
+ }
3480
+ this.logger.info?.(`automation travel ${account.label}: nothing to do (state=${travel.state})`);
1809
3481
  }
1810
3482
  };
1811
3483
  //#endregion
@@ -1852,19 +3524,26 @@ const automationSchema = z.object({
1852
3524
  reportHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the activity report (runs before tasks)"),
1853
3525
  taskHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for task enrolment and claiming"),
1854
3526
  streakHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for streak redemption"),
3527
+ travelHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the buddy travel loop"),
1855
3528
  exhaustCooldownMs: z.number().step(1).min(1e3).description("How long a spent account rests, in milliseconds")
1856
3529
  });
1857
3530
  z.object({
1858
3531
  authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
1859
3532
  cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
1860
- distribution: z.union(["priority", "round-robin"]).default("priority").description("How requests are spread: priority (drain one) or round-robin"),
3533
+ distribution: z.union([
3534
+ "priority",
3535
+ "round-robin",
3536
+ "balanced"
3537
+ ]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)"),
1861
3538
  disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
3539
+ 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"),
1862
3540
  enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
1863
3541
  imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
1864
3542
  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"),
1865
3543
  modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
1866
3544
  modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway"),
1867
- automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)")
3545
+ automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)"),
3546
+ automationEarnings: z.any().description("Automation earnings ledger (written by the scheduler)")
1868
3547
  });
1869
3548
  /** Assemble the runtime objects without registering anything. */
1870
3549
  /**