dsh-workbuddy-bridge 0.5.4 → 0.5.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/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as variantFor, D as WorkBuddyUpstreamClient, S as WORKBUDDY_VARIANTS, X as resolveAppVersion, a as readHostHeartbeat, c as WORKBUDDY_BRIDGE_VERSION, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, x as CN_VARIANT } from "./heartbeat-CKHC_c0-.js";
2
+ import { C as variantFor, D as WorkBuddyUpstreamClient, Q as resolveAppVersion, S as WORKBUDDY_VARIANTS, a as readHostHeartbeat, c as WORKBUDDY_BRIDGE_VERSION, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, o as workbuddyHostHeartbeatPath, r as isHeartbeatProcessAlive, u as FALLBACK_WORKBUDDY_MODELS, x as CN_VARIANT } from "./heartbeat-BOSjBL0B.js";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  //#region src/cli/bin.ts
@@ -367,14 +367,16 @@ const UPSTREAM_CODE_KINDS = {
367
367
  "12153": "session_dead",
368
368
  "11128": "client",
369
369
  "11133": "client",
370
- "11140": "client"
370
+ "11140": "client",
371
+ "11158": "client"
371
372
  };
372
373
  /** 业务码 → 失败类别:信封 code 非 0 时的第一落点。 */
373
374
  const ENVELOPE_CODE_KINDS = {
374
375
  12153: "session_dead",
375
376
  11128: "client",
376
377
  11133: "client",
377
- 11140: "client"
378
+ 11140: "client",
379
+ 11158: "client"
378
380
  };
379
381
  /**
380
382
  * 文案兜底标记。**只在既无机器码也无业务码可依时使用**,且命中后必须记日志:
@@ -692,6 +694,30 @@ async function probeModel(options) {
692
694
  *
693
695
  * @module dsh-workbuddy-bridge/upstream
694
696
  */
697
+ /**
698
+ * Wait before each gate retry, in milliseconds; the length is the retry budget.
699
+ *
700
+ * Measured 2026-11-06 (CN gateway): the first attempt of a conversation is refused
701
+ * and the identical request answers 200 at +0.74 s. Two steps cover that window
702
+ * with room to spare; a third would only add latency to a turn that is genuinely
703
+ * blocked.
704
+ */
705
+ const GATE_RETRY_DELAYS_MS = [400, 1200];
706
+ /** Abortable delay: a cancelled turn must not keep retrying. */
707
+ async function sleep(ms, signal) {
708
+ if (signal?.aborted === true) return;
709
+ await new Promise((resolve) => {
710
+ const timer = setTimeout(() => {
711
+ signal?.removeEventListener("abort", onAbort);
712
+ resolve();
713
+ }, ms);
714
+ const onAbort = () => {
715
+ clearTimeout(timer);
716
+ resolve();
717
+ };
718
+ signal?.addEventListener("abort", onAbort, { once: true });
719
+ });
720
+ }
695
721
  const CN_CHAT_BASE = "https://copilot.tencent.com";
696
722
  const CN_BILLING_BASE = "https://www.codebuddy.cn";
697
723
  const GLOBAL_BASE = "https://www.workbuddy.ai";
@@ -1049,11 +1075,114 @@ function neutralizeAssistantExtras(message) {
1049
1075
  if (typeof args === "string") fn["arguments"] = neutralizeSignatureText(args);
1050
1076
  }
1051
1077
  }
1052
- /** Apply the two signature rewrites to one string; see {@link neutralizeChannelSignature}. */
1078
+ /**
1079
+ * Rewrite the measured channel-gate fingerprints in one text blob.
1080
+ *
1081
+ * Exported for tests and for callers that assemble a body themselves.
1082
+ * Idempotent: every `to` differs from its `from` and recreates no other `from`.
1083
+ */
1084
+ function rewriteChannelGateSentences(text) {
1085
+ let out = text;
1086
+ for (const [from, to] of IDENTITY_REWRITES) if (out.includes(from)) out = out.split(from).join(to);
1087
+ return out.replace(FILE_REFERENCE_PARAGRAPH, FILE_REFERENCE_REPLACEMENT);
1088
+ }
1089
+ /**
1090
+ * Apply the signature rewrites to one string; see {@link neutralizeChannelSignature}.
1091
+ *
1092
+ * Every rewrite is a **real wording change**. The zero-width-space variant this
1093
+ * function used until 2026-10-29 is retired: both reference implementations
1094
+ * measured that the CN gateway normalizes invisible characters before matching,
1095
+ * so a U+200B separator was equivalent to not rewriting at all.
1096
+ */
1053
1097
  function neutralizeSignatureText(text) {
1054
- return text.replace(CHANNEL_OPENER, "$1You are an AI agent.").replace(CHANNEL_SIGNATURE_PHRASE, `DeepSeek${ZERO_WIDTH_SPACE}$1Harness`);
1098
+ return replaceBrandPhrase(rewriteChannelGateSentences(text).replace(CHANNEL_OPENER, "$1You are an AI agent."));
1099
+ }
1100
+ /** What the product phrase becomes; see {@link replaceBrandPhrase}. */
1101
+ const BRAND_PHRASE_REPLACEMENT = "the agent runtime";
1102
+ /**
1103
+ * The phrase as it appears after an article the sentence already wrote
1104
+ * (`… through the DeepSeek Harness web interface`).
1105
+ *
1106
+ * Matched **with** that article so the replacement supplies the only one:
1107
+ * replacing the bare phrase there produced `through the the agent runtime`,
1108
+ * which is what this pattern exists to prevent. It also collapses whatever
1109
+ * whitespace run separated the three words, so the sentence reads normally
1110
+ * afterwards. The trailing slash guard is the same one the bare pattern uses, so
1111
+ * a real `…/DeepSeek Harness/…` path is still never touched.
1112
+ */
1113
+ const CHANNEL_SIGNATURE_PHRASE_AFTER_ARTICLE = /\bthe[\s\u00A0]+DeepSeek[\s\u00A0]+Harness(?![\\/])/gi;
1114
+ /**
1115
+ * Replace the brand phrase with a neutral noun phrase, in both of the forms a
1116
+ * sentence can use it.
1117
+ *
1118
+ * The replacement carries its own `the` because the phrase is normally the
1119
+ * subject of a mid-sentence clause (`Powered by DeepSeek Harness.` → `Powered by
1120
+ * the agent runtime.`). The article-bearing spelling is handled first and
1121
+ * absorbs that article; the bare spelling then gets the article from the
1122
+ * replacement. One pattern cannot do both, because the replacement text is a
1123
+ * constant.
1124
+ */
1125
+ function replaceBrandPhrase(text) {
1126
+ return text.replace(CHANNEL_SIGNATURE_PHRASE_AFTER_ARTICLE, BRAND_PHRASE_REPLACEMENT).replace(CHANNEL_SIGNATURE_PHRASE, BRAND_PHRASE_REPLACEMENT);
1055
1127
  }
