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.
@@ -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 required = parameters["required"];
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.1";
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-BfAvb1_d.js";
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, not this plugin's. `openai-completions.js`
696
- * records the reasoning field name the upstream stream actually used into
697
- * `thinkingSignature` (`:428`), and on the way back out writes
698
- * `assistantMsg[<that name>] = <the thinking text>` (`:1006`), so a
699
- * transcript produced against a gateway that says `reasoning` is replayed as
700
- * `reasoning` and one that says `reasoning_content` as `reasoning_content`.
701
- * This plugin sets only `compat.supportsReasoningEffort` (`src/adapter.ts:200`),
702
- * so the `else` branch is taken and the field goes out raw rather than folded
703
- * into text.
704
- *
705
- * An earlier revision of this comment claimed the opposite — that historical
706
- * `reasoning` never reaches the wire, because `requiresThinkingAsText` is unset
707
- * and the `reasoning_details` route needs a signature the parser never writes.
708
- * Both halves are wrong under pi-ai 0.85.1: the unset flag is precisely what
709
- * routes thinking out as a raw field, and the parser does write the
710
- * `reasoning_details` signature (`:188–193`). The correction matters because it
711
- * turns `reasoning` into a bucket that can be non-zero — and diffing it across
712
- * regions is the direct measurement of the drift.
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 calls;
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 name = parseAttributes(open.attributes)["name"];
1503
- if (name === void 0 || name === "") return void 0;
1504
- if (!gate.declaredNames.has(name)) return void 0;
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 recovered = parseBlock(body, this.gate);
1695
- if (recovered.length > 0) calls.push(...recovered);
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) parts.push(this.pending.slice(0, hold));
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, verbatim. */
1865
+ /** End of stream: hand back everything still held — minus the debris. */
1705
1866
  flush() {
1706
- const text = this.pending;
1867
+ let text = this.pending;
1707
1868
  this.pending = "";
1708
- return {
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. **Largest expiring amount.** Between two accounts that both have expiring
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
- * 4. **Credits, highest first.** Among accounts with nothing expiring, spend the
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
- * 5. **Freshest credential.** Tie-break so a stale token never wins a draw.
3567
- * 6. **Account id.** Final tie-break, making the order TOTAL: two accounts
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
- for (const row of rankPool(await localPoolMembers(region), Date.now())) {
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.1",
5
+ "version": "4.8.2",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",