dsh-workbuddy-connect 0.4.0 → 0.5.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/README.en.md +27 -9
- package/README.md +28 -9
- package/lib/bin.js +71 -23
- package/lib/client.js +1123 -970
- package/lib/index.d.ts +402 -19
- package/lib/index.js +733 -183
- package/lib/{host-heartbeat-0UAWqWac.js → variants-GgLTJgZs.js} +1896 -1002
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,15 +1,136 @@
|
|
|
1
|
-
import { C as
|
|
1
|
+
import { A as parseModelCatalog, B as readBundleVersion, C as desktopAuthCandidatesFor, D as classifyUpstreamError, E as WorkBuddyUpstreamClient, F as probeModel, H as validAppVersion, I as randomSentinel, L as WORKBUDDY_APP_VERSION_FILENAME, M as prepareInternationalChatBody, N as regionOf, O as modelWithCurrentPromotion, P as PROBE_EFFORT_CANDIDATES, R as appUserAgent, S as defaultDesktopAuthPath, T as workbuddyOwnAuthPath, V as resolveAppVersion, _ as WorkBuddyCatalog, a as WORKBUDDY_HOST_HEARTBEAT_FILENAME, b as WorkBuddyCredentialStore, c as processStartTimeMs, d as writeHostHeartbeat, f as WORKBUDDY_CONNECT_VERSION, g as FALLBACK_WORKBUDDY_MODELS, h as FALLBACK_WORKBUDDY_AI_MODELS, i as variantFor, j as prepareChatBody, k as normalizeCredits, l as readHostHeartbeat, n as CN_VARIANT, o as clearHostHeartbeat, r as WORKBUDDY_VARIANTS, s as isHeartbeatProcessAlive, t as AI_VARIANT, u as workbuddyHostHeartbeatPath, v as WORKBUDDY_AUTH_FILENAME, w as parseWorkBuddyAuth, x as defaultDesktopAuthCandidates, y as WORKBUDDY_AUTH_FILE_ENV, z as installedAppVersion } from "./variants-GgLTJgZs.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
5
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
7
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
6
8
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
7
9
|
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
8
10
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
9
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
11
|
import { createServer } from "node:http";
|
|
11
12
|
import { Readable } from "node:stream";
|
|
12
|
-
|
|
13
|
+
//#region src/catalog-store.ts
|
|
14
|
+
/**
|
|
15
|
+
* The last catalog that actually loaded, kept per variant and per account.
|
|
16
|
+
*
|
|
17
|
+
* Both the plan (§4 "降级顺序为同版同来源的最近成功目录 → 本版内置保守目录")
|
|
18
|
+
* and the README promise this fallback, and without it a restart always drops
|
|
19
|
+
* the user to the built-in roster even when a good catalog was fetched minutes
|
|
20
|
+
* earlier. The built-in roster is a snapshot taken once; a fetched catalog is
|
|
21
|
+
* what the upstream actually serves to this account.
|
|
22
|
+
*
|
|
23
|
+
* What it deliberately is *not*:
|
|
24
|
+
*
|
|
25
|
+
* - not a cache with a freshness policy — it never prevents a fetch, it only
|
|
26
|
+
* answers when a fetch cannot;
|
|
27
|
+
* - not shared across accounts (a different account can see a different roster
|
|
28
|
+
* and different promotions), nor across variants (the CN and international
|
|
29
|
+
* endpoints disagree about rates and windows for the same model id);
|
|
30
|
+
* - not a place for secrets: model metadata only, never a token. The account
|
|
31
|
+
* key is a `uid:enterpriseId` identity already visible in the status document.
|
|
32
|
+
*
|
|
33
|
+
* @module dsh-workbuddy-connect/catalog-store
|
|
34
|
+
*/
|
|
35
|
+
/** On-disk format this reader accepts; other versions are discarded. */
|
|
36
|
+
const CATALOG_FORMAT_VERSION = 1;
|
|
37
|
+
/** Basename of the CN variant's saved catalog inside the Harness home. */
|
|
38
|
+
const WORKBUDDY_CATALOG_FILENAME = ".workbuddy-catalog.json";
|
|
39
|
+
/** Plugin-owned saved-catalog path inside the Harness home. */
|
|
40
|
+
function workbuddyCatalogPath(filename = WORKBUDDY_CATALOG_FILENAME) {
|
|
41
|
+
return join(resolveDshHome(), filename);
|
|
42
|
+
}
|
|
43
|
+
/** Whether a parsed value is a model row worth keeping. */
|
|
44
|
+
function isModel(value) {
|
|
45
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
46
|
+
const row = value;
|
|
47
|
+
return typeof row["id"] === "string" && row["id"] !== "" && typeof row["name"] === "string" && typeof row["contextWindow"] === "number" && Number.isFinite(row["contextWindow"]) && typeof row["maxTokens"] === "number" && Number.isFinite(row["maxTokens"]) && typeof row["supportsImages"] === "boolean";
|
|
48
|
+
}
|
|
49
|
+
/** Whether a parsed value is a saved catalog this reader can trust. */
|
|
50
|
+
function isSaved(value) {
|
|
51
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
52
|
+
const entry = value;
|
|
53
|
+
if (typeof entry["account"] !== "string" || entry["account"] === "") return false;
|
|
54
|
+
if (typeof entry["source"] !== "string" || entry["source"] === "") return false;
|
|
55
|
+
if (typeof entry["fetchedAtMs"] !== "number" || !Number.isFinite(entry["fetchedAtMs"])) return false;
|
|
56
|
+
const models = entry["models"];
|
|
57
|
+
if (!Array.isArray(models) || models.length === 0) return false;
|
|
58
|
+
return models.every(isModel);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The last successful catalog per account, read once and written atomically.
|
|
62
|
+
*
|
|
63
|
+
* Malformed content reads as "nothing saved" rather than throwing: this file
|
|
64
|
+
* is an optimization for the offline and first-seconds cases, and a corrupt one
|
|
65
|
+
* must never be able to stop the plugin from serving models.
|
|
66
|
+
*/
|
|
67
|
+
var WorkBuddyCatalogStore = class {
|
|
68
|
+
path;
|
|
69
|
+
entries;
|
|
70
|
+
constructor(options = {}) {
|
|
71
|
+
this.path = typeof options === "string" ? options : options.path ?? workbuddyCatalogPath();
|
|
72
|
+
}
|
|
73
|
+
/** Resolved state-file path, for the CLI and tests. */
|
|
74
|
+
filePath() {
|
|
75
|
+
return this.path;
|
|
76
|
+
}
|
|
77
|
+
load() {
|
|
78
|
+
if (this.entries !== void 0) return this.entries;
|
|
79
|
+
const entries = {};
|
|
80
|
+
if (existsSync(this.path)) try {
|
|
81
|
+
const parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
82
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
83
|
+
const document = parsed;
|
|
84
|
+
const raw = document["version"] === CATALOG_FORMAT_VERSION ? document["entries"] : void 0;
|
|
85
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
86
|
+
for (const [key, value] of Object.entries(raw)) if (isSaved(value)) entries[key] = value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {}
|
|
90
|
+
this.entries = entries;
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
/** The saved catalog for one account, or `undefined` when there is none. */
|
|
94
|
+
get(account) {
|
|
95
|
+
const entry = this.load()[account];
|
|
96
|
+
return entry === void 0 ? void 0 : entry;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Remember a catalog for an account, replacing whatever was saved before.
|
|
100
|
+
*
|
|
101
|
+
* A failed write is swallowed: the plugin has already served these models,
|
|
102
|
+
* and losing the *memory* of them is not worth surfacing.
|
|
103
|
+
*/
|
|
104
|
+
set(account, catalog) {
|
|
105
|
+
const entries = this.load();
|
|
106
|
+
entries[account] = {
|
|
107
|
+
account,
|
|
108
|
+
...catalog
|
|
109
|
+
};
|
|
110
|
+
this.persist();
|
|
111
|
+
}
|
|
112
|
+
/** Forget one account's catalog — used when that account signs out. */
|
|
113
|
+
delete(account) {
|
|
114
|
+
const entries = this.load();
|
|
115
|
+
if (!(account in entries)) return;
|
|
116
|
+
delete entries[account];
|
|
117
|
+
this.persist();
|
|
118
|
+
}
|
|
119
|
+
persist() {
|
|
120
|
+
const directory = dirname(this.path);
|
|
121
|
+
try {
|
|
122
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
|
|
123
|
+
const document = {
|
|
124
|
+
version: CATALOG_FORMAT_VERSION,
|
|
125
|
+
entries: this.load()
|
|
126
|
+
};
|
|
127
|
+
const temporary = resolve(`${this.path}.tmp`);
|
|
128
|
+
writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
|
|
129
|
+
renameSync(temporary, this.path);
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
//#endregion
|
|
13
134
|
//#region src/adapter.ts
|
|
14
135
|
/**
|
|
15
136
|
* The `workbuddy` pi-ai provider: one loopback-backed adapter registered
|
|
@@ -154,18 +275,19 @@ function reasoningFields(info, observed) {
|
|
|
154
275
|
};
|
|
155
276
|
}
|
|
156
277
|
/** Build one pi-ai model descriptor pointing at the loopback shim. */
|
|
157
|
-
function toPiModel(info, baseUrl, observed) {
|
|
278
|
+
function toPiModel(info, baseUrl, observed, providerId = WORKBUDDY_PROVIDER) {
|
|
158
279
|
return {
|
|
159
280
|
id: info.id,
|
|
160
281
|
name: info.name,
|
|
161
282
|
api: "openai-completions",
|
|
162
|
-
provider:
|
|
283
|
+
provider: providerId,
|
|
163
284
|
baseUrl,
|
|
164
285
|
input: info.supportsImages === true ? ["text", "image"] : ["text"],
|
|
165
286
|
...reasoningFields(info, observed),
|
|
166
287
|
cost: NO_COST,
|
|
167
288
|
contextWindow: info.contextWindow,
|
|
168
|
-
maxTokens: info.maxTokens
|
|
289
|
+
maxTokens: info.maxTokens,
|
|
290
|
+
compat: { maxTokensField: "max_tokens" }
|
|
169
291
|
};
|
|
170
292
|
}
|
|
171
293
|
/**
|
|
@@ -182,14 +304,16 @@ function toPiModel(info, baseUrl, observed) {
|
|
|
182
304
|
*/
|
|
183
305
|
function createWorkBuddyAdapter(options) {
|
|
184
306
|
const { shim, store, catalog, resolveAttachments, observe } = options;
|
|
307
|
+
const providerId = options.providerId ?? "workbuddy";
|
|
308
|
+
const displayName = options.displayName ?? "WorkBuddy";
|
|
185
309
|
const buildModels = () => {
|
|
186
310
|
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
187
|
-
return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id)));
|
|
311
|
+
return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id), providerId));
|
|
188
312
|
};
|
|
189
313
|
const provider = {
|
|
190
314
|
...createProvider({
|
|
191
|
-
id:
|
|
192
|
-
name:
|
|
315
|
+
id: providerId,
|
|
316
|
+
name: displayName,
|
|
193
317
|
auth: { apiKey: {
|
|
194
318
|
name: "WorkBuddy OAuth bearer token",
|
|
195
319
|
async resolve({ credential }) {
|
|
@@ -206,8 +330,8 @@ function createWorkBuddyAdapter(options) {
|
|
|
206
330
|
getModels: () => buildModels()
|
|
207
331
|
};
|
|
208
332
|
const profile = {
|
|
209
|
-
provider:
|
|
210
|
-
displayName
|
|
333
|
+
provider: providerId,
|
|
334
|
+
displayName,
|
|
211
335
|
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
212
336
|
retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-connect retryPolicy"),
|
|
213
337
|
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
@@ -215,7 +339,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
215
339
|
...REQUEST_IMAGE_BUDGETS,
|
|
216
340
|
piProvider: provider
|
|
217
341
|
};
|
|
218
|
-
let profiles = /* @__PURE__ */ new Map([[
|
|
342
|
+
let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
219
343
|
return {
|
|
220
344
|
adapter: new WorkBuddyPiAiAdapter(catalog, {
|
|
221
345
|
profiles: () => profiles,
|
|
@@ -224,7 +348,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
224
348
|
...resolveAttachments === void 0 ? {} : { resolveAttachments }
|
|
225
349
|
}),
|
|
226
350
|
invalidate: () => {
|
|
227
|
-
profiles = /* @__PURE__ */ new Map([[
|
|
351
|
+
profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
228
352
|
}
|
|
229
353
|
};
|
|
230
354
|
}
|
|
@@ -541,9 +665,17 @@ const PROBE_FORMAT_VERSION = 1;
|
|
|
541
665
|
* picker entry.
|
|
542
666
|
*/
|
|
543
667
|
const DEFAULT_TTL_MS = 12096e5;
|
|
544
|
-
/**
|
|
545
|
-
|
|
546
|
-
|
|
668
|
+
/**
|
|
669
|
+
* Plugin-owned probe record path inside the Harness home.
|
|
670
|
+
*
|
|
671
|
+
* One file per variant. Same-named models exist on both endpoints (the
|
|
672
|
+
* international catalog repeats `glm-5.3`, `glm-5.2`, `hy3`, `kimi-k2.6`), and
|
|
673
|
+
* {@link fingerprintModel} covers only `id`/`reasoning`/`supportsImages` —
|
|
674
|
+
* never the provider — so a single shared file would let one variant's
|
|
675
|
+
* observation answer for the other. The paths differ; the format does not.
|
|
676
|
+
*/
|
|
677
|
+
function workbuddyProbePath(filename = WORKBUDDY_PROBE_FILENAME) {
|
|
678
|
+
return join(resolveDshHome(), filename);
|
|
547
679
|
}
|
|
548
680
|
/**
|
|
549
681
|
* Fingerprint the catalog fields a probe depends on.
|
|
@@ -625,12 +757,17 @@ var WorkBuddyProbeStore = class {
|
|
|
625
757
|
}
|
|
626
758
|
/**
|
|
627
759
|
* The usable record for a model, or `undefined` when there is none, it is
|
|
628
|
-
* expired,
|
|
760
|
+
* expired, it was taken against a different catalog row, or it belongs to a
|
|
761
|
+
* different account.
|
|
762
|
+
*
|
|
763
|
+
* @param account - the account in effect, as `uid:enterpriseId`. Records are
|
|
764
|
+
* only returned for the account that produced them.
|
|
629
765
|
*/
|
|
630
|
-
get(modelId, fingerprint) {
|
|
766
|
+
get(modelId, fingerprint, account) {
|
|
631
767
|
const record = this.load()[modelId];
|
|
632
768
|
if (record === void 0) return void 0;
|
|
633
769
|
if (record.fingerprint !== fingerprint) return void 0;
|
|
770
|
+
if (record.account !== account) return void 0;
|
|
634
771
|
if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
|
|
635
772
|
return record;
|
|
636
773
|
}
|
|
@@ -655,14 +792,15 @@ var WorkBuddyProbeStore = class {
|
|
|
655
792
|
all() {
|
|
656
793
|
return { ...this.load() };
|
|
657
794
|
}
|
|
658
|
-
/** Build a record stamped with this store's clock and
|
|
659
|
-
record(fingerprint, validation, efforts) {
|
|
795
|
+
/** Build a record stamped with this store's clock, version, and account. */
|
|
796
|
+
record(fingerprint, validation, efforts, account) {
|
|
660
797
|
return {
|
|
661
798
|
fingerprint,
|
|
662
799
|
validation,
|
|
663
800
|
efforts: validation === "validating" ? [...efforts] : [],
|
|
664
801
|
probedAtMs: this.now(),
|
|
665
|
-
pluginVersion: this.pluginVersion
|
|
802
|
+
pluginVersion: this.pluginVersion,
|
|
803
|
+
account
|
|
666
804
|
};
|
|
667
805
|
}
|
|
668
806
|
/**
|
|
@@ -723,7 +861,9 @@ var WorkBuddyProbeService = class {
|
|
|
723
861
|
const info = this.options.catalog.current().find((model) => model.id === modelId);
|
|
724
862
|
if (info === void 0) return void 0;
|
|
725
863
|
if (info.reasoning?.supportedEfforts !== void 0 && info.reasoning.supportedEfforts.length > 0) return;
|
|
726
|
-
|
|
864
|
+
const account = this.options.account();
|
|
865
|
+
if (account === void 0) return void 0;
|
|
866
|
+
return this.options.store.get(modelId, fingerprintModel(info), account);
|
|
727
867
|
}
|
|
728
868
|
/**
|
|
729
869
|
* Probe one model, serially.
|
|
@@ -742,7 +882,13 @@ var WorkBuddyProbeService = class {
|
|
|
742
882
|
state: "unavailable",
|
|
743
883
|
reason: `unknown model: ${modelId}`
|
|
744
884
|
};
|
|
745
|
-
const
|
|
885
|
+
const account = this.options.account();
|
|
886
|
+
if (account === void 0) return {
|
|
887
|
+
state: "unavailable",
|
|
888
|
+
reason: "no WorkBuddy credential"
|
|
889
|
+
};
|
|
890
|
+
const pendingKey = JSON.stringify([account, modelId]);
|
|
891
|
+
const pending = this.pending.get(pendingKey);
|
|
746
892
|
if (pending !== void 0) return pending;
|
|
747
893
|
const run = this.queue.then(async () => {
|
|
748
894
|
const current = this.options.catalog.current().find((model) => model.id === modelId);
|
|
@@ -765,6 +911,10 @@ var WorkBuddyProbeService = class {
|
|
|
765
911
|
efforts: cached.efforts,
|
|
766
912
|
requests: 0
|
|
767
913
|
};
|
|
914
|
+
if (this.options.account() !== account) return {
|
|
915
|
+
state: "unavailable",
|
|
916
|
+
reason: "account changed before detection"
|
|
917
|
+
};
|
|
768
918
|
const credential = await this.options.credentials.current();
|
|
769
919
|
if (credential === void 0) return {
|
|
770
920
|
state: "unavailable",
|
|
@@ -777,7 +927,11 @@ var WorkBuddyProbeService = class {
|
|
|
777
927
|
send,
|
|
778
928
|
...this.options.sentinel === void 0 ? {} : { sentinel: this.options.sentinel }
|
|
779
929
|
});
|
|
780
|
-
|
|
930
|
+
if (this.options.account() !== account) return {
|
|
931
|
+
state: "unavailable",
|
|
932
|
+
reason: "account changed during detection"
|
|
933
|
+
};
|
|
934
|
+
const record = this.options.store.record(fingerprintModel(current), outcome.validation, outcome.efforts, account);
|
|
781
935
|
this.options.store.set(modelId, record);
|
|
782
936
|
if (outcome.validation === "unknown") return {
|
|
783
937
|
state: "unavailable",
|
|
@@ -794,30 +948,15 @@ var WorkBuddyProbeService = class {
|
|
|
794
948
|
}
|
|
795
949
|
});
|
|
796
950
|
this.queue = run.catch(() => void 0);
|
|
797
|
-
this.pending.set(
|
|
951
|
+
this.pending.set(pendingKey, run);
|
|
798
952
|
try {
|
|
799
953
|
return await run;
|
|
800
954
|
} finally {
|
|
801
|
-
this.pending.delete(
|
|
955
|
+
this.pending.delete(pendingKey);
|
|
802
956
|
}
|
|
803
957
|
}
|
|
804
958
|
};
|
|
805
959
|
//#endregion
|
|
806
|
-
//#region src/status-paths.ts
|
|
807
|
-
/** Node-free constants and types shared by the Host and browser halves. */
|
|
808
|
-
/** Plugin-owned status endpoint consumed by its browser half. */
|
|
809
|
-
const WORKBUDDY_STATUS_PATH = "/plugins/dsh-workbuddy-connect/status";
|
|
810
|
-
/**
|
|
811
|
-
* Plugin-owned probe control endpoint.
|
|
812
|
-
*
|
|
813
|
-
* Separate from the status route because it accepts writes: the status route's
|
|
814
|
-
* loopback Host/Origin guard protects against a DNS-rebinding *page*, which is
|
|
815
|
-
* not the same as authorizing a state-changing action. This route therefore
|
|
816
|
-
* also requires the in-process key the browser half receives with the status
|
|
817
|
-
* document.
|
|
818
|
-
*/
|
|
819
|
-
const WORKBUDDY_PROBE_PATH = "/plugins/dsh-workbuddy-connect/probe";
|
|
820
|
-
//#endregion
|
|
821
960
|
//#region src/web-status.ts
|
|
822
961
|
/** Redact token-like content before it crosses to the browser. */
|
|
823
962
|
function safeMessage(error) {
|
|
@@ -847,7 +986,10 @@ function loopbackRequest(req) {
|
|
|
847
986
|
*/
|
|
848
987
|
async function workBuddyWebStatus(deps) {
|
|
849
988
|
const authStatus = await deps.store.status();
|
|
850
|
-
if (authStatus.state !== "signed-in") return {
|
|
989
|
+
if (authStatus.state !== "signed-in") return {
|
|
990
|
+
status: "signed-out",
|
|
991
|
+
...authStatus.reason === void 0 ? {} : { reason: authStatus.reason }
|
|
992
|
+
};
|
|
851
993
|
const status = {
|
|
852
994
|
status: "signed-in",
|
|
853
995
|
...authStatus.nickname === void 0 ? {} : { nickname: authStatus.nickname },
|
|
@@ -857,19 +999,29 @@ async function workBuddyWebStatus(deps) {
|
|
|
857
999
|
};
|
|
858
1000
|
const modelsField = deps.models().map((model) => {
|
|
859
1001
|
const rate = normalizeCredits(model.billing?.credits);
|
|
1002
|
+
const supported = model.supportedContextWindows ?? [];
|
|
1003
|
+
const maxContextWindow = supported.length > 0 ? Math.max(...supported) : void 0;
|
|
860
1004
|
return {
|
|
861
1005
|
id: model.id,
|
|
862
1006
|
name: model.name,
|
|
863
1007
|
...model.billing?.free === true ? { free: true } : {},
|
|
864
1008
|
...model.billing?.badges !== void 0 && model.billing.badges.length > 0 ? { badges: model.billing.badges } : {},
|
|
865
1009
|
...rate === void 0 ? {} : { credits: rate },
|
|
866
|
-
...
|
|
1010
|
+
...model.billing?.rateUnknown === true ? { rateUnknown: true } : {},
|
|
1011
|
+
...typeof model.contextWindow === "number" && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {},
|
|
1012
|
+
...maxContextWindow === void 0 || maxContextWindow === model.contextWindow ? {} : { maxContextWindow },
|
|
1013
|
+
...typeof model.maxInputTokens === "number" && model.maxInputTokens > 0 ? { maxInputTokens: model.maxInputTokens } : {}
|
|
867
1014
|
};
|
|
868
1015
|
});
|
|
869
|
-
const
|
|
1016
|
+
const catalog = deps.catalog?.();
|
|
1017
|
+
const withCatalog = catalog === void 0 ? status : {
|
|
870
1018
|
...status,
|
|
1019
|
+
catalog
|
|
1020
|
+
};
|
|
1021
|
+
const statusWithModels = modelsField.length > 0 ? {
|
|
1022
|
+
...withCatalog,
|
|
871
1023
|
models: modelsField
|
|
872
|
-
} :
|
|
1024
|
+
} : withCatalog;
|
|
873
1025
|
const probed = deps.probe === void 0 ? statusWithModels : {
|
|
874
1026
|
...statusWithModels,
|
|
875
1027
|
probe: deps.probe(),
|
|
@@ -912,10 +1064,11 @@ function workBuddyStatusHandler(deps) {
|
|
|
912
1064
|
}
|
|
913
1065
|
/** Mount the GET status route on an optional webServer context. */
|
|
914
1066
|
function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
1067
|
+
const path = deps.path ?? "/plugins/dsh-workbuddy-connect/status";
|
|
915
1068
|
ctx.effect(() => {
|
|
916
1069
|
const dispose = ctx.webServer.register({
|
|
917
1070
|
kind: "exact",
|
|
918
|
-
path
|
|
1071
|
+
path,
|
|
919
1072
|
handler: workBuddyStatusHandler(deps)
|
|
920
1073
|
});
|
|
921
1074
|
return () => {
|
|
@@ -991,6 +1144,7 @@ function parseAction(text) {
|
|
|
991
1144
|
const wrapped = parsed;
|
|
992
1145
|
const action = wrapped["action"];
|
|
993
1146
|
if (action === "clear") return { action: "clear" };
|
|
1147
|
+
if (action === "refresh") return { action: "refresh" };
|
|
994
1148
|
if (action === "probe") {
|
|
995
1149
|
const model = wrapped["model"];
|
|
996
1150
|
if (typeof model !== "string" || model.trim() === "") return void 0;
|
|
@@ -1034,6 +1188,14 @@ function workBuddyProbeHandler(deps, key) {
|
|
|
1034
1188
|
json(res, 200, { state: "cleared" });
|
|
1035
1189
|
return;
|
|
1036
1190
|
}
|
|
1191
|
+
if (action.action === "refresh") {
|
|
1192
|
+
if (deps.refresh === void 0) {
|
|
1193
|
+
json(res, 404, { error: "refresh-not-supported" });
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
json(res, 200, await deps.refresh());
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1037
1199
|
json(res, 200, await deps.probe(action.model));
|
|
1038
1200
|
} catch (error) {
|
|
1039
1201
|
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -1042,10 +1204,11 @@ function workBuddyProbeHandler(deps, key) {
|
|
|
1042
1204
|
}
|
|
1043
1205
|
/** Mount the POST probe-control route on an optional webServer context. */
|
|
1044
1206
|
function registerWorkBuddyProbeRoute(ctx, deps, key) {
|
|
1207
|
+
const path = deps.path ?? "/plugins/dsh-workbuddy-connect/probe";
|
|
1045
1208
|
ctx.effect(() => {
|
|
1046
1209
|
const dispose = ctx.webServer.register({
|
|
1047
1210
|
kind: "exact",
|
|
1048
|
-
path
|
|
1211
|
+
path,
|
|
1049
1212
|
handler: workBuddyProbeHandler(deps, key)
|
|
1050
1213
|
});
|
|
1051
1214
|
return () => {
|
|
@@ -1060,7 +1223,7 @@ const name = "llm-workbuddy";
|
|
|
1060
1223
|
/** The model registry required before the provider can register. */
|
|
1061
1224
|
const inject = ["llm"];
|
|
1062
1225
|
/**
|
|
1063
|
-
* Settings namespace owning the
|
|
1226
|
+
* Settings namespace owning the CN card's section.
|
|
1064
1227
|
*
|
|
1065
1228
|
* DSH 0.1.2 dropped the `settingsNamespace()` branding function: a namespace is
|
|
1066
1229
|
* now a nominal string, validated by the type system where it is used rather
|
|
@@ -1072,176 +1235,563 @@ const inject = ["llm"];
|
|
|
1072
1235
|
* their namespaces as plain string literals).
|
|
1073
1236
|
*/
|
|
1074
1237
|
const WORKBUDDY_SETTINGS_NS = "workbuddy";
|
|
1238
|
+
/**
|
|
1239
|
+
* Settings namespace owning the international card's section.
|
|
1240
|
+
*
|
|
1241
|
+
* One namespace per card, not one shared: the settings Plugins tab dispatches a
|
|
1242
|
+
* card by rendering `settings.plugin.item` with `entryKey = ns` for each
|
|
1243
|
+
* namespace the Host serves, and skips an entry whose key names no served
|
|
1244
|
+
* namespace. With a single installed section, the international card registers
|
|
1245
|
+
* into the slot but is never rendered — the card list is built from the Host's
|
|
1246
|
+
* sections, not from the slot's entries. Each card therefore needs its own
|
|
1247
|
+
* installed section whose namespace equals the card's slot key.
|
|
1248
|
+
*/
|
|
1249
|
+
const WORKBUDDY_AI_SETTINGS_NS = "workbuddy-ai";
|
|
1250
|
+
/**
|
|
1251
|
+
* How often the credential files are re-checked, in milliseconds.
|
|
1252
|
+
*
|
|
1253
|
+
* A startup-only catalog fetch cannot notice a sign-in that happens while DSH
|
|
1254
|
+
* is already running, so the model group would not appear until a restart. This
|
|
1255
|
+
* poll is a cheap existence/parse read of at most a few local files: it never
|
|
1256
|
+
* contacts the network and never runs a reasoning probe.
|
|
1257
|
+
*
|
|
1258
|
+
* `DSH_WORKBUDDY_POLL_MS` overrides it. That exists so the sweep can be
|
|
1259
|
+
* exercised end to end in tests and shortened while diagnosing a slow sign-in
|
|
1260
|
+
* on a real machine; it is not a product setting and no UI exposes it. The
|
|
1261
|
+
* value is clamped to a sane range so a mistaken override cannot turn the poll
|
|
1262
|
+
* into a busy loop.
|
|
1263
|
+
*/
|
|
1264
|
+
const CREDENTIAL_POLL_MS = 3e4;
|
|
1265
|
+
/** Floor and ceiling for the overridable poll interval. */
|
|
1266
|
+
const MIN_POLL_MS = 100;
|
|
1267
|
+
const MAX_POLL_MS = 864e5;
|
|
1268
|
+
/** Resolve the sweep interval, honoring the override when it is usable. */
|
|
1269
|
+
function credentialPollMs() {
|
|
1270
|
+
const override = Number(process.env["DSH_WORKBUDDY_POLL_MS"]);
|
|
1271
|
+
if (!Number.isFinite(override) || override < MIN_POLL_MS) return CREDENTIAL_POLL_MS;
|
|
1272
|
+
return Math.min(override, MAX_POLL_MS);
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* How long to wait before retrying a catalog fetch that failed.
|
|
1276
|
+
*
|
|
1277
|
+
* The credential sweep deliberately does not re-fetch a catalog it already has
|
|
1278
|
+
* (a same-identity token rotation carries no new model information). But a
|
|
1279
|
+
* *failed* fetch must not be treated the same way: without a retry, one
|
|
1280
|
+
* transient network blip at startup would leave the group on the built-in
|
|
1281
|
+
* fallback roster until the user noticed and pressed refresh. This bound keeps
|
|
1282
|
+
* that recovery automatic while still honoring the "not every round" rule — at
|
|
1283
|
+
* most one attempt per interval, and none at all once a live catalog lands.
|
|
1284
|
+
*
|
|
1285
|
+
* Expressed as a multiple of the sweep rather than a fixed duration so the two
|
|
1286
|
+
* stay in proportion under the `DSH_WORKBUDDY_POLL_MS` override.
|
|
1287
|
+
*/
|
|
1288
|
+
const CATALOG_RETRY_SWEEPS = 10;
|
|
1289
|
+
/** Explicit CN desktop auth-file path (shared by the plugin schema and its section). */
|
|
1290
|
+
const AUTH_FILE_FIELD = z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)");
|
|
1291
|
+
/** Explicit international desktop auth-file path (shared by the plugin schema and its section). */
|
|
1292
|
+
const AUTH_FILE_AI_FIELD = z.string().description("WorkBuddy AI desktop auth file (defaults to the app's own location)");
|
|
1293
|
+
/** Probe authorization (shared by the plugin schema and the CN section). */
|
|
1294
|
+
const PROBE_CONSENT_FIELD = z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)");
|
|
1075
1295
|
const Config = z.object({
|
|
1076
|
-
authFile:
|
|
1077
|
-
|
|
1296
|
+
authFile: AUTH_FILE_FIELD,
|
|
1297
|
+
authFileAI: AUTH_FILE_AI_FIELD,
|
|
1298
|
+
probeConsent: PROBE_CONSENT_FIELD
|
|
1078
1299
|
});
|
|
1079
1300
|
/**
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1301
|
+
* The CN card's settings section: only the fields that card edits.
|
|
1302
|
+
*
|
|
1303
|
+
* A section is what makes its namespace "served", which is what the Plugins
|
|
1304
|
+
* tab dispatches a card by — so the schema and the card must stay split the
|
|
1305
|
+
* same way. `probeConsent` lives here because it predates the second variant;
|
|
1306
|
+
* it gates no current code path (only manual, per-click-confirmed probes run),
|
|
1307
|
+
* so it is left where existing users set it rather than moved and re-asked.
|
|
1084
1308
|
*/
|
|
1085
|
-
|
|
1309
|
+
const CN_SECTION = z.object({
|
|
1310
|
+
authFile: AUTH_FILE_FIELD,
|
|
1311
|
+
probeConsent: PROBE_CONSENT_FIELD
|
|
1312
|
+
});
|
|
1313
|
+
/** The international card's settings section: only its own auth-file path. */
|
|
1314
|
+
const AI_SECTION = z.object({ authFileAI: AUTH_FILE_AI_FIELD });
|
|
1315
|
+
/** Stable identity key used by credentials, probe records, and catalog entries. */
|
|
1316
|
+
function credentialIdentity(credential) {
|
|
1317
|
+
return `${credential.uid}:${credential.enterpriseId ?? ""}`;
|
|
1318
|
+
}
|
|
1319
|
+
/** Read the configured explicit auth-file path for one variant. */
|
|
1320
|
+
function configuredAuthFile(config, variant) {
|
|
1321
|
+
return variant.id === CN_VARIANT.id ? config.authFile : config.authFileAI;
|
|
1322
|
+
}
|
|
1323
|
+
/** The settings namespace a variant's card and provider directory entry use. */
|
|
1324
|
+
function settingsNamespaceFor(variant) {
|
|
1325
|
+
return variant.id === CN_VARIANT.id ? WORKBUDDY_SETTINGS_NS : WORKBUDDY_AI_SETTINGS_NS;
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* The static catalog a variant serves before its first successful fetch.
|
|
1329
|
+
*
|
|
1330
|
+
* Each variant has its own roster: the two endpoints share several model ids
|
|
1331
|
+
* but not their billing, context windows, or reasoning sets, so one shared
|
|
1332
|
+
* fallback would misdescribe whichever variant it was not captured from.
|
|
1333
|
+
*/
|
|
1334
|
+
function fallbackFor(variant) {
|
|
1335
|
+
return variant.id === CN_VARIANT.id ? FALLBACK_WORKBUDDY_MODELS : FALLBACK_WORKBUDDY_AI_MODELS;
|
|
1336
|
+
}
|
|
1337
|
+
/** Build one variant's stores and probe state. */
|
|
1338
|
+
function createVariantRuntime(config, variant, current, identityOf) {
|
|
1086
1339
|
const client = new WorkBuddyUpstreamClient();
|
|
1340
|
+
const configured = configuredAuthFile(config, variant);
|
|
1087
1341
|
const store = new WorkBuddyCredentialStore({
|
|
1088
|
-
|
|
1342
|
+
variant,
|
|
1343
|
+
...configured === void 0 ? {} : { desktopPath: configured },
|
|
1089
1344
|
refresh: (credential) => client.refreshToken(credential)
|
|
1090
1345
|
});
|
|
1091
|
-
const
|
|
1346
|
+
const fallback = fallbackFor(variant);
|
|
1347
|
+
const catalog = new WorkBuddyCatalog(fallback);
|
|
1348
|
+
catalog.setVisible(false);
|
|
1349
|
+
const probeStore = new WorkBuddyProbeStore({
|
|
1350
|
+
pluginVersion: WORKBUDDY_CONNECT_VERSION,
|
|
1351
|
+
path: workbuddyProbePath(variant.probeFilename)
|
|
1352
|
+
});
|
|
1353
|
+
const savedCatalogs = new WorkBuddyCatalogStore(workbuddyCatalogPath(variant.catalogFilename));
|
|
1354
|
+
return {
|
|
1355
|
+
variant,
|
|
1356
|
+
store,
|
|
1357
|
+
client,
|
|
1358
|
+
catalog,
|
|
1359
|
+
probeStore,
|
|
1360
|
+
probeService: new WorkBuddyProbeService({
|
|
1361
|
+
store: probeStore,
|
|
1362
|
+
catalog,
|
|
1363
|
+
credentials: store,
|
|
1364
|
+
client,
|
|
1365
|
+
consent: () => current().probeConsent === true,
|
|
1366
|
+
account: () => identityOf(variant.id)
|
|
1367
|
+
}),
|
|
1368
|
+
savedCatalogs,
|
|
1369
|
+
fallback,
|
|
1370
|
+
catalogSource: "fallback",
|
|
1371
|
+
catalogFetchedAtMs: void 0,
|
|
1372
|
+
catalogError: void 0,
|
|
1373
|
+
lastFetchAtMs: 0,
|
|
1374
|
+
catalogGeneration: 0,
|
|
1375
|
+
inflightFetch: void 0,
|
|
1376
|
+
invalidate: () => {},
|
|
1377
|
+
registered: false
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
/** The catalog provenance the card displays. */
|
|
1381
|
+
function catalogSection(runtime) {
|
|
1382
|
+
const fetch = runtime.client.lastCatalog;
|
|
1383
|
+
return {
|
|
1384
|
+
source: runtime.catalogSource,
|
|
1385
|
+
...runtime.catalogFetchedAtMs === void 0 ? {} : { fetchedAt: runtime.catalogFetchedAtMs },
|
|
1386
|
+
...fetch?.appVersion === void 0 ? {} : { appVersion: fetch.appVersion.version },
|
|
1387
|
+
...runtime.catalogError === void 0 ? {} : { error: runtime.catalogError }
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Whether a model can be probed by hand: it reasons and the upstream declares
|
|
1392
|
+
* no effort set for it.
|
|
1393
|
+
*
|
|
1394
|
+
* Deliberately *not* filtered by whether a result already exists. Dropping a
|
|
1395
|
+
* model once it has been detected made the list shrink with use, so
|
|
1396
|
+
* re-detecting one model — after an upstream change, say — meant clearing every
|
|
1397
|
+
* other result first. The list stays stable and the card marks which entries
|
|
1398
|
+
* already have an answer.
|
|
1399
|
+
*/
|
|
1400
|
+
function isProbeCandidate(info) {
|
|
1401
|
+
if (info.reasoning?.supports !== true) return false;
|
|
1402
|
+
return (info.reasoning.supportedEfforts?.length ?? 0) === 0;
|
|
1403
|
+
}
|
|
1404
|
+
/** Compact probe state for one card: consent, candidates, observations. */
|
|
1405
|
+
function probeSection(runtime, consent) {
|
|
1406
|
+
const models = runtime.catalog.current();
|
|
1407
|
+
const results = models.flatMap((info) => {
|
|
1408
|
+
const record = runtime.probeService.recordFor(info.id);
|
|
1409
|
+
if (record === void 0) return [];
|
|
1410
|
+
return [{
|
|
1411
|
+
id: info.id,
|
|
1412
|
+
name: info.name,
|
|
1413
|
+
validation: record.validation,
|
|
1414
|
+
efforts: record.efforts,
|
|
1415
|
+
probedAt: record.probedAtMs
|
|
1416
|
+
}];
|
|
1417
|
+
});
|
|
1418
|
+
return {
|
|
1419
|
+
consent,
|
|
1420
|
+
running: runtime.probeService.isRunning(),
|
|
1421
|
+
candidates: models.filter(isProbeCandidate).map((info) => info.id),
|
|
1422
|
+
results: newestFirst(results)
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Start one variant: its loopback endpoint, provider registration, and
|
|
1427
|
+
* configuration-card wiring.
|
|
1428
|
+
*
|
|
1429
|
+
* Registration waits for the shim to hold a port, because the provider's
|
|
1430
|
+
* models read the shim origin at construction time. A failure here is
|
|
1431
|
+
* contained to this variant: the caller logs it and the other keeps working.
|
|
1432
|
+
*
|
|
1433
|
+
* @returns whether the provider registered.
|
|
1434
|
+
*/
|
|
1435
|
+
async function startVariant(ctx, runtime) {
|
|
1436
|
+
const { variant, store, client, catalog, probeService } = runtime;
|
|
1092
1437
|
const shim = createWorkBuddyShim({
|
|
1093
1438
|
store,
|
|
1094
1439
|
client,
|
|
1095
1440
|
catalog,
|
|
1096
1441
|
logger: ctx.logger
|
|
1097
1442
|
});
|
|
1443
|
+
try {
|
|
1444
|
+
await shim.ready;
|
|
1445
|
+
} catch (error) {
|
|
1446
|
+
ctx.logger.error(`dsh-workbuddy-connect: ${variant.displayName} loopback endpoint failed to start`, error);
|
|
1447
|
+
return false;
|
|
1448
|
+
}
|
|
1449
|
+
try {
|
|
1450
|
+
const workbuddy = createWorkBuddyAdapter({
|
|
1451
|
+
providerId: variant.id,
|
|
1452
|
+
displayName: variant.displayName,
|
|
1453
|
+
shim,
|
|
1454
|
+
store,
|
|
1455
|
+
catalog,
|
|
1456
|
+
resolveAttachments: () => ctx.get("attachments"),
|
|
1457
|
+
observe: (modelId) => probeService.recordFor(modelId)
|
|
1458
|
+
});
|
|
1459
|
+
workbuddy.invalidate;
|
|
1460
|
+
runtime.invalidate = () => {
|
|
1461
|
+
workbuddy.invalidate();
|
|
1462
|
+
ctx.emit("llm/adapters-updated");
|
|
1463
|
+
};
|
|
1464
|
+
let releaseAdapter;
|
|
1465
|
+
let releaseDirectory;
|
|
1466
|
+
try {
|
|
1467
|
+
releaseAdapter = ctx.llm.registerAdapter([variant.id], workbuddy.adapter);
|
|
1468
|
+
releaseDirectory = ctx.llm.registerConfigurableProviders([{
|
|
1469
|
+
provider: variant.id,
|
|
1470
|
+
displayName: variant.displayName,
|
|
1471
|
+
settingsNs: settingsNamespaceFor(variant),
|
|
1472
|
+
settingsPath: [],
|
|
1473
|
+
declared: false
|
|
1474
|
+
}]);
|
|
1475
|
+
} finally {
|
|
1476
|
+
if (releaseAdapter === void 0 || releaseDirectory === void 0) {
|
|
1477
|
+
releaseAdapter?.();
|
|
1478
|
+
releaseDirectory?.();
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
try {
|
|
1482
|
+
ctx.effect(() => () => {
|
|
1483
|
+
releaseAdapter?.();
|
|
1484
|
+
releaseDirectory?.();
|
|
1485
|
+
shim.close();
|
|
1486
|
+
});
|
|
1487
|
+
} catch {
|
|
1488
|
+
releaseAdapter?.();
|
|
1489
|
+
releaseDirectory?.();
|
|
1490
|
+
shim.close();
|
|
1491
|
+
}
|
|
1492
|
+
runtime.registered = true;
|
|
1493
|
+
return true;
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
ctx.logger.error(`dsh-workbuddy-connect: ${variant.displayName} provider registration failed`, error);
|
|
1496
|
+
shim.close();
|
|
1497
|
+
return false;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Start both variants: their loopback endpoints, the `workbuddy` and
|
|
1502
|
+
* `workbuddy-ai` providers, their configuration cards, and their
|
|
1503
|
+
* credential-driven catalog lifecycles.
|
|
1504
|
+
*
|
|
1505
|
+
* Each variant registers unconditionally; what varies is whether its catalog is
|
|
1506
|
+
* *visible*. An empty catalog is how DSH hides a model group (the host filters
|
|
1507
|
+
* out groups with no models), which keeps a sign-in that happens after startup
|
|
1508
|
+
* working without re-registering the provider.
|
|
1509
|
+
*/
|
|
1510
|
+
function apply(ctx, config) {
|
|
1098
1511
|
let current = () => config;
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
catalog,
|
|
1103
|
-
credentials: store,
|
|
1104
|
-
client,
|
|
1105
|
-
consent: () => current().probeConsent === true
|
|
1106
|
-
});
|
|
1512
|
+
/** Timers and in-flight work belonging to this plugin instance. */
|
|
1513
|
+
let stopped = false;
|
|
1514
|
+
const timers = [];
|
|
1107
1515
|
/**
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1516
|
+
* The account identity each variant last published a catalog for. Keeps a
|
|
1517
|
+
* same-identity token rotation from re-fetching, and lets a late response
|
|
1518
|
+
* from a previous identity be discarded instead of overwriting a newer one.
|
|
1519
|
+
*/
|
|
1520
|
+
const lastIdentities = /* @__PURE__ */ new Map();
|
|
1521
|
+
const runtimes = WORKBUDDY_VARIANTS.map((variant) => createVariantRuntime(config, variant, () => current(), (id) => lastIdentities.get(id)));
|
|
1522
|
+
const probeKey = createProbeKey();
|
|
1523
|
+
/**
|
|
1524
|
+
* Point a variant at an account identity, invalidating whatever the previous
|
|
1525
|
+
* one left behind.
|
|
1526
|
+
*
|
|
1527
|
+
* One helper for all four transitions (sweep sign-in, sweep sign-out, manual
|
|
1528
|
+
* refresh, manual refresh sign-out) because each of them used to do its own
|
|
1529
|
+
* partial version, and the manual path forgot pieces the sweep did. Every
|
|
1530
|
+
* transition bumps {@link VariantRuntime.catalogGeneration}, which is what
|
|
1531
|
+
* makes an in-flight request from before the change refuse to write back.
|
|
1110
1532
|
*
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1533
|
+
* Probe observations are dropped whenever the account actually changes —
|
|
1534
|
+
* including sign-out, and including the "signed out, then in as someone else"
|
|
1535
|
+
* sequence that used to look like a first sighting and let the new account
|
|
1536
|
+
* inherit the old one's detected levels. They are deliberately NOT cleared on
|
|
1537
|
+
* a first sign-in: no previous account's data could leak there, and clearing
|
|
1538
|
+
* would delete records this very account owns (written before a restart, or
|
|
1539
|
+
* seeded while all of this is running).
|
|
1540
|
+
*
|
|
1541
|
+
* @param identity - the account now in effect, or `undefined` when signed out.
|
|
1116
1542
|
*/
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1543
|
+
const adoptIdentity = (runtime, identity) => {
|
|
1544
|
+
const id = runtime.variant.id;
|
|
1545
|
+
const known = lastIdentities.get(id);
|
|
1546
|
+
if (known === identity) return;
|
|
1547
|
+
const hadCredential = known !== void 0;
|
|
1548
|
+
if (identity === void 0) lastIdentities.delete(id);
|
|
1549
|
+
else lastIdentities.set(id, identity);
|
|
1550
|
+
runtime.catalogGeneration += 1;
|
|
1551
|
+
runtime.inflightFetch?.controller.abort();
|
|
1552
|
+
runtime.inflightFetch = void 0;
|
|
1553
|
+
if (hadCredential && known !== identity) {
|
|
1554
|
+
runtime.probeStore.clear();
|
|
1555
|
+
runtime.invalidate();
|
|
1556
|
+
}
|
|
1557
|
+
if (identity === void 0) {
|
|
1558
|
+
if (known !== void 0) runtime.savedCatalogs.delete(known);
|
|
1559
|
+
runtime.catalog.set(runtime.fallback);
|
|
1560
|
+
runtime.catalogSource = "fallback";
|
|
1561
|
+
runtime.catalogFetchedAtMs = void 0;
|
|
1562
|
+
runtime.catalogError = void 0;
|
|
1563
|
+
if (runtime.catalog.setVisible(false)) runtime.invalidate();
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
const saved = runtime.savedCatalogs.get(identity);
|
|
1567
|
+
if (saved !== void 0) {
|
|
1568
|
+
runtime.catalog.set([...saved.models]);
|
|
1569
|
+
runtime.catalogSource = "saved";
|
|
1570
|
+
runtime.catalogFetchedAtMs = saved.fetchedAtMs;
|
|
1571
|
+
} else {
|
|
1572
|
+
runtime.catalog.set(runtime.fallback);
|
|
1573
|
+
runtime.catalogSource = "fallback";
|
|
1574
|
+
runtime.catalogFetchedAtMs = void 0;
|
|
1575
|
+
}
|
|
1576
|
+
runtime.catalogError = void 0;
|
|
1577
|
+
runtime.catalog.setVisible(true);
|
|
1578
|
+
runtime.invalidate();
|
|
1142
1579
|
};
|
|
1143
|
-
const probeKey = createProbeKey();
|
|
1144
|
-
let refreshProbeModels = () => {};
|
|
1145
1580
|
ctx.inject(["webServer"], (webCtx) => {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1581
|
+
for (const runtime of runtimes) {
|
|
1582
|
+
registerWorkBuddyStatusRoute(webCtx, {
|
|
1583
|
+
path: runtime.variant.statusPath,
|
|
1584
|
+
store: runtime.store,
|
|
1585
|
+
client: runtime.client,
|
|
1586
|
+
models: () => runtime.catalog.current(),
|
|
1587
|
+
catalog: () => catalogSection(runtime),
|
|
1588
|
+
probe: () => probeSection(runtime, current().probeConsent === true),
|
|
1589
|
+
probeKey
|
|
1590
|
+
});
|
|
1591
|
+
registerWorkBuddyProbeRoute(webCtx, {
|
|
1592
|
+
path: runtime.variant.probePath,
|
|
1593
|
+
probe: async (modelId) => {
|
|
1594
|
+
const result = await runtime.probeService.probe(modelId, true);
|
|
1595
|
+
if (result.state === "ok") runtime.invalidate();
|
|
1596
|
+
return result;
|
|
1597
|
+
},
|
|
1598
|
+
clear: () => {
|
|
1599
|
+
runtime.probeStore.clear();
|
|
1600
|
+
runtime.invalidate();
|
|
1601
|
+
},
|
|
1602
|
+
refresh: async () => {
|
|
1603
|
+
if (stopped) return {
|
|
1604
|
+
state: "failed",
|
|
1605
|
+
reason: "plugin is stopping"
|
|
1606
|
+
};
|
|
1607
|
+
let credential;
|
|
1608
|
+
try {
|
|
1609
|
+
credential = await runtime.store.current();
|
|
1610
|
+
} catch (error) {
|
|
1611
|
+
return {
|
|
1612
|
+
state: "failed",
|
|
1613
|
+
reason: error instanceof Error ? error.message.slice(0, 300) : String(error)
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
if (credential === void 0) {
|
|
1617
|
+
adoptIdentity(runtime, void 0);
|
|
1618
|
+
return { state: "signed-out" };
|
|
1619
|
+
}
|
|
1620
|
+
const identity = credentialIdentity(credential);
|
|
1621
|
+
adoptIdentity(runtime, identity);
|
|
1622
|
+
await fetchCatalog(runtime, identity);
|
|
1623
|
+
return runtime.catalogError === void 0 ? {
|
|
1624
|
+
state: "refreshed",
|
|
1625
|
+
reason: `${runtime.catalog.current().length} models`
|
|
1626
|
+
} : {
|
|
1627
|
+
state: "failed",
|
|
1628
|
+
reason: runtime.catalogError
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
}, probeKey);
|
|
1632
|
+
}
|
|
1164
1633
|
});
|
|
1165
1634
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
1166
|
-
|
|
1635
|
+
/** Section sources; each falls back to its own slice when its side unloads. */
|
|
1636
|
+
const sources = {
|
|
1637
|
+
cn: () => config,
|
|
1638
|
+
ai: () => config
|
|
1639
|
+
};
|
|
1640
|
+
/** Merge both sections into the whole config the rest of the plugin reads. */
|
|
1641
|
+
const merged = () => ({
|
|
1642
|
+
...sources.cn().authFile === void 0 ? {} : { authFile: sources.cn().authFile },
|
|
1643
|
+
...sources.cn().probeConsent === void 0 ? {} : { probeConsent: sources.cn().probeConsent },
|
|
1644
|
+
...sources.ai().authFileAI === void 0 ? {} : { authFileAI: sources.ai().authFileAI }
|
|
1645
|
+
});
|
|
1646
|
+
const repointStores = () => {
|
|
1647
|
+
const next = merged();
|
|
1648
|
+
for (const runtime of runtimes) runtime.store.setDesktopPath(configuredAuthFile(next, runtime.variant));
|
|
1649
|
+
};
|
|
1650
|
+
settingsCtx.settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, CN_SECTION, config, {
|
|
1167
1651
|
setSource(source) {
|
|
1168
|
-
|
|
1652
|
+
sources.cn = source;
|
|
1653
|
+
current = merged;
|
|
1169
1654
|
},
|
|
1170
|
-
onChange
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1655
|
+
onChange: repointStores
|
|
1656
|
+
});
|
|
1657
|
+
settingsCtx.settings.installSection(ctx, WORKBUDDY_AI_SETTINGS_NS, AI_SECTION, config, {
|
|
1658
|
+
setSource(source) {
|
|
1659
|
+
sources.ai = source;
|
|
1660
|
+
current = merged;
|
|
1661
|
+
},
|
|
1662
|
+
onChange: repointStores
|
|
1174
1663
|
});
|
|
1175
1664
|
});
|
|
1176
|
-
let stopped = false;
|
|
1177
1665
|
ctx.effect(() => () => {
|
|
1178
1666
|
stopped = true;
|
|
1179
|
-
|
|
1667
|
+
for (const timer of timers) clearInterval(timer);
|
|
1668
|
+
timers.length = 0;
|
|
1180
1669
|
clearHostHeartbeat();
|
|
1181
1670
|
});
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1671
|
+
/**
|
|
1672
|
+
* Fetch one variant's catalog for the current credential.
|
|
1673
|
+
*
|
|
1674
|
+
* Shared by the credential sweep and the card's manual refresh, and written
|
|
1675
|
+
* so that concurrent callers cost one request and cannot interleave badly:
|
|
1676
|
+
*
|
|
1677
|
+
* - **One request at a time.** A second caller joins the in-flight fetch
|
|
1678
|
+
* instead of starting its own (spec §5: one catalog request per variant at
|
|
1679
|
+
* a time).
|
|
1680
|
+
* - **Generation-checked write-back.** The request records the generation it
|
|
1681
|
+
* started under and writes nothing if the generation moved on — which is
|
|
1682
|
+
* what a slow answer from a superseded account must not do. Checking only
|
|
1683
|
+
* the *identity* was not enough: two refreshes for the same account can
|
|
1684
|
+
* still finish out of order, and the older one would win.
|
|
1685
|
+
* - **`resolve()`, not `current()`.** Only `resolve()` performs the locked,
|
|
1686
|
+
* single-flight token renewal. Reading `current()` meant an expired token
|
|
1687
|
+
* made every catalog request fail until something else happened to refresh
|
|
1688
|
+
* it, leaving the group on the fallback roster.
|
|
1689
|
+
*/
|
|
1690
|
+
const fetchCatalog = async (runtime, identity) => {
|
|
1691
|
+
const inflight = runtime.inflightFetch;
|
|
1692
|
+
const generation = runtime.catalogGeneration;
|
|
1693
|
+
if (inflight !== void 0 && inflight.identity === identity && inflight.generation === generation) return inflight.promise;
|
|
1694
|
+
inflight?.controller.abort();
|
|
1695
|
+
const controller = new AbortController();
|
|
1696
|
+
let run;
|
|
1697
|
+
run = (async () => {
|
|
1698
|
+
let models;
|
|
1201
1699
|
try {
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
declared: false
|
|
1209
|
-
}]);
|
|
1210
|
-
} finally {
|
|
1211
|
-
if (releaseAdapter === void 0 || releaseDirectory === void 0) {
|
|
1212
|
-
releaseAdapter?.();
|
|
1213
|
-
releaseDirectory?.();
|
|
1700
|
+
const credential = await runtime.store.resolve();
|
|
1701
|
+
const resolvedIdentity = credentialIdentity(credential);
|
|
1702
|
+
if (resolvedIdentity !== identity) {
|
|
1703
|
+
adoptIdentity(runtime, resolvedIdentity);
|
|
1704
|
+
await fetchCatalog(runtime, resolvedIdentity);
|
|
1705
|
+
return;
|
|
1214
1706
|
}
|
|
1707
|
+
models = await runtime.client.fetchModels(credential, controller.signal);
|
|
1708
|
+
const latest = await runtime.store.current();
|
|
1709
|
+
const latestIdentity = latest === void 0 ? void 0 : credentialIdentity(latest);
|
|
1710
|
+
if (latestIdentity !== identity) {
|
|
1711
|
+
adoptIdentity(runtime, latestIdentity);
|
|
1712
|
+
if (latestIdentity !== void 0) await fetchCatalog(runtime, latestIdentity);
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
if (stopped || runtime.catalogGeneration !== generation) return;
|
|
1717
|
+
runtime.lastFetchAtMs = Date.now();
|
|
1718
|
+
runtime.catalogError = error instanceof Error ? error.message.slice(0, 300) : String(error);
|
|
1719
|
+
ctx.logger.warn(`dsh-workbuddy-connect: ${runtime.variant.displayName} catalog unavailable; serving the fallback list`, error);
|
|
1720
|
+
runtime.invalidate();
|
|
1721
|
+
return;
|
|
1215
1722
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1723
|
+
if (stopped || runtime.catalogGeneration !== generation) return;
|
|
1724
|
+
runtime.lastFetchAtMs = Date.now();
|
|
1725
|
+
runtime.catalog.set([...models]);
|
|
1726
|
+
runtime.catalogSource = "live";
|
|
1727
|
+
runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
|
|
1728
|
+
runtime.catalogError = void 0;
|
|
1729
|
+
if (lastIdentities.get(runtime.variant.id) === identity) runtime.savedCatalogs.set(identity, {
|
|
1730
|
+
source: runtime.client.lastCatalog?.source ?? "unknown",
|
|
1731
|
+
fetchedAtMs: runtime.client.lastCatalog?.fetchedAtMs ?? Date.now(),
|
|
1732
|
+
models: [...models],
|
|
1733
|
+
...runtime.client.lastCatalog?.appVersion === void 0 ? {} : { appVersion: runtime.client.lastCatalog.appVersion.version }
|
|
1734
|
+
});
|
|
1735
|
+
runtime.invalidate();
|
|
1736
|
+
})().finally(() => {
|
|
1737
|
+
if (runtime.inflightFetch?.promise === run) runtime.inflightFetch = void 0;
|
|
1738
|
+
});
|
|
1739
|
+
runtime.inflightFetch = {
|
|
1740
|
+
identity,
|
|
1741
|
+
generation,
|
|
1742
|
+
controller,
|
|
1743
|
+
promise: run
|
|
1744
|
+
};
|
|
1745
|
+
return run;
|
|
1746
|
+
};
|
|
1747
|
+
/**
|
|
1748
|
+
* Reconcile one variant with its credentials.
|
|
1749
|
+
*
|
|
1750
|
+
* Four transitions matter, and each is a different action:
|
|
1751
|
+
*
|
|
1752
|
+
* - **none → some** (first sighting): reveal the group and fetch a catalog.
|
|
1753
|
+
* - **none → some, identity changed**: additionally drop the previous
|
|
1754
|
+
* account's observations, so another user's probe answers cannot be read as
|
|
1755
|
+
* the new account's.
|
|
1756
|
+
* - **some → none**: hide the group and stop serving its models.
|
|
1757
|
+
* - **same identity**: nothing to do — the store refreshes tokens on demand,
|
|
1758
|
+
* and re-fetching on every rotation would hit the catalog endpoint for no
|
|
1759
|
+
* new information.
|
|
1760
|
+
*/
|
|
1761
|
+
const syncVariant = async (runtime) => {
|
|
1762
|
+
if (stopped || !runtime.registered) return;
|
|
1763
|
+
const credential = await runtime.store.current().catch((error) => {
|
|
1764
|
+
ctx.logger.warn(`dsh-workbuddy-connect: ${runtime.variant.displayName} credential read failed`, error);
|
|
1765
|
+
});
|
|
1766
|
+
if (stopped) return;
|
|
1767
|
+
if (credential === void 0) {
|
|
1768
|
+
adoptIdentity(runtime, void 0);
|
|
1228
1769
|
return;
|
|
1229
1770
|
}
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1771
|
+
const identity = credentialIdentity(credential);
|
|
1772
|
+
if (lastIdentities.get(runtime.variant.id) === identity && runtime.catalog.isVisible()) {
|
|
1773
|
+
const stale = runtime.catalogSource !== "live";
|
|
1774
|
+
const due = Date.now() - runtime.lastFetchAtMs >= credentialPollMs() * CATALOG_RETRY_SWEEPS;
|
|
1775
|
+
if (stale && due) await fetchCatalog(runtime, identity);
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
adoptIdentity(runtime, identity);
|
|
1779
|
+
await fetchCatalog(runtime, identity);
|
|
1780
|
+
};
|
|
1781
|
+
/** Run one reconcile sweep across both variants. */
|
|
1782
|
+
const syncAll = async () => {
|
|
1783
|
+
for (const runtime of runtimes) await syncVariant(runtime);
|
|
1784
|
+
};
|
|
1785
|
+
Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime))).then(() => {
|
|
1786
|
+
if (stopped) return;
|
|
1787
|
+
if (runtimes.some((runtime) => runtime.registered)) writeHostHeartbeat();
|
|
1788
|
+
syncAll();
|
|
1789
|
+
const timer = setInterval(() => {
|
|
1790
|
+
syncAll();
|
|
1791
|
+
}, credentialPollMs());
|
|
1792
|
+
timer.unref?.();
|
|
1793
|
+
timers.push(timer);
|
|
1244
1794
|
});
|
|
1245
1795
|
}
|
|
1246
1796
|
//#endregion
|
|
1247
|
-
export { Config, FALLBACK_WORKBUDDY_MODELS, PROBE_EFFORT_CANDIDATES, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WorkBuddyCatalog, WorkBuddyCredentialStore, WorkBuddyProbeService, WorkBuddyProbeStore, WorkBuddyUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, fingerprintModel, inject, isHeartbeatProcessAlive, name, normalizeCredits, parseWorkBuddyAuth, prepareChatBody, probeModel, processStartTimeMs, randomSentinel, readHostHeartbeat, regionOf, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath };
|
|
1797
|
+
export { AI_VARIANT, CN_VARIANT, Config, FALLBACK_WORKBUDDY_AI_MODELS, FALLBACK_WORKBUDDY_MODELS, PROBE_EFFORT_CANDIDATES, WORKBUDDY_AI_SETTINGS_NS, WORKBUDDY_APP_VERSION_FILENAME, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CATALOG_FILENAME, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROBE_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_VARIANTS, WorkBuddyCatalog, WorkBuddyCatalogStore, WorkBuddyCredentialStore, WorkBuddyProbeService, WorkBuddyProbeStore, WorkBuddyUpstreamClient, appUserAgent, apply, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, desktopAuthCandidatesFor, fingerprintModel, inject, installedAppVersion, isHeartbeatProcessAlive, modelWithCurrentPromotion, name, normalizeCredits, parseModelCatalog, parseWorkBuddyAuth, prepareChatBody, prepareInternationalChatBody, probeModel, processStartTimeMs, randomSentinel, readBundleVersion, readHostHeartbeat, regionOf, resolveAppVersion, validAppVersion, variantFor, workbuddyCatalogPath, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, workbuddyProbePath };
|