dsh-connect-workbuddy 4.14.1 → 5.0.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.
@@ -1026,6 +1026,269 @@ function imageDefaultFor(info) {
1026
1026
  if (info.supportsImages === false) return false;
1027
1027
  return NATIVE_MODALITY_BY_MODEL_ID[info.id] === "multimodal";
1028
1028
  }
1029
+ /** Everything the app treats as a queue condition (`isQueueError`). */
1030
+ const WORKBUDDY_QUEUE_CODES = [
1031
+ 6020,
1032
+ 6021,
1033
+ 6022
1034
+ ];
1035
+ /** The app's default when the upstream sends no `retry_after` (`?? 5`). */
1036
+ const WORKBUDDY_QUEUE_DEFAULT_RETRY_SEC = 5;
1037
+ /** True when a business code is one of the app's three queue codes. */
1038
+ function isQueueCode(value) {
1039
+ return typeof value === "number" && WORKBUDDY_QUEUE_CODES.includes(value);
1040
+ }
1041
+ /** Read one queue condition out of a `data`-shaped object, or `undefined`. */
1042
+ function reportFrom(code, data) {
1043
+ if (!isQueueCode(code)) return void 0;
1044
+ const body = typeof data === "object" && data !== null ? data : {};
1045
+ const numberField = (key) => typeof body[key] === "number" && Number.isFinite(body[key]) ? body[key] : void 0;
1046
+ const position = numberField("queue_position");
1047
+ const size = numberField("queue_size");
1048
+ const retryAfter = numberField("retry_after");
1049
+ return {
1050
+ code,
1051
+ ...position === void 0 ? {} : { position },
1052
+ ...size === void 0 ? {} : { size },
1053
+ retryAfterSec: retryAfter !== void 0 && retryAfter > 0 ? retryAfter : WORKBUDDY_QUEUE_DEFAULT_RETRY_SEC
1054
+ };
1055
+ }
1056
+ /**
1057
+ * Read a queue condition out of one upstream body.
1058
+ *
1059
+ * Three shapes are accepted, because the gateway wraps the same fact three ways
1060
+ * depending on which channel answered:
1061
+ *
1062
+ * 1. the chat endpoint's own error envelope — `{"code":6020,"data":{…}}`;
1063
+ * 2. the app's agent-protocol envelope, where the condition rides as a JSON
1064
+ * STRING inside `_meta['codebuddy.ai/errorMessage']`
1065
+ * (`{"code":6020,"data":{…},"message":"…"}` — see the app's
1066
+ * `extractQueueInfoFromPromptResult`);
1067
+ * 3. a nested `data.code`, for a gateway that wraps one envelope in another.
1068
+ *
1069
+ * Anything else — a 14003 throttle, a quota refusal, a 500 — is NOT a queue and
1070
+ * returns `undefined`, so this can sit in the error path without changing it.
1071
+ */
1072
+ function parseQueueAnswer(text) {
1073
+ let parsed;
1074
+ try {
1075
+ parsed = JSON.parse(text);
1076
+ } catch {
1077
+ return;
1078
+ }
1079
+ const direct = readEnvelope$1(parsed);
1080
+ if (direct !== void 0) return direct;
1081
+ if (typeof parsed === "object" && parsed !== null) {
1082
+ const meta = parsed["_meta"];
1083
+ if (typeof meta === "object" && meta !== null) {
1084
+ const message = meta["codebuddy.ai/errorMessage"];
1085
+ if (typeof message === "string") try {
1086
+ return readEnvelope$1(JSON.parse(message));
1087
+ } catch {
1088
+ return;
1089
+ }
1090
+ }
1091
+ }
1092
+ }
1093
+ /** `{code, data}` at the top level, or one `data` level down. */
1094
+ function readEnvelope$1(value) {
1095
+ if (typeof value !== "object" || value === null) return void 0;
1096
+ const envelope = value;
1097
+ const top = reportFrom(envelope["code"], envelope["data"]);
1098
+ if (top !== void 0) return top;
1099
+ const data = envelope["data"];
1100
+ if (typeof data === "object" && data !== null) {
1101
+ const inner = data;
1102
+ return reportFrom(inner["code"], inner["data"] ?? inner);
1103
+ }
1104
+ }
1105
+ /**
1106
+ * The sentence a queue condition is reported with — the app's own vocabulary
1107
+ * ("排队", "位"), and never the upstream's untranslated `message` key.
1108
+ *
1109
+ * `waiting` names both numbers because they answer different questions: the
1110
+ * POSITION is how long the wait is, the SIZE is whether it is moving at all.
1111
+ */
1112
+ function queueNoticeText(report) {
1113
+ if (report.code === 6020) return `模型排队中:你排在第 ${report.position === void 0 ? "未知" : `${report.position}`} 位${report.size === void 0 ? "" : `,队列共 ${report.size} 个请求`};约 ${report.retryAfterSec} 秒后自动重试`;
1114
+ if (report.code === 6021) return `模型队列已满${report.size === void 0 ? "" : `(共 ${report.size} 个请求排队)`},现在无法排队;请稍后重试或换一个模型`;
1115
+ return `你的排队任务已达上限${report.size === void 0 ? "" : `(共 ${report.size} 个请求排队)`},请等待已排队请求完成后再试`;
1116
+ }
1117
+ //#endregion
1118
+ //#region src/image-gen.ts
1119
+ /**
1120
+ * WorkBuddy 生图的纯逻辑层:媒体模型筛选、请求体构造、响应解析、失败描述。
1121
+ *
1122
+ * 这一层刻意不做任何网络 I/O、不引用宿主的任何包。`src/upstream.ts` 会被
1123
+ * `src/bin.ts` 静态可达(见 tests/host-peers.spec.ts 对 lib/bin.js 可达图的断言:
1124
+ * 除 `node:` 外不得出现任何裸说明符),所以任何被它引用的模块都必须保持零外部依赖。
1125
+ *
1126
+ * 上游契约照抄官方 CLI `codebuddy-lite-wb.mjs` 的 ImageService(实测 2026-10-21,
1127
+ * 国内区 `copilot.tencent.com` 与 `www.workbuddy.cn` 两个入口逐字段同答):
1128
+ *
1129
+ * POST {endpoint}/v2/images/generations
1130
+ * body = { model, prompt, size, ...按 model 前缀分支的字段 }
1131
+ * resp = { code, msg, request_id?, data: { created, data: [{ url } | { b64_json }],
1132
+ * usage: { credit } } }
1133
+ *
1134
+ * 前缀分支(`buildBaseRequestBody` 原文行为):
1135
+ * - `hunyuan-*`:额外只带 `n`(默认 1)/ `footnote` / `revise`
1136
+ * - `gemini-*` :什么都不加
1137
+ * - 其它 :`response_format: 'b64_json'` + 可选 `n` / `quality` / `style` / `background`
1138
+ *
1139
+ * 国内区产品配置里带 `text-to-image` 标签的只有 `hunyuan-image-alpha`,
1140
+ * 带 `image-to-image` 的只有 `hunyuan-image-alpha-edit`,因此实际走的就是
1141
+ * hunyuan 分支、响应里是 COS 签名 URL(24h 有效),单张约 5.71 credits。
1142
+ *
1143
+ * @module dsh-connect-workbuddy/image-gen
1144
+ */
1145
+ /** 产品配置里「文生图」模型的标签。 */
1146
+ const IMAGE_TAG_TEXT_TO_IMAGE = "text-to-image";
1147
+ /** 官方 CLI 的默认尺寸。 */
1148
+ const DEFAULT_IMAGE_SIZE = "1024x1024";
1149
+ /** 生图路由。 */
1150
+ const IMAGE_GENERATIONS_PATH = "/v2/images/generations";
1151
+ /** 生图链路的错误。带一个稳定的 `code`,避免调用方去匹配文案。 */
1152
+ var WorkBuddyImageError = class extends Error {
1153
+ code;
1154
+ constructor(message, code) {
1155
+ super(message);
1156
+ this.name = "WorkBuddyImageError";
1157
+ this.code = code;
1158
+ }
1159
+ };
1160
+ function asString(value) {
1161
+ return typeof value === "string" && value.trim() !== "" ? value : void 0;
1162
+ }
1163
+ function stringList(value) {
1164
+ if (!Array.isArray(value)) return [];
1165
+ const out = [];
1166
+ for (const item of value) {
1167
+ const text = asString(item);
1168
+ if (text !== void 0) out.push(text);
1169
+ }
1170
+ return out;
1171
+ }
1172
+ /**
1173
+ * 从产品配置的 `data.models` 里挑出带指定 tag 的媒体模型。
1174
+ *
1175
+ * 去重按 `id`:上游目录里同一个 id 只应出现一次,但目录是外部数据,
1176
+ * 重复条目不该让卡片或工具参数里出现两个同名选项。
1177
+ */
1178
+ function selectMediaModels(rawModels, tag = IMAGE_TAG_TEXT_TO_IMAGE) {
1179
+ if (!Array.isArray(rawModels)) return [];
1180
+ const out = [];
1181
+ const seen = /* @__PURE__ */ new Set();
1182
+ for (const entry of rawModels) {
1183
+ if (typeof entry !== "object" || entry === null) continue;
1184
+ const record = entry;
1185
+ const id = asString(record["id"]);
1186
+ if (id === void 0 || seen.has(id)) continue;
1187
+ const tags = stringList(record["tags"]);
1188
+ if (!tags.includes(tag)) continue;
1189
+ seen.add(id);
1190
+ out.push({
1191
+ id,
1192
+ name: asString(record["name"]) ?? id,
1193
+ tags
1194
+ });
1195
+ }
1196
+ return out;
1197
+ }
1198
+ /**
1199
+ * 选出生图模型:优先用调用方点名的那个,否则用目录里的第一个。
1200
+ *
1201
+ * 点名的模型必须在目录里——不在目录里就直接报错,而不是把它发上去让上游
1202
+ * 回一个「route config not found」。这样报错信息能指出真正的问题(模型名写错
1203
+ * 或该区没有这个模型),而不是一句上游 404。
1204
+ */
1205
+ function pickImageModel(models, requested) {
1206
+ if (requested !== void 0 && requested !== "") {
1207
+ const found = models.find((model) => model.id === requested);
1208
+ if (found === void 0) {
1209
+ const known = models.map((model) => model.id).join(", ");
1210
+ throw new WorkBuddyImageError(`workbuddy image: 目录里没有生图模型「${requested}」。可用:${known === "" ? "(空)" : known}`, "NO_IMAGE_MODEL");
1211
+ }
1212
+ return found;
1213
+ }
1214
+ const first = models[0];
1215
+ if (first === void 0) throw new WorkBuddyImageError("workbuddy image: 该区账号的产品配置里没有任何带 text-to-image 标签的模型。国内区应有 hunyuan-image-alpha;国际区当前没有生图模型。", "NO_IMAGE_MODEL");
1216
+ return first;
1217
+ }
1218
+ /** 按官方 CLI 的 `buildBaseRequestBody` 分支规则拼请求体。 */
1219
+ function buildImageBody(request) {
1220
+ const model = request.model;
1221
+ const body = {
1222
+ model,
1223
+ prompt: request.prompt,
1224
+ size: request.size ?? "1024x1024"
1225
+ };
1226
+ if (model.startsWith("hunyuan-")) {
1227
+ body["n"] = request.n ?? 1;
1228
+ return body;
1229
+ }
1230
+ if (model.startsWith("gemini-")) return body;
1231
+ body["response_format"] = "b64_json";
1232
+ if (request.n !== void 0) body["n"] = request.n;
1233
+ return body;
1234
+ }
1235
+ /**
1236
+ * 解析生图响应。
1237
+ *
1238
+ * 信封是 `{code, msg, request_id, data}`;`code !== 0` 是业务失败(与 HTTP 状态
1239
+ * 无关,上游可能 HTTP 200 但 code 非 0)。`data.data` 是数组,元素要么带 `url`
1240
+ * 要么带 `b64_json`;`data.usage.credit` 是本次实际扣的积分。
1241
+ */
1242
+ function parseImageResponse(payload) {
1243
+ if (typeof payload !== "object" || payload === null) throw new WorkBuddyImageError("workbuddy image: 上游返回的不是 JSON 对象", "UPSTREAM");
1244
+ const envelope = payload;
1245
+ const code = typeof envelope["code"] === "number" ? envelope["code"] : void 0;
1246
+ if (code !== void 0 && code !== 0) {
1247
+ const message = asString(envelope["msg"]) ?? "unknown error";
1248
+ const requestId = asString(envelope["request_id"]);
1249
+ throw new WorkBuddyImageError(`workbuddy image: 上游拒绝(code ${code}): ${message}` + (requestId === void 0 ? "" : `(request_id ${requestId})`), "UPSTREAM");
1250
+ }
1251
+ const data = envelope["data"];
1252
+ if (typeof data !== "object" || data === null) throw new WorkBuddyImageError("workbuddy image: 响应缺少 data 字段", "UPSTREAM");
1253
+ const record = data;
1254
+ const items = Array.isArray(record["data"]) ? record["data"] : [];
1255
+ const first = items.length > 0 ? items[0] : void 0;
1256
+ let url;
1257
+ let b64;
1258
+ if (typeof first === "object" && first !== null) {
1259
+ const item = first;
1260
+ url = asString(item["url"]);
1261
+ b64 = asString(item["b64_json"]);
1262
+ }
1263
+ if (url === void 0 && b64 === void 0) throw new WorkBuddyImageError("workbuddy image: 上游返回成功但 data.data 里既没有 url 也没有 b64_json", "EMPTY_RESULT");
1264
+ const usage = typeof record["usage"] === "object" && record["usage"] !== null ? record["usage"] : {};
1265
+ const credit = typeof usage["credit"] === "number" && Number.isFinite(usage["credit"]) ? usage["credit"] : void 0;
1266
+ const created = typeof record["created"] === "number" ? record["created"] : void 0;
1267
+ return {
1268
+ ...url === void 0 ? {} : { url },
1269
+ ...b64 === void 0 ? {} : { b64 },
1270
+ ...created === void 0 ? {} : { created },
1271
+ ...credit === void 0 ? {} : { credit }
1272
+ };
1273
+ }
1274
+ /** 把一次非 2xx 的响应翻成带 `code` 的错误,好让上层决定怎么提示。 */
1275
+ function describeImageFailure(status, bodyText) {
1276
+ const text = bodyText.trim();
1277
+ let detail = text.slice(0, 240);
1278
+ if (text.startsWith("{")) try {
1279
+ const parsed = JSON.parse(text);
1280
+ if (typeof parsed === "object" && parsed !== null) {
1281
+ const record = parsed;
1282
+ const message = asString(record["msg"]);
1283
+ const code = typeof record["code"] === "number" ? record["code"] : void 0;
1284
+ if (message !== void 0) detail = code === void 0 ? message : `code ${code}: ${message}`;
1285
+ }
1286
+ } catch {}
1287
+ if (status === 401 || status === 403) return new WorkBuddyImageError(`workbuddy image: 凭据被上游拒绝(HTTP ${status}),请在插件设置里重新登录 WorkBuddy。${detail === "" ? "" : ` ${detail}`}`, "AUTH");
1288
+ if (status === 429) return new WorkBuddyImageError(`workbuddy image: 触发上游限流(HTTP 429),稍后再试。${detail === "" ? "" : ` ${detail}`}`, "RATE_LIMIT");
1289
+ if (status === 404) return new WorkBuddyImageError(`workbuddy image: 上游没有这条生图路由(HTTP 404)。全球区没有生图模型,请用国内区账号。${detail === "" ? "" : ` ${detail}`}`, "NO_ROUTE");
1290
+ return new WorkBuddyImageError(`workbuddy image: 上游失败(HTTP ${status})${detail === "" ? "" : `: ${detail}`}`, "UPSTREAM");
1291
+ }
1029
1292
  //#endregion
