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/CHANGELOG.md +39 -0
- package/README.en.md +4 -0
- package/README.md +7 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +989 -198
- package/lib/{host-heartbeat-BfAvb1_d.js → host-heartbeat-CS6PxirW.js} +11 -2
- package/lib/index.d.ts +39 -0
- package/lib/index.js +337 -55
- package/package.json +1 -1
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-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
|
-
/**
|
|
217
|
-
|
|
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:
|
|
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,
|
|
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,
|
|
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
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
* `
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
* `
|
|
711
|
-
*
|
|
712
|
-
*
|
|
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
|
|
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
|
|
1503
|
-
|
|
1504
|
-
|
|
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
|
|
1695
|
-
if (
|
|
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)
|
|
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
|
|
1904
|
+
/** End of stream: hand back everything still held — minus the debris. */
|
|
1705
1905
|
flush() {
|
|
1706
|
-
|
|
1906
|
+
let text = this.pending;
|
|
1707
1907
|
this.pending = "";
|
|
1708
|
-
|
|
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. **
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3567
|
-
*
|
|
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
|
-
|
|
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.
|
|
5
|
+
"version": "4.9.0",
|
|
6
6
|
"author": "dingminhua",
|
|
7
7
|
"copyright": "Copyright (c) 2026 LaoDing",
|
|
8
8
|
"license": "MIT",
|