dsh-connect-workbuddy 5.7.0 → 5.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.en.md +4 -1
- package/README.md +4 -1
- package/docs/WINDOWS.md +6 -0
- package/lib/bin.js +58 -3
- package/lib/client.js +800 -142
- package/lib/{host-heartbeat-CVz2J0Xt.js → host-heartbeat-CM-09T4U.js} +685 -30
- package/lib/index.d.ts +278 -4
- package/lib/index.js +89 -12
- package/package.json +1 -1
|
@@ -1883,6 +1883,14 @@ const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
|
|
|
1883
1883
|
* `/v3/config` as {@link PRODUCT_UA}.
|
|
1884
1884
|
*/
|
|
1885
1885
|
const PRODUCT_UA = "WorkBuddy/5.8.3 WorkBuddy/5.8.3 CLI/2.161.0";
|
|
1886
|
+
/**
|
|
1887
|
+
* The app version inside {@link PRODUCT_UA}, for headers that need it alone.
|
|
1888
|
+
*
|
|
1889
|
+
* Derived from the UA instead of repeated as a literal: the `X-IDE-Version`
|
|
1890
|
+
* header (issue #132) must agree with what the UA claims, and two independent
|
|
1891
|
+
* literals is exactly how those two drift apart at the next version bump.
|
|
1892
|
+
*/
|
|
1893
|
+
const APP_VERSION = PRODUCT_UA.match(/WorkBuddy\/([^\s]+)/u)?.[1] ?? "5.8.3";
|
|
1886
1894
|
const JSON_TIMEOUT_MS = 3e4;
|
|
1887
1895
|
const ERROR_BODY_LIMIT = 4096;
|
|
1888
1896
|
/**
|
|
@@ -1964,11 +1972,148 @@ const CREDITS_EXHAUSTED_CODE = "\"code\":14018";
|
|
|
1964
1972
|
/** Session-invalidation markers that mean "sign in again in the WorkBuddy app". */
|
|
1965
1973
|
const SESSION_DEAD_MARKERS = ["Offline user session not found", "12153"];
|
|
1966
1974
|
/**
|
|
1975
|
+
* The business code a failure envelope states, or `undefined` when it names none.
|
|
1976
|
+
*
|
|
1977
|
+
* WHY PARSE RATHER THAN SEARCH THE TEXT. The five marker constants below this
|
|
1978
|
+
* file used to look for the quoted-key form (`'"code":11140'`) with `includes`,
|
|
1979
|
+
* which is exact only for ONE serialization: measured behaviour of the real
|
|
1980
|
+
* gateway is compact JSON, so it worked. It is nonetheless a spelling test
|
|
1981
|
+
* dressed up as a code test, and two spellings that a proxy, a re-serializer or
|
|
1982
|
+
* a nested envelope can produce fell through it — `{"code": 11140}` (a space) and
|
|
1983
|
+
* `"code":"11140"` (a string). The consequence of a MISS is not neutral: with no
|
|
1984
|
+
* policy marker and no channel marker, the body classifies as `client`, and
|
|
1985
|
+
* `outcomeOfFailure(403, …)` then answers `credential-rejected`, which marks a
|
|
1986
|
+
* perfectly healthy account as SIGNED OUT and benches it (issue #127 review).
|
|
1987
|
+
*
|
|
1988
|
+
* Reading the field is exact for all three spellings and for the nested
|
|
1989
|
+
* `{"error":{"data":{"code":…}}}` shape this file already documents at the
|
|
1990
|
+
* credit markers. `JSON.parse` also decodes `\uXXXX` escapes, which raw-text
|
|
1991
|
+
* matching cannot see (see {@link isAccountRestrictedBody}).
|
|
1992
|
+
*
|
|
1993
|
+
* The body arrives TRUNCATED to {@link ERROR_BODY_LIMIT}, so a parse failure is
|
|
1994
|
+
* normal for a large HTML error page and falls back to the compact/spaced text
|
|
1995
|
+
* forms rather than reporting no code. A request id is hexadecimal and can
|
|
1996
|
+
* contain `11140` by coincidence, which is why the fallback anchors on the
|
|
1997
|
+
* quoted key and a digit boundary rather than the bare digits.
|
|
1998
|
+
*/
|
|
1999
|
+
function businessCodeOf(body) {
|
|
2000
|
+
let parsed;
|
|
2001
|
+
try {
|
|
2002
|
+
parsed = JSON.parse(body);
|
|
2003
|
+
} catch {
|
|
2004
|
+
const match = /"code"\s*:\s*"?(\d{4,6}(?!\d))/u.exec(body);
|
|
2005
|
+
return match?.[1] === void 0 ? void 0 : Number(match[1]);
|
|
2006
|
+
}
|
|
2007
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
2008
|
+
const raw = parsed;
|
|
2009
|
+
const direct = asBusinessCode(raw["code"]);
|
|
2010
|
+
if (direct !== void 0) return direct;
|
|
2011
|
+
const error = raw["error"];
|
|
2012
|
+
if (typeof error === "object" && error !== null) {
|
|
2013
|
+
const data = error["data"];
|
|
2014
|
+
if (typeof data === "object" && data !== null) {
|
|
2015
|
+
const nested = asBusinessCode(data["code"]);
|
|
2016
|
+
if (nested !== void 0) return nested;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
const data = raw["data"];
|
|
2020
|
+
if (typeof data === "object" && data !== null) return asBusinessCode(data["code"]);
|
|
2021
|
+
}
|
|
2022
|
+
/** One code field, accepting the number and the string spelling alike. */
|
|
2023
|
+
function asBusinessCode(value) {
|
|
2024
|
+
if (typeof value === "number" && Number.isInteger(value)) return value;
|
|
2025
|
+
if (typeof value === "string" && /^\d{4,6}$/u.test(value.trim())) return Number(value.trim());
|
|
2026
|
+
}
|
|
2027
|
+
/** The `11140` content-policy family, whatever the envelope's JSON spelling is. */
|
|
2028
|
+
const CONTENT_POLICY_CODE = 11140;
|
|
2029
|
+
/**
|
|
1967
2030
|
* Content-policy refusal markers from the chat gateway. The quoted-key form
|
|
1968
2031
|
* avoids substring hits inside unrelated values (request ids are hex and can
|
|
1969
2032
|
* contain `11140` by coincidence).
|
|
1970
|
-
|
|
1971
|
-
|
|
2033
|
+
*
|
|
2034
|
+
* `request illegal` is deliberately absent: the live `11128` channel-gate body
|
|
2035
|
+
* uses the SAME `msg`, so this marker used to run first and classify that gate
|
|
2036
|
+
* as `policy_reject`. The consequence was severe — the channel rescue ladder
|
|
2037
|
+
* never ran, the reply changed 400 to 403, and users were told to switch
|
|
2038
|
+
* accounts for a request-side refusal (issue #127).
|
|
2039
|
+
*/
|
|
2040
|
+
function isContentPolicyBody(body) {
|
|
2041
|
+
return businessCodeOf(body) === CONTENT_POLICY_CODE;
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Whether a failure body is the account-restriction member of the `11140`
|
|
2045
|
+
* family (issue #127).
|
|
2046
|
+
*
|
|
2047
|
+
* TWO HALVES MUST BOTH HOLD — the business code `"code":11140` AND one of the
|
|
2048
|
+
* restriction spellings. This is the same rule this file already applies to the
|
|
2049
|
+
* `11128` family after issue #127: a bare sentence never classifies anything,
|
|
2050
|
+
* because it is text that can appear anywhere.
|
|
2051
|
+
*
|
|
2052
|
+
* WHY THE CODE HALF IS NOT OPTIONAL HERE. The verdict is the heaviest in the
|
|
2053
|
+
* classifier: `account_restricted` is failover-worthy AND benches the account
|
|
2054
|
+
* with NO cooldown — nothing but a re-authorisation returns it. Measured before
|
|
2055
|
+
* the code was required (see `classifyUpstreamError`), a body that merely
|
|
2056
|
+
* MENTIONS the phrase classified as `account_restricted` whatever else it was:
|
|
2057
|
+
* a 402 credit exhaustion, a 401 dead session, and even a 500 whose generic
|
|
2058
|
+
* `retry later` text happened to contain the words. Each of those would have
|
|
2059
|
+
* taken an account permanently out of the pool for a condition that was about
|
|
2060
|
+
* the wallet, the token, or the platform. The code half is what keeps a heavy,
|
|
2061
|
+
* non-self-healing verdict pinned to the one business code that states it.
|
|
2062
|
+
*
|
|
2063
|
+
* Both measured `displayMsg` spellings are recognised (the Chinese sentence and
|
|
2064
|
+
* its English translation). A `11140` body carrying neither stays
|
|
2065
|
+
* `policy_reject`: inventing an account verdict from the shared code alone would
|
|
2066
|
+
* bench an account over a request-side refusal — the mirror-image error, which
|
|
2067
|
+
* the code half must not be allowed to fix by loosening the phrase half.
|
|
2068
|
+
*/
|
|
2069
|
+
const ACCOUNT_RESTRICTED_MARKERS = ["账号存在异常行为", "restricted due to unusual activity"];
|
|
2070
|
+
/**
|
|
2071
|
+
* Whether a failure body is the account-restriction member of the `11140` family.
|
|
2072
|
+
*
|
|
2073
|
+
* Three separations make this decision safe, and all three were earned by the
|
|
2074
|
+
* issue #127 review:
|
|
2075
|
+
*
|
|
2076
|
+
* 1. **Code-gated.** Only a `11140` envelope can reach the phrase test
|
|
2077
|
+
* ({@link isContentPolicyBody}). The phrase alone is not evidence about the
|
|
2078
|
+
* account: a risk-control throttle, a credit notice or an HTML edge page can
|
|
2079
|
+
* carry the same sentence, and this verdict is the only IRREVERSIBLE one in
|
|
2080
|
+
* the classifier — it benches the account with no cooldown.
|
|
2081
|
+
* 2. **Decoded text.** The match runs on `displayMsg` / `displayTips` / `msg`
|
|
2082
|
+
* after `JSON.parse`, not on the raw bytes. An upstream or proxy that
|
|
2083
|
+
* serializes non-ASCII as `\uXXXX` (Python's `json.dumps` default) would
|
|
2084
|
+
* otherwise never match the Chinese sentence, and a restriction would silently
|
|
2085
|
+
* degrade to a content refusal. Matching the FIELD rather than the whole body
|
|
2086
|
+
* also stops the phrase in an unrelated field from deciding the class.
|
|
2087
|
+
* 3. **Whitespace-normalized.** A line break inside the sentence survives as
|
|
2088
|
+
* `\n` in raw JSON and as real whitespace once parsed; collapsing runs of
|
|
2089
|
+
* whitespace means both spellings match.
|
|
2090
|
+
*/
|
|
2091
|
+
function isAccountRestrictedBody(body) {
|
|
2092
|
+
if (!isContentPolicyBody(body)) return false;
|
|
2093
|
+
const haystack = accountVerdictText(body);
|
|
2094
|
+
if (haystack === "") return false;
|
|
2095
|
+
return ACCOUNT_RESTRICTED_MARKERS.some((marker) => haystack.includes(normalizeVerdictText(marker)));
|
|
2096
|
+
}
|
|
2097
|
+
/** The sentences an account verdict can be stated in: display fields, then `msg`. */
|
|
2098
|
+
function accountVerdictText(body) {
|
|
2099
|
+
let parsed;
|
|
2100
|
+
try {
|
|
2101
|
+
parsed = JSON.parse(body);
|
|
2102
|
+
} catch {
|
|
2103
|
+
return normalizeVerdictText(body);
|
|
2104
|
+
}
|
|
2105
|
+
if (typeof parsed !== "object" || parsed === null) return "";
|
|
2106
|
+
const raw = parsed;
|
|
2107
|
+
return normalizeVerdictText([
|
|
2108
|
+
localizedText(raw["displayMsg"]),
|
|
2109
|
+
localizedText(raw["displayTips"]),
|
|
2110
|
+
typeof raw["msg"] === "string" ? raw["msg"] : void 0
|
|
2111
|
+
].filter((value) => typeof value === "string").join(" "));
|
|
2112
|
+
}
|
|
2113
|
+
/** NFKC, zero-width stripped, whitespace runs collapsed, lower-cased. */
|
|
2114
|
+
function normalizeVerdictText(text) {
|
|
2115
|
+
return text.normalize("NFKC").replace(/[\u200b-\u200d\ufeff]/gu, "").replace(/\s+/gu, " ").trim().toLowerCase();
|
|
2116
|
+
}
|
|
1972
2117
|
/**
|
|
1973
2118
|
* Business code of the MODEL-scoped throttle (`14003`, "too many requests").
|
|
1974
2119
|
*
|
|
@@ -2252,7 +2397,8 @@ function classifyUpstreamError(status, body) {
|
|
|
2252
2397
|
for (const marker of HARD_CREDIT_MARKERS) if (lower.includes(marker.toLowerCase()) || body.includes(marker)) return "hard_credit";
|
|
2253
2398
|
if (body.includes(CREDITS_EXHAUSTED_CODE)) return "hard_credit";
|
|
2254
2399
|
for (const marker of SESSION_DEAD_MARKERS) if (body.includes(marker)) return "session_dead";
|
|
2255
|
-
|
|
2400
|
+
if (isAccountRestrictedBody(body)) return "account_restricted";
|
|
2401
|
+
if (isContentPolicyBody(body)) return "policy_reject";
|
|
2256
2402
|
if (isChannelRejectBody(body)) return "channel_reject";
|
|
2257
2403
|
if (isModelBusyBody(body)) return "model_busy";
|
|
2258
2404
|
if (parseQueueAnswer(body) !== void 0) return "model_queue";
|
|
@@ -2426,8 +2572,8 @@ function commonHeaders(credential) {
|
|
|
2426
2572
|
*
|
|
2427
2573
|
* VALUES. The gateway does not verify where these ids come from — any well-formed
|
|
2428
2574
|
* UUID passes — so nothing here is a secret and nothing is persisted. They are
|
|
2429
|
-
* still freshly generated per request,
|
|
2430
|
-
*
|
|
2575
|
+
* still freshly generated per request, as the official client generates
|
|
2576
|
+
* its own per call: reusing one value across requests would forge a
|
|
2431
2577
|
* conversation identity the plugin does not actually have, and the first thing
|
|
2432
2578
|
* that would break is the upstream's own correlation of a session it is being
|
|
2433
2579
|
* told about twice under one id.
|
|
@@ -2437,17 +2583,77 @@ function commonHeaders(credential) {
|
|
|
2437
2583
|
* side is exactly how the 3.1.0 defect became international-only and invisible
|
|
2438
2584
|
* from the CN side. One code path, both regions.
|
|
2439
2585
|
*
|
|
2440
|
-
* @returns the three headers,
|
|
2586
|
+
* @returns the three headers (four when the usage identity is on, which adds
|
|
2587
|
+
* `X-Request-ID`), every value distinct.
|
|
2441
2588
|
*/
|
|
2442
|
-
function conversationHeaders() {
|
|
2589
|
+
function conversationHeaders(shape, identity = {}) {
|
|
2590
|
+
const officialIds = shape === "chat" && identity.usageIdentity === true;
|
|
2591
|
+
const id = officialIds ? uuidv7 : randomUUID;
|
|
2592
|
+
const messageId = id();
|
|
2593
|
+
return {
|
|
2594
|
+
"X-Conversation-ID": id(),
|
|
2595
|
+
"X-Conversation-Request-ID": id(),
|
|
2596
|
+
"X-Conversation-Message-ID": messageId,
|
|
2597
|
+
...officialIds ? { "X-Request-ID": messageId } : {}
|
|
2598
|
+
};
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* A 32-character lowercase UUIDv7, as the official client's request ids appear.
|
|
2602
|
+
*
|
|
2603
|
+
* WHY THE FORMAT MATTERS AT ALL (issue #132). The gateway echoes
|
|
2604
|
+
* `X-Conversation-Message-ID` back as the SSE `id`, and the WorkBuddy usage page
|
|
2605
|
+
* shows that value as the request id. Official traffic therefore shows
|
|
2606
|
+
* `01a169…`-style 32-hex ids, while this plugin's `randomUUID()` produced
|
|
2607
|
+
* hyphenated v4 ids — so the same account's usage page read as two different
|
|
2608
|
+
* clients, and a reporter comparing them concluded the plugin was not the
|
|
2609
|
+
* official client. Nothing upstream REJECTS a v4 id — the hyphenated v4 this
|
|
2610
|
+
* plugin has always sent is what answers 200 today — so this is a
|
|
2611
|
+
* display-fidelity fix, not a gate.
|
|
2612
|
+
*
|
|
2613
|
+
* Only the FORMAT is aligned, never the value's meaning: the timestamp prefix
|
|
2614
|
+
* is the moment this request was generated, which is exactly what a v7 id is
|
|
2615
|
+
* for. It is NOT a claim that these ids correlate with an official session —
|
|
2616
|
+
* the plugin still generates a fresh identity per request (see
|
|
2617
|
+
* {@link conversationHeaders}).
|
|
2618
|
+
*/
|
|
2619
|
+
function uuidv7(nowMs = Date.now()) {
|
|
2620
|
+
const bytes = randomBytes(16);
|
|
2621
|
+
for (let index = 0; index < 6; index++) bytes[index] = Math.floor(nowMs / 2 ** (8 * (5 - index))) & 255;
|
|
2622
|
+
bytes[6] = bytes[6] & 15 | 112;
|
|
2623
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
2624
|
+
return bytes.toString("hex");
|
|
2625
|
+
}
|
|
2626
|
+
/**
|
|
2627
|
+
* The IDE identity headers the official desktop client sends on chat (issue #132).
|
|
2628
|
+
*
|
|
2629
|
+
* WHY. The gateway records the usage page's 使用端 (client) column from
|
|
2630
|
+
* `X-IDE-Name`, and it is EMPTY when the header is absent. The plugin has always
|
|
2631
|
+
* spoken the CLI channel — {@link CLIENT_UA}, which is correct for the chat
|
|
2632
|
+
* contract — but it sent no `X-IDE-*` at all, so every request this plugin made
|
|
2633
|
+
* appeared on the user's own usage page as an unidentified client while the
|
|
2634
|
+
* official app's requests read `WorkBuddy`. That is a reporting mismatch, not a
|
|
2635
|
+
* gate: the reporter measured that adding the header changes the usage column
|
|
2636
|
+
* and nothing else (HTTP 200, model unaffected).
|
|
2637
|
+
*
|
|
2638
|
+
* `X-IDE-Type` stays `CLI` on purpose. Claiming the desktop app's type as well
|
|
2639
|
+
* would be a bigger identity claim than the one this fix needs, and the mixed
|
|
2640
|
+
* CLI + WorkBuddy shape is what the reporter measured. The version comes from
|
|
2641
|
+
* {@link PRODUCT_UA} rather than a second hardcoded number, so the two cannot
|
|
2642
|
+
* drift apart when the app version is bumped.
|
|
2643
|
+
*
|
|
2644
|
+
* Sent only when {@link WorkBuddyWireIdentity.usageIdentity} is on — see that
|
|
2645
|
+
* field for the evidence tier behind the default.
|
|
2646
|
+
*/
|
|
2647
|
+
function ideHeaders(identity) {
|
|
2648
|
+
if (identity.usageIdentity !== true) return {};
|
|
2443
2649
|
return {
|
|
2444
|
-
"X-
|
|
2445
|
-
"X-
|
|
2446
|
-
"X-
|
|
2650
|
+
"X-IDE-Type": "CLI",
|
|
2651
|
+
"X-IDE-Name": "WorkBuddy",
|
|
2652
|
+
"X-IDE-Version": APP_VERSION
|
|
2447
2653
|
};
|
|
2448
2654
|
}
|
|
2449
2655
|
/** Chat request headers, including the X-No-* conventions the official CLI uses. */
|
|
2450
|
-
function chatHeaders(credential) {
|
|
2656
|
+
function chatHeaders(credential, identity = {}) {
|
|
2451
2657
|
return {
|
|
2452
2658
|
...commonHeaders(credential),
|
|
2453
2659
|
"Content-Type": "application/json",
|
|
@@ -2455,7 +2661,8 @@ function chatHeaders(credential) {
|
|
|
2455
2661
|
...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
|
|
2456
2662
|
...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
|
|
2457
2663
|
"X-Product": "SaaS",
|
|
2458
|
-
...
|
|
2664
|
+
...ideHeaders(identity),
|
|
2665
|
+
...conversationHeaders("chat", identity)
|
|
2459
2666
|
};
|
|
2460
2667
|
}
|
|
2461
2668
|
/** Refresh-endpoint headers; X-Refresh-Token appears here and nowhere else. */
|
|
@@ -2492,9 +2699,17 @@ function billingHeaders(credential) {
|
|
|
2492
2699
|
* 探针当时没带,带了只是多一个可能被网关拒掉的变量,没有收益。
|
|
2493
2700
|
*
|
|
2494
2701
|
* 复用 {@link conversationHeaders}:官方 CLI 的 ImageService 也是每次现生成
|
|
2495
|
-
* 一组会话 UUID
|
|
2496
|
-
|
|
2497
|
-
|
|
2702
|
+
* 一组会话 UUID。但这里**传 `'image'`,且刻意不跟 `usageIdentity` 开关走**:
|
|
2703
|
+
* issue #132 的实测只覆盖 chat,而生图这两个入口在 2026-10-21 是用带连字符的
|
|
2704
|
+
* v4 答的 200,而 id 的**格式**从来不是生图那道闸问的事。把对齐「顺手统一」到
|
|
2705
|
+
* 这里,等于用一条没人测过的字节去换一次显示对齐——`tests/upstream.spec.ts` 里
|
|
2706
|
+
* 那条生图守卫钉的就是这一点,而 `shape` 是**唯一**挡住它的东西:客户端把
|
|
2707
|
+
* `usageIdentity` 一路传进了 `imageHeaders`,是 `'image'` 这个形状参数让它即便带着开关
|
|
2708
|
+
* 也不采用 chat 的形状。守卫因此真能失败——两个方向都实跑过:把 `'image'` 改成
|
|
2709
|
+
* `'chat'` 会红,删掉 `shape === 'chat'` 判据也会红。(identity 没接进来之前,`shape`
|
|
2710
|
+
* 是空转参数、只改它守卫照样绿;接进来是这一步的前提,别退回成不传 identity。)
|
|
2711
|
+
*/
|
|
2712
|
+
function imageHeaders(credential, identity = {}) {
|
|
2498
2713
|
return {
|
|
2499
2714
|
"Authorization": `Bearer ${credential.accessToken}`,
|
|
2500
2715
|
"Accept": "application/json, text/plain, */*",
|
|
@@ -2505,7 +2720,7 @@ function imageHeaders(credential) {
|
|
|
2505
2720
|
...credential.uid === "" ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
|
|
2506
2721
|
...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
|
|
2507
2722
|
...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
|
|
2508
|
-
...conversationHeaders()
|
|
2723
|
+
...conversationHeaders("image", identity)
|
|
2509
2724
|
};
|
|
2510
2725
|
}
|
|
2511
2726
|
/**
|
|
@@ -2576,6 +2791,68 @@ const WORKBUDDY_FALLBACK_SYSTEM_PROMPT = "You are a helpful assistant.";
|
|
|
2576
2791
|
*/
|
|
2577
2792
|
const UNAPPROVED_CHANNEL_OPENINGS = ["you are an ai agent powered by deepseek harness", "you are claude code, anthropic's official cli for claude"];
|
|
2578
2793
|
/**
|
|
2794
|
+
* The identity clauses an agent prompt introduces itself with, matched so the
|
|
2795
|
+
* plugin can break them INSIDE rather than in front of.
|
|
2796
|
+
*
|
|
2797
|
+
* WHY {@link UNAPPROVED_CHANNEL_OPENINGS} DOES NOT COVER THESE. That list is a
|
|
2798
|
+
* PREFIX rule, and it is the right rule for the gate the plugin measured on
|
|
2799
|
+
* 2026-10-21. It is the wrong rule for the gate the upstream has been running
|
|
2800
|
+
* since: a case-insensitive EXACT SUBSTRING match ANYWHERE in a scanned
|
|
2801
|
+
* message, which is what this plugin's own issue #125 recorded (three sites in
|
|
2802
|
+
* the live DSH prompt, "只改任意一两处不够,三处全改才 200") and what
|
|
2803
|
+
* `workbuddy2api-hub` #533 independently bisected to the byte on a real 124 KB
|
|
2804
|
+
* request. A prefix prepend cannot break a substring match, because the banned
|
|
2805
|
+
* span is still in the text, intact, one line lower.
|
|
2806
|
+
*
|
|
2807
|
+
* THE THREE MEASURED SITES, and why each needs its own alternative:
|
|
2808
|
+
*
|
|
2809
|
+
* You are an AI agent powered by DeepSeek Harness.
|
|
2810
|
+
* You are a coding agent powered by the <model> model.
|
|
2811
|
+
* You are interacting with the user through the DeepSeek Harness Web GUI …
|
|
2812
|
+
*
|
|
2813
|
+
* The first is on the prefix list. The other two are NOT, and the second is the
|
|
2814
|
+
* one that survives every rung this plugin had: it carries no adjacent
|
|
2815
|
+
* capitalised word pair (so {@link WORD_INTERNAL_BREAK} misses it), no comma or
|
|
2816
|
+
* clause punctuation, and its terminal full stop lands AFTER the banned span (so
|
|
2817
|
+
* the sentence rules miss it too). Measured against a real DSH prompt: sparse
|
|
2818
|
+
* perturbation clears sites 1 and 3 and leaves site 2 byte-identical, which is
|
|
2819
|
+
* exactly the request that kept answering 11128.
|
|
2820
|
+
*
|
|
2821
|
+
* The model name in site 2 is not stable (`qmodel_38max`, `deepseek-v4.1-flash`,
|
|
2822
|
+
* …), so the clause is matched up to `powered by` and the variable tail is left
|
|
2823
|
+
* to the break rule itself.
|
|
2824
|
+
*/
|
|
2825
|
+
const IDENTITY_CLAUSE = /(you)(\s+are\s+(?:(?:an?\s+)?[\w-]*\s*agent\s+powered\s+by|interacting\s+with\s+the\s+user\s+through\s+the\s+deepseek\s+harness))/giu;
|
|
2826
|
+
/**
|
|
2827
|
+
* Break an identity clause at EVERY word boundary inside it.
|
|
2828
|
+
*
|
|
2829
|
+
* NOT one break, and that is deliberate. The plugin does not know which span the
|
|
2830
|
+
* upstream listed: #533's minimal failing sample is the whole sentence, but the
|
|
2831
|
+
* gate is a substring match, so the listed span could equally be a shorter piece
|
|
2832
|
+
* of it (`a coding agent powered by`). One break at the front defeats only the
|
|
2833
|
+
* spans that start there. Breaking at every internal word boundary defeats every
|
|
2834
|
+
* span inside the clause, whatever its endpoints, for about ten invisible
|
|
2835
|
+
* characters.
|
|
2836
|
+
*
|
|
2837
|
+
* The cost is why this can run on EVERY request instead of only in a rescue:
|
|
2838
|
+
* measured on a real 10,818-character DSH system prompt it adds ~28 characters
|
|
2839
|
+
* (+0.26%), against the dense rescue's 7,742 (+70.7%). Both break all three
|
|
2840
|
+
* sites; only this one is cheap enough to send unconditionally.
|
|
2841
|
+
*
|
|
2842
|
+
* The break goes AFTER each whitespace run, so the text keeps its own spacing
|
|
2843
|
+
* and a reader (or a tokenizer) sees the same words in the same order.
|
|
2844
|
+
*
|
|
2845
|
+
* Naturally idempotent: once a clause carries a break, `IDENTITY_CLAUSE` no
|
|
2846
|
+
* longer matches it — `\s+` does not match U+200B, so the second alternative
|
|
2847
|
+
* fails at the inserted character and the clause is left alone on every later
|
|
2848
|
+
* pass. That is a property this function has to have on its own, because
|
|
2849
|
+
* {@link perturbChannelFingerprints} can no longer refuse to touch text that
|
|
2850
|
+
* already carries an invisible break.
|
|
2851
|
+
*/
|
|
2852
|
+
function breakIdentityClauses(text) {
|
|
2853
|
+
return text.replace(IDENTITY_CLAUSE, (clause) => clause.replace(/(\s+)/gu, `$1${ZERO_WIDTH_SPACE}`));
|
|
2854
|
+
}
|
|
2855
|
+
/**
|
|
2579
2856
|
* Whether this text OPENS with an opening the gateways refuse.
|
|
2580
2857
|
*
|
|
2581
2858
|
* The gate is a PREFIX match on the START of a `system` message, so this is
|
|
@@ -2668,8 +2945,8 @@ function neutralizeChannelFingerprints(obj) {
|
|
|
2668
2945
|
if (!scansOpening(message["role"])) continue;
|
|
2669
2946
|
const content = message["content"];
|
|
2670
2947
|
if (typeof content === "string") {
|
|
2671
|
-
|
|
2672
|
-
message["content"] =
|
|
2948
|
+
const next = neutralizeScannedText(content);
|
|
2949
|
+
if (next !== void 0) message["content"] = next;
|
|
2673
2950
|
continue;
|
|
2674
2951
|
}
|
|
2675
2952
|
if (!Array.isArray(content)) continue;
|
|
@@ -2679,12 +2956,37 @@ function neutralizeChannelFingerprints(obj) {
|
|
|
2679
2956
|
const type = record["type"];
|
|
2680
2957
|
if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
|
|
2681
2958
|
const text = record["text"];
|
|
2682
|
-
if (typeof text !== "string"
|
|
2683
|
-
|
|
2959
|
+
if (typeof text !== "string") continue;
|
|
2960
|
+
const next = neutralizeScannedText(text);
|
|
2961
|
+
if (next !== void 0) record["text"] = next;
|
|
2684
2962
|
}
|
|
2685
2963
|
}
|
|
2686
2964
|
}
|
|
2687
2965
|
/**
|
|
2966
|
+
* Both neutralising transforms, applied to one scanned text part.
|
|
2967
|
+
*
|
|
2968
|
+
* `undefined` means the text goes on the wire byte-for-byte, which is the case
|
|
2969
|
+
* for every ordinary message: an ordinary conversation contains no identity
|
|
2970
|
+
* clause, so this changes nothing at all and the body is not re-serialised.
|
|
2971
|
+
*
|
|
2972
|
+
* THE ORDER IS LOAD-BEARING. The prefix rule reads the START of the text, so it
|
|
2973
|
+
* has to look at the text the HOST wrote; running {@link breakIdentityClauses}
|
|
2974
|
+
* first would insert an invisible break inside `You are an AI agent powered by
|
|
2975
|
+
* DeepSeek Harness`, and {@link isUnapprovedChannelOpening} would then no longer
|
|
2976
|
+
* match it — silently dropping the neutral head the prefix gate still needs. So
|
|
2977
|
+
* the head goes on first, and the clause breaks land in the text underneath it.
|
|
2978
|
+
*
|
|
2979
|
+
* BOTH RUN, because they defend different gates. The head defeats the prefix
|
|
2980
|
+
* match at offset 0; the clause breaks defeat the exact-substring match
|
|
2981
|
+
* anywhere inside. A body that needs only one of them gets only that one:
|
|
2982
|
+
* the Claude Code opening on the prefix list carries no identity clause, and an
|
|
2983
|
+
* identity clause nobody put on the prefix list needs no head.
|
|
2984
|
+
*/
|
|
2985
|
+
function neutralizeScannedText(text) {
|
|
2986
|
+
const broken = breakIdentityClauses(isUnapprovedChannelOpening(text) ? `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}` : text);
|
|
2987
|
+
return broken === text ? void 0 : broken;
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2688
2990
|
* A body in which EVERY scanned opening (`system` and `assistant`) is moved
|
|
2689
2991
|
* behind the neutral head.
|
|
2690
2992
|
*
|
|
@@ -2857,8 +3159,7 @@ function perturbChannelFingerprints(bodyJson, dense = false) {
|
|
|
2857
3159
|
if (!Array.isArray(messages)) return void 0;
|
|
2858
3160
|
let changed = false;
|
|
2859
3161
|
const perturb = (text) => {
|
|
2860
|
-
|
|
2861
|
-
const boundaries = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])/gu, `$1${ZERO_WIDTH_SPACE}`).replace(/([,;])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(WORD_INTERNAL_BREAK, (whole, head, tail) => `${head}${ZERO_WIDTH_SPACE}${tail}`);
|
|
3162
|
+
const boundaries = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])(?!\u200b)/gu, `$1${ZERO_WIDTH_SPACE}`).replace(/([,;])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(WORD_INTERNAL_BREAK, (whole, head, tail) => `${head}${ZERO_WIDTH_SPACE}${tail}`);
|
|
2862
3163
|
if (!dense) return boundaries === text ? void 0 : boundaries;
|
|
2863
3164
|
const denseText = boundaries.replace(/(?=\S)/gu, (whole, offset, source) => {
|
|
2864
3165
|
if (source.slice(Math.max(0, offset - 1), offset + 1).includes(ZERO_WIDTH_SPACE)) return "";
|
|
@@ -3041,6 +3342,133 @@ function modelIdOfBody(bodyJson) {
|
|
|
3041
3342
|
}
|
|
3042
3343
|
}
|
|
3043
3344
|
/**
|
|
3345
|
+
* Prefixes the DSH harness uses for the context it injects as `user` messages.
|
|
3346
|
+
*
|
|
3347
|
+
* MEASURED shapes, not guesses: the harness's time-context provider appends
|
|
3348
|
+
* `Time sampled while preparing turn N, step M: …`, its runtime-context
|
|
3349
|
+
* provider appends `Current runtime context. …`, and its reminders arrive as
|
|
3350
|
+
* `<system-reminder>` blocks. `billion-context` may additionally wrap each
|
|
3351
|
+
* message in an `<acp …>mNNNNN</acp>` marker, so the pattern TOLERATES that
|
|
3352
|
+
* marker in front of an injected block — the marker itself is never removed
|
|
3353
|
+
* from an injected message (see {@link ACP_LEADING_TAG} for where a marker IS
|
|
3354
|
+
* removed, and why that is only the recorded prompt).
|
|
3355
|
+
*
|
|
3356
|
+
* A body whose trailing user message starts with none of these is left exactly
|
|
3357
|
+
* as it arrived — the alternative (moving "the last non-empty user message")
|
|
3358
|
+
* would reorder real conversations from other clients.
|
|
3359
|
+
*/
|
|
3360
|
+
const INJECTED_USER_PREFIX = /^(?:<acp\b[^>]*>m\d{1,8}<\/acp>[ \t]*\r?\n?)*(?:Time sampled while preparing turn \d+, step \d+:|Current runtime context\.|<system-reminder>)/u;
|
|
3361
|
+
/**
|
|
3362
|
+
* The per-message marker `billion-context` puts in front of every message.
|
|
3363
|
+
*
|
|
3364
|
+
* Stripped from the REAL prompt only (issue #132), because the usage page takes
|
|
3365
|
+
* that message verbatim and the recorded row would otherwise open with
|
|
3366
|
+
* `<acp …>m00042</acp>` instead of what the user typed. Only the marker is
|
|
3367
|
+
* removed, never any text: the earlier messages keep theirs, so a model that
|
|
3368
|
+
* cites a past message by its `mNNNNN` id can still do it — the one id lost is
|
|
3369
|
+
* the message being answered right now, which is the only reference that cannot
|
|
3370
|
+
* matter. Injected messages keep their marker too: they are not the row being
|
|
3371
|
+
* recorded, and touching them would widen the edit for no benefit. Done only
|
|
3372
|
+
* under the same opt-in as the reorder, since it deletes bytes the upstream
|
|
3373
|
+
* counts.
|
|
3374
|
+
*/
|
|
3375
|
+
const ACP_LEADING_TAG = /^<acp\b[^>]*>m\d{1,8}<\/acp>[ \t]*\r?\n?/u;
|
|
3376
|
+
/**
|
|
3377
|
+
* The plain text of one message's content, for prefix matching only.
|
|
3378
|
+
*
|
|
3379
|
+
* Array-shaped content contributes its text parts joined by a newline; a
|
|
3380
|
+
* non-text part is skipped rather than stringified, because `[object Object]`
|
|
3381
|
+
* would make every prefix test miss.
|
|
3382
|
+
*/
|
|
3383
|
+
function textOfContent(content) {
|
|
3384
|
+
if (typeof content === "string") return content;
|
|
3385
|
+
if (!Array.isArray(content)) return "";
|
|
3386
|
+
const parts = [];
|
|
3387
|
+
for (const part of content) {
|
|
3388
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
3389
|
+
const record = part;
|
|
3390
|
+
if (typeof record["text"] !== "string") continue;
|
|
3391
|
+
parts.push(record["text"]);
|
|
3392
|
+
}
|
|
3393
|
+
return parts.join("\n");
|
|
3394
|
+
}
|
|
3395
|
+
/**
|
|
3396
|
+
* Drop the leading `billion-context` marker from one message's content.
|
|
3397
|
+
*
|
|
3398
|
+
* Handles both shapes (a bare string, or array content whose first `text` part
|
|
3399
|
+
* carries the marker) and leaves everything else untouched. Returns whether it
|
|
3400
|
+
* changed anything, so a body with no marker is not re-serialized for nothing.
|
|
3401
|
+
*/
|
|
3402
|
+
function stripLeadingAcpTag(content) {
|
|
3403
|
+
if (typeof content === "string") {
|
|
3404
|
+
if (!ACP_LEADING_TAG.test(content)) return {
|
|
3405
|
+
value: content,
|
|
3406
|
+
changed: false
|
|
3407
|
+
};
|
|
3408
|
+
return {
|
|
3409
|
+
value: content.replace(ACP_LEADING_TAG, ""),
|
|
3410
|
+
changed: true
|
|
3411
|
+
};
|
|
3412
|
+
}
|
|
3413
|
+
if (!Array.isArray(content)) return {
|
|
3414
|
+
value: content,
|
|
3415
|
+
changed: false
|
|
3416
|
+
};
|
|
3417
|
+
for (const part of content) {
|
|
3418
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
3419
|
+
const record = part;
|
|
3420
|
+
if (record["type"] !== "text" || typeof record["text"] !== "string") continue;
|
|
3421
|
+
if (!ACP_LEADING_TAG.test(record["text"])) continue;
|
|
3422
|
+
record["text"] = record["text"].replace(ACP_LEADING_TAG, "");
|
|
3423
|
+
return {
|
|
3424
|
+
value: content,
|
|
3425
|
+
changed: true
|
|
3426
|
+
};
|
|
3427
|
+
}
|
|
3428
|
+
return {
|
|
3429
|
+
value: content,
|
|
3430
|
+
changed: false
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3433
|
+
/** Whether this message is one of the harness's injected context blocks. */
|
|
3434
|
+
function isInjectedUserMessage(message) {
|
|
3435
|
+
if (message["role"] !== "user") return false;
|
|
3436
|
+
return INJECTED_USER_PREFIX.test(textOfContent(message["content"]).replace(/^\s+/u, ""));
|
|
3437
|
+
}
|
|
3438
|
+
/**
|
|
3439
|
+
* Move the trailing injected `user` messages above the real prompt.
|
|
3440
|
+
*
|
|
3441
|
+
* Returns `true` when the order changed, so the caller can serialize only once.
|
|
3442
|
+
* Never throws, and a body with nothing to move keeps its exact message order.
|
|
3443
|
+
*/
|
|
3444
|
+
function keepPromptLast(messages) {
|
|
3445
|
+
let promptIndex = -1;
|
|
3446
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
3447
|
+
const value = messages[index];
|
|
3448
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
3449
|
+
const message = value;
|
|
3450
|
+
if (message["role"] !== "user") continue;
|
|
3451
|
+
if (isInjectedUserMessage(message)) continue;
|
|
3452
|
+
if (textOfContent(message["content"]).trim() === "") continue;
|
|
3453
|
+
promptIndex = index;
|
|
3454
|
+
break;
|
|
3455
|
+
}
|
|
3456
|
+
if (promptIndex < 0) return false;
|
|
3457
|
+
const trailing = messages.slice(promptIndex + 1).filter((value) => typeof value === "object" && value !== null && !Array.isArray(value) ? isInjectedUserMessage(value) : false);
|
|
3458
|
+
if (trailing.length === 0) return false;
|
|
3459
|
+
const kept = messages.filter((value, index) => !(index > promptIndex && typeof value === "object" && value !== null && !Array.isArray(value) && isInjectedUserMessage(value)));
|
|
3460
|
+
kept.splice(promptIndex, 0, ...trailing);
|
|
3461
|
+
const prompt = kept[promptIndex + trailing.length];
|
|
3462
|
+
if (typeof prompt === "object" && prompt !== null && !Array.isArray(prompt)) {
|
|
3463
|
+
const message = prompt;
|
|
3464
|
+
const stripped = stripLeadingAcpTag(message["content"]);
|
|
3465
|
+
if (stripped.changed) message["content"] = stripped.value;
|
|
3466
|
+
}
|
|
3467
|
+
messages.length = 0;
|
|
3468
|
+
messages.push(...kept);
|
|
3469
|
+
return true;
|
|
3470
|
+
}
|
|
3471
|
+
/**
|
|
3044
3472
|
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream: force
|
|
3045
3473
|
* `stream: true` (the upstream rejects non-streaming), flatten `tool_choice`
|
|
3046
3474
|
* (the upstream's field is a string; object forms return 400), rewrite
|
|
@@ -3057,7 +3485,7 @@ function modelIdOfBody(bodyJson) {
|
|
|
3057
3485
|
* upstream already answers keeps its prompt as close to byte-for-byte as these
|
|
3058
3486
|
* rules allow.
|
|
3059
3487
|
*/
|
|
3060
|
-
function prepareChatBody(source) {
|
|
3488
|
+
function prepareChatBody(source, options) {
|
|
3061
3489
|
let body;
|
|
3062
3490
|
try {
|
|
3063
3491
|
body = JSON.parse(source);
|
|
@@ -3073,6 +3501,7 @@ function prepareChatBody(source) {
|
|
|
3073
3501
|
if (message["role"] === "developer") message["role"] = "system";
|
|
3074
3502
|
}
|
|
3075
3503
|
ensureSystemHead(obj);
|
|
3504
|
+
if (options?.keepPromptLast === true && Array.isArray(obj["messages"])) keepPromptLast(obj["messages"]);
|
|
3076
3505
|
neutralizeChannelFingerprints(obj);
|
|
3077
3506
|
normalizeToolChoice(obj);
|
|
3078
3507
|
dropRefusedReasoning(obj);
|
|
@@ -3526,6 +3955,7 @@ function requestEvidenceOf(bodyJson) {
|
|
|
3526
3955
|
*/
|
|
3527
3956
|
var WorkBuddyUpstreamClient = class {
|
|
3528
3957
|
onFallback;
|
|
3958
|
+
identity;
|
|
3529
3959
|
/**
|
|
3530
3960
|
* Reports a fallback the catalog reader had to take.
|
|
3531
3961
|
*
|
|
@@ -3535,8 +3965,9 @@ var WorkBuddyUpstreamClient = class {
|
|
|
3535
3965
|
* `ctx.logger.warn` in; without a host the event is dropped, which is the
|
|
3536
3966
|
* same silence the previous single-source reader had.
|
|
3537
3967
|
*/
|
|
3538
|
-
constructor(onFallback) {
|
|
3968
|
+
constructor(onFallback, identity = {}) {
|
|
3539
3969
|
this.onFallback = onFallback;
|
|
3970
|
+
this.identity = identity;
|
|
3540
3971
|
}
|
|
3541
3972
|
/** POST the chat endpoint; a successful answer is the raw SSE response. */
|
|
3542
3973
|
async chatStream(credential, bodyJson, signal) {
|
|
@@ -3545,7 +3976,7 @@ var WorkBuddyUpstreamClient = class {
|
|
|
3545
3976
|
response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
|
|
3546
3977
|
method: "POST",
|
|
3547
3978
|
headers: {
|
|
3548
|
-
...chatHeaders(credential),
|
|
3979
|
+
...chatHeaders(credential, this.identity),
|
|
3549
3980
|
"Authorization": `Bearer ${credential.accessToken}`
|
|
3550
3981
|
},
|
|
3551
3982
|
body: bodyJson,
|
|
@@ -3608,7 +4039,7 @@ var WorkBuddyUpstreamClient = class {
|
|
|
3608
4039
|
response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
|
|
3609
4040
|
method: "POST",
|
|
3610
4041
|
headers: {
|
|
3611
|
-
...chatHeaders(credential),
|
|
4042
|
+
...chatHeaders(credential, this.identity),
|
|
3612
4043
|
"Authorization": `Bearer ${credential.accessToken}`
|
|
3613
4044
|
},
|
|
3614
4045
|
body: bodyJson,
|
|
@@ -3810,7 +4241,7 @@ var WorkBuddyUpstreamClient = class {
|
|
|
3810
4241
|
async generateImage(credential, request, signal) {
|
|
3811
4242
|
const response = await fetch(`${chatBase(credential)}${IMAGE_GENERATIONS_PATH}`, {
|
|
3812
4243
|
method: "POST",
|
|
3813
|
-
headers: imageHeaders(credential),
|
|
4244
|
+
headers: imageHeaders(credential, this.identity),
|
|
3814
4245
|
body: JSON.stringify(buildImageBody(request)),
|
|
3815
4246
|
signal: signal ?? AbortSignal.timeout(IMAGE_TIMEOUT_MS)
|
|
3816
4247
|
});
|
|
@@ -5176,9 +5607,21 @@ var WorkBuddyCredentialStore = class {
|
|
|
5176
5607
|
* refuses to use is what made the dropdown look healthy while every request
|
|
5177
5608
|
* failed with 401.
|
|
5178
5609
|
*
|
|
5610
|
+
* THE POOL'S RUNTIME OVERRIDE IS PART OF `current()`, SO IT IS PART OF THIS.
|
|
5611
|
+
* `current()` consults `rotatedAccountId` FIRST; this method used to ignore it
|
|
5612
|
+
* and answer from `accountId` alone. With the pool on, the two therefore
|
|
5613
|
+
* disagreed on every rotation: the shim billed account B while the card marked
|
|
5614
|
+
* account A — the reporter's screenshot showed 「当前选用」 on a rate-limited
|
|
5615
|
+
* account while the pill's total belonged to the one actually serving. Worse,
|
|
5616
|
+
* the usage document takes its `credential` from `current()` and its
|
|
5617
|
+
* `account.accountId` from this list, so one document could name two accounts.
|
|
5618
|
+
*
|
|
5179
5619
|
* So an explicit selection that matches NO local account marks nothing as
|
|
5180
5620
|
* selected (no silent fallback to a different account — see {@link current}).
|
|
5181
5621
|
* The implicit default is marked only when nothing was explicitly chosen.
|
|
5622
|
+
* A live rotation marker wins over both, mirroring `current()`'s precedence,
|
|
5623
|
+
* and only while it still names a local account — `current()` falls through in
|
|
5624
|
+
* that case, and so does this.
|
|
5182
5625
|
*/
|
|
5183
5626
|
async accounts() {
|
|
5184
5627
|
const credentials = await this.readAll();
|
|
@@ -5186,6 +5629,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
5186
5629
|
const hasExplicitSelection = this.accountId !== void 0;
|
|
5187
5630
|
const selectedExists = hasExplicitSelection && credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
|
|
5188
5631
|
const defaultSelected = this.preferred(credentials);
|
|
5632
|
+
const rotatedId = this.rotatedAccountId !== void 0 && credentials.some((credential) => workbuddyAccountId(credential) === this.rotatedAccountId) ? this.rotatedAccountId : void 0;
|
|
5189
5633
|
return credentials.map((credential) => {
|
|
5190
5634
|
const id = workbuddyAccountId(credential);
|
|
5191
5635
|
return {
|
|
@@ -5196,7 +5640,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
5196
5640
|
source: credential.source,
|
|
5197
5641
|
tokenExpiresAtMs: credential.expiresAtMs,
|
|
5198
5642
|
filePath: credential.filePath,
|
|
5199
|
-
selected: selectedExists ? id === this.accountId : !hasExplicitSelection && credential === defaultSelected
|
|
5643
|
+
selected: rotatedId !== void 0 ? id === rotatedId : selectedExists ? id === this.accountId : !hasExplicitSelection && credential === defaultSelected
|
|
5200
5644
|
};
|
|
5201
5645
|
});
|
|
5202
5646
|
}
|
|
@@ -6148,10 +6592,221 @@ function generationOf(version) {
|
|
|
6148
6592
|
if (match === null) return "legacy";
|
|
6149
6593
|
return Number(match[1]) >= 87 ? "modern" : "legacy";
|
|
6150
6594
|
}
|
|
6595
|
+
/** The versions this build can read (add a new one on upgrade, keep old ones). */
|
|
6596
|
+
const KNOWN_ROSTER_SEEN_VERSIONS = Object.freeze([1]);
|
|
6597
|
+
/**
|
|
6598
|
+
* The "new" window: a first-seen stamp inside it counts as new.
|
|
6599
|
+
*
|
|
6600
|
+
* 7 days is a deliberate scale — it has to cover "the user was away a few days
|
|
6601
|
+
* and came back to two new models" without letting one bulk upload still carry
|
|
6602
|
+
* a marker two weeks later. The window is a CONSTANT here rather than a config:
|
|
6603
|
+
* a "new model" marker that could be tuned to 365 days has no meaning.
|
|
6604
|
+
*/
|
|
6605
|
+
const ROSTER_SEEN_NEW_WINDOW_MS = 6048e5;
|
|
6606
|
+
/**
|
|
6607
|
+
* How long a read answer stays valid before the file is read again.
|
|
6608
|
+
*
|
|
6609
|
+
* Another process may have just written, so the answer has to be visible within
|
|
6610
|
+
* a tick — but a single snapshot assembly should not re-read the file several
|
|
6611
|
+
* times.
|
|
6612
|
+
*/
|
|
6613
|
+
const ROSTER_SEEN_READ_TTL_MS = 5e3;
|
|
6614
|
+
/** Filename prefix for one region's first-sighting history. */
|
|
6615
|
+
const ROSTER_SEEN_FILE_PREFIX = ".workbuddy-roster-seen";
|
|
6616
|
+
const EMPTY_PAYLOAD = {
|
|
6617
|
+
seen: {},
|
|
6618
|
+
version: null
|
|
6619
|
+
};
|
|
6620
|
+
/** Whether this build can read a stored version. */
|
|
6621
|
+
function isKnownRosterSeenVersion(version) {
|
|
6622
|
+
return typeof version === "number" && KNOWN_ROSTER_SEEN_VERSIONS.includes(version);
|
|
6623
|
+
}
|
|
6624
|
+
/**
|
|
6625
|
+
* Normalise a first-seen table: keep only entries whose id is a non-empty
|
|
6626
|
+
* string and whose stamp is a finite positive number.
|
|
6627
|
+
*
|
|
6628
|
+
* Dropping rather than repairing a bad entry is deliberate: a stamp of 0 or
|
|
6629
|
+
* negative makes `now - stamp` come out as decades, and that id would count as
|
|
6630
|
+
* "new" forever. Better to let it take a real stamp the next time it appears.
|
|
6631
|
+
*/
|
|
6632
|
+
function normalizeRosterSeen(raw) {
|
|
6633
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
6634
|
+
const out = {};
|
|
6635
|
+
for (const [id, stamp] of Object.entries(raw)) {
|
|
6636
|
+
if (id === "") continue;
|
|
6637
|
+
if (typeof stamp !== "number" || !Number.isFinite(stamp) || stamp <= 0) continue;
|
|
6638
|
+
out[id] = stamp;
|
|
6639
|
+
}
|
|
6640
|
+
return out;
|
|
6641
|
+
}
|
|
6642
|
+
/**
|
|
6643
|
+
* One region's path for its first-sighting history.
|
|
6644
|
+
*
|
|
6645
|
+
* Region-keyed for the same reason the credential and pool copies are: the two
|
|
6646
|
+
* regions are parallel stacks and must never overwrite each other's history.
|
|
6647
|
+
*/
|
|
6648
|
+
function rosterSeenPath(region, dir) {
|
|
6649
|
+
if (typeof dir === "string" && dir !== "") return join(dir, `${ROSTER_SEEN_FILE_PREFIX}.${region}.json`);
|
|
6650
|
+
return join(resolveDshHome(), `${ROSTER_SEEN_FILE_PREFIX}.${region}.json`);
|
|
6651
|
+
}
|
|
6652
|
+
/**
|
|
6653
|
+
* The ids from a first-seen table that are still "new", oldest first.
|
|
6654
|
+
*
|
|
6655
|
+
* Ascending on purpose: the caller shows only the first few names (the pill
|
|
6656
|
+
* tooltip / popover budget), and the ones seen EARLIEST are the ones closest to
|
|
6657
|
+
* expiring — and the ones the reader is most likely to have already met and
|
|
6658
|
+
* needs told "it really only just appeared".
|
|
6659
|
+
*/
|
|
6660
|
+
function freshIdsOf(seen, nowMs) {
|
|
6661
|
+
return Object.entries(seen).filter(([, first]) => nowMs - first < ROSTER_SEEN_NEW_WINDOW_MS).sort((left, right) => left[1] - right[1]).map(([id]) => id);
|
|
6662
|
+
}
|
|
6663
|
+
/**
|
|
6664
|
+
* Parse a raw JSON value into a payload; an unreadable version reads as an
|
|
6665
|
+
* empty table but KEEPS the version number — reads stay permissive, while the
|
|
6666
|
+
* write guard refuses to overwrite it (the two halves are one rule).
|
|
6667
|
+
*/
|
|
6668
|
+
function parseRosterSeenPayload(raw) {
|
|
6669
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return EMPTY_PAYLOAD;
|
|
6670
|
+
const source = raw;
|
|
6671
|
+
const version = typeof source["version"] === "number" ? source["version"] : null;
|
|
6672
|
+
if (!isKnownRosterSeenVersion(version)) return {
|
|
6673
|
+
seen: {},
|
|
6674
|
+
version
|
|
6675
|
+
};
|
|
6676
|
+
return {
|
|
6677
|
+
seen: normalizeRosterSeen(source["seen"]),
|
|
6678
|
+
version
|
|
6679
|
+
};
|
|
6680
|
+
}
|
|
6681
|
+
/**
|
|
6682
|
+
* The file-backed first-sighting table for one region.
|
|
6683
|
+
*/
|
|
6684
|
+
function createFileRosterSeenStore(region, options = {}) {
|
|
6685
|
+
const { dir, ttlMs = ROSTER_SEEN_READ_TTL_MS } = options;
|
|
6686
|
+
const filePath = rosterSeenPath(region, dir);
|
|
6687
|
+
/** Read the file's raw JSON, or `null` for a missing or unreadable file. */
|
|
6688
|
+
const readRaw = async () => {
|
|
6689
|
+
try {
|
|
6690
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
6691
|
+
} catch {
|
|
6692
|
+
return null;
|
|
6693
|
+
}
|
|
6694
|
+
};
|
|
6695
|
+
let cached;
|
|
6696
|
+
const readPayload = async () => {
|
|
6697
|
+
if (cached !== void 0 && Date.now() - cached.at < ttlMs) return cached.payload;
|
|
6698
|
+
const payload = parseRosterSeenPayload(await readRaw());
|
|
6699
|
+
cached = {
|
|
6700
|
+
payload,
|
|
6701
|
+
at: Date.now()
|
|
6702
|
+
};
|
|
6703
|
+
return payload;
|
|
6704
|
+
};
|
|
6705
|
+
let writeTail = Promise.resolve();
|
|
6706
|
+
const enqueueWrite = (task) => {
|
|
6707
|
+
const run = writeTail.then(task, task);
|
|
6708
|
+
writeTail = run.then(() => void 0, () => void 0);
|
|
6709
|
+
return run;
|
|
6710
|
+
};
|
|
6711
|
+
return {
|
|
6712
|
+
/**
|
|
6713
|
+
* Record one round's complete catalog and answer which of its ids are still
|
|
6714
|
+
* inside the new window.
|
|
6715
|
+
*
|
|
6716
|
+
* One call diffs AND persists (see the module note). The first write is
|
|
6717
|
+
* SEEDING: every id is stamped as already expired and `newIds` answers
|
|
6718
|
+
* empty.
|
|
6719
|
+
*/
|
|
6720
|
+
async note(ids, nowMs = Date.now()) {
|
|
6721
|
+
const list = Array.isArray(ids) ? ids.filter((id) => typeof id === "string" && id !== "") : [];
|
|
6722
|
+
const unique = [...new Set(list)];
|
|
6723
|
+
if (unique.length === 0) return { newIds: [] };
|
|
6724
|
+
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
6725
|
+
return enqueueWrite(() => withFileLock(filePath, async () => {
|
|
6726
|
+
const current = parseRosterSeenPayload(await readRaw());
|
|
6727
|
+
if (current.version !== null && !isKnownRosterSeenVersion(current.version)) return { newIds: [] };
|
|
6728
|
+
const previous = current.seen;
|
|
6729
|
+
const seeded = current.version === null;
|
|
6730
|
+
const next = {};
|
|
6731
|
+
for (const id of unique) {
|
|
6732
|
+
const first = previous[id];
|
|
6733
|
+
next[id] = typeof first === "number" && first > 0 ? Math.min(first, now) : seeded ? now - ROSTER_SEEN_NEW_WINDOW_MS : now;
|
|
6734
|
+
}
|
|
6735
|
+
if (Object.keys(next).length > 200) return { newIds: [] };
|
|
6736
|
+
if (seeded || Object.keys(previous).length !== Object.keys(next).length || Object.keys(next).some((id) => previous[id] !== next[id])) {
|
|
6737
|
+
await writeFileAtomic(filePath, `${JSON.stringify({
|
|
6738
|
+
version: 1,
|
|
6739
|
+
seen: next,
|
|
6740
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6741
|
+
}, null, 2)}\n`, {
|
|
6742
|
+
mode: 384,
|
|
6743
|
+
dirMode: 448
|
|
6744
|
+
});
|
|
6745
|
+
cached = {
|
|
6746
|
+
payload: {
|
|
6747
|
+
seen: next,
|
|
6748
|
+
version: 1
|
|
6749
|
+
},
|
|
6750
|
+
at: Date.now()
|
|
6751
|
+
};
|
|
6752
|
+
}
|
|
6753
|
+
return { newIds: seeded ? [] : freshIdsOf(next, now) };
|
|
6754
|
+
}));
|
|
6755
|
+
},
|
|
6756
|
+
/**
|
|
6757
|
+
* Read-only: the ids that still count as "new" right now (ascending).
|
|
6758
|
+
*
|
|
6759
|
+
* NEVER WRITES. The snapshot and the provider route are read paths, and
|
|
6760
|
+
* writing happens only in the reconciler's `note`; a read path that topped
|
|
6761
|
+
* up stamps would let one panel poll rewrite "who is new".
|
|
6762
|
+
*/
|
|
6763
|
+
async fresh(nowMs = Date.now()) {
|
|
6764
|
+
return freshIdsOf((await readPayload()).seen, Number.isFinite(nowMs) ? nowMs : Date.now());
|
|
6765
|
+
},
|
|
6766
|
+
/** Whether this table has ever been written (seeded vs "really none new"). */
|
|
6767
|
+
async isSet() {
|
|
6768
|
+
return (await readPayload()).version !== null;
|
|
6769
|
+
},
|
|
6770
|
+
/**
|
|
6771
|
+
* The read-only readout the usage route carries, or the version note when
|
|
6772
|
+
* the on-disk history is in a version this build cannot read.
|
|
6773
|
+
*/
|
|
6774
|
+
async readout(nowMs = Date.now()) {
|
|
6775
|
+
const payload = await readPayload();
|
|
6776
|
+
const version = payload.version;
|
|
6777
|
+
if (version === null || isKnownRosterSeenVersion(version)) return { newModelIds: freshIdsOf(payload.seen, Number.isFinite(nowMs) ? nowMs : Date.now()) };
|
|
6778
|
+
return {
|
|
6779
|
+
newModelIds: [],
|
|
6780
|
+
versionNote: `The first-sighting history is stored in version ${String(version)}, which this build cannot read; it is answered as empty, so the pill shows no new-model marker. Known versions: ${KNOWN_ROSTER_SEEN_VERSIONS.join(", ")}.`
|
|
6781
|
+
};
|
|
6782
|
+
},
|
|
6783
|
+
/** The on-disk path (diagnostics and tests). */
|
|
6784
|
+
filePath
|
|
6785
|
+
};
|
|
6786
|
+
}
|
|
6787
|
+
/**
|
|
6788
|
+
* doctor's read-only summary: parse a roster-seen payload without building a
|
|
6789
|
+
* store or touching any cache.
|
|
6790
|
+
*
|
|
6791
|
+
* `null` for a payload the whole shape does not recognise; `versionKnown: false`
|
|
6792
|
+
* for a recognised version this build cannot read — so `doctor` can tell "the
|
|
6793
|
+
* file is corrupt" from "a newer build wrote it".
|
|
6794
|
+
*/
|
|
6795
|
+
function describeRosterSeenPayload(raw) {
|
|
6796
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
6797
|
+
const version = raw["version"];
|
|
6798
|
+
if (typeof version !== "number") return null;
|
|
6799
|
+
const seen = normalizeRosterSeen(raw["seen"]);
|
|
6800
|
+
return {
|
|
6801
|
+
versionKnown: isKnownRosterSeenVersion(version),
|
|
6802
|
+
ids: Object.keys(seen).length,
|
|
6803
|
+
fresh: freshIdsOf(seen, Date.now()).length
|
|
6804
|
+
};
|
|
6805
|
+
}
|
|
6151
6806
|
//#endregion
|
|
6152
6807
|
//#region src/version.ts
|
|
6153
6808
|
/** The npm package version this build was produced from. */
|
|
6154
|
-
const WORKBUDDY_CONNECT_VERSION = "5.
|
|
6809
|
+
const WORKBUDDY_CONNECT_VERSION = "5.8.1";
|
|
6155
6810
|
//#endregion
|
|
6156
6811
|
//#region src/host-heartbeat.ts
|
|
6157
6812
|
/**
|
|
@@ -6309,4 +6964,4 @@ async function clearHostHeartbeat() {
|
|
|
6309
6964
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
6310
6965
|
}
|
|
6311
6966
|
//#endregion
|
|
6312
|
-
export {
|
|
6967
|
+
export { parseCreditMultiplier as $, isEncryptedCredentialError as A, isWorkbuddyBundle as At, WorkBuddyCredentialRejectedError as B, WorkBuddyCredentialStore as C, clearAtRestKeyCache as Ct, defaultDesktopAuthDirs as D, findWorkbuddyAppExecutable as Dt, defaultDesktopAuthCandidates as E, fetchAtRestKeyPayload as Et, workbuddyAccountId as F, workbuddyAppExecutableCandidates as Ft, forceNeutralHead as G, channelGateMemberOf as H, workbuddyOwnAuthPath as I, hostPeersInfo as It, isCredentialRejectedError as J, isContextTooLongBody as K, CONTEXT_OVERFLOW_MARKER as L, resolveDshHome as Lt, legacyWorkbuddyOwnAuthPath as M, macosNestedAppBundles as Mt, parseWorkBuddyAuth as N, openEncryptedField as Nt, defaultDesktopAuthPath as O, findWorkbuddyAppExecutableWithSource as Ot, preferredAccountName as P, readAtRestKey as Pt, openingEvidenceOfBody as Q, CREDENTIAL_REJECTED_CODE as R, withFileLock as Rt, WORKBUDDY_AUTH_FILE_ENV as S, WORKBUDDY_APP_EXECUTABLE_ENV as St, authFileName as T, deriveAtRestKeyId as Tt, classifyUpstreamError as U, WorkBuddyUpstreamClient as V, declaredTools as W, knownRegionOf as X, isFirstMessageGateBody as Y, modelIdOfBody as Z, deriveCatalog as _, parseQueueAnswer as _t, readHostHeartbeat as a, requestEvidenceOf as at, ENCRYPTED_CREDENTIAL_CODE as b, imageDefaultFor as bt, WORKBUDDY_CONNECT_VERSION as c, defaultOffModelIds as ct, rosterSeenPath as d, offRefused as dt, parseReasoning as et, piAiRuntimeInfo as f, recordRefusal as ft, applyContextBudgets as g, pickImageModel as gt, WorkBuddyCatalog as h, WorkBuddyImageError as ht, processStartTimeMs as i, regionOf as it, isVaultAccountId as j, macosBundleExecutable as jt, hasEncryptedCredentialFields as k, isEncryptedFieldWrapper as kt, createFileRosterSeenStore as l, lastRefusalFor as lt, FALLBACK_WORKBUDDY_MODELS_GLOBAL as m, DEFAULT_IMAGE_SIZE as mt, clearHostHeartbeat as n, perturbChannelFingerprints as nt, workbuddyHostHeartbeatPath as o, attachBusyLevels as ot, FALLBACK_WORKBUDDY_MODELS as p, withEffectiveOff as pt, isConversationIdGateBody as q, isHeartbeatProcessAlive as r, prepareChatBody as rt, writeHostHeartbeat as s, REFUSES_OFF_MODEL_IDS as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, parseUpstreamModel as tt, describeRosterSeenPayload as u, offDefaultFor as ut, fallbackModelsFor as v, queueNoticeText as vt, WorkBuddyEncryptedCredentialError as w, deriveAtRestKey as wt, WORKBUDDY_AUTH_FILENAME as x, nativeModalityOf as xt, withoutRouters as y, NATIVE_MODALITY_BY_MODEL_ID as yt, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as z, writeFileAtomic as zt };
|