1030
1293
  //#region src/off-thinking.ts
1031
1294
  /**
@@ -1353,6 +1616,126 @@ function attachPromos(models, promos) {
1353
1616
  });
1354
1617
  }
1355
1618
  //#endregion
1619
+ //#region src/busy.ts
1620
+ /** The upstream's enum, in the order its own bundle declares it. */
1621
+ const WORKBUDDY_BUSY_LEVELS = [
1622
+ "idle",
1623
+ "normal",
1624
+ "busy",
1625
+ "congested",
1626
+ "unavailable",
1627
+ "unknown"
1628
+ ];
1629
+ /** Coerce one `busy_level` value; everything the enum does not name is `unknown`. */
1630
+ function normalizeBusyLevel(value) {
1631
+ return typeof value === "string" && WORKBUDDY_BUSY_LEVELS.includes(value) ? value : "unknown";
1632
+ }
1633
+ /**
1634
+ * Read one probe answer into a model-id → level map.
1635
+ *
1636
+ * Tolerant by design: an entry that is not an object, or that names no model, is
1637
+ * skipped; a level the enum does not know becomes `unknown` rather than being
1638
+ * dropped, because "the upstream answered about this model but said something
1639
+ * we do not understand" is still a fact about that model.
1640
+ */
1641
+ function parseBusyLevels(entries) {
1642
+ const levels = /* @__PURE__ */ new Map();
1643
+ for (const entry of Array.isArray(entries) ? entries : []) {
1644
+ if (typeof entry !== "object" || entry === null) continue;
1645
+ const raw = entry;
1646
+ const id = typeof raw["model"] === "string" ? raw["model"] : "";
1647
+ if (id === "") continue;
1648
+ levels.set(id, normalizeBusyLevel(raw["busy_level"]));
1649
+ }
1650
+ return levels;
1651
+ }
1652
+ /**
1653
+ * Split ids into probe-sized batches, de-duplicated and order-preserving.
1654
+ *
1655
+ * De-duplication matters: the roster the plugin sends is the `cli` agent's
1656
+ * roster PLUS the user's kept ids, so the same id can legitimately appear twice,
1657
+ * and the upstream counts what it is asked for.
1658
+ */
1659
+ function batchModelIds(ids, size = 50) {
1660
+ const unique = [...new Set(ids.filter((id) => id !== ""))];
1661
+ const batches = [];
1662
+ for (let index = 0; index < unique.length; index += size) batches.push(unique.slice(index, index + size));
1663
+ return batches;
1664
+ }
1665
+ /** The app's own Chinese wording, per level. `normal` and `unknown` have none. */
1666
+ const BUSY_LABELS_ZH = {
1667
+ idle: "空闲",
1668
+ busy: "轻微拥堵",
1669
+ congested: "极度拥堵",
1670
+ unavailable: "不可用"
1671
+ };
1672
+ /** The app's own English wording, per level. */
1673
+ const BUSY_LABELS_EN = {
1674
+ idle: "Idle",
1675
+ busy: "Slightly congested",
1676
+ congested: "Extremely congested",
1677
+ unavailable: "Unavailable"
1678
+ };
1679
+ /**
1680
+ * The word this level is drawn with, or `undefined` when it is not worth drawing.
1681
+ *
1682
+ * `normal` returns `undefined` on purpose: the app's selector shows a busy line
1683
+ * only for a level that differs from the healthy default, and a badge that reads
1684
+ * "正常" on fifteen rows out of twenty is noise, not information. `unknown`
1685
+ * returns `undefined` for the same reason plus one more — the plugin must not
1686
+ * invent a claim the upstream never made.
1687
+ */
1688
+ function busyLabel(level, locale = "zh") {
1689
+ return (locale === "en" ? BUSY_LABELS_EN : BUSY_LABELS_ZH)[level];
1690
+ }
1691
+ /**
1692
+ * Suffix appended to a model's DSH-facing display name, prefixed with a middle
1693
+ * dot, or `''` when the level is not worth drawing.
1694
+ *
1695
+ * This is the "badge to the RIGHT of the model name" the DSH model selector
1696
+ * already does for its own variants (`deepseek-v4.1-flash · 1017`): DSH renders
1697
+ * `name` verbatim and offers no separate badge slot to a provider, so the only
1698
+ * way to put load beside the name — instead of in a second line, or inside the
1699
+ * name — is to extend the name. The name stays display-only: every DSH-side join
1700
+ * keys on the model ID, and `workBuddyDisplayName` is the single place that
1701
+ * builds it.
1702
+ *
1703
+ * ONLY A LEVEL THAT ASKS FOR A DECISION IS DRAWN — `busy`, `congested`,
1704
+ * `unavailable`. `idle` deliberately gets no suffix here even though it has a
1705
+ * word: on the measured CN roster seven of twenty models are idle, and a picker
1706
+ * whose every row carries a badge reads as decoration rather than as a warning.
1707
+ * That is also how the app's own selector behaves in its compact mode, where only
1708
+ * `congested` is drawn at all. The card's detail panel is where the full scale
1709
+ * belongs.
1710
+ */
1711
+ function busyDisplaySuffix(level, locale = "zh") {
1712
+ if (level === void 0 || !DRAWN_IN_PICKER.has(level)) return "";
1713
+ const label = busyLabel(level, locale);
1714
+ return label === void 0 ? "" : ` · ${label}`;
1715
+ }
1716
+ /** The levels that change a model-picking decision; see {@link busyDisplaySuffix}. */
1717
+ const DRAWN_IN_PICKER = /* @__PURE__ */ new Set([
1718
+ "busy",
1719
+ "congested",
1720
+ "unavailable"
1721
+ ]);
1722
+ /**
1723
+ * Attach each model's level to its catalog entry.
1724
+ *
1725
+ * A model the probe did not answer for keeps whatever it had — which, for a
1726
+ * freshly discovered roster, is nothing. Models are copied, never mutated: the
1727
+ * catalog entry is shared with the settings card's saved snapshot.
1728
+ */
1729
+ function attachBusyLevels(models, levels) {
1730
+ return models.map((model) => {
1731
+ const level = levels.get(model.id);
1732
+ return level === void 0 || level === model.busyLevel ? model : {
1733
+ ...model,
1734
+ busyLevel: level
1735
+ };
1736
+ });
1737
+ }
1738
+ //#endregion
1356
1739
  //#region src/upstream.ts
