dsh-connect-workbuddy 4.8.1 → 4.8.2
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 +17 -0
- package/README.en.md +4 -0
- package/README.md +4 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +989 -198
- package/lib/{host-heartbeat-BfAvb1_d.js → host-heartbeat-B-4nBogJ.js} +11 -2
- package/lib/index.d.ts +33 -0
- package/lib/index.js +293 -50
- package/package.json +1 -1
|
@@ -1447,6 +1447,7 @@ function declaredTools(bodyJson) {
|
|
|
1447
1447
|
if (!Array.isArray(tools)) return void 0;
|
|
1448
1448
|
const names = /* @__PURE__ */ new Set();
|
|
1449
1449
|
const requiredParameters = /* @__PURE__ */ new Map();
|
|
1450
|
+
const acceptedParameters = /* @__PURE__ */ new Map();
|
|
1450
1451
|
for (const value of tools) {
|
|
1451
1452
|
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
1452
1453
|
const fn = value["function"];
|
|
@@ -1457,7 +1458,14 @@ function declaredTools(bodyJson) {
|
|
|
1457
1458
|
names.add(name);
|
|
1458
1459
|
const parameters = definition["parameters"];
|
|
1459
1460
|
if (typeof parameters !== "object" || parameters === null || Array.isArray(parameters)) continue;
|
|
1460
|
-
const
|
|
1461
|
+
const schema = parameters;
|
|
1462
|
+
const accepted = /* @__PURE__ */ new Set();
|
|
1463
|
+
const properties = schema["properties"];
|
|
1464
|
+
if (typeof properties === "object" && properties !== null && !Array.isArray(properties)) {
|
|
1465
|
+
for (const key of Object.keys(properties)) if (key !== "") accepted.add(key);
|
|
1466
|
+
}
|
|
1467
|
+
acceptedParameters.set(name, accepted);
|
|
1468
|
+
const required = schema["required"];
|
|
1461
1469
|
if (!Array.isArray(required)) continue;
|
|
1462
1470
|
const list = required.filter((entry) => typeof entry === "string" && entry !== "");
|
|
1463
1471
|
if (list.length > 0) requiredParameters.set(name, list);
|
|
@@ -1472,6 +1480,7 @@ function declaredTools(bodyJson) {
|
|
|
1472
1480
|
return {
|
|
1473
1481
|
names,
|
|
1474
1482
|
requiredParameters,
|
|
1483
|
+
acceptedParameters,
|
|
1475
1484
|
...pinnedToolName === void 0 ? {} : { pinnedToolName }
|
|
1476
1485
|
};
|
|
1477
1486
|
}
|
|
@@ -3957,7 +3966,7 @@ function generationOf(version) {
|
|
|
3957
3966
|
//#endregion
|
|
3958
3967
|
//#region src/version.ts
|
|
3959
3968
|
/** The npm package version this build was produced from. */
|
|
3960
|
-
const WORKBUDDY_CONNECT_VERSION = "4.8.
|
|
3969
|
+
const WORKBUDDY_CONNECT_VERSION = "4.8.2";
|
|
3961
3970
|
//#endregion
|
|
3962
3971
|
//#region src/host-heartbeat.ts
|
|
3963
3972
|
/**
|
package/lib/index.d.ts
CHANGED
|
@@ -2755,6 +2755,16 @@ interface WorkBuddyWebPool {
|
|
|
2755
2755
|
targetModelSource: 'preferred' | 'free' | 'none' | 'stale';
|
|
2756
2756
|
/** The account ids the user has checked into this region's pool. */
|
|
2757
2757
|
memberAccountIds?: readonly string[];
|
|
2758
|
+
/**
|
|
2759
|
+
* The user's hand-ordered pool order, as account ids.
|
|
2760
|
+
*
|
|
2761
|
+
* Independent of {@link memberAccountIds}: an id need not be checked in
|
|
2762
|
+
* (it only takes effect once the account is a member), and ids not listed
|
|
2763
|
+
* sort last. Kept separate from membership so reordering never conflicts
|
|
2764
|
+
* with, or is destroyed by, a membership edit. Only ids that resolve to a
|
|
2765
|
+
* currently listed account are transmitted (see `web-status.ts`).
|
|
2766
|
+
*/
|
|
2767
|
+
orderAccountIds?: readonly string[];
|
|
2758
2768
|
/**
|
|
2759
2769
|
* Tokens each test probe sends, as the user saved it; `0`/absent = default.
|
|
2760
2770
|
*
|
|
@@ -3048,6 +3058,14 @@ interface WorkBuddyPoolDeps {
|
|
|
3048
3058
|
*/
|
|
3049
3059
|
probeInputTokens?: number;
|
|
3050
3060
|
memberAccountIds: readonly string[];
|
|
3061
|
+
/**
|
|
3062
|
+
* The user's hand-ordered pool order, as account ids.
|
|
3063
|
+
*
|
|
3064
|
+
* Optional so a Host that never learnt about the setting still satisfies
|
|
3065
|
+
* this interface — its absence then reads as "no manual order" and the
|
|
3066
|
+
* ranking simply ignores it.
|
|
3067
|
+
*/
|
|
3068
|
+
orderAccountIds?: readonly string[];
|
|
3051
3069
|
};
|
|
3052
3070
|
/** One region's CHECKED accounts, plus their credits and last measurements. */
|
|
3053
3071
|
members(region: WorkBuddyRegion): Promise<readonly WorkBuddyPoolMember[]>;
|
|
@@ -3306,6 +3324,21 @@ interface WorkBuddyPoolPreferences {
|
|
|
3306
3324
|
* the pool covers nothing and both buttons stay disabled.
|
|
3307
3325
|
*/
|
|
3308
3326
|
memberAccountIds?: string[];
|
|
3327
|
+
/**
|
|
3328
|
+
* The user's hand-ordered pool order, as a list of account ids.
|
|
3329
|
+
*
|
|
3330
|
+
* SEPARATE FROM MEMBERSHIP: this is purely a display/preference of what the
|
|
3331
|
+
* user wants the pool to prefer, and it is independent of which accounts are
|
|
3332
|
+
* checked in. An id here need not be in {@link memberAccountIds} — the
|
|
3333
|
+
* ranking only consults this order for accounts that are actually members,
|
|
3334
|
+
* and ids that are not listed simply sort last. Keeping them separate lets
|
|
3335
|
+
* the user reorder first and check accounts in later without the ordering
|
|
3336
|
+
* being destroyed by an unrelated membership edit (see `poolPreferencesOf`,
|
|
3337
|
+
* which must always carry this list through alongside membership). Ids that
|
|
3338
|
+
* no longer resolve to a listed account are dropped on transmit, never
|
|
3339
|
+
* silently merged into membership.
|
|
3340
|
+
*/
|
|
3341
|
+
orderAccountIds?: string[];
|
|
3309
3342
|
}
|
|
3310
3343
|
/** Plugin configuration. */
|
|
3311
3344
|
interface Config {
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-
|
|
1
|
+
import { $ as isEncryptedFieldWrapper, A as workbuddyAccountId, B as modelIdOfBody, C as defaultDesktopAuthDirs, D as isVaultAccountId, E as isEncryptedCredentialError, F as WorkBuddyUpstreamClient, G as regionOf, H as parseReasoning, I as classifyUpstreamError, J as deriveAtRestKey, K as WORKBUDDY_APP_EXECUTABLE_ENV, L as declaredTools, M as CREDENTIAL_REJECTED_CODE, N as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, O as legacyWorkbuddyOwnAuthPath, P as WorkBuddyCredentialRejectedError, R as isCredentialRejectedError, S as defaultDesktopAuthCandidates, T as hasEncryptedCredentialFields, U as parseUpstreamModel, V as parseCreditMultiplier, W as prepareChatBody, X as fetchAtRestKeyPayload, Y as deriveAtRestKeyId, Z as findWorkbuddyAppExecutable, _ as WORKBUDDY_AUTH_FILENAME, a as readHostHeartbeat, at as workbuddyAppExecutableCandidates, b as WorkBuddyEncryptedCredentialError, c as WORKBUDDY_CONNECT_VERSION, ct as withFileLock, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as isWorkbuddyBundle, f as WorkBuddyCatalog, g as ENCRYPTED_CREDENTIAL_CODE, h as fallbackModelsFor, i as processStartTimeMs, it as readAtRestKey, j as workbuddyOwnAuthPath, k as parseWorkBuddyAuth, l as piAiRuntimeInfo, lt as writeFileAtomic, m as deriveCatalog, n as clearHostHeartbeat, nt as macosNestedAppBundles, o as workbuddyHostHeartbeatPath, p as applyContextBudgets, q as clearAtRestKeyCache, r as isHeartbeatProcessAlive, rt as openEncryptedField, s as writeHostHeartbeat, st as resolveDshHome, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, tt as macosBundleExecutable, u as FALLBACK_WORKBUDDY_MODELS, v as WORKBUDDY_AUTH_FILE_ENV, w as defaultDesktopAuthPath, x as authFileName, y as WorkBuddyCredentialStore, z as knownRegionOf } from "./host-heartbeat-B-4nBogJ.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -692,24 +692,53 @@ async function foldOnce(deps, agent, measurement, window, target) {
|
|
|
692
692
|
* complementary residual of 0.3%. The gateways did not miscount; the same
|
|
693
693
|
* history was labelled differently on the two sides.
|
|
694
694
|
*
|
|
695
|
-
* The labelling is pi-ai's replay
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
* `
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
* `
|
|
711
|
-
*
|
|
712
|
-
*
|
|
695
|
+
* The labelling is pi-ai's replay — which assistant messages it lets survive as
|
|
696
|
+
* thinking at all. `transform-messages.js:68` (1.1.0, the version the host
|
|
697
|
+
* bundles) computes
|
|
698
|
+
*
|
|
699
|
+
* isSameModel = assistantMsg.provider === model.provider
|
|
700
|
+
* && assistantMsg.api === model.api
|
|
701
|
+
* && assistantMsg.model === model.id;
|
|
702
|
+
*
|
|
703
|
+
* and for a message that fails it, `:88–96` returns the surviving thinking as
|
|
704
|
+
* `{ type: "text", text: block.thinking }` — downgraded, not dropped — while
|
|
705
|
+
* `:76` drops redacted thinking and `:103` strips tool-call `thoughtSignature`.
|
|
706
|
+
* `buildParams` runs that transform at `openai-completions.js:896`, before
|
|
707
|
+
* anything is assembled, so a thinking block only stays a thinking block when
|
|
708
|
+
* its message was recorded under the same provider AND the same model id as the
|
|
709
|
+
* request now in flight. Survivors leave as the raw field: `:1002–1008` writes
|
|
710
|
+
* `assistantMsg[signature] = <thinking>`, `signature` being the field name the
|
|
711
|
+
* stream used, recorded per block at `:399–416` (`reasoning_content` for this
|
|
712
|
+
* vendor, uniformly — 790 replay records in one real session held that value
|
|
713
|
+
* and no other). Non-survivors are text by the time `:964–971` collects
|
|
714
|
+
* `assistantText`, so they land in `assistantMsg.content` instead. This plugin
|
|
715
|
+
* sets only `compat.supportsReasoningEffort` (`src/adapter.ts:200`), which
|
|
716
|
+
* leaves `requiresThinkingAsText` at its `false` default (`:1270`), so the raw
|
|
717
|
+
* field route is the live one rather than the fold-into-content route at `:983`.
|
|
718
|
+
*
|
|
719
|
+
* Two provider ids for one model is therefore two identities for one model, and
|
|
720
|
+
* a region switch flips the sign for the entire other side at once: everything
|
|
721
|
+
* that side ever thought is replayed as content instead of as a reasoning field,
|
|
722
|
+
* and the gateways count content. That is the 1.94× above; the same formula read
|
|
723
|
+
* in the other direction is the 0.696× the reverse switch produces (text
|
|
724
|
+
* 642,800 vs 447,306 across the same bytes). The model-id leg of `isSameModel`
|
|
725
|
+
* matters for the same reason — switching models inside ONE region has the same
|
|
726
|
+
* effect — which is why the symptom belongs in `README`'s 已知限制, where users
|
|
727
|
+
* meet it, and not only here.
|
|
728
|
+
*
|
|
729
|
+
* The retraction trail, because this header has misled twice. The first version
|
|
730
|
+
* claimed historical `reasoning` never reaches the wire, because
|
|
731
|
+
* `requiresThinkingAsText` is unset and the `reasoning_details` route needs a
|
|
732
|
+
* signature the parser never writes. Both halves are wrong: the unset flag is
|
|
733
|
+
* precisely what routes thinking out as a raw field. The second version — the
|
|
734
|
+
* one this paragraph replaces — claimed the difference was the field NAME,
|
|
735
|
+
* signature-driven, citing 0.85.1's `:428`/`:1006` and concluding that a gateway
|
|
736
|
+
* saying `reasoning` replays as `reasoning`. That cannot be right either: both
|
|
737
|
+
* gateways say `reasoning_content`, so a name-keyed story predicts NO difference
|
|
738
|
+
* between them, and the host ships 1.1.0, not 0.85.1. What differs is survival,
|
|
739
|
+
* not naming. The file keeps both dead ends on purpose — each one shaped what
|
|
740
|
+
* this report measures, and the third version only settled the question because
|
|
741
|
+
* the report could show bytes equal while buckets were not.
|
|
713
742
|
*
|
|
714
743
|
* This module exists to settle exactly that, and it can do it WITHOUT either
|
|
715
744
|
* region having credit: the body is assembled locally, before the upstream ever
|
|
@@ -1478,8 +1507,9 @@ function nestedOrText(value) {
|
|
|
1478
1507
|
* This is where gates 2 and 4 live. Returning nothing is the COMMON case for
|
|
1479
1508
|
* mistaken input and is not an error: callers must show the block verbatim.
|
|
1480
1509
|
*/
|
|
1481
|
-
function parseBlock(block, gate) {
|
|
1510
|
+
function parseBlock(block, gate, seen) {
|
|
1482
1511
|
const calls = [];
|
|
1512
|
+
let withheld = false;
|
|
1483
1513
|
const spans = ignoredSpans(block);
|
|
1484
1514
|
let index = 0;
|
|
1485
1515
|
while (index < block.length) {
|
|
@@ -1490,21 +1520,34 @@ function parseBlock(block, gate) {
|
|
|
1490
1520
|
if (close === void 0) break;
|
|
1491
1521
|
const call = parseInvoke(tag, close, block, gate);
|
|
1492
1522
|
if (call !== void 0) calls.push(call);
|
|
1523
|
+
else if (tag.attributes.trim() === "" && gate.onWithheld !== void 0) {
|
|
1524
|
+
seen.reason ??= "unnamed-invoke";
|
|
1525
|
+
withheld = true;
|
|
1526
|
+
}
|
|
1493
1527
|
index = close.end + 1;
|
|
1494
1528
|
continue;
|
|
1495
1529
|
}
|
|
1496
1530
|
index = tag.end + 1;
|
|
1497
1531
|
}
|
|
1498
|
-
return
|
|
1532
|
+
return {
|
|
1533
|
+
calls,
|
|
1534
|
+
withheld
|
|
1535
|
+
};
|
|
1499
1536
|
}
|
|
1500
1537
|
/** One `<invoke>` element → a call, or nothing when a gate refuses it. */
|
|
1501
1538
|
function parseInvoke(open, close, block, gate) {
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1504
|
-
|
|
1539
|
+
const declared = parseAttributes(open.attributes)["name"];
|
|
1540
|
+
const parameters = parseParameters(block.slice(open.end + 1, close.start));
|
|
1541
|
+
let name;
|
|
1542
|
+
if (declared !== void 0 && declared !== "") {
|
|
1543
|
+
if (!gate.declaredNames.has(declared)) return void 0;
|
|
1544
|
+
name = declared;
|
|
1545
|
+
} else if (open.attributes.trim() === "") {
|
|
1546
|
+
name = inferNameFromSchema(parameters, gate);
|
|
1547
|
+
if (name === void 0) return void 0;
|
|
1548
|
+
} else return;
|
|
1505
1549
|
const pinned = gate.pinnedToolName;
|
|
1506
1550
|
if (pinned !== void 0 && pinned !== "auto" && pinned !== "required" && pinned !== "none" && pinned !== name) return;
|
|
1507
|
-
const parameters = parseParameters(block.slice(open.end + 1, close.start));
|
|
1508
1551
|
const required = gate.requiredParameters?.get(name);
|
|
1509
1552
|
if (required !== void 0 && required.some((parameter) => parameters[parameter] === void 0)) return void 0;
|
|
1510
1553
|
return {
|
|
@@ -1514,6 +1557,35 @@ function parseInvoke(open, close, block, gate) {
|
|
|
1514
1557
|
};
|
|
1515
1558
|
}
|
|
1516
1559
|
/**
|
|
1560
|
+
* Name a call from its ARGUMENTS when the opener names no tool — or refuse.
|
|
1561
|
+
*
|
|
1562
|
+
* The evidence is the model's own parameter tags; the constraint is the
|
|
1563
|
+
* request's schemas. A tool qualifies only when every tag the block carries is
|
|
1564
|
+
* accepted by its schema AND every required parameter is already present, and
|
|
1565
|
+
* exactly ONE declared tool qualifies. Everything less certain refuses:
|
|
1566
|
+
*
|
|
1567
|
+
* - a block with no parameter tags at all (no evidence to read);
|
|
1568
|
+
* - a tool whose schema is unknown here (it cannot be cleared — silence must
|
|
1569
|
+
* not let an unverified tool win or lose);
|
|
1570
|
+
* - a parameter no accepted set lists;
|
|
1571
|
+
* - two tools that both fit ("the only tool on offer" was the whole point).
|
|
1572
|
+
*/
|
|
1573
|
+
function inferNameFromSchema(parameters, gate) {
|
|
1574
|
+
const tags = Object.keys(parameters);
|
|
1575
|
+
if (tags.length === 0) return void 0;
|
|
1576
|
+
let fit;
|
|
1577
|
+
for (const tool of gate.declaredNames) {
|
|
1578
|
+
const accepted = gate.acceptedParameters?.get(tool);
|
|
1579
|
+
if (accepted === void 0) return void 0;
|
|
1580
|
+
if (tags.some((tag) => !accepted.has(tag))) continue;
|
|
1581
|
+
const required = gate.requiredParameters?.get(tool);
|
|
1582
|
+
if (required !== void 0 && required.some((parameter) => parameters[parameter] === void 0)) continue;
|
|
1583
|
+
if (fit !== void 0) return void 0;
|
|
1584
|
+
fit = tool;
|
|
1585
|
+
}
|
|
1586
|
+
return fit;
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1517
1589
|
* `text` with every markup block removed — the check behind {@link hasProse}.
|
|
1518
1590
|
*
|
|
1519
1591
|
* ANY marker-bearing tag counts as markup, whatever it is called. Keying on the
|
|
@@ -1556,6 +1628,74 @@ function stripMarkup(text) {
|
|
|
1556
1628
|
return result;
|
|
1557
1629
|
}
|
|
1558
1630
|
/**
|
|
1631
|
+
* Drop stray marker-bearing tags from otherwise-ordinary prose, and say so.
|
|
1632
|
+
*
|
|
1633
|
+
* This is the OTHER half of "the user must never see protocol debris": a block
|
|
1634
|
+
* is handled by {@link parseBlock} (recovered, withheld, or shown verbatim by
|
|
1635
|
+
* gate 4), but lone tags outside every block — `</|DSML|| calls>` closing a
|
|
1636
|
+
* wrapper whose opener belonged to a withheld call, `<|DSML| validate>` —
|
|
1637
|
+
* arrive as plain text and used to be forwarded raw. They are markup, not the
|
|
1638
|
+
* answer: {@link stripMarkup} already knows exactly which bytes those are
|
|
1639
|
+
* (marker-bearing tags only, quoted ones inside fences and code spans kept),
|
|
1640
|
+
* so this runs it and records the removal. The record makes it reportable —
|
|
1641
|
+
* silence about removing content is the failure mode this whole module exists
|
|
1642
|
+
* to avoid.
|
|
1643
|
+
*
|
|
1644
|
+
* Gate 4's "show it verbatim" applies to BLOCKS (which may be content); a lone
|
|
1645
|
+
* marker tag has no content of its own to lose, only the name it wears.
|
|
1646
|
+
*/
|
|
1647
|
+
function stripStray(text, seen) {
|
|
1648
|
+
const cleaned = stripMarkup(text);
|
|
1649
|
+
if (cleaned !== text) seen.reason ??= "stray-markup";
|
|
1650
|
+
return cleaned;
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Drop a marker-bearing tag fragment that the stream held and can never finish.
|
|
1654
|
+
*
|
|
1655
|
+
* {@link holdFrom} keeps back a tail that could still become a block opener, so
|
|
1656
|
+
* at end of stream that held tail is by construction an unterminated `<…`. Two
|
|
1657
|
+
* outcomes, and the difference is the marker:
|
|
1658
|
+
*
|
|
1659
|
+
* - it carries the marker (`<|DSM`, `</|DSML| invo`): the marker is what
|
|
1660
|
+
* makes it protocol debris rather than prose. Its close is never coming, so
|
|
1661
|
+
* forwarding it hands the user half a tag — the `</|DSML| invo` that
|
|
1662
|
+
* trailed a killed stream. It goes, and says so.
|
|
1663
|
+
* - it does not (`<`, `</`, `<tool`): a bare `<` is doubt, not evidence, and
|
|
1664
|
+
* is kept. A person may have meant the character, and half a word is a
|
|
1665
|
+
* worse lie than a stray bracket.
|
|
1666
|
+
*
|
|
1667
|
+
* Only the LAST `<` is examined. Everything before it was already cleared by
|
|
1668
|
+
* `holdFrom`, and an earlier `<` inside ordinary prose must not be dragged out
|
|
1669
|
+
* along with the fragment that happens to follow it.
|
|
1670
|
+
*/
|
|
1671
|
+
function dropUnfinishedMarkerTail(text, seen) {
|
|
1672
|
+
const lastLt = text.lastIndexOf("<");
|
|
1673
|
+
if (lastLt === -1) return text;
|
|
1674
|
+
if (!isLateMarkerFragment(text.slice(lastLt))) return text;
|
|
1675
|
+
seen.reason ??= "stray-markup";
|
|
1676
|
+
return text.slice(0, lastLt);
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Is this unterminated tag fragment a marker-bearing one?
|
|
1680
|
+
*
|
|
1681
|
+
* Deliberately narrow, for the same reason {@link looksLikeTagStart} is: it must
|
|
1682
|
+
* still be open (`>` never arrived, which is what "held at end of stream" means)
|
|
1683
|
+
* and, once the `<` and an optional `/` are stripped, the rest must BE a partial
|
|
1684
|
+
* marker or be a complete marker followed by a partial name. `<tool` fails the
|
|
1685
|
+
* second test and survives; so does `<`, which is not evidence of anything.
|
|
1686
|
+
*/
|
|
1687
|
+
function isLateMarkerFragment(tail) {
|
|
1688
|
+
if (!tail.startsWith("<")) return false;
|
|
1689
|
+
if (tail.includes(">")) return false;
|
|
1690
|
+
let body = tail.slice(1);
|
|
1691
|
+
if (body.startsWith("/")) body = body.slice(1);
|
|
1692
|
+
if (body === "") return false;
|
|
1693
|
+
if (isPartialMarker(body)) return true;
|
|
1694
|
+
const prefixLength = prefixLengthAt(body, 0);
|
|
1695
|
+
if (prefixLength === 0) return false;
|
|
1696
|
+
return /^[A-Za-z_-]*$/.test(body.slice(prefixLength).trim());
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1559
1699
|
* Does `text` carry content outside markup blocks?
|
|
1560
1700
|
*
|
|
1561
1701
|
* This is what makes "the turn produced nothing usable" a checkable fact rather
|
|
@@ -1610,6 +1750,7 @@ function looksLikeTagStart(tail) {
|
|
|
1610
1750
|
if (body.startsWith("/")) body = body.slice(1);
|
|
1611
1751
|
if (body === "" || isPartialMarker(body)) return true;
|
|
1612
1752
|
const prefixLength = prefixLengthAt(body, 0);
|
|
1753
|
+
if (prefixLength > 0) return true;
|
|
1613
1754
|
body = body.slice(prefixLength).trim().toLowerCase();
|
|
1614
1755
|
if (body === "" || isPartialMarker(body)) return true;
|
|
1615
1756
|
return [
|
|
@@ -1665,6 +1806,22 @@ function normalizeFullwidth(text) {
|
|
|
1665
1806
|
var DsmlStreamBuffer = class {
|
|
1666
1807
|
pending = "";
|
|
1667
1808
|
gate;
|
|
1809
|
+
/** The first withholding reason observed in this stream, reported once. */
|
|
1810
|
+
withHeld = {};
|
|
1811
|
+
/**
|
|
1812
|
+
* Set once anything has been withheld from this response.
|
|
1813
|
+
*
|
|
1814
|
+
* It gates the debris sweep, and that is the whole point: gate 4 promises a
|
|
1815
|
+
* REFUSED block is shown verbatim, so marker text must survive the stream
|
|
1816
|
+
* untouched in every response that withheld nothing. Only after a block has
|
|
1817
|
+
* been removed can the tags left around it be known to belong to markup the
|
|
1818
|
+
* reader is no longer seeing — the wrapper's own `calls` closer, the opener
|
|
1819
|
+
* the withheld call never got. Before that, a marker tag in the text may be
|
|
1820
|
+
* the answer itself.
|
|
1821
|
+
*/
|
|
1822
|
+
withheldOnce = false;
|
|
1823
|
+
/** Whether the one notice allowed per response has gone out. See `takeWithheld`. */
|
|
1824
|
+
noticeSent = false;
|
|
1668
1825
|
constructor(gate) {
|
|
1669
1826
|
this.gate = gate;
|
|
1670
1827
|
}
|
|
@@ -1678,46 +1835,106 @@ var DsmlStreamBuffer = class {
|
|
|
1678
1835
|
this.pending = "";
|
|
1679
1836
|
return {
|
|
1680
1837
|
text,
|
|
1681
|
-
prose: hasProse(text)
|
|
1838
|
+
prose: hasProse(text),
|
|
1839
|
+
withheld: false
|
|
1682
1840
|
};
|
|
1683
1841
|
}
|
|
1684
1842
|
for (;;) {
|
|
1685
1843
|
const found = findFirstOpener(this.pending, ignoredSpans(this.pending));
|
|
1686
1844
|
if (found === void 0) break;
|
|
1687
|
-
const head = this.pending.slice(0, found.open.start);
|
|
1845
|
+
const head = this.stripDebris(this.pending.slice(0, found.open.start));
|
|
1688
1846
|
if (head !== "") parts.push(head);
|
|
1689
1847
|
if (found.close === void 0) {
|
|
1690
1848
|
this.pending = this.pending.slice(found.open.start);
|
|
1691
|
-
return finish(parts, calls);
|
|
1849
|
+
return finish(parts, calls, this.takeWithheld());
|
|
1692
1850
|
}
|
|
1693
1851
|
const body = this.pending.slice(found.open.start, found.close.end + 1);
|
|
1694
|
-
const
|
|
1695
|
-
if (
|
|
1696
|
-
else parts.push(body);
|
|
1852
|
+
const parsed = parseBlock(body, this.gate, this.withHeld);
|
|
1853
|
+
if (parsed.calls.length > 0) calls.push(...parsed.calls);
|
|
1854
|
+
else if (!parsed.withheld) parts.push(body);
|
|
1697
1855
|
this.pending = this.pending.slice(found.close.end + 1);
|
|
1698
1856
|
}
|
|
1699
1857
|
const hold = holdFrom(this.pending, ignoredSpans(this.pending));
|
|
1700
|
-
if (hold > 0)
|
|
1858
|
+
if (hold > 0) {
|
|
1859
|
+
const tail = this.stripDebris(this.pending.slice(0, hold));
|
|
1860
|
+
if (tail !== "") parts.push(tail);
|
|
1861
|
+
}
|
|
1701
1862
|
this.pending = this.pending.slice(hold);
|
|
1702
|
-
return finish(parts, calls);
|
|
1863
|
+
return finish(parts, calls, this.takeWithheld());
|
|
1703
1864
|
}
|
|
1704
|
-
/** End of stream: hand back everything still held
|
|
1865
|
+
/** End of stream: hand back everything still held — minus the debris. */
|
|
1705
1866
|
flush() {
|
|
1706
|
-
|
|
1867
|
+
let text = this.pending;
|
|
1707
1868
|
this.pending = "";
|
|
1708
|
-
|
|
1869
|
+
if (isUnnamedInvoke(text) && this.gate.onWithheld !== void 0) {
|
|
1870
|
+
this.withHeld.reason ??= "unnamed-invoke";
|
|
1871
|
+
text = "";
|
|
1872
|
+
}
|
|
1873
|
+
if (this.debris()) {
|
|
1874
|
+
text = dropUnfinishedMarkerTail(text, this.withHeld);
|
|
1875
|
+
text = stripStray(text, this.withHeld);
|
|
1876
|
+
}
|
|
1877
|
+
const state = this.takeWithheld();
|
|
1878
|
+
const outcome = {
|
|
1709
1879
|
text,
|
|
1710
|
-
prose: hasProse(text)
|
|
1880
|
+
prose: hasProse(text),
|
|
1881
|
+
withheld: state.withheld
|
|
1882
|
+
};
|
|
1883
|
+
if (state.withheldReason !== void 0) outcome.withheldReason = state.withheldReason;
|
|
1884
|
+
return outcome;
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Apply the debris sweep, but only to a response that has withheld something.
|
|
1888
|
+
*
|
|
1889
|
+
* See {@link withheldOnce}: before the first withhold, marker text in the
|
|
1890
|
+
* prose is the caller's to show, not this layer's to delete.
|
|
1891
|
+
*/
|
|
1892
|
+
stripDebris(text) {
|
|
1893
|
+
return this.debris() ? stripStray(text, this.withHeld) : text;
|
|
1894
|
+
}
|
|
1895
|
+
/** Has anything been withheld from this response, yet or already? */
|
|
1896
|
+
debris() {
|
|
1897
|
+
return this.withheldOnce || this.withHeld.reason !== void 0;
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Hand the accumulated reason to exactly one outcome.
|
|
1901
|
+
*
|
|
1902
|
+
* Exactly one, because the notice it drives is emitted once per response. A
|
|
1903
|
+
* reason reported on two outcomes would print the same sentence twice.
|
|
1904
|
+
*/
|
|
1905
|
+
takeWithheld() {
|
|
1906
|
+
const reason = this.withHeld.reason;
|
|
1907
|
+
if (reason === void 0) return { withheld: false };
|
|
1908
|
+
this.withHeld = {};
|
|
1909
|
+
this.withheldOnce = true;
|
|
1910
|
+
if (this.noticeSent) return { withheld: true };
|
|
1911
|
+
this.noticeSent = true;
|
|
1912
|
+
this.gate.onWithheld?.(reason);
|
|
1913
|
+
return {
|
|
1914
|
+
withheld: true,
|
|
1915
|
+
withheldReason: reason
|
|
1711
1916
|
};
|
|
1712
1917
|
}
|
|
1713
1918
|
};
|
|
1919
|
+
/**
|
|
1920
|
+
* Is this block an invocation the model wrote without naming a tool?
|
|
1921
|
+
*
|
|
1922
|
+
* Deliberately narrow: no attribute at all on the opener. `<invoke name="">`
|
|
1923
|
+
* is a REFUSED call with a name the gates can judge, and gate 4 shows it.
|
|
1924
|
+
*/
|
|
1925
|
+
function isUnnamedInvoke(body) {
|
|
1926
|
+
const open = scanTag(body, 0);
|
|
1927
|
+
return open !== void 0 && !open.closing && open.name === "invoke" && open.attributes.trim() === "";
|
|
1928
|
+
}
|
|
1714
1929
|
/** Assemble one `add()` outcome without ever emitting a `calls: undefined` key. */
|
|
1715
|
-
function finish(parts, calls) {
|
|
1930
|
+
function finish(parts, calls, state) {
|
|
1716
1931
|
const text = parts.join("");
|
|
1717
1932
|
const outcome = {
|
|
1718
1933
|
text,
|
|
1719
|
-
prose: hasProse(text)
|
|
1934
|
+
prose: hasProse(text),
|
|
1935
|
+
withheld: state.withheld
|
|
1720
1936
|
};
|
|
1937
|
+
if (state.withheldReason !== void 0) outcome.withheldReason = state.withheldReason;
|
|
1721
1938
|
if (calls.length > 0) outcome.calls = [...calls];
|
|
1722
1939
|
return outcome;
|
|
1723
1940
|
}
|
|
@@ -2832,6 +3049,8 @@ function createWorkBuddyShim(options) {
|
|
|
2832
3049
|
const gate = declared === void 0 ? void 0 : {
|
|
2833
3050
|
declaredNames: declared.names,
|
|
2834
3051
|
requiredParameters: declared.requiredParameters,
|
|
3052
|
+
acceptedParameters: declared.acceptedParameters,
|
|
3053
|
+
inferFromSchema: true,
|
|
2835
3054
|
...declared.pinnedToolName === void 0 ? {} : { pinnedToolName: declared.pinnedToolName }
|
|
2836
3055
|
};
|
|
2837
3056
|
const notice = switchedFrom === void 0 || switchedBecause === void 0 ? void 0 : failoverNoticeText(switchedFrom, switchedBecause);
|
|
@@ -3559,12 +3778,24 @@ function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
|
|
|
3559
3778
|
* 2. **Expiring credits first, soonest first.** Points about to expire are
|
|
3560
3779
|
* worth exactly zero after they do, so spending them before they lapse is
|
|
3561
3780
|
* strictly better than saving them.
|
|
3562
|
-
* 3. **
|
|
3781
|
+
* 3. **User's manual order.** When the user has hand-ordered the pool (see
|
|
3782
|
+
* `orderAccountIds`), that order breaks ties among accounts that are equal
|
|
3783
|
+
* on the keys above AND below. It is the one place the user's own preference
|
|
3784
|
+
* influences "who serves next": the manual picker on the card drags these
|
|
3785
|
+
* ids into place, and the ranking honors the result.
|
|
3786
|
+
* WHY EXPIRY STILL OUTRANKS THE MANUAL ORDER: this rule sits strictly BELOW
|
|
3787
|
+
* the at-risk flag (rule #2), so an account with ANY expiring credits always
|
|
3788
|
+
* beats the manual order even when the user dragged it to the bottom. That
|
|
3789
|
+
* is deliberate (issue #70): a manual order that is unsaved, stale, or
|
|
3790
|
+
* sloppily edited must never cause credits to lapse unspent — expiring
|
|
3791
|
+
* credits are real money about to vanish, while the manual order is just a
|
|
3792
|
+
* preference that might be wrong. This is not a bug, and a test pins it.
|
|
3793
|
+
* 4. **Largest expiring amount.** Between two accounts that both have expiring
|
|
3563
3794
|
* points, the one with more at risk goes first.
|
|
3564
|
-
*
|
|
3795
|
+
* 5. **Credits, highest first.** Among accounts with nothing expiring, spend the
|
|
3565
3796
|
* one with the most, so a nearly-drained account is conserved.
|
|
3566
|
-
*
|
|
3567
|
-
*
|
|
3797
|
+
* 6. **Freshest credential.** Tie-break so a stale token never wins a draw.
|
|
3798
|
+
* 7. **Account id.** Final tie-break, making the order TOTAL: two accounts
|
|
3568
3799
|
* equal on every ranking key would otherwise keep the caller's array order,
|
|
3569
3800
|
* so the same pool could rotate differently between two runs.
|
|
3570
3801
|
*
|
|
@@ -3594,7 +3825,9 @@ function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
|
|
|
3594
3825
|
* throws on missing data (an account with no credits is ranked as 0, not
|
|
3595
3826
|
* crashed on).
|
|
3596
3827
|
*/
|
|
3597
|
-
function rankPool(members, nowMs) {
|
|
3828
|
+
function rankPool(members, nowMs, orderAccountIds) {
|
|
3829
|
+
const order = /* @__PURE__ */ new Map();
|
|
3830
|
+
for (const [index, id] of (orderAccountIds ?? []).entries()) if (!order.has(id)) order.set(id, index);
|
|
3598
3831
|
const rows = members.map((member) => {
|
|
3599
3832
|
const excludedBy = exclusionOf(member.probe, nowMs);
|
|
3600
3833
|
return {
|
|
@@ -3602,6 +3835,7 @@ function rankPool(members, nowMs) {
|
|
|
3602
3835
|
score: scoreOf(member),
|
|
3603
3836
|
...excludedBy === void 0 ? {} : { excludedBy },
|
|
3604
3837
|
_usable: excludedBy === void 0,
|
|
3838
|
+
_order: order.get(member.account.id) ?? Number.POSITIVE_INFINITY,
|
|
3605
3839
|
_atRisk: (member.credits?.expiringSoon ?? 0) > 0,
|
|
3606
3840
|
_expiringAmount: member.credits?.expiringSoon ?? 0,
|
|
3607
3841
|
_expiring: member.credits?.nearestExpiryMs ?? Number.POSITIVE_INFINITY,
|
|
@@ -3611,6 +3845,7 @@ function rankPool(members, nowMs) {
|
|
|
3611
3845
|
rows.sort((left, right) => {
|
|
3612
3846
|
if (left._usable !== right._usable) return left._usable ? -1 : 1;
|
|
3613
3847
|
if (left._atRisk !== right._atRisk) return left._atRisk ? -1 : 1;
|
|
3848
|
+
if (left._order !== right._order) return left._order - right._order;
|
|
3614
3849
|
if (left._atRisk && right._atRisk && left._expiring !== right._expiring) return left._expiring - right._expiring;
|
|
3615
3850
|
if (left._expiringAmount !== right._expiringAmount) return right._expiringAmount - left._expiringAmount;
|
|
3616
3851
|
if (left.score !== right.score) return right.score - left.score;
|
|
@@ -4950,7 +5185,7 @@ async function workBuddyWebPool(deps, region) {
|
|
|
4950
5185
|
if (pool === void 0) return void 0;
|
|
4951
5186
|
const preferences = pool.preferences(region);
|
|
4952
5187
|
const members = await pool.members(region);
|
|
4953
|
-
const ranked = rankPool(members, Date.now());
|
|
5188
|
+
const ranked = rankPool(members, Date.now(), preferences.orderAccountIds);
|
|
4954
5189
|
const catalog = pool.catalog?.(region) ?? [];
|
|
4955
5190
|
const target = resolveTargetModel(catalog, preferences.targetModelId);
|
|
4956
5191
|
const currentAccountId = await pool.currentAccountId?.(region);
|
|
@@ -5015,6 +5250,11 @@ async function workBuddyWebPool(deps, region) {
|
|
|
5015
5250
|
...target.staleModelId === void 0 ? {} : { staleTargetModelId: target.staleModelId },
|
|
5016
5251
|
targetModelSource: target.source,
|
|
5017
5252
|
memberAccountIds: preferences.memberAccountIds,
|
|
5253
|
+
...(() => {
|
|
5254
|
+
const listed = new Set(accounts.map((account) => account.accountId));
|
|
5255
|
+
const orderedIds = (preferences.orderAccountIds ?? []).filter((id) => listed.has(id));
|
|
5256
|
+
return orderedIds.length === 0 ? {} : { orderAccountIds: orderedIds };
|
|
5257
|
+
})(),
|
|
5018
5258
|
...preferences.probeInputTokens === void 0 ? {} : { probeInputTokens: preferences.probeInputTokens },
|
|
5019
5259
|
effectiveMemberAccountIds: effectiveMembersOf(accounts.map((account) => account.accountId), new Set(preferences.memberAccountIds)),
|
|
5020
5260
|
...memberNames === void 0 ? {} : { memberNames },
|
|
@@ -5638,7 +5878,8 @@ const poolConfig = z.object({
|
|
|
5638
5878
|
* an older build — and one hand-edited to something odd — behaves identically.
|
|
5639
5879
|
*/
|
|
5640
5880
|
probeInputTokens: z.number().step(1).default(0).description("Input tokens each test probe sends: 10000 / 20000 / 30000 / 50000 / 100000. Bigger probes catch a rate limit real conversations would hit, but cost more credit per test and may mark an account unusable for large requests only. 0 uses the measured default (25000)"),
|
|
5641
|
-
memberAccountIds: z.array(z.string()).default([]).description("Account ids checked into this pool (opt-in; empty means the pool covers nothing)")
|
|
5881
|
+
memberAccountIds: z.array(z.string()).default([]).description("Account ids checked into this pool (opt-in; empty means the pool covers nothing)"),
|
|
5882
|
+
orderAccountIds: z.array(z.string()).default([]).description("The user's hand-ordered pool order, as account ids. Independent of membership: an id need not be checked in (it only takes effect once the account is a member), and ids not listed sort last. Separate from memberAccountIds so reordering never conflicts with, or is destroyed by, a membership edit.")
|
|
5642
5883
|
});
|
|
5643
5884
|
const regionStateConfig = z.object({
|
|
5644
5885
|
enabled: z.boolean().default(true).description("Whether this region's provider is offered to DSH (opt-out; false withdraws it entirely)"),
|
|
@@ -6413,7 +6654,8 @@ function apply(ctx, config) {
|
|
|
6413
6654
|
const failoverAccountFor = async (region, triedAccountIds) => {
|
|
6414
6655
|
if (!poolPreferencesOf(current(), region).enabled) return void 0;
|
|
6415
6656
|
const tried = new Set(triedAccountIds);
|
|
6416
|
-
|
|
6657
|
+
const order = poolPreferencesOf(current(), region).orderAccountIds;
|
|
6658
|
+
for (const row of rankPool(await localPoolMembers(region), Date.now(), order)) {
|
|
6417
6659
|
if (row.excludedBy !== void 0) continue;
|
|
6418
6660
|
if (tried.has(row.account.id)) continue;
|
|
6419
6661
|
const credential = await stacks[region].store.credentialFor(row.account.id);
|
|
@@ -6601,7 +6843,7 @@ function apply(ctx, config) {
|
|
|
6601
6843
|
store.setRotatedAccount(void 0);
|
|
6602
6844
|
return;
|
|
6603
6845
|
}
|
|
6604
|
-
const winner = rankPool(await localPoolMembers(region), Date.now()).find((row) => row.excludedBy === void 0);
|
|
6846
|
+
const winner = rankPool(await localPoolMembers(region), Date.now(), poolPreferencesOf(current(), region).orderAccountIds).find((row) => row.excludedBy === void 0);
|
|
6605
6847
|
store.setRotatedAccount(winner?.account.id);
|
|
6606
6848
|
};
|
|
6607
6849
|
/**
|
|
@@ -6620,7 +6862,7 @@ function apply(ctx, config) {
|
|
|
6620
6862
|
const credential = await stacks[region].store.current().catch(() => void 0);
|
|
6621
6863
|
if (credential === void 0) return false;
|
|
6622
6864
|
const servingId = workbuddyAccountId(credential);
|
|
6623
|
-
const row = rankPool(await localPoolMembers(region), Date.now()).find((candidate) => candidate.account.id === servingId);
|
|
6865
|
+
const row = rankPool(await localPoolMembers(region), Date.now(), poolPreferencesOf(current(), region).orderAccountIds).find((candidate) => candidate.account.id === servingId);
|
|
6624
6866
|
return row !== void 0 && row.excludedBy !== void 0;
|
|
6625
6867
|
};
|
|
6626
6868
|
/**
|
|
@@ -6686,7 +6928,8 @@ function apply(ctx, config) {
|
|
|
6686
6928
|
enabled: pool?.enabled === true,
|
|
6687
6929
|
targetModelId: pool?.targetModelId ?? "",
|
|
6688
6930
|
...probeInputTokens === void 0 ? {} : { probeInputTokens },
|
|
6689
|
-
memberAccountIds: pool?.memberAccountIds ?? []
|
|
6931
|
+
memberAccountIds: pool?.memberAccountIds ?? [],
|
|
6932
|
+
orderAccountIds: pool?.orderAccountIds ?? []
|
|
6690
6933
|
};
|
|
6691
6934
|
};
|
|
6692
6935
|
/**
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-connect-workbuddy",
|
|
3
3
|
"displayName": "DSH Connect WorkBuddy",
|
|
4
4
|
"description": "把本机登录的 WorkBuddy 模型接入 DeepSeek Harness,并提供只读的积分概览与模型管理。",
|
|
5
|
-
"version": "4.8.
|
|
5
|
+
"version": "4.8.2",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|