1056
1128
  /**
1129
+ * DSH's identity lines. The gate matches each one **independently** (measured
1130
+ * 2026-10-29 by two probes on the sibling plugin), so a body whose brand is
1131
+ * already neutralized still fails on these.
1132
+ *
1133
+ * These rows match the host's **original** wording and therefore run before the
1134
+ * brand pass (see {@link neutralizeSignatureText}). A `to` side must not
1135
+ * reintroduce the brand: the brand pass that follows would rewrite it again,
1136
+ * and the second pass would then produce a different string — the
1137
+ * non-idempotence the idempotence test catches.
1138
+ */
1139
+ const IDENTITY_REWRITES = [
1140
+ ["You are a helpful AI assistant.", "You are a capable AI helper."],
1141
+ ["You are an AI assistant", "You are an AI agent."],
1142
+ ["You are a coding agent powered by the ", "You are a coding agent running on the "],
1143
+ ["You are interacting with the user through the DeepSeek web interface", "You are working with the user inside the agent runtime web interface"],
1144
+ ["Agent Teams is available in this session", "Agent Teams is enabled for this session"]
1145
+ ];
1146
+ /**
1147
+ * The @-path paragraph `@deepseek-ai/dsh-file-reference` injects into every
1148
+ * system prompt.
1149
+ *
1150
+ * **Whole-paragraph replacement, measured.** The gateway answers 200 to each
1151
+ * sentence alone and 400 to the paragraph, i.e. the match unit is the paragraph,
1152
+ * not a sentence. Everything short of replacing it leaves the count above the
1153
+ * threshold, and that includes the sibling implementation's one-word insertion —
1154
+ * measured 2026-11-06 against this gateway, on a real 92 KB turn:
1155
+ *
1156
+ * | form sent in \`system\` | refused |
1157
+ * | --- | --- |
1158
+ * | original paragraph | 3/3 |
1159
+ * | original + \`that\` inserted into the first sentence | 3/3 |
1160
+ * | **replaced with the neutral paragraph below** | **0/3** |
1161
+ * | removed outright | 0/3 |
1162
+ *
1163
+ * The rule matches the paragraph by its shape rather than by one literal string:
1164
+ * the host has shipped at least two wordings (this repo's own older rewrite
1165
+ * produced "Tokens prefixed with @ are paths that the user explicitly
1166
+ * referenced."), and a literal would silently stop matching the next time the host
1167
+ * reworded a word. The `@` glyph and its meaning survive in the replacement: the
1168
+ * model still has to know what a leading `@` means, only the phrasing the gate
1169
+ * reads is gone.
1170
+ *
1171
+ * Idempotent by construction: the replacement contains none of the shapes the
1172
+ * pattern looks for.
1173
+ */
1174
+ const FILE_REFERENCE_PARAGRAPH = /(?:Paths prefixed with @|Tokens prefixed with @|Tokens starting with @)[\s\S]{0,600}?quotes a path containing spaces\./iu;
1175
+ /** What the paragraph becomes; see {@link FILE_REFERENCE_PARAGRAPH}. */
1176
+ const FILE_REFERENCE_REPLACEMENT = "A token beginning with @ names a location the user pointed at. Resolve relative ones from the project folder and treat absolute ones as host paths. A trailing slash means a directory, otherwise it is a file. Quote a path containing spaces with @\"...\".";
1177
+ /**
1178
+ * The same paragraph as an older release of this plugin left it in history.
1179
+ *
1180
+ * A `to` side becomes a fingerprint once the gate has been measured against it,
1181
+ * and a session that ran 0.5.5 carries those forms in its `assistant` echo. They
1182
+ * are matched by the same pattern above, so nothing extra is needed here — this
1183
+ * note exists because the pattern's alternation is what makes that true.
1184
+ */
1185
+ /**
1057
1186
  * The opener DSH puts at the head of every system prompt.
1058
1187
  *
1059
1188
  * Leading whitespace and a BOM are tolerated (group 1 carries them through to
@@ -1069,8 +1198,41 @@ const CHANNEL_OPENER = /^([\s\uFEFF]*)You\s+are\s+an\s+AI\s+agent\s+powered\s+by
1069
1198
  * sentence reads identically.
1070
1199
  */