1357
1740
  /**
1358
1741
  * WorkBuddy (CodeBuddy / copilot.tencent.com) upstream client: chat streaming,
@@ -1429,6 +1812,22 @@ const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
1429
1812
  */
1430
1813
  const GLOBAL_CONFIG_PATH = "/v3/config";
1431
1814
  /**
1815
+ * The app's model-load probe (see `src/busy.ts`), read-only and free.
1816
+ *
1817
+ * Not part of any catalog document: this endpoint answers ONE fact per model id
1818
+ * — how busy it is right now — and answers it for up to 50 ids at a time. It is
1819
+ * host- and identity-insensitive on the CN side (measured 2026-10-21 on all
1820
+ * three CN hosts, under both the CLI and the app user agent: byte-identical),
1821
+ * so it is asked as the chat channel's own identity.
1822
+ */
1823
+ const MODEL_BUSY_PATH = "/v2/chat/busy";
1824
+ /**
1825
+ * The app's own ceiling for one probe (`timeout: 5e3` in its
1826
+ * `useModelBusyLevels`). Short on purpose: a slow load probe is worth less than
1827
+ * the refresh it is attached to, and the catalog must not wait on it.
1828
+ */
1829
+ const MODEL_BUSY_TIMEOUT_MS = 5e3;
1830
+ /**
1432
1831
  * The chat channel's user agent — the identity the plugin's adapter speaks as.
1433
1832
  *
1434
1833
  * Chat is a different contract from configuration: the conversation-identity
@@ -1492,6 +1891,15 @@ const ERROR_BODY_LIMIT = 4096;
1492
1891
  */
1493
1892
  const PROBE_TIMEOUT_MS = 1e4;
1494
1893
  /**
1894
+ * 生图请求的超时。
1895
+ *
1896
+ * 实测一张 1024x1024 的图在官方网关要 17–39 秒(`hunyuan-image-alpha`,
1897
+ * 2026-10-21,两个国内入口各测一次),比任何 JSON 接口都慢一个数量级,
1898
+ * 因此不能复用 {@link JSON_TIMEOUT_MS}。三分钟足够覆盖一次正常的排队+推理,
1899
+ * 又不至于让一个卡死的连接占住工具槽位太久。
1900
+ */
1901
+ const IMAGE_TIMEOUT_MS = 18e4;
1902
+ /**
1495
1903
  * Insufficient-credit markers.
1496
1904
  *
1497
1905
  * ENGLISH IS MATCHED CASE-INSENSITIVELY (see {@link classifyUpstreamError}) so
@@ -1598,8 +2006,15 @@ const CHANNEL_REJECT_MARKERS = ["unapproved channel", "illegal api invocation"];
1598
2006
  * be read as the prompt rule: a confidently wrong diagnosis is worse here than
1599
2007
  * the generic `client` reply, because it sends the user to rewrite a system
1600
2008
  * prompt that is not at fault.
2009
+ *
2010
+ * Each is also exported as its own predicate ({@link isConversationIdGateBody},
2011
+ * {@link isFirstMessageGateBody}) so the reply can NAME the gate instead of
2012
+ * relaying the upstream's `displayTips` — which is the SAME misleading
2013
+ * sentence for all three members of the `11128` family.
1601
2014
  */
