dsh-connect-workbuddy 4.4.4 → 4.4.6

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/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { $ as isWorkbuddyBundle, A as workbuddyAccountId, B as parseCreditMultiplier, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as WORKBUDDY_APP_EXECUTABLE_ENV, H as parseUpstreamModel, I as classifyUpstreamError, J as deriveAtRestKeyId, K as clearAtRestKeyCache, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, Q as isEncryptedFieldWrapper, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as prepareChatBody, V as parseReasoning, W as regionOf, X as findWorkbuddyAppExecutable, Y as fetchAtRestKeyPayload, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as writeFileAtomic, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as macosBundleExecutable, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as workbuddyAppExecutableCandidates, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, m as deriveCatalog, n as clearHostHeartbeat, nt as openEncryptedField, o as workbuddyHostHeartbeatPath, ot as resolveDshHome, p as applyContextBudgets, q as deriveAtRestKey, r as isHeartbeatProcessAlive, rt as readAtRestKey, s as writeHostHeartbeat, st as withFileLock, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosNestedAppBundles, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-BwbDWvzw.js";
1
+ import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-B2jrUDhg.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { readFile } from "node:fs/promises";
3
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
5
  import { join } from "node:path";
6
6
  import { createProvider } from "@earendil-works/pi-ai";
@@ -1776,6 +1776,7 @@ function createWorkBuddyShim(options) {
1776
1776
  const failoverAccount = options.failoverAccount;
1777
1777
  const prepareAccount = options.prepareAccount;
1778
1778
  const onAccountFailure = options.onAccountFailure;
1779
+ const onRequestBody = options.onRequestBody;
1779
1780
  const SHARED_SECRET = randomBytes(32).toString("base64url");
1780
1781
  /** Constant-time bearer check; absent or mismatched bearers are rejected. */
1781
1782
  function bearerOk(req) {
@@ -1886,6 +1887,12 @@ function createWorkBuddyShim(options) {
1886
1887
  }
1887
1888
  const raw = (await readBody(req)).toString("utf8");
1888
1889
  const prepared = prepareChatBody(raw);
1890
+ const requestModelId = modelIdOfBody(prepared);
1891
+ if (onRequestBody !== void 0) try {
1892
+ onRequestBody(prepared);
1893
+ } catch (error) {
1894
+ logger?.warn("dsh-connect-workbuddy: body report failed", error);
1895
+ }
1889
1896
  const controller = new AbortController();
1890
1897
  res.on("close", () => controller.abort());
1891
1898
  let result = await client.chatStream(credential, prepared, controller.signal);
@@ -1929,7 +1936,8 @@ function createWorkBuddyShim(options) {
1929
1936
  onAccountFailure(entry.accountId, {
1930
1937
  status: entry.failure.status,
1931
1938
  kind: entry.failure.kind,
1932
- message: entry.failure.message
1939
+ message: entry.failure.message,
1940
+ ...requestModelId === void 0 ? {} : { modelId: requestModelId }
1933
1941
  });
1934
1942
  } catch (error) {
1935
1943
  logger?.warn("dsh-connect-workbuddy: recording an account failure failed", error);
@@ -2042,6 +2050,246 @@ function createWorkBuddyShim(options) {
2042
2050
  };
2043
2051
  }
2044
2052
  //#endregion
2053
+ //#region src/body-report.ts
2054
+ /**
2055
+ * Body-composition report: WHAT was sent upstream, in counts (issue #59).
2056
+ *
2057
+ * WHY THIS EXISTS. Issue #59's reporter measured the SAME conversation charged
2058
+ * ~708K tokens by the CN gateway and ~1.374M by the international one (1.94×),
2059
+ * which turns a region switch into an instant `code 11115 prompt is too long`.
2060
+ * Two worlds explain that, and they have opposite owners:
2061
+ *
2062
+ * - the gateways count identical bytes differently (upstream's business); or
2063
+ * - the two requests did not carry the same messages (ours to fix).
2064
+ *
2065
+ * The second world was left standing by two independent measurements. The
2066
+ * reporter ran this project's `--kind`/`--language` probe on BOTH regions and
2067
+ * got the same tokens-per-1000-chars on byte-identical prose, code and JSON
2068
+ * (ratios 0.98–1.06 across six cells), so identical bytes really do bill
2069
+ * identically. And a code trace showed historical `reasoning` never reaches the
2070
+ * wire at all: pi-ai only forwards `thinking` blocks as text when
2071
+ * `compat.requiresThinkingAsText` is set — this plugin never sets it — and the
2072
+ * `reasoning_details` route needs a signature the stream parser never writes.
2073
+ * With the counting difference excluded and reasoning excluded, the remaining
2074
+ * candidate is that the two bodies differ in the first place.
2075
+ *
2076
+ * This module exists to settle exactly that, and it can do it WITHOUT either
2077
+ * region having credit: the body is assembled locally, before the upstream ever
2078
+ * gets a chance to answer `14018 Credits exhausted`. Capture one composition per
2079
+ * region in the same session and diff them.
2080
+ *
2081
+ * It records COUNTS ONLY — never a byte of message text, no account name, no
2082
+ * token. That is a hard rule, not a default: the point is to compare shape
2083
+ * (`messages.length`, per-block type, CJK vs non-CJK characters, total bytes),
2084
+ * and every one of those is answerable arithmetically. `tests/issue59-body-report.spec.ts`
2085
+ * asserts the report cannot leak a fixture's text.
2086
+ */
2087
+ /**
2088
+ * Environment variable that enables the report.
2089
+ *
2090
+ * Unset or blank means OFF — a diagnostic that writes files must be opt-in.
2091
+ * `1`/`true` writes to `<DSH home>/body-reports/`; anything else is read as a
2092
+ * directory path, so a user can point it at an existing folder (and, on
2093
+ * Windows, one with spaces).
2094
+ */
2095
+ const WORKBUDDY_BODY_REPORT_ENV = "WORKBUDDY_BODY_REPORT";
2096
+ /**
2097
+ * Character ranges that tokenize (and therefore bill) like CJK.
2098
+ *
2099
+ * Deliberately generous: the caller's question is "how much of this is the ~3×
2100
+ * kind", and the measured spread is 0.669 vs 0.224 tokens/char, so a character
2101
+ * class that is borderline costs far less than mis-attributing a whole bucket.
2102
+ * Ranges follow Unicode blocks, plus the compatibility forms and the two
2103
+ * extension planes where rare Han characters live.
2104
+ */
2105
+ const CJK_RANGES = [
2106
+ [11904, 12031],
2107
+ [12288, 12351],
2108
+ [12352, 12543],
2109
+ [13312, 19903],
2110
+ [19968, 40959],
2111
+ [63744, 64255],
2112
+ [65072, 65103],
2113
+ [65280, 65519],
2114
+ [131072, 195103]
2115
+ ];
2116
+ /** Whether one code point bills like CJK. */
2117
+ function isCjkCodePoint(code) {
2118
+ return CJK_RANGES.some(([low, high]) => code >= low && code <= high);
2119
+ }
2120
+ /** Count characters (code points) and how many of them are CJK. */
2121
+ function countChars(text) {
2122
+ let chars = 0;
2123
+ let cjkChars = 0;
2124
+ for (const character of text) {
2125
+ chars += 1;
2126
+ const code = character.codePointAt(0);
2127
+ if (code !== void 0 && isCjkCodePoint(code)) cjkChars += 1;
2128
+ }
2129
+ return {
2130
+ chars,
2131
+ cjkChars
2132
+ };
2133
+ }
2134
+ /** Build one block report from already-extracted text. */
2135
+ function block(type, text) {
2136
+ return {
2137
+ type,
2138
+ ...countChars(text)
2139
+ };
2140
+ }
2141
+ /** Whether a value is a plain object (not null, not an array). */
2142
+ function isRecord(value) {
2143
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2144
+ }
2145
+ /**
2146
+ * Describe one message's blocks.
2147
+ *
2148
+ * The role decides what a content string IS, and that is not cosmetic: a
2149
+ * `tool` message's payload is a tool result, so counting it as `text` would hide
2150
+ * the very bucket a long agentic session is mostly made of.
2151
+ */
2152
+ function describeMessage(message) {
2153
+ const role = typeof message["role"] === "string" ? message["role"] : "unknown";
2154
+ const blocks = [];
2155
+ const content = message["content"];
2156
+ /**
2157
+ * The block type a plain string carries, by the ROLE carrying it.
2158
+ *
2159
+ * A `tool` message's string is a tool RESULT and a `system` one is the system
2160
+ * prompt; calling both `text` would fold the biggest bucket of a long agentic
2161
+ * session into the smallest one, which is the opposite of what a composition
2162
+ * report is for.
2163
+ */
2164
+ const stringType = role === "tool" ? "tool-result" : role === "system" || role === "developer" ? "system" : "text";
2165
+ if (typeof content === "string") {
2166
+ if (content !== "") blocks.push(block(stringType, content));
2167
+ } else if (Array.isArray(content)) for (const part of content) {
2168
+ if (!isRecord(part)) continue;
2169
+ const type = typeof part["type"] === "string" ? part["type"] : "unknown";
2170
+ if (type === "text") {
2171
+ const text = part["text"];
2172
+ if (typeof text === "string" && text !== "") blocks.push(block(stringType, text));
2173
+ continue;
2174
+ }
2175
+ blocks.push({
2176
+ type: type === "image_url" ? "image" : type,
2177
+ chars: 0,
2178
+ cjkChars: 0
2179
+ });
2180
+ }
2181
+ const toolCalls = message["tool_calls"];
2182
+ if (Array.isArray(toolCalls)) for (const call of toolCalls) {
2183
+ if (!isRecord(call)) continue;
2184
+ const fn = call["function"];
2185
+ const name = isRecord(fn) && typeof fn["name"] === "string" ? fn["name"] : "";
2186
+ const args = isRecord(fn) && typeof fn["arguments"] === "string" ? fn["arguments"] : "";
2187
+ blocks.push(block("tool-call", `${name}${args}`));
2188
+ }
2189
+ for (const field of [
2190
+ "reasoning_content",
2191
+ "reasoning",
2192
+ "reasoning_text"
2193
+ ]) {
2194
+ const value = message[field];
2195
+ if (typeof value === "string" && value !== "") blocks.push(block("reasoning", value));
2196
+ }
2197
+ return {
2198
+ role,
2199
+ chars: blocks.reduce((sum, entry) => sum + entry.chars, 0),
2200
+ cjkChars: blocks.reduce((sum, entry) => sum + entry.cjkChars, 0),
2201
+ blocks
2202
+ };
2203
+ }
2204
+ /**
2205
+ * Describe one serialized request body.
2206
+ *
2207
+ * Returns `undefined` when the body is not a JSON object — a diagnostic must
2208
+ * never be the reason a chat request fails, so every caller treats `undefined`
2209
+ * as "nothing to report" rather than an error.
2210
+ */
2211
+ function describeBody(bodyJson, region, nowMs) {
2212
+ let parsed;
2213
+ try {
2214
+ parsed = JSON.parse(bodyJson);
2215
+ } catch {
2216
+ return;
2217
+ }
2218
+ if (!isRecord(parsed)) return void 0;
2219
+ const rawMessages = parsed["messages"];
2220
+ const messages = Array.isArray(rawMessages) ? rawMessages.filter(isRecord).map(describeMessage) : [];
2221
+ const rawTools = parsed["tools"];
2222
+ const tools = Array.isArray(rawTools) ? block("tools", rawTools.map((tool) => isRecord(tool) ? JSON.stringify(tool) : "").join("")) : {
2223
+ type: "tools",
2224
+ chars: 0,
2225
+ cjkChars: 0
2226
+ };
2227
+ const chars = messages.reduce((sum, message) => sum + message.chars, 0) + tools.chars;
2228
+ const cjkChars = messages.reduce((sum, message) => sum + message.cjkChars, 0) + tools.cjkChars;
2229
+ return {
2230
+ at: new Date(nowMs).toISOString(),
2231
+ region,
2232
+ ...typeof parsed["model"] === "string" ? { model: parsed["model"] } : {},
2233
+ bodyBytes: Buffer.byteLength(bodyJson, "utf8"),
2234
+ messageCount: messages.length,
2235
+ chars,
2236
+ cjkChars,
2237
+ tools,
2238
+ messages
2239
+ };
2240
+ }
2241
+ /**
2242
+ * Resolve the directory the report writes to, or `undefined` when disabled.
2243
+ *
2244
+ * Exported for the same reason the other env-var resolvers in this plugin are:
2245
+ * the behaviour is worth a unit test, and the test must not have to set a real
2246
+ * process-wide variable to get one.
2247
+ */
2248
+ function bodyReportDirectory(env = process.env) {
2249
+ const configured = env[WORKBUDDY_BODY_REPORT_ENV];
2250
+ if (configured === void 0) return void 0;
2251
+ const trimmed = configured.trim();
2252
+ if (trimmed === "") return void 0;
2253
+ if (trimmed === "1" || trimmed.toLowerCase() === "true") return join(resolveDshHome(), "body-reports");
2254
+ return trimmed;
2255
+ }
2256
+ /**
2257
+ * Append one composition to `<dir>/<region>.jsonl`.
2258
+ *
2259
+ * One file per region is the whole point: the reporter captures a session, then
2260
+ * diffs `cn.jsonl`'s last line against `global.jsonl`'s. JSON Lines rather than
2261
+ * one file per request because a single turn makes many upstream calls (one per
2262
+ * tool step) and the LAST one is the interesting one.
2263
+ *
2264
+ * Read-modify-write is avoided on purpose — a long session would rewrite a
2265
+ * growing file on every step. The lock keeps two concurrent requests from
2266
+ * interleaving a line.
2267
+ */
2268
+ async function appendBodyReport(directory, report) {
2269
+ const file = join(directory, `${report.region}.jsonl`);
2270
+ await mkdir(directory, { recursive: true });
2271
+ const line = `${JSON.stringify(report)}\n`;
2272
+ await withFileLock(file, () => appendFile(file, line, "utf8"));
2273
+ }
2274
+ /**
2275
+ * Record one outgoing body, if the report is enabled.
2276
+ *
2277
+ * NEVER THROWS and never awaits long: this is called on the request path, and a
2278
+ * chat request must not fail (or stall) because a diagnostic could not write a
2279
+ * file. A failure is reported through `onError` so the caller can log it.
2280
+ */
2281
+ async function recordBodyReport(region, bodyJson, options = {}) {
2282
+ const directory = bodyReportDirectory(options.env);
2283
+ if (directory === void 0) return;
2284
+ try {
2285
+ const report = describeBody(bodyJson, region, options.nowMs ?? Date.now());
2286
+ if (report === void 0) return;
2287
+ await appendBodyReport(directory, report);
2288
+ } catch (error) {
2289
+ options.onError?.(error);
2290
+ }
2291
+ }
2292
+ //#endregion
2045
2293
  //#region src/probe.ts
2046
2294
  /**
2047
2295
  * How long to bench a MODEL that answered 14003 with no stated time.
@@ -2504,7 +2752,22 @@ async function testAllAccounts(targets, modelId, deps, onRow) {
2504
2752
  const rows = [];
2505
2753
  for (const [index, target] of targets.entries()) {
2506
2754
  if (index > 0) await (deps.wait ?? defaultWait)(400);
2507
- const row = await testOne(target, modelId, deps);
2755
+ const wanted = typeof modelId === "function" ? modelId(target.accountId) : { modelId };
2756
+ if (wanted.modelId === void 0) {
2757
+ const row = {
2758
+ accountId: target.accountId,
2759
+ accountName: target.accountName,
2760
+ result: {
2761
+ modelId: wanted.staleModelId ?? "",
2762
+ outcome: "unavailable",
2763
+ message: wanted.staleModelId === void 0 ? "this region offers no model to test" : `the test model "${wanted.staleModelId}" is no longer offered in this region`
2764
+ }
2765
+ };
2766
+ rows.push(row);
2767
+ onRow?.(row);
2768
+ continue;
2769
+ }
2770
+ const row = await testOne(target, wanted.modelId, deps);
2508
2771
  rows.push(row);
2509
2772
  onRow?.(row);
2510
2773
  }
@@ -2567,6 +2830,8 @@ function probeUpdatesOf(rows) {
2567
2830
  outcome: row.result.outcome,
2568
2831
  atMs,
2569
2832
  source: "test-batch",
2833
+ modelId: row.result.modelId,
2834
+ ...row.accountName === "" ? {} : { accountName: row.accountName },
2570
2835
  ...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs },
2571
2836
  ...row.result.message === void 0 ? {} : { message: row.result.message }
2572
2837
  };
@@ -2809,6 +3074,30 @@ function pickFreeModel(catalog) {
2809
3074
  * That is the safe direction: no credits are spent while the roster is unknown,
2810
3075
  * and the card offers to switch back to automatic.
2811
3076
  */
3077
+ /**
3078
+ * The model ONE account's test should target (issue #61).
3079
+ *
3080
+ * The region-wide `targetModelId` is one value, but `6004` is an ACCOUNT × MODEL
3081
+ * condition: the reporter measured two accounts where `hy4-preview-f` answered
3082
+ * `ok` and `step-5-preview` returned `429 / 6004` at the same moment. Testing
3083
+ * only the region's model therefore cannot answer "can this account serve the
3084
+ * model I actually use", which is the question the pool's test button is read as
3085
+ * answering.
3086
+ *
3087
+ * A per-account override is the smallest thing that makes the question
3088
+ * answerable, and it is additive: an account with no entry keeps using
3089
+ * `targetModelId`, so every existing profile behaves identically.
3090
+ *
3091
+ * Returns a PREFERENCE, not a resolved target — `resolveTargetModel` still
3092
+ * validates it against the catalog, so an override pointing at a model this
3093
+ * region no longer offers is reported as stale rather than silently swapped for
3094
+ * one the user did not choose.
3095
+ */
3096
+ function targetModelPreference(preferences, accountId) {
3097
+ const perAccount = accountId === void 0 ? void 0 : preferences.targetModelIdByAccount?.[accountId];
3098
+ if (perAccount !== void 0 && perAccount !== "") return perAccount;
3099
+ return preferences.targetModelId;
3100
+ }
2812
3101
  function resolveTargetModel(catalog, preferredId) {
2813
3102
  if (preferredId !== void 0 && preferredId !== "") return catalog.some((model) => model.id === preferredId) ? {
2814
3103
  modelId: preferredId,
@@ -3317,7 +3606,7 @@ const POOL_STORE_PREFIX = ".workbuddy-pool";
3317
3606
  function isProbe(value) {
3318
3607
  if (typeof value !== "object" || value === null) return false;
3319
3608
  const candidate = value;
3320
- return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string") && (candidate["source"] === void 0 || typeof candidate["source"] === "string");
3609
+ return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number") && (candidate["message"] === void 0 || typeof candidate["message"] === "string") && (candidate["accountName"] === void 0 || typeof candidate["accountName"] === "string") && (candidate["source"] === void 0 || typeof candidate["source"] === "string");
3321
3610
  }
3322
3611
  /**
3323
3612
  * One region's path for measured pool facts.
@@ -3366,7 +3655,8 @@ async function readPoolProbes(region) {
3366
3655
  atMs: probe.atMs,
3367
3656
  ...knownProbeSource(probe.source) === void 0 ? {} : { source: knownProbeSource(probe.source) },
3368
3657
  ...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs },
3369
- ...probe.message === void 0 ? {} : { message: probe.message }
3658
+ ...probe.message === void 0 ? {} : { message: probe.message },
3659
+ ...probe.accountName === void 0 ? {} : { accountName: probe.accountName }
3370
3660
  };
3371
3661
  }
3372
3662
  return probes;
@@ -3414,10 +3704,14 @@ function knownProbeSource(value) {
3414
3704
  */
3415
3705
  function mergePoolProbe(existing, update) {
3416
3706
  if (existing === void 0) return update;
3417
- if (update.retryAtMs !== void 0) return update;
3418
- if (existing.retryAtMs === void 0 || existing.retryAtMs <= update.atMs) return update;
3419
- return {
3707
+ const merged = update.accountName === void 0 && existing.accountName !== void 0 ? {
3420
3708
  ...update,
3709
+ accountName: existing.accountName
3710
+ } : update;
3711
+ if (update.retryAtMs !== void 0) return merged;
3712
+ if (existing.retryAtMs === void 0 || existing.retryAtMs <= update.atMs) return merged;
3713
+ return {
3714
+ ...merged,
3421
3715
  retryAtMs: existing.retryAtMs
3422
3716
  };
3423
3717
  }
@@ -3794,7 +4088,6 @@ function toCredits(answer) {
3794
4088
  remain: pack.remain,
3795
4089
  size: pack.size,
3796
4090
  monthly: pack.monthly,
3797
- ...pack.cycleRemain === void 0 ? {} : { cycleRemain: pack.cycleRemain },
3798
4091
  ...pack.refreshAtMs === void 0 ? {} : { cycleRefreshMs: pack.refreshAtMs },
3799
4092
  ...pack.expiresAtMs === void 0 ? {} : { expiresAtMs: pack.expiresAtMs }
3800
4093
  })),
@@ -4009,20 +4302,32 @@ async function workBuddyWebPool(deps, region) {
4009
4302
  const preferences = pool.preferences(region);
4010
4303
  const members = await pool.members(region);
4011
4304
  const ranked = rankPool(members, Date.now());
4012
- const target = resolveTargetModel(pool.catalog?.(region) ?? [], preferences.targetModelId);
4305
+ const catalog = pool.catalog?.(region) ?? [];
4306
+ const target = resolveTargetModel(catalog, preferences.targetModelId);
4013
4307
  const currentAccountId = await pool.currentAccountId?.(region);
4014
4308
  const byId = new Map(members.map((member) => [member.account.id, member]));
4015
4309
  const rankedIds = new Set(ranked.map((row) => row.account.id));
4016
4310
  const others = await pool.otherAccounts?.(region) ?? [];
4017
4311
  const checkedIn = await pool.checkedInToday?.(region).catch(() => ({})) ?? {};
4018
- const accounts = [...ranked.map((row) => ({
4312
+ const rows = [...ranked.map((row) => ({
4019
4313
  account: row.account,
4020
4314
  member: true,
4021
4315
  ranked: row
4022
4316
  })), ...others.filter((account) => !rankedIds.has(account.id)).map((account) => ({
4023
4317
  account,
4024
4318
  member: false
4025
- }))].map(({ account, member, ranked: row }) => {
4319
+ }))];
4320
+ /**
4321
+ * Names for saved members the roster above does NOT contain (issue #60).
4322
+ *
4323
+ * Read for the SAVED ids only, and only from the measurement record: those
4324
+ * are exactly the accounts the card cannot name from `accounts`, because
4325
+ * nothing about them is left on this machine except the fact that they were
4326
+ * once measured. Best-effort — a failed read names nothing, and the card
4327
+ * falls back to an id prefix rather than inventing a label.
4328
+ */
4329
+ const memberNames = await pool.memberNames?.(region).catch(() => void 0) ?? void 0;
4330
+ const accounts = rows.map(({ account, member, ranked: row }) => {
4026
4331
  const measured = byId.get(account.id);
4027
4332
  return {
4028
4333
  accountId: account.id,
@@ -4037,8 +4342,17 @@ async function workBuddyWebPool(deps, region) {
4037
4342
  atMs: measured.probe.atMs,
4038
4343
  ...measured.probe.source === void 0 ? {} : { source: measured.probe.source },
4039
4344
  ...measured.probe.retryAtMs === void 0 ? {} : { retryAtMs: measured.probe.retryAtMs },
4040
- ...measured.probe.message === void 0 ? {} : { message: measured.probe.message }
4345
+ ...measured.probe.message === void 0 ? {} : { message: measured.probe.message },
4346
+ ...measured.probe.modelId === void 0 ? {} : { modelId: measured.probe.modelId }
4041
4347
  } },
4348
+ ...(() => {
4349
+ const perRow = resolveTargetModel(catalog, targetModelPreference(preferences, account.id));
4350
+ return {
4351
+ ...perRow.modelId === void 0 ? {} : { testModelId: perRow.modelId },
4352
+ testModelSource: perRow.source,
4353
+ ...perRow.staleModelId === void 0 ? {} : { staleTestModelId: perRow.staleModelId }
4354
+ };
4355
+ })(),
4042
4356
  ...row?.excludedBy === void 0 ? {} : { excludedBy: row.excludedBy },
4043
4357
  current: account.id === currentAccountId,
4044
4358
  member,
@@ -4051,9 +4365,11 @@ async function workBuddyWebPool(deps, region) {
4051
4365
  ...target.modelId === void 0 ? {} : { targetModelId: target.modelId },
4052
4366
  ...target.staleModelId === void 0 ? {} : { staleTargetModelId: target.staleModelId },
4053
4367
  targetModelSource: target.source,
4368
+ targetModelIdByAccount: preferences.targetModelIdByAccount ?? {},
4054
4369
  memberAccountIds: preferences.memberAccountIds,
4055
4370
  ...preferences.probeInputTokens === void 0 ? {} : { probeInputTokens: preferences.probeInputTokens },
4056
4371
  effectiveMemberAccountIds: effectiveMembersOf(accounts.map((account) => account.accountId), new Set(preferences.memberAccountIds)),
4372
+ ...memberNames === void 0 ? {} : { memberNames },
4057
4373
  accounts,
4058
4374
  catalog: deps.displayModels(region).map((model) => ({
4059
4375
  id: model.id,
@@ -4293,7 +4609,20 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
4293
4609
  reason: "pool-unavailable",
4294
4610
  error: "pool test unavailable"
4295
4611
  });
4296
- const target = resolveTargetModel(pool.catalog?.(region) ?? [], pool.preferences(region).targetModelId);
4612
+ const preferences = pool.preferences(region);
4613
+ const catalog = pool.catalog?.(region) ?? [];
4614
+ /**
4615
+ * The model ONE member is tested on (issue #61).
4616
+ *
4617
+ * Per account, because a test model is a per-account choice: `6004` is
4618
+ * an account × model condition, so a batch that tested every member on
4619
+ * one model would report "可用" for an account whose own model is the
4620
+ * one that is limited. Resolution still runs through
4621
+ * `resolveTargetModel`, so a stale override is reported, never
4622
+ * silently swapped.
4623
+ */
4624
+ const targetFor = (accountId) => resolveTargetModel(catalog, targetModelPreference(preferences, accountId));
4625
+ const target = resolveTargetModel(catalog, targetModelPreference(preferences, void 0));
4297
4626
  if (target.modelId === void 0) return json(res, 409, {
4298
4627
  action: "test",
4299
4628
  modelId: void 0,
@@ -4315,7 +4644,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
4315
4644
  modelId: target.modelId
4316
4645
  });
4317
4646
  try {
4318
- await pool.test(region, target.modelId, (row) => {
4647
+ await pool.test(region, targetFor, (row) => {
4319
4648
  writeLine({ row });
4320
4649
  });
4321
4650
  writeLine({ done: true });
@@ -4653,6 +4982,7 @@ const modelConfig = z.object({
4653
4982
  const poolConfig = z.object({
4654
4983
  enabled: z.boolean().default(false).description("Whether this region's account pool is active (opt-in). While on, a failed chat request is retried against the other usable pool members before the error is reported."),
4655
4984
  targetModelId: z.string().default("").description("Model id to test; empty means pick a zero-multiplier model from this region's catalog"),
4985
+ targetModelIdByAccount: z.dict(z.string()).default({}).description("Per-account test model, keyed by account id; an account with no entry uses targetModelId. Use this when an account must be tested on the model you actually chat with, because 6004 is an account-by-model condition."),
4656
4986
  /**
4657
4987
  * Sizing of the test probe, as a token count the user picks from a fixed set.
4658
4988
  *
@@ -4910,6 +5240,9 @@ function apply(ctx, config) {
4910
5240
  * traffic once the provider is registered, which happens after this whole
4911
5241
  * setup completes.
4912
5242
  */
5243
+ onRequestBody: (body) => {
5244
+ recordBodyReport(region, body, { onError: (error) => ctx.logger.warn("dsh-connect-workbuddy: body report failed", error) });
5245
+ },
4913
5246
  prepareAccount: () => applyPoolSelection(region),
4914
5247
  failoverAccount: (triedAccountIds) => failoverAccountFor(region, triedAccountIds),
4915
5248
  onAccountFailure: (accountId, failure) => {
@@ -5140,6 +5473,27 @@ function apply(ctx, config) {
5140
5473
  return effectiveMembersOf(saved, new Set(accounts.map((account) => account.id)));
5141
5474
  },
5142
5475
  /**
5476
+ * Last measured display name per saved member id (issue #60).
5477
+ *
5478
+ * Answers the one question the card cannot answer itself: a saved member
5479
+ * whose sign-in is gone has no row in the roster, so the name the pool
5480
+ * once saw is the only thing that can identify it. Read from the runtime
5481
+ * probe store — no upstream call, and no credential touched.
5482
+ *
5483
+ * Best-effort, and deliberately NOT filtered to ghosts here: the route
5484
+ * knows which ids are unlisted (it built the roster) and names only
5485
+ * those, so this stays a plain lookup with one meaning.
5486
+ */
5487
+ memberNames: async (region) => {
5488
+ const probes = await readPoolProbes(region);
5489
+ const names = {};
5490
+ for (const [accountId, probe] of Object.entries(probes)) {
5491
+ if (probe.accountName === void 0 || probe.accountName === "") continue;
5492
+ names[accountId] = probe.accountName;
5493
+ }
5494
+ return names;
5495
+ },
5496
+ /**
5143
5497
  * Local sign-ins the pool does NOT cover.
5144
5498
  *
5145
5499
  * Sent so the card can offer them to check in. Membership is an explicit
@@ -5222,7 +5576,7 @@ function apply(ctx, config) {
5222
5576
  * rather than pretending a test ran.
5223
5577
  */
5224
5578
  testOne: async (region, accountId) => {
5225
- const target = regionTargetModel(region);
5579
+ const target = regionTargetModel(region, accountId);
5226
5580
  if (target === void 0) return void 0;
5227
5581
  const store = stacks[region].store;
5228
5582
  const credential = await store.credentialFor(accountId).catch(() => void 0);
@@ -5497,6 +5851,7 @@ function apply(ctx, config) {
5497
5851
  outcome,
5498
5852
  atMs: Date.now(),
5499
5853
  source: "live-request",
5854
+ ...failure.modelId === void 0 ? {} : { modelId: failure.modelId },
5500
5855
  ...retryAtMs === void 0 ? {} : { retryAtMs },
5501
5856
  ...message === "" ? {} : { message }
5502
5857
  } });
@@ -5587,11 +5942,12 @@ function apply(ctx, config) {
5587
5942
  /**
5588
5943
  * The model a per-row test would use right now: the SAME resolution the
5589
5944
  * batch route performs (`resolveTargetModel` over the region's displayed
5590
- * catalog and the saved preference), so a row test and a batch test never
5591
- * measure different models. Undefined mirrors the route's 409 states.
5945
+ * catalog and the saved preference — including a per-account override,
5946
+ * issue #61), so a row test and a batch test never measure different models.
5947
+ * Undefined mirrors the route's 409 states.
5592
5948
  */
5593
- const regionTargetModel = (region) => {
5594
- return resolveTargetModel(displayModels(current(), region), poolPreferencesOf(current(), region).targetModelId).modelId;
5949
+ const regionTargetModel = (region, accountId) => {
5950
+ return resolveTargetModel(displayModels(current(), region), targetModelPreference(poolPreferencesOf(current(), region), accountId)).modelId;
5595
5951
  };
5596
5952
  /** One region's pool preferences, with the schema's defaults applied. */
5597
5953
  const poolPreferencesOf = (config, region) => {
@@ -5600,6 +5956,7 @@ function apply(ctx, config) {
5600
5956
  return {
5601
5957
  enabled: pool?.enabled === true,
5602
5958
  targetModelId: pool?.targetModelId ?? "",
5959
+ targetModelIdByAccount: { ...pool?.targetModelIdByAccount },
5603
5960
  ...probeInputTokens === void 0 ? {} : { probeInputTokens },
5604
5961
  memberAccountIds: pool?.memberAccountIds ?? []
5605
5962
  };
@@ -5774,4 +6131,4 @@ function apply(ctx, config) {
5774
6131
  });
5775
6132
  }
5776
6133
  //#endregion
5777
- export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
6134
+ export { CREDENTIAL_REJECTED_CODE, Config, DEFAULT_PROBE_INPUT_TOKENS, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKEN_CHOICES, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, REFUSES_OFF_MODEL_IDS, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, defaultOffModelIds, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, knownRegionOf, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, modelIdOfBody, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, offDefaultFor, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withEffectiveOff, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-connect-workbuddy",
3
3
  "displayName": "DSH Connect WorkBuddy",
4
4
  "description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
5
- "version": "4.4.4",
5
+ "version": "4.4.6",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",