dsh-connect-workbuddy 5.13.0 → 5.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +161 -0
- package/README.en.md +14 -7
- package/README.md +16 -7
- package/lib/bin.js +1 -1
- package/lib/client.js +68 -6
- package/lib/{host-heartbeat-DLVGPY4k.js → host-heartbeat-DMC9icX8.js} +294 -22
- package/lib/index.d.ts +91 -16
- package/lib/index.js +165 -56
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as parseCreditMultiplier, $t as
|
|
1
|
+
import { $ as parseCreditMultiplier, $t as resolveDshHome, A as isEncryptedCredentialError, At as pickMediaModel, B as WorkBuddyCredentialRejectedError, Bt as deriveAtRestKey, C as WorkBuddyCredentialStore, Ct as IMAGE_TAG_TEXT_TO_IMAGE, D as defaultDesktopAuthDirs, Dt as imageMimeForPath, E as defaultDesktopAuthCandidates, Et as imageDataUrl, F as workbuddyAccountId, Ft as NATIVE_MODALITY_BY_MODEL_ID, G as forceNeutralHead, Gt as isEncryptedFieldWrapper, H as channelGateMemberOf, Ht as fetchAtRestKeyPayload, I as workbuddyOwnAuthPath, It as imageDefaultFor, J as isCredentialRejectedError, Jt as macosNestedAppBundles, K as isContextTooLongBody, Kt as isWorkbuddyBundle, L as CONTEXT_OVERFLOW_MARKER, Lt as nativeModalityOf, M as legacyWorkbuddyOwnAuthPath, Mt as parseQueueAnswer, N as parseWorkBuddyAuth, Nt as queueNoticeText, O as defaultDesktopAuthPath, Ot as isBareBase64Image, P as preferredAccountName, Pt as pruneTempDir, Q as openingEvidenceOfBody, R as CREDENTIAL_REJECTED_CODE, Rt as WORKBUDDY_APP_EXECUTABLE_ENV, S as WORKBUDDY_AUTH_FILE_ENV, St as IMAGE_TAG_IMAGE_TO_IMAGE, T as authFileName, Tt as WorkBuddyImageError, U as classifyUpstreamError, Ut as findWorkbuddyAppExecutable, V as WorkBuddyUpstreamClient, Vt as deriveAtRestKeyId, W as declaredTools, X as knownRegionOf, Xt as readAtRestKey, Y as isFirstMessageGateBody, Yt as openEncryptedField, Z as modelIdOfBody, Zt as workbuddyAppExecutableCandidates, _ as deriveCatalog, _t as WorkBuddyVideoError, a as readHostHeartbeat, at as requestEvidenceOf, b as ENCRYPTED_CREDENTIAL_CODE, bt as resolveVideoParams, c as WORKBUDDY_CONNECT_VERSION, ct as REFUSES_OFF_MODEL_IDS, dt as offDefaultFor, en as withFileLock, et as parseReasoning, f as piAiRuntimeInfo, ft as offRefused, g as applyContextBudgets, gt as VIDEO_TAG_TEXT_TO_VIDEO, h as WorkBuddyCatalog, ht as VIDEO_TAG_IMAGE_TO_VIDEO, i as processStartTimeMs, it as regionOf, j as isVaultAccountId, jt as sniffImageMime, k as hasEncryptedCredentialFields, kt as parseImageDataUrl, l as createFileRosterSeenStore, lt as defaultOffModelIds, m as FALLBACK_WORKBUDDY_MODELS_GLOBAL, mt as withEffectiveOff, n as clearHostHeartbeat, nt as perturbChannelFingerprints, o as workbuddyHostHeartbeatPath, ot as rewriteSessionTitleModel, p as FALLBACK_WORKBUDDY_MODELS, pt as recordRefusal, q as isConversationIdGateBody, qt as macosBundleExecutable, r as isHeartbeatProcessAlive, rt as prepareChatBody, s as writeHostHeartbeat, st as attachBusyLevels, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tn as writeFileAtomic, tt as parseUpstreamModel, ut as lastRefusalFor, v as fallbackModelsFor, vt as parseSupportedSecondsRange, w as WorkBuddyEncryptedCredentialError, wt as MAX_INPUT_IMAGE_BYTES, x as WORKBUDDY_AUTH_FILENAME, xt as DEFAULT_IMAGE_SIZE, y as withoutRouters, yt as pickVideoModel, z as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, zt as clearAtRestKeyCache } from "./host-heartbeat-DMC9icX8.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { appendFile, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -1745,6 +1745,39 @@ function isForeignAssistant(message, identity, requestModelId) {
|
|
|
1745
1745
|
return typeof model === "string" && model !== requestModelId;
|
|
1746
1746
|
}
|
|
1747
1747
|
/**
|
|
1748
|
+
* Whether this assistant turn came from a DIFFERENT PROVIDER/route, rather than
|
|
1749
|
+
* from a different model on one of this bundle's own routes.
|
|
1750
|
+
*
|
|
1751
|
+
* WHY THIS DISTINCTION EXISTS (issue #163's live scene). A session switched
|
|
1752
|
+
* from `dsh-opencode-go` to a WorkBuddy route mid-turn. The host rebuilt the
|
|
1753
|
+
* other provider's assistant turns as `dsh-foreign`, and pi-ai rewrote their
|
|
1754
|
+
* unsigned `thinking` blocks into visible `text` — 14,410 / 4,225 / 5,138
|
|
1755
|
+
* characters in the measured scene. WorkBuddy's gateway scans every assistant
|
|
1756
|
+
* message and refused the turn with business code 11128; all three in-place
|
|
1757
|
+
* rescue rungs and three auto-`继续` attempts failed because the bytes never
|
|
1758
|
+
* changed.
|
|
1759
|
+
*
|
|
1760
|
+
* A cross-provider thinking block is different from a same-route model switch:
|
|
1761
|
+
* it carries no `thinkingSignature` (the host builds `foreignAssistant()`
|
|
1762
|
+
* without one), so it could never be replayed as reasoning. Its only possible
|
|
1763
|
+
* contribution to the wire is the prose pi-ai manufactures from it. Dropping it
|
|
1764
|
+
* is therefore a correctness repair, not a preference — and it must happen
|
|
1765
|
+
* even when the user never opted into {@link dropForeignThinking}.
|
|
1766
|
+
*
|
|
1767
|
+
* Same-route model switches (`hy3` → `deepseek-v4.1-flash`) stay behind the
|
|
1768
|
+
* explicit switch, because those turns WERE produced by this bundle and their
|
|
1769
|
+
* reasoning may be worth keeping in the transcript for the user to read.
|
|
1770
|
+
*/
|
|
1771
|
+
function isCrossProviderAssistant(message, identity, ownedRoutes) {
|
|
1772
|
+
if (typeof message !== "object" || message === null) return false;
|
|
1773
|
+
const record = message;
|
|
1774
|
+
if (record["role"] !== "assistant") return false;
|
|
1775
|
+
if (record["api"] === "dsh-foreign") return true;
|
|
1776
|
+
const provider = record["provider"];
|
|
1777
|
+
if (typeof provider !== "string") return true;
|
|
1778
|
+
return provider !== identity && !ownedRoutes.includes(provider);
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1748
1781
|
* Every foreign thinking block pi-ai would turn into visible prose (issue #147).
|
|
1749
1782
|
*
|
|
1750
1783
|
* THE DEFECT. pi-ai keeps a historical `thinking` block only while
|
|
@@ -1801,38 +1834,26 @@ function foreignThinkingOf(context, identity = WORKBUDDY_MODEL_IDENTITY, request
|
|
|
1801
1834
|
return found;
|
|
1802
1835
|
}
|
|
1803
1836
|
/**
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
1806
|
-
* WHY REMOVAL IS THE HONEST REPAIR. The alternatives are both worse:
|
|
1807
|
-
*
|
|
1808
|
-
* - LEAVE IT: pi-ai publishes the other provider's private working notes as
|
|
1809
|
-
* this turn's answer (the #147 defect), so the user reads reasoning instead
|
|
1810
|
-
* of a reply and the model imitates that shape forever after.
|
|
1811
|
-
* - KEEP IT AS THINKING: impossible from here. `isSameModel` is pi-ai's own
|
|
1812
|
-
* predicate over fields this plugin does not control (`model.provider` /
|
|
1813
|
-
* `model.api` / `model.id` are compared against the MESSAGE's), and the
|
|
1814
|
-
* only way to satisfy it is to claim the foreign turn as this model's own
|
|
1815
|
-
* output — which is exactly the provenance lie issue #111 removed. These
|
|
1816
|
-
* blocks carry no `thinkingSignature` (the host builds them that way), so
|
|
1817
|
-
* they could not be replayed as signed reasoning even if claimed.
|
|
1837
|
+
* The subset of {@link foreignThinkingOf} that is safe to repair without asking.
|
|
1818
1838
|
*
|
|
1819
|
-
*
|
|
1820
|
-
*
|
|
1821
|
-
*
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
1839
|
+
* This is the predicate the adapter uses when `dropForeignThinking` is OFF: a
|
|
1840
|
+
* turn from another provider/route is dropped unconditionally, because its
|
|
1841
|
+
* unsigned thinking block can never be replayed and its flattened prose is what
|
|
1842
|
+
* the WorkBuddy gateway refuses (issue #163's provider-switch scene). A turn
|
|
1843
|
+
* from this bundle under a different model id is NOT included — that one is
|
|
1844
|
+
* still the explicit switch's business.
|
|
1824
1845
|
*
|
|
1825
|
-
*
|
|
1826
|
-
*
|
|
1827
|
-
*
|
|
1828
|
-
* @param context - a pi-ai context.
|
|
1829
|
-
* @param identity - the identity this adapter stamps on its own models.
|
|
1830
|
-
* @param requestModelId - model id the request is addressed to (see
|
|
1831
|
-
* {@link isForeignAssistant}); omitted, only the provider/api legs are judged.
|
|
1832
|
-
* @returns the repaired context, or the input when no block qualified.
|
|
1846
|
+
* Runs after `alignAssistantIdentities`, so a global-region turn this bundle
|
|
1847
|
+
* owns has already had its `dsh-foreign` marker repaired away and is not
|
|
1848
|
+
* misclassified.
|
|
1833
1849
|
*/
|
|
1834
|
-
function
|
|
1835
|
-
const
|
|
1850
|
+
function crossProviderThinkingOf(context, identity = WORKBUDDY_MODEL_IDENTITY, ownedRoutes = [WORKBUDDY_PROVIDER, WORKBUDDY_GLOBAL_PROVIDER]) {
|
|
1851
|
+
const messages = context.messages;
|
|
1852
|
+
if (!Array.isArray(messages)) return [];
|
|
1853
|
+
return foreignThinkingOf(context, identity).filter((block) => isCrossProviderAssistant(messages[block.messageIndex], identity, ownedRoutes));
|
|
1854
|
+
}
|
|
1855
|
+
/** Remove exactly the listed blocks; the one place that edits a transcript. */
|
|
1856
|
+
function dropThinkingBlocks(context, affected) {
|
|
1836
1857
|
if (affected.length === 0) return context;
|
|
1837
1858
|
const messages = context.messages;
|
|
1838
1859
|
const byMessage = /* @__PURE__ */ new Map();
|
|
@@ -1860,13 +1881,12 @@ function withLegacyContext(api, generation, identity = WORKBUDDY_MODEL_IDENTITY,
|
|
|
1860
1881
|
/** One place so both branches and both entry points agree on the order. */
|
|
1861
1882
|
const prepare = (context, requestModelId) => {
|
|
1862
1883
|
const aligned = alignAssistantIdentities(generation === "modern" ? foldModernContext(context) : adaptLegacyPiAiContext(context), identity, ownedRoutes);
|
|
1863
|
-
|
|
1864
|
-
const affected = foreignThinkingOf(aligned, identity, requestModelId);
|
|
1884
|
+
const affected = repair?.dropForeignThinking === true ? foreignThinkingOf(aligned, identity, requestModelId) : crossProviderThinkingOf(aligned, identity, ownedRoutes);
|
|
1865
1885
|
if (affected.length === 0) return aligned;
|
|
1866
1886
|
try {
|
|
1867
|
-
repair
|
|
1887
|
+
repair?.onForeignThinking?.(affected);
|
|
1868
1888
|
} catch {}
|
|
1869
|
-
return
|
|
1889
|
+
return dropThinkingBlocks(aligned, affected);
|
|
1870
1890
|
};
|
|
1871
1891
|
if (generation === "modern") return {
|
|
1872
1892
|
...api,
|
|
@@ -2232,7 +2252,7 @@ async function foldOnce(deps, agent, measurement, window, target) {
|
|
|
2232
2252
|
* 宿主也不会替我们重发:`channel_reject` 走的是 400,`code` 又是它不认识的字符串,
|
|
2233
2253
|
* 不在 `DEFAULT_RETRYABLE_CODES` 里,于是这一轮直接结束,用户看到的正是上面那句。
|
|
2234
2254
|
*
|
|
2235
|
-
* ##
|
|
2255
|
+
* ## 为什么它过去看起来可能成功(只对随轮次变化的窄情况)
|
|
2236
2256
|
*
|
|
2237
2257
|
* `docs/PINNED-11128-RETRY.md` §2 记录了两条互相矛盾的事实:插件自己在
|
|
2238
2258
|
* `perturbChannelFingerprints` 旁的注释说「给定提示词被拒是确定性的」,而现场报告
|
|
@@ -2241,19 +2261,24 @@ async function foldOnce(deps, agent, measurement, window, target) {
|
|
|
2241
2261
|
* 可能变化、插件也可能因为学到偏好而从第一发就带扰动。所以「重发一次」不是把被拒
|
|
2242
2262
|
* 的字节原样再塞一遍(那才是真正的浪费),而是**开一个新回合**。
|
|
2243
2263
|
*
|
|
2264
|
+
* #163 的 2026-10-29 实测把这条解释收窄了:固定 system 指纹每轮逐字节相同,新回合
|
|
2265
|
+
* 换不掉它;只有被拒内容恰好落在随轮次变化的段上时才可能碰过。不要把它当成
|
|
2266
|
+
* 「11128 只要重发就能过」的承诺。
|
|
2267
|
+
*
|
|
2244
2268
|
* 本模块就是把这个「手动再发一次」自动化,并且**只做这件事**:
|
|
2245
2269
|
*
|
|
2246
2270
|
* - 只认 `channel_reject` 这一种失败。配额、限流、网络、内容审核都有各自的处置,
|
|
2247
2271
|
* 那些失败再发「继续」只会多烧一次请求(`NO_CONTINUE_CODES` 的同一判断)。
|
|
2248
|
-
* - 上限默认
|
|
2249
|
-
*
|
|
2272
|
+
* - 上限默认 **1** 次,**连续**计数:一次成功回合或一条真实用户消息立即清零。
|
|
2273
|
+
* 旧默认是 3;#163 的实测把「新回合会换掉被拒内容」证伪了(固定 system 指纹每轮
|
|
2274
|
+
* 相同,3 次只是把同一个 400 付三遍),所以一次足够:1 次救不了,3 次也救不了。
|
|
2250
2275
|
* - 不做换号、不换区域、不改请求内容、不重试同一份字节:换号对这类拒绝无效,而
|
|
2251
2276
|
* 篡改请求才是这条功能不该碰的东西。
|
|
2252
2277
|
* - 子代理会话一律不碰:子代理由父代理驱动,抢跑会打乱执行树。
|
|
2253
2278
|
*
|
|
2254
|
-
*
|
|
2255
|
-
*
|
|
2256
|
-
*
|
|
2279
|
+
* 它是**最后兜底**,不是 11128 的修复:固定指纹由 `prepareChatBody` 的按句改词处理;
|
|
2280
|
+
* 改词后仍 11128,多半是新的固定指纹,重发同样无效。只有「被拒内容刚好随轮次变化」
|
|
2281
|
+
* 的窄情况才可能靠它碰过去,所以默认关闭、默认只发 1 次。
|
|
2257
2282
|
*
|
|
2258
2283
|
* 参考:`dsh-client-auto-continue`(MIT,HsiangNianian/dsh-auto-continue)—
|
|
2259
2284
|
* 「连续失败上限」这套**语义**取自它:`state.consecutive` 在每次发出时自增、
|
|
@@ -2314,7 +2339,7 @@ const SESSION_EVICT = 50;
|
|
|
2314
2339
|
* @returns 归一化后的设置。
|
|
2315
2340
|
*/
|
|
2316
2341
|
function channelContinueSettingsOf(enabled, max, text) {
|
|
2317
|
-
const capped = Math.min(MAX_CONSECUTIVE_CEILING, Math.max(1, typeof max === "number" && Number.isFinite(max) ? Math.trunc(max) :
|
|
2342
|
+
const capped = Math.min(MAX_CONSECUTIVE_CEILING, Math.max(1, typeof max === "number" && Number.isFinite(max) ? Math.trunc(max) : 1));
|
|
2318
2343
|
const body = typeof text === "string" && text.trim() !== "" ? text.trim() : DEFAULT_CONTINUE_TEXT;
|
|
2319
2344
|
return {
|
|
2320
2345
|
enabled: enabled === true,
|
|
@@ -4336,7 +4361,36 @@ const NO_RETRY_HEADERS = Object.freeze({
|
|
|
4336
4361
|
"x-should-retry": "false",
|
|
4337
4362
|
"retry-after": "0"
|
|
4338
4363
|
});
|
|
4339
|
-
/**
|
|
4364
|
+
/**
|
|
4365
|
+
* An over-limit request body (issue #174).
|
|
4366
|
+
*
|
|
4367
|
+
* A named class rather than an `Error` with a magic message, because the two
|
|
4368
|
+
* callers must answer it with a DIFFERENT status: the protocol answer is 413,
|
|
4369
|
+
* and classifying it as an ordinary internal failure made it a 500. A class
|
|
4370
|
+
* survives renaming the message, which is the only reason this is not a
|
|
4371
|
+
* string comparison.
|
|
4372
|
+
*/
|
|
4373
|
+
var RequestBodyTooLargeError = class extends Error {
|
|
4374
|
+
limitBytes;
|
|
4375
|
+
constructor(limitBytes) {
|
|
4376
|
+
super(`request body too large (limit ${limitBytes} bytes)`);
|
|
4377
|
+
this.limitBytes = limitBytes;
|
|
4378
|
+
this.name = "RequestBodyTooLargeError";
|
|
4379
|
+
}
|
|
4380
|
+
};
|
|
4381
|
+
/**
|
|
4382
|
+
* Read a request body with a size cap; over-limit bodies fail the request.
|
|
4383
|
+
*
|
|
4384
|
+
* AN OVER-LIMIT BODY IS STOPPED, NOT DESTROYED (issue #174). Calling
|
|
4385
|
+
* `req.destroy()` here tears down the SOCKET, so the 413 the caller is about to
|
|
4386
|
+
* write can never reach the client — it saw a reset connection instead of an
|
|
4387
|
+
* answer, which is strictly worse than the 500 this replaced. So the stream is
|
|
4388
|
+
* paused and its listeners are dropped: the request is no longer read, the
|
|
4389
|
+
* response still goes out, and Node closes the connection once the response
|
|
4390
|
+
* ends. Bytes already arriving are discarded with the socket, which is the
|
|
4391
|
+
* whole point of a cap (`content-length` is not trusted, so the cap has to be
|
|
4392
|
+
* enforced on arrival).
|
|
4393
|
+
*/
|
|
4340
4394
|
function readBody(req) {
|
|
4341
4395
|
return new Promise((resolve, reject) => {
|
|
4342
4396
|
const chunks = [];
|
|
@@ -4344,8 +4398,11 @@ function readBody(req) {
|
|
|
4344
4398
|
req.on("data", (chunk) => {
|
|
4345
4399
|
size += chunk.length;
|
|
4346
4400
|
if (size > REQUEST_BODY_LIMIT) {
|
|
4347
|
-
|
|
4348
|
-
req.
|
|
4401
|
+
chunks.length = 0;
|
|
4402
|
+
req.removeAllListeners("data");
|
|
4403
|
+
req.removeAllListeners("end");
|
|
4404
|
+
req.pause();
|
|
4405
|
+
reject(new RequestBodyTooLargeError(REQUEST_BODY_LIMIT));
|
|
4349
4406
|
return;
|
|
4350
4407
|
}
|
|
4351
4408
|
chunks.push(chunk);
|
|
@@ -5256,8 +5313,10 @@ function createWorkBuddyShim(options) {
|
|
|
5256
5313
|
}
|
|
5257
5314
|
writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
|
|
5258
5315
|
} catch (error) {
|
|
5259
|
-
if (!res.headersSent)
|
|
5260
|
-
|
|
5316
|
+
if (!res.headersSent) {
|
|
5317
|
+
if (error instanceof RequestBodyTooLargeError) writeOpenAIError(res, 413, "client", error.message, "request_too_large");
|
|
5318
|
+
else writeOpenAIError(res, 500, "internal", String(error));
|
|
5319
|
+
} else res.end();
|
|
5261
5320
|
}
|
|
5262
5321
|
}
|
|
5263
5322
|
/**
|
|
@@ -5647,14 +5706,16 @@ function isBillableFailure$1(error, sent) {
|
|
|
5647
5706
|
if (error instanceof WorkBuddyImageError) return BILLABLE_FAILURE_CODES$1.has(error.code);
|
|
5648
5707
|
return true;
|
|
5649
5708
|
}
|
|
5709
|
+
/** 生成图片的临时目录(issue #174)——清理由 {@link pruneTempDir} 负责。 */
|
|
5710
|
+
const TEMP_IMAGE_DIR = join(tmpdir(), "workbuddy-image-gen");
|
|
5650
5711
|
/** 把 b64 图片落成一个临时文件,返回绝对路径。 */
|
|
5651
5712
|
async function writeTempImage(b64, model) {
|
|
5652
|
-
|
|
5653
|
-
await mkdir(dir, { recursive: true });
|
|
5713
|
+
await mkdir(TEMP_IMAGE_DIR, { recursive: true });
|
|
5654
5714
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
5655
5715
|
const safeModel = model.replace(/[^A-Za-z0-9._-]+/g, "_");
|
|
5656
|
-
const path = join(
|
|
5716
|
+
const path = join(TEMP_IMAGE_DIR, `${safeModel}-${stamp}.png`);
|
|
5657
5717
|
await writeFile(path, Buffer.from(b64, "base64"));
|
|
5718
|
+
pruneTempDir(TEMP_IMAGE_DIR, 20, [".png"]);
|
|
5658
5719
|
return path;
|
|
5659
5720
|
}
|
|
5660
5721
|
/**
|
|
@@ -11216,6 +11277,12 @@ const regionStateConfig = z.object({
|
|
|
11216
11277
|
* ticked correctly without the user having to save.
|
|
11217
11278
|
*/
|
|
11218
11279
|
offModelIds: z.array(z.string()).default([]).description("Model ids whose \"off\" thinking level is offered; seeded from the built-in rule when first read."),
|
|
11280
|
+
/**
|
|
11281
|
+
* Whether a human has edited the image boxes (issue #168). `false` lets the
|
|
11282
|
+
* vendor-verified table fill ids the saved array does not mention; `true`
|
|
11283
|
+
* makes `imageModelIds` the whole answer, so an explicit untick sticks.
|
|
11284
|
+
*/
|
|
11285
|
+
imageSelectionExplicit: z.boolean().default(false),
|
|
11219
11286
|
contextBudgets: z.dict(z.number().step(1).min(1)).default({}),
|
|
11220
11287
|
pool: poolConfig.default({})
|
|
11221
11288
|
});
|
|
@@ -11305,16 +11372,16 @@ const Config = z.object({
|
|
|
11305
11372
|
usageIdentity: asVolatile(z.boolean().default(false).description("Make the upstream's usage page record these calls like the official client's: add the X-IDE-* identity headers (which fill the 使用端 column) and send the conversation ids as 32-hex UUIDv7. Off by default — it is a display alignment measured by a reporter, not by this plugin, and the chat channel identity (User-Agent) and request body never change.")),
|
|
11306
11373
|
keepPromptLast: asVolatile(z.boolean().default(false).description("Move the harness-injected runtime context above the real prompt, so the WorkBuddy usage page records what you actually typed instead of the injected block. Message count and text are preserved; the one edit is the recorded prompt's own leading <acp> marker. Off by default because it changes the message order the upstream counts.")),
|
|
11307
11374
|
extraInjectedPrefixes: asVolatile(z.array(z.string()).default([]).description("Extra literal prefixes (issue #133) treated like the harness-injected context markers when keepPromptLast is on — for blocks a companion plugin injects that the built-in list does not recognize. Each entry matches the start of a trailing user message; read only while keepPromptLast is enabled. Sampled when the plugin mounts: reload the plugin (or restart the host) after changing it.")),
|
|
11308
|
-
dropForeignThinking: asVolatile(z.boolean().default(false).description("Drop foreign thinking blocks that pi-ai would publish as prose (issue #147).
|
|
11375
|
+
dropForeignThinking: asVolatile(z.boolean().default(false).description("Drop foreign thinking blocks that pi-ai would publish as prose (issue #147). CROSS-PROVIDER turns are dropped automatically: after a provider switch the other provider's unsigned thinking blocks can never be replayed, and pi-ai would flatten them into assistant prose that the gateway refuses with 11128 (issue #163's measured scene). This switch only widens the repair to a model switch inside one of this bundle's own routes. On, those blocks are removed from the request; text and tool calls are untouched. Off by default for the same-route leg because it changes bytes the upstream counts.")),
|
|
11309
11376
|
sessionTitle: asVolatile(z.object({
|
|
11310
11377
|
enabled: z.boolean().default(false),
|
|
11311
11378
|
model: z.string().default("wb-lite")
|
|
11312
11379
|
}).default({}).description("Move the host session-title request onto a hidden lightweight model (issues #132/#133). The host fires one real upstream request per new session either way; enabled re-points it at `model` (default wb-lite, measured 0 credits by the reporter) instead of billing the main paid model. Request volume still counts, and the lightweight model is reporter-measured, not reproduced here. Sampled when the plugin mounts: reload the plugin (or restart the host) after changing it.")),
|
|
11313
11380
|
channelRejectContinue: asVolatile(z.object({
|
|
11314
11381
|
enabled: z.boolean().default(false),
|
|
11315
|
-
maxConsecutive: z.number().step(1).min(1).max(10).default(
|
|
11382
|
+
maxConsecutive: z.number().step(1).min(1).max(10).default(1),
|
|
11316
11383
|
text: z.string().default("继续")
|
|
11317
|
-
}).default({}).description("After the gateway refuses a turn with `channel_reject` (business code 11128), send 「继续」 once
|
|
11384
|
+
}).default({}).description("LAST RESORT, not a fix for 11128. After the gateway refuses a turn with `channel_reject` (business code 11128), send 「继续」 once automatically. Off by default: it spends a real request without the user typing anything. Fixed system fingerprints are handled by prepareChatBody's sentence rewrites; they are byte-identical every turn, so resending only pays for the same 400 again. The narrow case this can help is content that changes between turns. `maxConsecutive` (default 1) counts CONSECUTIVE auto-sends: one successful turn, or one real user message, resets it. Subagent sessions are never touched. Live: the engine reads this on every failure, so no reload is needed."))
|
|
11318
11385
|
});
|
|
11319
11386
|
/**
|
|
11320
11387
|
* One region's saved model state. A config written before the region split has
|
|
@@ -11558,10 +11625,51 @@ function apply(ctx, config) {
|
|
|
11558
11625
|
...model,
|
|
11559
11626
|
...images.has(model.id) ? { multimodal: true } : { multimodal: false }
|
|
11560
11627
|
}));
|
|
11628
|
+
/**
|
|
11629
|
+
* The image selection one region should be stamped with (issue #168).
|
|
11630
|
+
*
|
|
11631
|
+
* The saved array alone cannot answer "does this model take images?", because
|
|
11632
|
+
* it has two writers with different meanings: the user ticking a box (a
|
|
11633
|
+
* DECISION, to be kept forever) and a catalog refresh re-deriving the
|
|
11634
|
+
* vendor-verified defaults (a SNAPSHOT of the roster as it stood that day).
|
|
11635
|
+
* Stamping the snapshot marks every model the roster gained SINCE as
|
|
11636
|
+
* text-only, `workBuddyModelInput` then answers `['text']`, and the host
|
|
11637
|
+
* refuses the attachment outright — `Model "…" does not support image input`
|
|
11638
|
+
* (`MODEL_DOES_NOT_SUPPORT_IMAGES`) — for a model `imageDefaultFor`
|
|
11639
|
+
* classifies as verified-multimodal.
|
|
11640
|
+
*
|
|
11641
|
+
* So the two sides are UNIONED, each contributing exactly what it can know:
|
|
11642
|
+
*
|
|
11643
|
+
* - the SAVED array states every model the user has ticked. A tick is
|
|
11644
|
+
* honoured even where the vendor table disagrees (someone may know this
|
|
11645
|
+
* account refuses images for that model).
|
|
11646
|
+
* - the VENDOR TABLE states the models this build can vouch for, and is
|
|
11647
|
+
* consulted only for ids the saved array says nothing about. That is what
|
|
11648
|
+
* un-breaks a newcomer without touching a single decision already made.
|
|
11649
|
+
*
|
|
11650
|
+
* `imageSelectionExplicit` closes the one gap a union cannot: the array cannot
|
|
11651
|
+
* say whether an ABSENT id was unticked or simply did not exist yet. Once a
|
|
11652
|
+
* human has edited the boxes, absence is a decision again and the table stops
|
|
11653
|
+
* contributing at all.
|
|
11654
|
+
*
|
|
11655
|
+
* This keeps every existing shape working: an empty `imageModelIds` on a
|
|
11656
|
+
* never-edited region now means "the user has not chosen any" and is filled
|
|
11657
|
+
* from the verified list; a populated one keeps every id it carries. It is
|
|
11658
|
+
* deliberately NOT a hard gate on the flag alone, because the settings seam
|
|
11659
|
+
* (`ctx.settings.update`) is a public write path and
|
|
11660
|
+
* `tests/settings-integration.spec.ts` pins that writing `imageModelIds`
|
|
11661
|
+
* there must reach the adapter without a second "I edited it" call.
|
|
11662
|
+
*/
|
|
11663
|
+
const effectiveImageIds = (state, models) => {
|
|
11664
|
+
const chosen = new Set(state.imageModelIds ?? []);
|
|
11665
|
+
if (state.imageSelectionExplicit === true) return chosen;
|
|
11666
|
+
for (const model of models) if (!chosen.has(model.id) && imageDefaultFor(model)) chosen.add(model.id);
|
|
11667
|
+
return chosen;
|
|
11668
|
+
};
|
|
11561
11669
|
const configuredModels = (value, region, liveModels = []) => {
|
|
11562
11670
|
const state = regionStateOf(value, region);
|
|
11563
11671
|
const roster = resolveRoster(state.lastCatalog ?? [], liveModels, region, { keepLoadLevel: true });
|
|
11564
|
-
const declared = withImageSelection(deriveCatalog(roster, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}),
|
|
11672
|
+
const declared = withImageSelection(deriveCatalog(roster, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), effectiveImageIds(state, roster));
|
|
11565
11673
|
return withEffectiveOff(declared, new Set(savedOffModelIds(state, declared))).map((model) => model.reasoning === void 0 || lastRefusalFor(model.id) === void 0 ? model : {
|
|
11566
11674
|
...model,
|
|
11567
11675
|
reasoning: {
|
|
@@ -12112,7 +12220,7 @@ function apply(ctx, config) {
|
|
|
12112
12220
|
*/
|
|
12113
12221
|
busyLevels: (region) => liveBusyLevels(region),
|
|
12114
12222
|
enabledModelIds: (region) => regionStateOf(current(), region).enabledModelIds ?? [],
|
|
12115
|
-
imageModelIds: (region) => regionStateOf(current(), region)
|
|
12223
|
+
imageModelIds: (region) => [...effectiveImageIds(regionStateOf(current(), region), displayModels(current(), region))],
|
|
12116
12224
|
offModelIds: (region) => savedOffModelIds(regionStateOf(current(), region), displayModels(current(), region)),
|
|
12117
12225
|
contextBudgets: (region) => regionStateOf(current(), region).contextBudgets ?? {},
|
|
12118
12226
|
discoverModels,
|
|
@@ -13086,6 +13194,7 @@ function apply(ctx, config) {
|
|
|
13086
13194
|
blocks,
|
|
13087
13195
|
atMs: Date.now()
|
|
13088
13196
|
};
|
|
13197
|
+
if (unwrapVolatile(current().dropForeignThinking) !== true) ctx.logger.warn(`dsh-connect-workbuddy: dropped ${blocks.length} cross-provider thinking block(s) before a WorkBuddy request (provider switch); see issue #163`);
|
|
13089
13198
|
}
|
|
13090
13199
|
});
|
|
13091
13200
|
invalidateCatalog = () => {
|
|
@@ -13136,7 +13245,7 @@ function apply(ctx, config) {
|
|
|
13136
13245
|
const discovered = await discoverModels(region, signal);
|
|
13137
13246
|
const live = await withBusyLevels(region, discovered, signal);
|
|
13138
13247
|
const state = regionStateOf(current(), region);
|
|
13139
|
-
return withImageSelection(deriveCatalog(live, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}),
|
|
13248
|
+
return withImageSelection(deriveCatalog(live, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), effectiveImageIds(state, live)).map((model) => ({
|
|
13140
13249
|
id: model.id,
|
|
13141
13250
|
name: workBuddyDisplayName(model),
|
|
13142
13251
|
contextWindow: model.contextWindow,
|
|
@@ -13160,7 +13269,7 @@ function apply(ctx, config) {
|
|
|
13160
13269
|
const models = await withBusyLevels(region, await client.fetchModels(credential, void 0, regionStateOf(current(), region).enabledModelIds ?? []));
|
|
13161
13270
|
if (stopped) return;
|
|
13162
13271
|
const state = regionStateOf(current(), region);
|
|
13163
|
-
stacks[region].catalog.set(withEffectiveOff(withImageSelection(deriveCatalog(models, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}),
|
|
13272
|
+
stacks[region].catalog.set(withEffectiveOff(withImageSelection(deriveCatalog(models, new Set(state.enabledModelIds ?? []), state.contextBudgets ?? {}), effectiveImageIds(state, models)), new Set(savedOffModelIds(state, models))));
|
|
13164
13273
|
adapters[region].invalidate();
|
|
13165
13274
|
try {
|
|
13166
13275
|
await stacks[region].rosterSeen.note(models.map((model) => model.id));
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-workbuddy",
|
|
3
3
|
"displayName": "DSH Connect WorkBuddy",
|
|
4
4
|
"description": "把本机登录的 WorkBuddy 模型(国内版与国际版)接入 DeepSeek Harness:每次刷新按上游目录自动读取每个模型的积分倍率,支持逐模型图片输入开关、多账号切换与账号池自动换号,并提供每日签到、卡片与输入框里的胶囊积分读数等只读积分概览。",
|
|
5
|
-
"version": "5.
|
|
5
|
+
"version": "5.14.0",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|