dsh-connect-workbuddy 4.8.1 → 4.9.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/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-CS6PxirW.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";
@@ -106,6 +106,38 @@ const WORKBUDDY_PROVIDERS = {
106
106
  cn: WORKBUDDY_PROVIDER,
107
107
  global: WORKBUDDY_GLOBAL_PROVIDER
108
108
  };
109
+ /**
110
+ * Model identity BOTH regions stamp on their models — the fix for issue #59.
111
+ *
112
+ * pi-ai keeps a historical thinking block as `thinking` only when the message
113
+ * that carried it names the SAME provider, api and model id as the request in
114
+ * flight (`transform-messages.js:68`); every other block is downgraded to plain
115
+ * `text` (`:88–96`) — downgraded, not dropped, which is why no byte count ever
116
+ * explained the 1.94× regional jump. The two gateways are two provider STRINGS
117
+ * for ONE model, so a transcript recorded in one region was replayed as text in
118
+ * the other, and issue #59's reporter measured exactly that: 708K → 1.374M on
119
+ * byte-identical history, and a compression request that could then never fit
120
+ * the window it was computed for.
121
+ *
122
+ * `provider` is also the KEY pi-ai dispatches on (`models.js:410–416`
123
+ * `requireProvider`), so the identity cannot simply be the shared string: the
124
+ * registry has to keep resolving per region. Both halves of that are already
125
+ * satisfied by pi-ai's own lookup shape — `getModel(provider, id)` is
126
+ * `getModels(provider).find(...)` (`models.js:116–118`), i.e. the KEY is the
127
+ * route id while the STAMPED field is whatever the provider put there — so the
128
+ * route stays `workbuddy`/`workbuddy-global` (each with its own shim, catalog
129
+ * and credential store) while every model it serves advertises THIS id. This
130
+ * plugin's provider registers no `fetchModels`, so nothing downstream derives
131
+ * anything from the stamped field; the openai-completions api uses
132
+ * `model.provider` only for provider-specific quirks (none of which match
133
+ * these ids) and as an error-message label.
134
+ *
135
+ * Deliberately NOT the same string as either route: a message is only
136
+ * same-model when provider, api AND model id all match, so the shared id must
137
+ * not be reachable as a route a user could pick — it names one model, not one
138
+ * gateway.
139
+ */
140
+ const WORKBUDDY_MODEL_IDENTITY = "workbuddy";
109
141
  /** Region a provider route id belongs to. */