1071
1200
  const CHANNEL_SIGNATURE_PHRASE = /(?<![\\/])DeepSeek([\s\u00A0]+)Harness(?![\\/])/gi;
1072
- /** U+200B: invisible to a reader and to the model, breaks a substring match. */
1073
- const ZERO_WIDTH_SPACE = "​";
1201
+ /**
1202
+ * The upstream refusal that means "content filter", not "bad request".
1203
+ *
1204
+ * Two shapes reach us and both must be recognised:
1205
+ *
1206
+ * - the documented gate form, `{"code":11-128,"msg":"Illegal API invocation…"}`;
1207
+ * - the shape issue #25 actually renders (reproduced 2026-10-29):
1208
+ * `{"message":"请求被拦截,请重新发送","type":"client","code":"client"}` — **no
1209
+ * `11-128` anywhere**, so a code-only test misses the exact case the hint
1210
+ * exists for.
1211
+ */
1212
+ const CHANNEL_GATE_CODE = /\b11-128\b/;
1213
+ /** The CN gateway's user-facing block copy, which survives even when the code does not. */
1214
+ const CHANNEL_GATE_COPY = /请求被拦截/;
1215
+ /** True when an upstream body is a channel-gate refusal; see {@link CHANNEL_GATE_CODE}. */
1216
+ function isChannelGateBody(body) {
1217
+ return CHANNEL_GATE_CODE.test(body) || CHANNEL_GATE_COPY.test(body);
1218
+ }
1219
+ /**
1220
+ * The plan-limit refusal: the account's tier does not include the model
1221
+ * (issue #24, measured on a free international account).
1222
+ *
1223
+ * Two shapes, because the code can be in the envelope or written into the
1224
+ * message text — `{"code":11158,"msg":"model [gpt-6-luna] is not allowed for
1225
+ * user type [personal_free_user]", "displayMsg":{…}}`. Both must be recognised
1226
+ * for the same reason as the channel gate: the actionable advice (pick another
1227
+ * model) is not guessable from `请求被拦截`-style copy, and retrying the same
1228
+ * model can never succeed.
1229
+ */
1230
+ const PLAN_LIMIT_CODE = /\b11158\b/;
1231
+ const PLAN_LIMIT_COPY = /is not allowed for user type/i;
1232
+ /** True when an upstream body says the account's plan excludes the model. */
1233
+ function isPlanLimitBody(body) {
1234
+ return PLAN_LIMIT_CODE.test(body) || PLAN_LIMIT_COPY.test(body);
1235
+ }
1074
1236
  /** Rewrite `role: "developer"` messages to `role: "system"` (upstream rejects developer). */