1602
- const OTHER_CHANNEL_CODE_GATES = ["conversation id missing", "first message is not system prompt"];
2015
+ const CONVERSATION_ID_GATE_MARKER = "conversation id missing";
2016
+ const FIRST_MESSAGE_GATE_MARKER = "first message is not system prompt";
2017
+ const OTHER_CHANNEL_CODE_GATES = [CONVERSATION_ID_GATE_MARKER, FIRST_MESSAGE_GATE_MARKER];
1603
2018
  /**
1604
2019
  * Whether a failure body is the MODEL-scoped throttle rather than an account
1605
2020
  * limit.
@@ -1637,52 +2052,57 @@ function isChannelRejectBody(body) {
1637
2052
  return CHANNEL_REJECT_MARKERS.some((marker) => lower.includes(marker));
1638
2053
  }
1639
2054
  /**
1640
- * The OPENINGS of the `system` messages in a prepared request body.
2055
+ * Whether a failure body is the CONVERSATION-ID gate (`11128`).
1641
2056
  *
1642
- * This is the evidence the channel gate actually reacts to, and echoing it is
1643
- * what turns "the upstream refused" into "the upstream refused because of this
1644
- * line" — the reader can then fix the prompt or report the new opening, instead
1645
- * of cycling accounts that cannot change a byte of it.
2057
+ * One of the two gates that share the channel gate's business code, and one
2058
+ * of the two this plugin's diagnosis EXCLUDES: the prompt is not at fault
2059
+ * here, the request headers are — which is why it must never be reported as
2060
+ * "the gateway refused your system prompt".
1646
2061
  *
1647
- * Each entry is the first non-empty line, clipped: a fingerprint lives at the
1648
- * START of the content, so the rest of a 10 KB system prompt is noise that
1649
- * would bury the one sentence worth reading. At most three entries are kept —
1650
- * the gate scans every `system` message, but a body with more than three of them
1651
- * is already worth showing compactly.
2062
+ * It is exported anyway, so a reply can NAME it. The upstream's `displayTips`
2063
+ * for all three `11128` gates is the identical sentence ("这次调用没通过渠道
2064
+ * 校验,可能不是官方客户端发出的"), so the tips cannot tell them apart — only
2065
+ * `msg` can. Hence the wording is the criterion and the code is the
2066
+ * corroboration, never the other way round.
1652
2067
  *
1653
- * Never throws: a malformed body is the upstream's to reject, and losing the
1654
- * failure measurement is not an acceptable price for reading an excerpt.
2068
+ * BOTH halves are required, and the code is matched in its quoted-key form
2069
+ * (`"code":11128`): a request id is hexadecimal and can contain the digits
2070
+ * `11128` by coincidence, and a bare substring search over an arbitrary
2071
+ * failure body is how a red herring gets promoted into a diagnosis.
1655
2072
  */