110
142
  function regionOfProvider(provider) {
111
143
  for (const [region, id] of Object.entries(WORKBUDDY_PROVIDERS)) if (id === provider) return region;
@@ -213,14 +245,20 @@ function workBuddyThinkingLevelMap(info) {
213
245
  else map.off = null;
214
246
  return map;
215
247
  }
216
- /** Build one pi-ai model descriptor pointing at the loopback shim. */
217
- function toPiModel(info, baseUrl, providerId) {
248
+ /**
249
+ * Build one pi-ai model descriptor pointing at the loopback shim.
250
+ *
251
+ * `identity` — not the route id — is what `provider` carries; see
252
+ * {@link WORKBUDDY_MODEL_IDENTITY} for why the two must differ. The route the
253
+ * model is REACHED by is the registry key the adapter serves, not this field.
254
+ */
255
+ function toPiModel(info, baseUrl, identity = WORKBUDDY_MODEL_IDENTITY) {
218
256
  const thinkingLevelMap = workBuddyThinkingLevelMap(info);
219
257
  return {
220
258
  id: info.id,
221
259
  name: workBuddyDisplayName(info),
222
260
  api: "openai-completions",
223
- provider: providerId,
261
+ provider: identity,
224
262
  baseUrl,
225
263
  input: workBuddyModelInput(info),
226
264
  cost: NO_COST,
@@ -526,10 +564,11 @@ function createWorkBuddyAdapter(options) {
526
564
  const { shim, store, catalog, resolveAttachments } = options;
527
565
  const providerId = options.provider ?? "workbuddy";
528
566
  const providerName = options.displayName ?? "WorkBuddy";
567
+ const modelIdentity = options.modelIdentity ?? "workbuddy";
529
568
  const generation = options.piAiGeneration ?? piAiRuntimeInfo().generation;
530
569
  const buildModels = () => {
531
570
  const baseUrl = `${shim.baseUrl()}/v1`;
532
- return catalog.current().map((info) => toPiModel(info, baseUrl, providerId));
571
+ return catalog.current().map((info) => toPiModel(info, baseUrl, modelIdentity));
533
572
  };
534
573
  const provider = {
535
574
  ...createProvider({
@@ -688,28 +727,57 @@ async function foldOnce(deps, agent, measurement, window, target) {
688
727
  * what produced it. A paired capture in one session (4.6.7) measured
689
728
  * `bodyBytes` 3,628,068 vs 3,626,374, a ratio of 0.9995: both requests carried
690
729
  * the same bytes. What differed was the BUCKET each byte was counted in — CN
691
- * text 741,952 vs GL 184,821, CN reasoning 72,270 vs GL 627,994, with a
730
+ * text 741,687 vs GL 184,821, CN reasoning 72,270 vs GL 627,994, with a
692
731
  * complementary residual of 0.3%. The gateways did not miscount; the same
693
732
  * history was labelled differently on the two sides.
694
733
  *
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.
734
+ * The labelling is pi-ai's replay — which assistant messages it lets survive as
735
+ * thinking at all. `transform-messages.js:68` (1.1.0, the version the host
736
+ * bundles) computes
737
+ *
738
+ * isSameModel = assistantMsg.provider === model.provider
739
+ * && assistantMsg.api === model.api
740
+ * && assistantMsg.model === model.id;
741
+ *
742
+ * and for a message that fails it, `:88–96` returns the surviving thinking as
743
+ * `{ type: "text", text: block.thinking }` — downgraded, not dropped — while
744
+ * `:76` drops redacted thinking and `:103` strips tool-call `thoughtSignature`.
745
+ * `buildParams` runs that transform at `openai-completions.js:896`, before
746
+ * anything is assembled, so a thinking block only stays a thinking block when
747
+ * its message was recorded under the same provider AND the same model id as the
748
+ * request now in flight. Survivors leave as the raw field: `:1002–1008` writes
749
+ * `assistantMsg[signature] = <thinking>`, `signature` being the field name the
750
+ * stream used, recorded per block at `:399–416` (`reasoning_content` for this
751
+ * vendor, uniformly — 790 replay records in one real session held that value
752
+ * and no other). Non-survivors are text by the time `:964–971` collects
753
+ * `assistantText`, so they land in `assistantMsg.content` instead. This plugin
754
+ * sets only `compat.supportsReasoningEffort` (`src/adapter.ts:200`), which
755
+ * leaves `requiresThinkingAsText` at its `false` default (`:1270`), so the raw
756
+ * field route is the live one rather than the fold-into-content route at `:983`.
757
+ *
758
+ * Two provider ids for one model is therefore two identities for one model, and
759
+ * a region switch flips the sign for the entire other side at once: everything
760
+ * that side ever thought is replayed as content instead of as a reasoning field,
761
+ * and the gateways count content. That is the 1.94× above; the same formula read
762
+ * in the other direction is the 0.696× the reverse switch produces (text
763
+ * 642,800 vs 447,306 across the same bytes). The model-id leg of `isSameModel`
764
+ * matters for the same reason — switching models inside ONE region has the same
765
+ * effect — which is why the symptom belongs in `README`'s 已知限制, where users
766
+ * meet it, and not only here.
767
+ *
768
+ * The retraction trail, because this header has misled twice. The first version
769
+ * claimed historical `reasoning` never reaches the wire, because
770
+ * `requiresThinkingAsText` is unset and the `reasoning_details` route needs a
771
+ * signature the parser never writes. Both halves are wrong: the unset flag is
772
+ * precisely what routes thinking out as a raw field. The second version — the
773
+ * one this paragraph replaces — claimed the difference was the field NAME,
774
+ * signature-driven, citing 0.85.1's `:428`/`:1006` and concluding that a gateway
775
+ * saying `reasoning` replays as `reasoning`. That cannot be right either: both
776
+ * gateways say `reasoning_content`, so a name-keyed story predicts NO difference
777
+ * between them, and the host ships 1.1.0, not 0.85.1. What differs is survival,
778
+ * not naming. The file keeps both dead ends on purpose — each one shaped what
779
+ * this report measures, and the third version only settled the question because
780
+ * the report could show bytes equal while buckets were not.
713
781
  *
714
782
  * This module exists to settle exactly that, and it can do it WITHOUT either
715
783
  * region having credit: the body is assembled locally, before the upstream ever
@@ -1478,8 +1546,9 @@ function nestedOrText(value) {
1478
1546
  * This is where gates 2 and 4 live. Returning nothing is the COMMON case for
1479
1547
  * mistaken input and is not an error: callers must show the block verbatim.
1480
1548
  */
1481
- function parseBlock(block, gate) {
1549
+ function parseBlock(block, gate, seen) {
1482
1550
  const calls = [];
1551
+ let withheld = false;
1483
1552
  const spans = ignoredSpans(block);
1484
1553
  let index = 0;
1485
1554
  while (index < block.length) {
@@ -1490,21 +1559,34 @@ function parseBlock(block, gate) {
1490
1559
  if (close === void 0) break;
1491
1560
  const call = parseInvoke(tag, close, block, gate);
1492
1561
  if (call !== void 0) calls.push(call);
1562
+ else if (tag.attributes.trim() === "" && gate.onWithheld !== void 0) {
1563
+ seen.reason ??= "unnamed-invoke";
1564
+ withheld = true;
1565
+ }
1493
1566
  index = close.end + 1;
1494
1567
  continue;
1495
1568
  }
1496
1569
  index = tag.end + 1;
1497
1570
  }
1498
- return calls;
1571
+ return {
1572
+ calls,
1573
+ withheld
1574
+ };
1499
1575
  }
1500
1576
  /** One `<invoke>` element → a call, or nothing when a gate refuses it. */
1501
1577
  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;
1578
+ const declared = parseAttributes(open.attributes)["name"];
1579
+ const parameters = parseParameters(block.slice(open.end + 1, close.start));
1580
+ let name;
1581
+ if (declared !== void 0 && declared !== "") {
1582
+ if (!gate.declaredNames.has(declared)) return void 0;
1583
+ name = declared;
1584
+ } else if (open.attributes.trim() === "") {
1585
+ name = inferNameFromSchema(parameters, gate);
1586
+ if (name === void 0) return void 0;
1587
+ } else return;
1505
1588
  const pinned = gate.pinnedToolName;
1506
1589
  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
1590
  const required = gate.requiredParameters?.get(name);
1509
1591
  if (required !== void 0 && required.some((parameter) => parameters[parameter] === void 0)) return void 0;
1510
1592
  return {
@@ -1514,6 +1596,35 @@ function parseInvoke(open, close, block, gate) {
1514
1596
  };
1515
1597
  }
1516
1598
  /**
1599
+ * Name a call from its ARGUMENTS when the opener names no tool — or refuse.
1600
+ *
1601
+ * The evidence is the model's own parameter tags; the constraint is the
1602
+ * request's schemas. A tool qualifies only when every tag the block carries is
1603
+ * accepted by its schema AND every required parameter is already present, and
1604
+ * exactly ONE declared tool qualifies. Everything less certain refuses:
1605
+ *
1606
+ * - a block with no parameter tags at all (no evidence to read);
1607
+ * - a tool whose schema is unknown here (it cannot be cleared — silence must
1608
+ * not let an unverified tool win or lose);
1609
+ * - a parameter no accepted set lists;
1610
+ * - two tools that both fit ("the only tool on offer" was the whole point).
1611
+ */
1612
+ function inferNameFromSchema(parameters, gate) {
1613
+ const tags = Object.keys(parameters);
1614
+ if (tags.length === 0) return void 0;
1615
+ let fit;
1616
+ for (const tool of gate.declaredNames) {
1617
+ const accepted = gate.acceptedParameters?.get(tool);
1618
+ if (accepted === void 0) return void 0;
1619
+ if (tags.some((tag) => !accepted.has(tag))) continue;
1620
+ const required = gate.requiredParameters?.get(tool);
1621
+ if (required !== void 0 && required.some((parameter) => parameters[parameter] === void 0)) continue;
1622
+ if (fit !== void 0) return void 0;
1623
+ fit = tool;
1624
+ }
1625
+ return fit;
1626
+ }
1627
+ /**
1517
1628
  * `text` with every markup block removed — the check behind {@link hasProse}.
1518
1629
  *
1519
1630
  * ANY marker-bearing tag counts as markup, whatever it is called. Keying on the
@@ -1556,6 +1667,74 @@ function stripMarkup(text) {
1556
1667
  return result;
1557
1668
  }
1558
1669
  /**
1670
+ * Drop stray marker-bearing tags from otherwise-ordinary prose, and say so.
1671
+ *
1672
+ * This is the OTHER half of "the user must never see protocol debris": a block
1673
+ * is handled by {@link parseBlock} (recovered, withheld, or shown verbatim by
1674
+ * gate 4), but lone tags outside every block — `</|DSML|| calls>` closing a
1675
+ * wrapper whose opener belonged to a withheld call, `<|DSML| validate>` —
1676
+ * arrive as plain text and used to be forwarded raw. They are markup, not the
1677
+ * answer: {@link stripMarkup} already knows exactly which bytes those are
1678
+ * (marker-bearing tags only, quoted ones inside fences and code spans kept),
1679
+ * so this runs it and records the removal. The record makes it reportable —
1680
+ * silence about removing content is the failure mode this whole module exists
1681
+ * to avoid.
1682
+ *
1683
+ * Gate 4's "show it verbatim" applies to BLOCKS (which may be content); a lone
1684
+ * marker tag has no content of its own to lose, only the name it wears.
1685
+ */
1686
+ function stripStray(text, seen) {
1687
+ const cleaned = stripMarkup(text);
1688
+ if (cleaned !== text) seen.reason ??= "stray-markup";
1689
+ return cleaned;
1690
+ }
1691
+ /**
1692
+ * Drop a marker-bearing tag fragment that the stream held and can never finish.
1693
+ *
1694
+ * {@link holdFrom} keeps back a tail that could still become a block opener, so
1695
+ * at end of stream that held tail is by construction an unterminated `<…`. Two
1696
+ * outcomes, and the difference is the marker:
1697
+ *
1698
+ * - it carries the marker (`<|DSM`, `</|DSML| invo`): the marker is what
1699
+ * makes it protocol debris rather than prose. Its close is never coming, so
1700
+ * forwarding it hands the user half a tag — the `</|DSML| invo` that
1701
+ * trailed a killed stream. It goes, and says so.
1702
+ * - it does not (`<`, `</`, `<tool`): a bare `<` is doubt, not evidence, and
1703
+ * is kept. A person may have meant the character, and half a word is a
1704
+ * worse lie than a stray bracket.
1705
+ *
1706
+ * Only the LAST `<` is examined. Everything before it was already cleared by
1707
+ * `holdFrom`, and an earlier `<` inside ordinary prose must not be dragged out
1708
+ * along with the fragment that happens to follow it.
1709
+ */
1710
+ function dropUnfinishedMarkerTail(text, seen) {
1711
+ const lastLt = text.lastIndexOf("<");
1712
+ if (lastLt === -1) return text;
1713
+ if (!isLateMarkerFragment(text.slice(lastLt))) return text;
1714
+ seen.reason ??= "stray-markup";
1715
+ return text.slice(0, lastLt);
1716
+ }
1717
+ /**
1718
+ * Is this unterminated tag fragment a marker-bearing one?
1719
+ *
1720
+ * Deliberately narrow, for the same reason {@link looksLikeTagStart} is: it must
1721
+ * still be open (`>` never arrived, which is what "held at end of stream" means)
1722
+ * and, once the `<` and an optional `/` are stripped, the rest must BE a partial
1723
+ * marker or be a complete marker followed by a partial name. `<tool` fails the
1724
+ * second test and survives; so does `<`, which is not evidence of anything.
1725
+ */
1726
+ function isLateMarkerFragment(tail) {
1727
+ if (!tail.startsWith("<")) return false;
1728
+ if (tail.includes(">")) return false;
1729
+ let body = tail.slice(1);
1730
+ if (body.startsWith("/")) body = body.slice(1);
1731
+ if (body === "") return false;
1732
+ if (isPartialMarker(body)) return true;
1733
+ const prefixLength = prefixLengthAt(body, 0);
1734
+ if (prefixLength === 0) return false;
1735
+ return /^[A-Za-z_-]*$/.test(body.slice(prefixLength).trim());
1736
+ }
1737
+ /**
1559
1738
  * Does `text` carry content outside markup blocks?
1560
1739
  *
1561
1740
  * This is what makes "the turn produced nothing usable" a checkable fact rather
@@ -1610,6 +1789,7 @@ function looksLikeTagStart(tail) {
1610
1789
  if (body.startsWith("/")) body = body.slice(1);
1611
1790
  if (body === "" || isPartialMarker(body)) return true;
1612
1791
  const prefixLength = prefixLengthAt(body, 0);
1792
+ if (prefixLength > 0) return true;
1613
1793
  body = body.slice(prefixLength).trim().toLowerCase();
1614
1794
  if (body === "" || isPartialMarker(body)) return true;
1615
1795
  return [
@@ -1665,6 +1845,22 @@ function normalizeFullwidth(text) {
1665
1845
  var DsmlStreamBuffer = class {
1666
1846
  pending = "";
1667
1847
  gate;
1848
+ /** The first withholding reason observed in this stream, reported once. */
1849
+ withHeld = {};
1850
+ /**
1851
+ * Set once anything has been withheld from this response.
1852
+ *
1853
+ * It gates the debris sweep, and that is the whole point: gate 4 promises a
1854
+ * REFUSED block is shown verbatim, so marker text must survive the stream
1855
+ * untouched in every response that withheld nothing. Only after a block has
1856
+ * been removed can the tags left around it be known to belong to markup the
1857
+ * reader is no longer seeing — the wrapper's own `calls` closer, the opener
1858
+ * the withheld call never got. Before that, a marker tag in the text may be
1859
+ * the answer itself.
1860
+ */
1861
+ withheldOnce = false;
1862
+ /** Whether the one notice allowed per response has gone out. See `takeWithheld`. */
1863
+ noticeSent = false;
1668
1864
  constructor(gate) {
1669
1865
  this.gate = gate;
1670
1866
  }
@@ -1678,46 +1874,106 @@ var DsmlStreamBuffer = class {
1678
1874
  this.pending = "";
1679
1875
  return {
1680
1876
  text,
1681
- prose: hasProse(text)
1877
+ prose: hasProse(text),
1878
+ withheld: false
1682
1879
  };
1683
1880
  }
1684
1881
  for (;;) {
1685
1882
  const found = findFirstOpener(this.pending, ignoredSpans(this.pending));
1686
1883
  if (found === void 0) break;
1687
- const head = this.pending.slice(0, found.open.start);
1884
+ const head = this.stripDebris(this.pending.slice(0, found.open.start));
1688
1885
  if (head !== "") parts.push(head);
1689
1886
  if (found.close === void 0) {
1690
1887
  this.pending = this.pending.slice(found.open.start);
1691
- return finish(parts, calls);
1888
+ return finish(parts, calls, this.takeWithheld());
1692
1889
  }
1693
1890
  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);
1891
+ const parsed = parseBlock(body, this.gate, this.withHeld);
1892
+ if (parsed.calls.length > 0) calls.push(...parsed.calls);
1893
+ else if (!parsed.withheld) parts.push(body);
1697
1894
  this.pending = this.pending.slice(found.close.end + 1);
1698
1895
  }
1699
1896
  const hold = holdFrom(this.pending, ignoredSpans(this.pending));
1700
- if (hold > 0) parts.push(this.pending.slice(0, hold));
1897
+ if (hold > 0) {
1898
+ const tail = this.stripDebris(this.pending.slice(0, hold));
1899
+ if (tail !== "") parts.push(tail);
1900
+ }
1701
1901
  this.pending = this.pending.slice(hold);
1702
- return finish(parts, calls);
1902
+ return finish(parts, calls, this.takeWithheld());
1703
1903
  }
1704
- /** End of stream: hand back everything still held, verbatim. */
1904
+ /** End of stream: hand back everything still held — minus the debris. */
1705
1905
  flush() {
1706
- const text = this.pending;
1906
+ let text = this.pending;
1707
1907
  this.pending = "";
1708
- return {
1908
+ if (isUnnamedInvoke(text) && this.gate.onWithheld !== void 0) {
1909
+ this.withHeld.reason ??= "unnamed-invoke";
1910
+ text = "";
1911
+ }
1912
+ if (this.debris()) {
1913
+ text = dropUnfinishedMarkerTail(text, this.withHeld);
1914
+ text = stripStray(text, this.withHeld);
1915
+ }
1916
+ const state = this.takeWithheld();
1917
+ const outcome = {
1709
1918
  text,
1710
- prose: hasProse(text)
1919
+ prose: hasProse(text),
1920
+ withheld: state.withheld
1921
+ };
1922
+ if (state.withheldReason !== void 0) outcome.withheldReason = state.withheldReason;
1923
+ return outcome;
1924
+ }
1925
+ /**
1926
+ * Apply the debris sweep, but only to a response that has withheld something.
1927
+ *
1928
+ * See {@link withheldOnce}: before the first withhold, marker text in the
1929
+ * prose is the caller's to show, not this layer's to delete.
1930
+ */
1931
+ stripDebris(text) {
1932
+ return this.debris() ? stripStray(text, this.withHeld) : text;
1933
+ }
1934
+ /** Has anything been withheld from this response, yet or already? */
1935
+ debris() {
1936
+ return this.withheldOnce || this.withHeld.reason !== void 0;
1937
+ }
1938
+ /**
1939
+ * Hand the accumulated reason to exactly one outcome.
1940
+ *
1941
+ * Exactly one, because the notice it drives is emitted once per response. A
1942
+ * reason reported on two outcomes would print the same sentence twice.
1943
+ */
1944
+ takeWithheld() {
1945
+ const reason = this.withHeld.reason;
1946
+ if (reason === void 0) return { withheld: false };
1947
+ this.withHeld = {};
1948
+ this.withheldOnce = true;
1949
+ if (this.noticeSent) return { withheld: true };
1950
+ this.noticeSent = true;
1951
+ this.gate.onWithheld?.(reason);
1952
+ return {
1953
+ withheld: true,
1954
+ withheldReason: reason
1711
1955
  };
1712
1956
  }
1713
1957
  };
1958
+ /**
1959
+ * Is this block an invocation the model wrote without naming a tool?
1960
+ *
1961
+ * Deliberately narrow: no attribute at all on the opener. `<invoke name="">`
1962
+ * is a REFUSED call with a name the gates can judge, and gate 4 shows it.
1963
+ */
1964
+ function isUnnamedInvoke(body) {
1965
+ const open = scanTag(body, 0);
1966
+ return open !== void 0 && !open.closing && open.name === "invoke" && open.attributes.trim() === "";
1967
+ }
1714
1968
  /** Assemble one `add()` outcome without ever emitting a `calls: undefined` key. */
1715
- function finish(parts, calls) {
1969
+ function finish(parts, calls, state) {
1716
1970
  const text = parts.join("");
1717
1971
  const outcome = {
1718
1972
  text,
1719
- prose: hasProse(text)
1973
+ prose: hasProse(text),
1974
+ withheld: state.withheld
1720
1975
  };
1976
+ if (state.withheldReason !== void 0) outcome.withheldReason = state.withheldReason;
1721
1977
  if (calls.length > 0) outcome.calls = [...calls];
1722
1978
  return outcome;
1723
1979
  }
@@ -2832,6 +3088,8 @@ function createWorkBuddyShim(options) {
2832
3088
  const gate = declared === void 0 ? void 0 : {
2833
3089
  declaredNames: declared.names,
2834
3090
  requiredParameters: declared.requiredParameters,
3091
+ acceptedParameters: declared.acceptedParameters,
3092
+ inferFromSchema: true,
2835
3093
  ...declared.pinnedToolName === void 0 ? {} : { pinnedToolName: declared.pinnedToolName }
2836
3094
  };
2837
3095
  const notice = switchedFrom === void 0 || switchedBecause === void 0 ? void 0 : failoverNoticeText(switchedFrom, switchedBecause);
@@ -3559,12 +3817,24 @@ function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
3559
3817
  * 2. **Expiring credits first, soonest first.** Points about to expire are
3560
3818
  * worth exactly zero after they do, so spending them before they lapse is
3561
3819
  * strictly better than saving them.
3562
- * 3. **Largest expiring amount.** Between two accounts that both have expiring
3820
+ * 3. **User's manual order.** When the user has hand-ordered the pool (see
3821
+ * `orderAccountIds`), that order breaks ties among accounts that are equal
3822
+ * on the keys above AND below. It is the one place the user's own preference
3823
+ * influences "who serves next": the manual picker on the card drags these
3824
+ * ids into place, and the ranking honors the result.
3825
+ * WHY EXPIRY STILL OUTRANKS THE MANUAL ORDER: this rule sits strictly BELOW
3826
+ * the at-risk flag (rule #2), so an account with ANY expiring credits always
3827
+ * beats the manual order even when the user dragged it to the bottom. That
3828
+ * is deliberate (issue #70): a manual order that is unsaved, stale, or
3829
+ * sloppily edited must never cause credits to lapse unspent — expiring
3830
+ * credits are real money about to vanish, while the manual order is just a
3831
+ * preference that might be wrong. This is not a bug, and a test pins it.
3832
+ * 4. **Largest expiring amount.** Between two accounts that both have expiring
3563
3833
  * points, the one with more at risk goes first.
3564
- * 4. **Credits, highest first.** Among accounts with nothing expiring, spend the
3834
+ * 5. **Credits, highest first.** Among accounts with nothing expiring, spend the
3565
3835
  * 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
3836
+ * 6. **Freshest credential.** Tie-break so a stale token never wins a draw.
3837
+ * 7. **Account id.** Final tie-break, making the order TOTAL: two accounts
3568
3838
  * equal on every ranking key would otherwise keep the caller's array order,
3569
3839
  * so the same pool could rotate differently between two runs.
3570
3840
  *
@@ -3594,7 +3864,9 @@ function retryDue(probe, nowMs, fallbackMs = POOL_UNKNOWN_COOLDOWN_MS) {
3594
3864
  * throws on missing data (an account with no credits is ranked as 0, not
3595
3865
  * crashed on).
3596
3866
  */
3597
- function rankPool(members, nowMs) {
3867
+ function rankPool(members, nowMs, orderAccountIds) {
3868
+ const order = /* @__PURE__ */ new Map();
3869
+ for (const [index, id] of (orderAccountIds ?? []).entries()) if (!order.has(id)) order.set(id, index);
3598
3870
  const rows = members.map((member) => {
3599
3871
  const excludedBy = exclusionOf(member.probe, nowMs);
3600
3872
  return {
@@ -3602,6 +3874,7 @@ function rankPool(members, nowMs) {
3602
3874
  score: scoreOf(member),
3603
3875
  ...excludedBy === void 0 ? {} : { excludedBy },
3604
3876
  _usable: excludedBy === void 0,
3877
+ _order: order.get(member.account.id) ?? Number.POSITIVE_INFINITY,
3605
3878
  _atRisk: (member.credits?.expiringSoon ?? 0) > 0,
3606
3879
  _expiringAmount: member.credits?.expiringSoon ?? 0,
3607
3880
  _expiring: member.credits?.nearestExpiryMs ?? Number.POSITIVE_INFINITY,
@@ -3611,6 +3884,7 @@ function rankPool(members, nowMs) {
3611
3884
  rows.sort((left, right) => {
3612
3885
  if (left._usable !== right._usable) return left._usable ? -1 : 1;
3613
3886
  if (left._atRisk !== right._atRisk) return left._atRisk ? -1 : 1;
3887
+ if (left._order !== right._order) return left._order - right._order;
3614
3888
  if (left._atRisk && right._atRisk && left._expiring !== right._expiring) return left._expiring - right._expiring;
3615
3889
  if (left._expiringAmount !== right._expiringAmount) return right._expiringAmount - left._expiringAmount;
3616
3890
  if (left.score !== right.score) return right.score - left.score;
@@ -4950,7 +5224,7 @@ async function workBuddyWebPool(deps, region) {
4950
5224
  if (pool === void 0) return void 0;
4951
5225
  const preferences = pool.preferences(region);
4952
5226
  const members = await pool.members(region);
4953
- const ranked = rankPool(members, Date.now());
5227
+ const ranked = rankPool(members, Date.now(), preferences.orderAccountIds);
4954
5228
  const catalog = pool.catalog?.(region) ?? [];
4955
5229
  const target = resolveTargetModel(catalog, preferences.targetModelId);
4956
5230
  const currentAccountId = await pool.currentAccountId?.(region);
@@ -5015,6 +5289,11 @@ async function workBuddyWebPool(deps, region) {
5015
5289
  ...target.staleModelId === void 0 ? {} : { staleTargetModelId: target.staleModelId },
5016
5290
  targetModelSource: target.source,
5017
5291
  memberAccountIds: preferences.memberAccountIds,
5292
+ ...(() => {
5293
+ const listed = new Set(accounts.map((account) => account.accountId));
5294
+ const orderedIds = (preferences.orderAccountIds ?? []).filter((id) => listed.has(id));
5295
+ return orderedIds.length === 0 ? {} : { orderAccountIds: orderedIds };
5296
+ })(),
5018
5297
  ...preferences.probeInputTokens === void 0 ? {} : { probeInputTokens: preferences.probeInputTokens },
5019
5298
  effectiveMemberAccountIds: effectiveMembersOf(accounts.map((account) => account.accountId), new Set(preferences.memberAccountIds)),
5020
5299
  ...memberNames === void 0 ? {} : { memberNames },
@@ -5638,7 +5917,8 @@ const poolConfig = z.object({
5638
5917
  * an older build — and one hand-edited to something odd — behaves identically.
5639
5918
  */
5640
5919
  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)")
5920
+ memberAccountIds: z.array(z.string()).default([]).description("Account ids checked into this pool (opt-in; empty means the pool covers nothing)"),
5921
+ 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
5922
  });
5643
5923
  const regionStateConfig = z.object({
5644
5924
  enabled: z.boolean().default(true).description("Whether this region's provider is offered to DSH (opt-out; false withdraws it entirely)"),
@@ -6413,7 +6693,8 @@ function apply(ctx, config) {
6413
6693
  const failoverAccountFor = async (region, triedAccountIds) => {
6414
6694
  if (!poolPreferencesOf(current(), region).enabled) return void 0;
6415
6695
  const tried = new Set(triedAccountIds);
6416
- for (const row of rankPool(await localPoolMembers(region), Date.now())) {
6696
+ const order = poolPreferencesOf(current(), region).orderAccountIds;
6697
+ for (const row of rankPool(await localPoolMembers(region), Date.now(), order)) {
6417
6698
  if (row.excludedBy !== void 0) continue;
6418
6699
  if (tried.has(row.account.id)) continue;
6419
6700
  const credential = await stacks[region].store.credentialFor(row.account.id);
@@ -6601,7 +6882,7 @@ function apply(ctx, config) {
6601
6882
  store.setRotatedAccount(void 0);
6602
6883
  return;
6603
6884
  }
6604
- const winner = rankPool(await localPoolMembers(region), Date.now()).find((row) => row.excludedBy === void 0);
6885
+ const winner = rankPool(await localPoolMembers(region), Date.now(), poolPreferencesOf(current(), region).orderAccountIds).find((row) => row.excludedBy === void 0);
6605
6886
  store.setRotatedAccount(winner?.account.id);
6606
6887
  };
6607
6888
  /**
@@ -6620,7 +6901,7 @@ function apply(ctx, config) {
6620
6901
  const credential = await stacks[region].store.current().catch(() => void 0);
6621
6902
  if (credential === void 0) return false;
6622
6903
  const servingId = workbuddyAccountId(credential);
6623
- const row = rankPool(await localPoolMembers(region), Date.now()).find((candidate) => candidate.account.id === servingId);
6904
+ const row = rankPool(await localPoolMembers(region), Date.now(), poolPreferencesOf(current(), region).orderAccountIds).find((candidate) => candidate.account.id === servingId);
6624
6905
  return row !== void 0 && row.excludedBy !== void 0;
6625
6906
  };
6626
6907
  /**
@@ -6686,7 +6967,8 @@ function apply(ctx, config) {
6686
6967
  enabled: pool?.enabled === true,
6687
6968
  targetModelId: pool?.targetModelId ?? "",
6688
6969
  ...probeInputTokens === void 0 ? {} : { probeInputTokens },
6689
- memberAccountIds: pool?.memberAccountIds ?? []
6970
+ memberAccountIds: pool?.memberAccountIds ?? [],
6971
+ orderAccountIds: pool?.orderAccountIds ?? []
6690
6972
  };
6691
6973
  };
6692
6974
  /**
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.9.0",
6
6
  "author": "dingminhua",
7
7
  "copyright": "Copyright (c) 2026 LaoDing",
8
8
  "license": "MIT",