1075
1237
  function normalizeDeveloperRole(obj) {
1076
1238
  const messages = obj["messages"];
@@ -1152,11 +1314,28 @@ var WorkBuddyUpstreamClient = class {
1152
1314
  resolveAppVersion;
1153
1315
  /** Chat-identity resolver; see {@link WorkBuddyUpstreamClientOptions.resolveChatIdentity}. */
1154
1316
  resolveChatIdentity;
1317
+ /** Request observer; see {@link WorkBuddyUpstreamClientOptions.onChatRequest}. */
1318
+ onChatRequest;
1319
+ /** Outcome observer; see {@link WorkBuddyUpstreamClientOptions.onChatOutcome}. */
1320
+ onChatOutcome;
1155
1321
  /** Provenance of the most recent successful catalog fetch, for the card. */
1156
1322
  lastCatalog;
1157
1323
  constructor(options = {}) {
1158
1324
  this.resolveAppVersion = options.resolveAppVersion ?? (() => resolveAppVersion());
1159
1325
  this.resolveChatIdentity = options.resolveChatIdentity ?? ((region) => resolveChatIdentity(region));
1326
+ this.onChatRequest = options.onChatRequest;
1327
+ this.onChatOutcome = options.onChatOutcome;
1328
+ }
1329
+ /**
1330
+ * Report an outcome without letting an observer break the request it observes.
1331
+ *
1332
+ * Both observers exist for diagnostics, so their failure must stay invisible to
1333
+ * the message being diagnosed.
1334
+ */
1335
+ reportOutcome(outcome) {
1336
+ try {
1337
+ this.onChatOutcome?.(outcome);
1338
+ } catch {}
1160
1339
  }
1161
1340
  /**
1162
1341
  * POST the chat endpoint; a successful answer is the raw SSE response.
@@ -1181,35 +1360,72 @@ var WorkBuddyUpstreamClient = class {
1181
1360
  userAgent = chatUserAgent(fallbackChatIdentity(region), region);
1182
1361
  }
1183
1362
  const body = region === "global" ? prepareInternationalChatBody(bodyJson) : prepareChatBody(bodyJson);
1184
- let response;
1363
+ const url = `${chatBase(credential)}/v2/chat/completions`;
1364
+ const headers = {
1365
+ ...chatHeaders(credential, userAgent),
1366
+ Authorization: `Bearer ${credential.accessToken}`
1367
+ };
1185
1368
  try {
1186
- response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
1187
- method: "POST",
1188
- headers: {
1189
- ...chatHeaders(credential, userAgent),
1190
- Authorization: `Bearer ${credential.accessToken}`
1191
- },
1192
- body,
1193
- ...signal === void 0 ? {} : { signal }
1369
+ this.onChatRequest?.({
1370
+ url,
1371
+ headers,
1372
+ body
1194
1373
  });
1195
- } catch (error) {
1196
- return {
1197
- ok: false,
1198
- status: 0,
1199
- kind: "server",
1200
- message: `transport error: ${String(error)}`
1374
+ } catch {}
1375
+ let response;
1376
+ let attempt = 0;
1377
+ /** The last refusal, kept for the report once the retry budget is spent. */
1378
+ let refusal;
1379
+ for (;;) {
1380
+ attempt++;
1381
+ try {
1382
+ response = await fetch(url, {
1383
+ method: "POST",
1384
+ headers,
1385
+ body,
1386
+ ...signal === void 0 ? {} : { signal }
1387
+ });
1388
+ } catch (error) {
1389
+ this.reportOutcome({
1390
+ status: 0,
1391
+ message: `transport error: ${String(error)}`
1392
+ });
1393
+ return {
1394
+ ok: false,
1395
+ status: 0,
1396
+ kind: "server",
1397
+ message: `transport error: ${String(error)}`
1398
+ };
1399
+ }
1400
+ if (response.ok) {
1401
+ this.reportOutcome({ status: response.status });
1402
+ return {
1403
+ ok: true,
1404
+ response
1405
+ };
1406
+ }
1407
+ const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
1408
+ refusal = {
1409
+ status: response.status,
1410
+ text
1201
1411
  };
1412
+ if (!isChannelGateBody(text) || attempt > GATE_RETRY_DELAYS_MS.length) break;
1413
+ if (signal?.aborted === true) break;
1414
+ await sleep(GATE_RETRY_DELAYS_MS[attempt - 1], signal);
1202
1415
  }
1203
- if (response.ok) return {
1204
- ok: true,
1205
- response
1416
+ const last = refusal ?? {
1417
+ status: response.status,
1418
+ text: ""
1206
1419
  };
1207
- const text = (await response.text()).slice(0, ERROR_BODY_LIMIT);
1420
+ this.reportOutcome({
1421
+ status: last.status,
1422
+ message: last.text
1423
+ });
1208
1424
  return {
1209
1425
  ok: false,
1210
- status: response.status,
1211
- kind: classifyUpstreamError(response.status, text),
1212
- message: text
1426
+ status: last.status,
1427
+ kind: classifyUpstreamError(last.status, last.text),
1428
+ message: last.text
1213
1429
  };
1214
1430
  }
1215
1431
  /** POST the token-refresh endpoint; the caller merges the outcome. */
@@ -3949,7 +4165,7 @@ var WorkBuddyCatalog = class {
3949
4165
  };
3950
4166
  //#endregion
3951
4167
  //#region src/version.ts
3952
- const WORKBUDDY_BRIDGE_VERSION = "0.5.4";
4168
+ const WORKBUDDY_BRIDGE_VERSION = "0.5.6";
3953
4169
  //#endregion
3954
4170
  //#region src/web/heartbeat.ts
3955
4171
  /**
@@ -4120,4 +4336,4 @@ function isHeartbeatProcessAlive(heartbeat) {
4120
4336
  return startAtMs <= heartbeat.registeredAt;
4121
4337
  }
4122
4338
  //#endregion
4123
- export { parseModelCatalog as A, FALLBACK_CN_APP_VERSION as B, variantFor as C, WorkBuddyUpstreamClient as D, WORKBUDDY_STATUS_PATH as E, probeModel as F, validCliVersion as G, fallbackChatIdentity as H, randomSentinel as I, installedAppVersion as J, WORKBUDDY_APP_VERSION_FILENAME as K, classifyUpstreamError as L, prepareInternationalChatBody as M, regionOf as N, modelWithCurrentPromotion as O, PROBE_EFFORT_CANDIDATES as P, upstreamDisplayMessage as R, WORKBUDDY_VARIANTS as S, WORKBUDDY_PROBE_PATH as T, readCliVersion as U, chatUserAgent as V, resolveChatIdentity as W, resolveAppVersion as X, readBundleVersion as Y, validAppVersion as Z, desktopAuthCandidatesFor as _, readHostHeartbeat as a, AI_VARIANT as b, WORKBUDDY_BRIDGE_VERSION as c, WorkBuddyCatalog as d, WORKBUDDY_AUTH_FILENAME as f, defaultDesktopAuthPath as g, defaultDesktopAuthCandidates as h, processStartTimeMs as i, prepareChatBody as j, normalizeCredits as k, FALLBACK_WORKBUDDY_AI_MODELS as l, WorkBuddyCredentialStore as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, WORKBUDDY_AUTH_FILE_ENV as p, appUserAgent as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, parseWorkBuddyAuth as v, WORKBUDDY_CREDIT_PATH as w, CN_VARIANT as x, workbuddyOwnAuthPath as y, CN_APP_VERSION_FILENAME as z };
4339
+ export { validAppVersion as $, modelWithCurrentPromotion as A, upstreamDisplayMessage as B, variantFor as C, WorkBuddyUpstreamClient as D, WORKBUDDY_STATUS_PATH as E, regionOf as F, readCliVersion as G, FALLBACK_CN_APP_VERSION as H, PROBE_EFFORT_CANDIDATES as I, WORKBUDDY_APP_VERSION_FILENAME as J, resolveChatIdentity as K, probeModel as L, parseModelCatalog as M, prepareChatBody as N, isChannelGateBody as O, prepareInternationalChatBody as P, resolveAppVersion as Q, randomSentinel as R, WORKBUDDY_VARIANTS as S, WORKBUDDY_PROBE_PATH as T, chatUserAgent as U, CN_APP_VERSION_FILENAME as V, fallbackChatIdentity as W, installedAppVersion as X, appUserAgent as Y, readBundleVersion as Z, desktopAuthCandidatesFor as _, readHostHeartbeat as a, AI_VARIANT as b, WORKBUDDY_BRIDGE_VERSION as c, WorkBuddyCatalog as d, WORKBUDDY_AUTH_FILENAME as f, defaultDesktopAuthPath as g, defaultDesktopAuthCandidates as h, processStartTimeMs as i, normalizeCredits as j, isPlanLimitBody as k, FALLBACK_WORKBUDDY_AI_MODELS as l, WorkBuddyCredentialStore as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, WORKBUDDY_AUTH_FILE_ENV as p, validCliVersion as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, parseWorkBuddyAuth as v, WORKBUDDY_CREDIT_PATH as w, CN_VARIANT as x, workbuddyOwnAuthPath as y, classifyUpstreamError as z };
package/lib/index.d.ts CHANGED
@@ -656,6 +656,33 @@ interface WorkBuddyUpstreamClientOptions {
656
656
  * billing never consult it — those requests keep their long-standing headers.
657
657
  */
658
658
  resolveChatIdentity?: (region: WorkBuddyRegion) => Promise<ChatIdentity>;
659
+ /**
660
+ * Observes the final chat request — URL, complete headers, exact body — before
661
+ * it leaves. Diagnostic only; see `llm/capture.ts` for why headers are as
662
+ * load-bearing as the body when a request is refused on content.
663
+ *
664
+ * Kept as an injected hook rather than a built-in file write so the client
665
+ * stays free of filesystem policy, and so a test can assert the exact bytes
666
+ * without touching a disk.
667
+ */
668
+ onChatRequest?: (request: {
669
+ url: string;
670
+ headers: Record<string, string>;
671
+ body: string;
672
+ }) => void;
673
+ /**
674
+ * Observes how the chat request ended — the verdict that belongs to the
675
+ * request {@link onChatRequest} just reported. Status `0` is a transport
676
+ * failure.
677
+ *
678
+ * Paired with the request hook rather than left to the caller, because only
679
+ * this function sees both halves; splitting them is how a captured body gets
680
+ * paired with another request's verdict.
681
+ */
682
+ onChatOutcome?: (outcome: {
683
+ status: number;
684
+ message?: string;
685
+ }) => void;
659
686
  }
660
687
  /**
661
688
  * Upstream HTTP client. One instance serves the whole plugin; requests take
@@ -673,9 +700,20 @@ declare class WorkBuddyUpstreamClient {
673
700
  private readonly resolveAppVersion;
674
701
  /** Chat-identity resolver; see {@link WorkBuddyUpstreamClientOptions.resolveChatIdentity}. */
675
702
  private readonly resolveChatIdentity;
703
+ /** Request observer; see {@link WorkBuddyUpstreamClientOptions.onChatRequest}. */
704
+ private readonly onChatRequest;
705
+ /** Outcome observer; see {@link WorkBuddyUpstreamClientOptions.onChatOutcome}. */
706
+ private readonly onChatOutcome;
676
707
  /** Provenance of the most recent successful catalog fetch, for the card. */
677
708
  lastCatalog: WorkBuddyCatalogFetch | undefined;
678
709
  constructor(options?: WorkBuddyUpstreamClientOptions);
710
+ /**
711
+ * Report an outcome without letting an observer break the request it observes.
712
+ *
713
+ * Both observers exist for diagnostics, so their failure must stay invisible to
714
+ * the message being diagnosed.
715
+ */
716
+ private reportOutcome;
679
717
  /**
680
718
  * POST the chat endpoint; a successful answer is the raw SSE response.
681
719
  *
package/lib/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import { A as parseModelCatalog, B as FALLBACK_CN_APP_VERSION, C as variantFor, D as WorkBuddyUpstreamClient, F as probeModel, G as validCliVersion, H as fallbackChatIdentity, I as randomSentinel, J as installedAppVersion, K as WORKBUDDY_APP_VERSION_FILENAME, L as classifyUpstreamError, M as prepareInternationalChatBody, N as regionOf, O as modelWithCurrentPromotion, P as PROBE_EFFORT_CANDIDATES, R as upstreamDisplayMessage, S as WORKBUDDY_VARIANTS, U as readCliVersion, V as chatUserAgent, W as resolveChatIdentity, X as resolveAppVersion, Y as readBundleVersion, Z as validAppVersion, _ as desktopAuthCandidatesFor, a as readHostHeartbeat, b as AI_VARIANT, c as WORKBUDDY_BRIDGE_VERSION, d as WorkBuddyCatalog, f as WORKBUDDY_AUTH_FILENAME, g as defaultDesktopAuthPath, h as defaultDesktopAuthCandidates, i as processStartTimeMs, j as prepareChatBody, k as normalizeCredits, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as WORKBUDDY_AUTH_FILE_ENV, q as appUserAgent, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as parseWorkBuddyAuth, w as WORKBUDDY_CREDIT_PATH, x as CN_VARIANT, y as workbuddyOwnAuthPath, z as CN_APP_VERSION_FILENAME } from "./heartbeat-CKHC_c0-.js";
1
+ import { $ as validAppVersion, A as modelWithCurrentPromotion, B as upstreamDisplayMessage, C as variantFor, D as WorkBuddyUpstreamClient, F as regionOf, G as readCliVersion, H as FALLBACK_CN_APP_VERSION, I as PROBE_EFFORT_CANDIDATES, J as WORKBUDDY_APP_VERSION_FILENAME, K as resolveChatIdentity, L as probeModel, M as parseModelCatalog, N as prepareChatBody, O as isChannelGateBody, P as prepareInternationalChatBody, Q as resolveAppVersion, R as randomSentinel, S as WORKBUDDY_VARIANTS, U as chatUserAgent, V as CN_APP_VERSION_FILENAME, W as fallbackChatIdentity, X as installedAppVersion, Y as appUserAgent, Z as readBundleVersion, _ as desktopAuthCandidatesFor, a as readHostHeartbeat, b as AI_VARIANT, c as WORKBUDDY_BRIDGE_VERSION, d as WorkBuddyCatalog, f as WORKBUDDY_AUTH_FILENAME, g as defaultDesktopAuthPath, h as defaultDesktopAuthCandidates, i as processStartTimeMs, j as normalizeCredits, k as isPlanLimitBody, l as FALLBACK_WORKBUDDY_AI_MODELS, m as WorkBuddyCredentialStore, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as WORKBUDDY_AUTH_FILE_ENV, q as validCliVersion, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as parseWorkBuddyAuth, w as WORKBUDDY_CREDIT_PATH, x as CN_VARIANT, y as workbuddyOwnAuthPath, z as classifyUpstreamError } from "./heartbeat-BOSjBL0B.js";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
5
5
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
6
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
7
  import { createProvider } from "@earendil-works/pi-ai";
8
8
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
9
9
  import { ReasoningEffortId, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
@@ -648,6 +648,139 @@ var WorkBuddyPiAiAdapter = class extends PiAiAdapter {
648
648
  };
649
649
  }
650
650
  };
651
+ /** The capture log under `$DSH_HOME`; one JSON object per line. */
652
+ const CAPTURE_LOG_FILENAME = ".workbuddy-captures.jsonl";
653
+ /**
654
+ * Held across the request/response pair.
655
+ *
656
+ * A single slot is deliberate rather than a map: this is a human-driven
657
+ * diagnostic, the host serialises one turn at a time, and the cost of a
658
+ * collision (one record with another's verdict) is a re-run, not a wrong fix.
659
+ * A map would need per-request ids threaded through the client for no gain here.
660
+ */
661
+ let pending;
662
+ /** Where the log goes right now, or `undefined` when disarmed. */
663
+ function captureTarget() {
664
+ const fromEnv = process.env["WORKBUDDY_CAPTURE_BODY"];
665
+ if (fromEnv !== void 0 && fromEnv.trim() !== "") return fromEnv;
666
+ try {
667
+ const home = resolveDshHome();
668
+ return existsSync(join(home, ".workbuddy-capture.on")) ? join(home, CAPTURE_LOG_FILENAME) : void 0;
669
+ } catch {
670
+ return;
671
+ }
672
+ }
673
+ /**
674
+ * Strip credential material before anything is written.
675
+ *
676
+ * The log is meant to be pasted into an issue. A bearer token in it would hand
677
+ * the reporter's account to whoever reads the issue, so redaction happens on the
678
+ * way in rather than being left to whoever copies the file.
679
+ */
680
+ function redacted(headers) {
681
+ const out = {};
682
+ for (const [name, value] of Object.entries(headers)) {
683
+ const lowered = name.toLowerCase();
684
+ out[name] = lowered === "authorization" || lowered === "cookie" || lowered.includes("token") ? "[redacted]" : value;
685
+ }
686
+ return out;
687
+ }
688
+ /**
689
+ * A non-reversible fingerprint of the bearer token, for comparing two runs.
690
+ *
691
+ * The token itself stays out of the log, but "was this the same token?" is a real
692
+ * question when the same body and headers answer 200 on a replay and 400 from the
693
+ * host: a token refresh between the two makes them different requests. A digest
694
+ * answers it without storing the secret.
695
+ */
696
+ function tokenFingerprint(headers) {
697
+ const entry = Object.entries(headers).find(([name]) => name.toLowerCase() === "authorization");
698
+ if (entry === void 0) return void 0;
699
+ const token = entry[1].replace(/^Bearer\s+/i, "");
700
+ if (token === "") return void 0;
701
+ return createHash("sha256").update(token).digest("hex").slice(0, 12);
702
+ }
703
+ /**
704
+ * Remember the request that is about to leave.
705
+ *
706
+ * Called from the client's `onChatRequest` hook, i.e. after every header and the
707
+ * body are final and before the fetch — so what is recorded is what the upstream
708
+ * receives, not what the caller intended.
709
+ */
710
+ function rememberChatRequest(url, headers, body) {
711
+ if (captureTarget() === void 0) return;
712
+ pending = {
713
+ url,
714
+ headers: redacted(headers),
715
+ body,
716
+ at: (/* @__PURE__ */ new Date()).toISOString(),
717
+ token: tokenFingerprint(headers)
718
+ };
719
+ }
720
+ /**
721
+ * The most recent request this process captured, with its verdict.
722
+ *
723
+ * Exists so a maintainer can re-send the exact refused request from **inside**
724
+ * the host process: the same body, headers and token answered 200 from a separate
725
+ * process and 400 from the host, which leaves the sending process as the only
726
+ * remaining variable.
727
+ *
728
+ * Falls back to the capture log's last line, because the in-memory slot does not
729
+ * survive a plugin reload — and a reload is exactly when the experiment is
730
+ * needed.
731
+ */
732
+ function lastCapturedRequest() {
733
+ if (lastRequest !== void 0) return lastRequest;
734
+ const target = captureTarget();
735
+ if (target === void 0 || !existsSync(target)) return void 0;
736
+ try {
737
+ const last = readFileSync(target, "utf8").split("\n").filter((line) => line.trim() !== "").at(-1);
738
+ if (last === void 0) return void 0;
739
+ const record = JSON.parse(last);
740
+ if (typeof record.body !== "string") return void 0;
741
+ return {
742
+ body: record.body,
743
+ at: record.at ?? "",
744
+ status: record.status ?? 0,
745
+ headers: record.headers ?? {}
746
+ };
747
+ } catch {
748
+ return;
749
+ }
750
+ }
751
+ /** Set by {@link rememberChatRequest} and refreshed by {@link recordChatOutcome}. */
752
+ let lastRequest;
753
+ /**
754
+ * Append the pending request with its verdict.
755
+ *
756
+ * `status` 0 means a transport failure. A missing pending request (a caller that
757
+ * skips the hook) writes nothing rather than inventing a record.
758
+ */
759
+ function recordChatOutcome(status, message) {
760
+ const target = captureTarget();
761
+ const entry = pending;
762
+ pending = void 0;
763
+ if (entry !== void 0) lastRequest = {
764
+ body: entry.body,
765
+ at: entry.at,
766
+ status,
767
+ headers: entry.headers
768
+ };
769
+ if (target === void 0 || entry === void 0) return;
770
+ const line = JSON.stringify({
771
+ at: entry.at,
772
+ url: entry.url,
773
+ status,
774
+ ...message === void 0 ? {} : { message: message.slice(0, 400) },
775
+ tokenFingerprint: entry.token,
776
+ headers: entry.headers,
777
+ bodyLength: entry.body.length,
778
+ body: entry.body
779
+ });
780
+ try {
781
+ appendFileSync(target, `${line}\n`, { mode: 384 });
782
+ } catch {}
783
+ }
651
784
  //#endregion
652
785
  //#region src/llm/loopback.ts
653
786
  /**
@@ -790,6 +923,31 @@ const KIND_STATUS = {
790
923
  server: 502,
791
924
  client: 400
792
925
  };
926
+ /**
927
+ * What to do when the gateway refuses a request on content (code `11-128`).
928
+ *
929
+ * Two actions only, and the copy keeps the code spelled `11-128` so that the
930
+ * guidance itself, echoed back into a later request, cannot re-arm the gate.
931
+ * No status digits (see {@link failureMessage}) and no retry advice: retrying
932
+ * the same poisoned conversation reproduces the refusal.
933
+ *
934
+ * The running version rides along (issue #30): the same symptom reaches users
935
+ * on two very different layers — an install older than the fix, and a residual
936
+ * trigger after it — and without the version the user cannot tell which layer
937
+ * they are on, so the first thing they check is their account.
938
+ */
939
+ function channelGateHint() {
940
+ return ` — 渠道校验拦截(11-128)。请新开会话重试;若持续出现,先更新插件(当前 ${WORKBUDDY_BRIDGE_VERSION}) / blocked by channel verification (11-128): start a new conversation, and update the plugin if it persists (running ${WORKBUDDY_BRIDGE_VERSION})`;
941
+ }
942
+ /**
943
+ * What to do when the account's plan excludes the requested model (issue #24).
944
+ *
945
+ * No retry advice, because no retry can help: the model is not on this tier,
946
+ * and only picking a different model succeeds. The upstream's own
947
+ * `displayMsg` ("Your account type does not support this model.") says what is
948
+ * wrong but not what to do, which is the gap this fills.
949
+ */
950
+ const PLAN_LIMIT_HINT = " — 当前账号套餐不包含这个模型(11158),请换用其他模型 / your plan does not include this model (11158): pick another one";
793
951
  function writeJson(res, status, body) {
794
952
  const payload = JSON.stringify(body);
795
953
  res.writeHead(status, {
@@ -818,14 +976,23 @@ function writeOpenAIError(res, status, kind, message) {
818
976
  * The upstream's own `displayMsg` wins over the raw body: it is the sentence
819
977
  * written for a human ("账号存在异常行为,已被限制使用"), while the body is a JSON
820
978
  * document whose `msg` is often a machine phrase such as `request illegal`.
979
+ *
980
+ * The channel-gate code (`11-128`) appends {@link channelGateHint}: its raw
981
+ * body is `{"message":"请求被拦截,请重新发送","type":"client","code":"client"}`,
982
+ * which on its own reads as a transient hiccup the user should simply retry —
983
+ * the two actions that actually help (a new conversation; update the plugin)
984
+ * are not guessable from it. A plan-limit refusal (`11158`) appends
985
+ * {@link PLAN_LIMIT_HINT} for the same reason: the fix is "pick another model",
986
+ * which the upstream copy does not say.
821
987
  */
822
988
  function failureMessage(result) {
823
989
  const reason = upstreamDisplayMessage(result.message) ?? result.message.slice(0, 400);
824
990
  const head = `workbuddy upstream ${result.kind}`;
825
- return result.kind === "session_dead" ? `${head} (http ${result.status}): ${reason}` : `${head}: ${reason}`;
991
+ const line = result.kind === "session_dead" ? `${head} (http ${result.status}): ${reason}` : `${head}: ${reason}`;
992
+ const gated = isChannelGateBody(result.message) ? `${line}${channelGateHint()}` : line;
993
+ return isPlanLimitBody(result.message) ? `${gated}${PLAN_LIMIT_HINT}` : gated;
826
994
  }
827
- /** Read a request body with a size cap; over-limit bodies fail the request. */
828
- function readBody$1(req) {
995
+ /** Read a request body with a size cap; over-limit bodies fail the request. */ function readBody$1(req) {
829
996
  return new Promise((resolve, reject) => {
830
997
  const chunks = [];
831
998
  let size = 0;
@@ -1641,6 +1808,22 @@ async function workBuddyWebStatus(deps) {
1641
1808
  /** The status route's request handler, extracted so tests can mount it on a bare server. */
1642
1809
  function workBuddyStatusHandler(deps) {
1643
1810
  return async (req, res) => {
1811
+ if (req.method === "POST") {
1812
+ if (deps.replayCaptured === void 0) {
1813
+ json$2(res, 405, { error: "method not allowed" });
1814
+ return;
1815
+ }
1816
+ if (!loopbackRequest$1(req)) {
1817
+ json$2(res, 403, { error: "request-not-trusted" });
1818
+ return;
1819
+ }
1820
+ try {
1821
+ json$2(res, 200, await deps.replayCaptured());
1822
+ } catch (error) {
1823
+ json$2(res, 500, { error: safeMessage(error) });
1824
+ }
1825
+ return;
1826
+ }
1644
1827
  if (req.method !== "GET") {
1645
1828
  json$2(res, 405, { error: "method not allowed" });
1646
1829
  return;
@@ -2022,7 +2205,14 @@ function fallbackFor(variant) {
2022
2205
  }
2023
2206
  /** Build one variant's stores and probe state. */
2024
2207
  function createVariantRuntime(config, variant, identityOf, accountOf, creditIndex) {
2025
- const client = new WorkBuddyUpstreamClient();
2208
+ const client = new WorkBuddyUpstreamClient({
2209
+ onChatRequest: ({ url, headers, body }) => {
2210
+ rememberChatRequest(url, headers, body);
2211
+ },
2212
+ onChatOutcome: (outcome) => {
2213
+ recordChatOutcome(outcome.status, outcome.message);
2214
+ }
2215
+ });
2026
2216
  const configured = configuredAuthFile(config, variant);
2027
2217
  const store = new WorkBuddyCredentialStore({
2028
2218
  variant,
@@ -2303,6 +2493,19 @@ function apply(ctx, refs) {
2303
2493
  catalog: () => catalogSection(runtime),
2304
2494
  probe: () => probeSection(runtime, config().probeConsent),
2305
2495
  probeKey,
2496
+ replayCaptured: async () => {
2497
+ const captured = lastCapturedRequest();
2498
+ if (captured === void 0) return { error: "nothing captured" };
2499
+ const credential = await runtime.store.resolve();
2500
+ const result = await runtime.client.chatStream(credential, captured.body);
2501
+ return {
2502
+ ok: result.ok,
2503
+ replayedStatus: result.ok ? result.response.status : result.status,
2504
+ capturedAt: captured.at,
2505
+ capturedStatus: captured.status,
2506
+ bodyLength: captured.body.length
2507
+ };
2508
+ },
2306
2509
  visibility: () => {
2307
2510
  const account = runtime.account();
2308
2511
  return account === void 0 ? void 0 : {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-workbuddy-bridge",
3
3
  "displayName": "DSH WorkBuddy Bridge",
4
4
  "description": "将 WorkBuddy 桌面 App 包含的模型自动接入 DeepSeek Harness — bring WorkBuddy desktop app models into DeepSeek Harness with zero configuration.",
5
- "version": "0.5.4",
5
+ "version": "0.5.6",
6
6
  "author": "zlZayn",
7
7
  "keywords": [
8
8
  "dsh",
@@ -90,6 +90,7 @@
90
90
  "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
91
91
  "@deepseek-ai/dsh-llm": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
92
92
  "@deepseek-ai/dsh-llm-pi-ai": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
93
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
93
94
  "@deepseek-ai/dsh-settings": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
94
95
  "@deepseek-ai/schemastery": "^3.18.4",
95
96
  "@earendil-works/pi-ai": "^1.1.0",
@@ -116,6 +117,7 @@
116
117
  "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
117
118
  "@deepseek-ai/dsh-llm": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
118
119
  "@deepseek-ai/dsh-llm-pi-ai": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
120
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
119
121
  "@deepseek-ai/dsh-settings": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
120
122
  "@deepseek-ai/dsh-util-code-language": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
121
123
  "@deepseek-ai/dsh-util-workspace-path": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
@@ -127,7 +129,7 @@
127
129
  "@types/react": "~18.3.1",
128
130
  "@types/react-dom": "^18.3.7",
129
131
  "@types/react-test-renderer": "^18.3.1",
130
- "@vitest/coverage-v8": "^4.1.11",
132
+ "@vitest/coverage-v8": "^4.1.12",
131
133
  "anser": "^2.3.5",
132
134
  "clsx": "^2.1.1",
133
135
  "diff": "^9.0.0",
@@ -158,7 +160,7 @@
158
160
  "typescript": "^5.9.3",
159
161
  "typescript-eslint": "^8.70.1",
160
162
  "unrun": "^0.3.1",
161
- "vitest": "^4.1.11",
163
+ "vitest": "^4.1.12",
162
164
  "zustand": "^5.0.15"
163
165
  },
164
166
  "scripts": {