dsh-xdpool 2.4.6 → 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 +124 -0
- 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 +218 -21
- package/lib/client.js +277 -624
- package/lib/index.d.ts +54 -82
- package/lib/index.js +299 -386
- package/package.json +1 -4
- package/vendor/channel-pack/pack.js +27 -5
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.
|
|
@@ -390,10 +399,39 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
|
|
|
390
399
|
* rewrite is applied to assistant history too — one past reply carrying a
|
|
391
400
|
* fingerprint would otherwise fail every later request in the conversation.
|
|
392
401
|
*
|
|
402
|
+
* A THIRD FINGERPRINT (measured against the live domestic gateway 2026-10-28)
|
|
403
|
+
*
|
|
404
|
+
* The file-reference guidance DSH contributes whenever the agent has a `read`
|
|
405
|
+
* tool reintroduced the same 400/11128 after the two rewrites above shipped:
|
|
406
|
+
*
|
|
407
|
+
* Tokens prefixed with @ are paths the user explicitly referenced.
|
|
408
|
+
* Relative paths resolve from the workspace root; absolute paths identify
|
|
409
|
+
* files or directories on the host.
|
|
410
|
+
*
|
|
411
|
+
* Ordinary phrasing throughout — the blocked region names no product — so the
|
|
412
|
+
* fingerprint is not "this prompt is not WorkBuddy's" but a literal substring
|
|
413
|
+
* list that happens to include this sentence. Neither sentence is the trigger
|
|
414
|
+
* on its own: each 200s in isolation, and the pair 400s; the smallest edit
|
|
415
|
+
* anywhere in the pair clears it, which is what marks it as content and not
|
|
416
|
+
* prompt length, and the matching half is the second sentence ("Tokens
|
|
417
|
+
* prefixed with @" alone stayed clean while the "Relative paths…" line was the
|
|
418
|
+
* one whose text the gateway recognised).
|
|
419
|
+
*
|
|
420
|
+
* The rewrite turns the indicative into the same statement's participle —
|
|
421
|
+
* "Relative paths resolve from the workspace root" becomes "Relative paths are
|
|
422
|
+
* resolved against the workspace root". Meaning, length class and line breaks
|
|
423
|
+
* are preserved; the substring the gateway lists is gone. Verified at 200 with
|
|
424
|
+
* the full runtime system prompt (≈41k chars) that reproduced the 400 on the
|
|
425
|
+
* same account, and with the pair alone.
|
|
426
|
+
*
|
|
393
427
|
* The list is an ARRAY because these fingerprints are appended to over time; a
|
|
394
428
|
* future rejection should be one more row here, not a new code path.
|
|
395
429
|
*/
|
|
396
|
-
const BANNED_SYSTEM_PHRASES = Object.freeze([
|
|
430
|
+
const BANNED_SYSTEM_PHRASES = Object.freeze([
|
|
431
|
+
[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"],
|
|
432
|
+
[/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"],
|
|
433
|
+
[/Relative paths resolve from the workspace root/gi, "Relative paths are resolved against the workspace root"]
|
|
434
|
+
]);
|
|
397
435
|
/**
|
|
398
436
|
* Rewrite fingerprinted system/assistant content, leaving everything else
|
|
399
437
|
* byte-identical.
|
|
@@ -1124,7 +1162,8 @@ function parseTask(value) {
|
|
|
1124
1162
|
status: typeof raw["status"] === "string" ? raw["status"] : "",
|
|
1125
1163
|
claimable: !claimed && target > 0 && current >= target,
|
|
1126
1164
|
claimed,
|
|
1127
|
-
locked: raw["locked"] === true
|
|
1165
|
+
locked: raw["locked"] === true,
|
|
1166
|
+
jumpUrl: typeof raw["jump_url"] === "string" ? raw["jump_url"] : ""
|
|
1128
1167
|
};
|
|
1129
1168
|
}
|
|
1130
1169
|
var WorkBuddyUpstreamClient = class {
|
|
@@ -3840,11 +3879,11 @@ var WorkBuddyAccountPool = class {
|
|
|
3840
3879
|
* model id the legacy account-wide check applies (callers that cannot name a
|
|
3841
3880
|
* model, e.g. CLI diagnostics).
|
|
3842
3881
|
*/
|
|
3843
|
-
available(now, modelId, region) {
|
|
3882
|
+
available(now, modelId, region, reserveExempt) {
|
|
3844
3883
|
return this.accounts.filter((account) => {
|
|
3845
3884
|
if (this.disabledIds.has(account.id)) return false;
|
|
3846
3885
|
const reserve = this.creditReserves.get(account.id);
|
|
3847
|
-
if (reserve !== void 0 && reserve > 0) {
|
|
3886
|
+
if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
|
|
3848
3887
|
const balance = this.creditBalances.get(account.id);
|
|
3849
3888
|
if (balance !== void 0 && balance <= reserve) return false;
|
|
3850
3889
|
}
|
|
@@ -3868,7 +3907,7 @@ var WorkBuddyAccountPool = class {
|
|
|
3868
3907
|
* Counts are over the region's accounts, since a provider only ever sees its
|
|
3869
3908
|
* own gateway.
|
|
3870
3909
|
*/
|
|
3871
|
-
unavailableReason(modelId, region) {
|
|
3910
|
+
unavailableReason(modelId, region, reserveExempt) {
|
|
3872
3911
|
const now = Date.now();
|
|
3873
3912
|
const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
|
|
3874
3913
|
if (inRegion.length === 0) return {
|
|
@@ -3881,6 +3920,29 @@ var WorkBuddyAccountPool = class {
|
|
|
3881
3920
|
let cooling = 0;
|
|
3882
3921
|
let disabled = 0;
|
|
3883
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;
|
|
3884
3946
|
for (const account of inRegion) {
|
|
3885
3947
|
if (this.disabledIds.has(account.id)) {
|
|
3886
3948
|
disabled += 1;
|
|
@@ -3896,16 +3958,14 @@ var WorkBuddyAccountPool = class {
|
|
|
3896
3958
|
continue;
|
|
3897
3959
|
}
|
|
3898
3960
|
const reserve = this.creditReserves.get(account.id);
|
|
3899
|
-
if (reserve !== void 0 && reserve > 0) {
|
|
3961
|
+
if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
|
|
3900
3962
|
const balance = this.creditBalances.get(account.id);
|
|
3901
|
-
if (balance !== void 0 && balance <= reserve)
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
dead,
|
|
3906
|
-
reason: "reserve"
|
|
3907
|
-
};
|
|
3963
|
+
if (balance !== void 0 && balance <= reserve) {
|
|
3964
|
+
reserved += 1;
|
|
3965
|
+
continue;
|
|
3966
|
+
}
|
|
3908
3967
|
}
|
|
3968
|
+
usable += 1;
|
|
3909
3969
|
}
|
|
3910
3970
|
if (dead > 0) return {
|
|
3911
3971
|
total: inRegion.length,
|
|
@@ -3928,6 +3988,13 @@ var WorkBuddyAccountPool = class {
|
|
|
3928
3988
|
dead,
|
|
3929
3989
|
reason: "disabled"
|
|
3930
3990
|
};
|
|
3991
|
+
if (usable === 0 && reserved > 0) return {
|
|
3992
|
+
total: inRegion.length,
|
|
3993
|
+
cooling,
|
|
3994
|
+
disabled,
|
|
3995
|
+
dead,
|
|
3996
|
+
reason: "reserve"
|
|
3997
|
+
};
|
|
3931
3998
|
return {
|
|
3932
3999
|
total: inRegion.length,
|
|
3933
4000
|
cooling,
|
|
@@ -4104,16 +4171,18 @@ var WorkBuddyAccountPool = class {
|
|
|
4104
4171
|
* Scans on first use, and rescans when every known account is cooling down: a
|
|
4105
4172
|
* fresh desktop login is the usual way out of an exhausted pool.
|
|
4106
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.
|
|
4107
4176
|
* `conversationKey` is only consulted under `sticky`; other modes ignore it.
|
|
4108
4177
|
*/
|
|
4109
|
-
async acquire(modelId, region, conversationKey) {
|
|
4178
|
+
async acquire(modelId, region, conversationKey, reserveExempt) {
|
|
4110
4179
|
if (this.accounts.length === 0) await this.scan();
|
|
4111
4180
|
const maxTries = this.accounts.length + 1;
|
|
4112
4181
|
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
4113
|
-
let pool = this.available(Date.now(), modelId, region);
|
|
4182
|
+
let pool = this.available(Date.now(), modelId, region, reserveExempt);
|
|
4114
4183
|
if (pool.length === 0 && attempt === 0) {
|
|
4115
4184
|
await this.scan();
|
|
4116
|
-
pool = this.available(Date.now(), modelId, region);
|
|
4185
|
+
pool = this.available(Date.now(), modelId, region, reserveExempt);
|
|
4117
4186
|
}
|
|
4118
4187
|
if (pool.length === 0) return void 0;
|
|
4119
4188
|
if (this.preferredAccountId !== void 0) {
|
|
@@ -4966,22 +5035,6 @@ const POOL_COMBOS_SAVE_PATH = "/plugins/dsh-xdpool/combos/save";
|
|
|
4966
5035
|
*/
|
|
4967
5036
|
const POOL_RELAY_PATH = "/plugins/dsh-xdpool/relay";
|
|
4968
5037
|
/**
|
|
4969
|
-
* What the locally running billion-context proxy is doing, read-only.
|
|
4970
|
-
*
|
|
4971
|
-
* Its own route because the answer comes from ANOTHER plugin: billion-context
|
|
4972
|
-
* runs its own loopback listener and publishes its own JSON, so this route only
|
|
4973
|
-
* finds that listener and relays a filtered subset. Nothing here is folded into
|
|
4974
|
-
* {@link POOL_STATUS_PATH}: the pool card polls that every 30 seconds, and a
|
|
4975
|
-
* third-party proxy's session table is not something every pool poll should
|
|
4976
|
-
* carry.
|
|
4977
|
-
*
|
|
4978
|
-
* The word "read-only" is load-bearing on both ends. This plugin sends GETs
|
|
4979
|
-
* only — it never configures, restarts or writes to the proxy — and the payload
|
|
4980
|
-
* it relays deliberately omits every field that could carry conversation
|
|
4981
|
-
* material.
|
|
4982
|
-
*/
|
|
4983
|
-
const POOL_BILLION_PATH = "/plugins/dsh-xdpool/billion";
|
|
4984
|
-
/**
|
|
4985
5038
|
* Read the usage panel's aggregate.
|
|
4986
5039
|
*
|
|
4987
5040
|
* GET only, and token-free by construction: the recorder holds counts and
|
|
@@ -12198,65 +12251,6 @@ function createWorkBuddyAdapter(options) {
|
|
|
12198
12251
|
};
|
|
12199
12252
|
}
|
|
12200
12253
|
//#endregion
|
|
12201
|
-
//#region src/billion-mount.ts
|
|
12202
|
-
/** The package name the loader resolves, exactly as our patch row names it. */
|
|
12203
|
-
const BILLION_PACKAGE = "billion-context";
|
|
12204
|
-
/**
|
|
12205
|
-
* Is this package installed and able to run?
|
|
12206
|
-
*
|
|
12207
|
-
* Any entry whose name matches counts — including one whose fiber has not
|
|
12208
|
-
* started yet. When this function still decided whether to mount, a fiber-less
|
|
12209
|
-
* entry had to be treated as absent: treating an import-in-flight as present
|
|
12210
|
-
* would have skipped the mount and left the compressor never starting. Now that
|
|
12211
|
-
* nothing mounts, the distinction only picks a label, and the honest one is
|
|
12212
|
-
* "installed": a user watching billion-context's own boot does not want the
|
|
12213
|
-
* card telling them it is missing while its proxy is half-way up.
|
|
12214
|
-
*
|
|
12215
|
-
* A DISABLED entry does not count. `loader.entries()` yields disabled rows too
|
|
12216
|
-
* — the host skips starting them, it does not drop them — and our own patch
|
|
12217
|
-
* row is disabled on purpose. Counting it would make every profile that never
|
|
12218
|
-
* installed billion-context read as "installed", which is precisely the lie the
|
|
12219
|
-
* card must not tell.
|
|
12220
|
-
*/
|
|
12221
|
-
function mountedInLoader(loader, packageName) {
|
|
12222
|
-
for (const entry of loader.entries()) {
|
|
12223
|
-
if (entry.options?.name !== packageName) continue;
|
|
12224
|
-
if (entry.options?.disabled === true) continue;
|
|
12225
|
-
return true;
|
|
12226
|
-
}
|
|
12227
|
-
return false;
|
|
12228
|
-
}
|
|
12229
|
-
/**
|
|
12230
|
-
* Report whether the compressor is already running. Never mounts.
|
|
12231
|
-
*
|
|
12232
|
-
* Resolves rather than rejects on every path: a compressor that will not start
|
|
12233
|
-
* is a card that says so, never a pool that fails to boot. The pool's own model
|
|
12234
|
-
* registry has nothing to do with compression, and one missing optional feature
|
|
12235
|
-
* must not take the accounts down with it.
|
|
12236
|
-
*/
|
|
12237
|
-
async function mountBillionContext(ctx, logger) {
|
|
12238
|
-
try {
|
|
12239
|
-
let loader;
|
|
12240
|
-
ctx.inject(["loader"], (scoped) => {
|
|
12241
|
-
loader = scoped.loader;
|
|
12242
|
-
});
|
|
12243
|
-
if (loader === void 0) return {
|
|
12244
|
-
state: "unavailable",
|
|
12245
|
-
reason: "the host exposes no plugin loader"
|
|
12246
|
-
};
|
|
12247
|
-
if (mountedInLoader(loader, BILLION_PACKAGE)) {
|
|
12248
|
-
logger.info?.(`[xdpool] billion-context is already mounted; leaving it alone`);
|
|
12249
|
-
return { state: "already-mounted" };
|
|
12250
|
-
}
|
|
12251
|
-
return { state: "not-required" };
|
|
12252
|
-
} catch (error) {
|
|
12253
|
-
return {
|
|
12254
|
-
state: "unavailable",
|
|
12255
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
12256
|
-
};
|
|
12257
|
-
}
|
|
12258
|
-
}
|
|
12259
|
-
//#endregion
|
|
12260
12254
|
//#region src/pack-accounts.ts
|
|
12261
12255
|
/**
|
|
12262
12256
|
* Channel ids whose accounts this plugin serves itself.
|
|
@@ -13726,6 +13720,33 @@ function withSessionQuotaHint(error, retryAfterMs) {
|
|
|
13726
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);
|
|
13727
13721
|
}
|
|
13728
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
|
+
/**
|
|
13729
13750
|
* POST one streaming turn and hand back chat-shaped deltas.
|
|
13730
13751
|
*
|
|
13731
13752
|
* The shape of the ANSWER decides how it is read, never its Content-Type: a front
|
|
@@ -13769,7 +13790,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
|
|
|
13769
13790
|
const retryAfter = retryAfterMs(response);
|
|
13770
13791
|
if (!response.ok) {
|
|
13771
13792
|
const refusal = await response.text().catch(() => "");
|
|
13772
|
-
throw withSessionQuotaHint(
|
|
13793
|
+
throw withSessionQuotaHint(withFreeTierHint(response.status, refusal, retryAfter), retryAfter);
|
|
13773
13794
|
}
|
|
13774
13795
|
if (response.body === null) throw new AggregateError("server", "the free lane returned no body", response.status);
|
|
13775
13796
|
let events;
|
|
@@ -13802,7 +13823,7 @@ async function postFreeStreamed({ modelId, body, session, requestId, hooks, plan
|
|
|
13802
13823
|
} catch {
|
|
13803
13824
|
continue;
|
|
13804
13825
|
}
|
|
13805
|
-
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);
|
|
13806
13827
|
const translated = chatChunksFor(wire, decoded, tools);
|
|
13807
13828
|
for (const delta of translated.frames) hooks.onDelta(delta);
|
|
13808
13829
|
if (translated.usage !== void 0) usage = translated.usage;
|
|
@@ -15019,6 +15040,71 @@ function appearanceChain(themeKey = APPEARANCE_THEME_KEY) {
|
|
|
15019
15040
|
};
|
|
15020
15041
|
}
|
|
15021
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
|
+
/**
|
|
15022
15108
|
* The three "summon an expert" events (`expert_summon_click` and friends).
|
|
15023
15109
|
*
|
|
15024
15110
|
* Paid before the conversation, in the order the app emits them.
|
|
@@ -15141,7 +15227,12 @@ const EVENT_CHAIN_BUILDERS = {
|
|
|
15141
15227
|
skill_1: true,
|
|
15142
15228
|
expert_5: true,
|
|
15143
15229
|
Expert_team_use_3: true,
|
|
15144
|
-
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
|
|
15145
15236
|
};
|
|
15146
15237
|
const AUTOMATION_TICK_MS = 6e4;
|
|
15147
15238
|
/** The jobs in the order a tick runs them: report before tasks, always. */
|
|
@@ -15349,6 +15440,16 @@ var WorkBuddyScheduler = class {
|
|
|
15349
15440
|
/** Host hooks that persist the ledger across restarts. */
|
|
15350
15441
|
loadEarnings;
|
|
15351
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();
|
|
15352
15453
|
constructor(pool, client, options = {}) {
|
|
15353
15454
|
this.pool = pool;
|
|
15354
15455
|
this.client = client;
|
|
@@ -15385,6 +15486,18 @@ var WorkBuddyScheduler = class {
|
|
|
15385
15486
|
this.saveEarningsFn = save;
|
|
15386
15487
|
}
|
|
15387
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
|
+
/**
|
|
15388
15501
|
* Fold a previously persisted ledger back in, when it belongs to today.
|
|
15389
15502
|
*
|
|
15390
15503
|
* Used after the settings document becomes readable, which happens after
|
|
@@ -15654,7 +15767,7 @@ var WorkBuddyScheduler = class {
|
|
|
15654
15767
|
compensationCredit: (existing?.compensationCredit ?? 0) + compensationCredit,
|
|
15655
15768
|
date: today
|
|
15656
15769
|
});
|
|
15657
|
-
this.persistEarnings();
|
|
15770
|
+
this.pendingEarningsWrite = this.persistEarnings();
|
|
15658
15771
|
}
|
|
15659
15772
|
/** Clear the per-account counters when the local day changes. */
|
|
15660
15773
|
rollEarnings(today) {
|
|
@@ -15670,9 +15783,18 @@ var WorkBuddyScheduler = class {
|
|
|
15670
15783
|
* Best effort on purpose: a failed save must never abort a run that has
|
|
15671
15784
|
* already collected rewards, and the in-memory ledger keeps serving the card
|
|
15672
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.
|
|
15673
15794
|
*/
|
|
15674
15795
|
persistEarnings() {
|
|
15675
|
-
|
|
15796
|
+
const save = this.saveEarningsFn;
|
|
15797
|
+
if (save === void 0) return Promise.resolve();
|
|
15676
15798
|
const accounts = {};
|
|
15677
15799
|
for (const [id, entry] of this.earnings) accounts[id] = entry;
|
|
15678
15800
|
const ledger = {
|
|
@@ -15680,12 +15802,14 @@ var WorkBuddyScheduler = class {
|
|
|
15680
15802
|
accounts
|
|
15681
15803
|
};
|
|
15682
15804
|
try {
|
|
15683
|
-
const saved =
|
|
15684
|
-
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) => {
|
|
15685
15807
|
this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
|
|
15686
15808
|
});
|
|
15809
|
+
return Promise.resolve();
|
|
15687
15810
|
} catch (error) {
|
|
15688
15811
|
this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
|
|
15812
|
+
return Promise.resolve();
|
|
15689
15813
|
}
|
|
15690
15814
|
}
|
|
15691
15815
|
/**
|
|
@@ -15794,6 +15918,7 @@ var WorkBuddyScheduler = class {
|
|
|
15794
15918
|
state.detail = detail;
|
|
15795
15919
|
state.progress = progressNote;
|
|
15796
15920
|
this.logger.info?.(`automation ${accountWord}: ${state.message}`);
|
|
15921
|
+
if (kind === "tasks" || kind === "checkin" || kind === "streak" || kind === "travel" || kind === "compensation") await this.flushEarnings();
|
|
15797
15922
|
}
|
|
15798
15923
|
/** Compose the one-line summary shown on the card. */
|
|
15799
15924
|
summarise(kind, ok, failed, claimed, credit, energy) {
|
|
@@ -15916,10 +16041,43 @@ var WorkBuddyScheduler = class {
|
|
|
15916
16041
|
case "expert_5": return this.expertChains(credential, "agent", task);
|
|
15917
16042
|
case "Expert_team_use_3": return this.expertChains(credential, "team", task);
|
|
15918
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);
|
|
15919
16049
|
default: return [];
|
|
15920
16050
|
}
|
|
15921
16051
|
}
|
|
15922
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
|
+
/**
|
|
15923
16081
|
* The summon-and-use chains for the expert tasks.
|
|
15924
16082
|
*
|
|
15925
16083
|
* Two steps per expert, and both are load-bearing: the summon events alone are
|
|
@@ -17259,33 +17417,6 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17259
17417
|
}
|
|
17260
17418
|
});
|
|
17261
17419
|
/**
|
|
17262
|
-
* The billion-context relay.
|
|
17263
|
-
*
|
|
17264
|
-
* GET only, loopback origin only. Its own route because it is the one
|
|
17265
|
-
* document here that comes from ANOTHER plugin's loopback listener: the
|
|
17266
|
-
* proxy publishes its own JSON and this only reads it, so folding it into
|
|
17267
|
-
* the pool status document would put a third-party service on the critical
|
|
17268
|
-
* path of every 30-second pool poll.
|
|
17269
|
-
*
|
|
17270
|
-
* Answered even when the proxy is absent — `state: 'missing'` with the
|
|
17271
|
-
* install command is far more actionable than a 503, and it is the normal
|
|
17272
|
-
* state on a machine that has not installed billion-context.
|
|
17273
|
-
*/
|
|
17274
|
-
const disposeBillion = ctx.webServer.register({
|
|
17275
|
-
kind: "exact",
|
|
17276
|
-
path: POOL_BILLION_PATH,
|
|
17277
|
-
handler: async (req, res) => {
|
|
17278
|
-
if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
|
|
17279
|
-
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
17280
|
-
if (deps.billionDocument === void 0) return json(res, 503, { error: "the billion-context relay is not available in this build" });
|
|
17281
|
-
try {
|
|
17282
|
-
json(res, 200, await deps.billionDocument());
|
|
17283
|
-
} catch (error) {
|
|
17284
|
-
json(res, 500, { error: safeMessage(error) });
|
|
17285
|
-
}
|
|
17286
|
-
}
|
|
17287
|
-
});
|
|
17288
|
-
/**
|
|
17289
17420
|
* The chain list plus the roster its members are drawn from.
|
|
17290
17421
|
*
|
|
17291
17422
|
* GET only. Deliberately not part of the pool status document: chains change
|
|
@@ -17361,7 +17492,6 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17361
17492
|
disposeRelay();
|
|
17362
17493
|
disposeCombosSave();
|
|
17363
17494
|
disposeCombos();
|
|
17364
|
-
disposeBillion();
|
|
17365
17495
|
disposeChannels();
|
|
17366
17496
|
disposeAggregate();
|
|
17367
17497
|
disposeAggregateDiscover();
|
|
@@ -17385,247 +17515,6 @@ function registerPoolStatusRoute(ctx, deps) {
|
|
|
17385
17515
|
}, "dsh-xdpool: Web status route");
|
|
17386
17516
|
}
|
|
17387
17517
|
//#endregion
|
|
17388
|
-
//#region src/billion.ts
|
|
17389
|
-
/**
|
|
17390
|
-
* Read-only observation of a locally running billion-context proxy.
|
|
17391
|
-
*
|
|
17392
|
-
* WHAT THIS IS, AND WHY IT IS NOT AN EMBEDDED COMPRESSOR
|
|
17393
|
-
*
|
|
17394
|
-
* billion-context is a separate DSH plugin that is very likely already
|
|
17395
|
-
* installed on this machine — on a stock desktop profile it is mounted by the
|
|
17396
|
-
* profile's bundle layer and runs its own loopback listener. This module never
|
|
17397
|
-
* implements compression and never proposes to: it finds that listener and
|
|
17398
|
-
* relays a few numbers so the card can show what the compressor is doing.
|
|
17399
|
-
* Re-implementing folding here would mean a second, divergent answer to the
|
|
17400
|
-
* same question and a second set of ways to get it wrong.
|
|
17401
|
-
*
|
|
17402
|
-
* HOW THE LISTENER IS FOUND
|
|
17403
|
-
*
|
|
17404
|
-
* The proxy picks an ephemeral port at spawn and records it in its own state
|
|
17405
|
-
* directory, which we read rather than guess. Guessing would mean scanning a
|
|
17406
|
-
* port range on every card poll, and a wrong guess is worse than no answer: it
|
|
17407
|
-
* could read some unrelated loopback service. The recorded file is
|
|
17408
|
-
* `<state>/billion-context/proxy-origin` and holds a JSON object with at least
|
|
17409
|
-
* an `origin`; `port-zone.json` is the same fact keyed by lane. Both are read,
|
|
17410
|
-
* and the origin file wins, because it is the one the proxy itself reads on
|
|
17411
|
-
* startup.
|
|
17412
|
-
*
|
|
17413
|
-
* `state` is resolved the same way the proxy resolves it: `XDG_STATE_HOME` when
|
|
17414
|
-
* set, else `<homedir>/.local/state` on every platform including Windows — not
|
|
17415
|
-
* `%LOCALAPPDATA%`, which is what a Windows developer would reach for first and
|
|
17416
|
-
* which would therefore be wrong.
|
|
17417
|
-
*
|
|
17418
|
-
* WHAT IS RELAYED
|
|
17419
|
-
*
|
|
17420
|
-
* Counts, sizes, timestamps. Not titles, not first-block hints, not any
|
|
17421
|
-
* conversation material. The proxy is happy to hand over more than that, and
|
|
17422
|
-
* taking it would put the user's own words into a document the card fetches
|
|
17423
|
-
* every 30 seconds and that lives in the plugin's HTTP log.
|
|
17424
|
-
*
|
|
17425
|
-
* The one addition to that list is the proxy's own origin, so the card can link
|
|
17426
|
-
* out to the dashboard the proxy already ships instead of redrawing it. That is
|
|
17427
|
-
* a service address rather than conversation material, and anyone who can read
|
|
17428
|
-
* this document can already reach that listener directly — which is why it is
|
|
17429
|
-
* safe to publish where a title would not be.
|
|
17430
|
-
*
|
|
17431
|
-
* The proxy also has NO AUTHENTICATION by its own documentation — the README
|
|
17432
|
-
* says so outright — so every request here is a hard local one, and the relay
|
|
17433
|
-
* only ever reads `/__bili/health` and `/__bili/sessions`. Both are GETs. This
|
|
17434
|
-
* module has no code path that could write to that proxy.
|
|
17435
|
-
*
|
|
17436
|
-
* @module dsh-xdpool/billion
|
|
17437
|
-
*/
|
|
17438
|
-
/** The proxy's own management prefix. Never guess it; it is theirs. */
|
|
17439
|
-
const BILI_PREFIX = "/__bili";
|
|
17440
|
-
/** Loopback only. The proxy binds 127.0.0.1 and we refuse anything else. */
|
|
17441
|
-
const LOOPBACK_HOSTS$1 = [
|
|
17442
|
-
"127.0.0.1",
|
|
17443
|
-
"localhost",
|
|
17444
|
-
"[::1]",
|
|
17445
|
-
"::1"
|
|
17446
|
-
];
|
|
17447
|
-
/**
|
|
17448
|
-
* How long one relay request may take.
|
|
17449
|
-
*
|
|
17450
|
-
* Short on purpose: this runs inside the card's status request, and a stalled
|
|
17451
|
-
* third-party proxy must degrade to a panel that says "no answer" rather than
|
|
17452
|
-
* hold the pool card hostage. The proxy answers these two endpoints in
|
|
17453
|
-
* milliseconds; the budget is for the case where it does not.
|
|
17454
|
-
*/
|
|
17455
|
-
const RELAY_TIMEOUT_MS = 1500;
|
|
17456
|
-
/** How many sessions the card is shown. 50 rows is already past the fold. */
|
|
17457
|
-
const MAX_SESSIONS = 50;
|
|
17458
|
-
/**
|
|
17459
|
-
* Where the proxy keeps its state directory.
|
|
17460
|
-
*
|
|
17461
|
-
* Mirrors the proxy's own resolution exactly: `XDG_STATE_HOME` when it holds an
|
|
17462
|
-
* absolute path, else `<homedir>/.local/state`. Deliberately NOT
|
|
17463
|
-
* `DSH_HOME ?? homedir()` — DSH_HOME is the profile root and a live value on a
|
|
17464
|
-
* real desktop install, so preferring it sent this reader to
|
|
17465
|
-
* `<profile>/.local/state/billion-context` and reported "missing" on a machine
|
|
17466
|
-
* where the proxy was running and answering. Probed against the live proxy,
|
|
17467
|
-
* which is the only way that mistake is visible: every unit test with a faked
|
|
17468
|
-
* state directory passes either way.
|
|
17469
|
-
*
|
|
17470
|
-
* Undefined when neither resolves, because inventing a path to probe would
|
|
17471
|
-
* turn "not installed" into a silent hunt through the filesystem.
|
|
17472
|
-
*/
|
|
17473
|
-
function stateRoot() {
|
|
17474
|
-
const xdg = env.XDG_STATE_HOME;
|
|
17475
|
-
if (typeof xdg === "string" && xdg !== "") {
|
|
17476
|
-
if (xdg.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(xdg)) return join(xdg, "billion-context");
|
|
17477
|
-
return;
|
|
17478
|
-
}
|
|
17479
|
-
const home = homedir();
|
|
17480
|
-
if (typeof home !== "string" || home === "") return void 0;
|
|
17481
|
-
return join(home, ".local", "state", "billion-context");
|
|
17482
|
-
}
|
|
17483
|
-
/** A loopback http(s) origin, or undefined for anything else. */
|
|
17484
|
-
function parseLoopbackOrigin(raw) {
|
|
17485
|
-
let url;
|
|
17486
|
-
try {
|
|
17487
|
-
url = new URL(raw);
|
|
17488
|
-
} catch {
|
|
17489
|
-
return;
|
|
17490
|
-
}
|
|
17491
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
17492
|
-
if (!LOOPBACK_HOSTS$1.includes(url.hostname.toLowerCase())) return void 0;
|
|
17493
|
-
return url.origin;
|
|
17494
|
-
}
|
|
17495
|
-
/**
|
|
17496
|
-
* The proxy origin, from the file the proxy itself reads on startup.
|
|
17497
|
-
*
|
|
17498
|
-
* `port-zone.json` is the fallback because it is keyed by lane and therefore
|
|
17499
|
-
* distinguishes the dsh proxy from, say, a concurrently running opencode one —
|
|
17500
|
-
* but `proxy-origin` is preferred because it is the single-writer record.
|
|
17501
|
-
*/
|
|
17502
|
-
async function readProxyOrigin() {
|
|
17503
|
-
const root = stateRoot();
|
|
17504
|
-
if (root === void 0) return void 0;
|
|
17505
|
-
for (const name of ["proxy-origin", "port-zone.json"]) {
|
|
17506
|
-
let raw;
|
|
17507
|
-
try {
|
|
17508
|
-
raw = await readFile(join(root, name), "utf8");
|
|
17509
|
-
} catch {
|
|
17510
|
-
continue;
|
|
17511
|
-
}
|
|
17512
|
-
if (name === "proxy-origin") {
|
|
17513
|
-
try {
|
|
17514
|
-
const parsed = JSON.parse(raw);
|
|
17515
|
-
const origin = typeof parsed.origin === "string" ? parseLoopbackOrigin(parsed.origin) : void 0;
|
|
17516
|
-
if (origin !== void 0) return origin;
|
|
17517
|
-
} catch {}
|
|
17518
|
-
continue;
|
|
17519
|
-
}
|
|
17520
|
-
try {
|
|
17521
|
-
const port = JSON.parse(raw).lanes?.dsh;
|
|
17522
|
-
if (typeof port === "number" && Number.isInteger(port) && port > 0 && port < 65536) return `http://127.0.0.1:${port}`;
|
|
17523
|
-
} catch {
|
|
17524
|
-
continue;
|
|
17525
|
-
}
|
|
17526
|
-
}
|
|
17527
|
-
}
|
|
17528
|
-
/** One GET against the proxy, with the relay budget on it. Never throws. */
|
|
17529
|
-
async function fetchJson(origin, path) {
|
|
17530
|
-
const controller = new AbortController();
|
|
17531
|
-
const timer = setTimeout(() => {
|
|
17532
|
-
controller.abort();
|
|
17533
|
-
}, RELAY_TIMEOUT_MS);
|
|
17534
|
-
try {
|
|
17535
|
-
const response = await fetch(new URL(BILI_PREFIX + path, origin), {
|
|
17536
|
-
method: "GET",
|
|
17537
|
-
headers: { accept: "application/json" },
|
|
17538
|
-
signal: controller.signal
|
|
17539
|
-
});
|
|
17540
|
-
if (!response.ok) return void 0;
|
|
17541
|
-
return await response.json();
|
|
17542
|
-
} catch {
|
|
17543
|
-
return;
|
|
17544
|
-
} finally {
|
|
17545
|
-
clearTimeout(timer);
|
|
17546
|
-
}
|
|
17547
|
-
}
|
|
17548
|
-
/** A finite number, or undefined. Guards every relayed field. */
|
|
17549
|
-
function num(value) {
|
|
17550
|
-
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
17551
|
-
}
|
|
17552
|
-
/** A string, or ''. */
|
|
17553
|
-
function str(value) {
|
|
17554
|
-
return typeof value === "string" ? value : "";
|
|
17555
|
-
}
|
|
17556
|
-
/** A boolean, or false. */
|
|
17557
|
-
function bool(value) {
|
|
17558
|
-
return value === true;
|
|
17559
|
-
}
|
|
17560
|
-
/**
|
|
17561
|
-
* One session row, reduced.
|
|
17562
|
-
*
|
|
17563
|
-
* Every field is defaulted rather than passed through: the proxy's document is
|
|
17564
|
-
* a moving target across versions, and an unreadable row should render as
|
|
17565
|
-
* zeroes we can explain, not as `undefined` reaching the card.
|
|
17566
|
-
*/
|
|
17567
|
-
function toSession(raw) {
|
|
17568
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
17569
|
-
const row = raw;
|
|
17570
|
-
const id = str(row.id);
|
|
17571
|
-
if (id === "") return void 0;
|
|
17572
|
-
return {
|
|
17573
|
-
id,
|
|
17574
|
-
protocol: str(row.protocol),
|
|
17575
|
-
live: bool(row.live),
|
|
17576
|
-
requests: num(row.requests) ?? 0,
|
|
17577
|
-
contextTokens: num(row.contextTokens) ?? 0,
|
|
17578
|
-
contextTokensSource: str(row.contextTokensSource),
|
|
17579
|
-
tokensSaved: num(row.tokensSaved) ?? 0,
|
|
17580
|
-
inputTokens: num(row.inputTokens) ?? 0,
|
|
17581
|
-
cachedTokens: num(row.cachedTokens) ?? 0,
|
|
17582
|
-
outputTokens: num(row.outputTokens) ?? 0,
|
|
17583
|
-
cacheHitPct: num(row.cacheHitPct) ?? -1,
|
|
17584
|
-
blocks: num(row.blocks) ?? 0,
|
|
17585
|
-
contextWindow: num(row.contextWindow) ?? 0,
|
|
17586
|
-
lastSeen: str(row.lastSeen),
|
|
17587
|
-
lastModel: str(row.lastModel)
|
|
17588
|
-
};
|
|
17589
|
-
}
|
|
17590
|
-
/** The whole relay, safe to hand the card: it never rejects. */
|
|
17591
|
-
async function readBillionStatus() {
|
|
17592
|
-
const origin = await readProxyOrigin();
|
|
17593
|
-
if (origin === void 0) return {
|
|
17594
|
-
state: "missing",
|
|
17595
|
-
reason: "billion-context is not running on this machine. Install it with \"dsh plugin --profile <name> add billion-context\" and this page fills in.",
|
|
17596
|
-
sessions: [],
|
|
17597
|
-
totalSessions: 0
|
|
17598
|
-
};
|
|
17599
|
-
const health = await fetchJson(origin, "/health");
|
|
17600
|
-
if (health === null || typeof health !== "object") return {
|
|
17601
|
-
state: "unreachable",
|
|
17602
|
-
reason: `billion-context is recorded at ${origin} but did not answer. It may have just been restarted.`,
|
|
17603
|
-
origin,
|
|
17604
|
-
sessions: [],
|
|
17605
|
-
totalSessions: 0
|
|
17606
|
-
};
|
|
17607
|
-
const meta = health;
|
|
17608
|
-
const sessionsDoc = await fetchJson(origin, "/sessions");
|
|
17609
|
-
const rawSessions = sessionsDoc !== null && typeof sessionsDoc === "object" ? sessionsDoc.sessions : void 0;
|
|
17610
|
-
const rows = Array.isArray(rawSessions) ? rawSessions.slice(0, MAX_SESSIONS) : [];
|
|
17611
|
-
const sessions = [];
|
|
17612
|
-
for (const row of rows) {
|
|
17613
|
-
const session = toSession(row);
|
|
17614
|
-
if (session !== void 0) sessions.push(session);
|
|
17615
|
-
}
|
|
17616
|
-
const total = num(sessionsDoc !== null && typeof sessionsDoc === "object" ? sessionsDoc.total : void 0) ?? sessions.length;
|
|
17617
|
-
const version = str(meta.version);
|
|
17618
|
-
const startedAt = num(meta.startedAt);
|
|
17619
|
-
return {
|
|
17620
|
-
state: "ready",
|
|
17621
|
-
origin,
|
|
17622
|
-
...version === "" ? {} : { version },
|
|
17623
|
-
...startedAt === void 0 ? {} : { startedAt },
|
|
17624
|
-
sessions,
|
|
17625
|
-
totalSessions: total
|
|
17626
|
-
};
|
|
17627
|
-
}
|
|
17628
|
-
//#endregion
|
|
17629
17518
|
//#region src/diagnostics.ts
|
|
17630
17519
|
/**
|
|
17631
17520
|
* A startup trace this plugin owns.
|
|
@@ -18631,6 +18520,34 @@ function createWorkBuddyShim(options) {
|
|
|
18631
18520
|
const server = createServer((req, res) => {
|
|
18632
18521
|
handle(req, res);
|
|
18633
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
|
+
}
|
|
18634
18551
|
/**
|
|
18635
18552
|
* "The same model has a paid row" sentence for a 429 body, or undefined.
|
|
18636
18553
|
*
|
|
@@ -19131,9 +19048,9 @@ function createWorkBuddyShim(options) {
|
|
|
19131
19048
|
if (signal.aborted) return;
|
|
19132
19049
|
attempts.push(member.modelId);
|
|
19133
19050
|
if (!member.aggregate) {
|
|
19134
|
-
const account = await
|
|
19051
|
+
const { account, reserveExempt } = await acquireForRequest(member.modelId, conversationKey, signal);
|
|
19135
19052
|
if (account === void 0) {
|
|
19136
|
-
const why = pool.unavailableReason(member.modelId, region);
|
|
19053
|
+
const why = pool.unavailableReason(member.modelId, region, reserveExempt);
|
|
19137
19054
|
last = {
|
|
19138
19055
|
kind: why.reason,
|
|
19139
19056
|
status: why.reason === "cooling" ? 429 : 401,
|
|
@@ -19141,7 +19058,11 @@ function createWorkBuddyShim(options) {
|
|
|
19141
19058
|
};
|
|
19142
19059
|
continue;
|
|
19143
19060
|
}
|
|
19144
|
-
const
|
|
19061
|
+
const memberRaw = JSON.stringify({
|
|
19062
|
+
...JSON.parse(raw),
|
|
19063
|
+
model: member.modelId
|
|
19064
|
+
});
|
|
19065
|
+
const prepared = client.prepareChatBodyFor(memberRaw, region ?? "cn");
|
|
19145
19066
|
const result = await client.chatStream(account.credential, prepared, signal, conversationKey);
|
|
19146
19067
|
if (result.ok) {
|
|
19147
19068
|
const turnId = randomBytes(8).toString("hex");
|
|
@@ -19305,9 +19226,9 @@ function createWorkBuddyShim(options) {
|
|
|
19305
19226
|
};
|
|
19306
19227
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
19307
19228
|
if (controller.signal.aborted) return;
|
|
19308
|
-
const account = await
|
|
19229
|
+
const { account, reserveExempt } = await acquireForRequest(modelId, conversationKey, controller.signal);
|
|
19309
19230
|
if (account === void 0) {
|
|
19310
|
-
const why = pool.unavailableReason(modelId, region);
|
|
19231
|
+
const why = pool.unavailableReason(modelId, region, reserveExempt);
|
|
19311
19232
|
if (why.reason === "cooling") {
|
|
19312
19233
|
const subject = modelId === void 0 ? "every WorkBuddy account is rate-limited" : `every account is rate-limited for model ${modelId}`;
|
|
19313
19234
|
const upstream = last === void 0 ? "" : ` — ${last.message.slice(0, 200)}`;
|
|
@@ -19320,7 +19241,7 @@ function createWorkBuddyShim(options) {
|
|
|
19320
19241
|
return;
|
|
19321
19242
|
}
|
|
19322
19243
|
if (why.reason === "reserve") {
|
|
19323
|
-
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`);
|
|
19324
19245
|
return;
|
|
19325
19246
|
}
|
|
19326
19247
|
if (why.reason === "session_dead") {
|
|
@@ -19374,7 +19295,7 @@ function createWorkBuddyShim(options) {
|
|
|
19374
19295
|
}
|
|
19375
19296
|
if (isContextTooLong(last.message)) {
|
|
19376
19297
|
const recovered = await recoverFromContextOverrun({
|
|
19377
|
-
raw,
|
|
19298
|
+
raw: poolRaw,
|
|
19378
19299
|
modelId,
|
|
19379
19300
|
controller,
|
|
19380
19301
|
region,
|
|
@@ -19382,6 +19303,7 @@ function createWorkBuddyShim(options) {
|
|
|
19382
19303
|
client,
|
|
19383
19304
|
pool,
|
|
19384
19305
|
maxAttempts,
|
|
19306
|
+
acquireAccount: async () => (await acquireForRequest(modelId, conversationKey, controller.signal)).account,
|
|
19385
19307
|
usage: usageContext,
|
|
19386
19308
|
contextWindow: modelId === void 0 ? void 0 : catalog.current().find((m) => m.id === modelId)?.contextWindow
|
|
19387
19309
|
});
|
|
@@ -19579,7 +19501,7 @@ async function recoverFromContextOverrun(options) {
|
|
|
19579
19501
|
let compacted = messages;
|
|
19580
19502
|
let compactionDetail = "";
|
|
19581
19503
|
try {
|
|
19582
|
-
const summariser = await pool.acquire(modelId, region);
|
|
19504
|
+
const summariser = await (options.acquireAccount?.() ?? pool.acquire(modelId, region));
|
|
19583
19505
|
if (summariser === void 0) compactionDetail = "no account available to summarise with";
|
|
19584
19506
|
else {
|
|
19585
19507
|
const outcome = await compactWithSummary(messages, {
|
|
@@ -19612,7 +19534,7 @@ async function recoverFromContextOverrun(options) {
|
|
|
19612
19534
|
ok: false,
|
|
19613
19535
|
detail: "client disconnected"
|
|
19614
19536
|
};
|
|
19615
|
-
const account = await pool.acquire(modelId, region);
|
|
19537
|
+
const account = await (options.acquireAccount?.() ?? pool.acquire(modelId, region));
|
|
19616
19538
|
if (account === void 0) return {
|
|
19617
19539
|
ok: false,
|
|
19618
19540
|
detail: "no account available after compaction"
|
|
@@ -21088,9 +21010,7 @@ function apply(ctx, config = {}) {
|
|
|
21088
21010
|
await setSetting("disabledAccountIds", next, next);
|
|
21089
21011
|
},
|
|
21090
21012
|
setCreditReserve: async (accountId, reserve) => {
|
|
21091
|
-
const next =
|
|
21092
|
-
if (reserve > 0) next[accountId] = reserve;
|
|
21093
|
-
else delete next[accountId];
|
|
21013
|
+
const next = creditReservesWith(current().creditReserves, accountId, reserve);
|
|
21094
21014
|
await setSetting("creditReserves", next, next);
|
|
21095
21015
|
},
|
|
21096
21016
|
setAccountIgnored: async (accountId, ignored) => {
|
|
@@ -21264,7 +21184,6 @@ function apply(ctx, config = {}) {
|
|
|
21264
21184
|
await aggregateCatalog.refresh();
|
|
21265
21185
|
return combosStatusOf(readAggregateSettingsSync(), aggregateCatalog, core.catalogs.cn.current());
|
|
21266
21186
|
},
|
|
21267
|
-
billionDocument: readBillionStatus,
|
|
21268
21187
|
/**
|
|
21269
21188
|
* Relay state plus the gateway's model picker.
|
|
21270
21189
|
*
|
|
@@ -21342,12 +21261,6 @@ function apply(ctx, config = {}) {
|
|
|
21342
21261
|
adoptChannelPackAccounts(ctx, core.pool, ctx.logger);
|
|
21343
21262
|
});
|
|
21344
21263
|
});
|
|
21345
|
-
mountBillionContext(ctx, ctx.logger).then((outcome) => {
|
|
21346
|
-
recordStartupNote(`billion-context ${outcome.state}`);
|
|
21347
|
-
if (outcome.state === "unavailable") ctx.logger.warn(`[xdpool] billion-context not mounted — ${outcome.reason}`);
|
|
21348
|
-
}, (error) => {
|
|
21349
|
-
recordStartupNote(`billion-context mount failed: ${describeError(error)}`);
|
|
21350
|
-
});
|
|
21351
21264
|
Promise.all([shims.cn.ready, shims.global.ready]).then(async () => {
|
|
21352
21265
|
if (stopped) return;
|
|
21353
21266
|
recordStartupNote(`both shims ready cn=${shims.cn.baseUrl()} global=${shims.global.baseUrl()}; entering registration`);
|