@eddyskywalker/dsh-chatgpt-subscription 0.26.1 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +57 -0
- package/lib/client.js +2176 -116
- package/lib/client.js.map +1 -1
- package/lib/index.js +2112 -93
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/progress-updates/contracts.d.ts +125 -0
- package/lib/types/client/progress-updates/contracts.d.ts.map +1 -0
- package/lib/types/client/progress-updates/guide-artwork.d.ts +28 -0
- package/lib/types/client/progress-updates/guide-artwork.d.ts.map +1 -0
- package/lib/types/client/progress-updates/host-source.d.ts +128 -0
- package/lib/types/client/progress-updates/host-source.d.ts.map +1 -0
- package/lib/types/client/progress-updates/index.d.ts +91 -0
- package/lib/types/client/progress-updates/index.d.ts.map +1 -0
- package/lib/types/client/progress-updates/locales.d.ts +127 -0
- package/lib/types/client/progress-updates/locales.d.ts.map +1 -0
- package/lib/types/client/progress-updates/markdown-lite.d.ts +60 -0
- package/lib/types/client/progress-updates/markdown-lite.d.ts.map +1 -0
- package/lib/types/client/progress-updates/progress-row.d.ts +87 -0
- package/lib/types/client/progress-updates/progress-row.d.ts.map +1 -0
- package/lib/types/client/progress-updates/settings-binding.d.ts +71 -0
- package/lib/types/client/progress-updates/settings-binding.d.ts.map +1 -0
- package/lib/types/client/progress-updates/settings-section.d.ts +57 -0
- package/lib/types/client/progress-updates/settings-section.d.ts.map +1 -0
- package/lib/types/client/progress-updates/styles.d.ts +34 -0
- package/lib/types/client/progress-updates/styles.d.ts.map +1 -0
- package/lib/types/client/progress-updates/tool-name.d.ts +14 -0
- package/lib/types/client/progress-updates/tool-name.d.ts.map +1 -0
- package/lib/types/client/progress-updates/updates-panel.d.ts +113 -0
- package/lib/types/client/progress-updates/updates-panel.d.ts.map +1 -0
- package/lib/types/client/step-code/locales.d.ts +2 -2
- package/lib/types/client/workbuddy/WorkBuddySection.d.ts.map +1 -1
- package/lib/types/client/workbuddy/locales.d.ts +108 -0
- package/lib/types/client/workbuddy/locales.d.ts.map +1 -1
- package/lib/types/client/workbuddy/styles.d.ts.map +1 -1
- package/lib/types/client/workbuddy/useWorkBuddySection.d.ts +9 -1
- package/lib/types/client/workbuddy/useWorkBuddySection.d.ts.map +1 -1
- package/lib/types/host/progress-updates/index.d.ts +90 -0
- package/lib/types/host/progress-updates/index.d.ts.map +1 -0
- package/lib/types/host/progress-updates/nudge.d.ts +91 -0
- package/lib/types/host/progress-updates/nudge.d.ts.map +1 -0
- package/lib/types/host/progress-updates/prompt.d.ts +34 -0
- package/lib/types/host/progress-updates/prompt.d.ts.map +1 -0
- package/lib/types/host/progress-updates/routes.d.ts +39 -0
- package/lib/types/host/progress-updates/routes.d.ts.map +1 -0
- package/lib/types/host/progress-updates/store.d.ts +74 -0
- package/lib/types/host/progress-updates/store.d.ts.map +1 -0
- package/lib/types/host/progress-updates/tool.d.ts +42 -0
- package/lib/types/host/progress-updates/tool.d.ts.map +1 -0
- package/lib/types/host/reasoning-collapse-guard/index.d.ts +5 -0
- package/lib/types/host/reasoning-collapse-guard/index.d.ts.map +1 -1
- package/lib/types/host/web-provider-reconciler.d.ts +34 -0
- package/lib/types/host/web-provider-reconciler.d.ts.map +1 -0
- package/lib/types/host/workbuddy/account-pool.d.ts +32 -1
- package/lib/types/host/workbuddy/account-pool.d.ts.map +1 -1
- package/lib/types/host/workbuddy/adapter.d.ts +32 -0
- package/lib/types/host/workbuddy/adapter.d.ts.map +1 -1
- package/lib/types/host/workbuddy/atrest.d.ts +98 -0
- package/lib/types/host/workbuddy/atrest.d.ts.map +1 -0
- package/lib/types/host/workbuddy/checkin.d.ts +16 -0
- package/lib/types/host/workbuddy/checkin.d.ts.map +1 -1
- package/lib/types/host/workbuddy/client.d.ts +9 -0
- package/lib/types/host/workbuddy/client.d.ts.map +1 -1
- package/lib/types/host/workbuddy/dsml.d.ts +55 -0
- package/lib/types/host/workbuddy/dsml.d.ts.map +1 -0
- package/lib/types/host/workbuddy/guardrails.d.ts +111 -0
- package/lib/types/host/workbuddy/guardrails.d.ts.map +1 -0
- package/lib/types/host/workbuddy/mapper.d.ts +11 -0
- package/lib/types/host/workbuddy/mapper.d.ts.map +1 -1
- package/lib/types/host/workbuddy/model-catalog.d.ts +8 -1
- package/lib/types/host/workbuddy/model-catalog.d.ts.map +1 -1
- package/lib/types/host/workbuddy/outbound-identity.d.ts +66 -0
- package/lib/types/host/workbuddy/outbound-identity.d.ts.map +1 -0
- package/lib/types/host/workbuddy/routes.d.ts +8 -1
- package/lib/types/host/workbuddy/routes.d.ts.map +1 -1
- package/lib/types/host/workbuddy/token-store.d.ts +93 -0
- package/lib/types/host/workbuddy/token-store.d.ts.map +1 -1
- package/lib/types/index.d.ts +63 -5
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/shared/workbuddy-contracts.d.ts +54 -2
- package/lib/types/shared/workbuddy-contracts.d.ts.map +1 -1
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import * as LlmModule from "@deepseek-ai/dsh-llm";
|
|
4
|
-
import { HarnessError, LlmAdapter, LlmError, LlmError as LlmError$1, ProviderRequestId, ReasoningEffortId, attributionHeaders, errorChain, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
4
|
+
import { HarnessError, LlmAdapter, LlmError, LlmError as LlmError$1, ProviderRequestId, ReasoningEffortId, attributionHeaders, createUserMessage, errorChain, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
5
5
|
import fs, { chmod, lstat, mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
6
6
|
import path, { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
7
|
import os, { homedir, platform, userInfo } from "node:os";
|
|
@@ -14,7 +14,7 @@ import { WebError } from "@deepseek-ai/dsh-web";
|
|
|
14
14
|
import { lookup } from "node:dns/promises";
|
|
15
15
|
import { isIP } from "node:net";
|
|
16
16
|
import { MAX_TIMER_DELAY_MS, idleWatchdog, timeoutOf } from "@deepseek-ai/dsh-timeout";
|
|
17
|
-
import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
|
|
17
|
+
import { boundContextSummary, createUserMessage as createUserMessage$1 } from "@deepseek-ai/dsh-llm/message";
|
|
18
18
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
19
19
|
import * as SettingsModule from "@deepseek-ai/dsh-settings";
|
|
20
20
|
import { URL as URL$1, URLSearchParams as URLSearchParams$1, fileURLToPath } from "node:url";
|
|
@@ -1361,14 +1361,14 @@ const SHARD_PAYLOAD_SIZE = 7e3;
|
|
|
1361
1361
|
*/
|
|
1362
1362
|
const MAX_SHARDS = 128;
|
|
1363
1363
|
/** Property that marks a primary entry as the envelope of a chunked value. */
|
|
1364
|
-
const ENVELOPE_KEY = "__dshChunked";
|
|
1364
|
+
const ENVELOPE_KEY$1 = "__dshChunked";
|
|
1365
1365
|
/**
|
|
1366
1366
|
* Read the envelope of a stored primary entry, or null when the entry holds a
|
|
1367
1367
|
* plain value. A malformed envelope is invalid stored data, not a plain value.
|
|
1368
1368
|
*/
|
|
1369
1369
|
function envelopeFrom(parsed) {
|
|
1370
1370
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
1371
|
-
const marker = parsed[ENVELOPE_KEY];
|
|
1371
|
+
const marker = parsed[ENVELOPE_KEY$1];
|
|
1372
1372
|
if (marker === void 0) return null;
|
|
1373
1373
|
if (typeof marker !== "object" || marker === null) throw new Error(INVALID);
|
|
1374
1374
|
const { gen, chunks, bytes } = marker;
|
|
@@ -1489,7 +1489,7 @@ var SecretServiceCredentialStore = class {
|
|
|
1489
1489
|
"store",
|
|
1490
1490
|
"--label=DSH Antigravity OAuth",
|
|
1491
1491
|
...this.attributes()
|
|
1492
|
-
], JSON.stringify({ [ENVELOPE_KEY]: envelope }))).code !== 0) {
|
|
1492
|
+
], JSON.stringify({ [ENVELOPE_KEY$1]: envelope }))).code !== 0) {
|
|
1493
1493
|
await this.clearShards(gen, chunks);
|
|
1494
1494
|
throw new Error(UNAVAILABLE);
|
|
1495
1495
|
}
|
|
@@ -4348,7 +4348,7 @@ function createCodexImageTool(oauth, attachments, options = {}) {
|
|
|
4348
4348
|
...image.name !== void 0 ? { name: image.name } : {}
|
|
4349
4349
|
}
|
|
4350
4350
|
};
|
|
4351
|
-
if (exec.parent !== void 0) exec.deferContext(createUserMessage({
|
|
4351
|
+
if (exec.parent !== void 0) exec.deferContext(createUserMessage$1({
|
|
4352
4352
|
content: [{
|
|
4353
4353
|
type: "image",
|
|
4354
4354
|
attachment: image
|
|
@@ -8293,7 +8293,7 @@ function createAntigravityImageTool(accountPool, store, attachments, options = {
|
|
|
8293
8293
|
...image.name !== void 0 ? { name: image.name } : {}
|
|
8294
8294
|
}))
|
|
8295
8295
|
};
|
|
8296
|
-
if (exec.parent !== void 0) exec.deferContext(createUserMessage({
|
|
8296
|
+
if (exec.parent !== void 0) exec.deferContext(createUserMessage$1({
|
|
8297
8297
|
content: saved.map((attachment) => ({
|
|
8298
8298
|
type: "image",
|
|
8299
8299
|
attachment
|
|
@@ -12256,6 +12256,24 @@ function runningProviderValue(value) {
|
|
|
12256
12256
|
function providerId(value) {
|
|
12257
12257
|
return typeof value === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(value) ? value : null;
|
|
12258
12258
|
}
|
|
12259
|
+
/**
|
|
12260
|
+
* Start reconciling the web provider selection on a timer.
|
|
12261
|
+
*
|
|
12262
|
+
* @param options - the reconcile callback and an optional interval.
|
|
12263
|
+
* @returns the disposer that stops the timer.
|
|
12264
|
+
*/
|
|
12265
|
+
function startWebProviderReconciler(options) {
|
|
12266
|
+
const intervalMs = options.intervalMs ?? 3e4;
|
|
12267
|
+
const timer = setInterval(() => {
|
|
12268
|
+
try {
|
|
12269
|
+
options.reconcile();
|
|
12270
|
+
} catch {}
|
|
12271
|
+
}, intervalMs);
|
|
12272
|
+
timer.unref?.();
|
|
12273
|
+
return () => {
|
|
12274
|
+
clearInterval(timer);
|
|
12275
|
+
};
|
|
12276
|
+
}
|
|
12259
12277
|
//#endregion
|
|
12260
12278
|
//#region src/host/common/quota-refresh.ts
|
|
12261
12279
|
/**
|
|
@@ -26783,7 +26801,7 @@ function createKimiVideoTool(ctx, options = {}) {
|
|
|
26783
26801
|
throw error;
|
|
26784
26802
|
}
|
|
26785
26803
|
const question = typeof args.question === "string" ? args.question.trim() : "";
|
|
26786
|
-
exec.deferContext(createUserMessage({
|
|
26804
|
+
exec.deferContext(createUserMessage$1({
|
|
26787
26805
|
content: [{
|
|
26788
26806
|
type: "video",
|
|
26789
26807
|
attachment: ref
|
|
@@ -32720,8 +32738,17 @@ function parseWorkBuddyCreditMultiplier(raw) {
|
|
|
32720
32738
|
if (!Number.isFinite(value) || value < 0) return null;
|
|
32721
32739
|
return stripped;
|
|
32722
32740
|
}
|
|
32723
|
-
/**
|
|
32741
|
+
/**
|
|
32742
|
+
* Every level this route can name, in escalating order (the card's sort key).
|
|
32743
|
+
*
|
|
32744
|
+
* `none` is the one level that is not a rung of any ladder: it is the measured
|
|
32745
|
+
* spelling that turns the reasoning trace OFF upstream (`reasoning_effort:
|
|
32746
|
+
* "none"`, the value the sibling gateway sends on its own cheap calls). It is
|
|
32747
|
+
* only ever offered on a model the catalog marks `canDisableThinking`, so it
|
|
32748
|
+
* sorts below everything and is never used to converge a level onto a ladder.
|
|
32749
|
+
*/
|
|
32724
32750
|
const WORKBUDDY_REASONING_EFFORTS = [
|
|
32751
|
+
"none",
|
|
32725
32752
|
"minimal",
|
|
32726
32753
|
"low",
|
|
32727
32754
|
"medium",
|
|
@@ -33853,9 +33880,18 @@ function modelsForRegion$1(region, catalog = FALLBACK_MODELS$1) {
|
|
|
33853
33880
|
function workBuddyModelSupportsImage(id, catalog = FALLBACK_MODELS$1) {
|
|
33854
33881
|
return resolveWorkBuddyModel(id, catalog).supportsImage;
|
|
33855
33882
|
}
|
|
33856
|
-
/**
|
|
33883
|
+
/**
|
|
33884
|
+
* Reasoning ladder one model accepts, plus `none` when its trace can be turned off.
|
|
33885
|
+
*
|
|
33886
|
+
* The ladder in the catalog is what the model ACCEPTS, so `none` never appears
|
|
33887
|
+
* in it; `canDisableThinking` is the separate statement that switching thinking
|
|
33888
|
+
* off is allowed, and this is the single place that turns it into the one level
|
|
33889
|
+
* the picker can offer. Callers that need the ladder alone read the entry.
|
|
33890
|
+
*/
|
|
33857
33891
|
function workBuddyReasoningEfforts(id, catalog = FALLBACK_MODELS$1) {
|
|
33858
|
-
|
|
33892
|
+
const entry = resolveWorkBuddyModel(id, catalog);
|
|
33893
|
+
if (!entry.canDisableThinking || entry.reasoningEfforts.length === 0) return entry.reasoningEfforts;
|
|
33894
|
+
return entry.reasoningEfforts.includes("none") ? entry.reasoningEfforts : ["none", ...entry.reasoningEfforts];
|
|
33859
33895
|
}
|
|
33860
33896
|
/** Output cap requested when the caller omits one. */
|
|
33861
33897
|
function maxOutputTokensFor$3(id, catalog = FALLBACK_MODELS$1) {
|
|
@@ -33972,6 +34008,547 @@ function withResolvedIdentity(credentials) {
|
|
|
33972
34008
|
return changed ? next : credentials;
|
|
33973
34009
|
}
|
|
33974
34010
|
//#endregion
|
|
34011
|
+
//#region src/host/workbuddy/guardrails.ts
|
|
34012
|
+
/**
|
|
34013
|
+
* Local quota guardrails for the WorkBuddy line.
|
|
34014
|
+
*
|
|
34015
|
+
* The subscription meters an account × model over a 24h window (429 `code
|
|
34016
|
+
* 6004`), but it only says so AFTER the window is gone — and on the shared
|
|
34017
|
+
* account that window is minutes of a large model or a day of a small one. These
|
|
34018
|
+
* guardrails stop serving BEFORE that happens, so an exhausted window never has
|
|
34019
|
+
* to be discovered by a user staring at a 429.
|
|
34020
|
+
*
|
|
34021
|
+
* Three of the four guardrails workbuddy2api-hub ships are reproduced here, and
|
|
34022
|
+
* all three are EXACT — they read numbers this plugin already has:
|
|
34023
|
+
*
|
|
34024
|
+
* reserveCredits stop the account when its remaining credits fall below N
|
|
34025
|
+
* (the billing snapshot already reports the remainder)
|
|
34026
|
+
* dailyTokens stop the account for the rest of the local day at N tokens
|
|
34027
|
+
* modelDailyTokens stop ONE model for the rest of the local day at N tokens
|
|
34028
|
+
*
|
|
34029
|
+
* The fourth one there — a daily CREDITS budget — is deliberately NOT
|
|
34030
|
+
* reproduced: a request's credit cost is not on the wire this route reads, and
|
|
34031
|
+
* deriving it from the catalog multiplier × tokens would invent a number that
|
|
34032
|
+
* silently diverges from the bill. A guardrail that lies is worse than none.
|
|
34033
|
+
*
|
|
34034
|
+
* `0` means OFF for every field, and the default is all-off: an install that
|
|
34035
|
+
* never opens this setting behaves exactly as before.
|
|
34036
|
+
*/
|
|
34037
|
+
/** Shipped default: every guardrail off. */
|
|
34038
|
+
const DEFAULT_GUARDRAILS = {
|
|
34039
|
+
reserveCredits: 0,
|
|
34040
|
+
dailyTokens: 0,
|
|
34041
|
+
modelDailyTokens: 0
|
|
34042
|
+
};
|
|
34043
|
+
function positiveOrZero(value) {
|
|
34044
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return 0;
|
|
34045
|
+
return Math.floor(value);
|
|
34046
|
+
}
|
|
34047
|
+
/** Narrow a stored or patched value into guardrails; anything unusable means off. */
|
|
34048
|
+
function normalizeGuardrails(value) {
|
|
34049
|
+
if (typeof value !== "object" || value === null) return { ...DEFAULT_GUARDRAILS };
|
|
34050
|
+
const record = value;
|
|
34051
|
+
return {
|
|
34052
|
+
reserveCredits: positiveOrZero(record.reserveCredits),
|
|
34053
|
+
dailyTokens: positiveOrZero(record.dailyTokens),
|
|
34054
|
+
modelDailyTokens: positiveOrZero(record.modelDailyTokens)
|
|
34055
|
+
};
|
|
34056
|
+
}
|
|
34057
|
+
/** Whether any guardrail is armed; a no-op check must stay cheap. */
|
|
34058
|
+
function guardrailsArmed(guardrails) {
|
|
34059
|
+
return guardrails.reserveCredits > 0 || guardrails.dailyTokens > 0 || guardrails.modelDailyTokens > 0;
|
|
34060
|
+
}
|
|
34061
|
+
/**
|
|
34062
|
+
* The local calendar day a counter belongs to.
|
|
34063
|
+
*
|
|
34064
|
+
* Local, not UTC, because the reset the user reasons about is their own
|
|
34065
|
+
* midnight — the same choice this line makes for the CN check-in day.
|
|
34066
|
+
*/
|
|
34067
|
+
function localDayKey(now = Date.now()) {
|
|
34068
|
+
const date = new Date(now);
|
|
34069
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
34070
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
34071
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
34072
|
+
}
|
|
34073
|
+
/** Where the local usage counters live. */
|
|
34074
|
+
function usageLedgerPath() {
|
|
34075
|
+
return path.join(dshHomeDir(), "storages", "workbuddy-usage.json");
|
|
34076
|
+
}
|
|
34077
|
+
/**
|
|
34078
|
+
* Token counters per account per local day.
|
|
34079
|
+
*
|
|
34080
|
+
* Deliberately a small persisted document rather than an accounting system: it
|
|
34081
|
+
* exists to answer one question ("has this account / model used its day?") and a
|
|
34082
|
+
* restart must not forget it, or a guardrail would reset by restarting DSH.
|
|
34083
|
+
*/
|
|
34084
|
+
var WorkBuddyUsageLedger = class {
|
|
34085
|
+
filePath;
|
|
34086
|
+
now;
|
|
34087
|
+
document = {
|
|
34088
|
+
version: 1,
|
|
34089
|
+
day: localDayKey(),
|
|
34090
|
+
accounts: {}
|
|
34091
|
+
};
|
|
34092
|
+
loaded = false;
|
|
34093
|
+
writing = Promise.resolve();
|
|
34094
|
+
constructor(filePath = usageLedgerPath(), now = () => Date.now()) {
|
|
34095
|
+
this.filePath = filePath;
|
|
34096
|
+
this.now = now;
|
|
34097
|
+
}
|
|
34098
|
+
/** Read the counters once; a missing or unreadable file starts a fresh day. */
|
|
34099
|
+
async load() {
|
|
34100
|
+
if (this.loaded) return;
|
|
34101
|
+
this.loaded = true;
|
|
34102
|
+
try {
|
|
34103
|
+
const raw = JSON.parse(await fs.readFile(this.filePath, "utf8"));
|
|
34104
|
+
if (typeof raw !== "object" || raw === null) return;
|
|
34105
|
+
const record = raw;
|
|
34106
|
+
const accounts = typeof record.accounts === "object" && record.accounts !== null ? record.accounts : {};
|
|
34107
|
+
const entries = {};
|
|
34108
|
+
for (const [accountId, value] of Object.entries(accounts)) {
|
|
34109
|
+
if (typeof value !== "object" || value === null) continue;
|
|
34110
|
+
const entry = value;
|
|
34111
|
+
const perModel = {};
|
|
34112
|
+
if (typeof entry.perModel === "object" && entry.perModel !== null) {
|
|
34113
|
+
for (const [model, tokens] of Object.entries(entry.perModel)) if (typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) perModel[model] = tokens;
|
|
34114
|
+
}
|
|
34115
|
+
entries[accountId] = {
|
|
34116
|
+
tokens: typeof entry.tokens === "number" && Number.isFinite(entry.tokens) ? entry.tokens : 0,
|
|
34117
|
+
perModel
|
|
34118
|
+
};
|
|
34119
|
+
}
|
|
34120
|
+
this.document = {
|
|
34121
|
+
version: 1,
|
|
34122
|
+
day: typeof record.day === "string" ? record.day : localDayKey(this.now()),
|
|
34123
|
+
accounts: entries
|
|
34124
|
+
};
|
|
34125
|
+
this.rollIfNewDay();
|
|
34126
|
+
} catch {}
|
|
34127
|
+
}
|
|
34128
|
+
/** Drop yesterday's counters the moment a read notices the day changed. */
|
|
34129
|
+
rollIfNewDay() {
|
|
34130
|
+
const today = localDayKey(this.now());
|
|
34131
|
+
if (this.document.day === today) return;
|
|
34132
|
+
this.document = {
|
|
34133
|
+
version: 1,
|
|
34134
|
+
day: today,
|
|
34135
|
+
accounts: {}
|
|
34136
|
+
};
|
|
34137
|
+
}
|
|
34138
|
+
/** Tokens one account has used today, across models. */
|
|
34139
|
+
tokensToday(accountId) {
|
|
34140
|
+
this.rollIfNewDay();
|
|
34141
|
+
return this.document.accounts[accountId]?.tokens ?? 0;
|
|
34142
|
+
}
|
|
34143
|
+
/** Tokens one account has used today on one model. */
|
|
34144
|
+
modelTokensToday(accountId, model) {
|
|
34145
|
+
this.rollIfNewDay();
|
|
34146
|
+
return this.document.accounts[accountId]?.perModel[model] ?? 0;
|
|
34147
|
+
}
|
|
34148
|
+
/** Today's counters, for the settings card and for tests. */
|
|
34149
|
+
snapshot() {
|
|
34150
|
+
this.rollIfNewDay();
|
|
34151
|
+
return this.document.accounts;
|
|
34152
|
+
}
|
|
34153
|
+
/**
|
|
34154
|
+
* Add one request's tokens.
|
|
34155
|
+
*
|
|
34156
|
+
* Serialized through a promise chain: two concurrent requests on different
|
|
34157
|
+
* accounts would otherwise read the same document, add their own counts and
|
|
34158
|
+
* write, losing one of them.
|
|
34159
|
+
*/
|
|
34160
|
+
async record(accountId, model, tokens) {
|
|
34161
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return;
|
|
34162
|
+
this.rollIfNewDay();
|
|
34163
|
+
const entry = this.document.accounts[accountId] ?? {
|
|
34164
|
+
tokens: 0,
|
|
34165
|
+
perModel: {}
|
|
34166
|
+
};
|
|
34167
|
+
entry.tokens += Math.floor(tokens);
|
|
34168
|
+
entry.perModel[model] = (entry.perModel[model] ?? 0) + Math.floor(tokens);
|
|
34169
|
+
this.document.accounts[accountId] = entry;
|
|
34170
|
+
const write = this.writing.then(() => this.persist());
|
|
34171
|
+
this.writing = write.catch(() => void 0);
|
|
34172
|
+
await write.catch(() => void 0);
|
|
34173
|
+
}
|
|
34174
|
+
async persist() {
|
|
34175
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
34176
|
+
const tmp = `${this.filePath}.tmp.${Date.now()}`;
|
|
34177
|
+
await fs.writeFile(tmp, JSON.stringify(this.document, null, 2), "utf8");
|
|
34178
|
+
await fs.rename(tmp, this.filePath);
|
|
34179
|
+
}
|
|
34180
|
+
};
|
|
34181
|
+
/**
|
|
34182
|
+
* Decide whether one account may serve one model right now.
|
|
34183
|
+
*
|
|
34184
|
+
* Pure on purpose: every number it reads is passed in, so the decision can be
|
|
34185
|
+
* tested without a pool, a clock or a network.
|
|
34186
|
+
*/
|
|
34187
|
+
function evaluateGuardrails(input) {
|
|
34188
|
+
const { guardrails, tokensToday, modelTokensToday, remainingCredits } = input;
|
|
34189
|
+
if (guardrails.reserveCredits > 0 && typeof remainingCredits === "number" && Number.isFinite(remainingCredits) && remainingCredits < guardrails.reserveCredits) return {
|
|
34190
|
+
allowed: false,
|
|
34191
|
+
reason: "reserve-credits",
|
|
34192
|
+
detail: `${remainingCredits.toFixed(2)} credits left, below the reserved ${guardrails.reserveCredits}`
|
|
34193
|
+
};
|
|
34194
|
+
if (guardrails.dailyTokens > 0 && tokensToday >= guardrails.dailyTokens) return {
|
|
34195
|
+
allowed: false,
|
|
34196
|
+
reason: "daily-tokens",
|
|
34197
|
+
detail: `${tokensToday} tokens today, at the ${guardrails.dailyTokens} daily cap`
|
|
34198
|
+
};
|
|
34199
|
+
if (guardrails.modelDailyTokens > 0 && modelTokensToday >= guardrails.modelDailyTokens) return {
|
|
34200
|
+
allowed: false,
|
|
34201
|
+
reason: "model-daily-tokens",
|
|
34202
|
+
detail: `${modelTokensToday} tokens today on this model, at the ${guardrails.modelDailyTokens} cap`
|
|
34203
|
+
};
|
|
34204
|
+
return { allowed: true };
|
|
34205
|
+
}
|
|
34206
|
+
//#endregion
|
|
34207
|
+
//#region src/host/workbuddy/outbound-identity.ts
|
|
34208
|
+
/**
|
|
34209
|
+
* Outbound client identities this route can present.
|
|
34210
|
+
*
|
|
34211
|
+
* The subscription gateway gates on client identity, and it gates DIFFERENTLY
|
|
34212
|
+
* per surface: measured on this route, `CodeBuddyIDE` is rejected by
|
|
34213
|
+
* `/v3/config` with 400 `code 12403` ("check ua") and 401'd outright by the
|
|
34214
|
+
* international chat endpoint, which is why the CLI identity is what the line
|
|
34215
|
+
* ships and has always used.
|
|
34216
|
+
*
|
|
34217
|
+
* Two more identities exist on the same backend, and workbuddy2api-hub has
|
|
34218
|
+
* captured what the real clients send (their `wb_identity.py`, measured
|
|
34219
|
+
* 2026-10-16 with mitmproxy against the international desktop build 5.8.4):
|
|
34220
|
+
*
|
|
34221
|
+
* desktop `WorkBuddy/5.8.4 workbuddy-ai/5.8.4 CLI/2.161.0`
|
|
34222
|
+
* + `X-IDE-Type/Name: WorkBuddy`, `X-IDE-Version`, `X-Agent-Intent: craft`
|
|
34223
|
+
* + `X-Agent-Purpose: conversation`, `X-Agent-Type: main`, trace headers
|
|
34224
|
+
* vscode `VSCode/1.119.0 WorkBuddy/4.9.29177644`
|
|
34225
|
+
* + `X-IDE-Type/Name: VSCode`, `X-IDE-Version`, `X-Product-Version`, `X-Env-ID`
|
|
34226
|
+
* cli `CLI/<v> CodeBuddy/<v>` + `X-IDE-Type/Name: CLI`
|
|
34227
|
+
*
|
|
34228
|
+
* `cli` is deliberately kept byte-identical to what this line sends today
|
|
34229
|
+
* (including the `CodeBuddyIDE` IDE name, which is what was actually measured
|
|
34230
|
+
* to work here) so the default cannot regress; the other two are opt-in
|
|
34231
|
+
* attempts.
|
|
34232
|
+
*
|
|
34233
|
+
* Quota is NOT per identity: the subscription meters an account × model over a
|
|
34234
|
+
* 24h window (429 `code 6004`), so switching identity cannot create allowance.
|
|
34235
|
+
* What it can do is give each account a stable, plausible client — the same
|
|
34236
|
+
* reason the gateway projects one device per account.
|
|
34237
|
+
*/
|
|
34238
|
+
/** Everything the settings card may offer, in display order. */
|
|
34239
|
+
const OUTBOUND_PRODUCTS = [
|
|
34240
|
+
"cli",
|
|
34241
|
+
"workbuddy",
|
|
34242
|
+
"vscode"
|
|
34243
|
+
];
|
|
34244
|
+
/** Narrow a stored or patched value into a supported preference. */
|
|
34245
|
+
function normalizeOutboundSetting(value) {
|
|
34246
|
+
if (value === "auto") return "auto";
|
|
34247
|
+
return typeof value === "string" && OUTBOUND_PRODUCTS.includes(value) ? value : "cli";
|
|
34248
|
+
}
|
|
34249
|
+
/**
|
|
34250
|
+
* Which identity one account uses.
|
|
34251
|
+
*
|
|
34252
|
+
* `auto` assigns by hashing the account key, so an account keeps the same
|
|
34253
|
+
* client for its whole life — the one property that matters for a device
|
|
34254
|
+
* fingerprint — while a pool as a whole spreads over the available clients.
|
|
34255
|
+
*/
|
|
34256
|
+
function resolveOutboundProduct(setting, accountKey) {
|
|
34257
|
+
if (setting !== "auto") return setting;
|
|
34258
|
+
const pick = (createHash("sha256").update(accountKey).digest()[0] ?? 0) % OUTBOUND_PRODUCTS.length;
|
|
34259
|
+
return OUTBOUND_PRODUCTS[pick];
|
|
34260
|
+
}
|
|
34261
|
+
const DESKTOP_VERSION_INTL = "5.8.4";
|
|
34262
|
+
const DESKTOP_VERSION_CN = "5.5.6";
|
|
34263
|
+
const DESKTOP_UA_INTL = `WorkBuddy/${DESKTOP_VERSION_INTL} workbuddy-ai/${DESKTOP_VERSION_INTL} CLI/2.161.0`;
|
|
34264
|
+
const DESKTOP_UA_CN = `WorkBuddy/${DESKTOP_VERSION_CN} WorkBuddy/${DESKTOP_VERSION_CN} CLI/2.137.1`;
|
|
34265
|
+
const VSCODE_IDE_VERSION = "1.119.0";
|
|
34266
|
+
const VSCODE_PRODUCT_VERSION = "4.9.29177644";
|
|
34267
|
+
const VSCODE_UA = `VSCode/${VSCODE_IDE_VERSION} WorkBuddy/${VSCODE_PRODUCT_VERSION}`;
|
|
34268
|
+
/**
|
|
34269
|
+
* The CLI identity this line has always presented.
|
|
34270
|
+
*
|
|
34271
|
+
* Kept as a literal rather than rebuilt, because it is the one string measured
|
|
34272
|
+
* to be accepted by chat, `/v3/config` and billing on both regions.
|
|
34273
|
+
*/
|
|
34274
|
+
const CLI_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
34275
|
+
/**
|
|
34276
|
+
* A stable pseudo-device id for one account.
|
|
34277
|
+
*
|
|
34278
|
+
* Same derivation as the measured clients (`derive_id`: md5 of `salt:uid`
|
|
34279
|
+
* reshaped into a UUID), so the value is a well-formed v4-shaped machine id
|
|
34280
|
+
* that never changes for an account and never collides between accounts. A
|
|
34281
|
+
* per-PROCESS id would be worse than none here: it would present every account
|
|
34282
|
+
* in the pool as the same machine.
|
|
34283
|
+
*/
|
|
34284
|
+
function deriveMachineId(uid) {
|
|
34285
|
+
const digest = createHash("md5").update(`machine:${uid.trim() === "" ? "anonymous" : uid}`).digest("hex");
|
|
34286
|
+
const shaped = `${digest.slice(0, 12)}4${digest.slice(13, 16)}a${digest.slice(17)}`;
|
|
34287
|
+
return [
|
|
34288
|
+
shaped.slice(0, 8),
|
|
34289
|
+
shaped.slice(8, 12),
|
|
34290
|
+
shaped.slice(12, 16),
|
|
34291
|
+
shaped.slice(16, 20),
|
|
34292
|
+
shaped.slice(20, 32)
|
|
34293
|
+
].join("-");
|
|
34294
|
+
}
|
|
34295
|
+
function hexId() {
|
|
34296
|
+
return createHash("sha256").update(`${Date.now()}${Math.random()}`).digest("hex").slice(0, 32);
|
|
34297
|
+
}
|
|
34298
|
+
/** One conversation request's trace chain, as the desktop client emits it. */
|
|
34299
|
+
function traceHeaders() {
|
|
34300
|
+
const trace = hexId();
|
|
34301
|
+
const span = hexId().slice(0, 16);
|
|
34302
|
+
return {
|
|
34303
|
+
"X-Trace-ID": trace,
|
|
34304
|
+
traceparent: `00-${trace}-${span}-01`,
|
|
34305
|
+
b3: `${trace}-${span}-1`,
|
|
34306
|
+
"X-B3-TraceId": trace,
|
|
34307
|
+
"X-B3-SpanId": span,
|
|
34308
|
+
"X-B3-Sampled": "1"
|
|
34309
|
+
};
|
|
34310
|
+
}
|
|
34311
|
+
/** Resolve the identity block for one product on one region. */
|
|
34312
|
+
function outboundIdentity(product, region, uid) {
|
|
34313
|
+
if (product === "workbuddy") {
|
|
34314
|
+
const version = region === "cn" ? DESKTOP_VERSION_CN : DESKTOP_VERSION_INTL;
|
|
34315
|
+
return {
|
|
34316
|
+
product,
|
|
34317
|
+
userAgent: region === "cn" ? DESKTOP_UA_CN : DESKTOP_UA_INTL,
|
|
34318
|
+
headers: (conversation) => ({
|
|
34319
|
+
"X-Agent-Intent": "craft",
|
|
34320
|
+
"X-Agent-Purpose": "conversation",
|
|
34321
|
+
"X-Agent-Type": "main",
|
|
34322
|
+
"X-IDE-Name": "WorkBuddy",
|
|
34323
|
+
"X-IDE-Type": "WorkBuddy",
|
|
34324
|
+
"X-IDE-Version": version,
|
|
34325
|
+
"X-Private-Data": "true",
|
|
34326
|
+
"X-Machine-ID": deriveMachineId(uid),
|
|
34327
|
+
...conversation["X-Conversation-Request-ID"] === void 0 ? {} : { "X-Root-Request-ID": conversation["X-Conversation-Request-ID"] },
|
|
34328
|
+
...traceHeaders()
|
|
34329
|
+
})
|
|
34330
|
+
};
|
|
34331
|
+
}
|
|
34332
|
+
if (product === "vscode") return {
|
|
34333
|
+
product,
|
|
34334
|
+
userAgent: VSCODE_UA,
|
|
34335
|
+
headers: (conversation) => ({
|
|
34336
|
+
"X-Agent-Intent": "craft",
|
|
34337
|
+
"X-IDE-Name": "VSCode",
|
|
34338
|
+
"X-IDE-Type": "VSCode",
|
|
34339
|
+
"X-IDE-Version": VSCODE_IDE_VERSION,
|
|
34340
|
+
"X-Product-Version": VSCODE_PRODUCT_VERSION,
|
|
34341
|
+
"X-Env-ID": "production",
|
|
34342
|
+
"X-Machine-ID": deriveMachineId(uid),
|
|
34343
|
+
...conversation["X-Conversation-Request-ID"] === void 0 ? {} : { "X-Root-Request-ID": conversation["X-Conversation-Request-ID"] }
|
|
34344
|
+
})
|
|
34345
|
+
};
|
|
34346
|
+
return {
|
|
34347
|
+
product: "cli",
|
|
34348
|
+
userAgent: CLI_UA,
|
|
34349
|
+
headers: () => ({})
|
|
34350
|
+
};
|
|
34351
|
+
}
|
|
34352
|
+
/** Framing kinds and the code each carries in the AAD. */
|
|
34353
|
+
const FRAMING = {
|
|
34354
|
+
file: {
|
|
34355
|
+
magic: "WBEF1",
|
|
34356
|
+
code: 1
|
|
34357
|
+
},
|
|
34358
|
+
field: {
|
|
34359
|
+
magic: "WBEV1",
|
|
34360
|
+
code: 2
|
|
34361
|
+
}
|
|
34362
|
+
};
|
|
34363
|
+
/** Suite number this build speaks; the envelope may override it. */
|
|
34364
|
+
const DEFAULT_SUITE = 1;
|
|
34365
|
+
/** Hash scheme marker inside the AAD. */
|
|
34366
|
+
const SCHEME = "sym-v1";
|
|
34367
|
+
/** Every failure the at-rest path can report to a user. */
|
|
34368
|
+
var AtRestError = class extends Error {
|
|
34369
|
+
constructor(message) {
|
|
34370
|
+
super(message);
|
|
34371
|
+
this.name = "AtRestError";
|
|
34372
|
+
}
|
|
34373
|
+
};
|
|
34374
|
+
function lengthPrefixed(text) {
|
|
34375
|
+
const raw = Buffer.from(text, "utf8");
|
|
34376
|
+
const prefix = Buffer.alloc(4);
|
|
34377
|
+
prefix.writeUInt32BE(raw.length, 0);
|
|
34378
|
+
return Buffer.concat([prefix, raw]);
|
|
34379
|
+
}
|
|
34380
|
+
function uint32(value) {
|
|
34381
|
+
const raw = Buffer.alloc(4);
|
|
34382
|
+
raw.writeUInt32BE(value, 0);
|
|
34383
|
+
return raw;
|
|
34384
|
+
}
|
|
34385
|
+
/** The GCM associated data, byte-identical to the client's. */
|
|
34386
|
+
function associatedData(keyId, suite, framing) {
|
|
34387
|
+
const spec = FRAMING[framing];
|
|
34388
|
+
return Buffer.concat([
|
|
34389
|
+
Buffer.from("WB-AAD\0", "utf8"),
|
|
34390
|
+
Buffer.from([1]),
|
|
34391
|
+
lengthPrefixed(spec.magic),
|
|
34392
|
+
lengthPrefixed(SCHEME),
|
|
34393
|
+
uint32(suite),
|
|
34394
|
+
lengthPrefixed(keyId),
|
|
34395
|
+
Buffer.from([
|
|
34396
|
+
spec.code,
|
|
34397
|
+
0,
|
|
34398
|
+
0
|
|
34399
|
+
])
|
|
34400
|
+
]);
|
|
34401
|
+
}
|
|
34402
|
+
/** `protectorKeyId` / `envelope.keyId`: the first 16 hex characters of sha256(key). */
|
|
34403
|
+
function deriveKeyId(key) {
|
|
34404
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
34405
|
+
}
|
|
34406
|
+
/**
|
|
34407
|
+
* Whether a value is a sealed envelope rather than plaintext.
|
|
34408
|
+
*
|
|
34409
|
+
* Older clients sometimes stored the whole envelope as a JSON STRING in the
|
|
34410
|
+
* field, so that shape is recognised too.
|
|
34411
|
+
*/
|
|
34412
|
+
function isSealedValue(value) {
|
|
34413
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
34414
|
+
const record = value;
|
|
34415
|
+
return typeof record.envelope === "string" && "$wbEncrypted" in record;
|
|
34416
|
+
}
|
|
34417
|
+
if (typeof value === "string") {
|
|
34418
|
+
const trimmed = value.trimStart();
|
|
34419
|
+
return trimmed.startsWith("{") && trimmed.slice(0, 64).includes("$wbEncrypted");
|
|
34420
|
+
}
|
|
34421
|
+
return false;
|
|
34422
|
+
}
|
|
34423
|
+
function base64(value, label) {
|
|
34424
|
+
if (typeof value !== "string" || value === "") throw new AtRestError(`sealed envelope is missing ${label}`);
|
|
34425
|
+
return Buffer.from(value, "base64");
|
|
34426
|
+
}
|
|
34427
|
+
/** Split one envelope into its GCM parts. */
|
|
34428
|
+
function parseEnvelope(value) {
|
|
34429
|
+
const encoded = typeof value === "object" && value !== null ? value.envelope : value;
|
|
34430
|
+
if (typeof encoded !== "string") throw new AtRestError("sealed envelope is not a string");
|
|
34431
|
+
let parsed;
|
|
34432
|
+
try {
|
|
34433
|
+
parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
|
|
34434
|
+
} catch (error) {
|
|
34435
|
+
throw new AtRestError(`sealed envelope is not base64 JSON: ${error.message}`);
|
|
34436
|
+
}
|
|
34437
|
+
if (typeof parsed !== "object" || parsed === null) throw new AtRestError("sealed envelope is not an object");
|
|
34438
|
+
const record = parsed;
|
|
34439
|
+
return {
|
|
34440
|
+
suite: typeof record.suite === "number" && Number.isFinite(record.suite) ? record.suite : DEFAULT_SUITE,
|
|
34441
|
+
keyId: typeof record.keyId === "string" ? record.keyId : "",
|
|
34442
|
+
nonce: base64(record.nonce, "nonce"),
|
|
34443
|
+
authTag: base64(record.authTag, "authTag"),
|
|
34444
|
+
ciphertext: base64(record.ciphertext, "ciphertext")
|
|
34445
|
+
};
|
|
34446
|
+
}
|
|
34447
|
+
/** Open one envelope with the protector key, returning the plaintext bytes. */
|
|
34448
|
+
function openEnvelope(key, value, framing) {
|
|
34449
|
+
const envelope = parseEnvelope(value);
|
|
34450
|
+
const expected = deriveKeyId(key);
|
|
34451
|
+
if (envelope.keyId !== "" && envelope.keyId !== expected) throw new AtRestError(`sealed value was written by a different key (envelope ${envelope.keyId}, this key ${expected})`);
|
|
34452
|
+
const decipher = createDecipheriv("aes-256-gcm", key, envelope.nonce);
|
|
34453
|
+
decipher.setAAD(associatedData(envelope.keyId || expected, envelope.suite, framing));
|
|
34454
|
+
decipher.setAuthTag(envelope.authTag);
|
|
34455
|
+
try {
|
|
34456
|
+
return Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]);
|
|
34457
|
+
} catch {
|
|
34458
|
+
throw new AtRestError("sealed value could not be authenticated — the pasted key does not match this file");
|
|
34459
|
+
}
|
|
34460
|
+
}
|
|
34461
|
+
/** Decrypt a field that may be sealed, leaving plaintext values untouched. */
|
|
34462
|
+
function openSealedField(key, value) {
|
|
34463
|
+
if (value === null || value === void 0) return "";
|
|
34464
|
+
if (typeof value === "string" && !isSealedValue(value)) return value;
|
|
34465
|
+
if (typeof value !== "string" && !isSealedValue(value)) return "";
|
|
34466
|
+
return openEnvelope(key, value, "field").toString("utf8");
|
|
34467
|
+
}
|
|
34468
|
+
const HEX_KEY = /^[0-9a-f]{64}$/i;
|
|
34469
|
+
const BASE64_SECRET = /^[A-Za-z0-9+/]{43}=$/;
|
|
34470
|
+
/**
|
|
34471
|
+
* Normalise a pasted secret into the 32-byte protector key.
|
|
34472
|
+
*
|
|
34473
|
+
* Two spellings are accepted, neither requiring the user to compute anything:
|
|
34474
|
+
* a 64-character hex protector key (used as-is), or the 44-character base64
|
|
34475
|
+
* `atRestSecretKey` the client carries in its payload — that one is hashed as
|
|
34476
|
+
* the STRING, which is what produces the protector key.
|
|
34477
|
+
*/
|
|
34478
|
+
function parseKeyText(text) {
|
|
34479
|
+
let raw = text.trim();
|
|
34480
|
+
if (raw.slice(0, 2).toLowerCase() === "0x") raw = raw.slice(2).trim();
|
|
34481
|
+
if (HEX_KEY.test(raw)) return Buffer.from(raw, "hex");
|
|
34482
|
+
if (BASE64_SECRET.test(raw)) return createHash("sha256").update(raw, "ascii").digest();
|
|
34483
|
+
throw new AtRestError("unrecognised key format: paste the 64-character hex protector key or the 44-character base64 atRestSecretKey");
|
|
34484
|
+
}
|
|
34485
|
+
/** Directories the desktop client may keep its `keyblob` in. */
|
|
34486
|
+
function configDirCandidates() {
|
|
34487
|
+
const override = process.env["WORKBUDDY_CONFIG_DIR"] ?? process.env["CODEBUDDY_CONFIG_DIR"];
|
|
34488
|
+
if (override !== void 0 && override.trim() !== "") return [path.resolve(override.trim())];
|
|
34489
|
+
const home = os.homedir();
|
|
34490
|
+
return [
|
|
34491
|
+
"CodeBuddy",
|
|
34492
|
+
"WorkBuddy",
|
|
34493
|
+
".workbuddy-ai",
|
|
34494
|
+
".codebuddy"
|
|
34495
|
+
].map((name) => path.join(home, name));
|
|
34496
|
+
}
|
|
34497
|
+
/** Path of the client's keyblob, or null when the client is not installed here. */
|
|
34498
|
+
function keyBlobPath() {
|
|
34499
|
+
for (const dir of configDirCandidates()) {
|
|
34500
|
+
const candidate = path.join(dir, "keyblob");
|
|
34501
|
+
if (existsSync(candidate)) return candidate;
|
|
34502
|
+
}
|
|
34503
|
+
return null;
|
|
34504
|
+
}
|
|
34505
|
+
async function loadKeyBlob() {
|
|
34506
|
+
const file = keyBlobPath();
|
|
34507
|
+
if (file === null) return null;
|
|
34508
|
+
try {
|
|
34509
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
34510
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
34511
|
+
} catch {
|
|
34512
|
+
return null;
|
|
34513
|
+
}
|
|
34514
|
+
}
|
|
34515
|
+
/**
|
|
34516
|
+
* Validate a protector key against the client's own keyblob.
|
|
34517
|
+
*
|
|
34518
|
+
* This is the check that turns a typo into an error instead of a mystery: the
|
|
34519
|
+
* key must match the `static-v1` slot's `protectorKeyId` and must unwrap a
|
|
34520
|
+
* 32-byte master whose derived id matches the blob's own `keyId`. Returns
|
|
34521
|
+
* `false` when there is no keyblob to check against (the desktop client is not
|
|
34522
|
+
* installed on this machine) — absence of evidence, not evidence of a bad key.
|
|
34523
|
+
*/
|
|
34524
|
+
async function verifyKeyAgainstKeyBlob(key) {
|
|
34525
|
+
const blob = await loadKeyBlob();
|
|
34526
|
+
if (blob === null) return false;
|
|
34527
|
+
const slots = Array.isArray(blob.slots) ? blob.slots : [];
|
|
34528
|
+
const expected = deriveKeyId(key);
|
|
34529
|
+
for (const slot of slots) {
|
|
34530
|
+
if (slot.type !== "static-v1" || slot.wrapped === void 0) continue;
|
|
34531
|
+
if ((typeof slot.protectorKeyId === "string" ? slot.protectorKeyId.toLowerCase() : "") !== expected) throw new AtRestError("this key does not match the desktop client keyblob (protectorKeyId differs)");
|
|
34532
|
+
const master = openEnvelope(key, slot.wrapped, "file");
|
|
34533
|
+
if (master.length !== 32 || deriveKeyId(master) !== String(blob.keyId ?? "")) throw new AtRestError("the keyblob did not unwrap to a valid master key");
|
|
34534
|
+
return true;
|
|
34535
|
+
}
|
|
34536
|
+
throw new AtRestError("the desktop client keyblob has no usable static-v1 slot");
|
|
34537
|
+
}
|
|
34538
|
+
let activeKey = null;
|
|
34539
|
+
/** Install the protector key for this process (already persisted by the caller). */
|
|
34540
|
+
function setActiveAtRestKey(key) {
|
|
34541
|
+
activeKey = key;
|
|
34542
|
+
}
|
|
34543
|
+
/** The protector key this process holds, if any. */
|
|
34544
|
+
function activeAtRestKey() {
|
|
34545
|
+
return activeKey;
|
|
34546
|
+
}
|
|
34547
|
+
/** Whether sealed credentials can be opened right now. */
|
|
34548
|
+
function hasAtRestKey() {
|
|
34549
|
+
return activeKey !== null;
|
|
34550
|
+
}
|
|
34551
|
+
//#endregion
|
|
33975
34552
|
//#region src/host/workbuddy/token-store.ts
|
|
33976
34553
|
const WORKBUDDY_PREFERENCES_NAMESPACE = "dsh-workbuddy";
|
|
33977
34554
|
/** Runtime membership test for one stored effort value. */
|
|
@@ -34005,7 +34582,9 @@ function registerWorkBuddyPreferenceStore(settings, fallbackStore = new FileMode
|
|
|
34005
34582
|
defaultReasoningEffort: null,
|
|
34006
34583
|
selectedAccountId: null,
|
|
34007
34584
|
hiddenAccountIds: [],
|
|
34008
|
-
checkin: { ...DEFAULT_CHECKIN_SETTINGS }
|
|
34585
|
+
checkin: { ...DEFAULT_CHECKIN_SETTINGS },
|
|
34586
|
+
outboundIdentity: "cli",
|
|
34587
|
+
guardrails: { ...DEFAULT_GUARDRAILS }
|
|
34009
34588
|
};
|
|
34010
34589
|
const warmed = fallbackStore.read().then((stored) => {
|
|
34011
34590
|
snapshot = stored;
|
|
@@ -34026,7 +34605,18 @@ function registerWorkBuddyPreferenceStore(settings, fallbackStore = new FileMode
|
|
|
34026
34605
|
defaultReasoningEffort: z.union([...WORKBUDDY_REASONING_EFFORTS.map((effort) => z.const(effort)), z.const(null)]).default(null),
|
|
34027
34606
|
selectedAccountId: z.union([z.string(), z.const(null)]).default(null),
|
|
34028
34607
|
hiddenAccountIds: z.array(z.string()).default([]),
|
|
34029
|
-
checkin: z.object({ enabled: z.boolean().default(true) }).default({ enabled: true })
|
|
34608
|
+
checkin: z.object({ enabled: z.boolean().default(true) }).default({ enabled: true }),
|
|
34609
|
+
outboundIdentity: z.union([
|
|
34610
|
+
z.const("auto"),
|
|
34611
|
+
z.const("cli"),
|
|
34612
|
+
z.const("workbuddy"),
|
|
34613
|
+
z.const("vscode")
|
|
34614
|
+
]).default("cli"),
|
|
34615
|
+
guardrails: z.object({
|
|
34616
|
+
reserveCredits: z.number().min(0).default(0),
|
|
34617
|
+
dailyTokens: z.number().min(0).default(0),
|
|
34618
|
+
modelDailyTokens: z.number().min(0).default(0)
|
|
34619
|
+
}).default({ ...DEFAULT_GUARDRAILS })
|
|
34030
34620
|
}));
|
|
34031
34621
|
return {
|
|
34032
34622
|
ready: async () => void 0,
|
|
@@ -34039,7 +34629,9 @@ function registerWorkBuddyPreferenceStore(settings, fallbackStore = new FileMode
|
|
|
34039
34629
|
defaultReasoningEffort: value.defaultReasoningEffort,
|
|
34040
34630
|
selectedAccountId: value.selectedAccountId,
|
|
34041
34631
|
hiddenAccountIds: value.hiddenAccountIds,
|
|
34042
|
-
checkin: normalizeCheckinSettings(value.checkin)
|
|
34632
|
+
checkin: normalizeCheckinSettings(value.checkin),
|
|
34633
|
+
outboundIdentity: normalizeOutboundSetting(value.outboundIdentity),
|
|
34634
|
+
guardrails: normalizeGuardrails(value.guardrails)
|
|
34043
34635
|
};
|
|
34044
34636
|
},
|
|
34045
34637
|
update: async (patch) => {
|
|
@@ -34054,7 +34646,12 @@ function registerWorkBuddyPreferenceStore(settings, fallbackStore = new FileMode
|
|
|
34054
34646
|
checkin: patch.checkin !== void 0 ? normalizeCheckinSettings({
|
|
34055
34647
|
...normalizeCheckinSettings(current.checkin),
|
|
34056
34648
|
...patch.checkin
|
|
34057
|
-
}) : normalizeCheckinSettings(current.checkin)
|
|
34649
|
+
}) : normalizeCheckinSettings(current.checkin),
|
|
34650
|
+
outboundIdentity: patch.outboundIdentity !== void 0 ? normalizeOutboundSetting(patch.outboundIdentity) : normalizeOutboundSetting(current.outboundIdentity),
|
|
34651
|
+
guardrails: patch.guardrails !== void 0 ? normalizeGuardrails({
|
|
34652
|
+
...normalizeGuardrails(current.guardrails),
|
|
34653
|
+
...patch.guardrails
|
|
34654
|
+
}) : normalizeGuardrails(current.guardrails)
|
|
34058
34655
|
};
|
|
34059
34656
|
await scope.update(normalized);
|
|
34060
34657
|
fallbackStore.updateSettings(patch).catch(() => void 0);
|
|
@@ -34089,6 +34686,85 @@ function modelSettingsPath$7() {
|
|
|
34089
34686
|
function managedCredentialsPath() {
|
|
34090
34687
|
return path.join(dshHomeDir(), "storages", "workbuddy-accounts.json");
|
|
34091
34688
|
}
|
|
34689
|
+
/** Platform-encrypted storage for the pasted at-rest key (never plaintext on disk). */
|
|
34690
|
+
function atRestKeyPath() {
|
|
34691
|
+
return path.join(dshHomeDir(), "storages", "workbuddy-atrest-key.json");
|
|
34692
|
+
}
|
|
34693
|
+
function createAtRestKeyBackend(filePath) {
|
|
34694
|
+
const parse = (value) => {
|
|
34695
|
+
const record = isRecord$23(value) ? value : {};
|
|
34696
|
+
return {
|
|
34697
|
+
version: 1,
|
|
34698
|
+
keyHex: typeof record.keyHex === "string" ? record.keyHex : ""
|
|
34699
|
+
};
|
|
34700
|
+
};
|
|
34701
|
+
if (process.platform === "win32") return new WindowsDpapiCredentialStore(`${filePath}.dpapi`, parse);
|
|
34702
|
+
if (process.platform === "darwin") return new MacKeychainCredentialStore("dsh-workbuddy-atrest", managedCredentialAccount(filePath), parse);
|
|
34703
|
+
if (process.platform === "linux") return new SecretServiceCredentialStore("dsh-workbuddy-atrest", managedCredentialAccount(filePath), parse);
|
|
34704
|
+
throw new Error("WorkBuddy encrypted key storage requires Windows, macOS, or Linux.");
|
|
34705
|
+
}
|
|
34706
|
+
/**
|
|
34707
|
+
* Storage for the `$wbEncrypted` protector key.
|
|
34708
|
+
*
|
|
34709
|
+
* The key is the one secret that unlocks the desktop client's tokens, so it goes
|
|
34710
|
+
* through the same platform store the managed pool uses (DPAPI / Keychain /
|
|
34711
|
+
* Secret Service) and is installed into the process only after the client's own
|
|
34712
|
+
* keyblob accepts it. A pasted value that fails that check never displaces a
|
|
34713
|
+
* working key.
|
|
34714
|
+
*/
|
|
34715
|
+
var WorkBuddyAtRestKeyStore = class {
|
|
34716
|
+
filePath;
|
|
34717
|
+
backend;
|
|
34718
|
+
cached = null;
|
|
34719
|
+
constructor(filePath = atRestKeyPath(), backend = createAtRestKeyBackend(filePath)) {
|
|
34720
|
+
this.filePath = filePath;
|
|
34721
|
+
this.backend = backend;
|
|
34722
|
+
}
|
|
34723
|
+
/** Human-readable storage description, mirroring the managed pool's. */
|
|
34724
|
+
path() {
|
|
34725
|
+
if (process.platform === "win32") return `${this.filePath}.dpapi`;
|
|
34726
|
+
return `${process.platform === "darwin" ? "Keychain" : "Secret Service"}: dsh-workbuddy-atrest/${managedCredentialAccount(this.filePath)}`;
|
|
34727
|
+
}
|
|
34728
|
+
/**
|
|
34729
|
+
* Load the stored key into the process.
|
|
34730
|
+
*
|
|
34731
|
+
* Called once at startup: the credential readers consult the module-level
|
|
34732
|
+
* slot, so a key that is only on disk would otherwise leave every sealed
|
|
34733
|
+
* account unreadable until something else triggered a load.
|
|
34734
|
+
*/
|
|
34735
|
+
async load() {
|
|
34736
|
+
if (this.cached !== null) {
|
|
34737
|
+
setActiveAtRestKey(this.cached);
|
|
34738
|
+
return this.cached;
|
|
34739
|
+
}
|
|
34740
|
+
const hex = (await this.backend.load().catch(() => null))?.keyHex ?? "";
|
|
34741
|
+
if (!/^[0-9a-f]{64}$/i.test(hex)) {
|
|
34742
|
+
setActiveAtRestKey(null);
|
|
34743
|
+
return null;
|
|
34744
|
+
}
|
|
34745
|
+
this.cached = Buffer.from(hex, "hex");
|
|
34746
|
+
setActiveAtRestKey(this.cached);
|
|
34747
|
+
return this.cached;
|
|
34748
|
+
}
|
|
34749
|
+
/** Validate and persist a pasted secret; returns the key id for display. */
|
|
34750
|
+
async adopt(text) {
|
|
34751
|
+
const key = parseKeyText(text);
|
|
34752
|
+
await verifyKeyAgainstKeyBlob(key);
|
|
34753
|
+
await this.backend.save({
|
|
34754
|
+
version: 1,
|
|
34755
|
+
keyHex: key.toString("hex")
|
|
34756
|
+
});
|
|
34757
|
+
this.cached = key;
|
|
34758
|
+
setActiveAtRestKey(key);
|
|
34759
|
+
return deriveKeyId(key);
|
|
34760
|
+
}
|
|
34761
|
+
/** Forget the key, both on disk and in this process. */
|
|
34762
|
+
async clear() {
|
|
34763
|
+
this.cached = null;
|
|
34764
|
+
setActiveAtRestKey(null);
|
|
34765
|
+
await this.backend.clear().catch(() => void 0);
|
|
34766
|
+
}
|
|
34767
|
+
};
|
|
34092
34768
|
function isRecord$23(value) {
|
|
34093
34769
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34094
34770
|
}
|
|
@@ -34110,15 +34786,24 @@ function parseCredentialFile(value, sourceFile, sourceMtimeMs = 0) {
|
|
|
34110
34786
|
if (!isRecord$23(value)) throw new Error("WorkBuddy credential file is not an object");
|
|
34111
34787
|
const auth = isRecord$23(value.auth) ? value.auth : void 0;
|
|
34112
34788
|
if (auth === void 0) throw new Error("WorkBuddy credential file has no auth block");
|
|
34113
|
-
const
|
|
34114
|
-
|
|
34789
|
+
const rawAccess = auth.accessToken;
|
|
34790
|
+
const sealedAccess = isSealedValue(rawAccess) ? rawAccess : void 0;
|
|
34791
|
+
const plainAccess = sealedAccess === void 0 ? readString$2(auth, "accessToken") : void 0;
|
|
34792
|
+
if (sealedAccess === void 0 && plainAccess === void 0) throw new Error("WorkBuddy credential file has no access token");
|
|
34793
|
+
const accessToken = plainAccess ?? "";
|
|
34794
|
+
const rawRefresh = auth.refreshToken;
|
|
34795
|
+
const sealedRefresh = isSealedValue(rawRefresh) ? rawRefresh : void 0;
|
|
34115
34796
|
const account = isRecord$23(value.account) ? value.account : {};
|
|
34116
34797
|
const domain = readString$2(auth, "domain") ?? "www.codebuddy.cn";
|
|
34117
34798
|
const expiresAtRaw = auth.expiresAt;
|
|
34118
34799
|
const expiresAt = typeof expiresAtRaw === "number" && Number.isFinite(expiresAtRaw) ? expiresAtRaw : 0;
|
|
34119
34800
|
return withResolvedIdentity({
|
|
34120
34801
|
accessToken,
|
|
34121
|
-
refreshToken: readString$2(auth, "refreshToken") ?? "",
|
|
34802
|
+
refreshToken: sealedRefresh === void 0 ? readString$2(auth, "refreshToken") ?? "" : "",
|
|
34803
|
+
...sealedAccess === void 0 && sealedRefresh === void 0 ? {} : { sealed: {
|
|
34804
|
+
...sealedAccess === void 0 ? {} : { accessToken: sealedAccess },
|
|
34805
|
+
...sealedRefresh === void 0 ? {} : { refreshToken: sealedRefresh }
|
|
34806
|
+
} },
|
|
34122
34807
|
expiresAt,
|
|
34123
34808
|
region: regionForDomain(domain),
|
|
34124
34809
|
domain,
|
|
@@ -34208,16 +34893,56 @@ function parseManagedCredentials(value) {
|
|
|
34208
34893
|
});
|
|
34209
34894
|
});
|
|
34210
34895
|
}
|
|
34896
|
+
let sealedDiagnostics = {
|
|
34897
|
+
sealedFiles: 0,
|
|
34898
|
+
opened: 0,
|
|
34899
|
+
missingKey: false,
|
|
34900
|
+
lastError: null
|
|
34901
|
+
};
|
|
34902
|
+
/** Snapshot of the last scan's sealed-credential observations. */
|
|
34903
|
+
function sealedCredentialDiagnostics() {
|
|
34904
|
+
return { ...sealedDiagnostics };
|
|
34905
|
+
}
|
|
34906
|
+
/** Start a fresh scan window; the settings card reads what the last scan left. */
|
|
34907
|
+
function resetSealedDiagnostics() {
|
|
34908
|
+
sealedDiagnostics = {
|
|
34909
|
+
sealedFiles: 0,
|
|
34910
|
+
opened: 0,
|
|
34911
|
+
missingKey: false,
|
|
34912
|
+
lastError: null
|
|
34913
|
+
};
|
|
34914
|
+
}
|
|
34211
34915
|
/**
|
|
34212
|
-
*
|
|
34916
|
+
* Resolve a credential's sealed tokens into usable plaintext.
|
|
34213
34917
|
*
|
|
34214
|
-
*
|
|
34215
|
-
*
|
|
34216
|
-
*
|
|
34217
|
-
* the newest `expiresAt` among the timestamped snapshots. Picking purely by
|
|
34218
|
-
* mtime selected a stale snapshot during development, so the token's own
|
|
34219
|
-
* expiry is what decides.
|
|
34918
|
+
* Returns `null` when the credential is sealed and this process holds no key
|
|
34919
|
+
* that opens it: such a file is not an error the caller can act on per-file, it
|
|
34920
|
+
* is a missing key the settings card reports once.
|
|
34220
34921
|
*/
|
|
34922
|
+
function unsealCredentials(credentials) {
|
|
34923
|
+
if (credentials.sealed === void 0) return credentials;
|
|
34924
|
+
sealedDiagnostics.sealedFiles += 1;
|
|
34925
|
+
const key = activeAtRestKey();
|
|
34926
|
+
if (key === null) {
|
|
34927
|
+
sealedDiagnostics.missingKey = true;
|
|
34928
|
+
return null;
|
|
34929
|
+
}
|
|
34930
|
+
try {
|
|
34931
|
+
const accessToken = openSealedField(key, credentials.sealed.accessToken);
|
|
34932
|
+
if (accessToken === "") throw new AtRestError("sealed access token opened to an empty string");
|
|
34933
|
+
const refreshToken = credentials.sealed.refreshToken === void 0 ? credentials.refreshToken : openSealedField(key, credentials.sealed.refreshToken);
|
|
34934
|
+
sealedDiagnostics.opened += 1;
|
|
34935
|
+
return withResolvedIdentity({
|
|
34936
|
+
...credentials,
|
|
34937
|
+
accessToken,
|
|
34938
|
+
refreshToken,
|
|
34939
|
+
sealed: void 0
|
|
34940
|
+
});
|
|
34941
|
+
} catch (error) {
|
|
34942
|
+
sealedDiagnostics.lastError = error instanceof Error ? error.message : String(error);
|
|
34943
|
+
return null;
|
|
34944
|
+
}
|
|
34945
|
+
}
|
|
34221
34946
|
function isCanonicalFile(name) {
|
|
34222
34947
|
return /^(workbuddy|codebuddy)-desktop(-\w+)?\.info$/i.test(name);
|
|
34223
34948
|
}
|
|
@@ -34236,6 +34961,7 @@ async function scanCredentials(dir = codeBuddyAuthDir()) {
|
|
|
34236
34961
|
if (error.code === "ENOENT") return [];
|
|
34237
34962
|
throw new Error(`WorkBuddy credential directory could not be read: ${dir}`);
|
|
34238
34963
|
}
|
|
34964
|
+
resetSealedDiagnostics();
|
|
34239
34965
|
const found = [];
|
|
34240
34966
|
for (const name of names) {
|
|
34241
34967
|
if (!name.endsWith(".info")) continue;
|
|
@@ -34244,7 +34970,8 @@ async function scanCredentials(dir = codeBuddyAuthDir()) {
|
|
|
34244
34970
|
const stats = await fs.stat(file);
|
|
34245
34971
|
if (!stats.isFile()) continue;
|
|
34246
34972
|
const raw = await fs.readFile(file, "utf8");
|
|
34247
|
-
|
|
34973
|
+
const unsealed = unsealCredentials(parseCredentialFile(JSON.parse(raw), file, stats.mtimeMs));
|
|
34974
|
+
if (unsealed !== null) found.push(unsealed);
|
|
34248
34975
|
} catch {
|
|
34249
34976
|
continue;
|
|
34250
34977
|
}
|
|
@@ -34423,7 +35150,7 @@ var FileCredentialStore$4 = class {
|
|
|
34423
35150
|
const stats = await fs.stat(filePath);
|
|
34424
35151
|
if (!stats.isFile()) return null;
|
|
34425
35152
|
const raw = await fs.readFile(filePath, "utf8");
|
|
34426
|
-
return parseCredentialFile(JSON.parse(raw), filePath, stats.mtimeMs);
|
|
35153
|
+
return unsealCredentials(parseCredentialFile(JSON.parse(raw), filePath, stats.mtimeMs));
|
|
34427
35154
|
} catch {
|
|
34428
35155
|
return null;
|
|
34429
35156
|
}
|
|
@@ -34527,7 +35254,9 @@ var FileModelSettingsStore$4 = class {
|
|
|
34527
35254
|
defaultReasoningEffort,
|
|
34528
35255
|
selectedAccountId: typeof parsed.selectedAccountId === "string" && parsed.selectedAccountId !== "" ? parsed.selectedAccountId : null,
|
|
34529
35256
|
hiddenAccountIds: Array.isArray(parsed.hiddenAccountIds) ? parsed.hiddenAccountIds.filter((id) => typeof id === "string") : [],
|
|
34530
|
-
checkin: normalizeCheckinSettings(parsed.checkin)
|
|
35257
|
+
checkin: normalizeCheckinSettings(parsed.checkin),
|
|
35258
|
+
outboundIdentity: normalizeOutboundSetting(parsed.outboundIdentity),
|
|
35259
|
+
guardrails: normalizeGuardrails(parsed.guardrails)
|
|
34531
35260
|
};
|
|
34532
35261
|
}
|
|
34533
35262
|
} catch {}
|
|
@@ -34538,7 +35267,9 @@ var FileModelSettingsStore$4 = class {
|
|
|
34538
35267
|
defaultReasoningEffort: null,
|
|
34539
35268
|
selectedAccountId: null,
|
|
34540
35269
|
hiddenAccountIds: [],
|
|
34541
|
-
checkin: { ...DEFAULT_CHECKIN_SETTINGS }
|
|
35270
|
+
checkin: { ...DEFAULT_CHECKIN_SETTINGS },
|
|
35271
|
+
outboundIdentity: "cli",
|
|
35272
|
+
guardrails: { ...DEFAULT_GUARDRAILS }
|
|
34542
35273
|
};
|
|
34543
35274
|
}
|
|
34544
35275
|
async write(settings) {
|
|
@@ -34560,6 +35291,11 @@ var FileModelSettingsStore$4 = class {
|
|
|
34560
35291
|
...patch.checkin !== void 0 ? { checkin: normalizeCheckinSettings({
|
|
34561
35292
|
...current.checkin,
|
|
34562
35293
|
...patch.checkin
|
|
35294
|
+
}) } : {},
|
|
35295
|
+
...patch.outboundIdentity !== void 0 ? { outboundIdentity: normalizeOutboundSetting(patch.outboundIdentity) } : {},
|
|
35296
|
+
...patch.guardrails !== void 0 ? { guardrails: normalizeGuardrails({
|
|
35297
|
+
...normalizeGuardrails(current.guardrails),
|
|
35298
|
+
...patch.guardrails
|
|
34563
35299
|
}) } : {}
|
|
34564
35300
|
};
|
|
34565
35301
|
await this.write(next);
|
|
@@ -34638,6 +35374,8 @@ function conversationHeaders(sessionId) {
|
|
|
34638
35374
|
function workBuddyHeaders(credentials, extra = {}, channel) {
|
|
34639
35375
|
const domain = credentials.domain || "www.codebuddy.cn";
|
|
34640
35376
|
const intl = isIntlDomain(domain);
|
|
35377
|
+
const conversation = intl && channel !== void 0 ? conversationHeaders(channel.sessionId) : {};
|
|
35378
|
+
const identity = outboundIdentity(channel?.product ?? "cli", intl ? "intl" : "cn", credentials.uid ?? "");
|
|
34641
35379
|
return {
|
|
34642
35380
|
"content-type": "application/json",
|
|
34643
35381
|
accept: "application/json",
|
|
@@ -34649,12 +35387,13 @@ function workBuddyHeaders(credentials, extra = {}, channel) {
|
|
|
34649
35387
|
[HEADER_PRODUCT]: CLIENT_PRODUCT,
|
|
34650
35388
|
[HEADER_IDE_NAME]: "CodeBuddyIDE",
|
|
34651
35389
|
[HEADER_REQUESTED_WITH]: "XMLHttpRequest",
|
|
34652
|
-
"user-agent":
|
|
35390
|
+
"user-agent": identity.userAgent,
|
|
34653
35391
|
...intl ? {
|
|
34654
35392
|
origin: credentials.backend,
|
|
34655
35393
|
referer: `${credentials.backend}/`
|
|
34656
35394
|
} : {},
|
|
34657
|
-
...
|
|
35395
|
+
...conversation,
|
|
35396
|
+
...identity.headers(conversation),
|
|
34658
35397
|
...extra
|
|
34659
35398
|
};
|
|
34660
35399
|
}
|
|
@@ -35158,7 +35897,7 @@ function resetWebLogin() {
|
|
|
35158
35897
|
function isRecord$21(value) {
|
|
35159
35898
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35160
35899
|
}
|
|
35161
|
-
function stringOf(record, key) {
|
|
35900
|
+
function stringOf$1(record, key) {
|
|
35162
35901
|
const value = record[key];
|
|
35163
35902
|
if (typeof value === "string" && value.trim() !== "") return value.trim();
|
|
35164
35903
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
@@ -35207,9 +35946,9 @@ async function requestLoginState(region, fetchFn = fetch, signal) {
|
|
|
35207
35946
|
} catch {
|
|
35208
35947
|
root = void 0;
|
|
35209
35948
|
}
|
|
35210
|
-
if (!response.ok || root?.code !== 0 || !isRecord$21(root.data)) throw new Error(`${PROVIDER_NAME$3} 登录初始化失败(HTTP ${response.status}):${stringOf(root ?? {}, "msg") ?? text.slice(0, 160)}`);
|
|
35211
|
-
const state = stringOf(root.data, "state");
|
|
35212
|
-
const authUrl = stringOf(root.data, "authUrl");
|
|
35949
|
+
if (!response.ok || root?.code !== 0 || !isRecord$21(root.data)) throw new Error(`${PROVIDER_NAME$3} 登录初始化失败(HTTP ${response.status}):${stringOf$1(root ?? {}, "msg") ?? text.slice(0, 160)}`);
|
|
35950
|
+
const state = stringOf$1(root.data, "state");
|
|
35951
|
+
const authUrl = stringOf$1(root.data, "authUrl");
|
|
35213
35952
|
if (!state || !authUrl) throw new Error(`${PROVIDER_NAME$3} 登录响应缺少 state 或 authUrl`);
|
|
35214
35953
|
return {
|
|
35215
35954
|
state,
|
|
@@ -35223,27 +35962,27 @@ function parseLoginCredential(payload, attempt) {
|
|
|
35223
35962
|
if (!isRecord$21(payload)) throw new Error(`${PROVIDER_NAME$3} 登录轮询响应无效`);
|
|
35224
35963
|
const code = numberOf(payload, "code");
|
|
35225
35964
|
if (code === 11217) return null;
|
|
35226
|
-
if (code !== 0) throw new Error(`${PROVIDER_NAME$3} 登录失败:${stringOf(payload, "msg") ?? `code ${String(code)}`}`);
|
|
35965
|
+
if (code !== 0) throw new Error(`${PROVIDER_NAME$3} 登录失败:${stringOf$1(payload, "msg") ?? `code ${String(code)}`}`);
|
|
35227
35966
|
const data = isRecord$21(payload.data) ? payload.data : payload;
|
|
35228
35967
|
const auth = isRecord$21(data.auth) ? data.auth : data;
|
|
35229
35968
|
const account = isRecord$21(data.account) ? data.account : isRecord$21(auth.account) ? auth.account : isRecord$21(data.user) ? data.user : isRecord$21(auth.user) ? auth.user : {};
|
|
35230
|
-
const accessToken = stringOf(auth, "accessToken") ?? stringOf(auth, "access_token") ?? stringOf(data, "token");
|
|
35969
|
+
const accessToken = stringOf$1(auth, "accessToken") ?? stringOf$1(auth, "access_token") ?? stringOf$1(data, "token");
|
|
35231
35970
|
if (!accessToken) throw new Error(`${PROVIDER_NAME$3} 登录成功响应缺少 access token`);
|
|
35232
|
-
const domain = stringOf(auth, "domain") ?? attempt.domain;
|
|
35971
|
+
const domain = stringOf$1(auth, "domain") ?? attempt.domain;
|
|
35233
35972
|
const expiresIn = numberOf(auth, "expiresIn") ?? numberOf(auth, "expires_in");
|
|
35234
35973
|
const expiresAt = numberOf(auth, "expiresAt") ?? numberOf(auth, "expires_at") ?? (expiresIn === void 0 ? Date.now() + 3600 * 1e3 : Date.now() + expiresIn * 1e3);
|
|
35235
35974
|
return {
|
|
35236
35975
|
accessToken,
|
|
35237
|
-
refreshToken: stringOf(auth, "refreshToken") ?? stringOf(auth, "refresh_token") ?? stringOf(data, "refreshToken") ?? stringOf(data, "refresh_token") ?? "",
|
|
35976
|
+
refreshToken: stringOf$1(auth, "refreshToken") ?? stringOf$1(auth, "refresh_token") ?? stringOf$1(data, "refreshToken") ?? stringOf$1(data, "refresh_token") ?? "",
|
|
35238
35977
|
expiresAt,
|
|
35239
35978
|
region: regionForDomain(domain),
|
|
35240
35979
|
domain,
|
|
35241
35980
|
backend: backendForDomain(domain),
|
|
35242
|
-
uid: stringOf(account, "uid") ?? stringOf(data, "uid"),
|
|
35243
|
-
nickname: stringOf(account, "nickname") ?? stringOf(account, "name") ?? stringOf(data, "nickname"),
|
|
35244
|
-
uin: stringOf(account, "uin") ?? stringOf(data, "uin"),
|
|
35245
|
-
accountType: stringOf(account, "type") ?? stringOf(data, "accountType"),
|
|
35246
|
-
enterpriseId: stringOf(account, "enterpriseId") ?? stringOf(data, "enterpriseId"),
|
|
35981
|
+
uid: stringOf$1(account, "uid") ?? stringOf$1(data, "uid"),
|
|
35982
|
+
nickname: stringOf$1(account, "nickname") ?? stringOf$1(account, "name") ?? stringOf$1(data, "nickname"),
|
|
35983
|
+
uin: stringOf$1(account, "uin") ?? stringOf$1(data, "uin"),
|
|
35984
|
+
accountType: stringOf$1(account, "type") ?? stringOf$1(data, "accountType"),
|
|
35985
|
+
enterpriseId: stringOf$1(account, "enterpriseId") ?? stringOf$1(data, "enterpriseId"),
|
|
35247
35986
|
sourceFile: "",
|
|
35248
35987
|
sourceMtimeMs: 0,
|
|
35249
35988
|
source: "managed"
|
|
@@ -35452,6 +36191,7 @@ async function getWorkBuddyWebStatus(store, modelSettings, preferences, options
|
|
|
35452
36191
|
accountId: settings.selectedAccountId,
|
|
35453
36192
|
hiddenAccountIds: settings.hiddenAccountIds
|
|
35454
36193
|
});
|
|
36194
|
+
const sealedDiagnostics = sealedCredentialDiagnostics();
|
|
35455
36195
|
const enabled = settings.enabled !== false;
|
|
35456
36196
|
const fetchFn = options.fetchFn ?? fetch;
|
|
35457
36197
|
const live = credentials === null ? [] : await loadConfigCatalog(credentials, { fetchFn }).catch(() => []);
|
|
@@ -35491,7 +36231,12 @@ async function getWorkBuddyWebStatus(store, modelSettings, preferences, options
|
|
|
35491
36231
|
})),
|
|
35492
36232
|
activeAccountId: poolData?.activeAccountId,
|
|
35493
36233
|
rotationStrategy: poolData?.rotationStrategy ?? "sequential",
|
|
35494
|
-
checkin
|
|
36234
|
+
checkin,
|
|
36235
|
+
outboundIdentity: normalizeOutboundSetting(settings.outboundIdentity),
|
|
36236
|
+
guardrails: normalizeGuardrails(settings.guardrails),
|
|
36237
|
+
atRestKeySet: hasAtRestKey(),
|
|
36238
|
+
sealedAccounts: sealedDiagnostics.sealedFiles,
|
|
36239
|
+
sealedAccountsError: sealedDiagnostics.missingKey ? "The CodeBuddy desktop client seals its tokens; paste its at-rest key to read them." : sealedDiagnostics.lastError
|
|
35495
36240
|
};
|
|
35496
36241
|
}
|
|
35497
36242
|
/** Register the WorkBuddy settings routes under `/workbuddy/api`. */
|
|
@@ -35507,6 +36252,7 @@ function registerWorkBuddyRoutes(ctx, store, modelSettings, preferences, options
|
|
|
35507
36252
|
try {
|
|
35508
36253
|
if (path === "" || path === "status") {
|
|
35509
36254
|
if (method !== "GET") return sendMethodNotAllowed$3(response);
|
|
36255
|
+
await options.atRestKey?.load().catch(() => null);
|
|
35510
36256
|
const settings = preferences ? preferences.status() : await modelSettings.read();
|
|
35511
36257
|
await store.read({
|
|
35512
36258
|
force: true,
|
|
@@ -35808,6 +36554,32 @@ function registerWorkBuddyRoutes(ctx, store, modelSettings, preferences, options
|
|
|
35808
36554
|
const raw = body.checkin;
|
|
35809
36555
|
if (typeof raw.enabled === "boolean") patch.checkin = { enabled: raw.enabled };
|
|
35810
36556
|
}
|
|
36557
|
+
if (typeof body.guardrails === "object" && body.guardrails !== null) {
|
|
36558
|
+
const raw = body.guardrails;
|
|
36559
|
+
const guardrails = {};
|
|
36560
|
+
for (const field of [
|
|
36561
|
+
"reserveCredits",
|
|
36562
|
+
"dailyTokens",
|
|
36563
|
+
"modelDailyTokens"
|
|
36564
|
+
]) {
|
|
36565
|
+
const value = raw[field];
|
|
36566
|
+
if (value === void 0) continue;
|
|
36567
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return sendJson$3(response, 400, {
|
|
36568
|
+
ok: false,
|
|
36569
|
+
error: `${field} must be a non-negative number.`
|
|
36570
|
+
});
|
|
36571
|
+
guardrails[field] = Math.floor(value);
|
|
36572
|
+
}
|
|
36573
|
+
patch.guardrails = guardrails;
|
|
36574
|
+
}
|
|
36575
|
+
if (body.outboundIdentity !== void 0) {
|
|
36576
|
+
const requested = normalizeOutboundSetting(body.outboundIdentity);
|
|
36577
|
+
if (requested !== body.outboundIdentity) return sendJson$3(response, 400, {
|
|
36578
|
+
ok: false,
|
|
36579
|
+
error: "Unsupported outbound identity."
|
|
36580
|
+
});
|
|
36581
|
+
patch.outboundIdentity = requested;
|
|
36582
|
+
}
|
|
35811
36583
|
if (body.selectedAccountId !== void 0) {
|
|
35812
36584
|
const selected = body.selectedAccountId;
|
|
35813
36585
|
if (selected === null || typeof selected === "string") {
|
|
@@ -35860,6 +36632,53 @@ function registerWorkBuddyRoutes(ctx, store, modelSettings, preferences, options
|
|
|
35860
36632
|
value: await getWorkBuddyWebStatus(store, modelSettings, preferences, options)
|
|
35861
36633
|
});
|
|
35862
36634
|
}
|
|
36635
|
+
if (path === "at-rest-key") {
|
|
36636
|
+
if (!isSameOriginMutation(request)) return sendJson$3(response, 403, {
|
|
36637
|
+
ok: false,
|
|
36638
|
+
error: "Cross-origin request rejected."
|
|
36639
|
+
});
|
|
36640
|
+
const keyStore = options.atRestKey;
|
|
36641
|
+
if (keyStore === void 0) return sendJson$3(response, 400, {
|
|
36642
|
+
ok: false,
|
|
36643
|
+
error: "At-rest key storage is not installed."
|
|
36644
|
+
});
|
|
36645
|
+
if (method === "DELETE") {
|
|
36646
|
+
await keyStore.clear();
|
|
36647
|
+
store.invalidate();
|
|
36648
|
+
clearCachedQuota$5();
|
|
36649
|
+
clearCachedCatalog$1();
|
|
36650
|
+
return sendJson$3(response, 200, {
|
|
36651
|
+
ok: true,
|
|
36652
|
+
value: await getWorkBuddyWebStatus(store, modelSettings, preferences, options)
|
|
36653
|
+
});
|
|
36654
|
+
}
|
|
36655
|
+
if (method !== "POST") return sendMethodNotAllowed$3(response);
|
|
36656
|
+
const body = await readRequestJson$3(request);
|
|
36657
|
+
const secret = typeof body.key === "string" ? body.key : "";
|
|
36658
|
+
if (secret.trim() === "") return sendJson$3(response, 400, {
|
|
36659
|
+
ok: false,
|
|
36660
|
+
error: "A key is required."
|
|
36661
|
+
});
|
|
36662
|
+
let keyId;
|
|
36663
|
+
try {
|
|
36664
|
+
keyId = await keyStore.adopt(secret);
|
|
36665
|
+
} catch (error) {
|
|
36666
|
+
return sendJson$3(response, 400, {
|
|
36667
|
+
ok: false,
|
|
36668
|
+
error: error instanceof Error ? error.message : String(error)
|
|
36669
|
+
});
|
|
36670
|
+
}
|
|
36671
|
+
store.invalidate();
|
|
36672
|
+
clearCachedQuota$5();
|
|
36673
|
+
clearCachedCatalog$1();
|
|
36674
|
+
return sendJson$3(response, 200, {
|
|
36675
|
+
ok: true,
|
|
36676
|
+
value: {
|
|
36677
|
+
...await getWorkBuddyWebStatus(store, modelSettings, preferences, options),
|
|
36678
|
+
atRestKeyId: keyId
|
|
36679
|
+
}
|
|
36680
|
+
});
|
|
36681
|
+
}
|
|
35863
36682
|
if (path === "accounts/strategy") {
|
|
35864
36683
|
if (method !== "POST") return sendMethodNotAllowed$3(response);
|
|
35865
36684
|
if (!isSameOriginMutation(request)) return sendJson$3(response, 403, {
|
|
@@ -35937,6 +36756,234 @@ function registerWorkBuddyRoutes(ctx, store, modelSettings, preferences, options
|
|
|
35937
36756
|
};
|
|
35938
36757
|
}
|
|
35939
36758
|
//#endregion
|
|
36759
|
+
//#region src/host/workbuddy/dsml.ts
|
|
36760
|
+
/**
|
|
36761
|
+
* Recovery of tool calls the model PRINTED instead of emitting structurally.
|
|
36762
|
+
*
|
|
36763
|
+
* Measured upstream behaviour (and the reason workbuddy2api-hub has the same
|
|
36764
|
+
* recovery, `wb_proxy.py` "Printed tool calls on the Chat Completions path"):
|
|
36765
|
+
* a tools request sometimes answers with the call spelled out inside `content`
|
|
36766
|
+
* — either the full-width DSML form
|
|
36767
|
+
*
|
|
36768
|
+
* <||DSML|| calls><||DSML|| invoke name="run_code">
|
|
36769
|
+
* <||DSML|| parameter name="code">print(1)</||DSML|| parameter>
|
|
36770
|
+
* </||DSML|| invoke></||DSML|| calls>
|
|
36771
|
+
*
|
|
36772
|
+
* or a hybrid the model mixes on its own: a plain opening tag closed by DSML
|
|
36773
|
+
* tags, with the first parameter's close tag swallowed. Forwarded verbatim the
|
|
36774
|
+
* client sees prose, finds no call, and the turn simply ends — reported by
|
|
36775
|
+
* users as "it keeps stopping by itself".
|
|
36776
|
+
*
|
|
36777
|
+
* A structured `tool_calls` delta always wins; this only runs for text, which
|
|
36778
|
+
* is why the recovery is a pure function over the accumulated content.
|
|
36779
|
+
*/
|
|
36780
|
+
const TAG_START = "<[^>]*DSML[^>]*";
|
|
36781
|
+
const CALLS_RE = new RegExp(`${TAG_START}calls>(.*?)</[^>]*DSML[^>]*calls>`, "s");
|
|
36782
|
+
const INVOKE_RE = new RegExp(`${TAG_START}invoke\\s+name=["']([^"']+)["']>(.*?)</[^>]*invoke>`, "s");
|
|
36783
|
+
const DSML_PARAM_RE = new RegExp(`${TAG_START}parameter\\s+name=["']([^"']+)["'][^>]*>(.*?)</[^>]*parameter>`, "s");
|
|
36784
|
+
const PARAM_TAGS = "parameter|param|arg|argument";
|
|
36785
|
+
const LEAK_PARAM_RE = new RegExp(`<\\s*(?:\\|\\s*DSML\\s*\\|\\s*)?(${PARAM_TAGS})\\b([^>]*)>(.*?)</\\s*(?:\\|\\s*DSML\\s*\\|\\s*)?(?:parameter|param|arg|argument)\\s*>`, "gis");
|
|
36786
|
+
const LEAK_TAG_RE = /<\s*\/?\s*(?:\|\s*DSML\s*\|\s*)?([A-Za-z_][A-Za-z0-9_]*)([^>]*)>/g;
|
|
36787
|
+
const LEAK_NAME_ATTR_RE = /name\s*=\s*["']([^"']*)["']/;
|
|
36788
|
+
const WRAPPER_TAGS = /* @__PURE__ */ new Set([
|
|
36789
|
+
"calls",
|
|
36790
|
+
"tool_calls",
|
|
36791
|
+
"function_calls",
|
|
36792
|
+
"function_call"
|
|
36793
|
+
]);
|
|
36794
|
+
/** Fold the full-width spelling of the DSML token back to ASCII bars. */
|
|
36795
|
+
function normalizeDsmlBars(text) {
|
|
36796
|
+
return text.replace(/\uff5c+\s*DSML\s*\uff5c+/g, "|DSML|");
|
|
36797
|
+
}
|
|
36798
|
+
function callId() {
|
|
36799
|
+
return `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`;
|
|
36800
|
+
}
|
|
36801
|
+
function argumentsOf(params) {
|
|
36802
|
+
return JSON.stringify(params);
|
|
36803
|
+
}
|
|
36804
|
+
function stripSpans(text, spans) {
|
|
36805
|
+
if (spans.length === 0) return text;
|
|
36806
|
+
const sorted = [...spans].sort((a, b) => a[0] - b[0]);
|
|
36807
|
+
const kept = [];
|
|
36808
|
+
let last = 0;
|
|
36809
|
+
for (const [start, end] of sorted) {
|
|
36810
|
+
if (start < last) continue;
|
|
36811
|
+
kept.push(text.slice(last, start));
|
|
36812
|
+
last = end;
|
|
36813
|
+
}
|
|
36814
|
+
kept.push(text.slice(last));
|
|
36815
|
+
return kept.join("").trim();
|
|
36816
|
+
}
|
|
36817
|
+
/** Recover the full-width `<||DSML|| …>` spelling. */
|
|
36818
|
+
function parseDsmlToolCalls(text) {
|
|
36819
|
+
if (text === "" || !text.includes("DSML")) return null;
|
|
36820
|
+
const block = CALLS_RE.exec(text);
|
|
36821
|
+
if (block === null) return null;
|
|
36822
|
+
const callsBlock = block[1] ?? "";
|
|
36823
|
+
const calls = [];
|
|
36824
|
+
for (const invoke of callsBlock.matchAll(new RegExp(INVOKE_RE.source, "gs"))) {
|
|
36825
|
+
const params = {};
|
|
36826
|
+
for (const param of (invoke[2] ?? "").matchAll(new RegExp(DSML_PARAM_RE.source, "gs"))) params[param[1] ?? ""] = (param[2] ?? "").trim();
|
|
36827
|
+
calls.push({
|
|
36828
|
+
id: callId(),
|
|
36829
|
+
name: invoke[1] ?? "",
|
|
36830
|
+
arguments: argumentsOf(params)
|
|
36831
|
+
});
|
|
36832
|
+
}
|
|
36833
|
+
if (calls.length === 0) return null;
|
|
36834
|
+
return {
|
|
36835
|
+
calls,
|
|
36836
|
+
clean: `${text.slice(0, block.index).trim()} ${text.slice(block.index + block[0].length).trim()}`.trim()
|
|
36837
|
+
};
|
|
36838
|
+
}
|
|
36839
|
+
/**
|
|
36840
|
+
* Recover the hybrid spelling, driven by the `<parameter>` elements.
|
|
36841
|
+
*
|
|
36842
|
+
* Parameter elements are spelled the same way in both shapes and a parameter
|
|
36843
|
+
* name is the one thing a call cannot do without, so they are the anchor; the
|
|
36844
|
+
* nearest preceding opening tag that is neither a wrapper nor a parameter names
|
|
36845
|
+
* the tool.
|
|
36846
|
+
*/
|
|
36847
|
+
function parseLeakedToolCalls(text) {
|
|
36848
|
+
if (text === "" || !text.includes("<")) return null;
|
|
36849
|
+
const normalized = normalizeDsmlBars(text);
|
|
36850
|
+
const params = [];
|
|
36851
|
+
for (const match of normalized.matchAll(LEAK_PARAM_RE)) {
|
|
36852
|
+
const attrs = match[2] ?? "";
|
|
36853
|
+
const name = LEAK_NAME_ATTR_RE.exec(attrs)?.[1] ?? "";
|
|
36854
|
+
if (name === "" || match.index === void 0) continue;
|
|
36855
|
+
params.push({
|
|
36856
|
+
start: match.index,
|
|
36857
|
+
name,
|
|
36858
|
+
value: (match[3] ?? "").trim(),
|
|
36859
|
+
end: match.index + match[0].length
|
|
36860
|
+
});
|
|
36861
|
+
}
|
|
36862
|
+
if (params.length === 0) return null;
|
|
36863
|
+
const tags = [];
|
|
36864
|
+
for (const match of normalized.matchAll(LEAK_TAG_RE)) {
|
|
36865
|
+
if (match.index === void 0) continue;
|
|
36866
|
+
tags.push({
|
|
36867
|
+
start: match.index,
|
|
36868
|
+
end: match.index + match[0].length,
|
|
36869
|
+
name: (match[1] ?? "").toLowerCase(),
|
|
36870
|
+
closing: match[0].trimStart().startsWith("</"),
|
|
36871
|
+
attrs: match[2] ?? ""
|
|
36872
|
+
});
|
|
36873
|
+
}
|
|
36874
|
+
const groups = [];
|
|
36875
|
+
for (const site of params) {
|
|
36876
|
+
let owner = null;
|
|
36877
|
+
for (const tag of tags) {
|
|
36878
|
+
if (tag.start >= site.start) break;
|
|
36879
|
+
if (tag.closing) continue;
|
|
36880
|
+
if (WRAPPER_TAGS.has(tag.name)) continue;
|
|
36881
|
+
if (new RegExp(`^(${PARAM_TAGS})$`, "i").test(tag.name)) continue;
|
|
36882
|
+
owner = tag;
|
|
36883
|
+
}
|
|
36884
|
+
const nameAttr = owner === null ? null : LEAK_NAME_ATTR_RE.exec(owner.attrs);
|
|
36885
|
+
if ((owner === null ? "" : (nameAttr?.[1] ?? owner.name).trim()) === "") continue;
|
|
36886
|
+
const last = groups[groups.length - 1];
|
|
36887
|
+
if (last !== void 0 && owner !== null && last.owner !== null && last.owner.start === owner.start) {
|
|
36888
|
+
last.params[site.name] = site.value;
|
|
36889
|
+
last.spans.push([site.start, site.end]);
|
|
36890
|
+
} else groups.push({
|
|
36891
|
+
owner,
|
|
36892
|
+
params: { [site.name]: site.value },
|
|
36893
|
+
spans: [[site.start, site.end]]
|
|
36894
|
+
});
|
|
36895
|
+
}
|
|
36896
|
+
if (groups.length === 0) return null;
|
|
36897
|
+
const regions = [];
|
|
36898
|
+
const calls = [];
|
|
36899
|
+
for (const group of groups) {
|
|
36900
|
+
const first = group.owner?.start ?? Math.min(...group.spans.map((span) => span[0]));
|
|
36901
|
+
let last = Math.max(...group.spans.map((span) => span[1]));
|
|
36902
|
+
for (;;) {
|
|
36903
|
+
let next = last;
|
|
36904
|
+
while (next < normalized.length && " \r\n".includes(normalized[next] ?? "")) next += 1;
|
|
36905
|
+
const tag = /^<\s*\/\s*(?:\|\s*DSML\s*\|\s*)?[A-Za-z_][A-Za-z0-9_]*[^>]*>/i.exec(normalized.slice(next));
|
|
36906
|
+
if (tag === null) break;
|
|
36907
|
+
last = next + tag[0].length;
|
|
36908
|
+
}
|
|
36909
|
+
regions.push([first, last]);
|
|
36910
|
+
calls.push({
|
|
36911
|
+
id: callId(),
|
|
36912
|
+
name: (group.owner === null ? "" : LEAK_NAME_ATTR_RE.exec(group.owner.attrs)?.[1] ?? group.owner.name).trim(),
|
|
36913
|
+
arguments: argumentsOf(group.params)
|
|
36914
|
+
});
|
|
36915
|
+
}
|
|
36916
|
+
if (calls.length === 0) return null;
|
|
36917
|
+
let clean = stripSpans(normalized, regions);
|
|
36918
|
+
const leftover = [];
|
|
36919
|
+
for (const match of clean.matchAll(LEAK_TAG_RE)) {
|
|
36920
|
+
if (match.index === void 0) continue;
|
|
36921
|
+
const tagText = normalizeDsmlBars(match[0]).toUpperCase();
|
|
36922
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
36923
|
+
if (tagText.includes("DSML") || WRAPPER_TAGS.has(name)) leftover.push([match.index, match.index + match[0].length]);
|
|
36924
|
+
}
|
|
36925
|
+
clean = stripSpans(clean, leftover);
|
|
36926
|
+
return {
|
|
36927
|
+
calls,
|
|
36928
|
+
clean
|
|
36929
|
+
};
|
|
36930
|
+
}
|
|
36931
|
+
/** Recover a printed call under either spelling. */
|
|
36932
|
+
function recoverPrintedToolCalls(text) {
|
|
36933
|
+
return parseLeakedToolCalls(text) ?? parseDsmlToolCalls(text);
|
|
36934
|
+
}
|
|
36935
|
+
const TAG_NAMES = "run_code|run_shell|invoke|parameter|param|arg|argument|calls|tool_calls|function_calls|function_call";
|
|
36936
|
+
const OPENER_RE = new RegExp(`^<\\s*/?\\s*(?:\\|\\s*DSML\\s*\\||(?:${TAG_NAMES})\\b)`, "i");
|
|
36937
|
+
const CLOSER_RE = new RegExp(`<\\s*/\\s*(?:\\|\\s*DSML\\s*\\|\\s*)?(?:${TAG_NAMES})(?:\\s+[^>]*)?>`, "i");
|
|
36938
|
+
const OPENERS = [
|
|
36939
|
+
"<run_code",
|
|
36940
|
+
"<run_shell",
|
|
36941
|
+
"<invoke",
|
|
36942
|
+
"<parameter",
|
|
36943
|
+
"<param",
|
|
36944
|
+
"<arg",
|
|
36945
|
+
"<argument",
|
|
36946
|
+
"<calls",
|
|
36947
|
+
"<tool_calls",
|
|
36948
|
+
"<function_calls",
|
|
36949
|
+
"<function_call",
|
|
36950
|
+
"</run_code",
|
|
36951
|
+
"</run_shell",
|
|
36952
|
+
"</invoke",
|
|
36953
|
+
"</parameter",
|
|
36954
|
+
"</param",
|
|
36955
|
+
"</arg",
|
|
36956
|
+
"</argument",
|
|
36957
|
+
"</calls",
|
|
36958
|
+
"</tool_calls",
|
|
36959
|
+
"</function_calls",
|
|
36960
|
+
"</function_call",
|
|
36961
|
+
"<|dsml|",
|
|
36962
|
+
"</|dsml|",
|
|
36963
|
+
"<||dsml",
|
|
36964
|
+
"</||dsml"
|
|
36965
|
+
];
|
|
36966
|
+
/**
|
|
36967
|
+
* Index from which a streaming text buffer must be held back.
|
|
36968
|
+
*
|
|
36969
|
+
* The earliest `<` that either opens a recognisable tag or could still grow
|
|
36970
|
+
* into one (a delta may split `<parameter` across frames), or -1 when the
|
|
36971
|
+
* whole buffer is safe to emit as prose.
|
|
36972
|
+
*/
|
|
36973
|
+
function printedCallHoldIndex(text) {
|
|
36974
|
+
for (let index = text.indexOf("<"); index !== -1; index = text.indexOf("<", index + 1)) {
|
|
36975
|
+
const rest = text.slice(index);
|
|
36976
|
+
if (OPENER_RE.test(rest)) return index;
|
|
36977
|
+
const head = rest.replace(/\s+/g, "").toLowerCase().slice(0, 18);
|
|
36978
|
+
if (OPENERS.some((opener) => opener.startsWith(head))) return index;
|
|
36979
|
+
}
|
|
36980
|
+
return -1;
|
|
36981
|
+
}
|
|
36982
|
+
/** Whether a held buffer has already reached a closing tag. */
|
|
36983
|
+
function printedCallLooksComplete(text) {
|
|
36984
|
+
return CLOSER_RE.test(text);
|
|
36985
|
+
}
|
|
36986
|
+
//#endregion
|
|
35940
36987
|
//#region src/host/workbuddy/mapper.ts
|
|
35941
36988
|
/**
|
|
35942
36989
|
* Wire mapping for the WorkBuddy / CodeBuddy subscription chat endpoint.
|
|
@@ -36283,6 +37330,121 @@ function openAIAssistantContent$2(message) {
|
|
|
36283
37330
|
toolCalls
|
|
36284
37331
|
};
|
|
36285
37332
|
}
|
|
37333
|
+
/** Whether one wire message is an assistant turn. */
|
|
37334
|
+
function isAssistantTurn(message) {
|
|
37335
|
+
return message.role === "assistant";
|
|
37336
|
+
}
|
|
37337
|
+
/**
|
|
37338
|
+
* The text of one assistant turn, or `undefined` when the message is not an
|
|
37339
|
+
* assistant turn or does not carry plain text.
|
|
37340
|
+
*/
|
|
37341
|
+
function assistantTextOf(message) {
|
|
37342
|
+
if (!isAssistantTurn(message)) return void 0;
|
|
37343
|
+
return typeof message.content === "string" ? message.content : void 0;
|
|
37344
|
+
}
|
|
37345
|
+
/** The tool calls one wire message declares, which may be none. */
|
|
37346
|
+
function toolCallsOf(message) {
|
|
37347
|
+
return Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
37348
|
+
}
|
|
37349
|
+
/**
|
|
37350
|
+
* Concatenate one assistant turn's text onto another.
|
|
37351
|
+
*
|
|
37352
|
+
* A concatenation that would merge two words is separated by a single space: a
|
|
37353
|
+
* batch split across turns ends its first turn mid-sentence, so `"let me"` +
|
|
37354
|
+
* `"look"` has to leave as `"let me look"` and not `"let melook"`.
|
|
37355
|
+
*/
|
|
37356
|
+
function appendAssistantText(base, extra) {
|
|
37357
|
+
if (extra === "") return base;
|
|
37358
|
+
if (base === "") return extra;
|
|
37359
|
+
if (/\s$/.test(base) || /^\s/.test(extra) || /[\u3000-\u9fff\uff00-\uff9f]/.test(base + extra)) return base + extra;
|
|
37360
|
+
return `${base} ${extra}`;
|
|
37361
|
+
}
|
|
37362
|
+
/**
|
|
37363
|
+
* Fold adjacent assistant turns that each carry tool calls into one turn.
|
|
37364
|
+
*
|
|
37365
|
+
* A parallel batch the harness serialized as two assistant messages is one
|
|
37366
|
+
* batch to the upstream, and only one assistant turn may declare it: a gateway
|
|
37367
|
+
* that sees the batch split rejects every later turn of that conversation. Text
|
|
37368
|
+
* is concatenated and the calls are appended in order, ids untouched. A tool
|
|
37369
|
+
* result between two assistant turns is what proves they are separate turns,
|
|
37370
|
+
* so only the immediately adjacent run is merged — the following tool results
|
|
37371
|
+
* keep their own order.
|
|
37372
|
+
*/
|
|
37373
|
+
function mergeSplitToolCallBatches(messages) {
|
|
37374
|
+
const repaired = [];
|
|
37375
|
+
for (const message of messages) {
|
|
37376
|
+
const text = assistantTextOf(message);
|
|
37377
|
+
const calls = toolCallsOf(message);
|
|
37378
|
+
const previous = repaired.length > 0 ? repaired[repaired.length - 1] : void 0;
|
|
37379
|
+
const previousText = previous === void 0 ? void 0 : assistantTextOf(previous);
|
|
37380
|
+
if (text !== void 0 && previous !== void 0 && previousText !== void 0 && (calls.length > 0 || toolCallsOf(previous).length > 0)) {
|
|
37381
|
+
repaired[repaired.length - 1] = {
|
|
37382
|
+
...previous,
|
|
37383
|
+
content: appendAssistantText(previousText, text),
|
|
37384
|
+
tool_calls: [...toolCallsOf(previous), ...calls]
|
|
37385
|
+
};
|
|
37386
|
+
continue;
|
|
37387
|
+
}
|
|
37388
|
+
repaired.push(message);
|
|
37389
|
+
}
|
|
37390
|
+
return repaired;
|
|
37391
|
+
}
|
|
37392
|
+
/**
|
|
37393
|
+
* The text an assistant tool call is answered with when its result never came.
|
|
37394
|
+
*
|
|
37395
|
+
* This text is invented on purpose, exactly as the sibling gateway invents it:
|
|
37396
|
+
* an interrupted run leaves the call declared with no `role: "tool"` reply, and
|
|
37397
|
+
* the upstream rejects the whole turn rather than the missing result. Sending a
|
|
37398
|
+
* sentence the model can read beats both alternatives — dropping the call
|
|
37399
|
+
* silently rewrites history the model already saw (and leaves the *next*
|
|
37400
|
+
* assistant turn referring to a call that is no longer there), while dropping
|
|
37401
|
+
* the turn throws away the rest of the conversation's context. The wording is
|
|
37402
|
+
* deliberately unlike a real tool output: it is short, says it failed, and
|
|
37403
|
+
* carries no content that could be mistaken for a result.
|
|
37404
|
+
*/
|
|
37405
|
+
const NO_TOOL_RESULT_TEXT = "[no result: the tool call did not complete]";
|
|
37406
|
+
/**
|
|
37407
|
+
* Answer every declared tool call that has no `role: "tool"` message.
|
|
37408
|
+
*
|
|
37409
|
+
* The window is the stretch before the next assistant turn: call ids are only
|
|
37410
|
+
* meaningful inside one turn's batch, and a later turn's results may reuse none
|
|
37411
|
+
* of them. Calls that were answered are left alone, and the invented replies go
|
|
37412
|
+
* after the results that did arrive so the real ones keep their position.
|
|
37413
|
+
*/
|
|
37414
|
+
function fillMissingToolResults(messages) {
|
|
37415
|
+
const repaired = [];
|
|
37416
|
+
let index = 0;
|
|
37417
|
+
while (index < messages.length) {
|
|
37418
|
+
const message = messages[index];
|
|
37419
|
+
repaired.push(message);
|
|
37420
|
+
index += 1;
|
|
37421
|
+
if (!isAssistantTurn(message)) continue;
|
|
37422
|
+
const calls = toolCallsOf(message);
|
|
37423
|
+
if (calls.length === 0) continue;
|
|
37424
|
+
const answered = /* @__PURE__ */ new Set();
|
|
37425
|
+
const results = [];
|
|
37426
|
+
while (index < messages.length && !isAssistantTurn(messages[index])) {
|
|
37427
|
+
const result = messages[index];
|
|
37428
|
+
if (result.role === "tool" && typeof result.tool_call_id === "string") answered.add(result.tool_call_id);
|
|
37429
|
+
results.push(result);
|
|
37430
|
+
index += 1;
|
|
37431
|
+
}
|
|
37432
|
+
const invented = [];
|
|
37433
|
+
for (const call of calls) {
|
|
37434
|
+
const id = typeof call.id === "string" ? call.id : "";
|
|
37435
|
+
if (id === "" || answered.has(id)) continue;
|
|
37436
|
+
invented.push({
|
|
37437
|
+
role: "tool",
|
|
37438
|
+
tool_call_id: id,
|
|
37439
|
+
content: NO_TOOL_RESULT_TEXT
|
|
37440
|
+
});
|
|
37441
|
+
}
|
|
37442
|
+
let insertAt = results.length;
|
|
37443
|
+
while (insertAt > 0 && results[insertAt - 1].role === "user") insertAt -= 1;
|
|
37444
|
+
repaired.push(...results.slice(0, insertAt), ...invented, ...results.slice(insertAt));
|
|
37445
|
+
}
|
|
37446
|
+
return repaired;
|
|
37447
|
+
}
|
|
36286
37448
|
/**
|
|
36287
37449
|
* Build one `/v2/chat/completions` body.
|
|
36288
37450
|
*
|
|
@@ -36337,12 +37499,13 @@ function buildChatRequest$2(options, images = NO_RESOLVED_IMAGES$2) {
|
|
|
36337
37499
|
});
|
|
36338
37500
|
}
|
|
36339
37501
|
const effort = options.reasoningEffort === void 0 ? void 0 : String(options.reasoningEffort);
|
|
37502
|
+
const repairedMessages = fillMissingToolResults(mergeSplitToolCallBatches(messages));
|
|
36340
37503
|
return {
|
|
36341
37504
|
model: options.model,
|
|
36342
|
-
messages,
|
|
37505
|
+
messages: repairedMessages,
|
|
36343
37506
|
stream: true,
|
|
36344
37507
|
stream_options: { include_usage: true },
|
|
36345
|
-
max_tokens: options.maxTokens ?? maxOutputTokensFor$3(options.model),
|
|
37508
|
+
max_tokens: clampMinimumOutput(options.model, options.maxTokens ?? maxOutputTokensFor$3(options.model)),
|
|
36346
37509
|
...options.temperature === void 0 ? {} : { temperature: options.temperature },
|
|
36347
37510
|
...options.stop && options.stop.length > 0 ? { stop: options.stop } : {},
|
|
36348
37511
|
...options.tools && options.tools.length > 0 ? {
|
|
@@ -36374,9 +37537,122 @@ function createStreamState$4() {
|
|
|
36374
37537
|
cacheReadTokens: 0,
|
|
36375
37538
|
cacheWriteTokens: 0,
|
|
36376
37539
|
reasoningTokens: 0,
|
|
36377
|
-
sawUsage: false
|
|
37540
|
+
sawUsage: false,
|
|
37541
|
+
printed: {
|
|
37542
|
+
pending: "",
|
|
37543
|
+
holding: false
|
|
37544
|
+
}
|
|
36378
37545
|
};
|
|
36379
37546
|
}
|
|
37547
|
+
/**
|
|
37548
|
+
* Smallest output request the GPT-family upstreams accept.
|
|
37549
|
+
*
|
|
37550
|
+
* Measured upstream behaviour (also pinned by workbuddy2api-hub as #364/#252):
|
|
37551
|
+
* a `gpt-*` model rejects `max_tokens` below 16 with 400
|
|
37552
|
+
* `integer_below_min_value`, while every other family accepts a small value.
|
|
37553
|
+
* This route only ever asks for a small cap on purpose — the connection probe and
|
|
37554
|
+
* an output-limit probe both do — so the floor is applied here rather than left
|
|
37555
|
+
* for each caller to remember.
|
|
37556
|
+
*/
|
|
37557
|
+
const MINIMUM_GPT_MAX_TOKENS = 16;
|
|
37558
|
+
/** Raise a GPT-family output request to the floor that family enforces. */
|
|
37559
|
+
function clampMinimumOutput(model, maxTokens) {
|
|
37560
|
+
if (!model.trimStart().toLowerCase().startsWith("gpt-")) return maxTokens;
|
|
37561
|
+
return Math.max(maxTokens, MINIMUM_GPT_MAX_TOKENS);
|
|
37562
|
+
}
|
|
37563
|
+
/** Longest printed-call region held before it is written out as prose anyway. */
|
|
37564
|
+
const PRINTED_CALL_LIMIT = 64 * 1024;
|
|
37565
|
+
/** Append prose to the open text block, opening one when needed. */
|
|
37566
|
+
function emitProse(state, text) {
|
|
37567
|
+
if (text === "") return [];
|
|
37568
|
+
const out = closeToolCalls$2(state);
|
|
37569
|
+
if (state.current === null || state.current.type !== "text") out.push(...openTextBlock$2(state, "text"));
|
|
37570
|
+
state.current.text += text;
|
|
37571
|
+
state.hasContent = true;
|
|
37572
|
+
out.push({
|
|
37573
|
+
type: "text-delta",
|
|
37574
|
+
index: state.current.index,
|
|
37575
|
+
text
|
|
37576
|
+
});
|
|
37577
|
+
return out;
|
|
37578
|
+
}
|
|
37579
|
+
/** Emit recovered calls as settled tool-call blocks. */
|
|
37580
|
+
function emitPrintedCalls(state, calls) {
|
|
37581
|
+
const out = closeCurrent$2(state);
|
|
37582
|
+
for (const call of calls) {
|
|
37583
|
+
const index = state.blocks.length;
|
|
37584
|
+
const id = toToolCallId(call.id);
|
|
37585
|
+
const block = {
|
|
37586
|
+
type: "tool-call",
|
|
37587
|
+
id,
|
|
37588
|
+
name: call.name,
|
|
37589
|
+
arguments: call.arguments
|
|
37590
|
+
};
|
|
37591
|
+
state.blocks.push(block);
|
|
37592
|
+
out.push({
|
|
37593
|
+
type: "block-start",
|
|
37594
|
+
index,
|
|
37595
|
+
blockType: "tool-call"
|
|
37596
|
+
});
|
|
37597
|
+
out.push({
|
|
37598
|
+
type: "tool-call-delta",
|
|
37599
|
+
index,
|
|
37600
|
+
id,
|
|
37601
|
+
name: call.name,
|
|
37602
|
+
argumentsDelta: call.arguments
|
|
37603
|
+
});
|
|
37604
|
+
out.push({
|
|
37605
|
+
type: "block-end",
|
|
37606
|
+
index,
|
|
37607
|
+
block
|
|
37608
|
+
});
|
|
37609
|
+
state.hasToolCall = true;
|
|
37610
|
+
state.hasContent = true;
|
|
37611
|
+
}
|
|
37612
|
+
return out;
|
|
37613
|
+
}
|
|
37614
|
+
/**
|
|
37615
|
+
* Release held text, resolving it into prose or calls.
|
|
37616
|
+
*
|
|
37617
|
+
* `force` is what the end of a turn passes: whatever is still held must be
|
|
37618
|
+
* written out rather than dropped, even when it never closed.
|
|
37619
|
+
*/
|
|
37620
|
+
function drainPrinted(state, force) {
|
|
37621
|
+
const out = [];
|
|
37622
|
+
for (;;) {
|
|
37623
|
+
const pending = state.printed.pending;
|
|
37624
|
+
if (pending === "") return out;
|
|
37625
|
+
if (!state.printed.holding) {
|
|
37626
|
+
const hold = printedCallHoldIndex(pending);
|
|
37627
|
+
if (hold === -1) {
|
|
37628
|
+
out.push(...emitProse(state, pending));
|
|
37629
|
+
state.printed.pending = "";
|
|
37630
|
+
return out;
|
|
37631
|
+
}
|
|
37632
|
+
if (hold > 0) out.push(...emitProse(state, pending.slice(0, hold)));
|
|
37633
|
+
state.printed.pending = pending.slice(hold);
|
|
37634
|
+
state.printed.holding = true;
|
|
37635
|
+
}
|
|
37636
|
+
const buffer = state.printed.pending;
|
|
37637
|
+
if (!force && !printedCallLooksComplete(buffer) && buffer.length < PRINTED_CALL_LIMIT) return out;
|
|
37638
|
+
state.printed.holding = false;
|
|
37639
|
+
state.printed.pending = "";
|
|
37640
|
+
const recovered = recoverPrintedToolCalls(buffer);
|
|
37641
|
+
if (recovered === null) {
|
|
37642
|
+
out.push(...emitProse(state, buffer));
|
|
37643
|
+
return out;
|
|
37644
|
+
}
|
|
37645
|
+
if (recovered.clean !== "") out.push(...emitProse(state, recovered.clean));
|
|
37646
|
+
out.push(...emitPrintedCalls(state, recovered.calls));
|
|
37647
|
+
return out;
|
|
37648
|
+
}
|
|
37649
|
+
}
|
|
37650
|
+
/** Append chat text, recovering a call the model printed instead of emitting. */
|
|
37651
|
+
function feedText(state, text) {
|
|
37652
|
+
if (text === "") return [];
|
|
37653
|
+
state.printed.pending += text;
|
|
37654
|
+
return drainPrinted(state, false);
|
|
37655
|
+
}
|
|
36380
37656
|
function closeCurrent$2(state) {
|
|
36381
37657
|
if (state.current === null) return [];
|
|
36382
37658
|
const { index, type, text } = state.current;
|
|
@@ -36478,17 +37754,7 @@ function processStreamLine$3(line, state) {
|
|
|
36478
37754
|
});
|
|
36479
37755
|
}
|
|
36480
37756
|
const content = asString$11(delta.content);
|
|
36481
|
-
if (content !== void 0 && content !== "")
|
|
36482
|
-
out.push(...closeToolCalls$2(state));
|
|
36483
|
-
if (state.current === null || state.current.type !== "text") out.push(...openTextBlock$2(state, "text"));
|
|
36484
|
-
state.current.text += sanitizeText$4(content);
|
|
36485
|
-
state.hasContent = true;
|
|
36486
|
-
out.push({
|
|
36487
|
-
type: "text-delta",
|
|
36488
|
-
index: state.current.index,
|
|
36489
|
-
text: sanitizeText$4(content)
|
|
36490
|
-
});
|
|
36491
|
-
}
|
|
37757
|
+
if (content !== void 0 && content !== "") out.push(...feedText(state, sanitizeText$4(content)));
|
|
36492
37758
|
const toolDeltas = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
36493
37759
|
for (const entry of toolDeltas) {
|
|
36494
37760
|
if (!isRecord$20(entry)) continue;
|
|
@@ -36498,6 +37764,7 @@ function processStreamLine$3(line, state) {
|
|
|
36498
37764
|
const finish = asString$11(choice?.finish_reason);
|
|
36499
37765
|
if (finish !== void 0 && finish !== "") {
|
|
36500
37766
|
state.finishReason = finish;
|
|
37767
|
+
out.push(...drainPrinted(state, true));
|
|
36501
37768
|
out.push(...closeCurrent$2(state));
|
|
36502
37769
|
out.push(...closeToolCalls$2(state));
|
|
36503
37770
|
}
|
|
@@ -36572,7 +37839,11 @@ function finishReasonFor$4(state) {
|
|
|
36572
37839
|
function closeStream$3(state) {
|
|
36573
37840
|
if (state.finished) return [];
|
|
36574
37841
|
state.finished = true;
|
|
36575
|
-
const out = [
|
|
37842
|
+
const out = [
|
|
37843
|
+
...drainPrinted(state, true),
|
|
37844
|
+
...closeCurrent$2(state),
|
|
37845
|
+
...closeToolCalls$2(state)
|
|
37846
|
+
];
|
|
36576
37847
|
if (state.sawUsage) out.push({
|
|
36577
37848
|
type: "usage",
|
|
36578
37849
|
usage: tokenUsage$3(state)
|
|
@@ -36758,6 +38029,27 @@ var WorkBuddyAdapter = class extends LlmAdapter {
|
|
|
36758
38029
|
* A candidate the model does not accept is discarded rather than sent, since
|
|
36759
38030
|
* the endpoint rejects an unsupported level with `code 11150`.
|
|
36760
38031
|
*/
|
|
38032
|
+
/**
|
|
38033
|
+
* Accounts the LOCAL guardrails refuse for this model right now.
|
|
38034
|
+
*
|
|
38035
|
+
* Reads only numbers this plugin already owns — the billing snapshot's
|
|
38036
|
+
* remaining credits and this line's own token counters — so it never becomes a
|
|
38037
|
+
* second, disagreeing source of truth about the upstream allowance.
|
|
38038
|
+
*/
|
|
38039
|
+
async guardrailBlockedAccounts(settings, model) {
|
|
38040
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
38041
|
+
const ledger = this.options.usageLedger;
|
|
38042
|
+
const pool = this.accountPool;
|
|
38043
|
+
if (pool === null) return blocked;
|
|
38044
|
+
if (ledger === void 0 || !guardrailsArmed(settings.guardrails)) return blocked;
|
|
38045
|
+
for (const account of await pool.listAccounts()) if (!evaluateGuardrails({
|
|
38046
|
+
guardrails: settings.guardrails,
|
|
38047
|
+
tokensToday: ledger.tokensToday(account.id),
|
|
38048
|
+
modelTokensToday: ledger.modelTokensToday(account.id, model),
|
|
38049
|
+
remainingCredits: getCachedQuotaFor$1(account.id)?.remainingCredits
|
|
38050
|
+
}).allowed) blocked.add(account.id);
|
|
38051
|
+
return blocked;
|
|
38052
|
+
}
|
|
36761
38053
|
async effortFor(options) {
|
|
36762
38054
|
const settings = await this.settings();
|
|
36763
38055
|
const credentials = await this.credentials(settings);
|
|
@@ -36784,6 +38076,8 @@ var WorkBuddyAdapter = class extends LlmAdapter {
|
|
|
36784
38076
|
const pool = this.accountPool;
|
|
36785
38077
|
const tried = /* @__PURE__ */ new Set();
|
|
36786
38078
|
let response;
|
|
38079
|
+
let servedAccountId;
|
|
38080
|
+
if (pool !== null) for (const blockedId of await this.guardrailBlockedAccounts(settings, options.model)) tried.add(blockedId);
|
|
36787
38081
|
rotation: while (true) {
|
|
36788
38082
|
let credentials;
|
|
36789
38083
|
let accountId;
|
|
@@ -36795,12 +38089,13 @@ var WorkBuddyAdapter = class extends LlmAdapter {
|
|
|
36795
38089
|
} else {
|
|
36796
38090
|
let effective;
|
|
36797
38091
|
try {
|
|
36798
|
-
effective = await pool.getEffectiveCredential(tried, fetchFn);
|
|
38092
|
+
effective = await pool.getEffectiveCredential(tried, fetchFn, options.model);
|
|
36799
38093
|
} catch (error) {
|
|
36800
38094
|
if (error instanceof LlmError) throw error;
|
|
36801
38095
|
throw new LlmError(error instanceof Error ? error.message : `Not signed in to ${PROVIDER_NAME$3}.`, "MISSING_CREDENTIAL", { cause: error });
|
|
36802
38096
|
}
|
|
36803
38097
|
accountId = effective.account.id;
|
|
38098
|
+
servedAccountId = accountId;
|
|
36804
38099
|
tried.add(accountId);
|
|
36805
38100
|
credentials = effective.credentials;
|
|
36806
38101
|
}
|
|
@@ -36810,7 +38105,10 @@ var WorkBuddyAdapter = class extends LlmAdapter {
|
|
|
36810
38105
|
try {
|
|
36811
38106
|
response = await fetchWithContextBudgetRecovery(fetchFn)(`${credentials.backend}${CHAT_PATH}`, {
|
|
36812
38107
|
method: "POST",
|
|
36813
|
-
headers: workBuddyHeaders(credentials, { accept: "text/event-stream" }, {
|
|
38108
|
+
headers: workBuddyHeaders(credentials, { accept: "text/event-stream" }, {
|
|
38109
|
+
...requestOptions.sessionId === void 0 ? {} : { sessionId: String(requestOptions.sessionId) },
|
|
38110
|
+
product: resolveOutboundProduct(settings.outboundIdentity, accountId ?? credentials.uid ?? "")
|
|
38111
|
+
}),
|
|
36814
38112
|
body,
|
|
36815
38113
|
signal
|
|
36816
38114
|
});
|
|
@@ -36834,11 +38132,15 @@ var WorkBuddyAdapter = class extends LlmAdapter {
|
|
|
36834
38132
|
if (response.ok) break;
|
|
36835
38133
|
const failure = classifyFailure$1(response.status, refusedDetail, response.headers);
|
|
36836
38134
|
if (pool !== null && accountId !== void 0) {
|
|
36837
|
-
const after = retryAfterMs$1(response.headers);
|
|
36838
|
-
if (failure.code === "RATE_LIMIT") {
|
|
38135
|
+
const after = failure.failure.providerRetryAfterMs ?? retryAfterMs$1(response.headers);
|
|
38136
|
+
if (failure.code === "RATE_LIMIT") if (after !== void 0 && after > 3e5) {
|
|
38137
|
+
if (accountId !== void 0) pool.markModelCooldown(accountId, options.model, after);
|
|
38138
|
+
if (await pool.hasAnotherAvailableAccountFor(tried, options.model)) continue;
|
|
38139
|
+
} else {
|
|
36839
38140
|
await pool.markCooldown(accountId, after ?? 3e5, `${PROVIDER_NAME$3} 429`).catch(() => void 0);
|
|
36840
38141
|
if (await pool.hasAnotherAvailableAccount(tried)) continue;
|
|
36841
|
-
}
|
|
38142
|
+
}
|
|
38143
|
+
else if (failure.code === "INVALID_CREDENTIAL") {
|
|
36842
38144
|
if (await pool.hasAnotherAvailableAccount(tried)) continue;
|
|
36843
38145
|
}
|
|
36844
38146
|
}
|
|
@@ -36850,6 +38152,7 @@ var WorkBuddyAdapter = class extends LlmAdapter {
|
|
|
36850
38152
|
const decoder = new TextDecoder();
|
|
36851
38153
|
const state = createStreamState$4();
|
|
36852
38154
|
let buffer = "";
|
|
38155
|
+
let recordedTokens = 0;
|
|
36853
38156
|
try {
|
|
36854
38157
|
while (true) {
|
|
36855
38158
|
const { done, value } = await reader.read();
|
|
@@ -36860,16 +38163,23 @@ var WorkBuddyAdapter = class extends LlmAdapter {
|
|
|
36860
38163
|
buffer = lines.pop() ?? "";
|
|
36861
38164
|
for (const line of lines) {
|
|
36862
38165
|
activity?.pulse();
|
|
36863
|
-
for (const chunk of processStreamLine$3(line, state))
|
|
38166
|
+
for (const chunk of processStreamLine$3(line, state)) {
|
|
38167
|
+
if (chunk.type === "usage") recordedTokens = chunk.usage.inputTokens + chunk.usage.outputTokens + (chunk.usage.cacheReadTokens ?? 0) + (chunk.usage.cacheWriteTokens ?? 0);
|
|
38168
|
+
yield chunk;
|
|
38169
|
+
}
|
|
36864
38170
|
if (state.finished) return;
|
|
36865
38171
|
}
|
|
36866
38172
|
}
|
|
36867
38173
|
buffer += decoder.decode();
|
|
36868
|
-
if (buffer.trim() !== "") for (const line of buffer.split("\n")) for (const chunk of processStreamLine$3(line, state))
|
|
38174
|
+
if (buffer.trim() !== "") for (const line of buffer.split("\n")) for (const chunk of processStreamLine$3(line, state)) {
|
|
38175
|
+
if (chunk.type === "usage") recordedTokens = chunk.usage.inputTokens + chunk.usage.outputTokens + (chunk.usage.cacheReadTokens ?? 0) + (chunk.usage.cacheWriteTokens ?? 0);
|
|
38176
|
+
yield chunk;
|
|
38177
|
+
}
|
|
36869
38178
|
if (state.finished) return;
|
|
36870
38179
|
assertStreamComplete$4(state);
|
|
36871
38180
|
for (const chunk of closeStream$3(state)) yield chunk;
|
|
36872
38181
|
} finally {
|
|
38182
|
+
if (servedAccountId !== void 0 && recordedTokens > 0) this.options.usageLedger?.record(servedAccountId, options.model, recordedTokens);
|
|
36873
38183
|
reader.cancel().catch(() => void 0);
|
|
36874
38184
|
}
|
|
36875
38185
|
}
|
|
@@ -36889,7 +38199,7 @@ function classifyFailure$1(status, detail, headers) {
|
|
|
36889
38199
|
return new LlmError(`${PROVIDER_NAME$3} rejected the stored credential (${status}). Sign in again with the CodeBuddy desktop client.${detail ? ` ${detail}` : ""}`, "INVALID_CREDENTIAL", { status });
|
|
36890
38200
|
}
|
|
36891
38201
|
if (status === 429 || code === 6004 || code === 14003) {
|
|
36892
|
-
const after = headers ? retryAfterMs$1(headers) : void 0;
|
|
38202
|
+
const after = (headers ? retryAfterMs$1(headers) : void 0) ?? readRateLimitReset(detail);
|
|
36893
38203
|
return new LlmError(`${PROVIDER_NAME$3} rate limit or plan quota reached. Check the quota card in Settings > WorkBuddy.${detail ? ` ${detail}` : ""}`, "RATE_LIMIT", {
|
|
36894
38204
|
status: status === 429 ? 429 : void 0,
|
|
36895
38205
|
...after === void 0 ? {} : { providerRetryAfterMs: after }
|
|
@@ -36945,6 +38255,45 @@ function refusalIsNotCredential(detail) {
|
|
|
36945
38255
|
const type = inner.type ?? envelope.type;
|
|
36946
38256
|
return typeof type === "string" && type === "server_error" && hasMessage;
|
|
36947
38257
|
}
|
|
38258
|
+
/** How far out a reset instant may be and still be treated as one. */
|
|
38259
|
+
const MAX_RESET_HORIZON_MS = 10080 * 60 * 1e3;
|
|
38260
|
+
const RESET_STAMP_RE = /(?:resets?\s+at|reset\s+at|将在)\s*(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/;
|
|
38261
|
+
const RESET_TZ_RE = /UTC([+-])(\d{1,2})(?::?(\d{2}))?/;
|
|
38262
|
+
/**
|
|
38263
|
+
* The reset instant a quota refusal names, as a delay from now.
|
|
38264
|
+
*
|
|
38265
|
+
* A 24h allowance that is exhausted answers 429 / `code 6004` with the wall
|
|
38266
|
+
* clock it lifts — but only in the MESSAGE, never in a header, and in two
|
|
38267
|
+
* realm-specific wordings (measured: intl "… resets at 2026-09-20 10:50:48
|
|
38268
|
+
* UTC+8", cn "… 将在 2026-10-09 14:44:59 UTC+8 重置"). Reading the status alone
|
|
38269
|
+
* therefore loses the only fact that says how long the account is out, and the
|
|
38270
|
+
* pool parks a 24-hour limit for five minutes before trying it again.
|
|
38271
|
+
*
|
|
38272
|
+
* Deliberately tolerant, in the same direction as their gateway: an unparseable
|
|
38273
|
+
* body yields `undefined` (callers keep their default cooldown) rather than an
|
|
38274
|
+
* error, because this runs on a failure path. An absent `UTC±h` marker means
|
|
38275
|
+
* UTC, which is how the sibling implementation reads it too.
|
|
38276
|
+
*/
|
|
38277
|
+
function readRateLimitReset(detail, now = Date.now()) {
|
|
38278
|
+
const stamp = RESET_STAMP_RE.exec(detail);
|
|
38279
|
+
if (stamp === null) return void 0;
|
|
38280
|
+
const [, date, clock] = stamp;
|
|
38281
|
+
const parts = `${date} ${clock}`.split(/[- :]/).map((part) => Number(part));
|
|
38282
|
+
const [year, month, day, hour, minute, second] = parts;
|
|
38283
|
+
if (parts.length !== 6 || parts.some((part) => !Number.isFinite(part))) return void 0;
|
|
38284
|
+
const zone = RESET_TZ_RE.exec(detail);
|
|
38285
|
+
let offsetMs = 0;
|
|
38286
|
+
if (zone !== null) {
|
|
38287
|
+
const hours = Number(zone[2] ?? "0");
|
|
38288
|
+
const minutes = Number(zone[3] ?? "0");
|
|
38289
|
+
offsetMs = (zone[1] === "-" ? -1 : 1) * (hours * 60 + minutes) * 6e4;
|
|
38290
|
+
}
|
|
38291
|
+
const at = Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1, hour ?? 0, minute ?? 0, second ?? 0) - offsetMs;
|
|
38292
|
+
if (!Number.isFinite(at)) return void 0;
|
|
38293
|
+
const delay = at - now;
|
|
38294
|
+
if (delay <= 0 || delay > MAX_RESET_HORIZON_MS) return void 0;
|
|
38295
|
+
return delay;
|
|
38296
|
+
}
|
|
36948
38297
|
/** Read the subscription's own numeric error code out of a response body. */
|
|
36949
38298
|
function readErrorCode(detail) {
|
|
36950
38299
|
const matched = /"code"\s*:\s*(\d+)/.exec(detail);
|
|
@@ -37159,6 +38508,17 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
|
|
|
37159
38508
|
* decision goes through, so the mapping is never stale when it is consulted.
|
|
37160
38509
|
*/
|
|
37161
38510
|
legacyIds = /* @__PURE__ */ new Map();
|
|
38511
|
+
/**
|
|
38512
|
+
* Per-model parks, in memory only: account id → model → the instant it lifts.
|
|
38513
|
+
*
|
|
38514
|
+
* The subscription meters an account × MODEL over a 24h window (429 `code
|
|
38515
|
+
* 6004` names when it lifts), so cooling the whole ACCOUNT for one exhausted
|
|
38516
|
+
* model throws away every other model that account can still serve. Process
|
|
38517
|
+
* local on purpose, like the sibling gateway's model cooldowns: the window is
|
|
38518
|
+
* short, and a restart re-measures it with the first request rather than
|
|
38519
|
+
* trusting a stale timestamp.
|
|
38520
|
+
*/
|
|
38521
|
+
modelCooldowns = /* @__PURE__ */ new Map();
|
|
37162
38522
|
constructor(options = {}) {
|
|
37163
38523
|
const store = options.store ?? new FileCredentialStore$4();
|
|
37164
38524
|
const hooks = {
|
|
@@ -37341,8 +38701,9 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
|
|
|
37341
38701
|
* refreshes its own token, so the copy the pool adopted can be older than what
|
|
37342
38702
|
* is on disk.
|
|
37343
38703
|
*/
|
|
37344
|
-
async getEffectiveCredential(excludeIds, fetchFn = fetch) {
|
|
37345
|
-
const
|
|
38704
|
+
async getEffectiveCredential(excludeIds, fetchFn = fetch, model) {
|
|
38705
|
+
const parked = model === void 0 ? /* @__PURE__ */ new Set() : this.parkedAccountIds(model);
|
|
38706
|
+
const effective = await this.getEffectiveAccount(parked.size === 0 ? excludeIds : /* @__PURE__ */ new Set([...excludeIds ?? [], ...parked]), fetchFn);
|
|
37346
38707
|
if (effective.account.source !== "desktop" || effective.account.sourceFile === void 0) return effective;
|
|
37347
38708
|
const live = await this.store.readDesktopFile(effective.account.sourceFile);
|
|
37348
38709
|
if (live === null) return effective;
|
|
@@ -37360,6 +38721,52 @@ var WorkBuddyAccountPool = class extends AccountPoolCore {
|
|
|
37360
38721
|
};
|
|
37361
38722
|
}
|
|
37362
38723
|
/**
|
|
38724
|
+
* Park ONE model on one account; every other model keeps serving.
|
|
38725
|
+
*
|
|
38726
|
+
* Returns the instant the park lifts, or undefined for a non-positive
|
|
38727
|
+
* duration (which clears any existing park rather than extending it).
|
|
38728
|
+
*/
|
|
38729
|
+
markModelCooldown(accountId, model, durationMs) {
|
|
38730
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
|
38731
|
+
this.modelCooldowns.get(accountId)?.delete(model);
|
|
38732
|
+
return;
|
|
38733
|
+
}
|
|
38734
|
+
const until = Date.now() + durationMs;
|
|
38735
|
+
const forAccount = this.modelCooldowns.get(accountId) ?? /* @__PURE__ */ new Map();
|
|
38736
|
+
forAccount.set(model, until);
|
|
38737
|
+
this.modelCooldowns.set(accountId, forAccount);
|
|
38738
|
+
return until;
|
|
38739
|
+
}
|
|
38740
|
+
/** When this account's park on this model lifts, or undefined when it may serve. */
|
|
38741
|
+
modelCooldownUntil(accountId, model, now = Date.now()) {
|
|
38742
|
+
const until = this.modelCooldowns.get(accountId)?.get(model);
|
|
38743
|
+
return until !== void 0 && until > now ? until : void 0;
|
|
38744
|
+
}
|
|
38745
|
+
/** Accounts currently parked on one model, with lifted entries pruned on the way. */
|
|
38746
|
+
parkedAccountIds(model, now = Date.now()) {
|
|
38747
|
+
const parked = /* @__PURE__ */ new Set();
|
|
38748
|
+
for (const [accountId, models] of this.modelCooldowns) {
|
|
38749
|
+
const until = models.get(model);
|
|
38750
|
+
if (until === void 0) continue;
|
|
38751
|
+
if (until > now) parked.add(accountId);
|
|
38752
|
+
else models.delete(model);
|
|
38753
|
+
}
|
|
38754
|
+
return parked;
|
|
38755
|
+
}
|
|
38756
|
+
/**
|
|
38757
|
+
* Whether another account besides `triedAccountIds` can serve THIS model.
|
|
38758
|
+
*
|
|
38759
|
+
* The plain {@link hasAnotherAvailableAccount} answers for any model, so a
|
|
38760
|
+
* rotation loop that consulted it would keep trying while every remaining
|
|
38761
|
+
* account is parked on the model it is asking for — turning one exhausted
|
|
38762
|
+
* window into a burst of refused requests.
|
|
38763
|
+
*/
|
|
38764
|
+
async hasAnotherAvailableAccountFor(triedAccountIds, model) {
|
|
38765
|
+
const parked = this.parkedAccountIds(model);
|
|
38766
|
+
if (parked.size === 0) return super.hasAnotherAvailableAccount(triedAccountIds);
|
|
38767
|
+
return super.hasAnotherAvailableAccount(/* @__PURE__ */ new Set([...triedAccountIds, ...parked]));
|
|
38768
|
+
}
|
|
38769
|
+
/**
|
|
37363
38770
|
* Whether an account may serve a request.
|
|
37364
38771
|
*
|
|
37365
38772
|
* A hidden account keeps its place, its alias and its history, but the user
|
|
@@ -42137,7 +43544,7 @@ function sleep$1(ms, signal) {
|
|
|
42137
43544
|
* so that spelling is accepted too — but only for the 127/8 block, never for a
|
|
42138
43545
|
* v4-mapped public address.
|
|
42139
43546
|
*/
|
|
42140
|
-
function isLoopbackAddress(address) {
|
|
43547
|
+
function isLoopbackAddress$1(address) {
|
|
42141
43548
|
if (address === void 0) return false;
|
|
42142
43549
|
if (address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1") return true;
|
|
42143
43550
|
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
|
|
@@ -42709,7 +44116,7 @@ const SUCCESS_PAGE = "Claude sign-in completed. You can close this window.";
|
|
|
42709
44116
|
*/
|
|
42710
44117
|
function callbackHandler(request, response) {
|
|
42711
44118
|
(async () => {
|
|
42712
|
-
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
|
44119
|
+
if (!isLoopbackAddress$1(request.socket.remoteAddress)) {
|
|
42713
44120
|
await writeHtml(response, 403, "This callback only answers on this machine.");
|
|
42714
44121
|
return;
|
|
42715
44122
|
}
|
|
@@ -56803,6 +58210,16 @@ const CHECKIN_PATH = "/billing/meter/daily-checkin";
|
|
|
56803
58210
|
*/
|
|
56804
58211
|
const CHECKIN_TICK_MS$1 = 600 * 1e3;
|
|
56805
58212
|
const CHECKIN_TIMEOUT_MS$1 = 2e4;
|
|
58213
|
+
/**
|
|
58214
|
+
* Random pause between two accounts in one pass.
|
|
58215
|
+
*
|
|
58216
|
+
* Sequential accounts already serialise their requests, but they still arrive as
|
|
58217
|
+
* one tight cluster from one outbound IP; a dozen-account pool doing that at the
|
|
58218
|
+
* same minute is the burst pattern the sibling gateway added jitter for after
|
|
58219
|
+
* seeing upstream rate-limit whole pools.
|
|
58220
|
+
*/
|
|
58221
|
+
const CHECKIN_JITTER_MIN_MS = 2e3;
|
|
58222
|
+
const CHECKIN_JITTER_MAX_MS = 5e3;
|
|
56806
58223
|
/** State document the scheduler keeps beside the other plugin stores. */
|
|
56807
58224
|
function checkinStatePath$1() {
|
|
56808
58225
|
return path.join(dshHomeDir(), "storages", "workbuddy-checkin.json");
|
|
@@ -56893,6 +58310,8 @@ var WorkBuddyCheckinService = class {
|
|
|
56893
58310
|
fetchFn;
|
|
56894
58311
|
statePath;
|
|
56895
58312
|
now;
|
|
58313
|
+
pause;
|
|
58314
|
+
random;
|
|
56896
58315
|
logger;
|
|
56897
58316
|
state = {};
|
|
56898
58317
|
loaded = false;
|
|
@@ -56909,6 +58328,10 @@ var WorkBuddyCheckinService = class {
|
|
|
56909
58328
|
this.fetchFn = options.fetchFn ?? fetch;
|
|
56910
58329
|
this.statePath = options.statePath ?? checkinStatePath$1();
|
|
56911
58330
|
this.now = options.now ?? (() => Date.now());
|
|
58331
|
+
this.pause = options.pause ?? ((ms) => new Promise((resolve) => {
|
|
58332
|
+
setTimeout(resolve, ms);
|
|
58333
|
+
}));
|
|
58334
|
+
this.random = options.random ?? Math.random;
|
|
56912
58335
|
this.logger = options.logger;
|
|
56913
58336
|
}
|
|
56914
58337
|
/**
|
|
@@ -56961,6 +58384,7 @@ var WorkBuddyCheckinService = class {
|
|
|
56961
58384
|
this.logger?.warn(`[workbuddy-checkin] account scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
56962
58385
|
return;
|
|
56963
58386
|
}
|
|
58387
|
+
let ranOne = false;
|
|
56964
58388
|
for (const credentials of accounts) {
|
|
56965
58389
|
if (isIntlDomain(credentials.domain)) continue;
|
|
56966
58390
|
const id = workBuddyAccountId(credentials);
|
|
@@ -56969,7 +58393,9 @@ var WorkBuddyCheckinService = class {
|
|
|
56969
58393
|
if (sameDay && existing.done) continue;
|
|
56970
58394
|
if (!manual && sameDay && existing.attempts >= 3) continue;
|
|
56971
58395
|
if (!manual && sameDay && existing.inactive === true && nowMs - (existing.inactiveAt ?? 0) < 36e5) continue;
|
|
58396
|
+
if (ranOne) await this.pause(CHECKIN_JITTER_MIN_MS + Math.floor(this.random() * (CHECKIN_JITTER_MAX_MS - CHECKIN_JITTER_MIN_MS)));
|
|
56972
58397
|
await this.checkinOne(id, credentials, today, nowMs);
|
|
58398
|
+
ranOne = true;
|
|
56973
58399
|
}
|
|
56974
58400
|
this.lastRunAt = nowMs;
|
|
56975
58401
|
await this.save();
|
|
@@ -59957,6 +61383,11 @@ function installRelayProbe(ctx, options) {
|
|
|
59957
61383
|
* already existed when the guard installed was never reported at all. Both
|
|
59958
61384
|
* cases cut the runaway and then resumed nothing, which reads to the user as a
|
|
59959
61385
|
* conversation that simply stops mid-answer.
|
|
61386
|
+
*
|
|
61387
|
+
* Status: **switched off in this line by default**. Everything here still
|
|
61388
|
+
* works and is still tested; what changed is the wiring in `src/index.ts`,
|
|
61389
|
+
* whose `reasoningCollapseGuard` config now defaults to `false` instead of
|
|
61390
|
+
* `{}`. A deployment that wants the break back opts in with an object.
|
|
59960
61391
|
*/
|
|
59961
61392
|
const DEFAULT_GUARD_OPTIONS = {
|
|
59962
61393
|
windowChars: 4096,
|
|
@@ -60157,7 +61588,7 @@ function createBreakerState() {
|
|
|
60157
61588
|
* instead of impersonating a human turn.
|
|
60158
61589
|
*/
|
|
60159
61590
|
function createResumeMessage$1(text) {
|
|
60160
|
-
return createUserMessage({
|
|
61591
|
+
return createUserMessage$1({
|
|
60161
61592
|
content: [{
|
|
60162
61593
|
type: "text",
|
|
60163
61594
|
text
|
|
@@ -60461,7 +61892,7 @@ function resolveNoAnswerResumeOptions(options = {}) {
|
|
|
60461
61892
|
}
|
|
60462
61893
|
/** The message a resume carries, with this package's own provenance. */
|
|
60463
61894
|
function createResumeMessage(text) {
|
|
60464
|
-
return createUserMessage({
|
|
61895
|
+
return createUserMessage$1({
|
|
60465
61896
|
content: [{
|
|
60466
61897
|
type: "text",
|
|
60467
61898
|
text
|
|
@@ -60677,6 +62108,579 @@ function releaseListener(handle) {
|
|
|
60677
62108
|
}
|
|
60678
62109
|
handle?.dispose?.();
|
|
60679
62110
|
}
|
|
62111
|
+
/**
|
|
62112
|
+
* 判断是否该催促。达到阈值即触发,注入之后调用方把计数清零。
|
|
62113
|
+
* @param rounds - 自上次进展更新以来的连续轮次数。
|
|
62114
|
+
* @returns 是否达到 {@link NUDGE_THRESHOLD}。
|
|
62115
|
+
*/
|
|
62116
|
+
function shouldNudge(rounds) {
|
|
62117
|
+
return rounds >= 8;
|
|
62118
|
+
}
|
|
62119
|
+
/**
|
|
62120
|
+
* 把进展列表截断到环形缓冲上限,保留最新的一批。
|
|
62121
|
+
* @param list - 任意长度的进展列表(不会被就地修改)。
|
|
62122
|
+
* @returns 截断后的新数组。
|
|
62123
|
+
*/
|
|
62124
|
+
function capUpdates(list) {
|
|
62125
|
+
return list.length <= 50 ? [...list] : list.slice(list.length - 50);
|
|
62126
|
+
}
|
|
62127
|
+
/** 进程内自增序号,只用于拼出稳定 id;不含任何运行时依赖。 */
|
|
62128
|
+
let sequence = 0;
|
|
62129
|
+
/**
|
|
62130
|
+
* 生成一条进展的稳定 id。
|
|
62131
|
+
* @param at - 产生时刻(Unix 毫秒)。
|
|
62132
|
+
* @returns 形如 `pu-<时间戳>-<序号>` 的字符串。
|
|
62133
|
+
*/
|
|
62134
|
+
function nextUpdateId(at) {
|
|
62135
|
+
sequence += 1;
|
|
62136
|
+
return `pu-${at}-${sequence}`;
|
|
62137
|
+
}
|
|
62138
|
+
/**
|
|
62139
|
+
* 创建一个空的进展更新 store。
|
|
62140
|
+
* @returns 新的会话维度 store。
|
|
62141
|
+
*/
|
|
62142
|
+
function createProgressUpdateStore() {
|
|
62143
|
+
let current = {
|
|
62144
|
+
updates: [],
|
|
62145
|
+
roundsSinceUpdate: 0,
|
|
62146
|
+
nudges: 0
|
|
62147
|
+
};
|
|
62148
|
+
return {
|
|
62149
|
+
state: () => current,
|
|
62150
|
+
push: (update) => {
|
|
62151
|
+
current = {
|
|
62152
|
+
...current,
|
|
62153
|
+
updates: capUpdates([...current.updates, update])
|
|
62154
|
+
};
|
|
62155
|
+
return current;
|
|
62156
|
+
},
|
|
62157
|
+
bumpRound: () => {
|
|
62158
|
+
current = {
|
|
62159
|
+
...current,
|
|
62160
|
+
roundsSinceUpdate: current.roundsSinceUpdate + 1
|
|
62161
|
+
};
|
|
62162
|
+
return current.roundsSinceUpdate;
|
|
62163
|
+
},
|
|
62164
|
+
markUpdated: () => {
|
|
62165
|
+
current = {
|
|
62166
|
+
...current,
|
|
62167
|
+
roundsSinceUpdate: 0
|
|
62168
|
+
};
|
|
62169
|
+
return current;
|
|
62170
|
+
},
|
|
62171
|
+
markNudged: () => {
|
|
62172
|
+
current = {
|
|
62173
|
+
...current,
|
|
62174
|
+
roundsSinceUpdate: 0,
|
|
62175
|
+
nudges: current.nudges + 1
|
|
62176
|
+
};
|
|
62177
|
+
return current;
|
|
62178
|
+
},
|
|
62179
|
+
reset: () => {
|
|
62180
|
+
current = {
|
|
62181
|
+
updates: [],
|
|
62182
|
+
roundsSinceUpdate: 0,
|
|
62183
|
+
nudges: 0
|
|
62184
|
+
};
|
|
62185
|
+
return current;
|
|
62186
|
+
}
|
|
62187
|
+
};
|
|
62188
|
+
}
|
|
62189
|
+
//#endregion
|
|
62190
|
+
//#region src/host/progress-updates/tool.ts
|
|
62191
|
+
/** 注册的工具名。冻结契约的一部分,不要改动。 */
|
|
62192
|
+
const NOTIFY_USER_TOOL = "progress_update";
|
|
62193
|
+
/** 工具关闭时返回给模型的文本(canonical 输出的 render,而不是抛错)。 */
|
|
62194
|
+
const DISABLED_OUTPUT_TEXT = "Progress updates are disabled; the update was not displayed.";
|
|
62195
|
+
/** 工具描述的最终全文。 */
|
|
62196
|
+
const PROGRESS_UPDATE_DESCRIPTION = [
|
|
62197
|
+
"Report progress to the user without ending your turn.",
|
|
62198
|
+
"Use it during multi-step work so the user can follow along and correct course early:",
|
|
62199
|
+
"state your plan near the beginning of the task (what you will do, and in what order);",
|
|
62200
|
+
"report a meaningful conclusion the moment you have it (a key fact, the root cause, a verified assumption) rather than narrating every step;",
|
|
62201
|
+
"give a stage judgement when one phase ends and the next begins (where things stand, what happens next);",
|
|
62202
|
+
"explain what you are waiting for before a long wait such as a test run, an install, or a build;",
|
|
62203
|
+
"and say what blocks you — and what you will try next, or what you need from the user — when you are stuck.",
|
|
62204
|
+
"Each update has one short title line (plain text, no Markdown, no trailing period) with the details in the message body.",
|
|
62205
|
+
"Do not repeat a status that has not changed.",
|
|
62206
|
+
"When you run as a subagent, describe only your own subtask: finishing your subtask is not the same as finishing the overall task."
|
|
62207
|
+
].join(" ");
|
|
62208
|
+
/**
|
|
62209
|
+
* 构造 progress_update 工具。
|
|
62210
|
+
* @param deps - 依赖:当前开关与进展接收端。
|
|
62211
|
+
* @returns 可注册的工具定义。
|
|
62212
|
+
*/
|
|
62213
|
+
function createProgressUpdateTool(deps) {
|
|
62214
|
+
return defineTool({
|
|
62215
|
+
name: NOTIFY_USER_TOOL,
|
|
62216
|
+
description: PROGRESS_UPDATE_DESCRIPTION,
|
|
62217
|
+
parameters: {
|
|
62218
|
+
title: {
|
|
62219
|
+
type: "string",
|
|
62220
|
+
description: "One line, about 60 characters at most, in the user's language: plain text, no Markdown, no trailing period."
|
|
62221
|
+
},
|
|
62222
|
+
message: {
|
|
62223
|
+
type: "string",
|
|
62224
|
+
required: true,
|
|
62225
|
+
description: "The details, about 1000 characters at most, in light Markdown: one paragraph of prose plus a few bullets."
|
|
62226
|
+
}
|
|
62227
|
+
},
|
|
62228
|
+
output: {
|
|
62229
|
+
schema: {
|
|
62230
|
+
type: "object",
|
|
62231
|
+
additionalProperties: false,
|
|
62232
|
+
properties: { displayed: {
|
|
62233
|
+
type: "boolean",
|
|
62234
|
+
description: "Whether the update was actually shown to the user."
|
|
62235
|
+
} }
|
|
62236
|
+
},
|
|
62237
|
+
render(_args, value) {
|
|
62238
|
+
return [{
|
|
62239
|
+
type: "text",
|
|
62240
|
+
text: value.displayed ? "Progress update displayed." : DISABLED_OUTPUT_TEXT
|
|
62241
|
+
}];
|
|
62242
|
+
}
|
|
62243
|
+
},
|
|
62244
|
+
async execute(args, exec) {
|
|
62245
|
+
if (!deps.isEnabled()) return { displayed: false };
|
|
62246
|
+
const sink = deps.sink?.();
|
|
62247
|
+
if (sink === void 0) return { displayed: false };
|
|
62248
|
+
const at = Date.now();
|
|
62249
|
+
const source = deps.sourceOf?.(exec);
|
|
62250
|
+
const update = {
|
|
62251
|
+
id: nextUpdateId(at),
|
|
62252
|
+
message: args.message,
|
|
62253
|
+
at,
|
|
62254
|
+
...args.title === void 0 || args.title.trim() === "" ? {} : { title: args.title.trim() },
|
|
62255
|
+
...source === void 0 ? {} : { source }
|
|
62256
|
+
};
|
|
62257
|
+
return { displayed: sink.push(update) };
|
|
62258
|
+
}
|
|
62259
|
+
});
|
|
62260
|
+
}
|
|
62261
|
+
//#endregion
|
|
62262
|
+
//#region src/host/progress-updates/nudge.ts
|
|
62263
|
+
/** 注入到模型上下文里的催促正文。 */
|
|
62264
|
+
const NUDGE_TEXT = [
|
|
62265
|
+
"You have made a run of tool calls without reporting progress to the user.",
|
|
62266
|
+
"If you are still in the middle of multi-step work, send one progress update now with the progress_update tool:",
|
|
62267
|
+
"one short title line and the details in the body — what you have established so far, where things stand, and what you are doing next.",
|
|
62268
|
+
"Skip it when there is genuinely nothing new to report."
|
|
62269
|
+
].join(" ");
|
|
62270
|
+
/** 催促上下文在消息来源上的 kind(供客户端与日志区分这类注入)。 */
|
|
62271
|
+
const NUDGE_SOURCE_KIND = "progress-updates-nudge";
|
|
62272
|
+
/**
|
|
62273
|
+
* 造一条催促上下文。
|
|
62274
|
+
* @param sequence - 本会话第几次催促,写进 notice 摘要便于日志辨认。
|
|
62275
|
+
* @returns 可直接放进 `additionalContexts` 的用户角色消息。
|
|
62276
|
+
*/
|
|
62277
|
+
function createNudgeContext(sequence) {
|
|
62278
|
+
return createUserMessage({
|
|
62279
|
+
content: [{
|
|
62280
|
+
type: "text",
|
|
62281
|
+
text: NUDGE_TEXT
|
|
62282
|
+
}],
|
|
62283
|
+
source: {
|
|
62284
|
+
kind: NUDGE_SOURCE_KIND,
|
|
62285
|
+
form: "notice",
|
|
62286
|
+
summary: `progress update nudge #${sequence}`
|
|
62287
|
+
}
|
|
62288
|
+
});
|
|
62289
|
+
}
|
|
62290
|
+
/**
|
|
62291
|
+
* 找出一次工具调用所属的**计数会话**:子会话归并到最近的顶层祖先,整棵委派
|
|
62292
|
+
* 树共用一个计数器。
|
|
62293
|
+
* @param agent - 事件里的调用方 agent。
|
|
62294
|
+
* @param sessions - 会话查找表,缺省时退化为只认调用方自己的会话。
|
|
62295
|
+
* @returns 计数用的会话 id,无法判定时返回 undefined。
|
|
62296
|
+
*/
|
|
62297
|
+
function counterSessionIdOf(agent, sessions) {
|
|
62298
|
+
const own = stringOf(agent?.session?.header?.id) ?? stringOf(agent?.id);
|
|
62299
|
+
if (own === void 0) return void 0;
|
|
62300
|
+
if (sessions === void 0) return own;
|
|
62301
|
+
let current = own;
|
|
62302
|
+
let header = agent?.session?.header;
|
|
62303
|
+
for (let depth = 0; depth < 16 && current !== void 0; depth += 1) {
|
|
62304
|
+
const resolved = header ?? sessions.get(current)?.header;
|
|
62305
|
+
if (resolved === void 0) return current;
|
|
62306
|
+
const parentId = stringOf(resolved.parentSession);
|
|
62307
|
+
if (parentId === void 0) return current;
|
|
62308
|
+
const parent = sessions.get(parentId);
|
|
62309
|
+
if (parent === void 0) return current;
|
|
62310
|
+
header = parent.header;
|
|
62311
|
+
current = parentId;
|
|
62312
|
+
}
|
|
62313
|
+
return current;
|
|
62314
|
+
}
|
|
62315
|
+
/**
|
|
62316
|
+
* 读一次工具调用所属的「步」标识,用于把同一个模型步里的多条工具结果合并成一轮。
|
|
62317
|
+
*
|
|
62318
|
+
* 拿不到 turnBoundary(没有 agent-loop,或宿主不是真实会话)时返回 undefined,
|
|
62319
|
+
* 调用方退化为按工具结果计数——宁可多计,也不要漏掉催促。
|
|
62320
|
+
* @param agent - 事件里的调用方 agent。
|
|
62321
|
+
* @param projections - 会话投影服务,可选。
|
|
62322
|
+
* @returns 该步的唯一键,或 undefined。
|
|
62323
|
+
*/
|
|
62324
|
+
function stepKeyOf(agent, projections) {
|
|
62325
|
+
if (projections === void 0) return void 0;
|
|
62326
|
+
const session = agent?.session;
|
|
62327
|
+
if (session === void 0) return void 0;
|
|
62328
|
+
try {
|
|
62329
|
+
const seq = projections.stateOf(session, "turnBoundary")?.lastStepStartSeq;
|
|
62330
|
+
return typeof seq === "number" ? `step:${seq}` : void 0;
|
|
62331
|
+
} catch {
|
|
62332
|
+
return;
|
|
62333
|
+
}
|
|
62334
|
+
}
|
|
62335
|
+
/**
|
|
62336
|
+
* 读一个未知字段为字符串。
|
|
62337
|
+
* @param value - 候选值。
|
|
62338
|
+
* @returns 字符串,或任何其它类型的 undefined。
|
|
62339
|
+
*/
|
|
62340
|
+
function stringOf(value) {
|
|
62341
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
62342
|
+
}
|
|
62343
|
+
//#endregion
|
|
62344
|
+
//#region src/host/progress-updates/prompt.ts
|
|
62345
|
+
/**
|
|
62346
|
+
* 系统提示词引导:让模型知道「多步任务里要主动汇报进展」这件事该怎么用。
|
|
62347
|
+
*
|
|
62348
|
+
* 文本用函数形式提供(而不是静态字符串):开关关闭时返回空串,装配期就地
|
|
62349
|
+
* 抹掉引导,不需要重启会话、也不需要重挂插件行。
|
|
62350
|
+
*
|
|
62351
|
+
* @module dsh-chatgpt-subscription/host/progress-updates/prompt
|
|
62352
|
+
*/
|
|
62353
|
+
/**
|
|
62354
|
+
* 本模块提示词分节的名称,全局唯一(重复注册会抛错)。
|
|
62355
|
+
*
|
|
62356
|
+
* 保留原插件的 `plugin:progress-updates` 拼写:会话里已有的分节名不能因为搬家
|
|
62357
|
+
* 而变成第二个分节。
|
|
62358
|
+
*/
|
|
62359
|
+
const PROGRESS_SECTION_NAME = "plugin:progress-updates";
|
|
62360
|
+
/**
|
|
62361
|
+
* 引导词正文。写法对齐 Kimi 的「进展更新」:先说清多步任务早期的思路,再报
|
|
62362
|
+
* 有意义的结论、阶段判断、长等待与阻塞;每条给一行标题,细节放正文;不重复
|
|
62363
|
+
* 没有变化的状态。
|
|
62364
|
+
*/
|
|
62365
|
+
const PROGRESS_GUIDANCE = [
|
|
62366
|
+
"## 进展更新",
|
|
62367
|
+
"",
|
|
62368
|
+
"在多步任务里,你可以调用 `progress_update` 向用户主动汇报进展。它不会结束你的回合,也不消耗一次问答:报告完继续干活即可。",
|
|
62369
|
+
"",
|
|
62370
|
+
"什么时候用:",
|
|
62371
|
+
"- **任务早期**:先说清你打算怎么做(拆成哪几步、先动哪里),让用户在你埋头执行之前就能纠正方向。",
|
|
62372
|
+
"- **有意义的结论**:查到关键事实、定位到根因、验证了一个假设——报结论本身,不要报「我刚读了一个文件」。",
|
|
62373
|
+
"- **阶段判断**:一步做完、进入下一步之前,用一句话说明现在的状态和接下来要做什么。",
|
|
62374
|
+
"- **长等待之前**:跑测试、装依赖、等构建这类要花上一阵子的操作,先说清你在等什么、大概要等多久。",
|
|
62375
|
+
"- **被阻塞时**:缺信息、缺权限、外部依赖失败——说清卡在哪、你打算怎么绕过去,或者需要用户做什么。",
|
|
62376
|
+
"",
|
|
62377
|
+
"怎么写:",
|
|
62378
|
+
"- 每条给一行短标题(title,纯文本,不带 Markdown,不加结尾句号),细节放进正文(message)。",
|
|
62379
|
+
"- 正文用轻 Markdown:一段话说清发生了什么,必要时跟少量 bullet 列出要点。",
|
|
62380
|
+
"- 不要重复没有变化的状态:上一条报过的还在进行中,就不用再报一遍。",
|
|
62381
|
+
"- 进展更新是给用户看的,不是给日志看的:写人话,别复述工具调用参数。",
|
|
62382
|
+
"",
|
|
62383
|
+
"作为 subagent 运行时:只描述你自己的子任务进展。你的子任务完成不等于整体任务完成,不要把整体进度写成已完成。"
|
|
62384
|
+
].join("\n");
|
|
62385
|
+
/**
|
|
62386
|
+
* 生成提示词分节的文本提供者。
|
|
62387
|
+
* @param isEnabled - 读取当前开关状态的函数(每次装配重新求值)。
|
|
62388
|
+
* @returns 开关打开时返回引导词,关闭时返回空串。
|
|
62389
|
+
*/
|
|
62390
|
+
function progressSectionText(isEnabled) {
|
|
62391
|
+
return () => isEnabled() ? PROGRESS_GUIDANCE : "";
|
|
62392
|
+
}
|
|
62393
|
+
//#endregion
|
|
62394
|
+
//#region src/host/progress-updates/routes.ts
|
|
62395
|
+
/** 进展读取路由的完整路径。 */
|
|
62396
|
+
const PROGRESS_UPDATES_PATH = "/api/dsh-progress-updates/updates";
|
|
62397
|
+
/** 路由默认的 JSON 响应头。 */
|
|
62398
|
+
const JSON_HEADERS = {
|
|
62399
|
+
"content-type": "application/json; charset=utf-8",
|
|
62400
|
+
"referrer-policy": "no-referrer"
|
|
62401
|
+
};
|
|
62402
|
+
/**
|
|
62403
|
+
* 断言本次请求来自本机。
|
|
62404
|
+
*
|
|
62405
|
+
* 进展内容会包含用户在会话里做的事,属于个人数据,所以只有 loopback(桌面端
|
|
62406
|
+
* 自己)能读:socket 地址是权威判断,Host 头必须也是 loopback 权威(挡住 DNS
|
|
62407
|
+
* 重绑定),`X-Forwarded-For` 永远不信任。
|
|
62408
|
+
* @param request - 进来的 HTTP 请求。
|
|
62409
|
+
* @returns 是否放行。
|
|
62410
|
+
*/
|
|
62411
|
+
function isLoopbackRequest(request) {
|
|
62412
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
|
|
62413
|
+
const host = request.headers.host;
|
|
62414
|
+
if (typeof host !== "string" || host === "") return false;
|
|
62415
|
+
let hostname;
|
|
62416
|
+
try {
|
|
62417
|
+
hostname = new URL("http://" + host).hostname.toLowerCase();
|
|
62418
|
+
} catch {
|
|
62419
|
+
return false;
|
|
62420
|
+
}
|
|
62421
|
+
if (!isLoopbackHostname(hostname)) return false;
|
|
62422
|
+
return request.headers["sec-fetch-site"] !== "cross-site";
|
|
62423
|
+
}
|
|
62424
|
+
/** socket 远端地址是否落在 loopback 范围(127/8、::1、IPv4-mapped)。 */
|
|
62425
|
+
function isLoopbackAddress(address) {
|
|
62426
|
+
if (address === void 0) return false;
|
|
62427
|
+
const normalized = address.toLowerCase();
|
|
62428
|
+
if (normalized === "::1") return true;
|
|
62429
|
+
if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
|
|
62430
|
+
return isIPv4Loopback(normalized);
|
|
62431
|
+
}
|
|
62432
|
+
/** IPv4 127/8 判定(四段十进制,首段为 127)。 */
|
|
62433
|
+
function isIPv4Loopback(v4) {
|
|
62434
|
+
const parts = v4.split(".");
|
|
62435
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
62436
|
+
}
|
|
62437
|
+
/** 主机名是否指向 loopback 权威(localhost、[::1]、127/8)。 */
|
|
62438
|
+
function isLoopbackHostname(hostname) {
|
|
62439
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
62440
|
+
return isIPv4Loopback(hostname);
|
|
62441
|
+
}
|
|
62442
|
+
/**
|
|
62443
|
+
* 写一个 JSON 响应。
|
|
62444
|
+
* @param res - 响应对象。
|
|
62445
|
+
* @param status - HTTP 状态码。
|
|
62446
|
+
* @param body - 要序列化的值。
|
|
62447
|
+
* @param headers - 追加或覆盖的头。
|
|
62448
|
+
*/
|
|
62449
|
+
function writeJson(res, status, body, headers = {}) {
|
|
62450
|
+
res.writeHead(status, {
|
|
62451
|
+
...JSON_HEADERS,
|
|
62452
|
+
...headers
|
|
62453
|
+
});
|
|
62454
|
+
res.end(JSON.stringify(body));
|
|
62455
|
+
}
|
|
62456
|
+
/**
|
|
62457
|
+
* 进展读取路由:`GET /api/dsh-progress-updates/updates?session=<id>`。
|
|
62458
|
+
*
|
|
62459
|
+
* 返回 `{ updates: ProgressUpdate[] }`。响应带 no-store:这个列表随 agent 干活
|
|
62460
|
+
* 实时变化,缓存住就等于展示过期进展。
|
|
62461
|
+
* @param reader - 进展读取面。
|
|
62462
|
+
* @returns 可注册的 route。
|
|
62463
|
+
*/
|
|
62464
|
+
function makeProgressUpdatesRoute(reader) {
|
|
62465
|
+
return {
|
|
62466
|
+
kind: "exact",
|
|
62467
|
+
path: PROGRESS_UPDATES_PATH,
|
|
62468
|
+
handler: (req, res) => {
|
|
62469
|
+
if (!isLoopbackRequest(req)) {
|
|
62470
|
+
writeJson(res, 403, {
|
|
62471
|
+
ok: false,
|
|
62472
|
+
error: "forbidden: loopback-only"
|
|
62473
|
+
});
|
|
62474
|
+
return;
|
|
62475
|
+
}
|
|
62476
|
+
const session = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("session") ?? "";
|
|
62477
|
+
writeJson(res, 200, { updates: reader.updatesOf(session) }, { "cache-control": "no-store" });
|
|
62478
|
+
}
|
|
62479
|
+
};
|
|
62480
|
+
}
|
|
62481
|
+
//#endregion
|
|
62482
|
+
//#region src/host/progress-updates/index.ts
|
|
62483
|
+
/**
|
|
62484
|
+
* 读取一个配置字段,沿着 volatile 活引用取当前值。
|
|
62485
|
+
* @param field - 字段本身。
|
|
62486
|
+
* @returns 当前值,或字段缺失时的 undefined。
|
|
62487
|
+
*/
|
|
62488
|
+
function readProgressConfigField(field) {
|
|
62489
|
+
if (field === void 0 || field === null) return void 0;
|
|
62490
|
+
if (typeof field === "object" && typeof field.get === "function") return field.get();
|
|
62491
|
+
return field;
|
|
62492
|
+
}
|
|
62493
|
+
/**
|
|
62494
|
+
* 解析一次读取的有效设置:每次调用都重读活引用,因此设置保存后立刻生效。
|
|
62495
|
+
* @param config - 主入口收到的插件配置(活引用或普通值)。
|
|
62496
|
+
* @returns 生效的开关与催促阈值。
|
|
62497
|
+
*/
|
|
62498
|
+
function resolveProgressUpdatesSettings(config) {
|
|
62499
|
+
const enabled = readProgressConfigField(config?.progressUpdates);
|
|
62500
|
+
const threshold = readProgressConfigField(config?.progressUpdatesNudgeThreshold);
|
|
62501
|
+
return {
|
|
62502
|
+
enabled: enabled === true,
|
|
62503
|
+
nudgeThreshold: typeof threshold === "number" && Number.isSafeInteger(threshold) && threshold > 0 ? threshold : 8
|
|
62504
|
+
};
|
|
62505
|
+
}
|
|
62506
|
+
/**
|
|
62507
|
+
* 已挂载的模块登记表,走一个 global symbol:同一个包的两份模块实例(npm 拷贝与
|
|
62508
|
+
* 仓库 link)仍然共享一次判定,而重复注册同名工具或同名提示词分节会让宿主启动
|
|
62509
|
+
* 失败。
|
|
62510
|
+
*/
|
|
62511
|
+
const MOUNTED = Symbol.for("dsh-chatgpt-subscription.mounted-modules");
|
|
62512
|
+
/** 本模块在登记表里的名字。 */
|
|
62513
|
+
const MODULE_ID = "progress-updates";
|
|
62514
|
+
/**
|
|
62515
|
+
* 把一条催促挂到一个 post-execute 决策上,同时保住该决策原有的附加上下文。
|
|
62516
|
+
* @param decision - `next()` 交回来的决策。
|
|
62517
|
+
* @param context - 要追加的催促上下文。
|
|
62518
|
+
* @returns 追加后的决策(形状不变)。
|
|
62519
|
+
*/
|
|
62520
|
+
function withNudge(decision, context) {
|
|
62521
|
+
const additionalContexts = [...decision.additionalContexts ?? [], context];
|
|
62522
|
+
if (decision.kind === "block") return {
|
|
62523
|
+
kind: "block",
|
|
62524
|
+
feedback: decision.feedback,
|
|
62525
|
+
additionalContexts
|
|
62526
|
+
};
|
|
62527
|
+
if (decision.value !== void 0) return {
|
|
62528
|
+
kind: "accept",
|
|
62529
|
+
value: decision.value,
|
|
62530
|
+
additionalContexts
|
|
62531
|
+
};
|
|
62532
|
+
if (decision.content !== void 0) return {
|
|
62533
|
+
kind: "accept",
|
|
62534
|
+
content: decision.content,
|
|
62535
|
+
additionalContexts
|
|
62536
|
+
};
|
|
62537
|
+
return {
|
|
62538
|
+
kind: "accept",
|
|
62539
|
+
additionalContexts
|
|
62540
|
+
};
|
|
62541
|
+
}
|
|
62542
|
+
/**
|
|
62543
|
+
* 单实例守卫:本模块已挂载时放弃本次安装。
|
|
62544
|
+
* @param ctx - 宿主上下文。
|
|
62545
|
+
* @returns 释放函数(交出挂载权);已经挂载过时返回 undefined。
|
|
62546
|
+
*/
|
|
62547
|
+
function claimMount(ctx) {
|
|
62548
|
+
const registry = globalThis;
|
|
62549
|
+
const existing = registry[MOUNTED];
|
|
62550
|
+
const mounted = existing instanceof Set ? existing : /* @__PURE__ */ new Set();
|
|
62551
|
+
if (existing !== mounted) registry[MOUNTED] = mounted;
|
|
62552
|
+
if (mounted.has(MODULE_ID)) return void 0;
|
|
62553
|
+
mounted.add(MODULE_ID);
|
|
62554
|
+
const release = () => {
|
|
62555
|
+
mounted.delete(MODULE_ID);
|
|
62556
|
+
};
|
|
62557
|
+
if (typeof ctx.effect === "function") ctx.effect(() => release, "dsh-chatgpt-subscription: progress updates mount");
|
|
62558
|
+
return release;
|
|
62559
|
+
}
|
|
62560
|
+
/**
|
|
62561
|
+
* 挂载「进展更新」的宿主半边。
|
|
62562
|
+
* @param ctx - 宿主插件上下文。
|
|
62563
|
+
* @param options - 挂载选项(设置读取器)。
|
|
62564
|
+
* @returns 卸载函数;重复挂载时返回一个空操作。
|
|
62565
|
+
*/
|
|
62566
|
+
function installProgressUpdates(ctx, options) {
|
|
62567
|
+
const release = claimMount(ctx);
|
|
62568
|
+
if (release === void 0) return () => void 0;
|
|
62569
|
+
/** 每次调用重新读服务:宿主树可能在本模块挂载之后才 settle,读一次存下来会漏。 */
|
|
62570
|
+
const serviceOf = (name) => {
|
|
62571
|
+
const read = ctx.get;
|
|
62572
|
+
return typeof read === "function" ? read.call(ctx, name) : void 0;
|
|
62573
|
+
};
|
|
62574
|
+
/** 登记一个副作用;上下文没有 effect 时立即执行(测试用假上下文)。 */
|
|
62575
|
+
const track = (execute, label) => {
|
|
62576
|
+
if (typeof ctx.effect === "function") ctx.effect(execute, label);
|
|
62577
|
+
else execute();
|
|
62578
|
+
};
|
|
62579
|
+
/** 会话查找表:够把子会话回溯到顶层祖先即可。 */
|
|
62580
|
+
const sessionsOf = () => serviceOf("sessions");
|
|
62581
|
+
/** 会话投影服务:只用来读 turnBoundary,把同一个模型步里的多条工具结果合并成一轮。 */
|
|
62582
|
+
const projectionsOf = () => serviceOf("sessionProjections");
|
|
62583
|
+
/** 会话维度状态;键是计数会话 id(子会话已归并到顶层祖先)。 */
|
|
62584
|
+
const stores = /* @__PURE__ */ new Map();
|
|
62585
|
+
/** 已经计过数的「步」:同一个模型步里的后续工具结果不再重复计数。 */
|
|
62586
|
+
const countedSteps = /* @__PURE__ */ new Map();
|
|
62587
|
+
/**
|
|
62588
|
+
* 取一个计数会话的 store,按需创建。
|
|
62589
|
+
* @param key - 计数会话 id。
|
|
62590
|
+
* @returns 该会话的 store。
|
|
62591
|
+
*/
|
|
62592
|
+
const storeOf = (key) => {
|
|
62593
|
+
let store = stores.get(key);
|
|
62594
|
+
if (store === void 0) {
|
|
62595
|
+
store = createProgressUpdateStore();
|
|
62596
|
+
stores.set(key, store);
|
|
62597
|
+
}
|
|
62598
|
+
return store;
|
|
62599
|
+
};
|
|
62600
|
+
/**
|
|
62601
|
+
* 一次工具调用归属的计数会话 id。
|
|
62602
|
+
* @param exec - 工具执行上下文(或任意带 agent 的对象)。
|
|
62603
|
+
* @returns 计数会话 id;判定不出来时归入 `session` 这一桶。
|
|
62604
|
+
*/
|
|
62605
|
+
const counterKeyOf = (exec) => counterSessionIdOf(exec?.agent, sessionsOf()) ?? "session";
|
|
62606
|
+
/**
|
|
62607
|
+
* 浏览器半边的读取面:给定会话 id 就交出那个会话的进展快照。
|
|
62608
|
+
*
|
|
62609
|
+
* 请求不带 session(或带一个本进程没见过的 id)时,回退到那个"合成会话桶":
|
|
62610
|
+
* 这是单会话部署里的常态,也是插件第一次被调用、祖先血缘还没建立时的落点。
|
|
62611
|
+
*/
|
|
62612
|
+
const reader = { updatesOf: (sessionId) => {
|
|
62613
|
+
const direct = stores.get(sessionId);
|
|
62614
|
+
if (direct !== void 0) return direct.state().updates;
|
|
62615
|
+
if (sessionId !== "") {
|
|
62616
|
+
const merged = counterSessionIdOf({ id: sessionId }, sessionsOf());
|
|
62617
|
+
const parent = merged === void 0 ? void 0 : stores.get(merged);
|
|
62618
|
+
if (parent !== void 0) return parent.state().updates;
|
|
62619
|
+
return [];
|
|
62620
|
+
}
|
|
62621
|
+
const fallback = stores.get("session");
|
|
62622
|
+
return fallback === void 0 ? [] : fallback.state().updates;
|
|
62623
|
+
} };
|
|
62624
|
+
track(() => {
|
|
62625
|
+
const tools = serviceOf("tools");
|
|
62626
|
+
if (tools === void 0) {
|
|
62627
|
+
serviceOf("logger")?.warn("[dsh-chatgpt-subscription] progress updates tool skipped: no tools service");
|
|
62628
|
+
return () => void 0;
|
|
62629
|
+
}
|
|
62630
|
+
const tool = createProgressUpdateTool({
|
|
62631
|
+
isEnabled: () => options.settings().enabled,
|
|
62632
|
+
sink: () => ({ push: (update) => {
|
|
62633
|
+
if (!options.settings().enabled) return false;
|
|
62634
|
+
storeOf(update.source ?? "session").push(update);
|
|
62635
|
+
return true;
|
|
62636
|
+
} }),
|
|
62637
|
+
sourceOf: (exec) => counterKeyOf(exec)
|
|
62638
|
+
});
|
|
62639
|
+
return tools.register(tool);
|
|
62640
|
+
}, "dsh-chatgpt-subscription: progress update tool");
|
|
62641
|
+
track(() => {
|
|
62642
|
+
const systemPrompt = serviceOf("systemPrompt");
|
|
62643
|
+
if (systemPrompt === void 0) return () => void 0;
|
|
62644
|
+
return systemPrompt.section({
|
|
62645
|
+
name: PROGRESS_SECTION_NAME,
|
|
62646
|
+
order: 220,
|
|
62647
|
+
text: progressSectionText(() => options.settings().enabled)
|
|
62648
|
+
});
|
|
62649
|
+
}, "dsh-chatgpt-subscription: progress update prompt section");
|
|
62650
|
+
track(() => {
|
|
62651
|
+
const webServer = serviceOf("webServer");
|
|
62652
|
+
if (webServer === void 0) return () => void 0;
|
|
62653
|
+
return webServer.register(makeProgressUpdatesRoute(reader));
|
|
62654
|
+
}, "dsh-chatgpt-subscription: progress update route");
|
|
62655
|
+
const disposeListener = ctx.on?.("tools/post-execute", (async (exec, _result, next) => {
|
|
62656
|
+
const decision = await next();
|
|
62657
|
+
const current = options.settings();
|
|
62658
|
+
if (!current.enabled) return decision;
|
|
62659
|
+
const key = counterKeyOf(exec);
|
|
62660
|
+
const store = storeOf(key);
|
|
62661
|
+
if (exec.name === "progress_update") {
|
|
62662
|
+
store.markUpdated();
|
|
62663
|
+
const reportedStep = stepKeyOf(exec.agent, projectionsOf());
|
|
62664
|
+
if (reportedStep !== void 0) countedSteps.set(key, reportedStep);
|
|
62665
|
+
return decision;
|
|
62666
|
+
}
|
|
62667
|
+
const step = stepKeyOf(exec.agent, projectionsOf());
|
|
62668
|
+
if (step !== void 0) {
|
|
62669
|
+
if (countedSteps.get(key) === step) return decision;
|
|
62670
|
+
countedSteps.set(key, step);
|
|
62671
|
+
}
|
|
62672
|
+
const rounds = store.bumpRound();
|
|
62673
|
+
if (!(current.nudgeThreshold === 8 ? shouldNudge(rounds) : rounds >= current.nudgeThreshold)) return decision;
|
|
62674
|
+
const nudges = store.markNudged().nudges;
|
|
62675
|
+
return withNudge(decision, createNudgeContext(nudges));
|
|
62676
|
+
}));
|
|
62677
|
+
return () => {
|
|
62678
|
+
disposeListener?.();
|
|
62679
|
+
stores.clear();
|
|
62680
|
+
countedSteps.clear();
|
|
62681
|
+
release();
|
|
62682
|
+
};
|
|
62683
|
+
}
|
|
60680
62684
|
//#endregion
|
|
60681
62685
|
//#region src/index.ts
|
|
60682
62686
|
const Config = z.object({
|
|
@@ -60692,9 +62696,11 @@ const Config = z.object({
|
|
|
60692
62696
|
subagentModelTools: z.array(z.string()).default([]),
|
|
60693
62697
|
subagentModelInheritTools: z.array(z.string()).default(DEFAULT_SUBAGENT_INHERIT_TOOLS),
|
|
60694
62698
|
subagentModelScope: z.union([z.const("session"), z.const("preference")]).default("session"),
|
|
60695
|
-
reasoningCollapseGuard: z.union([z.object({}), z.const(false)]).default(
|
|
62699
|
+
reasoningCollapseGuard: z.union([z.object({}), z.const(false)]).default(false),
|
|
60696
62700
|
noAnswerResume: z.union([z.object({}), z.const(false)]).default({}),
|
|
60697
|
-
codexStreamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).step(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS)
|
|
62701
|
+
codexStreamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).step(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
62702
|
+
progressUpdates: z.boolean().default(false).volatile().description("启用进展更新:允许 agent 用 progress_update 工具主动汇报多步任务的进展,并在侧栏展示。"),
|
|
62703
|
+
progressUpdatesNudgeThreshold: z.number().step(1).min(1).max(50).default(8).volatile().description("催促阈值:连续这么多轮带工具调用却没有进展更新时,提醒 agent 发一条进展。")
|
|
60698
62704
|
});
|
|
60699
62705
|
const inject = [
|
|
60700
62706
|
"webServer",
|
|
@@ -60727,6 +62733,10 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
60727
62733
|
const minimaxCodePreferences = registerMinimaxCodePreferenceStore(ctx.settings, minimaxCodeModelSettings);
|
|
60728
62734
|
const minimaxCodeAccountPool = new MinimaxCodeAccountPool({ store: minimaxCodeStore });
|
|
60729
62735
|
const workBuddyStore = new FileCredentialStore$4();
|
|
62736
|
+
const workBuddyUsageLedger = new WorkBuddyUsageLedger();
|
|
62737
|
+
workBuddyUsageLedger.load().catch(() => void 0);
|
|
62738
|
+
const workBuddyAtRestKey = new WorkBuddyAtRestKeyStore();
|
|
62739
|
+
workBuddyAtRestKey.load().catch(() => null);
|
|
60730
62740
|
const workBuddyModelSettings = new FileModelSettingsStore$4();
|
|
60731
62741
|
const workBuddyPreferences = registerWorkBuddyPreferenceStore(ctx.settings, workBuddyModelSettings);
|
|
60732
62742
|
const workBuddyAccountPool = new WorkBuddyAccountPool({
|
|
@@ -60934,7 +62944,8 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
60934
62944
|
adapter: new WorkBuddyAdapter(workBuddyStore, workBuddyModelSettings, workBuddyPreferences, {
|
|
60935
62945
|
fetchFn: modelFetch.workbuddy,
|
|
60936
62946
|
attachments: ctx.attachments,
|
|
60937
|
-
accountPool: workBuddyAccountPool
|
|
62947
|
+
accountPool: workBuddyAccountPool,
|
|
62948
|
+
usageLedger: workBuddyUsageLedger
|
|
60938
62949
|
})
|
|
60939
62950
|
});
|
|
60940
62951
|
const workBuddyCheckin = new WorkBuddyCheckinService(workBuddyStore, {
|
|
@@ -60955,7 +62966,8 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
60955
62966
|
serving: () => workBuddyClaim.serving(),
|
|
60956
62967
|
conflict: () => workBuddyClaim.conflict(),
|
|
60957
62968
|
accountPool: workBuddyAccountPool,
|
|
60958
|
-
checkin: workBuddyCheckin
|
|
62969
|
+
checkin: workBuddyCheckin,
|
|
62970
|
+
atRestKey: workBuddyAtRestKey
|
|
60959
62971
|
});
|
|
60960
62972
|
const claudeClaim = claimProviderRoute(ctx, {
|
|
60961
62973
|
providerId: PROVIDER_ID,
|
|
@@ -61015,20 +63027,23 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
61015
63027
|
})).catch(() => []));
|
|
61016
63028
|
const searchSwitcher = new SearchProviderSwitcher(ctx.loader);
|
|
61017
63029
|
let lastFetchDiagnostic;
|
|
61018
|
-
const applyWebProviders = (
|
|
61019
|
-
|
|
61020
|
-
|
|
61021
|
-
pluginFetch
|
|
61022
|
-
|
|
61023
|
-
|
|
61024
|
-
|
|
61025
|
-
|
|
61026
|
-
|
|
61027
|
-
|
|
61028
|
-
|
|
61029
|
-
|
|
61030
|
-
|
|
61031
|
-
|
|
63030
|
+
const applyWebProviders = () => {
|
|
63031
|
+
preferences.hydrate().then(() => {
|
|
63032
|
+
const current = preferences.status();
|
|
63033
|
+
const pluginFetch = proxyManager.resolveActiveProxyUrl() !== null;
|
|
63034
|
+
return searchSwitcher.select(current.searchProvider, {
|
|
63035
|
+
pluginFetch,
|
|
63036
|
+
fetchProvider: fetchConfiguration.fetchProvider
|
|
63037
|
+
}).then(() => {
|
|
63038
|
+
const status = searchSwitcher.status();
|
|
63039
|
+
if (status.state === "missing" || status.state === "failed") return;
|
|
63040
|
+
const reason = fetchConfiguration.fetchProvider !== "auto" ? "explicit configuration" : current.searchProvider === "codex" ? "Codex search selected" : pluginFetch ? "active proxy detected" : "no proxy or Codex search";
|
|
63041
|
+
const diagnostic = `${status.configuredFetchProvider ?? "DSH default"} (mode=${fetchConfiguration.fetchProvider}; ${reason}; plugin limits=${fetchConfiguration.fetchMaxBodyChars} chars/${fetchConfiguration.fetchMaxResponseBytes} bytes)`;
|
|
63042
|
+
if (diagnostic !== lastFetchDiagnostic) {
|
|
63043
|
+
lastFetchDiagnostic = diagnostic;
|
|
63044
|
+
ctx.logger.info(`[dsh-chatgpt-subscription] Web fetch provider configured: ${diagnostic}`);
|
|
63045
|
+
}
|
|
63046
|
+
});
|
|
61032
63047
|
}).catch((error) => {
|
|
61033
63048
|
ctx.logger.warn(`[dsh-chatgpt-subscription] Web provider selection could not be applied: ${error instanceof Error ? error.message : String(error)}`);
|
|
61034
63049
|
});
|
|
@@ -61236,6 +63251,7 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
61236
63251
|
isEnabled: () => antigravityPreferences.status().enabled !== false
|
|
61237
63252
|
}));
|
|
61238
63253
|
const disposeVideoTool = ctx.tools.register(createKimiVideoTool(ctx, { fetchFn: proxyFetch }));
|
|
63254
|
+
const disposeProgressUpdates = installProgressUpdates(ctx, { settings: () => resolveProgressUpdatesSettings(pluginConfig) });
|
|
61239
63255
|
ctx.inject(["web"], (ctx) => {
|
|
61240
63256
|
ctx.web.registerSearchProvider(createCodexSearchProvider(oauth, { fetchFn: proxyFetch }));
|
|
61241
63257
|
ctx.web.registerFetchProvider(createCodexFetchProvider({
|
|
@@ -61245,19 +63261,22 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
61245
63261
|
}));
|
|
61246
63262
|
setTimeout(applyWebProviders, 0);
|
|
61247
63263
|
});
|
|
61248
|
-
const disposePreferenceWatch = preferences.watch((
|
|
63264
|
+
const disposePreferenceWatch = preferences.watch(() => applyWebProviders());
|
|
61249
63265
|
const disposeReadyWatch = ctx.get("appReady")?.onReady(() => applyWebProviders());
|
|
61250
63266
|
const disposeProxyWatch = proxyManager.onSystemProxyDetected(() => {
|
|
61251
63267
|
applyWebProviders();
|
|
61252
63268
|
});
|
|
63269
|
+
const disposeWebProviderReconciler = startWebProviderReconciler({ reconcile: () => applyWebProviders() });
|
|
61253
63270
|
return () => {
|
|
61254
63271
|
searchSwitcher.dispose();
|
|
61255
63272
|
disposeReadyWatch?.();
|
|
61256
63273
|
disposeProxyWatch();
|
|
61257
63274
|
disposePreferenceWatch();
|
|
63275
|
+
disposeWebProviderReconciler();
|
|
61258
63276
|
disposeImageTool();
|
|
61259
63277
|
disposeAntigravityImageTool();
|
|
61260
63278
|
disposeVideoTool();
|
|
63279
|
+
disposeProgressUpdates();
|
|
61261
63280
|
disposeAdapter();
|
|
61262
63281
|
disposeRoutes();
|
|
61263
63282
|
disposeHubOverview();
|