dsh-workbuddy-xdpool 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/lib/bin.js +2208 -12
- package/lib/client.js +553 -64
- package/lib/index.d.ts +1217 -5
- package/lib/index.js +2651 -151
- package/package.json +1 -1
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.
|
|
@@ -302,6 +359,290 @@ function parseUpstreamModel(value) {
|
|
|
302
359
|
...supportsImages === void 0 ? {} : { supportsImages }
|
|
303
360
|
};
|
|
304
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Flatten one upstream task entry.
|
|
364
|
+
*
|
|
365
|
+
* Progress is reported two ways depending on the task: flat `current`/`target`
|
|
366
|
+
* fields, or a nested `progress: {current, target}`. The nested form wins when
|
|
367
|
+
* it carries anything, because a task that reports both puts the live counter
|
|
368
|
+
* there. Entries without a usable `task_code` are dropped — without one the
|
|
369
|
+
* claim path cannot be built.
|
|
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
|
+
}
|
|
611
|
+
function parseTask(value) {
|
|
612
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
613
|
+
const raw = value;
|
|
614
|
+
const taskCode = typeof raw["task_code"] === "string" ? raw["task_code"] : "";
|
|
615
|
+
if (taskCode === "") return void 0;
|
|
616
|
+
const num = (key) => typeof raw[key] === "number" ? raw[key] : 0;
|
|
617
|
+
let current = num("current");
|
|
618
|
+
let target = num("target");
|
|
619
|
+
const progress = raw["progress"];
|
|
620
|
+
if (typeof progress === "object" && progress !== null) {
|
|
621
|
+
const nested = progress;
|
|
622
|
+
const nestedCurrent = typeof nested["current"] === "number" ? nested["current"] : 0;
|
|
623
|
+
const nestedTarget = typeof nested["target"] === "number" ? nested["target"] : 0;
|
|
624
|
+
if (nestedTarget > 0 || nestedCurrent > 0) {
|
|
625
|
+
current = nestedCurrent;
|
|
626
|
+
target = nestedTarget;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const acceptStatus = typeof raw["accept_status"] === "string" ? raw["accept_status"] : "";
|
|
630
|
+
const claimed = acceptStatus === "claimed";
|
|
631
|
+
return {
|
|
632
|
+
taskCode,
|
|
633
|
+
title: typeof raw["title"] === "string" && raw["title"] !== "" ? raw["title"] : taskCode,
|
|
634
|
+
credit: num("reward_credit"),
|
|
635
|
+
energy: num("reward_energy"),
|
|
636
|
+
hasReward: raw["has_reward"] === true,
|
|
637
|
+
target,
|
|
638
|
+
current,
|
|
639
|
+
acceptStatus,
|
|
640
|
+
status: typeof raw["status"] === "string" ? raw["status"] : "",
|
|
641
|
+
claimable: !claimed && target > 0 && current >= target,
|
|
642
|
+
claimed,
|
|
643
|
+
locked: raw["locked"] === true
|
|
644
|
+
};
|
|
645
|
+
}
|
|
305
646
|
var WorkBuddyUpstreamClient = class {
|
|
306
647
|
fetchImpl;
|
|
307
648
|
clientVersion;
|
|
@@ -578,6 +919,531 @@ var WorkBuddyUpstreamClient = class {
|
|
|
578
919
|
isStreakDay: data["is_streak_day"] === true
|
|
579
920
|
};
|
|
580
921
|
}
|
|
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
|
+
/**
|
|
998
|
+
* Report one chat-activity event to the growth system.
|
|
999
|
+
*
|
|
1000
|
+
* The body is an ARRAY holding a single `chat_request_send` event, and every
|
|
1001
|
+
* field is filled in: a three-field minimal event is accepted with 200 and
|
|
1002
|
+
* then silently dropped, so the full shape is load-bearing rather than
|
|
1003
|
+
* cosmetic. `userId` is the one field the server actually keys on — without
|
|
1004
|
+
* it the request still answers 200 and scores nothing.
|
|
1005
|
+
*
|
|
1006
|
+
* One report per account per day is the quota the reference panel settled on;
|
|
1007
|
+
* a single report lights the growth streak and unlocks the `first_buddy`
|
|
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.
|
|
1212
|
+
*/
|
|
1213
|
+
async reportActivity(credential, conversationId) {
|
|
1214
|
+
const conversationID = conversationId ?? `wb2api-${Date.now()}`;
|
|
1215
|
+
const requestID = conversationID;
|
|
1216
|
+
const now = Date.now();
|
|
1217
|
+
const event = {
|
|
1218
|
+
eventCode: "chat_request_send",
|
|
1219
|
+
timestamp: now,
|
|
1220
|
+
reportDelay: 0,
|
|
1221
|
+
mode: "craft",
|
|
1222
|
+
conversationId: conversationID,
|
|
1223
|
+
requestId: requestID,
|
|
1224
|
+
inputLength: 12,
|
|
1225
|
+
requestModelId: "deepseek-v4-flash",
|
|
1226
|
+
requestModelName: "DeepSeek V4 Flash",
|
|
1227
|
+
isPlan: false,
|
|
1228
|
+
isAutoExecuteTerminal: false,
|
|
1229
|
+
isAutoModify: false,
|
|
1230
|
+
codebaseEnable: false,
|
|
1231
|
+
maxToken: 0,
|
|
1232
|
+
maxSteps: 0,
|
|
1233
|
+
temperature: 0,
|
|
1234
|
+
maxRetries: 0,
|
|
1235
|
+
mentionContexts: [],
|
|
1236
|
+
knowledgeId: [],
|
|
1237
|
+
knowledgeName: [],
|
|
1238
|
+
codebaseId: "",
|
|
1239
|
+
mentionContextCount: 0,
|
|
1240
|
+
command: "",
|
|
1241
|
+
expertId: "",
|
|
1242
|
+
recommendId: "",
|
|
1243
|
+
skillId: "",
|
|
1244
|
+
skillCount: 0,
|
|
1245
|
+
totalCount: 0,
|
|
1246
|
+
fileUri: "",
|
|
1247
|
+
presentAt: now,
|
|
1248
|
+
traceId: "",
|
|
1249
|
+
rootRequestId: requestID,
|
|
1250
|
+
parentConversationId: conversationID,
|
|
1251
|
+
agentName: "default",
|
|
1252
|
+
agentType: "conversation",
|
|
1253
|
+
userId: credential.uid ?? ""
|
|
1254
|
+
};
|
|
1255
|
+
const response = await this.fetchImpl(`${billingBase(credential)}/v2/report`, {
|
|
1256
|
+
method: "POST",
|
|
1257
|
+
headers: billingHeaders(credential),
|
|
1258
|
+
body: JSON.stringify([event]),
|
|
1259
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
1260
|
+
});
|
|
1261
|
+
const envelope = await readEnvelope(response);
|
|
1262
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Read back the growth streak in days.
|
|
1266
|
+
*
|
|
1267
|
+
* This is the read-only oracle for {@link reportActivity}: a report that
|
|
1268
|
+
* returned 200 yet left the streak untouched was silently dropped (a missing
|
|
1269
|
+
* `userId` is the usual cause), so callers verify instead of trusting the
|
|
1270
|
+
* status code.
|
|
1271
|
+
*
|
|
1272
|
+
* Two shape traps, both measured against the live upstream:
|
|
1273
|
+
*
|
|
1274
|
+
* - The path carries NO `/v2` prefix, unlike its sibling task endpoints under
|
|
1275
|
+
* `/v2/activity/growth/*`. Asking for the `/v2` form does not 404; it
|
|
1276
|
+
* answers with a body that carries no `streak` object at all.
|
|
1277
|
+
* - The counter is nested as `data.streak.days`, not `data.days`. Reading the
|
|
1278
|
+
* flat field yields a constant 0, which would make every successful report
|
|
1279
|
+
* look like a silent drop.
|
|
1280
|
+
*
|
|
1281
|
+
* Returns 0 only when the field is genuinely absent.
|
|
1282
|
+
*/
|
|
1283
|
+
async growthStreakDays(credential) {
|
|
1284
|
+
const response = await this.fetchImpl(`${chatBase(credential)}/activity/growth/streak`, {
|
|
1285
|
+
headers: billingHeaders(credential),
|
|
1286
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
1287
|
+
});
|
|
1288
|
+
const envelope = await readEnvelope(response);
|
|
1289
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1290
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
1291
|
+
const streak = typeof data["streak"] === "object" && data["streak"] !== null ? data["streak"] : {};
|
|
1292
|
+
return typeof streak["days"] === "number" ? streak["days"] : 0;
|
|
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
|
+
}
|
|
581
1447
|
/** Legacy thin wrapper kept for `status`/`doctor`: returns raw envelope data. */
|
|
582
1448
|
async credits(credential) {
|
|
583
1449
|
try {
|
|
@@ -592,6 +1458,87 @@ var WorkBuddyUpstreamClient = class {
|
|
|
592
1458
|
};
|
|
593
1459
|
}
|
|
594
1460
|
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Fetch the growth task list for one account.
|
|
1463
|
+
*
|
|
1464
|
+
* The upstream answers `data.tasks[]`, and `claimable` is derived locally —
|
|
1465
|
+
* the upstream does not mark it. Only a task whose progress reached its
|
|
1466
|
+
* target and that is not already claimed counts as eligible.
|
|
1467
|
+
*/
|
|
1468
|
+
async listTasks(credential) {
|
|
1469
|
+
const response = await this.fetchImpl(`${chatBase(credential)}/v2/activity/growth/tasks`, {
|
|
1470
|
+
headers: chatHeaders(credential),
|
|
1471
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
1472
|
+
});
|
|
1473
|
+
const envelope = await readEnvelope(response);
|
|
1474
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1475
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
1476
|
+
const raw = Array.isArray(data["tasks"]) ? data["tasks"] : [];
|
|
1477
|
+
const out = [];
|
|
1478
|
+
for (const entry of raw) {
|
|
1479
|
+
const parsed = parseTask(entry);
|
|
1480
|
+
if (parsed !== void 0) out.push(parsed);
|
|
1481
|
+
}
|
|
1482
|
+
return out;
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Accept (enrol in) tasks by code.
|
|
1486
|
+
*
|
|
1487
|
+
* Accepting is the "sign up" half: it produces no progress by itself, and the
|
|
1488
|
+
* upstream answers success for an already-accepted task, so replaying this is
|
|
1489
|
+
* safe. Progress is lit by real activity (a chat, an activity report).
|
|
1490
|
+
*/
|
|
1491
|
+
async acceptTasks(credential, taskCodes) {
|
|
1492
|
+
if (taskCodes.length === 0) return;
|
|
1493
|
+
const response = await this.fetchImpl(`${chatBase(credential)}/v2/activity/growth/tasks/accept`, {
|
|
1494
|
+
method: "POST",
|
|
1495
|
+
headers: chatHeaders(credential),
|
|
1496
|
+
body: JSON.stringify({ task_codes: [...taskCodes] }),
|
|
1497
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
1498
|
+
});
|
|
1499
|
+
const envelope = await readEnvelope(response);
|
|
1500
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Claim one task's reward.
|
|
1504
|
+
*
|
|
1505
|
+
* Two details differ from list/accept and are load-bearing:
|
|
1506
|
+
*
|
|
1507
|
+
* - The task code rides the PATH, not the body.
|
|
1508
|
+
* - It is served by the web origin, not the chat host, and only when the
|
|
1509
|
+
* request carries the growth-centre Origin/Referer plus
|
|
1510
|
+
* `x-client-platform: web`. The chat host's `/reward/claim` path does not
|
|
1511
|
+
* exist and answers 400 "task not completed".
|
|
1512
|
+
*
|
|
1513
|
+
* A repeat claim answers `already_claimed` with zero credit, which is treated
|
|
1514
|
+
* as success so the caller can stay idempotent.
|
|
1515
|
+
*/
|
|
1516
|
+
async claimTaskReward(credential, taskCode) {
|
|
1517
|
+
const headers = {
|
|
1518
|
+
...billingHeaders(credential),
|
|
1519
|
+
"Accept": "application/json, text/plain, */*",
|
|
1520
|
+
"Origin": "https://www.workbuddy.cn",
|
|
1521
|
+
"Referer": "https://www.workbuddy.cn/profile/growth-center",
|
|
1522
|
+
"x-client-platform": "web",
|
|
1523
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
1524
|
+
};
|
|
1525
|
+
const response = await this.fetchImpl(`https://www.workbuddy.cn/activity/growth/tasks/${encodeURIComponent(taskCode)}/claim`, {
|
|
1526
|
+
method: "POST",
|
|
1527
|
+
headers,
|
|
1528
|
+
signal: AbortSignal.timeout(JSON_TIMEOUT_MS)
|
|
1529
|
+
});
|
|
1530
|
+
const envelope = await readEnvelope(response);
|
|
1531
|
+
if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
|
|
1532
|
+
const data = typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {};
|
|
1533
|
+
if (data["already_claimed"] === true) return {
|
|
1534
|
+
credit: 0,
|
|
1535
|
+
energy: 0
|
|
1536
|
+
};
|
|
1537
|
+
return {
|
|
1538
|
+
credit: typeof data["credit"] === "number" ? data["credit"] : 0,
|
|
1539
|
+
energy: typeof data["energy"] === "number" ? data["energy"] : 0
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
595
1542
|
};
|
|
596
1543
|
//#endregion
|
|
597
1544
|
//#region src/accounts.ts
|
|
@@ -823,7 +1770,26 @@ var WorkBuddyAccountPool = class {
|
|
|
823
1770
|
*/
|
|
824
1771
|
disabledIds = /* @__PURE__ */ new Set();
|
|
825
1772
|
/**
|
|
826
|
-
*
|
|
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
|
+
/**
|
|
1792
|
+
* Last time each account served a request, epoch ms. Drives the idle term
|
|
827
1793
|
* of the priority-mode weighting below: an account that just served loses to
|
|
828
1794
|
* one that has been idle, so a small pool stops hammering a single account.
|
|
829
1795
|
*
|
|
@@ -853,6 +1819,7 @@ var WorkBuddyAccountPool = class {
|
|
|
853
1819
|
if (options.exhaustCooldownMs !== void 0 && options.exhaustCooldownMs >= 1e3) this.exhaustCooldownMs = options.exhaustCooldownMs;
|
|
854
1820
|
if (options.distribution !== void 0) this.distribution = options.distribution;
|
|
855
1821
|
if (options.disabledAccountIds !== void 0) this.disabledIds = new Set(options.disabledAccountIds);
|
|
1822
|
+
if (options.creditReserves !== void 0) this.setCreditReserves(options.creditReserves);
|
|
856
1823
|
}
|
|
857
1824
|
/** Rescan the auth directories and merge newly discovered accounts. */
|
|
858
1825
|
async scan() {
|
|
@@ -906,6 +1873,11 @@ var WorkBuddyAccountPool = class {
|
|
|
906
1873
|
available(now, modelId, region) {
|
|
907
1874
|
return this.accounts.filter((account) => {
|
|
908
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
|
+
}
|
|
909
1881
|
if (account.cooldownUntilMs > now) return false;
|
|
910
1882
|
if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
|
|
911
1883
|
if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
|
|
@@ -1015,6 +1987,57 @@ var WorkBuddyAccountPool = class {
|
|
|
1015
1987
|
this.lastUsedAt.set(accountId, Date.now());
|
|
1016
1988
|
}
|
|
1017
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
|
+
/**
|
|
1018
2041
|
* The account that served the most recent request, if any.
|
|
1019
2042
|
*
|
|
1020
2043
|
* Distinct from "who would serve the next one": this is a record of what
|
|
@@ -1357,144 +2380,1393 @@ const INERT_AUTH = {
|
|
|
1357
2380
|
return false;
|
|
1358
2381
|
}
|
|
1359
2382
|
}
|
|
1360
|
-
};
|
|
1361
|
-
/** No per-token pricing is knowable for a subscription quota; report zero. */
|
|
1362
|
-
const NO_COST = {
|
|
1363
|
-
input: 0,
|
|
1364
|
-
output: 0,
|
|
1365
|
-
cacheRead: 0,
|
|
1366
|
-
cacheWrite: 0
|
|
1367
|
-
};
|
|
1368
|
-
/** pi-ai input modalities: images only when the catalog advertises them. */
|
|
1369
|
-
function modelInput(info) {
|
|
1370
|
-
return info.supportsImages ? ["text", "image"] : ["text"];
|
|
1371
|
-
}
|
|
1372
|
-
/** Map only levels the catalog advertises; undeclared DSH levels stay off. */
|
|
1373
|
-
function thinkingLevelMap(info) {
|
|
1374
|
-
const efforts = info.supportedEfforts;
|
|
1375
|
-
if (efforts === void 0 || efforts.length === 0) return void 0;
|
|
1376
|
-
const levels = [
|
|
1377
|
-
"minimal",
|
|
1378
|
-
"low",
|
|
1379
|
-
"medium",
|
|
1380
|
-
"high",
|
|
1381
|
-
"xhigh",
|
|
1382
|
-
"max"
|
|
1383
|
-
];
|
|
1384
|
-
const map = {};
|
|
1385
|
-
for (const level of levels) map[level] = efforts.includes(level) ? level : null;
|
|
1386
|
-
map["off"] = null;
|
|
1387
|
-
return map;
|
|
1388
|
-
}
|
|
1389
|
-
/** Middle-dot separator: unambiguous between the model's own hyphens and the
|
|
1390
|
-
* rate/badge suffix. Matches the LaoDing family convention. */
|
|
1391
|
-
const DISPLAY_SEPARATOR = " · ";
|
|
1392
|
-
/** Map an upstream tag code to the localized promo label shown next to the
|
|
1393
|
-
* credit rate in the model picker. Both `free` and `limited-free` collapse to
|
|
1394
|
-
* the same short label so the dropdown row stays scannable. */
|
|
1395
|
-
const TAG_LABEL = {
|
|
1396
|
-
"free": "限时免费",
|
|
1397
|
-
"limited-free": "限时免费",
|
|
1398
|
-
"night-discount": "夜间折扣"
|
|
1399
|
-
};
|
|
1400
|
-
/** Resolve the display suffix (`xN.NN`, promo badges) for one catalog row.
|
|
1401
|
-
* Returns `undefined` when there's nothing to show — the name is left alone
|
|
1402
|
-
* so we don't tack a trailing separator on a plain model. */
|
|
1403
|
-
function displaySuffix(info) {
|
|
1404
|
-
const parts = [];
|
|
1405
|
-
if (typeof info.multiplier === "number" && Number.isFinite(info.multiplier)) parts.push(`x${info.multiplier.toFixed(2)}`);
|
|
1406
|
-
for (const tag of info.tags ?? []) {
|
|
1407
|
-
const label = TAG_LABEL[tag];
|
|
1408
|
-
if (label !== void 0 && !parts.includes(label)) parts.push(label);
|
|
2383
|
+
};
|
|
2384
|
+
/** No per-token pricing is knowable for a subscription quota; report zero. */
|
|
2385
|
+
const NO_COST = {
|
|
2386
|
+
input: 0,
|
|
2387
|
+
output: 0,
|
|
2388
|
+
cacheRead: 0,
|
|
2389
|
+
cacheWrite: 0
|
|
2390
|
+
};
|
|
2391
|
+
/** pi-ai input modalities: images only when the catalog advertises them. */
|
|
2392
|
+
function modelInput(info) {
|
|
2393
|
+
return info.supportsImages ? ["text", "image"] : ["text"];
|
|
2394
|
+
}
|
|
2395
|
+
/** Map only levels the catalog advertises; undeclared DSH levels stay off. */
|
|
2396
|
+
function thinkingLevelMap(info) {
|
|
2397
|
+
const efforts = info.supportedEfforts;
|
|
2398
|
+
if (efforts === void 0 || efforts.length === 0) return void 0;
|
|
2399
|
+
const levels = [
|
|
2400
|
+
"minimal",
|
|
2401
|
+
"low",
|
|
2402
|
+
"medium",
|
|
2403
|
+
"high",
|
|
2404
|
+
"xhigh",
|
|
2405
|
+
"max"
|
|
2406
|
+
];
|
|
2407
|
+
const map = {};
|
|
2408
|
+
for (const level of levels) map[level] = efforts.includes(level) ? level : null;
|
|
2409
|
+
map["off"] = null;
|
|
2410
|
+
return map;
|
|
2411
|
+
}
|
|
2412
|
+
/** Middle-dot separator: unambiguous between the model's own hyphens and the
|
|
2413
|
+
* rate/badge suffix. Matches the LaoDing family convention. */
|
|
2414
|
+
const DISPLAY_SEPARATOR = " · ";
|
|
2415
|
+
/** Map an upstream tag code to the localized promo label shown next to the
|
|
2416
|
+
* credit rate in the model picker. Both `free` and `limited-free` collapse to
|
|
2417
|
+
* the same short label so the dropdown row stays scannable. */
|
|
2418
|
+
const TAG_LABEL = {
|
|
2419
|
+
"free": "限时免费",
|
|
2420
|
+
"limited-free": "限时免费",
|
|
2421
|
+
"night-discount": "夜间折扣"
|
|
2422
|
+
};
|
|
2423
|
+
/** Resolve the display suffix (`xN.NN`, promo badges) for one catalog row.
|
|
2424
|
+
* Returns `undefined` when there's nothing to show — the name is left alone
|
|
2425
|
+
* so we don't tack a trailing separator on a plain model. */
|
|
2426
|
+
function displaySuffix(info) {
|
|
2427
|
+
const parts = [];
|
|
2428
|
+
if (typeof info.multiplier === "number" && Number.isFinite(info.multiplier)) parts.push(`x${info.multiplier.toFixed(2)}`);
|
|
2429
|
+
for (const tag of info.tags ?? []) {
|
|
2430
|
+
const label = TAG_LABEL[tag];
|
|
2431
|
+
if (label !== void 0 && !parts.includes(label)) parts.push(label);
|
|
2432
|
+
}
|
|
2433
|
+
return parts.length === 0 ? void 0 : parts.join(DISPLAY_SEPARATOR);
|
|
2434
|
+
}
|
|
2435
|
+
/** Apply the catalog's rate + promo badges to one model's display name.
|
|
2436
|
+
* Display-only: the wire request is built from `model.id`, so renaming here
|
|
2437
|
+
* cannot affect routing, token, or upstream accounting. DSH 0.1.2's composer
|
|
2438
|
+
* (`ModelSelect`) renders `model.name` only, which is why the rate and
|
|
2439
|
+
* badges ride the name rather than a separate description column. */
|
|
2440
|
+
function withCatalogDisplay(name, info) {
|
|
2441
|
+
const suffix = displaySuffix(info);
|
|
2442
|
+
return suffix === void 0 ? name : `${name}${DISPLAY_SEPARATOR}${suffix}`;
|
|
2443
|
+
}
|
|
2444
|
+
/** Build one pi-ai model descriptor pointing at the loopback shim. */
|
|
2445
|
+
function toPiModel(info, baseUrl, providerId) {
|
|
2446
|
+
const map = thinkingLevelMap(info);
|
|
2447
|
+
return {
|
|
2448
|
+
id: info.id,
|
|
2449
|
+
name: withCatalogDisplay(info.name, info),
|
|
2450
|
+
api: "openai-completions",
|
|
2451
|
+
provider: providerId,
|
|
2452
|
+
baseUrl,
|
|
2453
|
+
input: modelInput(info),
|
|
2454
|
+
cost: NO_COST,
|
|
2455
|
+
contextWindow: info.contextWindow,
|
|
2456
|
+
maxTokens: info.maxOutputTokens,
|
|
2457
|
+
reasoning: map !== void 0,
|
|
2458
|
+
...map === void 0 ? {} : { thinkingLevelMap: map },
|
|
2459
|
+
compat: { supportsReasoningEffort: map !== void 0 }
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Assemble the adapter. `getModels` re-reads the live catalog, and every
|
|
2464
|
+
* model's `baseUrl` is re-resolved per read so the shim's ephemeral port
|
|
2465
|
+
* applies from the first snapshot after startup. Call only after `shim.ready`.
|
|
2466
|
+
*/
|
|
2467
|
+
function createWorkBuddyAdapter(options) {
|
|
2468
|
+
const { shim, catalog } = options;
|
|
2469
|
+
const providerId = options.providerId ?? "workbuddy-xdpool";
|
|
2470
|
+
const displayName = options.displayName ?? "WorkBuddy XD Pool";
|
|
2471
|
+
const buildModels = () => {
|
|
2472
|
+
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
2473
|
+
return catalog.visible().map((info) => toPiModel(info, baseUrl, providerId));
|
|
2474
|
+
};
|
|
2475
|
+
const provider = {
|
|
2476
|
+
...createProvider({
|
|
2477
|
+
id: providerId,
|
|
2478
|
+
name: displayName,
|
|
2479
|
+
auth: { apiKey: {
|
|
2480
|
+
name: "WorkBuddy XD Pool loopback secret",
|
|
2481
|
+
async resolve({ credential }) {
|
|
2482
|
+
const apiKey = credential?.key;
|
|
2483
|
+
return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
|
|
2484
|
+
auth: { apiKey },
|
|
2485
|
+
source: "WorkBuddy XD Pool"
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
} },
|
|
2489
|
+
models: buildModels(),
|
|
2490
|
+
api: openAICompletionsApi()
|
|
2491
|
+
}),
|
|
2492
|
+
getModels: () => buildModels()
|
|
2493
|
+
};
|
|
2494
|
+
const profile = {
|
|
2495
|
+
provider: providerId,
|
|
2496
|
+
displayName,
|
|
2497
|
+
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
2498
|
+
retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
|
|
2499
|
+
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
2500
|
+
...REQUEST_IMAGE_BUDGETS,
|
|
2501
|
+
piProvider: provider
|
|
2502
|
+
};
|
|
2503
|
+
Object.assign(profile, { modelErrors: /* @__PURE__ */ new Map() });
|
|
2504
|
+
let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
2505
|
+
return {
|
|
2506
|
+
providerId,
|
|
2507
|
+
displayName,
|
|
2508
|
+
adapter: new PiAiAdapter({
|
|
2509
|
+
profiles: () => profiles,
|
|
2510
|
+
auth: INERT_AUTH,
|
|
2511
|
+
resolveApiKey: async () => shim.token(),
|
|
2512
|
+
resolveAttachments: () => options.ctx.get("attachments"),
|
|
2513
|
+
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => options.ctx.get("fs")?.processPathFromHostPath(hostPath), ref)
|
|
2514
|
+
}),
|
|
2515
|
+
buildModels,
|
|
2516
|
+
invalidate: () => {
|
|
2517
|
+
profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
2518
|
+
}
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
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) {
|
|
3029
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
3030
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
3031
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* Whether `now`'s local hour is one of `hours`.
|
|
3035
|
+
*
|
|
3036
|
+
* The reference panel computes a `nextFire` instant and sleeps until it; this
|
|
3037
|
+
* loop instead wakes every minute and asks "is any job due now". Both fire at
|
|
3038
|
+
* the top of a configured hour, but the polling form cannot miss a slot to a
|
|
3039
|
+
* suspended process — a laptop that slept through 10:00 still runs the job the
|
|
3040
|
+
* moment it wakes, on the same day.
|
|
3041
|
+
*/
|
|
3042
|
+
function isFireHour(now, hours) {
|
|
3043
|
+
return hours.includes(now.getHours());
|
|
3044
|
+
}
|
|
3045
|
+
/** Whether this account may be used: not switched off, not cooling. */
|
|
3046
|
+
function eligible(account, now) {
|
|
3047
|
+
if (account.cooldownUntilMs > now) return false;
|
|
3048
|
+
if (regionOf(account.credential.domain) === "global") return false;
|
|
3049
|
+
return true;
|
|
3050
|
+
}
|
|
3051
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
3052
|
+
setTimeout(resolve, ms);
|
|
3053
|
+
});
|
|
3054
|
+
/**
|
|
3055
|
+
* The points automation.
|
|
3056
|
+
*
|
|
3057
|
+
* Owns a single timer loop. Construction is inert — nothing runs until
|
|
3058
|
+
* {@link start}, and {@link stop} is idempotent so a plugin teardown that fires
|
|
3059
|
+
* twice is harmless.
|
|
3060
|
+
*/
|
|
3061
|
+
var WorkBuddyScheduler = class {
|
|
3062
|
+
pool;
|
|
3063
|
+
client;
|
|
3064
|
+
logger;
|
|
3065
|
+
now;
|
|
3066
|
+
delayMs;
|
|
3067
|
+
/**
|
|
3068
|
+
* How long to wait for event scoring before re-reading the task list.
|
|
3069
|
+
* Tests set 0 so a pass does not spend nine real seconds per account.
|
|
3070
|
+
*/
|
|
3071
|
+
eventScoreWaitMs;
|
|
3072
|
+
/**
|
|
3073
|
+
* Gap between two expert summon chains.
|
|
3074
|
+
* Tests set 0 so a pass does not spend six real seconds per expert.
|
|
3075
|
+
*/
|
|
3076
|
+
expertGapMs;
|
|
3077
|
+
enabled;
|
|
3078
|
+
checkinHours;
|
|
3079
|
+
taskHours;
|
|
3080
|
+
reportHours;
|
|
3081
|
+
streakHours;
|
|
3082
|
+
travelHours;
|
|
3083
|
+
timer;
|
|
3084
|
+
running = false;
|
|
3085
|
+
/** Guards against a slow run overlapping the next tick. */
|
|
3086
|
+
busy = false;
|
|
3087
|
+
/** True while a manual run is in flight, so the card can poll it. */
|
|
3088
|
+
runInFlight = false;
|
|
3089
|
+
/**
|
|
3090
|
+
* Set once {@link stop} is called.
|
|
3091
|
+
*
|
|
3092
|
+
* Deliberately false before `start`: the loop is not running yet, but a
|
|
3093
|
+
* manual `tick` must still work. `stop` is what makes a run abandon the
|
|
3094
|
+
* accounts it has not reached yet.
|
|
3095
|
+
*/
|
|
3096
|
+
stopped = false;
|
|
3097
|
+
states = {
|
|
3098
|
+
checkin: { ...EMPTY_JOB_STATE },
|
|
3099
|
+
report: { ...EMPTY_JOB_STATE },
|
|
3100
|
+
tasks: { ...EMPTY_JOB_STATE },
|
|
3101
|
+
streak: { ...EMPTY_JOB_STATE },
|
|
3102
|
+
travel: { ...EMPTY_JOB_STATE }
|
|
3103
|
+
};
|
|
3104
|
+
claimableSeen = 0;
|
|
3105
|
+
/**
|
|
3106
|
+
* Credits/energy/tasks earned per account TODAY, keyed by account id.
|
|
3107
|
+
*
|
|
3108
|
+
* Cleared whenever the day key rolls over, so the card always answers
|
|
3109
|
+
* "what did the automation get for THIS account today".
|
|
3110
|
+
*/
|
|
3111
|
+
earnings = /* @__PURE__ */ new Map();
|
|
3112
|
+
/** Day key the counters above belong to. */
|
|
3113
|
+
earningsDate = "";
|
|
3114
|
+
/** Host hooks that persist the ledger across restarts. */
|
|
3115
|
+
loadEarnings;
|
|
3116
|
+
saveEarningsFn;
|
|
3117
|
+
constructor(pool, client, options = {}) {
|
|
3118
|
+
this.pool = pool;
|
|
3119
|
+
this.client = client;
|
|
3120
|
+
this.logger = options.logger ?? {};
|
|
3121
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
3122
|
+
this.delayMs = options.accountDelayMs ?? 800;
|
|
3123
|
+
this.eventScoreWaitMs = options.eventScoreWaitMs ?? 9e3;
|
|
3124
|
+
this.expertGapMs = options.expertGapMs ?? 6e3;
|
|
3125
|
+
this.loadEarnings = options.loadEarnings;
|
|
3126
|
+
this.saveEarningsFn = options.saveEarnings;
|
|
3127
|
+
const restored = options.loadEarnings?.();
|
|
3128
|
+
const today = dayKey(this.now());
|
|
3129
|
+
if (restored !== void 0 && restored.date === today) {
|
|
3130
|
+
for (const [id, entry] of Object.entries(restored.accounts)) this.earnings.set(id, entry);
|
|
3131
|
+
this.earningsDate = today;
|
|
3132
|
+
}
|
|
3133
|
+
this.enabled = options.enabled ?? false;
|
|
3134
|
+
this.checkinHours = options.checkinHours ?? [9];
|
|
3135
|
+
this.reportHours = options.reportHours ?? [10];
|
|
3136
|
+
this.taskHours = options.taskHours ?? [11];
|
|
3137
|
+
this.streakHours = options.streakHours ?? [12];
|
|
3138
|
+
this.travelHours = options.travelHours ?? [12];
|
|
3139
|
+
}
|
|
3140
|
+
/** Apply a new configuration; safe to call while running. */
|
|
3141
|
+
/**
|
|
3142
|
+
* Install the persistence hook once the host settings service is available.
|
|
3143
|
+
*
|
|
3144
|
+
* Separate from the constructor because the scheduler is built with the pool,
|
|
3145
|
+
* long before the settings section exists; a ledger written before that point
|
|
3146
|
+
* would have nowhere to go.
|
|
3147
|
+
*/
|
|
3148
|
+
setEarningsPersistence(save) {
|
|
3149
|
+
this.saveEarningsFn = save;
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* Fold a previously persisted ledger back in, when it belongs to today.
|
|
3153
|
+
*
|
|
3154
|
+
* Used after the settings document becomes readable, which happens after
|
|
3155
|
+
* construction; a ledger from an earlier day is ignored so the counters never
|
|
3156
|
+
* claim yesterday as today.
|
|
3157
|
+
*/
|
|
3158
|
+
applyEarningsLedger(ledger) {
|
|
3159
|
+
const today = dayKey(this.now());
|
|
3160
|
+
if (ledger.date !== today) return;
|
|
3161
|
+
for (const [id, entry] of Object.entries(ledger.accounts)) this.earnings.set(id, entry);
|
|
3162
|
+
this.earningsDate = today;
|
|
3163
|
+
}
|
|
3164
|
+
applyConfig(options) {
|
|
3165
|
+
if (options.enabled !== void 0) this.enabled = options.enabled;
|
|
3166
|
+
if (options.checkinHours !== void 0) this.checkinHours = options.checkinHours;
|
|
3167
|
+
if (options.reportHours !== void 0) this.reportHours = options.reportHours;
|
|
3168
|
+
if (options.taskHours !== void 0) this.taskHours = options.taskHours;
|
|
3169
|
+
if (options.streakHours !== void 0) this.streakHours = options.streakHours;
|
|
3170
|
+
if (options.travelHours !== void 0) this.travelHours = options.travelHours;
|
|
3171
|
+
}
|
|
3172
|
+
/** Hours for one job, used by the loop and the status document. */
|
|
3173
|
+
hoursOf(kind) {
|
|
3174
|
+
switch (kind) {
|
|
3175
|
+
case "checkin": return this.checkinHours;
|
|
3176
|
+
case "report": return this.reportHours;
|
|
3177
|
+
case "tasks": return this.taskHours;
|
|
3178
|
+
case "streak": return this.streakHours;
|
|
3179
|
+
case "travel": return this.travelHours;
|
|
3180
|
+
}
|
|
3181
|
+
}
|
|
3182
|
+
/** Start the loop. Idempotent. */
|
|
3183
|
+
start() {
|
|
3184
|
+
if (this.timer !== void 0) return;
|
|
3185
|
+
this.stopped = false;
|
|
3186
|
+
this.running = true;
|
|
3187
|
+
this.timer = setInterval(() => {
|
|
3188
|
+
this.tick();
|
|
3189
|
+
}, AUTOMATION_TICK_MS);
|
|
3190
|
+
this.timer.unref?.();
|
|
3191
|
+
}
|
|
3192
|
+
/** Stop the loop. Idempotent, and safe before `start`. */
|
|
3193
|
+
stop() {
|
|
3194
|
+
this.stopped = true;
|
|
3195
|
+
this.running = false;
|
|
3196
|
+
if (this.timer !== void 0) {
|
|
3197
|
+
clearInterval(this.timer);
|
|
3198
|
+
this.timer = void 0;
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
/** Snapshot for the status document. */
|
|
3202
|
+
/**
|
|
3203
|
+
* Run one job immediately, regardless of the clock.
|
|
3204
|
+
*
|
|
3205
|
+
* Exists so the automation can be verified from the card without waiting for
|
|
3206
|
+
* its hour. A manual run is recorded exactly like a scheduled one, so the
|
|
3207
|
+
* timer will not repeat it later the same day: every job is idempotent, but a
|
|
3208
|
+
* second pass would still be wasted upstream calls.
|
|
3209
|
+
*
|
|
3210
|
+
* `force` ignores the already-ran-today guard, which is what pressing the
|
|
3211
|
+
* button a second time means.
|
|
3212
|
+
*/
|
|
3213
|
+
async runNow(kind, force = false) {
|
|
3214
|
+
const today = dayKey(this.now());
|
|
3215
|
+
const state = this.states[kind];
|
|
3216
|
+
if (!force && state.lastRunDate === today) return { ...state };
|
|
3217
|
+
this.busy = true;
|
|
3218
|
+
try {
|
|
3219
|
+
await this.runJob(kind, today);
|
|
3220
|
+
} finally {
|
|
3221
|
+
this.busy = false;
|
|
3222
|
+
}
|
|
3223
|
+
return { ...this.states[kind] };
|
|
3224
|
+
}
|
|
3225
|
+
/**
|
|
3226
|
+
* Run every job once, in the scheduled order.
|
|
3227
|
+
*
|
|
3228
|
+
* Order matters and is not configurable: the activity report has to land
|
|
3229
|
+
* before the task pass reads task progress, or the pass sees counters the
|
|
3230
|
+
* report would have moved. This is what the card's single button calls.
|
|
3231
|
+
*/
|
|
3232
|
+
/**
|
|
3233
|
+
* Start a full pass in the background and return immediately.
|
|
3234
|
+
*
|
|
3235
|
+
* A pass takes tens of seconds - one upstream round trip per account per job,
|
|
3236
|
+
* plus the scoring wait - which is far too long to hold the card request open:
|
|
3237
|
+
* the browser or the host web server would time out, and the user would see
|
|
3238
|
+
* a hung button for a run that is actually working.
|
|
3239
|
+
*
|
|
3240
|
+
* Returns whether a run started. A second call while one is in flight is
|
|
3241
|
+
* ignored rather than queued: pressing the button twice means hurry up, and
|
|
3242
|
+
* the run already under way covers it.
|
|
3243
|
+
*/
|
|
3244
|
+
startRunAll() {
|
|
3245
|
+
if (this.runInFlight) return false;
|
|
3246
|
+
this.runInFlight = true;
|
|
3247
|
+
this.runAll().catch((error) => {
|
|
3248
|
+
this.logger.warn?.("dsh-workbuddy-xdpool automation run failed:", error);
|
|
3249
|
+
}).finally(() => {
|
|
3250
|
+
this.runInFlight = false;
|
|
3251
|
+
});
|
|
3252
|
+
return true;
|
|
3253
|
+
}
|
|
3254
|
+
async runAll() {
|
|
3255
|
+
const today = dayKey(this.now());
|
|
3256
|
+
const before = this.earningsSnapshot();
|
|
3257
|
+
let okCount = 0;
|
|
3258
|
+
let failed = 0;
|
|
3259
|
+
let jobsRun = 0;
|
|
3260
|
+
for (const kind of JOB_KINDS) {
|
|
3261
|
+
jobsRun += 1;
|
|
3262
|
+
this.busy = true;
|
|
3263
|
+
try {
|
|
3264
|
+
await this.runJob(kind, today);
|
|
3265
|
+
} finally {
|
|
3266
|
+
this.busy = false;
|
|
3267
|
+
}
|
|
3268
|
+
okCount += this.states[kind].ok;
|
|
3269
|
+
failed += this.states[kind].failed;
|
|
3270
|
+
}
|
|
3271
|
+
const gained = diffEarnings(before, this.earningsSnapshot());
|
|
3272
|
+
return {
|
|
3273
|
+
jobsRun,
|
|
3274
|
+
okCount,
|
|
3275
|
+
failed,
|
|
3276
|
+
credit: gained.credit,
|
|
3277
|
+
energy: gained.energy,
|
|
3278
|
+
claimed: gained.claimed,
|
|
3279
|
+
accounts: gained.accounts
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
status() {
|
|
3283
|
+
const jobs = {};
|
|
3284
|
+
for (const kind of JOB_KINDS) jobs[kind] = { ...this.states[kind] };
|
|
3285
|
+
return {
|
|
3286
|
+
enabled: this.enabled,
|
|
3287
|
+
running: this.running,
|
|
3288
|
+
checkinHours: this.checkinHours,
|
|
3289
|
+
taskHours: this.taskHours,
|
|
3290
|
+
reportHours: this.reportHours,
|
|
3291
|
+
streakHours: this.streakHours,
|
|
3292
|
+
travelHours: this.travelHours,
|
|
3293
|
+
jobs,
|
|
3294
|
+
claimableSeen: this.claimableSeen,
|
|
3295
|
+
earningsToday: this.earningsSnapshot(),
|
|
3296
|
+
runInProgress: this.runInFlight
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
/**
|
|
3300
|
+
* One poll: run every due job, serially.
|
|
3301
|
+
*
|
|
3302
|
+
* Serial by design — the jobs share the same accounts and the upstream
|
|
3303
|
+
* rate-limits per account, so overlapping passes would only trip that limit.
|
|
3304
|
+
* A job that throws is recorded and the loop continues.
|
|
3305
|
+
*/
|
|
3306
|
+
async tick() {
|
|
3307
|
+
if (!this.enabled || this.stopped || this.busy) return;
|
|
3308
|
+
this.busy = true;
|
|
3309
|
+
try {
|
|
3310
|
+
const now = this.now();
|
|
3311
|
+
const today = dayKey(now);
|
|
3312
|
+
for (const kind of JOB_KINDS) {
|
|
3313
|
+
if (this.stopped) return;
|
|
3314
|
+
if (this.states[kind].lastRunDate === today) continue;
|
|
3315
|
+
if (!isFireHour(now, this.hoursOf(kind))) continue;
|
|
3316
|
+
await this.runJob(kind, today);
|
|
3317
|
+
}
|
|
3318
|
+
} catch (error) {
|
|
3319
|
+
this.logger.warn?.("dsh-workbuddy-xdpool automation tick failed:", error);
|
|
3320
|
+
} finally {
|
|
3321
|
+
this.busy = false;
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
/** Run one job against every eligible account and record the outcome. */
|
|
3325
|
+
/**
|
|
3326
|
+
* Add one account's take to today's counters, resetting first if the day
|
|
3327
|
+
* rolled over. Called from the task pass, which is the only job that earns.
|
|
3328
|
+
*/
|
|
3329
|
+
/**
|
|
3330
|
+
* Today's per-account earnings, as a plain object for the status document.
|
|
3331
|
+
*
|
|
3332
|
+
* Rolls the day first so a status read just after midnight does not report
|
|
3333
|
+
* yesterday's totals under today's date.
|
|
3334
|
+
*/
|
|
3335
|
+
earningsSnapshot() {
|
|
3336
|
+
this.rollEarnings(dayKey(this.now()));
|
|
3337
|
+
const out = {};
|
|
3338
|
+
for (const [id, entry] of this.earnings) out[id] = { ...entry };
|
|
3339
|
+
return out;
|
|
3340
|
+
}
|
|
3341
|
+
/**
|
|
3342
|
+
* Add one account take to today counters, resetting first if the day rolled
|
|
3343
|
+
* over. Every source is tracked separately so the card can show what earned
|
|
3344
|
+
* what, rather than one opaque total.
|
|
3345
|
+
*/
|
|
3346
|
+
recordEarnings(accountId, today, delta) {
|
|
3347
|
+
this.rollEarnings(today);
|
|
3348
|
+
const credit = delta.credit ?? 0;
|
|
3349
|
+
const energy = delta.energy ?? 0;
|
|
3350
|
+
const claimed = delta.claimed ?? 0;
|
|
3351
|
+
const checkinCredit = delta.checkinCredit ?? 0;
|
|
3352
|
+
const bonusCredit = delta.bonusCredit ?? 0;
|
|
3353
|
+
const travelCredit = delta.travelCredit ?? 0;
|
|
3354
|
+
if (credit === 0 && energy === 0 && claimed === 0 && checkinCredit === 0 && bonusCredit === 0 && travelCredit === 0) return;
|
|
3355
|
+
const existing = this.earnings.get(accountId);
|
|
3356
|
+
this.earnings.set(accountId, {
|
|
3357
|
+
credit: (existing?.credit ?? 0) + credit,
|
|
3358
|
+
energy: (existing?.energy ?? 0) + energy,
|
|
3359
|
+
claimed: (existing?.claimed ?? 0) + claimed,
|
|
3360
|
+
checkinCredit: (existing?.checkinCredit ?? 0) + checkinCredit,
|
|
3361
|
+
bonusCredit: (existing?.bonusCredit ?? 0) + bonusCredit,
|
|
3362
|
+
travelCredit: (existing?.travelCredit ?? 0) + travelCredit,
|
|
3363
|
+
date: today
|
|
3364
|
+
});
|
|
3365
|
+
this.persistEarnings();
|
|
3366
|
+
}
|
|
3367
|
+
/** Clear the per-account counters when the local day changes. */
|
|
3368
|
+
rollEarnings(today) {
|
|
3369
|
+
if (this.earningsDate === today) return;
|
|
3370
|
+
this.earningsDate = today;
|
|
3371
|
+
this.earnings.clear();
|
|
3372
|
+
this.claimableSeen = 0;
|
|
3373
|
+
this.persistEarnings();
|
|
3374
|
+
}
|
|
3375
|
+
/**
|
|
3376
|
+
* Write the ledger through the host hook, when one was supplied.
|
|
3377
|
+
*
|
|
3378
|
+
* Best effort on purpose: a failed save must never abort a run that has
|
|
3379
|
+
* already collected rewards, and the in-memory ledger keeps serving the card
|
|
3380
|
+
* for the rest of the session either way.
|
|
3381
|
+
*/
|
|
3382
|
+
persistEarnings() {
|
|
3383
|
+
if (this.saveEarningsFn === void 0) return;
|
|
3384
|
+
try {
|
|
3385
|
+
const accounts = {};
|
|
3386
|
+
for (const [id, entry] of this.earnings) accounts[id] = entry;
|
|
3387
|
+
this.saveEarningsFn({
|
|
3388
|
+
date: this.earningsDate,
|
|
3389
|
+
accounts
|
|
3390
|
+
});
|
|
3391
|
+
} catch (error) {
|
|
3392
|
+
this.logger.warn?.("dsh-workbuddy-xdpool: could not persist automation earnings:", error);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
/**
|
|
3396
|
+
* Run one job against every eligible account and record the outcome.
|
|
3397
|
+
*
|
|
3398
|
+
* The task job runs in TWO passes. The first sends the event chains that light
|
|
3399
|
+
* up client-scored tasks; the second collects rewards. They are separate
|
|
3400
|
+
* because scoring lands asynchronously — a chain sent and claimed within the
|
|
3401
|
+
* same breath finds the task still un-scored — and because sending is fast
|
|
3402
|
+
* while claiming wants the whole pool to have been lit up first. Splitting
|
|
3403
|
+
* them costs one shared wait instead of one wait per account.
|
|
3404
|
+
*/
|
|
3405
|
+
async runJob(kind, today) {
|
|
3406
|
+
const accountWord = kind === "report" ? "report" : kind;
|
|
3407
|
+
let ok = 0;
|
|
3408
|
+
let failed = 0;
|
|
3409
|
+
let credit = 0;
|
|
3410
|
+
let energy = 0;
|
|
3411
|
+
let claimed = 0;
|
|
3412
|
+
const accounts = this.accountsInOrder();
|
|
3413
|
+
if (kind === "tasks") await this.sendEventChains(accounts);
|
|
3414
|
+
for (let index = 0; index < accounts.length; index++) {
|
|
3415
|
+
if (this.stopped) break;
|
|
3416
|
+
const account = accounts[index];
|
|
3417
|
+
if (account === void 0) continue;
|
|
3418
|
+
try {
|
|
3419
|
+
switch (kind) {
|
|
3420
|
+
case "checkin":
|
|
3421
|
+
try {
|
|
3422
|
+
const claim = await this.client.claimDailyCheckin(account.credential);
|
|
3423
|
+
this.recordEarnings(account.id, today, { checkinCredit: claim.credit });
|
|
3424
|
+
if (claim.credit > 0) this.logger.info?.(`automation checkin ${account.label}: +${claim.credit} credit`);
|
|
3425
|
+
} catch (error) {
|
|
3426
|
+
if (!isAlreadyCheckin(error)) throw error;
|
|
3427
|
+
this.logger.info?.(`automation checkin ${account.label}: already done today`);
|
|
3428
|
+
}
|
|
3429
|
+
break;
|
|
3430
|
+
case "report": {
|
|
3431
|
+
const days = await this.reportOne(account);
|
|
3432
|
+
this.logger.info?.(`automation report ${account.label}: streak days=${days}`);
|
|
3433
|
+
break;
|
|
3434
|
+
}
|
|
3435
|
+
case "tasks": {
|
|
3436
|
+
const result = await this.runTasks(account);
|
|
3437
|
+
credit += result.credit;
|
|
3438
|
+
energy += result.energy;
|
|
3439
|
+
claimed += result.claimed;
|
|
3440
|
+
this.claimableSeen += result.claimableCount;
|
|
3441
|
+
this.recordEarnings(account.id, today, {
|
|
3442
|
+
credit: result.credit,
|
|
3443
|
+
energy: result.energy,
|
|
3444
|
+
claimed: result.claimed
|
|
3445
|
+
});
|
|
3446
|
+
this.logger.info?.(`automation tasks ${account.label}: ${result.claimed} claimed (+${result.credit} credit, +${result.energy} energy, ${result.claimableCount} claimable seen)`);
|
|
3447
|
+
break;
|
|
3448
|
+
}
|
|
3449
|
+
case "streak":
|
|
3450
|
+
await this.redeemStreak(account);
|
|
3451
|
+
break;
|
|
3452
|
+
case "travel": await this.runTravel(account);
|
|
3453
|
+
}
|
|
3454
|
+
ok++;
|
|
3455
|
+
} catch (error) {
|
|
3456
|
+
failed++;
|
|
3457
|
+
this.logger.warn?.(`automation ${accountWord} ${account.label} failed:`, error);
|
|
3458
|
+
}
|
|
3459
|
+
if (index < accounts.length - 1 && this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
|
|
3460
|
+
}
|
|
3461
|
+
const state = this.states[kind];
|
|
3462
|
+
state.lastRunDate = today;
|
|
3463
|
+
state.lastRunAtMs = this.now().getTime();
|
|
3464
|
+
state.ok = ok;
|
|
3465
|
+
state.failed = failed;
|
|
3466
|
+
state.credit = credit;
|
|
3467
|
+
state.energy = energy;
|
|
3468
|
+
state.claimed = claimed;
|
|
3469
|
+
state.message = this.summarise(kind, ok, failed, claimed, credit, energy);
|
|
3470
|
+
this.logger.info?.(`automation ${accountWord}: ${state.message}`);
|
|
3471
|
+
}
|
|
3472
|
+
/** Compose the one-line summary shown on the card. */
|
|
3473
|
+
summarise(kind, ok, failed, claimed, credit, energy) {
|
|
3474
|
+
if (kind === "tasks") return `${ok} accounts, ${claimed} tasks claimed (+${credit} credit, +${energy} energy)${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
3475
|
+
return `${ok} accounts ok${failed > 0 ? `, ${failed} failed` : ""}`;
|
|
3476
|
+
}
|
|
3477
|
+
/**
|
|
3478
|
+
* Accounts to run against, in pool order.
|
|
3479
|
+
*
|
|
3480
|
+
* Disabled accounts are excluded here rather than filtered by the caller so a
|
|
3481
|
+
* card switch takes effect on the next pass without any event plumbing.
|
|
3482
|
+
*/
|
|
3483
|
+
accountsInOrder() {
|
|
3484
|
+
const now = Date.now();
|
|
3485
|
+
return this.pool.list().filter((account) => {
|
|
3486
|
+
if (this.pool.isDisabled(account.id)) return false;
|
|
3487
|
+
return eligible(account, now);
|
|
3488
|
+
});
|
|
3489
|
+
}
|
|
3490
|
+
/**
|
|
3491
|
+
* Send one activity report, then verify it landed.
|
|
3492
|
+
*
|
|
3493
|
+
* The upstream answers 200 even when it drops the event, so the streak is
|
|
3494
|
+
* read back as the oracle: `days > 0` means it counted. A failed read-back is
|
|
3495
|
+
* logged and treated as a suspicious result, never as a retry — the report is
|
|
3496
|
+
* idempotent per day, and hammering it is exactly what the one-a-day quota
|
|
3497
|
+
* exists to avoid.
|
|
3498
|
+
*/
|
|
3499
|
+
async reportOne(account) {
|
|
3500
|
+
await this.client.reportActivity(account.credential);
|
|
3501
|
+
try {
|
|
3502
|
+
const days = await this.client.growthStreakDays(account.credential);
|
|
3503
|
+
if (days === 0) this.logger.warn?.(`automation report ${account.label}: streak days=0 right after report (new account or scoring lag?)`);
|
|
3504
|
+
return days;
|
|
3505
|
+
} catch (error) {
|
|
3506
|
+
this.logger.warn?.(`automation report ${account.label}: streak read-back failed:`, error);
|
|
3507
|
+
return -1;
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
async sendEventChains(accounts) {
|
|
3511
|
+
let sentAnything = false;
|
|
3512
|
+
for (const account of accounts) {
|
|
3513
|
+
if (this.stopped) return;
|
|
3514
|
+
let tasks;
|
|
3515
|
+
try {
|
|
3516
|
+
tasks = await this.client.listTasks(account.credential);
|
|
3517
|
+
} catch (error) {
|
|
3518
|
+
this.logger.warn?.(`automation events ${account.label}: list failed:`, error);
|
|
3519
|
+
continue;
|
|
3520
|
+
}
|
|
3521
|
+
const pending = tasks.filter((task) => task.taskCode in EVENT_CHAIN_BUILDERS && !task.claimable && !task.locked);
|
|
3522
|
+
for (const task of pending) {
|
|
3523
|
+
if (this.stopped) return;
|
|
3524
|
+
let chains;
|
|
3525
|
+
try {
|
|
3526
|
+
chains = await this.chainsFor(task, account.credential);
|
|
3527
|
+
} catch (error) {
|
|
3528
|
+
this.logger.warn?.(`automation events ${account.label}: ${task.taskCode} could not be built:`, error);
|
|
3529
|
+
continue;
|
|
3530
|
+
}
|
|
3531
|
+
for (const chain of chains) {
|
|
3532
|
+
if (this.stopped) return;
|
|
3533
|
+
try {
|
|
3534
|
+
await this.sendChain(account.credential, chain);
|
|
3535
|
+
this.logger.info?.(`automation events ${account.label}: ${task.taskCode} chain sent`);
|
|
3536
|
+
sentAnything = true;
|
|
3537
|
+
} catch (error) {
|
|
3538
|
+
this.logger.warn?.(`automation events ${account.label}: ${task.taskCode} failed:`, error);
|
|
3539
|
+
break;
|
|
3540
|
+
}
|
|
3541
|
+
if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
if (sentAnything && this.eventScoreWaitMs > 0 && !this.stopped) await sleep(this.eventScoreWaitMs);
|
|
3546
|
+
}
|
|
3547
|
+
/**
|
|
3548
|
+
* Send one chain on the channel it was built for.
|
|
3549
|
+
*
|
|
3550
|
+
* The transport is not a detail of the sender: the scorer keys different
|
|
3551
|
+
* tasks to different fingerprint families, so a web-scored event posted as a
|
|
3552
|
+
* desktop event is accepted and then ignored.
|
|
3553
|
+
*/
|
|
3554
|
+
async sendChain(credential, chain) {
|
|
3555
|
+
if (chain.transport === "web") {
|
|
3556
|
+
const web = chain.web;
|
|
3557
|
+
if (web === void 0) throw new Error("web chain without a web event");
|
|
3558
|
+
await this.client.reportWebEvent(credential, web.eventCode, web.pageUrl, web.elementId, web.elementName);
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
await this.client.reportDesktopEvents(credential, chain.events ?? []);
|
|
3562
|
+
}
|
|
3563
|
+
/**
|
|
3564
|
+
* Build every chain that scores one task.
|
|
3565
|
+
*
|
|
3566
|
+
* Most tasks need a single chain; `template_5` needs five, because the scorer
|
|
3567
|
+
* counts distinct `template_used` events rather than a boolean. The two tasks
|
|
3568
|
+
* that join a conversation (skill, expert) open a real one first, which is why
|
|
3569
|
+
* this is async.
|
|
3570
|
+
*/
|
|
3571
|
+
async chainsFor(task, credential) {
|
|
3572
|
+
switch (task.taskCode) {
|
|
3573
|
+
case "Buddy_App":
|
|
3574
|
+
case "Buddy_App_QQ": return [buddyAppChain()];
|
|
3575
|
+
case "create_canvas": return [canvasChain()];
|
|
3576
|
+
case "automation_1": return [automationChain()];
|
|
3577
|
+
case "RichMeow_Chat": return [chatChain()];
|
|
3578
|
+
case "playbook_prompt": return [playbookChain()];
|
|
3579
|
+
case "template_5": return templateChains();
|
|
3580
|
+
case "Hp_Appearance":
|
|
3581
|
+
await this.client.setAppearanceTheme(credential, APPEARANCE_THEME_KEY);
|
|
3582
|
+
if (this.delayMs > 0 && !this.stopped) await sleep(2e3);
|
|
3583
|
+
return [appearanceChain()];
|
|
3584
|
+
case "Library_read": return [libraryReadChain()];
|
|
3585
|
+
case "skill_1": {
|
|
3586
|
+
const chat = await this.client.openConversation(credential);
|
|
3587
|
+
if (chat === void 0) throw new Error("skill_1: no server conversation");
|
|
3588
|
+
return [skillChain(chat.conversationId, chat.requestId)];
|
|
3589
|
+
}
|
|
3590
|
+
case "expert_5": return this.expertChains(credential, "agent", task);
|
|
3591
|
+
case "Expert_team_use_3": return this.expertChains(credential, "team", task);
|
|
3592
|
+
case "Expert_lighthouse": return this.lighthouseChains(credential);
|
|
3593
|
+
default: return [];
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
/**
|
|
3597
|
+
* The summon-and-use chains for the expert tasks.
|
|
3598
|
+
*
|
|
3599
|
+
* Two steps per expert, and both are load-bearing: the summon events alone are
|
|
3600
|
+
* impressions, and a use event on its own scores nothing because the scorer
|
|
3601
|
+
* looks the conversation up. Only a real chat with `X-Expert-Id` produces an
|
|
3602
|
+
* id it will accept.
|
|
3603
|
+
*/
|
|
3604
|
+
async expertChains(credential, expertType, task) {
|
|
3605
|
+
const needed = Math.max(0, task.target - task.current);
|
|
3606
|
+
if (needed === 0) return [];
|
|
3607
|
+
const experts = await this.client.marketExpertList(credential, expertType);
|
|
3608
|
+
const out = [];
|
|
3609
|
+
for (const expert of experts) {
|
|
3610
|
+
if (out.length >= needed) break;
|
|
3611
|
+
if (this.stopped) break;
|
|
3612
|
+
try {
|
|
3613
|
+
await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
|
|
3614
|
+
if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
|
|
3615
|
+
const chat = await this.client.openConversation(credential, expert.expertId);
|
|
3616
|
+
if (chat === void 0) continue;
|
|
3617
|
+
out.push({
|
|
3618
|
+
transport: "desktop",
|
|
3619
|
+
events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), expertActualUseEvent(expert, chat.conversationId, chat.requestId)]
|
|
3620
|
+
});
|
|
3621
|
+
} catch (error) {
|
|
3622
|
+
this.logger.warn?.(`automation events expert ${expert.expertId}:`, error);
|
|
3623
|
+
}
|
|
3624
|
+
if (this.expertGapMs > 0 && !this.stopped) await sleep(this.expertGapMs);
|
|
3625
|
+
}
|
|
3626
|
+
return out;
|
|
3627
|
+
}
|
|
3628
|
+
/**
|
|
3629
|
+
* The 腾讯轻量云 expert chain.
|
|
3630
|
+
*
|
|
3631
|
+
* Structurally the same as the expert task, with two differences the scorer
|
|
3632
|
+
* checks: `agent_task_created` has to name the expert, and the use event has
|
|
3633
|
+
* to report `mode: 'LOCAL'` with an empty type and zero cost — that is what
|
|
3634
|
+
* the lighthouse criterion looks for.
|
|
3635
|
+
*/
|
|
3636
|
+
async lighthouseChains(credential) {
|
|
3637
|
+
let expert = LIGHTHOUSE_EXPERT;
|
|
3638
|
+
try {
|
|
3639
|
+
const found = (await this.client.marketExpertList(credential, "agent")).find((item) => item.expertId === LIGHTHOUSE_EXPERT_ID);
|
|
3640
|
+
if (found !== void 0) expert = found;
|
|
3641
|
+
} catch (error) {
|
|
3642
|
+
this.logger.warn?.("automation events lighthouse list:", error);
|
|
3643
|
+
}
|
|
3644
|
+
await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
|
|
3645
|
+
if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
|
|
3646
|
+
const chat = await this.client.openConversation(credential, expert.expertId);
|
|
3647
|
+
if (chat === void 0) throw new Error("Expert_lighthouse: no server conversation");
|
|
3648
|
+
const use = expertActualUseEvent(expert, chat.conversationId, chat.requestId, "LOCAL");
|
|
3649
|
+
use["type"] = "";
|
|
3650
|
+
use["cost"] = 0;
|
|
3651
|
+
return [{
|
|
3652
|
+
transport: "desktop",
|
|
3653
|
+
events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), use]
|
|
3654
|
+
}];
|
|
3655
|
+
}
|
|
3656
|
+
/**
|
|
3657
|
+
* The task-centre pass for one account.
|
|
3658
|
+
*
|
|
3659
|
+
* Order matters: enrich first (enrol in everything open), then claim. Both
|
|
3660
|
+
* halves are idempotent — accepting an already-accepted task succeeds, and a
|
|
3661
|
+
* repeat claim answers `already_claimed` — so a pass that dies halfway is
|
|
3662
|
+
* safe to replay on the next tick.
|
|
3663
|
+
*/
|
|
3664
|
+
async runTasks(account) {
|
|
3665
|
+
const credential = account.credential;
|
|
3666
|
+
const open = (await this.client.listTasks(credential)).filter((task) => task.acceptStatus === "not_accepted" && !task.locked).map((task) => task.taskCode);
|
|
3667
|
+
if (open.length > 0) await this.client.acceptTasks(credential, open);
|
|
3668
|
+
const claimable = (await this.client.listTasks(credential)).filter((task) => task.claimable && !task.locked);
|
|
3669
|
+
let credit = 0;
|
|
3670
|
+
let energy = 0;
|
|
3671
|
+
let claimed = 0;
|
|
3672
|
+
for (const task of claimable) {
|
|
3673
|
+
if (this.stopped) break;
|
|
3674
|
+
const reward = await this.client.claimTaskReward(credential, task.taskCode);
|
|
3675
|
+
credit += reward.credit;
|
|
3676
|
+
energy += reward.energy;
|
|
3677
|
+
if (reward.credit > 0 || reward.energy > 0) {
|
|
3678
|
+
claimed++;
|
|
3679
|
+
this.logger.info?.(`automation claim ${account.label}: ${task.title} +${reward.credit}c +${reward.energy}e`);
|
|
3680
|
+
} else this.logger.info?.(`automation claim ${account.label}: ${task.taskCode} already claimed`);
|
|
3681
|
+
if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
|
|
3682
|
+
}
|
|
3683
|
+
return {
|
|
3684
|
+
claimed,
|
|
3685
|
+
credit,
|
|
3686
|
+
energy,
|
|
3687
|
+
claimableCount: claimable.length
|
|
3688
|
+
};
|
|
1409
3689
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
reasoning: map !== void 0,
|
|
1435
|
-
...map === void 0 ? {} : { thinkingLevelMap: map },
|
|
1436
|
-
compat: { supportsReasoningEffort: map !== void 0 }
|
|
1437
|
-
};
|
|
1438
|
-
}
|
|
1439
|
-
/**
|
|
1440
|
-
* Assemble the adapter. `getModels` re-reads the live catalog, and every
|
|
1441
|
-
* model's `baseUrl` is re-resolved per read so the shim's ephemeral port
|
|
1442
|
-
* applies from the first snapshot after startup. Call only after `shim.ready`.
|
|
1443
|
-
*/
|
|
1444
|
-
function createWorkBuddyAdapter(options) {
|
|
1445
|
-
const { shim, catalog } = options;
|
|
1446
|
-
const providerId = options.providerId ?? "workbuddy-xdpool";
|
|
1447
|
-
const displayName = options.displayName ?? "WorkBuddy XD Pool";
|
|
1448
|
-
const buildModels = () => {
|
|
1449
|
-
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
1450
|
-
return catalog.visible().map((info) => toPiModel(info, baseUrl, providerId));
|
|
1451
|
-
};
|
|
1452
|
-
const provider = {
|
|
1453
|
-
...createProvider({
|
|
1454
|
-
id: providerId,
|
|
1455
|
-
name: displayName,
|
|
1456
|
-
auth: { apiKey: {
|
|
1457
|
-
name: "WorkBuddy XD Pool loopback secret",
|
|
1458
|
-
async resolve({ credential }) {
|
|
1459
|
-
const apiKey = credential?.key;
|
|
1460
|
-
return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
|
|
1461
|
-
auth: { apiKey },
|
|
1462
|
-
source: "WorkBuddy XD Pool"
|
|
1463
|
-
};
|
|
1464
|
-
}
|
|
1465
|
-
} },
|
|
1466
|
-
models: buildModels(),
|
|
1467
|
-
api: openAICompletionsApi()
|
|
1468
|
-
}),
|
|
1469
|
-
getModels: () => buildModels()
|
|
1470
|
-
};
|
|
1471
|
-
const profile = {
|
|
1472
|
-
provider: providerId,
|
|
1473
|
-
displayName,
|
|
1474
|
-
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
1475
|
-
retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-xdpool retryPolicy"),
|
|
1476
|
-
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
1477
|
-
...REQUEST_IMAGE_BUDGETS,
|
|
1478
|
-
piProvider: provider
|
|
1479
|
-
};
|
|
1480
|
-
Object.assign(profile, { modelErrors: /* @__PURE__ */ new Map() });
|
|
1481
|
-
let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
1482
|
-
return {
|
|
1483
|
-
providerId,
|
|
1484
|
-
displayName,
|
|
1485
|
-
adapter: new PiAiAdapter({
|
|
1486
|
-
profiles: () => profiles,
|
|
1487
|
-
auth: INERT_AUTH,
|
|
1488
|
-
resolveApiKey: async () => shim.token(),
|
|
1489
|
-
resolveAttachments: () => options.ctx.get("attachments"),
|
|
1490
|
-
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => options.ctx.get("fs")?.processPathFromHostPath(hostPath), ref)
|
|
1491
|
-
}),
|
|
1492
|
-
buildModels,
|
|
1493
|
-
invalidate: () => {
|
|
1494
|
-
profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
3690
|
+
/**
|
|
3691
|
+
* Streak redemption plus the lottery it unlocks.
|
|
3692
|
+
*
|
|
3693
|
+
* Tiers unlock on consecutive active days (7/14/28). Redeeming one pays
|
|
3694
|
+
* credits, energy, a makeup card and — the part nothing else grants — lottery
|
|
3695
|
+
* draws, so the draw runs straight after and only for the chances in hand.
|
|
3696
|
+
*
|
|
3697
|
+
* Everything here is idempotent: a tier already claimed is skipped by its
|
|
3698
|
+
* status, and a draw consumes one chance, so a replay cannot double-spend.
|
|
3699
|
+
*/
|
|
3700
|
+
async redeemStreak(account) {
|
|
3701
|
+
const credential = account.credential;
|
|
3702
|
+
const status = await this.client.growthStreakFull(credential);
|
|
3703
|
+
for (const tier of status.tiers) {
|
|
3704
|
+
if (this.stopped) return;
|
|
3705
|
+
if (tier.status === "locked" || tier.status === "claimed") continue;
|
|
3706
|
+
try {
|
|
3707
|
+
await this.client.redeemStreakTier(credential, tier.tier);
|
|
3708
|
+
this.logger.info?.(`automation streak ${account.label}: redeemed ${tier.tier} (+${tier.credit}c +${tier.energy}e +${tier.chances} draw(s))`);
|
|
3709
|
+
this.recordEarnings(account.id, dayKey(this.now()), { bonusCredit: tier.credit });
|
|
3710
|
+
} catch (error) {
|
|
3711
|
+
this.logger.warn?.(`automation streak ${account.label}: redeem ${tier.tier} failed:`, error);
|
|
3712
|
+
}
|
|
3713
|
+
if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
|
|
1495
3714
|
}
|
|
1496
|
-
|
|
1497
|
-
|
|
3715
|
+
const chances = await this.client.lotteryChances(credential);
|
|
3716
|
+
for (let draw = 0; draw < chances; draw += 1) {
|
|
3717
|
+
if (this.stopped) return;
|
|
3718
|
+
try {
|
|
3719
|
+
const prize = await this.client.lotteryDraw(credential);
|
|
3720
|
+
this.logger.info?.(`automation lottery ${account.label}: draw ${draw + 1}/${chances} -> ${JSON.stringify(prize).slice(0, 120)}`);
|
|
3721
|
+
} catch (error) {
|
|
3722
|
+
this.logger.warn?.(`automation lottery ${account.label}: draw failed:`, error);
|
|
3723
|
+
break;
|
|
3724
|
+
}
|
|
3725
|
+
if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
/**
|
|
3729
|
+
* One trip through the buddy travel loop for an account.
|
|
3730
|
+
*
|
|
3731
|
+
* A single pass advances the state machine by at most one step: a trip
|
|
3732
|
+
* that has arrived is collected, and an idle buddy is sent out. A buddy
|
|
3733
|
+
* already travelling is left alone — there is nothing to do until it lands.
|
|
3734
|
+
*
|
|
3735
|
+
* Measured against the live upstream: the departed trip reports
|
|
3736
|
+
* `dailyLimitReached` immediately, so the once-a-day limit needs no local
|
|
3737
|
+
* bookkeeping.
|
|
3738
|
+
*/
|
|
3739
|
+
async runTravel(account) {
|
|
3740
|
+
const credential = account.credential;
|
|
3741
|
+
if (await this.client.buddyInfo(credential) === void 0) {
|
|
3742
|
+
try {
|
|
3743
|
+
await this.client.buddyAgree(credential);
|
|
3744
|
+
await this.client.buddyAdoptFirst(credential);
|
|
3745
|
+
this.logger.info?.(`automation travel ${account.label}: adopted first buddy`);
|
|
3746
|
+
} catch (error) {
|
|
3747
|
+
this.logger.info?.(`automation travel ${account.label}: adoption not available yet (` + String(error).slice(0, 80) + ")");
|
|
3748
|
+
}
|
|
3749
|
+
return;
|
|
3750
|
+
}
|
|
3751
|
+
const travel = await this.client.buddyTravelStatus(credential);
|
|
3752
|
+
if (travel.state === "arrived") {
|
|
3753
|
+
if (travel.recordId === 0) {
|
|
3754
|
+
this.logger.warn?.(`automation travel ${account.label}: arrived but no record id`);
|
|
3755
|
+
return;
|
|
3756
|
+
}
|
|
3757
|
+
const reward = await this.client.buddyTravelClaim(credential, travel.recordId);
|
|
3758
|
+
this.recordEarnings(account.id, dayKey(this.now()), { travelCredit: reward });
|
|
3759
|
+
this.logger.info?.(`automation travel ${account.label}: claimed trip +${reward}c`);
|
|
3760
|
+
return;
|
|
3761
|
+
}
|
|
3762
|
+
if (travel.state === "idle" && !travel.dailyLimitReached) {
|
|
3763
|
+
await this.client.buddyTravelDepart(credential);
|
|
3764
|
+
this.logger.info?.(`automation travel ${account.label}: departed (arrives later, claimed next pass)`);
|
|
3765
|
+
return;
|
|
3766
|
+
}
|
|
3767
|
+
this.logger.info?.(`automation travel ${account.label}: nothing to do (state=${travel.state})`);
|
|
3768
|
+
}
|
|
3769
|
+
};
|
|
1498
3770
|
//#endregion
|
|
1499
3771
|
//#region src/shim.ts
|
|
1500
3772
|
/**
|
|
@@ -1628,6 +3900,29 @@ function createWorkBuddyShim(options) {
|
|
|
1628
3900
|
if (address === null || typeof address === "string") throw new Error("workbuddy shim has no listening address");
|
|
1629
3901
|
return `http://127.0.0.1:${address.port}`;
|
|
1630
3902
|
};
|
|
3903
|
+
/**
|
|
3904
|
+
* Last balance refresh per account, so a busy account is not probed on
|
|
3905
|
+
* every request. Ten minutes is well inside the window where ordinary use
|
|
3906
|
+
* could cross a reserve.
|
|
3907
|
+
*/
|
|
3908
|
+
const lastBalanceAt = /* @__PURE__ */ new Map();
|
|
3909
|
+
const BALANCE_REFRESH_MS = 6e5;
|
|
3910
|
+
/**
|
|
3911
|
+
* Refresh one account known credit balance, best effort.
|
|
3912
|
+
*
|
|
3913
|
+
* Runs in the background after a successful request. Failures are swallowed
|
|
3914
|
+
* on purpose: a reserve is a safety feature, and a flaky balance lookup must
|
|
3915
|
+
* never become a failed user request or a noisy log.
|
|
3916
|
+
*/
|
|
3917
|
+
async function refreshBalance(account) {
|
|
3918
|
+
const now = Date.now();
|
|
3919
|
+
if (now - (lastBalanceAt.get(account.id) ?? 0) < BALANCE_REFRESH_MS) return;
|
|
3920
|
+
lastBalanceAt.set(account.id, now);
|
|
3921
|
+
try {
|
|
3922
|
+
const credits = await client.fetchCredits(account.credential);
|
|
3923
|
+
pool.noteCredits(account.id, credits.total);
|
|
3924
|
+
} catch {}
|
|
3925
|
+
}
|
|
1631
3926
|
async function handle(req, res) {
|
|
1632
3927
|
try {
|
|
1633
3928
|
if (!hostIsLoopback(req.headers.host)) {
|
|
@@ -1715,6 +4010,7 @@ function createWorkBuddyShim(options) {
|
|
|
1715
4010
|
if (result.ok) {
|
|
1716
4011
|
logger?.info?.(`dsh-workbuddy-xdpool: served by ${account.label}`);
|
|
1717
4012
|
pool.noteServed(account.id);
|
|
4013
|
+
refreshBalance(account);
|
|
1718
4014
|
res.writeHead(200, {
|
|
1719
4015
|
"Content-Type": "text/event-stream",
|
|
1720
4016
|
"Cache-Control": "no-cache",
|
|
@@ -1888,9 +4184,28 @@ const POOL_CHECKIN_PATH = "/plugins/dsh-workbuddy-xdpool/checkin";
|
|
|
1888
4184
|
const POOL_MODELS_SAVE_PATH = "/plugins/dsh-workbuddy-xdpool/models/save";
|
|
1889
4185
|
/** Switch one account in or out of the pool (card toggle). */
|
|
1890
4186
|
const POOL_ACCOUNT_DISABLE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/disabled";
|
|
4187
|
+
/** Run one automation job immediately, so the card can verify it on demand. */
|
|
4188
|
+
const POOL_AUTOMATION_RUN_PATH = "/plugins/dsh-workbuddy-xdpool/automation/run";
|
|
4189
|
+
/** Set or clear one account's reserved-credit floor. */
|
|
4190
|
+
const POOL_CREDIT_RESERVE_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/credit-reserve";
|
|
1891
4191
|
//#endregion
|
|
1892
4192
|
//#region src/web-status.ts
|
|
1893
4193
|
/** Redact token-like content before it crosses to the browser. */
|
|
4194
|
+
/**
|
|
4195
|
+
* A zeroed-out automation job record.
|
|
4196
|
+
*
|
|
4197
|
+
* Used when no scheduler is wired: the card renders the same shape either way,
|
|
4198
|
+
* so an unwired profile shows zeroes rather than a missing panel.
|
|
4199
|
+
*/
|
|
4200
|
+
function emptyAutomationJob() {
|
|
4201
|
+
return {
|
|
4202
|
+
ok: 0,
|
|
4203
|
+
failed: 0,
|
|
4204
|
+
credit: 0,
|
|
4205
|
+
energy: 0,
|
|
4206
|
+
claimed: 0
|
|
4207
|
+
};
|
|
4208
|
+
}
|
|
1894
4209
|
function safeMessage(error) {
|
|
1895
4210
|
return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
|
|
1896
4211
|
}
|
|
@@ -1997,7 +4312,42 @@ function parseAccountToggle(body, known) {
|
|
|
1997
4312
|
disabled
|
|
1998
4313
|
};
|
|
1999
4314
|
}
|
|
2000
|
-
|
|
4315
|
+
/**
|
|
4316
|
+
* Parse a manual-run request.
|
|
4317
|
+
*
|
|
4318
|
+
* The job name is checked against the real job list rather than passed through:
|
|
4319
|
+
* an unknown name would otherwise reach the scheduler and silently do nothing,
|
|
4320
|
+
* which reads to the user as a broken button.
|
|
4321
|
+
*/
|
|
4322
|
+
/**
|
|
4323
|
+
* Parse a reserved-credit update.
|
|
4324
|
+
*
|
|
4325
|
+
* The account must already be known, for the same reason the disable route
|
|
4326
|
+
* checks: an unknown id would sit in the settings file forever, attached to
|
|
4327
|
+
* nothing the card can act on. A negative or non-finite floor is rejected
|
|
4328
|
+
* rather than coerced.
|
|
4329
|
+
*/
|
|
4330
|
+
function parseCreditReserve(body, known) {
|
|
4331
|
+
const accountId = typeof body["accountId"] === "string" ? body["accountId"].trim() : "";
|
|
4332
|
+
const raw = body["reserve"];
|
|
4333
|
+
if (accountId === "" || typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return void 0;
|
|
4334
|
+
if (!known(accountId)) return void 0;
|
|
4335
|
+
return {
|
|
4336
|
+
accountId,
|
|
4337
|
+
reserve: Math.floor(raw)
|
|
4338
|
+
};
|
|
4339
|
+
}
|
|
4340
|
+
function parseAutomationRun(body) {
|
|
4341
|
+
const job = typeof body["job"] === "string" ? body["job"].trim() : "";
|
|
4342
|
+
if (job !== "all" && !isAutomationJobKind(job)) return void 0;
|
|
4343
|
+
const force = body["force"];
|
|
4344
|
+
if (force !== void 0 && typeof force !== "boolean") return void 0;
|
|
4345
|
+
return {
|
|
4346
|
+
job,
|
|
4347
|
+
...force === void 0 ? {} : { force }
|
|
4348
|
+
};
|
|
4349
|
+
}
|
|
4350
|
+
function toWebAccount(account, disabled, reserve, reserved) {
|
|
2001
4351
|
const now = Date.now();
|
|
2002
4352
|
const cooling = account.cooldownUntilMs > now;
|
|
2003
4353
|
const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
|
|
@@ -2014,6 +4364,8 @@ function toWebAccount(account, disabled) {
|
|
|
2014
4364
|
...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
|
|
2015
4365
|
...modelCooldowns.length === 0 ? {} : { modelCooldowns },
|
|
2016
4366
|
disabled,
|
|
4367
|
+
creditReserve: reserve,
|
|
4368
|
+
reserved,
|
|
2017
4369
|
rateLimitHits: account.rateLimitHits
|
|
2018
4370
|
};
|
|
2019
4371
|
}
|
|
@@ -2046,10 +4398,13 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
2046
4398
|
const rows = [];
|
|
2047
4399
|
const now = Date.now();
|
|
2048
4400
|
for (const account of accounts) {
|
|
2049
|
-
const row = toWebAccount(account, deps.pool.isDisabled(account.id));
|
|
4401
|
+
const row = toWebAccount(account, deps.pool.isDisabled(account.id), deps.pool.creditReserveOf(account.id), deps.pool.isReserved(account.id));
|
|
4402
|
+
const earned = deps.scheduler?.().earningsToday[account.id];
|
|
4403
|
+
if (earned !== void 0) Object.assign(row, { automationToday: earned });
|
|
2050
4404
|
if (!row.cooling) {
|
|
2051
4405
|
try {
|
|
2052
4406
|
const credits = await deps.client.fetchCredits(account.credential);
|
|
4407
|
+
deps.pool.noteCredits(account.id, credits.total);
|
|
2053
4408
|
Object.assign(row, { credits: {
|
|
2054
4409
|
total: credits.total,
|
|
2055
4410
|
packages: credits.packages,
|
|
@@ -2088,6 +4443,25 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
2088
4443
|
} catch {
|
|
2089
4444
|
shim = { running: false };
|
|
2090
4445
|
}
|
|
4446
|
+
const automation = deps.scheduler?.() ?? {
|
|
4447
|
+
enabled: false,
|
|
4448
|
+
running: false,
|
|
4449
|
+
checkinHours: [],
|
|
4450
|
+
reportHours: [],
|
|
4451
|
+
taskHours: [],
|
|
4452
|
+
streakHours: [],
|
|
4453
|
+
travelHours: [],
|
|
4454
|
+
jobs: {
|
|
4455
|
+
checkin: emptyAutomationJob(),
|
|
4456
|
+
report: emptyAutomationJob(),
|
|
4457
|
+
tasks: emptyAutomationJob(),
|
|
4458
|
+
streak: emptyAutomationJob(),
|
|
4459
|
+
travel: emptyAutomationJob()
|
|
4460
|
+
},
|
|
4461
|
+
claimableSeen: 0,
|
|
4462
|
+
earningsToday: {},
|
|
4463
|
+
runInProgress: false
|
|
4464
|
+
};
|
|
2091
4465
|
return {
|
|
2092
4466
|
ok: accounts.length > 0 && cooling < accounts.length,
|
|
2093
4467
|
accounts: rows,
|
|
@@ -2108,7 +4482,9 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
2108
4482
|
region,
|
|
2109
4483
|
distribution: deps.pool.currentDistribution(),
|
|
2110
4484
|
regions,
|
|
2111
|
-
shim
|
|
4485
|
+
shim,
|
|
4486
|
+
automation,
|
|
4487
|
+
creditReserves: deps.pool.creditReservesInOrder()
|
|
2112
4488
|
};
|
|
2113
4489
|
}
|
|
2114
4490
|
/**
|
|
@@ -2231,7 +4607,66 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
2231
4607
|
}
|
|
2232
4608
|
}
|
|
2233
4609
|
});
|
|
4610
|
+
/**
|
|
4611
|
+
* Run one automation job on demand.
|
|
4612
|
+
*
|
|
4613
|
+
* POST only, loopback origin only, and the job name must be one of the four
|
|
4614
|
+
* real jobs, so the card cannot name an arbitrary job. This exists so the
|
|
4615
|
+
* automation is verifiable without waiting for its scheduled hour.
|
|
4616
|
+
*/
|
|
4617
|
+
const disposeAutomationRun = ctx.webServer.register({
|
|
4618
|
+
kind: "exact",
|
|
4619
|
+
path: POOL_AUTOMATION_RUN_PATH,
|
|
4620
|
+
handler: async (req, res) => {
|
|
4621
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
4622
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
4623
|
+
if (deps.runAutomation === void 0) return json(res, 503, { error: "automation is not available in this build" });
|
|
4624
|
+
try {
|
|
4625
|
+
const run = parseAutomationRun(await readJsonBody(req));
|
|
4626
|
+
if (run === void 0) return json(res, 400, { error: "invalid automation run payload" });
|
|
4627
|
+
const started = deps.runAutomation(run.job, run.force === true);
|
|
4628
|
+
json(res, 200, {
|
|
4629
|
+
ok: true,
|
|
4630
|
+
job: run.job,
|
|
4631
|
+
started
|
|
4632
|
+
});
|
|
4633
|
+
} catch (error) {
|
|
4634
|
+
json(res, 500, { error: safeMessage(error) });
|
|
4635
|
+
}
|
|
4636
|
+
}
|
|
4637
|
+
});
|
|
4638
|
+
/**
|
|
4639
|
+
* Set or clear one account's reserved-credit floor.
|
|
4640
|
+
*
|
|
4641
|
+
* POST only, loopback origin only, and the id must already be a known
|
|
4642
|
+
* account: the card cannot invent an id, and a stale tab must not leave
|
|
4643
|
+
* orphan keys in the settings file.
|
|
4644
|
+
*/
|
|
4645
|
+
const disposeCreditReserve = ctx.webServer.register({
|
|
4646
|
+
kind: "exact",
|
|
4647
|
+
path: POOL_CREDIT_RESERVE_PATH,
|
|
4648
|
+
handler: async (req, res) => {
|
|
4649
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
4650
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
4651
|
+
if (deps.setCreditReserve === void 0) return json(res, 503, { error: "settings service unavailable; the reserve cannot be saved" });
|
|
4652
|
+
try {
|
|
4653
|
+
const body = await readJsonBody(req);
|
|
4654
|
+
const known = new Set(deps.pool.list().map((account) => account.id));
|
|
4655
|
+
const parsed = parseCreditReserve(body, (id) => known.has(id));
|
|
4656
|
+
if (parsed === void 0) return json(res, 400, { error: "invalid credit reserve payload" });
|
|
4657
|
+
await deps.setCreditReserve(parsed.accountId, parsed.reserve);
|
|
4658
|
+
json(res, 200, {
|
|
4659
|
+
ok: true,
|
|
4660
|
+
...parsed
|
|
4661
|
+
});
|
|
4662
|
+
} catch (error) {
|
|
4663
|
+
json(res, 500, { error: safeMessage(error) });
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4666
|
+
});
|
|
2234
4667
|
return () => {
|
|
4668
|
+
disposeAutomationRun();
|
|
4669
|
+
disposeCreditReserve();
|
|
2235
4670
|
disposeCheckin();
|
|
2236
4671
|
disposeAccountDisable();
|
|
2237
4672
|
disposeModelsSave();
|
|
@@ -2264,6 +4699,23 @@ const WORKBUDDY_POOL_SETTINGS_NS = "workbuddy-xdpool";
|
|
|
2264
4699
|
/** Upper bound the card offers as the "default" context window, in tokens. */
|
|
2265
4700
|
const DEFAULT_CONTEXT_BUDGET = 2e5;
|
|
2266
4701
|
/**
|
|
4702
|
+
* Fold a saved automation block into scheduler options.
|
|
4703
|
+
*
|
|
4704
|
+
* Absent means off, stated once here so every caller agrees: the card writes
|
|
4705
|
+
* `enabled` as a real boolean, and a config that never touched the section must
|
|
4706
|
+
* not accidentally arm background upstream traffic.
|
|
4707
|
+
*/
|
|
4708
|
+
function automationOptions(automation) {
|
|
4709
|
+
return {
|
|
4710
|
+
enabled: automation?.enabled === true,
|
|
4711
|
+
...automation?.checkinHours === void 0 ? {} : { checkinHours: automation.checkinHours },
|
|
4712
|
+
...automation?.reportHours === void 0 ? {} : { reportHours: automation.reportHours },
|
|
4713
|
+
...automation?.taskHours === void 0 ? {} : { taskHours: automation.taskHours },
|
|
4714
|
+
...automation?.streakHours === void 0 ? {} : { streakHours: automation.streakHours },
|
|
4715
|
+
...automation?.travelHours === void 0 ? {} : { travelHours: automation.travelHours }
|
|
4716
|
+
};
|
|
4717
|
+
}
|
|
4718
|
+
/**
|
|
2267
4719
|
* One region's model-selection schema.
|
|
2268
4720
|
*
|
|
2269
4721
|
* Every field is optional on purpose: an absent field keeps its documented
|
|
@@ -2276,6 +4728,27 @@ const modelSelectionSchema = z.object({
|
|
|
2276
4728
|
imageModelIds: z.array(z.string()).description("Model ids accepting image input in this region (absent = follow upstream)"),
|
|
2277
4729
|
contextBudgets: z.dict(z.number().step(1).min(1)).description("Per-model context-window override for this region")
|
|
2278
4730
|
});
|
|
4731
|
+
/**
|
|
4732
|
+
* Automation schema.
|
|
4733
|
+
*
|
|
4734
|
+
* `enabled` carries a real default (false) because the scheduler reads it on
|
|
4735
|
+
* every tick and a missing field must mean "off" rather than "undefined".
|
|
4736
|
+
* The hour lists fall back in the scheduler itself, so they stay optional here
|
|
4737
|
+
* and an absent list keeps the documented schedule.
|
|
4738
|
+
*
|
|
4739
|
+
* `exhaustCooldownMs` is mirrored from the pool options: the card offers it as
|
|
4740
|
+
* part of the automation block, since how long a spent account rests only
|
|
4741
|
+
* matters to the automation that has to work around it.
|
|
4742
|
+
*/
|
|
4743
|
+
const automationSchema = z.object({
|
|
4744
|
+
enabled: z.boolean().default(false).description("Run the daily points automation"),
|
|
4745
|
+
checkinHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the daily check-in"),
|
|
4746
|
+
reportHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the activity report (runs before tasks)"),
|
|
4747
|
+
taskHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for task enrolment and claiming"),
|
|
4748
|
+
streakHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for streak redemption"),
|
|
4749
|
+
travelHours: z.array(z.number().step(1).min(0).max(23)).description("Local hours for the buddy travel loop"),
|
|
4750
|
+
exhaustCooldownMs: z.number().step(1).min(1e3).description("How long a spent account rests, in milliseconds")
|
|
4751
|
+
});
|
|
2279
4752
|
/** Settings key holding one region's saved selection. */
|
|
2280
4753
|
const modelSelectionKeyFor = (region) => region === "cn" ? "modelSelectionCn" : "modelSelectionGlobal";
|
|
2281
4754
|
/**
|
|
@@ -2291,13 +4764,20 @@ const modelSelectionKeyFor = (region) => region === "cn" ? "modelSelectionCn" :
|
|
|
2291
4764
|
const Config = z.object({
|
|
2292
4765
|
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app own location)"),
|
|
2293
4766
|
cooldownMs: z.number().step(1).min(1e3).default(6e4).description("Rate-limit cooldown per account, in milliseconds"),
|
|
2294
|
-
distribution: z.union([
|
|
4767
|
+
distribution: z.union([
|
|
4768
|
+
"priority",
|
|
4769
|
+
"round-robin",
|
|
4770
|
+
"balanced"
|
|
4771
|
+
]).default("priority").description("How requests are spread: priority (drain one), round-robin (in order), or balanced (idle-weighted random)"),
|
|
2295
4772
|
disabledAccountIds: z.array(z.string()).default([]).description("Account ids excluded from the pool (empty = every discovered account participates)"),
|
|
4773
|
+
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"),
|
|
2296
4774
|
enabledModelIds: z.array(z.string()).default([]).description("Legacy shared model-id list; used by a region that has no per-region selection yet"),
|
|
2297
4775
|
imageModelIds: z.array(z.string()).default([]).description("Legacy shared image-id list; used by a region that has no per-region selection yet"),
|
|
2298
4776
|
contextBudgets: z.dict(z.number().step(1).min(1)).default({}).description("Legacy shared context budgets; used by a region with no per-region selection yet"),
|
|
2299
4777
|
modelSelectionCn: modelSelectionSchema.description("Model selection for the domestic gateway"),
|
|
2300
|
-
modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway")
|
|
4778
|
+
modelSelectionGlobal: modelSelectionSchema.description("Model selection for the international gateway"),
|
|
4779
|
+
automation: automationSchema.description("Daily points automation (activity report, task claiming, check-in)"),
|
|
4780
|
+
automationEarnings: z.any().description("Automation earnings ledger (written by the scheduler)")
|
|
2301
4781
|
});
|
|
2302
4782
|
/** Live API, published for the CLI. */
|
|
2303
4783
|
let api;
|
|
@@ -2320,16 +4800,18 @@ function setApi(next) {
|
|
|
2320
4800
|
*/
|
|
2321
4801
|
function createCore(logger) {
|
|
2322
4802
|
const client = new WorkBuddyUpstreamClient();
|
|
4803
|
+
const pool = new WorkBuddyAccountPool({
|
|
4804
|
+
...logger === void 0 ? {} : { logger },
|
|
4805
|
+
client
|
|
4806
|
+
});
|
|
2323
4807
|
return {
|
|
2324
|
-
pool
|
|
2325
|
-
...logger === void 0 ? {} : { logger },
|
|
2326
|
-
client
|
|
2327
|
-
}),
|
|
4808
|
+
pool,
|
|
2328
4809
|
catalogs: {
|
|
2329
4810
|
cn: new WorkBuddyCatalog(),
|
|
2330
4811
|
global: new WorkBuddyCatalog()
|
|
2331
4812
|
},
|
|
2332
|
-
client
|
|
4813
|
+
client,
|
|
4814
|
+
scheduler: new WorkBuddyScheduler(pool, client, { ...logger === void 0 ? {} : { logger } })
|
|
2333
4815
|
};
|
|
2334
4816
|
}
|
|
2335
4817
|
/**
|
|
@@ -2362,12 +4844,14 @@ function apply(ctx, config = {}) {
|
|
|
2362
4844
|
}
|
|
2363
4845
|
};
|
|
2364
4846
|
const applyConfigFromSource = () => {
|
|
2365
|
-
const { authFile, cooldownMs, distribution, disabledAccountIds, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal } = current();
|
|
4847
|
+
const { authFile, cooldownMs, distribution, disabledAccountIds, creditReserves, enabledModelIds, imageModelIds, contextBudgets, modelSelectionCn, modelSelectionGlobal, automation } = current();
|
|
2366
4848
|
core.pool.applyConfig({
|
|
2367
4849
|
...authFile === void 0 ? {} : { authDirs: [dirname(authFile)] },
|
|
2368
4850
|
...cooldownMs === void 0 ? {} : { cooldownMs },
|
|
2369
4851
|
distribution: distribution ?? "priority",
|
|
2370
|
-
...disabledAccountIds === void 0 ? {} : { disabledAccountIds }
|
|
4852
|
+
...disabledAccountIds === void 0 ? {} : { disabledAccountIds },
|
|
4853
|
+
...creditReserves === void 0 ? {} : { creditReserves },
|
|
4854
|
+
...automation?.exhaustCooldownMs === void 0 ? {} : { exhaustCooldownMs: automation.exhaustCooldownMs }
|
|
2371
4855
|
});
|
|
2372
4856
|
const legacySelection = {
|
|
2373
4857
|
...enabledModelIds === void 0 ? {} : { enabledModelIds },
|
|
@@ -2376,6 +4860,7 @@ function apply(ctx, config = {}) {
|
|
|
2376
4860
|
};
|
|
2377
4861
|
core.catalogs.cn.applySelection(modelSelectionCn ?? legacySelection);
|
|
2378
4862
|
core.catalogs.global.applySelection(modelSelectionGlobal ?? legacySelection);
|
|
4863
|
+
core.scheduler.applyConfig(automationOptions(automation));
|
|
2379
4864
|
};
|
|
2380
4865
|
const settingsService = ctx.settings;
|
|
2381
4866
|
if (typeof settingsService.installSection === "function") settingsService.installSection(ctx, WORKBUDDY_POOL_SETTINGS_NS, Config, config, sectionHooks);
|
|
@@ -2411,6 +4896,11 @@ function apply(ctx, config = {}) {
|
|
|
2411
4896
|
ctx.logger.warn?.(`dsh-workbuddy-xdpool: failed to persist ` + key, error);
|
|
2412
4897
|
}
|
|
2413
4898
|
};
|
|
4899
|
+
core.scheduler.setEarningsPersistence((ledger) => {
|
|
4900
|
+
setSetting("automationEarnings", ledger);
|
|
4901
|
+
});
|
|
4902
|
+
const storedLedger = current().automationEarnings;
|
|
4903
|
+
if (storedLedger !== void 0) core.scheduler.applyEarningsLedger(storedLedger);
|
|
2414
4904
|
const shims = {
|
|
2415
4905
|
cn: createWorkBuddyShim({
|
|
2416
4906
|
pool: core.pool,
|
|
@@ -2444,12 +4934,15 @@ function apply(ctx, config = {}) {
|
|
|
2444
4934
|
stopped = true;
|
|
2445
4935
|
shims.cn.close();
|
|
2446
4936
|
shims.global.close();
|
|
4937
|
+
core.scheduler.stop();
|
|
2447
4938
|
});
|
|
2448
4939
|
ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
|
|
2449
4940
|
pool: core.pool,
|
|
2450
4941
|
catalogs: core.catalogs,
|
|
2451
4942
|
client: core.client,
|
|
2452
4943
|
shim: () => shimInfo("cn"),
|
|
4944
|
+
scheduler: () => core.scheduler.status(),
|
|
4945
|
+
runAutomation: (_job, _force) => core.scheduler.startRunAll(),
|
|
2453
4946
|
saveSelection: (region, selection) => {
|
|
2454
4947
|
setSetting(modelSelectionKeyFor(region), {
|
|
2455
4948
|
...selection.enabledModelIds === void 0 ? {} : { enabledModelIds: [...selection.enabledModelIds] },
|
|
@@ -2461,6 +4954,12 @@ function apply(ctx, config = {}) {
|
|
|
2461
4954
|
const currentIds = current().disabledAccountIds ?? [];
|
|
2462
4955
|
const next = disabled ? currentIds.includes(accountId) ? currentIds : [...currentIds, accountId] : currentIds.filter((id) => id !== accountId);
|
|
2463
4956
|
setSetting("disabledAccountIds", next);
|
|
4957
|
+
},
|
|
4958
|
+
setCreditReserve: (accountId, reserve) => {
|
|
4959
|
+
const next = { ...current().creditReserves ?? {} };
|
|
4960
|
+
if (reserve > 0) next[accountId] = reserve;
|
|
4961
|
+
else delete next[accountId];
|
|
4962
|
+
setSetting("creditReserves", next);
|
|
2464
4963
|
}
|
|
2465
4964
|
}));
|
|
2466
4965
|
api = {
|
|
@@ -2487,6 +4986,7 @@ function apply(ctx, config = {}) {
|
|
|
2487
4986
|
core.pool.resetCooldowns();
|
|
2488
4987
|
}
|
|
2489
4988
|
};
|
|
4989
|
+
core.scheduler.start();
|
|
2490
4990
|
Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {
|
|
2491
4991
|
if (stopped) return;
|
|
2492
4992
|
try {
|
|
@@ -2591,4 +5091,4 @@ function apply(ctx, config = {}) {
|
|
|
2591
5091
|
});
|
|
2592
5092
|
}
|
|
2593
5093
|
//#endregion
|
|
2594
|
-
export { Config, DEFAULT_CONTEXT_BUDGET, FALLBACK_WORKBUDDY_MODELS, POOL_CHECKIN_PATH, POOL_MODELS_SAVE_PATH, POOL_RESCAN_PATH, POOL_RESET_COOLDOWN_PATH, POOL_STATUS_PATH, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_LIVE_FILENAME, WORKBUDDY_POOL_PROVIDER, WORKBUDDY_POOL_SETTINGS_NS, WorkBuddyAccountPool, WorkBuddyCatalog, WorkBuddyUpstreamClient, apply, buildStatus, candidateAuthDirs, classifyUpstreamError, createCore, createWorkBuddyAdapter, createWorkBuddyShim, currentApi, defaultDesktopAuthDirs, formatRates, formatStatus, inject, modelSelectionKeyFor, name, parseRateLimitReset, parseWorkBuddyAuth, setApi, workbuddyAccountId };
|
|
5094
|
+
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 };
|