dsh-connect-workbuddy 5.7.0 → 5.8.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 +48 -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-BiVFMXGE.js} +593 -24
- 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();
|
|
2443
2593
|
return {
|
|
2444
|
-
"X-Conversation-ID":
|
|
2445
|
-
"X-Conversation-Request-ID":
|
|
2446
|
-
"X-Conversation-Message-ID":
|
|
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 {};
|
|
2649
|
+
return {
|
|
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
|
/**
|
|
@@ -3041,6 +3256,133 @@ function modelIdOfBody(bodyJson) {
|
|
|
3041
3256
|
}
|
|
3042
3257
|
}
|
|
3043
3258
|
/**
|
|
3259
|
+
* Prefixes the DSH harness uses for the context it injects as `user` messages.
|
|
3260
|
+
*
|
|
3261
|
+
* MEASURED shapes, not guesses: the harness's time-context provider appends
|
|
3262
|
+
* `Time sampled while preparing turn N, step M: …`, its runtime-context
|
|
3263
|
+
* provider appends `Current runtime context. …`, and its reminders arrive as
|
|
3264
|
+
* `<system-reminder>` blocks. `billion-context` may additionally wrap each
|
|
3265
|
+
* message in an `<acp …>mNNNNN</acp>` marker, so the pattern TOLERATES that
|
|
3266
|
+
* marker in front of an injected block — the marker itself is never removed
|
|
3267
|
+
* from an injected message (see {@link ACP_LEADING_TAG} for where a marker IS
|
|
3268
|
+
* removed, and why that is only the recorded prompt).
|
|
3269
|
+
*
|
|
3270
|
+
* A body whose trailing user message starts with none of these is left exactly
|
|
3271
|
+
* as it arrived — the alternative (moving "the last non-empty user message")
|
|
3272
|
+
* would reorder real conversations from other clients.
|
|
3273
|
+
*/
|
|
3274
|
+
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;
|
|
3275
|
+
/**
|
|
3276
|
+
* The per-message marker `billion-context` puts in front of every message.
|
|
3277
|
+
*
|
|
3278
|
+
* Stripped from the REAL prompt only (issue #132), because the usage page takes
|
|
3279
|
+
* that message verbatim and the recorded row would otherwise open with
|
|
3280
|
+
* `<acp …>m00042</acp>` instead of what the user typed. Only the marker is
|
|
3281
|
+
* removed, never any text: the earlier messages keep theirs, so a model that
|
|
3282
|
+
* cites a past message by its `mNNNNN` id can still do it — the one id lost is
|
|
3283
|
+
* the message being answered right now, which is the only reference that cannot
|
|
3284
|
+
* matter. Injected messages keep their marker too: they are not the row being
|
|
3285
|
+
* recorded, and touching them would widen the edit for no benefit. Done only
|
|
3286
|
+
* under the same opt-in as the reorder, since it deletes bytes the upstream
|
|
3287
|
+
* counts.
|
|
3288
|
+
*/
|
|
3289
|
+
const ACP_LEADING_TAG = /^<acp\b[^>]*>m\d{1,8}<\/acp>[ \t]*\r?\n?/u;
|
|
3290
|
+
/**
|
|
3291
|
+
* The plain text of one message's content, for prefix matching only.
|
|
3292
|
+
*
|
|
3293
|
+
* Array-shaped content contributes its text parts joined by a newline; a
|
|
3294
|
+
* non-text part is skipped rather than stringified, because `[object Object]`
|
|
3295
|
+
* would make every prefix test miss.
|
|
3296
|
+
*/
|
|
3297
|
+
function textOfContent(content) {
|
|
3298
|
+
if (typeof content === "string") return content;
|
|
3299
|
+
if (!Array.isArray(content)) return "";
|
|
3300
|
+
const parts = [];
|
|
3301
|
+
for (const part of content) {
|
|
3302
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
3303
|
+
const record = part;
|
|
3304
|
+
if (typeof record["text"] !== "string") continue;
|
|
3305
|
+
parts.push(record["text"]);
|
|
3306
|
+
}
|
|
3307
|
+
return parts.join("\n");
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* Drop the leading `billion-context` marker from one message's content.
|
|
3311
|
+
*
|
|
3312
|
+
* Handles both shapes (a bare string, or array content whose first `text` part
|
|
3313
|
+
* carries the marker) and leaves everything else untouched. Returns whether it
|
|
3314
|
+
* changed anything, so a body with no marker is not re-serialized for nothing.
|
|
3315
|
+
*/
|
|
3316
|
+
function stripLeadingAcpTag(content) {
|
|
3317
|
+
if (typeof content === "string") {
|
|
3318
|
+
if (!ACP_LEADING_TAG.test(content)) return {
|
|
3319
|
+
value: content,
|
|
3320
|
+
changed: false
|
|
3321
|
+
};
|
|
3322
|
+
return {
|
|
3323
|
+
value: content.replace(ACP_LEADING_TAG, ""),
|
|
3324
|
+
changed: true
|
|
3325
|
+
};
|
|
3326
|
+
}
|
|
3327
|
+
if (!Array.isArray(content)) return {
|
|
3328
|
+
value: content,
|
|
3329
|
+
changed: false
|
|
3330
|
+
};
|
|
3331
|
+
for (const part of content) {
|
|
3332
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
|
|
3333
|
+
const record = part;
|
|
3334
|
+
if (record["type"] !== "text" || typeof record["text"] !== "string") continue;
|
|
3335
|
+
if (!ACP_LEADING_TAG.test(record["text"])) continue;
|
|
3336
|
+
record["text"] = record["text"].replace(ACP_LEADING_TAG, "");
|
|
3337
|
+
return {
|
|
3338
|
+
value: content,
|
|
3339
|
+
changed: true
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
return {
|
|
3343
|
+
value: content,
|
|
3344
|
+
changed: false
|
|
3345
|
+
};
|
|
3346
|
+
}
|
|
3347
|
+
/** Whether this message is one of the harness's injected context blocks. */
|
|
3348
|
+
function isInjectedUserMessage(message) {
|
|
3349
|
+
if (message["role"] !== "user") return false;
|
|
3350
|
+
return INJECTED_USER_PREFIX.test(textOfContent(message["content"]).replace(/^\s+/u, ""));
|
|
3351
|
+
}
|
|
3352
|
+
/**
|
|
3353
|
+
* Move the trailing injected `user` messages above the real prompt.
|
|
3354
|
+
*
|
|
3355
|
+
* Returns `true` when the order changed, so the caller can serialize only once.
|
|
3356
|
+
* Never throws, and a body with nothing to move keeps its exact message order.
|
|
3357
|
+
*/
|
|
3358
|
+
function keepPromptLast(messages) {
|
|
3359
|
+
let promptIndex = -1;
|
|
3360
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
3361
|
+
const value = messages[index];
|
|
3362
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
3363
|
+
const message = value;
|
|
3364
|
+
if (message["role"] !== "user") continue;
|
|
3365
|
+
if (isInjectedUserMessage(message)) continue;
|
|
3366
|
+
if (textOfContent(message["content"]).trim() === "") continue;
|
|
3367
|
+
promptIndex = index;
|
|
3368
|
+
break;
|
|
3369
|
+
}
|
|
3370
|
+
if (promptIndex < 0) return false;
|
|
3371
|
+
const trailing = messages.slice(promptIndex + 1).filter((value) => typeof value === "object" && value !== null && !Array.isArray(value) ? isInjectedUserMessage(value) : false);
|
|
3372
|
+
if (trailing.length === 0) return false;
|
|
3373
|
+
const kept = messages.filter((value, index) => !(index > promptIndex && typeof value === "object" && value !== null && !Array.isArray(value) && isInjectedUserMessage(value)));
|
|
3374
|
+
kept.splice(promptIndex, 0, ...trailing);
|
|
3375
|
+
const prompt = kept[promptIndex + trailing.length];
|
|
3376
|
+
if (typeof prompt === "object" && prompt !== null && !Array.isArray(prompt)) {
|
|
3377
|
+
const message = prompt;
|
|
3378
|
+
const stripped = stripLeadingAcpTag(message["content"]);
|
|
3379
|
+
if (stripped.changed) message["content"] = stripped.value;
|
|
3380
|
+
}
|
|
3381
|
+
messages.length = 0;
|
|
3382
|
+
messages.push(...kept);
|
|
3383
|
+
return true;
|
|
3384
|
+
}
|
|
3385
|
+
/**
|
|
3044
3386
|
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream: force
|
|
3045
3387
|
* `stream: true` (the upstream rejects non-streaming), flatten `tool_choice`
|
|
3046
3388
|
* (the upstream's field is a string; object forms return 400), rewrite
|
|
@@ -3057,7 +3399,7 @@ function modelIdOfBody(bodyJson) {
|
|
|
3057
3399
|
* upstream already answers keeps its prompt as close to byte-for-byte as these
|
|
3058
3400
|
* rules allow.
|
|
3059
3401
|
*/
|
|
3060
|
-
function prepareChatBody(source) {
|
|
3402
|
+
function prepareChatBody(source, options) {
|
|
3061
3403
|
let body;
|
|
3062
3404
|
try {
|
|
3063
3405
|
body = JSON.parse(source);
|
|
@@ -3073,6 +3415,7 @@ function prepareChatBody(source) {
|
|
|
3073
3415
|
if (message["role"] === "developer") message["role"] = "system";
|
|
3074
3416
|
}
|
|
3075
3417
|
ensureSystemHead(obj);
|
|
3418
|
+
if (options?.keepPromptLast === true && Array.isArray(obj["messages"])) keepPromptLast(obj["messages"]);
|
|
3076
3419
|
neutralizeChannelFingerprints(obj);
|
|
3077
3420
|
normalizeToolChoice(obj);
|
|
3078
3421
|
dropRefusedReasoning(obj);
|
|
@@ -3526,6 +3869,7 @@ function requestEvidenceOf(bodyJson) {
|
|
|
3526
3869
|
*/
|
|
3527
3870
|
var WorkBuddyUpstreamClient = class {
|
|
3528
3871
|
onFallback;
|
|
3872
|
+
identity;
|
|
3529
3873
|
/**
|
|
3530
3874
|
* Reports a fallback the catalog reader had to take.
|
|
3531
3875
|
*
|
|
@@ -3535,8 +3879,9 @@ var WorkBuddyUpstreamClient = class {
|
|
|
3535
3879
|
* `ctx.logger.warn` in; without a host the event is dropped, which is the
|
|
3536
3880
|
* same silence the previous single-source reader had.
|
|
3537
3881
|
*/
|
|
3538
|
-
constructor(onFallback) {
|
|
3882
|
+
constructor(onFallback, identity = {}) {
|
|
3539
3883
|
this.onFallback = onFallback;
|
|
3884
|
+
this.identity = identity;
|
|
3540
3885
|
}
|
|
3541
3886
|
/** POST the chat endpoint; a successful answer is the raw SSE response. */
|
|
3542
3887
|
async chatStream(credential, bodyJson, signal) {
|
|
@@ -3545,7 +3890,7 @@ var WorkBuddyUpstreamClient = class {
|
|
|
3545
3890
|
response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
|
|
3546
3891
|
method: "POST",
|
|
3547
3892
|
headers: {
|
|
3548
|
-
...chatHeaders(credential),
|
|
3893
|
+
...chatHeaders(credential, this.identity),
|
|
3549
3894
|
"Authorization": `Bearer ${credential.accessToken}`
|
|
3550
3895
|
},
|
|
3551
3896
|
body: bodyJson,
|
|
@@ -3608,7 +3953,7 @@ var WorkBuddyUpstreamClient = class {
|
|
|
3608
3953
|
response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
|
|
3609
3954
|
method: "POST",
|
|
3610
3955
|
headers: {
|
|
3611
|
-
...chatHeaders(credential),
|
|
3956
|
+
...chatHeaders(credential, this.identity),
|
|
3612
3957
|
"Authorization": `Bearer ${credential.accessToken}`
|
|
3613
3958
|
},
|
|
3614
3959
|
body: bodyJson,
|
|
@@ -3810,7 +4155,7 @@ var WorkBuddyUpstreamClient = class {
|
|
|
3810
4155
|
async generateImage(credential, request, signal) {
|
|
3811
4156
|
const response = await fetch(`${chatBase(credential)}${IMAGE_GENERATIONS_PATH}`, {
|
|
3812
4157
|
method: "POST",
|
|
3813
|
-
headers: imageHeaders(credential),
|
|
4158
|
+
headers: imageHeaders(credential, this.identity),
|
|
3814
4159
|
body: JSON.stringify(buildImageBody(request)),
|
|
3815
4160
|
signal: signal ?? AbortSignal.timeout(IMAGE_TIMEOUT_MS)
|
|
3816
4161
|
});
|
|
@@ -5176,9 +5521,21 @@ var WorkBuddyCredentialStore = class {
|
|
|
5176
5521
|
* refuses to use is what made the dropdown look healthy while every request
|
|
5177
5522
|
* failed with 401.
|
|
5178
5523
|
*
|
|
5524
|
+
* THE POOL'S RUNTIME OVERRIDE IS PART OF `current()`, SO IT IS PART OF THIS.
|
|
5525
|
+
* `current()` consults `rotatedAccountId` FIRST; this method used to ignore it
|
|
5526
|
+
* and answer from `accountId` alone. With the pool on, the two therefore
|
|
5527
|
+
* disagreed on every rotation: the shim billed account B while the card marked
|
|
5528
|
+
* account A — the reporter's screenshot showed 「当前选用」 on a rate-limited
|
|
5529
|
+
* account while the pill's total belonged to the one actually serving. Worse,
|
|
5530
|
+
* the usage document takes its `credential` from `current()` and its
|
|
5531
|
+
* `account.accountId` from this list, so one document could name two accounts.
|
|
5532
|
+
*
|
|
5179
5533
|
* So an explicit selection that matches NO local account marks nothing as
|
|
5180
5534
|
* selected (no silent fallback to a different account — see {@link current}).
|
|
5181
5535
|
* The implicit default is marked only when nothing was explicitly chosen.
|
|
5536
|
+
* A live rotation marker wins over both, mirroring `current()`'s precedence,
|
|
5537
|
+
* and only while it still names a local account — `current()` falls through in
|
|
5538
|
+
* that case, and so does this.
|
|
5182
5539
|
*/
|
|
5183
5540
|
async accounts() {
|
|
5184
5541
|
const credentials = await this.readAll();
|
|
@@ -5186,6 +5543,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
5186
5543
|
const hasExplicitSelection = this.accountId !== void 0;
|
|
5187
5544
|
const selectedExists = hasExplicitSelection && credentials.some((credential) => workbuddyAccountId(credential) === this.accountId);
|
|
5188
5545
|
const defaultSelected = this.preferred(credentials);
|
|
5546
|
+
const rotatedId = this.rotatedAccountId !== void 0 && credentials.some((credential) => workbuddyAccountId(credential) === this.rotatedAccountId) ? this.rotatedAccountId : void 0;
|
|
5189
5547
|
return credentials.map((credential) => {
|
|
5190
5548
|
const id = workbuddyAccountId(credential);
|
|
5191
5549
|
return {
|
|
@@ -5196,7 +5554,7 @@ var WorkBuddyCredentialStore = class {
|
|
|
5196
5554
|
source: credential.source,
|
|
5197
5555
|
tokenExpiresAtMs: credential.expiresAtMs,
|
|
5198
5556
|
filePath: credential.filePath,
|
|
5199
|
-
selected: selectedExists ? id === this.accountId : !hasExplicitSelection && credential === defaultSelected
|
|
5557
|
+
selected: rotatedId !== void 0 ? id === rotatedId : selectedExists ? id === this.accountId : !hasExplicitSelection && credential === defaultSelected
|
|
5200
5558
|
};
|
|
5201
5559
|
});
|
|
5202
5560
|
}
|
|
@@ -6148,10 +6506,221 @@ function generationOf(version) {
|
|
|
6148
6506
|
if (match === null) return "legacy";
|
|
6149
6507
|
return Number(match[1]) >= 87 ? "modern" : "legacy";
|
|
6150
6508
|
}
|
|
6509
|
+
/** The versions this build can read (add a new one on upgrade, keep old ones). */
|
|
6510
|
+
const KNOWN_ROSTER_SEEN_VERSIONS = Object.freeze([1]);
|
|
6511
|
+
/**
|
|
6512
|
+
* The "new" window: a first-seen stamp inside it counts as new.
|
|
6513
|
+
*
|
|
6514
|
+
* 7 days is a deliberate scale — it has to cover "the user was away a few days
|
|
6515
|
+
* and came back to two new models" without letting one bulk upload still carry
|
|
6516
|
+
* a marker two weeks later. The window is a CONSTANT here rather than a config:
|
|
6517
|
+
* a "new model" marker that could be tuned to 365 days has no meaning.
|
|
6518
|
+
*/
|
|
6519
|
+
const ROSTER_SEEN_NEW_WINDOW_MS = 6048e5;
|
|
6520
|
+
/**
|
|
6521
|
+
* How long a read answer stays valid before the file is read again.
|
|
6522
|
+
*
|
|
6523
|
+
* Another process may have just written, so the answer has to be visible within
|
|
6524
|
+
* a tick — but a single snapshot assembly should not re-read the file several
|
|
6525
|
+
* times.
|
|
6526
|
+
*/
|
|
6527
|
+
const ROSTER_SEEN_READ_TTL_MS = 5e3;
|
|
6528
|
+
/** Filename prefix for one region's first-sighting history. */
|
|
6529
|
+
const ROSTER_SEEN_FILE_PREFIX = ".workbuddy-roster-seen";
|
|
6530
|
+
const EMPTY_PAYLOAD = {
|
|
6531
|
+
seen: {},
|
|
6532
|
+
version: null
|
|
6533
|
+
};
|
|
6534
|
+
/** Whether this build can read a stored version. */
|
|
6535
|
+
function isKnownRosterSeenVersion(version) {
|
|
6536
|
+
return typeof version === "number" && KNOWN_ROSTER_SEEN_VERSIONS.includes(version);
|
|
6537
|
+
}
|
|
6538
|
+
/**
|
|
6539
|
+
* Normalise a first-seen table: keep only entries whose id is a non-empty
|
|
6540
|
+
* string and whose stamp is a finite positive number.
|
|
6541
|
+
*
|
|
6542
|
+
* Dropping rather than repairing a bad entry is deliberate: a stamp of 0 or
|
|
6543
|
+
* negative makes `now - stamp` come out as decades, and that id would count as
|
|
6544
|
+
* "new" forever. Better to let it take a real stamp the next time it appears.
|
|
6545
|
+
*/
|
|
6546
|
+
function normalizeRosterSeen(raw) {
|
|
6547
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
6548
|
+
const out = {};
|
|
6549
|
+
for (const [id, stamp] of Object.entries(raw)) {
|
|
6550
|
+
if (id === "") continue;
|
|
6551
|
+
if (typeof stamp !== "number" || !Number.isFinite(stamp) || stamp <= 0) continue;
|
|
6552
|
+
out[id] = stamp;
|
|
6553
|
+
}
|
|
6554
|
+
return out;
|
|
6555
|
+
}
|
|
6556
|
+
/**
|
|
6557
|
+
* One region's path for its first-sighting history.
|
|
6558
|
+
*
|
|
6559
|
+
* Region-keyed for the same reason the credential and pool copies are: the two
|
|
6560
|
+
* regions are parallel stacks and must never overwrite each other's history.
|
|
6561
|
+
*/
|
|
6562
|
+
function rosterSeenPath(region, dir) {
|
|
6563
|
+
if (typeof dir === "string" && dir !== "") return join(dir, `${ROSTER_SEEN_FILE_PREFIX}.${region}.json`);
|
|
6564
|
+
return join(resolveDshHome(), `${ROSTER_SEEN_FILE_PREFIX}.${region}.json`);
|
|
6565
|
+
}
|
|
6566
|
+
/**
|
|
6567
|
+
* The ids from a first-seen table that are still "new", oldest first.
|
|
6568
|
+
*
|
|
6569
|
+
* Ascending on purpose: the caller shows only the first few names (the pill
|
|
6570
|
+
* tooltip / popover budget), and the ones seen EARLIEST are the ones closest to
|
|
6571
|
+
* expiring — and the ones the reader is most likely to have already met and
|
|
6572
|
+
* needs told "it really only just appeared".
|
|
6573
|
+
*/
|
|
6574
|
+
function freshIdsOf(seen, nowMs) {
|
|
6575
|
+
return Object.entries(seen).filter(([, first]) => nowMs - first < ROSTER_SEEN_NEW_WINDOW_MS).sort((left, right) => left[1] - right[1]).map(([id]) => id);
|
|
6576
|
+
}
|
|
6577
|
+
/**
|
|
6578
|
+
* Parse a raw JSON value into a payload; an unreadable version reads as an
|
|
6579
|
+
* empty table but KEEPS the version number — reads stay permissive, while the
|
|
6580
|
+
* write guard refuses to overwrite it (the two halves are one rule).
|
|
6581
|
+
*/
|
|
6582
|
+
function parseRosterSeenPayload(raw) {
|
|
6583
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return EMPTY_PAYLOAD;
|
|
6584
|
+
const source = raw;
|
|
6585
|
+
const version = typeof source["version"] === "number" ? source["version"] : null;
|
|
6586
|
+
if (!isKnownRosterSeenVersion(version)) return {
|
|
6587
|
+
seen: {},
|
|
6588
|
+
version
|
|
6589
|
+
};
|
|
6590
|
+
return {
|
|
6591
|
+
seen: normalizeRosterSeen(source["seen"]),
|
|
6592
|
+
version
|
|
6593
|
+
};
|
|
6594
|
+
}
|
|
6595
|
+
/**
|
|
6596
|
+
* The file-backed first-sighting table for one region.
|
|
6597
|
+
*/
|
|
6598
|
+
function createFileRosterSeenStore(region, options = {}) {
|
|
6599
|
+
const { dir, ttlMs = ROSTER_SEEN_READ_TTL_MS } = options;
|
|
6600
|
+
const filePath = rosterSeenPath(region, dir);
|
|
6601
|
+
/** Read the file's raw JSON, or `null` for a missing or unreadable file. */
|
|
6602
|
+
const readRaw = async () => {
|
|
6603
|
+
try {
|
|
6604
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
6605
|
+
} catch {
|
|
6606
|
+
return null;
|
|
6607
|
+
}
|
|
6608
|
+
};
|
|
6609
|
+
let cached;
|
|
6610
|
+
const readPayload = async () => {
|
|
6611
|
+
if (cached !== void 0 && Date.now() - cached.at < ttlMs) return cached.payload;
|
|
6612
|
+
const payload = parseRosterSeenPayload(await readRaw());
|
|
6613
|
+
cached = {
|
|
6614
|
+
payload,
|
|
6615
|
+
at: Date.now()
|
|
6616
|
+
};
|
|
6617
|
+
return payload;
|
|
6618
|
+
};
|
|
6619
|
+
let writeTail = Promise.resolve();
|
|
6620
|
+
const enqueueWrite = (task) => {
|
|
6621
|
+
const run = writeTail.then(task, task);
|
|
6622
|
+
writeTail = run.then(() => void 0, () => void 0);
|
|
6623
|
+
return run;
|
|
6624
|
+
};
|
|
6625
|
+
return {
|
|
6626
|
+
/**
|
|
6627
|
+
* Record one round's complete catalog and answer which of its ids are still
|
|
6628
|
+
* inside the new window.
|
|
6629
|
+
*
|
|
6630
|
+
* One call diffs AND persists (see the module note). The first write is
|
|
6631
|
+
* SEEDING: every id is stamped as already expired and `newIds` answers
|
|
6632
|
+
* empty.
|
|
6633
|
+
*/
|
|
6634
|
+
async note(ids, nowMs = Date.now()) {
|
|
6635
|
+
const list = Array.isArray(ids) ? ids.filter((id) => typeof id === "string" && id !== "") : [];
|
|
6636
|
+
const unique = [...new Set(list)];
|
|
6637
|
+
if (unique.length === 0) return { newIds: [] };
|
|
6638
|
+
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
6639
|
+
return enqueueWrite(() => withFileLock(filePath, async () => {
|
|
6640
|
+
const current = parseRosterSeenPayload(await readRaw());
|
|
6641
|
+
if (current.version !== null && !isKnownRosterSeenVersion(current.version)) return { newIds: [] };
|
|
6642
|
+
const previous = current.seen;
|
|
6643
|
+
const seeded = current.version === null;
|
|
6644
|
+
const next = {};
|
|
6645
|
+
for (const id of unique) {
|
|
6646
|
+
const first = previous[id];
|
|
6647
|
+
next[id] = typeof first === "number" && first > 0 ? Math.min(first, now) : seeded ? now - ROSTER_SEEN_NEW_WINDOW_MS : now;
|
|
6648
|
+
}
|
|
6649
|
+
if (Object.keys(next).length > 200) return { newIds: [] };
|
|
6650
|
+
if (seeded || Object.keys(previous).length !== Object.keys(next).length || Object.keys(next).some((id) => previous[id] !== next[id])) {
|
|
6651
|
+
await writeFileAtomic(filePath, `${JSON.stringify({
|
|
6652
|
+
version: 1,
|
|
6653
|
+
seen: next,
|
|
6654
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6655
|
+
}, null, 2)}\n`, {
|
|
6656
|
+
mode: 384,
|
|
6657
|
+
dirMode: 448
|
|
6658
|
+
});
|
|
6659
|
+
cached = {
|
|
6660
|
+
payload: {
|
|
6661
|
+
seen: next,
|
|
6662
|
+
version: 1
|
|
6663
|
+
},
|
|
6664
|
+
at: Date.now()
|
|
6665
|
+
};
|
|
6666
|
+
}
|
|
6667
|
+
return { newIds: seeded ? [] : freshIdsOf(next, now) };
|
|
6668
|
+
}));
|
|
6669
|
+
},
|
|
6670
|
+
/**
|
|
6671
|
+
* Read-only: the ids that still count as "new" right now (ascending).
|
|
6672
|
+
*
|
|
6673
|
+
* NEVER WRITES. The snapshot and the provider route are read paths, and
|
|
6674
|
+
* writing happens only in the reconciler's `note`; a read path that topped
|
|
6675
|
+
* up stamps would let one panel poll rewrite "who is new".
|
|
6676
|
+
*/
|
|
6677
|
+
async fresh(nowMs = Date.now()) {
|
|
6678
|
+
return freshIdsOf((await readPayload()).seen, Number.isFinite(nowMs) ? nowMs : Date.now());
|
|
6679
|
+
},
|
|
6680
|
+
/** Whether this table has ever been written (seeded vs "really none new"). */
|
|
6681
|
+
async isSet() {
|
|
6682
|
+
return (await readPayload()).version !== null;
|
|
6683
|
+
},
|
|
6684
|
+
/**
|
|
6685
|
+
* The read-only readout the usage route carries, or the version note when
|
|
6686
|
+
* the on-disk history is in a version this build cannot read.
|
|
6687
|
+
*/
|
|
6688
|
+
async readout(nowMs = Date.now()) {
|
|
6689
|
+
const payload = await readPayload();
|
|
6690
|
+
const version = payload.version;
|
|
6691
|
+
if (version === null || isKnownRosterSeenVersion(version)) return { newModelIds: freshIdsOf(payload.seen, Number.isFinite(nowMs) ? nowMs : Date.now()) };
|
|
6692
|
+
return {
|
|
6693
|
+
newModelIds: [],
|
|
6694
|
+
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(", ")}.`
|
|
6695
|
+
};
|
|
6696
|
+
},
|
|
6697
|
+
/** The on-disk path (diagnostics and tests). */
|
|
6698
|
+
filePath
|
|
6699
|
+
};
|
|
6700
|
+
}
|
|
6701
|
+
/**
|
|
6702
|
+
* doctor's read-only summary: parse a roster-seen payload without building a
|
|
6703
|
+
* store or touching any cache.
|
|
6704
|
+
*
|
|
6705
|
+
* `null` for a payload the whole shape does not recognise; `versionKnown: false`
|
|
6706
|
+
* for a recognised version this build cannot read — so `doctor` can tell "the
|
|
6707
|
+
* file is corrupt" from "a newer build wrote it".
|
|
6708
|
+
*/
|
|
6709
|
+
function describeRosterSeenPayload(raw) {
|
|
6710
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
6711
|
+
const version = raw["version"];
|
|
6712
|
+
if (typeof version !== "number") return null;
|
|
6713
|
+
const seen = normalizeRosterSeen(raw["seen"]);
|
|
6714
|
+
return {
|
|
6715
|
+
versionKnown: isKnownRosterSeenVersion(version),
|
|
6716
|
+
ids: Object.keys(seen).length,
|
|
6717
|
+
fresh: freshIdsOf(seen, Date.now()).length
|
|
6718
|
+
};
|
|
6719
|
+
}
|
|
6151
6720
|
//#endregion
|
|
6152
6721
|
//#region src/version.ts
|
|
6153
6722
|
/** The npm package version this build was produced from. */
|
|
6154
|
-
const WORKBUDDY_CONNECT_VERSION = "5.
|
|
6723
|
+
const WORKBUDDY_CONNECT_VERSION = "5.8.0";
|
|
6155
6724
|
//#endregion
|
|
6156
6725
|
//#region src/host-heartbeat.ts
|
|
6157
6726
|
/**
|
|
@@ -6309,4 +6878,4 @@ async function clearHostHeartbeat() {
|
|
|
6309
6878
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
6310
6879
|
}
|
|
6311
6880
|
//#endregion
|
|
6312
|
-
export {
|
|
6881
|
+
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 };
|