1656
- function systemOpeningsOfBody(bodyJson) {
1657
- let parsed;
1658
- try {
1659
- parsed = JSON.parse(bodyJson);
1660
- } catch {
1661
- return [];
1662
- }
1663
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
1664
- const messages = parsed["messages"];
1665
- if (!Array.isArray(messages)) return [];
1666
- const openings = [];
1667
- for (const value of messages) {
1668
- if (openings.length >= 3) break;
1669
- if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
1670
- const message = value;
1671
- const role = message["role"];
1672
- if (typeof role !== "string" || role.trim().toLowerCase() !== "system") continue;
1673
- const content = message["content"];
1674
- if (typeof content !== "string") continue;
1675
- const opening = firstLineOf(content).replace(/\s+/gu, " ").trim();
1676
- if (opening !== "") openings.push(clip(opening, SYSTEM_OPENING_LIMIT));
1677
- }
1678
- return openings;
2073
+ function isConversationIdGateBody(body) {
2074
+ const lower = body.toLowerCase();
2075
+ return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(CONVERSATION_ID_GATE_MARKER);
1679
2076
  }
2077
+ /**
2078
+ * Whether a failure body is the FIRST-MESSAGE gate (`11128`).
2079
+ *
2080
+ * The global gateway's own rule: a chat request must OPEN with a system
2081
+ * message, and a user-first body is refused with `11128` / "first message is
2082
+ * not system prompt". The plugin's `ensureSystemHead()` satisfies it, so a
2083
+ * body carrying this wording says the request never had a system head — not
2084
+ * that a system prompt was fingerprinted.
2085
+ *
2086
+ * Excluded from the prompt diagnosis for the same reason as
2087
+ * {@link isConversationIdGateBody}, and named by the same rule: the CODE plus
2088
+ * the WORDING, case-insensitively, code in its quoted-key form.
2089
+ */
2090
+ function isFirstMessageGateBody(body) {
2091
+ const lower = body.toLowerCase();
2092
+ return lower.includes(CHANNEL_REJECT_CODE) && lower.includes(FIRST_MESSAGE_GATE_MARKER);
2093
+ }
2094
+ /**
2095
+ * The OPENINGS of the `system` messages in a prepared request body.
2096
+ *
2097
+ * REMOVED in favour of {@link systemOpeningEvidenceOfBody}, which answers the
2098
+ * same question ("what did the gate actually see?") for BOTH string and
2099
+ * array-shaped content and can also say whether an opening is the plugin's own
2100
+ * neutral head. Two functions that both claim to describe what the gate read
2101
+ * are two truths that drift — and the array-shaped case is exactly where the
2102
+ * old one was blind.
2103
+ */
1680
2104
  /** Longest system-prompt opening echoed back to the user, in characters. */
1681
2105
  const SYSTEM_OPENING_LIMIT = 120;
1682
- function firstLineOf(content) {
1683
- const newline = content.indexOf("\n");
1684
- return newline === -1 ? content : content.slice(0, newline);
1685
- }
1686
2106
  function clip(text, limit) {
1687
2107
  return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`;
1688
2108
  }
@@ -1696,6 +2116,7 @@ function classifyUpstreamError(status, body) {
1696
2116
  for (const marker of POLICY_REJECT_MARKERS) if (body.includes(marker)) return "policy_reject";
1697
2117
  if (isChannelRejectBody(body)) return "channel_reject";
1698
2118
  if (isModelBusyBody(body)) return "model_busy";
2119
+ if (parseQueueAnswer(body) !== void 0) return "model_queue";
1699
2120
  if (status === 429) return "soft_rate";
1700
2121
  if (status === 404) return "not_found";
1701
2122
  if (status >= 500) return "server";
@@ -1923,6 +2344,31 @@ function billingHeaders(credential) {
1923
2344
  return headers;
1924
2345
  }
1925
2346
  /**
2347
+ * 生图请求头。
2348
+ *
2349
+ * 实测口径(2026-10-21,国内区):`Authorization: Bearer` + `X-Product: SaaS` +
2350
+ * 用户/企业/域三连 + 会话身份三连 + `PRODUCT_UA`,在 `copilot.tencent.com` 与
2351
+ * `www.workbuddy.cn` 两个入口都返回 200。刻意**不带** `Origin` / `Referer`:
2352
+ * 探针当时没带,带了只是多一个可能被网关拒掉的变量,没有收益。
2353
+ *
2354
+ * 复用 {@link conversationHeaders}:官方 CLI 的 ImageService 也是每次现生成
2355
+ * 一组会话 UUID,且两个区走同一条代码路径(见该函数的注释)。
2356
+ */
2357
+ function imageHeaders(credential) {
2358
+ return {
2359
+ "Authorization": `Bearer ${credential.accessToken}`,
2360
+ "Accept": "application/json, text/plain, */*",
2361
+ "Content-Type": "application/json",
2362
+ "User-Agent": PRODUCT_UA,
2363
+ "X-Product": "SaaS",
2364
+ "X-Requested-With": "XMLHttpRequest",
2365
+ ...credential.uid === "" ? { "X-No-User-Id": "1" } : { "X-User-Id": credential.uid },
2366
+ ...credential.enterpriseId === void 0 || credential.enterpriseId === "" ? { "X-No-Enterprise-Id": "1" } : { "X-Enterprise-Id": credential.enterpriseId },
2367
+ ...credential.domain === "" ? { "X-No-Department-Info": "1" } : { "X-Domain": credential.domain },
2368
+ ...conversationHeaders()
2369
+ };
2370
+ }
2371
+ /**
1926
2372
  * Stand-in system message for a request that reached the wire carrying none.
1927
2373
  *
1928
2374
  * Not a stylistic default: both WorkBuddy gateways want the conversation to
@@ -1980,31 +2426,389 @@ const WORKBUDDY_FALLBACK_SYSTEM_PROMPT = "You are a helpful assistant.";
1980
2426
  * snapshot, not a theory of what the gate "means"; a gateway adding a new
1981
2427
  * opening is not caught until a body carries one, and the prepend only wins
1982
2428
  * while the gate keeps matching at offset 0.
2429
+ *
2430
+ * THE LIST CANNOT BE COMPLETE, AND THAT IS WHY {@link forceNeutralHead}
2431
+ * EXISTS. This is a snapshot of the upstream's own list, taken by measuring
2432
+ * openings one at a time; the upstream can extend it at any time and the
2433
+ * plugin cannot see it. So the plugin's own prepend is a best-effort filter,
2434
+ * not a guarantee — and the self-healing retry is what covers the openings
2435
+ * nobody has measured yet.
1983
2436
  */
1984
2437
  const UNAPPROVED_CHANNEL_OPENINGS = ["you are an ai agent powered by deepseek harness", "you are claude code, anthropic's official cli for claude"];
1985
2438
  /**
1986
- * Move every `system` message off the content fingerprint above by prepending
1987
- * the placeholder head.
2439
+ * Whether this text OPENS with an opening the gateways refuse.
2440
+ *
2441
+ * The gate is a PREFIX match on the START of a `system` message, so this is
2442
+ * the one predicate that decides it — used by the request path (which prepends
2443
+ * the neutral head on a hit) and by the failure path (which names the
2444
+ * offending line).
2445
+ *
2446
+ * THREE MEASURED RULES are folded in here, all from the live CN gateway on
2447
+ * 2026-10-21 (account `ba217455…`, `deepseek-v4.1-flash`, every other request
2448
+ * field held fixed):
2449
+ *
2450
+ * 1. Only the START counts — the same sentence mid-content answers 200.
2451
+ * 2. LEADING WHITESPACE is trimmed before the comparison — " <banned>" and
2452
+ * "\n<banned>" both answer 200 today.
2453
+ * 3. The comparison is CASE-INSENSITIVE — the lower-cased sentence fails too.
2454
+ *
2455
+ * (2) is a gift that can be withdrawn, so the comparison is made the way a
2456
+ * trimming gate would make it even while the gateway is lenient: a rule that
2457
+ * only holds while the gateway forgets to trim is a rule waiting to break.
2458
+ *
2459
+ * The list itself stays the measured snapshot. It is deliberately NOT
2460
+ * extended by guesswork: an opening nobody measured is an opening nobody knows
2461
+ * the gateway refuses, and inventing entries would neutralise prompts that
2462
+ * answer 200 today.
2463
+ */
2464
+ function isUnapprovedChannelOpening(text) {
2465
+ const opening = text.replace(/^\s+/u, "").toLowerCase();
2466
+ return UNAPPROVED_CHANNEL_OPENINGS.some((prefix) => opening.startsWith(prefix));
2467
+ }
2468
+ /**
2469
+ * Whether this text already begins with the plugin's own neutral head.
1988
2470
  *
1989
- * A no-op for a body with no system message and for every system message whose
1990
- * content starts with anything else, so a request that the gateways already
1991
- * answer keeps its prompt byte-for-byte — only the messages the gate would
1992
- * refuse grow, by one short line.
2471
+ * The question the failure path has to answer per system message: "is the
2472
+ * line the gateway refused OURS, or the HOST's?" — the plugin's head is the
2473
+ * one line it has ever added to a prompt, so it is the only opening that
2474
+ * cannot be a third-party-agent fingerprint.
2475
+ */
2476
+ function startsWithNeutralHead(text) {
2477
+ return text.replace(/^\s+/u, "").toLowerCase().startsWith(WORKBUDDY_FALLBACK_SYSTEM_PROMPT.toLowerCase());
2478
+ }
2479
+ /**
2480
+ * The roles whose message OPENINGS the gateway scans.
2481
+ *
2482
+ * MEASURED on the live CN gateway (2026-10-21, every other request field held
2483
+ * fixed): `system` and `assistant` are scanned; `user` and `tool` are not (a
2484
+ * body whose `user` or `tool` content opens with the banned identity answers
2485
+ * 200). That second role is the one the plugin missed until 4.15.0, and it is
2486
+ * why issue #98's sessions stayed broken: an `assistant` history message that
2487
+ * opens with the fingerprint poisons EVERY later turn of that session, on
2488
+ * every account and in every region.
2489
+ *
2490
+ * `developer` is not listed because `prepareChatBody` rewrites it to `system`
2491
+ * before this module looks at the body — and because both gateways refuse
2492
+ * `developer` outright, so neutralising its content would not save the request.
2493
+ */
2494
+ function scansOpening(role) {
2495
+ if (typeof role !== "string") return false;
2496
+ const name = role.trim().toLowerCase();
2497
+ return name === "system" || name === "assistant";
2498
+ }
2499
+ /**
2500
+ * Move every scanned message (`system` and `assistant`) off the content
2501
+ * fingerprint above by prepending the placeholder head.
2502
+ *
2503
+ * A no-op for a body whose messages start with anything else, so a request the
2504
+ * gateways already answer keeps its prompt byte-for-byte — only the messages
2505
+ * the gate would refuse grow, by one short line.
2506
+ *
2507
+ * BOTH ROLES, because the gate reads both. Until 4.15.0 this only handled
2508
+ * `system`, and the failure that left behind is issue #98: a conversation
2509
+ * whose HISTORY contains an assistant message opening with the banned identity
2510
+ * (the model echoing its own prompt, a restored session, a compaction
2511
+ * artefact) was refused on every single turn, and no account or region could
2512
+ * change a byte of it.
2513
+ *
2514
+ * ARRAY CONTENT IS HANDLED PART BY PART, and that is not a nicety. Measured on
2515
+ * the live CN gateway (2026-10-21): when a scanned message's `content` is an
2516
+ * array of parts, the gate reads EVERY TEXT PART of it — a banned opening in
2517
+ * the FIRST part is refused, and a banned opening in the SECOND part is
2518
+ * refused just the same. Prepending to the first part only would therefore
2519
+ * fix the first half of the failure and leave the second half to the users who
2520
+ * report it again.
1993
2521
  */
1994
2522
  function neutralizeChannelFingerprints(obj) {
1995
2523
  const messages = obj["messages"];
1996
2524
  if (!Array.isArray(messages)) return;
1997
2525
  for (const value of messages) {
2526
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
2527
+ const message = value;
2528
+ if (!scansOpening(message["role"])) continue;
2529
+ const content = message["content"];
2530
+ if (typeof content === "string") {
2531
+ if (!isUnapprovedChannelOpening(content)) continue;
2532
+ message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
2533
+ continue;
2534
+ }
2535
+ if (!Array.isArray(content)) continue;
2536
+ for (const part of content) {
2537
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
2538
+ const record = part;
2539
+ const type = record["type"];
2540
+ if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
2541
+ const text = record["text"];
2542
+ if (typeof text !== "string" || !isUnapprovedChannelOpening(text)) continue;
2543
+ record["text"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}`;
2544
+ }
2545
+ }
2546
+ }
2547
+ /**
2548
+ * A body in which EVERY scanned opening (`system` and `assistant`) is moved
2549
+ * behind the neutral head.
2550
+ *
2551
+ * THE ONE-SHOT SELF-HEALING RETRY'S BODY BUILDER. The gateway's fingerprint
2552
+ * list is a SNAPSHOT the upstream can extend at any time, and the plugin
2553
+ * cannot see it: the two entries in {@link UNAPPROVED_CHANNEL_OPENINGS} are
2554
+ * what was MEASURED on 2026-10-21, not a contract. So the day the gateway
2555
+ * adds an entry, this plugin's own prepend stops matching and the banned
2556
+ * opening goes out on the wire unchanged — exactly the dead end issue #98
2557
+ * describes, and exactly what a longer guess-list cannot prevent (nobody can
2558
+ * measure the next entry before it bites).
2559
+ *
2560
+ * The remedy is a second attempt that does not depend on knowing the
2561
+ * fingerprint at all: this builds a body in which EVERY scanned message,
2562
+ * whatever it opens with, gets the neutral head in front. The gateway matches
2563
+ * the START of a scanned message, so a neutral first line moves the whole
2564
+ * opening out of the matched region — whether the banned text is a known
2565
+ * entry, a new one, or a fingerprint this plugin has never heard of.
2566
+ *
2567
+ * `assistant` messages are included for the measured reason
2568
+ * {@link neutralizeChannelFingerprints} documents: a history message that opens
2569
+ * with a refused identity is what keeps a whole session failing, so a retry
2570
+ * that only moved the system prompt would spend a request to be refused for the
2571
+ * same reason.
2572
+ *
2573
+ * WHEN IT IS USED: only AFTER the gateway refused the first attempt with the
2574
+ * channel gate. A request the gateway already answers keeps its bytes
2575
+ * unchanged, because this function is never called for it — the normal path
2576
+ * is {@link prepareChatBody}, which only touches the openings it has MEASURED
2577
+ * as fingerprinted.
2578
+ *
2579
+ * `undefined` means "this body cannot change": every scanned opening already
2580
+ * starts with the neutral head, so a retry would send the same bytes and
2581
+ * spend a request for the same refusal. The caller uses that to avoid
2582
+ * spending the round trip.
2583
+ *
2584
+ * Empty contents are skipped: an empty string cannot open with anything, so it
2585
+ * cannot be the culprit, and an assistant message that carries `tool_calls`
2586
+ * with `content: ""` is a replay shape this function has no reason to touch.
2587
+ *
2588
+ * Never throws. A body it cannot read is the upstream's to reject, and a
2589
+ * parse error must not turn a retry into a crash.
2590
+ */
2591
+ function forceNeutralHead(bodyJson) {
2592
+ let parsed;
2593
+ try {
2594
+ parsed = JSON.parse(bodyJson);
2595
+ } catch {
2596
+ return;
2597
+ }
2598
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
2599
+ const obj = parsed;
2600
+ const messages = obj["messages"];
2601
+ if (!Array.isArray(messages)) return void 0;
2602
+ let changed = false;
2603
+ for (const value of messages) {
2604
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
2605
+ const message = value;
2606
+ if (!scansOpening(message["role"])) continue;
2607
+ const content = message["content"];
2608
+ if (typeof content === "string") {
2609
+ if (content.trim() === "" || startsWithNeutralHead(content)) continue;
2610
+ message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
2611
+ changed = true;
2612
+ continue;
2613
+ }
2614
+ if (!Array.isArray(content)) continue;
2615
+ for (const part of content) {
2616
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
2617
+ const record = part;
2618
+ const type = record["type"];
2619
+ if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
2620
+ const text = record["text"];
2621
+ if (typeof text !== "string" || text.trim() === "" || startsWithNeutralHead(text)) continue;
2622
+ record["text"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${text}`;
2623
+ changed = true;
2624
+ }
2625
+ }
2626
+ if (!changed) return void 0;
2627
+ return JSON.stringify(obj);
2628
+ }
2629
+ /**
2630
+ * The invisible character used to break a content fingerprint.
2631
+ *
2632
+ * U+200B ZERO WIDTH SPACE: it renders as nothing, it is not whitespace for the
2633
+ * gateway's own matching, and a model reads straight through it. Measured on
2634
+ * the live CN gateway: putting one inside the refused span turns 400 into 200,
2635
+ * while the text the model reads is unchanged.
2636
+ */
2637
+ const ZERO_WIDTH_SPACE = "​";
2638
+ /**
2639
+ * A body whose scanned message TEXT is perturbed just enough to break an exact
2640
+ * content fingerprint.
2641
+ *
2642
+ * THE SECOND HALF OF ISSUE #98, found on 2026-10-21 after 4.15.0 shipped. The
2643
+ * gateway does not only match the OPENING of a scanned message; it also matches
2644
+ * one long substring INSIDE the system prompt. Measured byte-exactly on a real
2645
+ * failed request (CN gateway, `deepseek-v4.1-flash`, the host's own body):
2646
+ *
2647
+ * - the refusal is reproducible, and it is not the opening: the plugin had
2648
+ * already prepended the neutral head, and the request still answered 400;
2649
+ * - bisecting the host's 10,956-character system prompt by lines and then by
2650
+ * sentences found a 221-character span: the DSH Web-GUI paragraph's first
2651
+ * two sentences ("You are interacting with the user through the DeepSeek
2652
+ * Harness Web GUI … they mean this GUI.");
2653
+ * - that span ALONE reproduces the 400, and the SAME span with ANY change
2654
+ * inside it answers 200 — one character rewritten, one quoted word
2655
+ * replaced, even a zero-width space inserted. An outer prefix or suffix
2656
+ * does not help, so the match is an exact substring, not an opening rule;
2657
+ * - the paragraph's individual sentences each answer 200, which is why the
2658
+ * plugin's opening-based normalisation never saw this.
2659
+ *
2660
+ * SO THE RESCUE CANNOT BE "prepend a line" (that is {@link forceNeutralHead},
2661
+ * and it is a no-op here). Nor can it be a list of refused spans: the list is
2662
+ * the upstream's own data, it already grew once without notice, and it will
2663
+ * grow again. What DOES generalise is the observation that an exact-substring
2664
+ * match dies on any perturbation inside it — so this inserts a zero-width space
2665
+ * at every sentence and clause boundary of the scanned messages, which is
2666
+ * invisible to the reader, meaningless to the model, and inside the refused
2667
+ * span with near-certainty for any prose fingerprint.
2668
+ *
2669
+ * WHEN IT IS USED: only as the last retry, after the gateway already refused
2670
+ * the request AND the neutral head could not change anything. Requests the
2671
+ * gateway answers keep their bytes untouched.
2672
+ *
2673
+ * Idempotent: a body that already carries U+200B in a scanned message is left
2674
+ * alone, so a retry never stacks perturbations. Returns `undefined` when
2675
+ * nothing would change, which is the caller's signal not to spend a request.
2676
+ *
2677
+ * Never throws: a malformed body is the upstream's to reject, and a parse error
2678
+ * must not turn a rescue into a crash.
2679
+ */
2680
+ function perturbChannelFingerprints(bodyJson) {
2681
+ let parsed;
2682
+ try {
2683
+ parsed = JSON.parse(bodyJson);
2684
+ } catch {
2685
+ return;
2686
+ }
2687
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
2688
+ const obj = parsed;
2689
+ const messages = obj["messages"];
2690
+ if (!Array.isArray(messages)) return void 0;
2691
+ let changed = false;
2692
+ const perturb = (text) => {
2693
+ if (text.includes(ZERO_WIDTH_SPACE)) return void 0;
2694
+ const next = text.replace(/([.!?])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`).replace(/([。!?])/gu, `$1${ZERO_WIDTH_SPACE}`).replace(/([,;])(\s+)/gu, `$1${ZERO_WIDTH_SPACE}$2`);
2695
+ return next === text ? void 0 : next;
2696
+ };
2697
+ for (const value of messages) {
2698
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
2699
+ const message = value;
2700
+ if (!scansOpening(message["role"])) continue;
2701
+ const content = message["content"];
2702
+ if (typeof content === "string") {
2703
+ const next = perturb(content);
2704
+ if (next !== void 0) {
2705
+ message["content"] = next;
2706
+ changed = true;
2707
+ }
2708
+ continue;
2709
+ }
2710
+ if (!Array.isArray(content)) continue;
2711
+ for (const part of content) {
2712
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
2713
+ const record = part;
2714
+ const type = record["type"];
2715
+ if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
2716
+ const text = record["text"];
2717
+ if (typeof text !== "string") continue;
2718
+ const next = perturb(text);
2719
+ if (next !== void 0) {
2720
+ record["text"] = next;
2721
+ changed = true;
2722
+ }
2723
+ }
2724
+ }
2725
+ if (!changed) return void 0;
2726
+ return JSON.stringify(obj);
2727
+ }
2728
+ /**
2729
+ * The openings of every scanned message in a body, one entry per TEXT PART.
2730
+ *
2731
+ * `system` AND `assistant`, because both are read by the gate (measured; see
2732
+ * {@link scansOpening}). `developer` counts as a system message because the
2733
+ * plugin rewrites `developer` → `system` on the wire.
2734
+ *
2735
+ * Array content contributes ONE ENTRY PER PART, for the same reason the request
2736
+ * path neutralises every part: the gate reads every text part, so "which line
2737
+ * tripped it" can be any of them.
2738
+ *
2739
+ * Capped at {@link SYSTEM_EVIDENCE_LIMIT} entries, and never throws — losing
2740
+ * the failure measurement is not an acceptable price for reading an excerpt.
2741
+ */
2742
+ function openingEvidenceOfBody(bodyJson) {
2743
+ let parsed;
2744
+ try {
2745
+ parsed = JSON.parse(bodyJson);
2746
+ } catch {
2747
+ return [];
2748
+ }
2749
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
2750
+ const messages = parsed["messages"];
2751
+ if (!Array.isArray(messages)) return [];
2752
+ const evidence = [];
2753
+ for (let index = 0; index < messages.length; index++) {
2754
+ if (evidence.length >= SYSTEM_EVIDENCE_LIMIT) break;
2755
+ const value = messages[index];
1998
2756
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
1999
2757
  const message = value;
2000
2758
  const role = message["role"];
2001
- if (typeof role !== "string" || role.trim().toLowerCase() !== "system") continue;
2759
+ if (typeof role !== "string") continue;
2760
+ const roleName = role.trim().toLowerCase();
2761
+ if (roleName !== "system" && roleName !== "assistant" && roleName !== "developer") continue;
2762
+ const roleOfEvidence = roleName === "assistant" ? "assistant" : "system";
2002
2763
  const content = message["content"];
2003
- if (typeof content !== "string") continue;
2004
- const opening = content.replace(/^\s+/, "").toLowerCase();
2005
- if (!UNAPPROVED_CHANNEL_OPENINGS.some((prefix) => opening.startsWith(prefix))) continue;
2006
- message["content"] = `${WORKBUDDY_FALLBACK_SYSTEM_PROMPT}\n\n${content}`;
2764
+ const texts = [];
2765
+ if (typeof content === "string") texts.push(content);
2766
+ else if (Array.isArray(content)) for (const part of content) {
2767
+ if (typeof part !== "object" || part === null || Array.isArray(part)) continue;
2768
+ const record = part;
2769
+ const type = record["type"];
2770
+ if (typeof type !== "string" || type.trim().toLowerCase() !== "text") continue;
2771
+ const text = record["text"];
2772
+ if (typeof text === "string") texts.push(text);
2773
+ }
2774
+ for (const text of texts) {
2775
+ if (evidence.length >= SYSTEM_EVIDENCE_LIMIT) break;
2776
+ const opening = firstNonEmptyLineOf(text).replace(/\s+/gu, " ").trim();
2777
+ if (opening === "") continue;
2778
+ evidence.push({
2779
+ opening: clip(opening, SYSTEM_OPENING_LIMIT),
2780
+ role: roleOfEvidence,
2781
+ neutralized: startsWithNeutralHead(opening) || isUnapprovedChannelOpening(opening),
2782
+ messageIndex: index
2783
+ });
2784
+ }
2007
2785
  }
2786
+ return evidence;
2787
+ }
2788
+ /**
2789
+ * How many openings the failure path reports.
2790
+ *
2791
+ * Eight is generous: the gate scans every system message and every text part
2792
+ * of it, so a body with more than a handful of openings is not a prompt the
2793
+ * reader can act on anyway — the reply names the first ones and the failure
2794
+ * body carries the rest.
2795
+ *
2796
+ * The cap is HERE and not in the reply: this function reports everything it
2797
+ * found (up to eight) and the reply renders as many of them as fit, saying
2798
+ * how many were left out. Truncating the evidence before the reply sees it
2799
+ * would make "N more" unanswerable.
2800
+ */
2801
+ const SYSTEM_EVIDENCE_LIMIT = 8;
2802
+ /**
2803
+ * The first line of a text that carries something.
2804
+ *
2805
+ * Blank lines are SKIPPED: the neutral head is followed by an empty line, so
2806
+ * the head itself is the line worth reporting, and a content that opens with
2807
+ * blank lines still has an opening.
2808
+ */
2809
+ function firstNonEmptyLineOf(content) {
2810
+ for (const line of content.split("\n")) if (line.trim() !== "") return line;
2811
+ return "";
2008
2812
  }
2009
2813
  /**
2010
2814
  * Make the conversation open with a system message, prepending the fallback
@@ -2664,6 +3468,101 @@ var WorkBuddyUpstreamClient = class {
2664
3468
  return await readCatalog(MODELS_CATALOG_PATH);
2665
3469
  }
2666
3470
  }
3471
+ /**
3472
+ * Read how busy each model is right now, from the upstream's own probe.
3473
+ *
3474
+ * Batched at the upstream's ceiling (`WORKBUDDY_BUSY_BATCH_SIZE`), because the
3475
+ * probe takes a list of ids and answers only about the ids it was given.
3476
+ *
3477
+ * Throws on anything that is not a clean `code: 0`, like every other reader in
3478
+ * this class — the CALLER decides that a failed load probe is survivable and
3479
+ * catches it. Nothing here is persisted: the level belongs to the refresh that
3480
+ * asked for it.
3481
+ */
3482
+ async fetchModelBusy(credential, modelIds, signal) {
3483
+ const levels = /* @__PURE__ */ new Map();
3484
+ for (const batch of batchModelIds(modelIds)) {
3485
+ const response = await fetch(`${chatBase(credential)}${MODEL_BUSY_PATH}`, {
3486
+ method: "POST",
3487
+ headers: {
3488
+ ...commonHeaders(credential),
3489
+ "Authorization": `Bearer ${credential.accessToken}`,
3490
+ "Content-Type": "application/json"
3491
+ },
3492
+ body: JSON.stringify({ models: batch }),
3493
+ signal: signal ?? AbortSignal.timeout(MODEL_BUSY_TIMEOUT_MS)
3494
+ });
3495
+ const envelope = await readEnvelope(response);
3496
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
3497
+ for (const [id, level] of parseBusyLevels(envelope.data)) levels.set(id, level);
3498
+ }
3499
+ return levels;
3500
+ }
3501
+ /**
3502
+ * 读该账号所属区的产品配置,挑出带 `text-to-image` 标签的媒体模型。
3503
+ *
3504
+ * 走的是和 {@link fetchModels} 同一条 `/v3/config`(国内区与国际区都答),
3505
+ * 区别只在这里保留 `data.models` 的媒体条目、而不是投影成 CLI 聊天目录。
3506
+ * 国内区实测 62 条模型里只有 `hunyuan-image-alpha` 带 `text-to-image`;
3507
+ * 国际区一条都没有 —— 所以本方法对国际区返回空数组,而不是编一个模型出来。
3508
+ *
3509
+ * 这是一次 EXTRA 读取,不是「复用目录那一次」:`fetchModels` 的结果被投影成
3510
+ * 聊天目录就丢掉了媒体标签,所以这里必须再读一遍同一份文档。它是只读 GET、
3511
+ * 不花积分,且工具侧有 5 分钟缓存,但「零新增请求」的说法不成立,别那么写。
3512
+ * 国内区与 {@link fetchModels} 一样走 `/v3` → `/v2` 降级:上游一旦不答
3513
+ * `/v3`,聊天目录会降级而这里不能变成一个原始异常。
3514
+ */
3515
+ async fetchMediaModels(credential, signal) {
3516
+ const readMedia = async (path) => {
3517
+ const response = await fetch(`${chatBase(credential)}${path}`, {
3518
+ headers: {
3519
+ "Authorization": `Bearer ${credential.accessToken}`,
3520
+ "Accept": "application/json",
3521
+ "Origin": originReferer(credential),
3522
+ "Referer": `${originReferer(credential)}/`,
3523
+ "User-Agent": PRODUCT_UA
3524
+ },
3525
+ signal: signal ?? AbortSignal.timeout(JSON_TIMEOUT_MS)
3526
+ });
3527
+ const envelope = await readEnvelope(response);
3528
+ if (!response.ok || envelope.code !== 0) throw envelopeError(response.status, envelope);
3529
+ return selectMediaModels((typeof envelope.data === "object" && envelope.data !== null ? envelope.data : {})["models"], IMAGE_TAG_TEXT_TO_IMAGE);
3530
+ };
3531
+ try {
3532
+ return await readMedia(GLOBAL_CONFIG_PATH);
3533
+ } catch (error) {
3534
+ if (regionOf(credential.domain) === "global") throw error;
3535
+ this.onFallback?.(`CN media catalog ${GLOBAL_CONFIG_PATH} failed; falling back to ${MODELS_CATALOG_PATH}: ` + (error instanceof Error ? error.message : String(error)));
3536
+ return await readMedia(MODELS_CATALOG_PATH);
3537
+ }
3538
+ }
3539
+ /**
3540
+ * 发一次文生图请求。
3541
+ *
3542
+ * 端点、请求体、响应解析全部照抄官方 CLI 的 ImageService(见 `image-gen.ts`
3543
+ * 的模块注释)。实测单张 5.71 credits,产物是 24 小时有效的 COS 签名 URL。
3544
+ *
3545
+ * 失败分两层:HTTP 非 2xx 交给 {@link describeImageFailure} 翻成带 code 的
3546
+ * 错误(401/403 提示重新登录、404 提示该区没有生图路由、429 提示限流);
3547
+ * HTTP 200 但信封 `code !== 0` 由 {@link parseImageResponse} 处理。
3548
+ */
3549
+ async generateImage(credential, request, signal) {
3550
+ const response = await fetch(`${chatBase(credential)}${IMAGE_GENERATIONS_PATH}`, {
3551
+ method: "POST",
3552
+ headers: imageHeaders(credential),
3553
+ body: JSON.stringify(buildImageBody(request)),
3554
+ signal: signal ?? AbortSignal.timeout(IMAGE_TIMEOUT_MS)
3555
+ });
3556
+ const text = await response.text();
3557
+ if (!response.ok) throw describeImageFailure(response.status, text);
3558
+ let payload;
3559
+ try {
3560
+ payload = JSON.parse(text);
3561
+ } catch {
3562
+ throw new WorkBuddyImageError(`workbuddy image: 上游返回的不是 JSON(HTTP ${response.status}): ${text.slice(0, 200)}`, "UPSTREAM");
3563
+ }
3564
+ return parseImageResponse(payload);
3565
+ }
2667
3566
  /** Query today's check-in status without changing account state. */
2668
3567
  async fetchCheckinStatus(credential) {
2669
3568
  const response = await fetch(`${billingBase(credential)}/v2/billing/meter/checkin-activity-status`, {
@@ -4863,7 +5762,7 @@ function generationOf(version) {
4863
5762
  //#endregion
4864
5763
  //#region src/version.ts
4865
5764
  /** The npm package version this build was produced from. */
4866
- const WORKBUDDY_CONNECT_VERSION = "4.14.1";
5765
+ const WORKBUDDY_CONNECT_VERSION = "5.0.0";
4867
5766
  //#endregion
4868
5767
  //#region src/host-heartbeat.ts
4869
5768
  /**
@@ -5021,4 +5920,4 @@ async function clearHostHeartbeat() {
5021
5920
  await rm(workbuddyHostHeartbeatPath(), { force: true });
5022
5921
  }
5023
5922
  //#endregion
5024
- export { withEffectiveOff as $, workbuddyAccountId as A, modelIdOfBody as B, defaultDesktopAuthDirs as C, isVaultAccountId as D, isEncryptedCredentialError as E, WorkBuddyUpstreamClient as F, regionOf as G, parseReasoning as H, classifyUpstreamError as I, defaultOffModelIds as J, systemOpeningsOfBody as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, WorkBuddyCredentialRejectedError as P, recordRefusal as Q, isCredentialRejectedError as R, defaultDesktopAuthCandidates as S, hasEncryptedCredentialFields as T, parseUpstreamModel as U, parseCreditMultiplier as V, prepareChatBody as W, offDefaultFor as X, lastRefusalFor as Y, offRefused as Z, WORKBUDDY_AUTH_FILENAME as _, hostPeersInfo as _t, readHostHeartbeat as a, deriveAtRestKey as at, WorkBuddyEncryptedCredentialError as b, writeFileAtomic as bt, WORKBUDDY_CONNECT_VERSION as c, findWorkbuddyAppExecutable as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, isWorkbuddyBundle as dt, NATIVE_MODALITY_BY_MODEL_ID as et, WorkBuddyCatalog as f, macosBundleExecutable as ft, ENCRYPTED_CREDENTIAL_CODE as g, workbuddyAppExecutableCandidates as gt, fallbackModelsFor as h, readAtRestKey as ht, processStartTimeMs as i, clearAtRestKeyCache as it, workbuddyOwnAuthPath as j, parseWorkBuddyAuth as k, piAiRuntimeInfo as l, findWorkbuddyAppExecutableWithSource as lt, deriveCatalog as m, openEncryptedField as mt, clearHostHeartbeat as n, nativeModalityOf as nt, workbuddyHostHeartbeatPath as o, deriveAtRestKeyId as ot, applyContextBudgets as p, macosNestedAppBundles as pt, REFUSES_OFF_MODEL_IDS as q, isHeartbeatProcessAlive as r, WORKBUDDY_APP_EXECUTABLE_ENV as rt, writeHostHeartbeat as s, fetchAtRestKeyPayload as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, imageDefaultFor as tt, FALLBACK_WORKBUDDY_MODELS as u, isEncryptedFieldWrapper as ut, WORKBUDDY_AUTH_FILE_ENV as v, resolveDshHome as vt, defaultDesktopAuthPath as w, authFileName as x, WorkBuddyCredentialStore as y, withFileLock as yt, knownRegionOf as z };
5923
+ export { REFUSES_OFF_MODEL_IDS as $, workbuddyAccountId as A, withFileLock as At, isCredentialRejectedError as B, defaultDesktopAuthDirs as C, macosBundleExecutable as Ct, isVaultAccountId as D, workbuddyAppExecutableCandidates as Dt, isEncryptedCredentialError as E, readAtRestKey as Et, WorkBuddyUpstreamClient as F, parseCreditMultiplier as G, knownRegionOf as H, classifyUpstreamError as I, perturbChannelFingerprints as J, parseReasoning as K, declaredTools as L, CREDENTIAL_REJECTED_CODE as M, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as N, legacyWorkbuddyOwnAuthPath as O, hostPeersInfo as Ot, WorkBuddyCredentialRejectedError as P, busyDisplaySuffix as Q, forceNeutralHead as R, defaultDesktopAuthCandidates as S, isWorkbuddyBundle as St, hasEncryptedCredentialFields as T, openEncryptedField as Tt, modelIdOfBody as U, isFirstMessageGateBody as V, openingEvidenceOfBody as W, regionOf as X, prepareChatBody as Y, attachBusyLevels as Z, WORKBUDDY_AUTH_FILENAME as _, deriveAtRestKeyId as _t, readHostHeartbeat as a, withEffectiveOff as at, WorkBuddyEncryptedCredentialError as b, findWorkbuddyAppExecutableWithSource as bt, WORKBUDDY_CONNECT_VERSION as c, pickImageModel as ct, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, NATIVE_MODALITY_BY_MODEL_ID as dt, defaultOffModelIds as et, WorkBuddyCatalog as f, imageDefaultFor as ft, ENCRYPTED_CREDENTIAL_CODE as g, deriveAtRestKey as gt, fallbackModelsFor as h, clearAtRestKeyCache as ht, processStartTimeMs as i, recordRefusal as it, workbuddyOwnAuthPath as j, writeFileAtomic as jt, parseWorkBuddyAuth as k, resolveDshHome as kt, piAiRuntimeInfo as l, parseQueueAnswer as lt, deriveCatalog as m, WORKBUDDY_APP_EXECUTABLE_ENV as mt, clearHostHeartbeat as n, offDefaultFor as nt, workbuddyHostHeartbeatPath as o, DEFAULT_IMAGE_SIZE as ot, applyContextBudgets as p, nativeModalityOf as pt, parseUpstreamModel as q, isHeartbeatProcessAlive as r, offRefused as rt, writeHostHeartbeat as s, WorkBuddyImageError as st, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, lastRefusalFor as tt, FALLBACK_WORKBUDDY_MODELS as u, queueNoticeText as ut, WORKBUDDY_AUTH_FILE_ENV as v, fetchAtRestKeyPayload as vt, defaultDesktopAuthPath as w, macosNestedAppBundles as wt, authFileName as x, isEncryptedFieldWrapper as xt, WorkBuddyCredentialStore as y, findWorkbuddyAppExecutable as yt, isConversationIdGateBody as z };