dsh-xdpool 2.5.0 → 2.5.1
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 +41 -52
- package/README.en.md +11 -2
- package/README.md +12 -3
- package/assets/community-qr.png +0 -0
- package/cordis.patch.yml +17 -41
- package/lib/bin.js +194 -30
- package/lib/client.js +285 -774
- package/lib/index.d.ts +54 -82
- package/lib/index.js +276 -409
- package/package.json +1 -4
- package/vendor/channel-pack/pack.js +8 -124
package/lib/index.js
CHANGED
|
@@ -15,6 +15,15 @@ import { env } from "node:process";
|
|
|
15
15
|
import http, { createServer, request } from "node:http";
|
|
16
16
|
import https, { request as request$1 } from "node:https";
|
|
17
17
|
import { Readable, Transform } from "node:stream";
|
|
18
|
+
//#region src/credit-reserve.ts
|
|
19
|
+
/** Build a merge-safe reserve update. Zero overrides inherited/previous floors. */
|
|
20
|
+
function creditReservesWith(current, accountId, reserve) {
|
|
21
|
+
return {
|
|
22
|
+
...current,
|
|
23
|
+
[accountId]: reserve
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
18
27
|
//#region src/badges.ts
|
|
19
28
|
/**
|
|
20
29
|
* Promotion badges the upstream attaches to a model.
|
|
@@ -1153,7 +1162,8 @@ function parseTask(value) {
|
|
|
1153
1162
|
status: typeof raw["status"] === "string" ? raw["status"] : "",
|
|
1154
1163
|
claimable: !claimed && target > 0 && current >= target,
|
|
1155
1164
|
claimed,
|
|
1156
|
-
locked: raw["locked"] === true
|
|
1165
|
+
locked: raw["locked"] === true,
|
|
1166
|
+
jumpUrl: typeof raw["jump_url"] === "string" ? raw["jump_url"] : ""
|
|
1157
1167
|
};
|
|
1158
1168
|
}
|
|
1159
1169
|
var WorkBuddyUpstreamClient = class {
|
|
@@ -1514,17 +1524,14 @@ var WorkBuddyUpstreamClient = class {
|
|
|
1514
1524
|
if (typeof raw !== "object" || raw === null) continue;
|
|
1515
1525
|
const account = raw;
|
|
1516
1526
|
const num = (key) => typeof account[key] === "number" ? account[key] : 0;
|
|
1517
|
-
const
|
|
1518
|
-
const
|
|
1519
|
-
const
|
|
1520
|
-
const size = periodic ? num("CycleCapacitySize") : num("CapacitySize");
|
|
1521
|
-
const remain = periodic ? num("CycleCapacityRemain") : num("CapacityRemain");
|
|
1527
|
+
const monthly = num("CapacityType") === 4;
|
|
1528
|
+
const size = monthly ? num("CycleCapacitySize") : num("CapacitySize");
|
|
1529
|
+
const remain = monthly ? num("CycleCapacityRemain") : num("CapacityRemain");
|
|
1522
1530
|
const capped = remain < 0 ? 0 : remain;
|
|
1523
1531
|
const cycleEndMs = parseDate(account["CycleEndTime"]);
|
|
1524
|
-
const expiresAtMs = monthly ? void 0 :
|
|
1532
|
+
const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
|
|
1525
1533
|
const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
|
|
1526
|
-
if (expiresAtMs !== void 0 && expiresAtMs <= Date.now()) continue;
|
|
1527
|
-
if (!periodic && capped <= 0) continue;
|
|
1534
|
+
if (!monthly && (capped <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
|
|
1528
1535
|
total += capped;
|
|
1529
1536
|
if (expiresAtMs !== void 0) {
|
|
1530
1537
|
if (nearestExpiryMs === void 0 || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs;
|
|
@@ -3872,11 +3879,11 @@ var WorkBuddyAccountPool = class {
|
|
|
3872
3879
|
* model id the legacy account-wide check applies (callers that cannot name a
|
|
3873
3880
|
* model, e.g. CLI diagnostics).
|
|
3874
3881
|
*/
|
|
3875
|
-
available(now, modelId, region) {
|
|
3882
|
+
available(now, modelId, region, reserveExempt) {
|
|
3876
3883
|
return this.accounts.filter((account) => {
|
|
3877
3884
|
if (this.disabledIds.has(account.id)) return false;
|
|
3878
3885
|
const reserve = this.creditReserves.get(account.id);
|
|
3879
|
-
if (reserve !== void 0 && reserve > 0) {
|
|
3886
|
+
if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
|
|
3880
3887
|
const balance = this.creditBalances.get(account.id);
|
|
3881
3888
|
if (balance !== void 0 && balance <= reserve) return false;
|
|
3882
3889
|
}
|
|
@@ -3900,7 +3907,7 @@ var WorkBuddyAccountPool = class {
|
|
|
3900
3907
|
* Counts are over the region's accounts, since a provider only ever sees its
|
|
3901
3908
|
* own gateway.
|
|
3902
3909
|
*/
|
|
3903
|
-
unavailableReason(modelId, region) {
|
|
3910
|
+
unavailableReason(modelId, region, reserveExempt) {
|
|
3904
3911
|
const now = Date.now();
|
|
3905
3912
|
const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
|
|
3906
3913
|
if (inRegion.length === 0) return {
|
|
@@ -3913,6 +3920,29 @@ var WorkBuddyAccountPool = class {
|
|
|
3913
3920
|
let cooling = 0;
|
|
3914
3921
|
let disabled = 0;
|
|
3915
3922
|
let dead = 0;
|
|
3923
|
+
/**
|
|
3924
|
+
* Accounts that survived every OTHER filter and are held back only by their
|
|
3925
|
+
* credit floor.
|
|
3926
|
+
*
|
|
3927
|
+
* Counted rather than returned on the first hit. The old `return` fired the
|
|
3928
|
+
* moment it met one reserved account, so a single account parked under its
|
|
3929
|
+
* floor reported `reserve` for the whole gateway — even while four other
|
|
3930
|
+
* accounts were healthy and had just been tried. The caller turns
|
|
3931
|
+
* `reason === 'reserve'` into a 402 telling the user to lower the reserve,
|
|
3932
|
+
* which pointed at a setting that was not the problem (#98: a five-account
|
|
3933
|
+
* pool reported `reserve_floor` because of one account that had sat below
|
|
3934
|
+
* its floor for weeks, while the actual state was "the rest are cooling").
|
|
3935
|
+
*/
|
|
3936
|
+
let reserved = 0;
|
|
3937
|
+
/**
|
|
3938
|
+
* Accounts that reached the end of the walk unblocked.
|
|
3939
|
+
*
|
|
3940
|
+
* Non-zero means the pool still has somewhere to send a request, so a
|
|
3941
|
+
* reserve is NOT what this answer is about: editing a number cannot fix a
|
|
3942
|
+
* request that some other account is already able to serve, and saying
|
|
3943
|
+
* otherwise sends the reader to the wrong setting.
|
|
3944
|
+
*/
|
|
3945
|
+
let usable = 0;
|
|
3916
3946
|
for (const account of inRegion) {
|
|
3917
3947
|
if (this.disabledIds.has(account.id)) {
|
|
3918
3948
|
disabled += 1;
|
|
@@ -3928,16 +3958,14 @@ var WorkBuddyAccountPool = class {
|
|
|
3928
3958
|
continue;
|
|
3929
3959
|
}
|
|
3930
3960
|
const reserve = this.creditReserves.get(account.id);
|
|
3931
|
-
if (reserve !== void 0 && reserve > 0) {
|
|
3961
|
+
if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
|
|
3932
3962
|
const balance = this.creditBalances.get(account.id);
|
|
3933
|
-
if (balance !== void 0 && balance <= reserve)
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
dead,
|
|
3938
|
-
reason: "reserve"
|
|
3939
|
-
};
|
|
3963
|
+
if (balance !== void 0 && balance <= reserve) {
|
|
3964
|
+
reserved += 1;
|
|
3965
|
+
continue;
|
|
3966
|
+
}
|
|
3940
3967
|
}
|
|
3968
|
+
usable += 1;
|
|
3941
3969
|
}
|
|
3942
3970
|
if (dead > 0) return {
|
|
3943
3971
|
total: inRegion.length,
|
|
@@ -3960,6 +3988,13 @@ var WorkBuddyAccountPool = class {
|
|
|
3960
3988
|
dead,
|
|
3961
3989
|
reason: "disabled"
|
|
3962
3990
|
};
|
|
3991
|
+
if (usable === 0 && reserved > 0) return {
|
|
3992
|
+
total: inRegion.length,
|
|
3993
|
+
cooling,
|
|
3994
|
+
disabled,
|
|
3995
|
+
dead,
|
|
3996
|
+
reason: "reserve"
|
|
3997
|
+
};
|
|
3963
3998
|
return {
|
|
3964
3999
|
total: inRegion.length,
|
|
3965
4000
|
cooling,
|
|
@@ -4136,16 +4171,18 @@ var WorkBuddyAccountPool = class {
|
|
|
4136
4171
|
* Scans on first use, and rescans when every known account is cooling down: a
|
|
4137
4172
|
* fresh desktop login is the usual way out of an exhausted pool.
|
|
4138
4173
|
*
|
|
4174
|
+
* `reserveExempt` is host-only: account ids whose own credentials freshly
|
|
4175
|
+
* confirmed a zero-credit model. It never bypasses disabled, dead credentials, cooldowns or region.
|
|
4139
4176
|
* `conversationKey` is only consulted under `sticky`; other modes ignore it.
|
|
4140
4177
|
*/
|
|
4141
|
-
async acquire(modelId, region, conversationKey) {
|
|
4178
|
+
async acquire(modelId, region, conversationKey, reserveExempt) {
|
|
4142
4179
|
if (this.accounts.length === 0) await this.scan();
|
|
4143
4180
|
const maxTries = this.accounts.length + 1;
|
|
4144
4181
|
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
4145
|
-
let pool = this.available(Date.now(), modelId, region);
|
|
4182
|
+
let pool = this.available(Date.now(), modelId, region, reserveExempt);
|
|
4146
4183
|
if (pool.length === 0 && attempt === 0) {
|
|
4147
4184
|
await this.scan();
|
|
4148
|
-
pool = this.available(Date.now(), modelId, region);
|
|
4185
|
+
pool = this.available(Date.now(), modelId, region, reserveExempt);
|
|
4149
4186
|
}
|
|
4150
4187
|
if (pool.length === 0) return void 0;
|
|
4151
4188
|
if (this.preferredAccountId !== void 0) {
|
|
@@ -4998,22 +5035,6 @@ const POOL_COMBOS_SAVE_PATH = "/plugins/dsh-xdpool/combos/save";
|
|
|
4998
5035
|
*/
|
|
4999
5036
|
const POOL_RELAY_PATH = "/plugins/dsh-xdpool/relay";
|
|
5000
5037
|
/**
|
|
5001
|
-
* What the locally running billion-context proxy is doing, read-only.
|
|
5002
|
-
*
|
|
5003
|
-
* Its own route because the answer comes from ANOTHER plugin: billion-context
|
|
5004
|
-
* runs its own loopback listener and publishes its own JSON, so this route only
|
|
5005
|
-
* finds that listener and relays a filtered subset. Nothing here is folded into
|
|
5006
|
-
* {@link POOL_STATUS_PATH}: the pool card polls that every 30 seconds, and a
|
|
5007
|
-
* third-party proxy's session table is not something every pool poll should
|
|
5008
|
-
* carry.
|
|
5009
|
-
*
|
|
5010
|
-
* The word "read-only" is load-bearing on both ends. This plugin sends GETs
|
|
5011
|
-
* only — it never configures, restarts or writes to the proxy — and the payload
|
|
5012
|
-
* it relays deliberately omits every field that could carry conversation
|
|
5013
|
-
* material.
|
|
5014
|
-
*/
|
|
5015
|
-
const POOL_BILLION_PATH = "/plugins/dsh-xdpool/billion";
|
|
5016
|
-
/**
|
|
5017
5038
|
* Read the usage panel's aggregate.
|
|
5018
5039
|
*
|
|
5019
5040
|
* GET only, and token-free by construction: the recorder holds counts and
|
|
@@ -12230,65 +12251,6 @@ function createWorkBuddyAdapter(options) {
|
|
|
12230
12251
|
};
|
|
12231
12252
|
}
|
|
12232
12253
|
//#endregion
|
|
12233
|
-
//#region src/billion-mount.ts
|
|
12234
|
-
/** The package name the loader resolves, exactly as our patch row names it. */
|
|
12235
|
-
const BILLION_PACKAGE = "billion-context";
|
|
12236
|
-
/**
|
|
12237
|
-
* Is this package installed and able to run?
|
|
12238
|
-
*
|
|
12239
|
-
* Any entry whose name matches counts — including one whose fiber has not
|
|
12240
|
-
* started yet. When this function still decided whether to mount, a fiber-less
|
|
12241
|
-
* entry had to be treated as absent: treating an import-in-flight as present
|
|
12242
|
-
* would have skipped the mount and left the compressor never starting. Now that
|
|
12243
|
-
* nothing mounts, the distinction only picks a label, and the honest one is
|
|
12244
|
-
* "installed": a user watching billion-context's own boot does not want the
|
|
12245
|
-
* card telling them it is missing while its proxy is half-way up.
|
|
12246
|
-
*
|
|
12247
|
-
* A DISABLED entry does not count. `loader.entries()` yields disabled rows too
|
|
12248
|
-
* — the host skips starting them, it does not drop them — and our own patch
|
|
12249
|
-
* row is disabled on purpose. Counting it would make every profile that never
|
|
12250
|
-
* installed billion-context read as "installed", which is precisely the lie the
|
|
12251
|
-
* card must not tell.
|
|
12252
|
-
*/
|
|
12253
|
-
function mountedInLoader(loader, packageName) {
|
|
12254
|
-
for (const entry of loader.entries()) {
|
|
12255
|
-
if (entry.options?.name !== packageName) continue;
|
|
12256
|
-
if (entry.options?.disabled === true) continue;
|
|
12257
|
-
return true;
|
|
12258
|
-
}
|
|
12259
|
-
return false;
|
|
12260
|
-
}
|
|
12261
|
-
/**
|
|
12262
|
-
* Report whether the compressor is already running. Never mounts.
|
|
12263
|
-
*
|
|
12264
|
-
* Resolves rather than rejects on every path: a compressor that will not start
|
|
12265
|
-
* is a card that says so, never a pool that fails to boot. The pool's own model
|
|
12266
|
-
* registry has nothing to do with compression, and one missing optional feature
|
|
12267
|
-
* must not take the accounts down with it.
|
|
12268
|
-
*/
|
|
12269
|
-
async function mountBillionContext(ctx, logger) {
|
|
12270
|
-
try {
|
|
12271
|
-
let loader;
|
|
12272
|
-
ctx.inject(["loader"], (scoped) => {
|
|
12273
|
-
loader = scoped.loader;
|
|
12274
|
-
});
|
|
12275
|
-
if (loader === void 0) return {
|
|
12276
|
-
state: "unavailable",
|
|
12277
|
-
reason: "the host exposes no plugin loader"
|
|
12278
|
-
};
|
|
12279
|
-
if (mountedInLoader(loader, BILLION_PACKAGE)) {
|
|
12280
|
-
logger.info?.(`[xdpool] billion-context is already mounted; leaving it alone`);
|
|
12281
|
-
return { state: "already-mounted" };
|
|
12282
|
-
}
|
|
12283
|
-
return { state: "not-required" };
|
|
12284
|
-
} catch (error) {
|
|
12285
|
-
return {
|
|
12286
|
-
state: "unavailable",
|
|
12287
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
12288
|
-
};
|
|
12289
|
-
}
|
|
12290
|
-
}
|
|
12291
|
-
//#endregion
|
|
12292
12254
|
//#region src/pack-accounts.ts
|
|
12293
12255
|
/**
|
|
12294
12256
|
* Channel ids whose accounts this plugin serves itself.
|
|
@@ -13758,6 +13720,33 @@ function withSessionQuotaHint(error, retryAfterMs) {
|
|
|
13758
13720
|
return new AggregateError(error.kind, `${error.message} — this quota is counted per conversation on the free tier, so a new conversation (or another model) starts with its own budget`, error.status);
|
|
13759
13721
|
}
|
|
13760
13722
|
/**
|
|
13723
|
+
* Say what a 403 on this lane actually means (#96).
|
|
13724
|
+
*
|
|
13725
|
+
* The gateway answers a client it does not recognise with `403 FreeTierError`
|
|
13726
|
+
* and no explanation of which of its four fingerprint facts was wrong. That
|
|
13727
|
+
* reaches the caller as a bare `client` failure, and a bare 4xx is what the
|
|
13728
|
+
* host then renders in its own words: the user in #96 saw "当前请求的额度已用尽"
|
|
13729
|
+
* for a request that had never been billed. The wording pointed at waiting,
|
|
13730
|
+
* while the actual cause was "this build's fingerprint no longer matches, update
|
|
13731
|
+
* the plugin" — the two need opposite responses.
|
|
13732
|
+
*
|
|
13733
|
+
* The lane cannot tell them apart from the outside (both look like a refusal
|
|
13734
|
+
* with a thin body), so this does not pretend to. It names the actionable
|
|
13735
|
+
* reading FIRST — the fingerprint, which the user can fix by updating — and
|
|
13736
|
+
* keeps the quota possibility second, because it is the one that fixes itself.
|
|
13737
|
+
*
|
|
13738
|
+
* A 429 keeps its own hint ({@link withSessionQuotaHint}): that one really is
|
|
13739
|
+
* quota, and blurring the two would lose a distinction worth keeping.
|
|
13740
|
+
*/
|
|
13741
|
+
function withFreeTierHint(status, refusal, retryAfterMs) {
|
|
13742
|
+
const base = classifyUpstream(status, refusal, retryAfterMs, "");
|
|
13743
|
+
if (status !== 403) return base;
|
|
13744
|
+
const separator = base.message.indexOf(": ");
|
|
13745
|
+
const detail = separator === -1 ? "" : base.message.slice(separator + 2);
|
|
13746
|
+
const kept = detail === "" ? "" : ` (provider said: ${detail})`;
|
|
13747
|
+
return new AggregateError(base.kind, `the free lane refused this client: its request fingerprint no longer matches what opencode.ai accepts, so this is NOT an exhausted quota and waiting will not help — update dsh-xdpool and try again; if it persists, report this message. The shared free quota is also metered per conversation, so one retry in a fresh conversation covers the case where only that budget is spent${kept}`, status);
|
|
13748
|
+
}
|
|
13749
|
+
/**
|
|
13761
13750
|
* POST one streaming turn and hand back chat-shaped deltas.
|
|
13762
13751
|
*
|
|
13763
13752
|
* The shape of the ANSWER decides how it is read, never its Content-Type: a front
|
|
@@ -13801,7 +13790,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
|
|
|
13801
13790
|
const retryAfter = retryAfterMs(response);
|
|
13802
13791
|
if (!response.ok) {
|
|
13803
13792
|
const refusal = await response.text().catch(() => "");
|
|
13804
|
-
throw withSessionQuotaHint(
|
|
13793
|
+
throw withSessionQuotaHint(withFreeTierHint(response.status, refusal, retryAfter), retryAfter);
|
|
13805
13794
|
}
|
|
13806
13795
|
if (response.body === null) throw new AggregateError("server", "the free lane returned no body", response.status);
|
|
13807
13796
|
let events;
|
|
@@ -13834,7 +13823,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
|
|
|
13834
13823
|
} catch {
|
|
13835
13824
|
continue;
|
|
13836
13825
|
}
|
|
13837
|
-
if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw withSessionQuotaHint(
|
|
13826
|
+
if (isRecord$1(decoded) && (decoded.error !== void 0 || decoded.type === "error")) throw withSessionQuotaHint(withFreeTierHint(response.status, event, retryAfter), retryAfter);
|
|
13838
13827
|
const translated = chatChunksFor(wire, decoded, tools);
|
|
13839
13828
|
for (const delta of translated.frames) hooks.onDelta(delta);
|
|
13840
13829
|
if (translated.usage !== void 0) usage = translated.usage;
|
|
@@ -15051,6 +15040,71 @@ function appearanceChain(themeKey = APPEARANCE_THEME_KEY) {
|
|
|
15051
15040
|
};
|
|
15052
15041
|
}
|
|
15053
15042
|
/**
|
|
15043
|
+
* Pull the `expertId` out of a task's `jump_url`.
|
|
15044
|
+
*
|
|
15045
|
+
* The "体验XX专家" tasks (#98) name an expert that is NOT in the marketplace
|
|
15046
|
+
* listing — the reporter翻遍了 marketExpertList in both `agent` and `team`
|
|
15047
|
+
* types and got zero hits — so the only place the id appears is the deeplink
|
|
15048
|
+
* embedded in the task itself:
|
|
15049
|
+
*
|
|
15050
|
+
* https://www.workbuddy.cn/work/launch/?deeplink=workbuddy%3A%2F%2Fexperts%3FexpertId%3Dex_GDl31WahyIH0&buddy_type=workbuddy
|
|
15051
|
+
*
|
|
15052
|
+
* The deeplink is percent-encoded, and it has to be: the `?` and `&` inside it
|
|
15053
|
+
* would otherwise end the outer query. So the parse is two steps — decode the
|
|
15054
|
+
* `deeplink` parameter, then read `expertId` off the scheme it decodes to — and
|
|
15055
|
+
* NOT a regex over the whole URL, which would also match the outer `expertId`
|
|
15056
|
+
* spelling in a future URL shape and silently pick the wrong one.
|
|
15057
|
+
*
|
|
15058
|
+
* Returns undefined for the tasks whose deeplink carries no id at all
|
|
15059
|
+
* (`WorkBuddy_Valorant` points at `workbuddy://expert` with no query), which is
|
|
15060
|
+
* the honest answer: those need a different source and are not guessed here.
|
|
15061
|
+
*/
|
|
15062
|
+
function expertIdFromJumpUrl(jumpUrl) {
|
|
15063
|
+
if (jumpUrl === "") return void 0;
|
|
15064
|
+
/** Read `expertId` off any URL-ish string, decoded or not. */
|
|
15065
|
+
const readId = (value) => {
|
|
15066
|
+
let query = value;
|
|
15067
|
+
try {
|
|
15068
|
+
const parsed = new URL(value);
|
|
15069
|
+
query = parsed.search === "" ? value : parsed.search;
|
|
15070
|
+
} catch {
|
|
15071
|
+
query = value;
|
|
15072
|
+
}
|
|
15073
|
+
const marker = query.indexOf("expertId=");
|
|
15074
|
+
if (marker === -1) return void 0;
|
|
15075
|
+
const rest = query.slice(marker + 9);
|
|
15076
|
+
const end = rest.search(/[&#]/u);
|
|
15077
|
+
const id = (end === -1 ? rest : rest.slice(0, end)).trim();
|
|
15078
|
+
return id === "" ? void 0 : id;
|
|
15079
|
+
};
|
|
15080
|
+
let source = jumpUrl;
|
|
15081
|
+
try {
|
|
15082
|
+
const raw = new URL(jumpUrl).searchParams.get("deeplink");
|
|
15083
|
+
if (raw !== null && raw !== "") source = raw;
|
|
15084
|
+
} catch {
|
|
15085
|
+
source = jumpUrl;
|
|
15086
|
+
}
|
|
15087
|
+
return readId(source);
|
|
15088
|
+
}
|
|
15089
|
+
/**
|
|
15090
|
+
* Build a summon record for an expert the marketplace does not list.
|
|
15091
|
+
*
|
|
15092
|
+
* Everything the chains need beyond the id is cosmetic — the scorer keys on
|
|
15093
|
+
* `expertId` — so the title from the task is reused as the display name rather
|
|
15094
|
+
* than inventing one. `version` is left empty, which every consumer already
|
|
15095
|
+
* defaults to `1.0.0`.
|
|
15096
|
+
*/
|
|
15097
|
+
function expertFromTask(expertId, title) {
|
|
15098
|
+
return {
|
|
15099
|
+
expertId,
|
|
15100
|
+
expertType: "agent",
|
|
15101
|
+
displayName: title,
|
|
15102
|
+
profession: title,
|
|
15103
|
+
version: "",
|
|
15104
|
+
categories: []
|
|
15105
|
+
};
|
|
15106
|
+
}
|
|
15107
|
+
/**
|
|
15054
15108
|
* The three "summon an expert" events (`expert_summon_click` and friends).
|
|
15055
15109
|
*
|
|
15056
15110
|
* Paid before the conversation, in the order the app emits them.
|
|
@@ -15173,7 +15227,12 @@ const EVENT_CHAIN_BUILDERS = {
|
|
|
15173
15227
|
skill_1: true,
|
|
15174
15228
|
expert_5: true,
|
|
15175
15229
|
Expert_team_use_3: true,
|
|
15176
|
-
Expert_lighthouse: true
|
|
15230
|
+
Expert_lighthouse: true,
|
|
15231
|
+
expert_use_maidehao: true,
|
|
15232
|
+
expert_use_douyinyin: true,
|
|
15233
|
+
expert_use_shangjia_xuanchuan: true,
|
|
15234
|
+
expert_use_bowangwang: true,
|
|
15235
|
+
WorkBuddy_Valorant: true
|
|
15177
15236
|
};
|
|
15178
15237
|
const AUTOMATION_TICK_MS = 6e4;
|
|
15179
15238
|
/** The jobs in the order a tick runs them: report before tasks, always. */
|
|
@@ -15381,6 +15440,16 @@ var WorkBuddyScheduler = class {
|
|
|
15381
15440
|
/** Host hooks that persist the ledger across restarts. */
|
|
15382
15441
|
loadEarnings;
|
|
15383
15442
|
saveEarningsFn;
|
|
15443
|
+
/**
|
|
15444
|
+
* The most recent ledger write that may still be in flight.
|
|
15445
|
+
*
|
|
15446
|
+
* `persistEarnings` is synchronous from its callers' point of view, but the
|
|
15447
|
+
* host's settings write is not: it is an async document update. Keeping the
|
|
15448
|
+
* promise here is what lets {@link flushEarnings} wait for the write that
|
|
15449
|
+
* matters — the last one before a restart — without making every counter
|
|
15450
|
+
* update await disk.
|
|
15451
|
+
*/
|
|
15452
|
+
pendingEarningsWrite = Promise.resolve();
|
|
15384
15453
|
constructor(pool, client, options = {}) {
|
|
15385
15454
|
this.pool = pool;
|
|
15386
15455
|
this.client = client;
|
|
@@ -15417,6 +15486,18 @@ var WorkBuddyScheduler = class {
|
|
|
15417
15486
|
this.saveEarningsFn = save;
|
|
15418
15487
|
}
|
|
15419
15488
|
/**
|
|
15489
|
+
* Wait for the ledger write that is (or was last) in flight.
|
|
15490
|
+
*
|
|
15491
|
+
* Called at the END of a task pass, right after the last claim, so the rewards
|
|
15492
|
+
* that were just collected are on disk before a restart can lose them. It is
|
|
15493
|
+
* deliberately NOT awaited on every counter bump: the settings document write
|
|
15494
|
+
* is the slow part, and paying it per account would slow the pass for no
|
|
15495
|
+
* benefit while the process is still alive.
|
|
15496
|
+
*/
|
|
15497
|
+
async flushEarnings() {
|
|
15498
|
+
await this.pendingEarningsWrite;
|
|
15499
|
+
}
|
|
15500
|
+
/**
|
|
15420
15501
|
* Fold a previously persisted ledger back in, when it belongs to today.
|
|
15421
15502
|
*
|
|
15422
15503
|
* Used after the settings document becomes readable, which happens after
|
|
@@ -15686,7 +15767,7 @@ var WorkBuddyScheduler = class {
|
|
|
15686
15767
|
compensationCredit: (existing?.compensationCredit ?? 0) + compensationCredit,
|
|
15687
15768
|
date: today
|
|
15688
15769
|
});
|
|
15689
|
-
this.persistEarnings();
|
|
15770
|
+
this.pendingEarningsWrite = this.persistEarnings();
|
|
15690
15771
|
}
|
|
15691
15772
|
/** Clear the per-account counters when the local day changes. */
|
|
15692
15773
|
rollEarnings(today) {
|
|
@@ -15702,9 +15783,18 @@ var WorkBuddyScheduler = class {
|
|
|
15702
15783
|
* Best effort on purpose: a failed save must never abort a run that has
|
|
15703
15784
|
* already collected rewards, and the in-memory ledger keeps serving the card
|
|
15704
15785
|
* for the rest of the session either way.
|
|
15786
|
+
*
|
|
15787
|
+
* The returned promise settles when the write has landed (or failed). Callers
|
|
15788
|
+
* that are about to END a run await it: reported in #98, a task pass that
|
|
15789
|
+
* claimed 21 tasks had its process restarted before the volatile settings
|
|
15790
|
+
* write reached disk, so `automationEarnings` still read `credit: 0` while
|
|
15791
|
+
* the upstream had really paid out. Fire-and-forget is fine for a counter the
|
|
15792
|
+
* card will re-read in this process; it is not fine for the last write before
|
|
15793
|
+
* exit.
|
|
15705
15794
|
*/
|
|
15706
15795
|
persistEarnings() {
|
|
15707
|
-
|
|
15796
|
+
const save = this.saveEarningsFn;
|
|
15797
|
+
if (save === void 0) return Promise.resolve();
|
|
15708
15798
|
const accounts = {};
|
|
15709
15799
|
for (const [id, entry] of this.earnings) accounts[id] = entry;
|
|
15710
15800
|
const ledger = {
|
|
@@ -15712,12 +15802,14 @@ var WorkBuddyScheduler = class {
|
|
|
15712
15802
|
accounts
|
|
15713
15803
|
};
|
|
15714
15804
|
try {
|
|
15715
|
-
const saved =
|
|
15716
|
-
if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
|
|
15805
|
+
const saved = save(ledger);
|
|
15806
|
+
if (saved !== void 0 && typeof saved.then === "function") return Promise.resolve(saved).then(void 0, (error) => {
|
|
15717
15807
|
this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
|
|
15718
15808
|
});
|
|
15809
|
+
return Promise.resolve();
|
|
15719
15810
|
} catch (error) {
|
|
15720
15811
|
this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
|
|
15812
|
+
return Promise.resolve();
|
|
15721
15813
|
}
|
|
15722
15814
|
}
|
|
15723
15815
|
/**
|
|
@@ -15826,6 +15918,7 @@ var WorkBuddyScheduler = class {
|
|
|
15826
15918
|
state.detail = detail;
|
|
15827
15919
|
state.progress = progressNote;
|
|
15828
15920
|
this.logger.info?.(`automation ${accountWord}: ${state.message}`);
|
|
15921
|
+
if (kind === "tasks" || kind === "checkin" || kind === "streak" || kind === "travel" || kind === "compensation") await this.flushEarnings();
|
|
15829
15922
|
}
|
|
15830
15923
|
/** Compose the one-line summary shown on the card. */
|
|
15831
15924
|
summarise(kind, ok, failed, claimed, credit, energy) {
|
|
@@ -15948,10 +16041,43 @@ var WorkBuddyScheduler = class {
|
|
|
15948
16041
|
case "expert_5": return this.expertChains(credential, "agent", task);
|
|
15949
16042
|
case "Expert_team_use_3": return this.expertChains(credential, "team", task);
|
|
15950
16043
|
case "Expert_lighthouse": return this.lighthouseChains(credential);
|
|
16044
|
+
case "expert_use_maidehao":
|
|
16045
|
+
case "expert_use_douyinyin":
|
|
16046
|
+
case "expert_use_shangjia_xuanchuan":
|
|
16047
|
+
case "expert_use_bowangwang":
|
|
16048
|
+
case "WorkBuddy_Valorant": return this.taskExpertChains(credential, task);
|
|
15951
16049
|
default: return [];
|
|
15952
16050
|
}
|
|
15953
16051
|
}
|
|
15954
16052
|
/**
|
|
16053
|
+
* Summon-and-use one expert NAMED BY THE TASK.
|
|
16054
|
+
*
|
|
16055
|
+
* Structurally `expertChains` minus the marketplace: the expert id is read
|
|
16056
|
+
* from the task's deeplink instead of by browsing `marketExpertList`, because
|
|
16057
|
+
* this family's experts are not listed there at all (#98 measured 500 entries
|
|
16058
|
+
* across both `agent` and `team`, zero hits).
|
|
16059
|
+
*
|
|
16060
|
+
* Returns `[]` — not a throw — when the task carries no usable id: the caller
|
|
16061
|
+
* logs "could not be built" only for exceptions, and a task shape this code
|
|
16062
|
+
* does not understand is information, not a fault.
|
|
16063
|
+
*/
|
|
16064
|
+
async taskExpertChains(credential, task) {
|
|
16065
|
+
const expertId = expertIdFromJumpUrl(task.jumpUrl);
|
|
16066
|
+
if (expertId === void 0) {
|
|
16067
|
+
this.logger.warn?.(`automation events ${task.taskCode}: no expertId in jump_url, skipping`);
|
|
16068
|
+
return [];
|
|
16069
|
+
}
|
|
16070
|
+
const expert = expertFromTask(expertId, task.title);
|
|
16071
|
+
await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
|
|
16072
|
+
if (this.delayMs > 0 && !this.stopped) await sleep$1(this.delayMs);
|
|
16073
|
+
const chat = await this.client.openConversation(credential, expert.expertId);
|
|
16074
|
+
if (chat === void 0) throw new Error(`${task.taskCode}: no server conversation`);
|
|
16075
|
+
return [{
|
|
16076
|
+
transport: "desktop",
|
|
16077
|
+
events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), expertActualUseEvent(expert, chat.conversationId, chat.requestId)]
|
|
16078
|
+
}];
|
|
16079
|
+
}
|
|
16080
|
+
/**
|
|
15955
16081
|
* The summon-and-use chains for the expert tasks.
|
|
15956
16082
|
*
|
|
15957
16083
|
* Two steps per expert, and both are load-bearing: the summon events alone are
|
|
@@ -17291,33 +17417,6 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17291
17417
|
}
|
|
17292
17418
|
});
|
|
17293
17419
|
/**
|
|
17294
|
-
* The billion-context relay.
|
|
17295
|
-
*
|
|
17296
|
-
* GET only, loopback origin only. Its own route because it is the one
|
|
17297
|
-
* document here that comes from ANOTHER plugin's loopback listener: the
|
|
17298
|
-
* proxy publishes its own JSON and this only reads it, so folding it into
|
|
17299
|
-
* the pool status document would put a third-party service on the critical
|
|
17300
|
-
* path of every 30-second pool poll.
|
|
17301
|
-
*
|
|
17302
|
-
* Answered even when the proxy is absent — `state: 'missing'` with the
|
|
17303
|
-
* install command is far more actionable than a 503, and it is the normal
|
|
17304
|
-
* state on a machine that has not installed billion-context.
|
|
17305
|
-
*/
|
|
17306
|
-
const disposeBillion = ctx.webServer.register({
|
|
17307
|
-
kind: "exact",
|
|
17308
|
-
path: POOL_BILLION_PATH,
|
|
17309
|
-
handler: async (req, res) => {
|
|
17310
|
-
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
17311
|
-
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
17312
|
-
if (deps.billionDocument === void 0) return json(res, 503, { error: "the billion-context relay is not available in this build" });
|
|
17313
|
-
try {
|
|
17314
|
-
json(res, 200, await deps.billionDocument());
|
|
17315
|
-
} catch (error) {
|
|
17316
|
-
json(res, 500, { error: safeMessage(error) });
|
|
17317
|
-
}
|
|
17318
|
-
}
|
|
17319
|
-
});
|
|
17320
|
-
/**
|
|
17321
17420
|
* The chain list plus the roster its members are drawn from.
|
|
17322
17421
|
*
|
|
17323
17422
|
* GET only. Deliberately not part of the pool status document: chains change
|
|
@@ -17393,7 +17492,6 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17393
17492
|
disposeRelay();
|
|
17394
17493
|
disposeCombosSave();
|
|
17395
17494
|
disposeCombos();
|
|
17396
|
-
disposeBillion();
|
|
17397
17495
|
disposeChannels();
|
|
17398
17496
|
disposeAggregate();
|
|
17399
17497
|
disposeAggregateDiscover();
|
|
@@ -17417,247 +17515,6 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17417
17515
|
}, "dsh-xdpool: Web status route");
|
|
17418
17516
|
}
|
|
17419
17517
|
//#endregion
|
|
17420
|
-
//#region src/billion.ts
|
|
17421
|
-
/**
|
|
17422
|
-
* Read-only observation of a locally running billion-context proxy.
|
|
17423
|
-
*
|
|
17424
|
-
* WHAT THIS IS, AND WHY IT IS NOT AN EMBEDDED COMPRESSOR
|
|
17425
|
-
*
|
|
17426
|
-
* billion-context is a separate DSH plugin that is very likely already
|
|
17427
|
-
* installed on this machine — on a stock desktop profile it is mounted by the
|
|
17428
|
-
* profile's bundle layer and runs its own loopback listener. This module never
|
|
17429
|
-
* implements compression and never proposes to: it finds that listener and
|
|
17430
|
-
* relays a few numbers so the card can show what the compressor is doing.
|
|
17431
|
-
* Re-implementing folding here would mean a second, divergent answer to the
|
|
17432
|
-
* same question and a second set of ways to get it wrong.
|
|
17433
|
-
*
|
|
17434
|
-
* HOW THE LISTENER IS FOUND
|
|
17435
|
-
*
|
|
17436
|
-
* The proxy picks an ephemeral port at spawn and records it in its own state
|
|
17437
|
-
* directory, which we read rather than guess. Guessing would mean scanning a
|
|
17438
|
-
* port range on every card poll, and a wrong guess is worse than no answer: it
|
|
17439
|
-
* could read some unrelated loopback service. The recorded file is
|
|
17440
|
-
* `<state>/billion-context/proxy-origin` and holds a JSON object with at least
|
|
17441
|
-
* an `origin`; `port-zone.json` is the same fact keyed by lane. Both are read,
|
|
17442
|
-
* and the origin file wins, because it is the one the proxy itself reads on
|
|
17443
|
-
* startup.
|
|
17444
|
-
*
|
|
17445
|
-
* `state` is resolved the same way the proxy resolves it: `XDG_STATE_HOME` when
|
|
17446
|
-
* set, else `<homedir>/.local/state` on every platform including Windows — not
|
|
17447
|
-
* `%LOCALAPPDATA%`, which is what a Windows developer would reach for first and
|
|
17448
|
-
* which would therefore be wrong.
|
|
17449
|
-
*
|
|
17450
|
-
* WHAT IS RELAYED
|
|
17451
|
-
*
|
|
17452
|
-
* Counts, sizes, timestamps. Not titles, not first-block hints, not any
|
|
17453
|
-
* conversation material. The proxy is happy to hand over more than that, and
|
|
17454
|
-
* taking it would put the user's own words into a document the card fetches
|
|
17455
|
-
* every 30 seconds and that lives in the plugin's HTTP log.
|
|
17456
|
-
*
|
|
17457
|
-
* The one addition to that list is the proxy's own origin, so the card can link
|
|
17458
|
-
* out to the dashboard the proxy already ships instead of redrawing it. That is
|
|
17459
|
-
* a service address rather than conversation material, and anyone who can read
|
|
17460
|
-
* this document can already reach that listener directly — which is why it is
|
|
17461
|
-
* safe to publish where a title would not be.
|
|
17462
|
-
*
|
|
17463
|
-
* The proxy also has NO AUTHENTICATION by its own documentation — the README
|
|
17464
|
-
* says so outright — so every request here is a hard local one, and the relay
|
|
17465
|
-
* only ever reads `/__bili/health` and `/__bili/sessions`. Both are GETs. This
|
|
17466
|
-
* module has no code path that could write to that proxy.
|
|
17467
|
-
*
|
|
17468
|
-
* @module dsh-xdpool/billion
|
|
17469
|
-
*/
|
|
17470
|
-
/** The proxy's own management prefix. Never guess it; it is theirs. */
|
|
17471
|
-
const BILI_PREFIX = "/__bili";
|
|
17472
|
-
/** Loopback only. The proxy binds 127.0.0.1 and we refuse anything else. */
|
|
17473
|
-
const LOOPBACK_HOSTS$1 = [
|
|
17474
|
-
"127.0.0.1",
|
|
17475
|
-
"localhost",
|
|
17476
|
-
"[::1]",
|
|
17477
|
-
"::1"
|
|
17478
|
-
];
|
|
17479
|
-
/**
|
|
17480
|
-
* How long one relay request may take.
|
|
17481
|
-
*
|
|
17482
|
-
* Short on purpose: this runs inside the card's status request, and a stalled
|
|
17483
|
-
* third-party proxy must degrade to a panel that says "no answer" rather than
|
|
17484
|
-
* hold the pool card hostage. The proxy answers these two endpoints in
|
|
17485
|
-
* milliseconds; the budget is for the case where it does not.
|
|
17486
|
-
*/
|
|
17487
|
-
const RELAY_TIMEOUT_MS = 1500;
|
|
17488
|
-
/** How many sessions the card is shown. 50 rows is already past the fold. */
|
|
17489
|
-
const MAX_SESSIONS = 50;
|
|
17490
|
-
/**
|
|
17491
|
-
* Where the proxy keeps its state directory.
|
|
17492
|
-
*
|
|
17493
|
-
* Mirrors the proxy's own resolution exactly: `XDG_STATE_HOME` when it holds an
|
|
17494
|
-
* absolute path, else `<homedir>/.local/state`. Deliberately NOT
|
|
17495
|
-
* `DSH_HOME ?? homedir()` — DSH_HOME is the profile root and a live value on a
|
|
17496
|
-
* real desktop install, so preferring it sent this reader to
|
|
17497
|
-
* `<profile>/.local/state/billion-context` and reported "missing" on a machine
|
|
17498
|
-
* where the proxy was running and answering. Probed against the live proxy,
|
|
17499
|
-
* which is the only way that mistake is visible: every unit test with a faked
|
|
17500
|
-
* state directory passes either way.
|
|
17501
|
-
*
|
|
17502
|
-
* Undefined when neither resolves, because inventing a path to probe would
|
|
17503
|
-
* turn "not installed" into a silent hunt through the filesystem.
|
|
17504
|
-
*/
|
|
17505
|
-
function stateRoot() {
|
|
17506
|
-
const xdg = env.XDG_STATE_HOME;
|
|
17507
|
-
if (typeof xdg === "string" && xdg !== "") {
|
|
17508
|
-
if (xdg.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(xdg)) return join(xdg, "billion-context");
|
|
17509
|
-
return;
|
|
17510
|
-
}
|
|
17511
|
-
const home = homedir();
|
|
17512
|
-
if (typeof home !== "string" || home === "") return void 0;
|
|
17513
|
-
return join(home, ".local", "state", "billion-context");
|
|
17514
|
-
}
|
|
17515
|
-
/** A loopback http(s) origin, or undefined for anything else. */
|
|
17516
|
-
function parseLoopbackOrigin(raw) {
|
|
17517
|
-
let url;
|
|
17518
|
-
try {
|
|
17519
|
-
url = new URL(raw);
|
|
17520
|
-
} catch {
|
|
17521
|
-
return;
|
|
17522
|
-
}
|
|
17523
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
17524
|
-
if (!LOOPBACK_HOSTS$1.includes(url.hostname.toLowerCase())) return void 0;
|
|
17525
|
-
return url.origin;
|
|
17526
|
-
}
|
|
17527
|
-
/**
|
|
17528
|
-
* The proxy origin, from the file the proxy itself reads on startup.
|
|
17529
|
-
*
|
|
17530
|
-
* `port-zone.json` is the fallback because it is keyed by lane and therefore
|
|
17531
|
-
* distinguishes the dsh proxy from, say, a concurrently running opencode one —
|
|
17532
|
-
* but `proxy-origin` is preferred because it is the single-writer record.
|
|
17533
|
-
*/
|
|
17534
|
-
async function readProxyOrigin() {
|
|
17535
|
-
const root = stateRoot();
|
|
17536
|
-
if (root === void 0) return void 0;
|
|
17537
|
-
for (const name of ["proxy-origin", "port-zone.json"]) {
|
|
17538
|
-
let raw;
|
|
17539
|
-
try {
|
|
17540
|
-
raw = await readFile(join(root, name), "utf8");
|
|
17541
|
-
} catch {
|
|
17542
|
-
continue;
|
|
17543
|
-
}
|
|
17544
|
-
if (name === "proxy-origin") {
|
|
17545
|
-
try {
|
|
17546
|
-
const parsed = JSON.parse(raw);
|
|
17547
|
-
const origin = typeof parsed.origin === "string" ? parseLoopbackOrigin(parsed.origin) : void 0;
|
|
17548
|
-
if (origin !== void 0) return origin;
|
|
17549
|
-
} catch {}
|
|
17550
|
-
continue;
|
|
17551
|
-
}
|
|
17552
|
-
try {
|
|
17553
|
-
const port = JSON.parse(raw).lanes?.dsh;
|
|
17554
|
-
if (typeof port === "number" && Number.isInteger(port) && port > 0 && port < 65536) return `http://127.0.0.1:${port}`;
|
|
17555
|
-
} catch {
|
|
17556
|
-
continue;
|
|
17557
|
-
}
|
|
17558
|
-
}
|
|
17559
|
-
}
|
|
17560
|
-
/** One GET against the proxy, with the relay budget on it. Never throws. */
|
|
17561
|
-
async function fetchJson(origin, path) {
|
|
17562
|
-
const controller = new AbortController();
|
|
17563
|
-
const timer = setTimeout(() => {
|
|
17564
|
-
controller.abort();
|
|
17565
|
-
}, RELAY_TIMEOUT_MS);
|
|
17566
|
-
try {
|
|
17567
|
-
const response = await fetch(new URL(BILI_PREFIX + path, origin), {
|
|
17568
|
-
method: "GET",
|
|
17569
|
-
headers: { accept: "application/json" },
|
|
17570
|
-
signal: controller.signal
|
|
17571
|
-
});
|
|
17572
|
-
if (!response.ok) return void 0;
|
|
17573
|
-
return await response.json();
|
|
17574
|
-
} catch {
|
|
17575
|
-
return;
|
|
17576
|
-
} finally {
|
|
17577
|
-
clearTimeout(timer);
|
|
17578
|
-
}
|
|
17579
|
-
}
|
|
17580
|
-
/** A finite number, or undefined. Guards every relayed field. */
|
|
17581
|
-
function num(value) {
|
|
17582
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
17583
|
-
}
|
|
17584
|
-
/** A string, or ''. */
|
|
17585
|
-
function str(value) {
|
|
17586
|
-
return typeof value === "string" ? value : "";
|
|
17587
|
-
}
|
|
17588
|
-
/** A boolean, or false. */
|
|
17589
|
-
function bool(value) {
|
|
17590
|
-
return value === true;
|
|
17591
|
-
}
|
|
17592
|
-
/**
|
|
17593
|
-
* One session row, reduced.
|
|
17594
|
-
*
|
|
17595
|
-
* Every field is defaulted rather than passed through: the proxy's document is
|
|
17596
|
-
* a moving target across versions, and an unreadable row should render as
|
|
17597
|
-
* zeroes we can explain, not as `undefined` reaching the card.
|
|
17598
|
-
*/
|
|
17599
|
-
function toSession(raw) {
|
|
17600
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
17601
|
-
const row = raw;
|
|
17602
|
-
const id = str(row.id);
|
|
17603
|
-
if (id === "") return void 0;
|
|
17604
|
-
return {
|
|
17605
|
-
id,
|
|
17606
|
-
protocol: str(row.protocol),
|
|
17607
|
-
live: bool(row.live),
|
|
17608
|
-
requests: num(row.requests) ?? 0,
|
|
17609
|
-
contextTokens: num(row.contextTokens) ?? 0,
|
|
17610
|
-
contextTokensSource: str(row.contextTokensSource),
|
|
17611
|
-
tokensSaved: num(row.tokensSaved) ?? 0,
|
|
17612
|
-
inputTokens: num(row.inputTokens) ?? 0,
|
|
17613
|
-
cachedTokens: num(row.cachedTokens) ?? 0,
|
|
17614
|
-
outputTokens: num(row.outputTokens) ?? 0,
|
|
17615
|
-
cacheHitPct: num(row.cacheHitPct) ?? -1,
|
|
17616
|
-
blocks: num(row.blocks) ?? 0,
|
|
17617
|
-
contextWindow: num(row.contextWindow) ?? 0,
|
|
17618
|
-
lastSeen: str(row.lastSeen),
|
|
17619
|
-
lastModel: str(row.lastModel)
|
|
17620
|
-
};
|
|
17621
|
-
}
|
|
17622
|
-
/** The whole relay, safe to hand the card: it never rejects. */
|
|
17623
|
-
async function readBillionStatus() {
|
|
17624
|
-
const origin = await readProxyOrigin();
|
|
17625
|
-
if (origin === void 0) return {
|
|
17626
|
-
state: "missing",
|
|
17627
|
-
reason: "billion-context is not running on this machine. Install it with \"dsh plugin --profile <name> add billion-context\" and this page fills in.",
|
|
17628
|
-
sessions: [],
|
|
17629
|
-
totalSessions: 0
|
|
17630
|
-
};
|
|
17631
|
-
const health = await fetchJson(origin, "/health");
|
|
17632
|
-
if (health === null || typeof health !== "object") return {
|
|
17633
|
-
state: "unreachable",
|
|
17634
|
-
reason: `billion-context is recorded at ${origin} but did not answer. It may have just been restarted.`,
|
|
17635
|
-
origin,
|
|
17636
|
-
sessions: [],
|
|
17637
|
-
totalSessions: 0
|
|
17638
|
-
};
|
|
17639
|
-
const meta = health;
|
|
17640
|
-
const sessionsDoc = await fetchJson(origin, "/sessions");
|
|
17641
|
-
const rawSessions = sessionsDoc !== null && typeof sessionsDoc === "object" ? sessionsDoc.sessions : void 0;
|
|
17642
|
-
const rows = Array.isArray(rawSessions) ? rawSessions.slice(0, MAX_SESSIONS) : [];
|
|
17643
|
-
const sessions = [];
|
|
17644
|
-
for (const row of rows) {
|
|
17645
|
-
const session = toSession(row);
|
|
17646
|
-
if (session !== void 0) sessions.push(session);
|
|
17647
|
-
}
|
|
17648
|
-
const total = num(sessionsDoc !== null && typeof sessionsDoc === "object" ? sessionsDoc.total : void 0) ?? sessions.length;
|
|
17649
|
-
const version = str(meta.version);
|
|
17650
|
-
const startedAt = num(meta.startedAt);
|
|
17651
|
-
return {
|
|
17652
|
-
state: "ready",
|
|
17653
|
-
origin,
|
|
17654
|
-
...version === "" ? {} : { version },
|
|
17655
|
-
...startedAt === void 0 ? {} : { startedAt },
|
|
17656
|
-
sessions,
|
|
17657
|
-
totalSessions: total
|
|
17658
|
-
};
|
|
17659
|
-
}
|
|
17660
|
-
//#endregion
|
|
17661
17518
|
//#region src/diagnostics.ts
|
|
17662
17519
|
/**
|
|
17663
17520
|
* A startup trace this plugin owns.
|
|
@@ -18663,6 +18520,34 @@ function createWorkBuddyShim(options) {
|
|
|
18663
18520
|
const server = createServer((req, res) => {
|
|
18664
18521
|
handle(req, res);
|
|
18665
18522
|
});
|
|
18523
|
+
async function acquireForRequest(modelId, conversationKey, signal) {
|
|
18524
|
+
const ordinary = await pool.acquire(modelId, region, conversationKey);
|
|
18525
|
+
const reserveExempt = /* @__PURE__ */ new Set();
|
|
18526
|
+
if (ordinary !== void 0 || modelId === void 0 || signal.aborted) return {
|
|
18527
|
+
account: ordinary,
|
|
18528
|
+
reserveExempt
|
|
18529
|
+
};
|
|
18530
|
+
const now = Date.now();
|
|
18531
|
+
const held = pool.list(region).filter((account) => pool.isReserved(account.id) && !pool.isDisabled(account.id) && (account.credentialDeadUntilMs ?? 0) <= now && account.cooldownUntilMs <= now && (account.modelCooldowns[modelId] ?? 0) <= now);
|
|
18532
|
+
const pricingSignal = AbortSignal.any([signal, AbortSignal.timeout(5e3)]);
|
|
18533
|
+
await Promise.all(held.map(async (account) => {
|
|
18534
|
+
try {
|
|
18535
|
+
const price = (await client.fetchModels(account.credential, pricingSignal)).find((model) => model.id === modelId)?.creditMultiplier;
|
|
18536
|
+
logger?.info?.(`dsh-xdpool: reserve pricing model=${modelId} region=${region ?? "any"} price=${price ?? "unknown"} exempt=${!pricingSignal.aborted && price === 0}`);
|
|
18537
|
+
if (!pricingSignal.aborted && price === 0) reserveExempt.add(account.id);
|
|
18538
|
+
} catch {
|
|
18539
|
+
logger?.info?.(`dsh-xdpool: reserve pricing unavailable model=${modelId} region=${region ?? "any"} aborted=${pricingSignal.aborted}`);
|
|
18540
|
+
}
|
|
18541
|
+
}));
|
|
18542
|
+
if (signal.aborted) return {
|
|
18543
|
+
account: void 0,
|
|
18544
|
+
reserveExempt
|
|
18545
|
+
};
|
|
18546
|
+
return {
|
|
18547
|
+
account: await pool.acquire(modelId, region, conversationKey, reserveExempt),
|
|
18548
|
+
reserveExempt
|
|
18549
|
+
};
|
|
18550
|
+
}
|
|
18666
18551
|
/**
|
|
18667
18552
|
* "The same model has a paid row" sentence for a 429 body, or undefined.
|
|
18668
18553
|
*
|
|
@@ -19163,9 +19048,9 @@ function createWorkBuddyShim(options) {
|
|
|
19163
19048
|
if (signal.aborted) return;
|
|
19164
19049
|
attempts.push(member.modelId);
|
|
19165
19050
|
if (!member.aggregate) {
|
|
19166
|
-
const account = await
|
|
19051
|
+
const { account, reserveExempt } = await acquireForRequest(member.modelId, conversationKey, signal);
|
|
19167
19052
|
if (account === void 0) {
|
|
19168
|
-
const why = pool.unavailableReason(member.modelId, region);
|
|
19053
|
+
const why = pool.unavailableReason(member.modelId, region, reserveExempt);
|
|
19169
19054
|
last = {
|
|
19170
19055
|
kind: why.reason,
|
|
19171
19056
|
status: why.reason === "cooling" ? 429 : 401,
|
|
@@ -19173,7 +19058,11 @@ function createWorkBuddyShim(options) {
|
|
|
19173
19058
|
};
|
|
19174
19059
|
continue;
|
|
19175
19060
|
}
|
|
19176
|
-
const
|
|
19061
|
+
const memberRaw = JSON.stringify({
|
|
19062
|
+
...JSON.parse(raw),
|
|
19063
|
+
model: member.modelId
|
|
19064
|
+
});
|
|
19065
|
+
const prepared = client.prepareChatBodyFor(memberRaw, region ?? "cn");
|
|
19177
19066
|
const result = await client.chatStream(account.credential, prepared, signal, conversationKey);
|
|
19178
19067
|
if (result.ok) {
|
|
19179
19068
|
const turnId = randomBytes(8).toString("hex");
|
|
@@ -19328,7 +19217,6 @@ function createWorkBuddyShim(options) {
|
|
|
19328
19217
|
const conversationKey = conversationKeyOf(poolRaw);
|
|
19329
19218
|
const tried = [];
|
|
19330
19219
|
let last;
|
|
19331
|
-
const refreshedThisRequest = /* @__PURE__ */ new Set();
|
|
19332
19220
|
const turnId = randomBytes(8).toString("hex");
|
|
19333
19221
|
const usageContext = usageRecorder === void 0 ? void 0 : {
|
|
19334
19222
|
recorder: usageRecorder,
|
|
@@ -19338,9 +19226,9 @@ function createWorkBuddyShim(options) {
|
|
|
19338
19226
|
};
|
|
19339
19227
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
19340
19228
|
if (controller.signal.aborted) return;
|
|
19341
|
-
const account = await
|
|
19229
|
+
const { account, reserveExempt } = await acquireForRequest(modelId, conversationKey, controller.signal);
|
|
19342
19230
|
if (account === void 0) {
|
|
19343
|
-
const why = pool.unavailableReason(modelId, region);
|
|
19231
|
+
const why = pool.unavailableReason(modelId, region, reserveExempt);
|
|
19344
19232
|
if (why.reason === "cooling") {
|
|
19345
19233
|
const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
|
|
19346
19234
|
const upstream = last === void 0 ? "" : ` — ${last.message.slice(0, 200)}`;
|
|
@@ -19353,7 +19241,7 @@ function createWorkBuddyShim(options) {
|
|
|
19353
19241
|
return;
|
|
19354
19242
|
}
|
|
19355
19243
|
if (why.reason === "reserve") {
|
|
19356
|
-
writeOpenAIError(res, 402, "reserve_floor", `
|
|
19244
|
+
writeOpenAIError(res, 402, "reserve_floor", `no WorkBuddy account is available; every account not excluded by a cooldown or a revoked sign-in is at or below its credit reserve (${why.total} account(s) total); lower the reserve on the pool card or top up credits`);
|
|
19357
19245
|
return;
|
|
19358
19246
|
}
|
|
19359
19247
|
if (why.reason === "session_dead") {
|
|
@@ -19383,12 +19271,6 @@ function createWorkBuddyShim(options) {
|
|
|
19383
19271
|
message: result.message
|
|
19384
19272
|
};
|
|
19385
19273
|
if (result.kind === "session_dead") {
|
|
19386
|
-
if (refreshedThisRequest.has(account.id)) {
|
|
19387
|
-
pool.penalizeCredentialDead(account.id);
|
|
19388
|
-
logger?.warn(`dsh-xdpool: ${account.label} still refused after a token refresh (attempt ${attempt + 1}/${maxAttempts}); rotating`);
|
|
19389
|
-
continue;
|
|
19390
|
-
}
|
|
19391
|
-
refreshedThisRequest.add(account.id);
|
|
19392
19274
|
logger?.warn(`dsh-xdpool: ${account.label} session dead; refreshing token and retrying`);
|
|
19393
19275
|
const before = account.credential.accessToken;
|
|
19394
19276
|
if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) {
|
|
@@ -19413,7 +19295,7 @@ function createWorkBuddyShim(options) {
|
|
|
19413
19295
|
}
|
|
19414
19296
|
if (isContextTooLong(last.message)) {
|
|
19415
19297
|
const recovered = await recoverFromContextOverrun({
|
|
19416
|
-
raw,
|
|
19298
|
+
raw: poolRaw,
|
|
19417
19299
|
modelId,
|
|
19418
19300
|
controller,
|
|
19419
19301
|
region,
|
|
@@ -19421,6 +19303,7 @@ function createWorkBuddyShim(options) {
|
|
|
19421
19303
|
client,
|
|
19422
19304
|
pool,
|
|
19423
19305
|
maxAttempts,
|
|
19306
|
+
acquireAccount: async () => (await acquireForRequest(modelId, conversationKey, controller.signal)).account,
|
|
19424
19307
|
usage: usageContext,
|
|
19425
19308
|
contextWindow: modelId === void 0 ? void 0 : catalog.current().find((m) => m.id === modelId)?.contextWindow
|
|
19426
19309
|
});
|
|
@@ -19618,7 +19501,7 @@ async function recoverFromContextOverrun(options) {
|
|
|
19618
19501
|
let compacted = messages;
|
|
19619
19502
|
let compactionDetail = "";
|
|
19620
19503
|
try {
|
|
19621
|
-
const summariser = await pool.acquire(modelId, region);
|
|
19504
|
+
const summariser = await (options.acquireAccount?.() ?? pool.acquire(modelId, region));
|
|
19622
19505
|
if (summariser === void 0) compactionDetail = "no account available to summarise with";
|
|
19623
19506
|
else {
|
|
19624
19507
|
const outcome = await compactWithSummary(messages, {
|
|
@@ -19646,13 +19529,12 @@ async function recoverFromContextOverrun(options) {
|
|
|
19646
19529
|
};
|
|
19647
19530
|
const retryBody = client.buildChatBody(parsed, compacted, region ?? "cn");
|
|
19648
19531
|
const tried = [];
|
|
19649
|
-
const refreshedThisRequest = /* @__PURE__ */ new Set();
|
|
19650
19532
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
19651
19533
|
if (controller.signal.aborted) return {
|
|
19652
19534
|
ok: false,
|
|
19653
19535
|
detail: "client disconnected"
|
|
19654
19536
|
};
|
|
19655
|
-
const account = await pool.acquire(modelId, region);
|
|
19537
|
+
const account = await (options.acquireAccount?.() ?? pool.acquire(modelId, region));
|
|
19656
19538
|
if (account === void 0) return {
|
|
19657
19539
|
ok: false,
|
|
19658
19540
|
detail: "no account available after compaction"
|
|
@@ -19680,11 +19562,6 @@ async function recoverFromContextOverrun(options) {
|
|
|
19680
19562
|
detail: "prompt still exceeded the window after compaction"
|
|
19681
19563
|
};
|
|
19682
19564
|
if (result.kind === "session_dead") {
|
|
19683
|
-
if (refreshedThisRequest.has(account.id)) {
|
|
19684
|
-
pool.penalizeCredentialDead(account.id);
|
|
19685
|
-
continue;
|
|
19686
|
-
}
|
|
19687
|
-
refreshedThisRequest.add(account.id);
|
|
19688
19565
|
const before = account.credential.accessToken;
|
|
19689
19566
|
if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) pool.penalizeCredentialDead(account.id);
|
|
19690
19567
|
continue;
|
|
@@ -20744,8 +20621,7 @@ const Config = z.object({
|
|
|
20744
20621
|
modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
|
|
20745
20622
|
modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
|
|
20746
20623
|
automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
|
|
20747
|
-
automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
|
|
20748
|
-
sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)"))
|
|
20624
|
+
automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
|
|
20749
20625
|
});
|
|
20750
20626
|
/** Live API, published for the CLI. */
|
|
20751
20627
|
let api;
|
|
@@ -21134,9 +21010,7 @@ function apply(ctx, config = {}) {
|
|
|
21134
21010
|
await setSetting("disabledAccountIds", next, next);
|
|
21135
21011
|
},
|
|
21136
21012
|
setCreditReserve: async (accountId, reserve) => {
|
|
21137
|
-
const next =
|
|
21138
|
-
if (reserve > 0) next[accountId] = reserve;
|
|
21139
|
-
else delete next[accountId];
|
|
21013
|
+
const next = creditReservesWith(current().creditReserves, accountId, reserve);
|
|
21140
21014
|
await setSetting("creditReserves", next, next);
|
|
21141
21015
|
},
|
|
21142
21016
|
setAccountIgnored: async (accountId, ignored) => {
|
|
@@ -21310,7 +21184,6 @@ function apply(ctx, config = {}) {
|
|
|
21310
21184
|
await aggregateCatalog.refresh();
|
|
21311
21185
|
return combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current());
|
|
21312
21186
|
},
|
|
21313
|
-
billionDocument: readBillionStatus,
|
|
21314
21187
|
/**
|
|
21315
21188
|
* Relay state plus the gateway's model picker.
|
|
21316
21189
|
*
|
|
@@ -21385,15 +21258,9 @@ function apply(ctx, config = {}) {
|
|
|
21385
21258
|
}) };
|
|
21386
21259
|
}).finally(() => {
|
|
21387
21260
|
if (stopped) return;
|
|
21388
|
-
adoptChannelPackAccounts(
|
|
21261
|
+
adoptChannelPackAccounts(ctx, core.pool, ctx.logger);
|
|
21389
21262
|
});
|
|
21390
21263
|
});
|
|
21391
|
-
mountBillionContext(ctx, ctx.logger).then((outcome) => {
|
|
21392
|
-
recordStartupNote(`billion-context ${outcome.state}`);
|
|
21393
|
-
if (outcome.state === "unavailable") ctx.logger.warn(`[xdpool] billion-context not mounted — ${outcome.reason}`);
|
|
21394
|
-
}, (error) => {
|
|
21395
|
-
recordStartupNote(`billion-context mount failed: ${describeError(error)}`);
|
|
21396
|
-
});
|
|
21397
21264
|
Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {
|
|
21398
21265
|
if (stopped) return;
|
|
21399
21266
|
recordStartupNote(`both shims ready cn=${shims.cn.baseUrl()} global=${shims.global.baseUrl()}; entering registration`);
|