dsh-workbuddy-bridge 0.5.5 → 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 +1 -1
- package/lib/{heartbeat-DYtNMdMm.js → heartbeat-BOSjBL0B.js} +197 -53
- package/lib/index.d.ts +38 -0
- package/lib/index.js +195 -7
- package/package.json +1 -1
package/lib/bin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { C as variantFor, D as WorkBuddyUpstreamClient,
|
|
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";
|
|
@@ -1057,8 +1083,8 @@ function neutralizeAssistantExtras(message) {
|
|
|
1057
1083
|
*/
|
|
1058
1084
|
function rewriteChannelGateSentences(text) {
|
|
1059
1085
|
let out = text;
|
|
1060
|
-
for (const [from, to] of
|
|
1061
|
-
return out;
|
|
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);
|
|
1062
1088
|
}
|
|
1063
1089
|
/**
|
|
1064
1090
|
* Apply the signature rewrites to one string; see {@link neutralizeChannelSignature}.
|
|
@@ -1069,46 +1095,93 @@ function rewriteChannelGateSentences(text) {
|
|
|
1069
1095
|
* so a U+200B separator was equivalent to not rewriting at all.
|
|
1070
1096
|
*/
|
|
1071
1097
|
function neutralizeSignatureText(text) {
|
|
1072
|
-
return rewriteChannelGateSentences(text.replace(CHANNEL_OPENER, "$1You are an AI agent.")
|
|
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);
|
|
1073
1127
|
}
|
|
1074
1128
|
/**
|
|
1075
1129
|
* DSH's identity lines. The gate matches each one **independently** (measured
|
|
1076
1130
|
* 2026-10-29 by two probes on the sibling plugin), so a body whose brand is
|
|
1077
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.
|
|
1078
1138
|
*/
|
|
1079
1139
|
const IDENTITY_REWRITES = [
|
|
1080
1140
|
["You are a helpful AI assistant.", "You are a capable AI helper."],
|
|
1081
1141
|
["You are an AI assistant", "You are an AI agent."],
|
|
1082
1142
|
["You are a coding agent powered by the ", "You are a coding agent running on the "],
|
|
1083
|
-
["You are interacting with the user through the DeepSeek
|
|
1143
|
+
["You are interacting with the user through the DeepSeek web interface", "You are working with the user inside the agent runtime web interface"],
|
|
1084
1144
|
["Agent Teams is available in this session", "Agent Teams is enabled for this session"]
|
|
1085
1145
|
];
|
|
1086
1146
|
/**
|
|
1087
1147
|
* The @-path paragraph `@deepseek-ai/dsh-file-reference` injects into every
|
|
1088
|
-
* system prompt
|
|
1089
|
-
*
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
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.
|
|
1100
1184
|
*/
|
|
1101
|
-
const FILE_REFERENCE_REWRITES = [
|
|
1102
|
-
["Paths prefixed with @ point to files the user referenced.", "Tokens starting with @ are locations the user explicitly named."],
|
|
1103
|
-
["Relative paths resolve against the working directory;", "Relative paths are resolved against the workspace root;"],
|
|
1104
|
-
["absolute paths identify files or directories on the host.", "absolute paths point at files or directories on the host."],
|
|
1105
|
-
["A trailing slash marks a directory:", "A trailing slash indicates a directory:"],
|
|
1106
|
-
["list it when its contents matter.", "list it whenever its contents matter."],
|
|
1107
|
-
["Anything else is a file:", "Everything else is a file:"],
|
|
1108
|
-
["use the read tool when its contents are needed,", "use the read tool whenever you need its contents,"],
|
|
1109
|
-
["and do not claim to have inspected it before reading.", "and never claim to have inspected it before reading it."],
|
|
1110
|
-
["@\"...\" quotes a path containing spaces.", "A path containing spaces is quoted with @\"...\"."]
|
|
1111
|
-
];
|
|
1112
1185
|
/**
|
|
1113
1186
|
* The opener DSH puts at the head of every system prompt.
|
|
1114
1187
|
*
|
|
@@ -1143,6 +1216,23 @@ const CHANNEL_GATE_COPY = /请求被拦截/;
|
|
|
1143
1216
|
function isChannelGateBody(body) {
|
|
1144
1217
|
return CHANNEL_GATE_CODE.test(body) || CHANNEL_GATE_COPY.test(body);
|
|
1145
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
|
+
}
|
|
1146
1236
|
/** Rewrite `role: "developer"` messages to `role: "system"` (upstream rejects developer). */
|
|
1147
1237
|
function normalizeDeveloperRole(obj) {
|
|
1148
1238
|
const messages = obj["messages"];
|
|
@@ -1224,11 +1314,28 @@ var WorkBuddyUpstreamClient = class {
|
|
|
1224
1314
|
resolveAppVersion;
|
|
1225
1315
|
/** Chat-identity resolver; see {@link WorkBuddyUpstreamClientOptions.resolveChatIdentity}. */
|
|
1226
1316
|
resolveChatIdentity;
|
|
1317
|
+
/** Request observer; see {@link WorkBuddyUpstreamClientOptions.onChatRequest}. */
|
|
1318
|
+
onChatRequest;
|
|
1319
|
+
/** Outcome observer; see {@link WorkBuddyUpstreamClientOptions.onChatOutcome}. */
|
|
1320
|
+
onChatOutcome;
|
|
1227
1321
|
/** Provenance of the most recent successful catalog fetch, for the card. */
|
|
1228
1322
|
lastCatalog;
|
|
1229
1323
|
constructor(options = {}) {
|
|
1230
1324
|
this.resolveAppVersion = options.resolveAppVersion ?? (() => resolveAppVersion());
|
|
1231
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 {}
|
|
1232
1339
|
}
|
|
1233
1340
|
/**
|
|
1234
1341
|
* POST the chat endpoint; a successful answer is the raw SSE response.
|
|
@@ -1253,35 +1360,72 @@ var WorkBuddyUpstreamClient = class {
|
|
|
1253
1360
|
userAgent = chatUserAgent(fallbackChatIdentity(region), region);
|
|
1254
1361
|
}
|
|
1255
1362
|
const body = region === "global" ? prepareInternationalChatBody(bodyJson) : prepareChatBody(bodyJson);
|
|
1256
|
-
|
|
1363
|
+
const url = `${chatBase(credential)}/v2/chat/completions`;
|
|
1364
|
+
const headers = {
|
|
1365
|
+
...chatHeaders(credential, userAgent),
|
|
1366
|
+
Authorization: `Bearer ${credential.accessToken}`
|
|
1367
|
+
};
|
|
1257
1368
|
try {
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
headers
|
|
1261
|
-
|
|
1262
|
-
Authorization: `Bearer ${credential.accessToken}`
|
|
1263
|
-
},
|
|
1264
|
-
body,
|
|
1265
|
-
...signal === void 0 ? {} : { signal }
|
|
1369
|
+
this.onChatRequest?.({
|
|
1370
|
+
url,
|
|
1371
|
+
headers,
|
|
1372
|
+
body
|
|
1266
1373
|
});
|
|
1267
|
-
} catch
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
|
1273
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);
|
|
1274
1415
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1416
|
+
const last = refusal ?? {
|
|
1417
|
+
status: response.status,
|
|
1418
|
+
text: ""
|
|
1278
1419
|
};
|
|
1279
|
-
|
|
1420
|
+
this.reportOutcome({
|
|
1421
|
+
status: last.status,
|
|
1422
|
+
message: last.text
|
|
1423
|
+
});
|
|
1280
1424
|
return {
|
|
1281
1425
|
ok: false,
|
|
1282
|
-
status:
|
|
1283
|
-
kind: classifyUpstreamError(
|
|
1284
|
-
message: text
|
|
1426
|
+
status: last.status,
|
|
1427
|
+
kind: classifyUpstreamError(last.status, last.text),
|
|
1428
|
+
message: last.text
|
|
1285
1429
|
};
|
|
1286
1430
|
}
|
|
1287
1431
|
/** POST the token-refresh endpoint; the caller merges the outcome. */
|
|
@@ -4021,7 +4165,7 @@ var WorkBuddyCatalog = class {
|
|
|
4021
4165
|
};
|
|
4022
4166
|
//#endregion
|
|
4023
4167
|
//#region src/version.ts
|
|
4024
|
-
const WORKBUDDY_BRIDGE_VERSION = "0.5.
|
|
4168
|
+
const WORKBUDDY_BRIDGE_VERSION = "0.5.6";
|
|
4025
4169
|
//#endregion
|
|
4026
4170
|
//#region src/web/heartbeat.ts
|
|
4027
4171
|
/**
|
|
@@ -4192,4 +4336,4 @@ function isHeartbeatProcessAlive(heartbeat) {
|
|
|
4192
4336
|
return startAtMs <= heartbeat.registeredAt;
|
|
4193
4337
|
}
|
|
4194
4338
|
//#endregion
|
|
4195
|
-
export {
|
|
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
|
|
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
|
/**
|
|
@@ -797,8 +930,24 @@ const KIND_STATUS = {
|
|
|
797
930
|
* guidance itself, echoed back into a later request, cannot re-arm the gate.
|
|
798
931
|
* No status digits (see {@link failureMessage}) and no retry advice: retrying
|
|
799
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.
|
|
800
949
|
*/
|
|
801
|
-
const
|
|
950
|
+
const PLAN_LIMIT_HINT = " — 当前账号套餐不包含这个模型(11158),请换用其他模型 / your plan does not include this model (11158): pick another one";
|
|
802
951
|
function writeJson(res, status, body) {
|
|
803
952
|
const payload = JSON.stringify(body);
|
|
804
953
|
res.writeHead(status, {
|
|
@@ -828,17 +977,20 @@ function writeOpenAIError(res, status, kind, message) {
|
|
|
828
977
|
* written for a human ("账号存在异常行为,已被限制使用"), while the body is a JSON
|
|
829
978
|
* document whose `msg` is often a machine phrase such as `request illegal`.
|
|
830
979
|
*
|
|
831
|
-
* The channel-gate code (`11-128`) appends {@link
|
|
980
|
+
* The channel-gate code (`11-128`) appends {@link channelGateHint}: its raw
|
|
832
981
|
* body is `{"message":"请求被拦截,请重新发送","type":"client","code":"client"}`,
|
|
833
982
|
* which on its own reads as a transient hiccup the user should simply retry —
|
|
834
983
|
* the two actions that actually help (a new conversation; update the plugin)
|
|
835
|
-
* are not guessable from it.
|
|
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.
|
|
836
987
|
*/
|
|
837
988
|
function failureMessage(result) {
|
|
838
989
|
const reason = upstreamDisplayMessage(result.message) ?? result.message.slice(0, 400);
|
|
839
990
|
const head = `workbuddy upstream ${result.kind}`;
|
|
840
991
|
const line = result.kind === "session_dead" ? `${head} (http ${result.status}): ${reason}` : `${head}: ${reason}`;
|
|
841
|
-
|
|
992
|
+
const gated = isChannelGateBody(result.message) ? `${line}${channelGateHint()}` : line;
|
|
993
|
+
return isPlanLimitBody(result.message) ? `${gated}${PLAN_LIMIT_HINT}` : gated;
|
|
842
994
|
}
|
|
843
995
|
/** Read a request body with a size cap; over-limit bodies fail the request. */ function readBody$1(req) {
|
|
844
996
|
return new Promise((resolve, reject) => {
|
|
@@ -1656,6 +1808,22 @@ async function workBuddyWebStatus(deps) {
|
|
|
1656
1808
|
/** The status route's request handler, extracted so tests can mount it on a bare server. */
|
|
1657
1809
|
function workBuddyStatusHandler(deps) {
|
|
1658
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
|
+
}
|
|
1659
1827
|
if (req.method !== "GET") {
|
|
1660
1828
|
json$2(res, 405, { error: "method not allowed" });
|
|
1661
1829
|
return;
|
|
@@ -2037,7 +2205,14 @@ function fallbackFor(variant) {
|
|
|
2037
2205
|
}
|
|
2038
2206
|
/** Build one variant's stores and probe state. */
|
|
2039
2207
|
function createVariantRuntime(config, variant, identityOf, accountOf, creditIndex) {
|
|
2040
|
-
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
|
+
});
|
|
2041
2216
|
const configured = configuredAuthFile(config, variant);
|
|
2042
2217
|
const store = new WorkBuddyCredentialStore({
|
|
2043
2218
|
variant,
|
|
@@ -2318,6 +2493,19 @@ function apply(ctx, refs) {
|
|
|
2318
2493
|
catalog: () => catalogSection(runtime),
|
|
2319
2494
|
probe: () => probeSection(runtime, config().probeConsent),
|
|
2320
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
|
+
},
|
|
2321
2509
|
visibility: () => {
|
|
2322
2510
|
const account = runtime.account();
|
|
2323
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.
|
|
5
|
+
"version": "0.5.6",
|
|
6
6
|
"author": "zlZayn",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"dsh",
|