dsh-workbuddy-connect 0.3.2 → 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 +40 -10
- package/README.md +42 -12
- package/lib/bin.js +71 -23
- package/lib/client.js +1033 -45
- package/lib/index.d.ts +619 -13
- package/lib/index.js +1196 -126
- package/lib/variants-GgLTJgZs.js +2321 -0
- package/package.json +11 -5
- package/lib/host-heartbeat-BSsKUoB8.js +0 -1198
package/lib/index.js
CHANGED
|
@@ -1,12 +1,136 @@
|
|
|
1
|
-
import { S as workbuddyOwnAuthPath, _ 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
|
+
import { dirname, join, resolve } from "node:path";
|
|
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";
|
|
3
7
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
4
8
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
5
9
|
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
6
10
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
7
|
-
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
8
11
|
import { createServer } from "node:http";
|
|
9
12
|
import { Readable } from "node:stream";
|
|
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
|
|
10
134
|
//#region src/adapter.ts
|
|
11
135
|
/**
|
|
12
136
|
* The `workbuddy` pi-ai provider: one loopback-backed adapter registered
|
|
@@ -107,29 +231,40 @@ function withCatalogDisplay(name, info) {
|
|
|
107
231
|
* `thinkingLevelMap` (every level pinned to its wire spelling or `null` for
|
|
108
232
|
* unsupported), mirroring `dsh-llm-pi-ai`'s own `resolveModelReasoning`.
|
|
109
233
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
|
|
124
|
-
|
|
234
|
+
* Two sources, strictly ordered (`docs/reasoning-effort-probe-plan.md` §5):
|
|
235
|
+
*
|
|
236
|
+
* 1. **The declared set.** When the upstream declares a non-empty
|
|
237
|
+
* `supportedEfforts`, exactly those values are offered and nothing else.
|
|
238
|
+
* This always wins: an observation never widens or narrows a declared set.
|
|
239
|
+
* 2. **A local observation.** Rows without a declared set (the older
|
|
240
|
+
* `{effort, summary}` shape) normally get no control at all — their
|
|
241
|
+
* selectable set is client-side knowledge the catalog does not carry, and
|
|
242
|
+
* the desktop app differs per model there. If the user authorized a probe
|
|
243
|
+
* and it established that the upstream *validates* the parameter, the
|
|
244
|
+
* verified spellings are offered.
|
|
245
|
+
*
|
|
246
|
+
* A `non-validating` observation deliberately yields no control: the upstream
|
|
247
|
+
* accepts values that cannot exist (measured on `glm-5.2`), so every per-level
|
|
248
|
+
* acceptance it produced would be a false positive.
|
|
249
|
+
*
|
|
250
|
+
* `off` is offered only when the upstream declares `canDisableThinking: true`.
|
|
251
|
+
* It is never probed — disabling thinking is a separate capability, and the
|
|
252
|
+
* per-model acceptance of `off` cannot be inferred from the row's shape.
|
|
253
|
+
*
|
|
254
|
+
* The offered set is described internally as "verified accepted", never as
|
|
255
|
+
* "verified effective": acceptance proves the upstream did not reject the
|
|
256
|
+
* spelling, not that it changes what the model does.
|
|
257
|
+
*/
|
|
258
|
+
function reasoningFields(info, observed) {
|
|
125
259
|
const reasoning = info.reasoning;
|
|
126
260
|
if (reasoning === void 0 || reasoning.supports !== true) return { reasoning: false };
|
|
127
|
-
const
|
|
128
|
-
|
|
261
|
+
const declared = reasoning.supportedEfforts;
|
|
262
|
+
const efforts = declared !== void 0 && declared.length > 0 ? declared : observed?.validation === "validating" && observed.efforts.length > 0 ? observed.efforts : void 0;
|
|
263
|
+
if (efforts === void 0) return { reasoning: false };
|
|
129
264
|
return {
|
|
130
265
|
reasoning: true,
|
|
131
266
|
thinkingLevelMap: {
|
|
132
|
-
off: reasoning.canDisableThinking === true ? "off" : null,
|
|
267
|
+
off: reasoning.canDisableThinking === true && declared !== void 0 && declared.length > 0 ? "off" : null,
|
|
133
268
|
minimal: null,
|
|
134
269
|
low: efforts.includes("low") ? "low" : null,
|
|
135
270
|
medium: efforts.includes("medium") ? "medium" : null,
|
|
@@ -140,18 +275,19 @@ function reasoningFields(info) {
|
|
|
140
275
|
};
|
|
141
276
|
}
|
|
142
277
|
/** Build one pi-ai model descriptor pointing at the loopback shim. */
|
|
143
|
-
function toPiModel(info, baseUrl) {
|
|
278
|
+
function toPiModel(info, baseUrl, observed, providerId = WORKBUDDY_PROVIDER) {
|
|
144
279
|
return {
|
|
145
280
|
id: info.id,
|
|
146
281
|
name: info.name,
|
|
147
282
|
api: "openai-completions",
|
|
148
|
-
provider:
|
|
283
|
+
provider: providerId,
|
|
149
284
|
baseUrl,
|
|
150
285
|
input: info.supportsImages === true ? ["text", "image"] : ["text"],
|
|
151
|
-
...reasoningFields(info),
|
|
286
|
+
...reasoningFields(info, observed),
|
|
152
287
|
cost: NO_COST,
|
|
153
288
|
contextWindow: info.contextWindow,
|
|
154
|
-
maxTokens: info.maxTokens
|
|
289
|
+
maxTokens: info.maxTokens,
|
|
290
|
+
compat: { maxTokensField: "max_tokens" }
|
|
155
291
|
};
|
|
156
292
|
}
|
|
157
293
|
/**
|
|
@@ -167,15 +303,17 @@ function toPiModel(info, baseUrl) {
|
|
|
167
303
|
* `modelErrors` since 0.1.5-alpha.2 (#12).
|
|
168
304
|
*/
|
|
169
305
|
function createWorkBuddyAdapter(options) {
|
|
170
|
-
const { shim, store, catalog, resolveAttachments } = options;
|
|
306
|
+
const { shim, store, catalog, resolveAttachments, observe } = options;
|
|
307
|
+
const providerId = options.providerId ?? "workbuddy";
|
|
308
|
+
const displayName = options.displayName ?? "WorkBuddy";
|
|
171
309
|
const buildModels = () => {
|
|
172
310
|
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
173
|
-
return catalog.current().map((info) => toPiModel(info, baseUrl));
|
|
311
|
+
return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id), providerId));
|
|
174
312
|
};
|
|
175
313
|
const provider = {
|
|
176
314
|
...createProvider({
|
|
177
|
-
id:
|
|
178
|
-
name:
|
|
315
|
+
id: providerId,
|
|
316
|
+
name: displayName,
|
|
179
317
|
auth: { apiKey: {
|
|
180
318
|
name: "WorkBuddy OAuth bearer token",
|
|
181
319
|
async resolve({ credential }) {
|
|
@@ -192,8 +330,8 @@ function createWorkBuddyAdapter(options) {
|
|
|
192
330
|
getModels: () => buildModels()
|
|
193
331
|
};
|
|
194
332
|
const profile = {
|
|
195
|
-
provider:
|
|
196
|
-
displayName
|
|
333
|
+
provider: providerId,
|
|
334
|
+
displayName,
|
|
197
335
|
streamIdleTimeoutMs: WORKBUDDY_STREAM_IDLE_TIMEOUT_MS,
|
|
198
336
|
retryPolicy: resolveRetryPolicy(void 0, "dsh-workbuddy-connect retryPolicy"),
|
|
199
337
|
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
@@ -201,7 +339,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
201
339
|
...REQUEST_IMAGE_BUDGETS,
|
|
202
340
|
piProvider: provider
|
|
203
341
|
};
|
|
204
|
-
let profiles = /* @__PURE__ */ new Map([[
|
|
342
|
+
let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
205
343
|
return {
|
|
206
344
|
adapter: new WorkBuddyPiAiAdapter(catalog, {
|
|
207
345
|
profiles: () => profiles,
|
|
@@ -210,7 +348,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
210
348
|
...resolveAttachments === void 0 ? {} : { resolveAttachments }
|
|
211
349
|
}),
|
|
212
350
|
invalidate: () => {
|
|
213
|
-
profiles = /* @__PURE__ */ new Map([[
|
|
351
|
+
profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
|
|
214
352
|
}
|
|
215
353
|
};
|
|
216
354
|
}
|
|
@@ -357,7 +495,7 @@ function writeOpenAIError(res, status, kind, message) {
|
|
|
357
495
|
} });
|
|
358
496
|
}
|
|
359
497
|
/** Read a request body with a size cap; over-limit bodies fail the request. */
|
|
360
|
-
function readBody(req) {
|
|
498
|
+
function readBody$1(req) {
|
|
361
499
|
return new Promise((resolve, reject) => {
|
|
362
500
|
const chunks = [];
|
|
363
501
|
let size = 0;
|
|
@@ -461,7 +599,7 @@ function createWorkBuddyShim(options) {
|
|
|
461
599
|
writeOpenAIError(res, 401, "not_signed_in", String(error));
|
|
462
600
|
return;
|
|
463
601
|
}
|
|
464
|
-
const raw = (await readBody(req)).toString("utf8");
|
|
602
|
+
const raw = (await readBody$1(req)).toString("utf8");
|
|
465
603
|
const prepared = prepareChatBody(raw);
|
|
466
604
|
const controller = new AbortController();
|
|
467
605
|
req.on("close", () => controller.abort());
|
|
@@ -499,17 +637,332 @@ function createWorkBuddyShim(options) {
|
|
|
499
637
|
};
|
|
500
638
|
}
|
|
501
639
|
//#endregion
|
|
502
|
-
//#region src/
|
|
503
|
-
/**
|
|
504
|
-
|
|
505
|
-
|
|
640
|
+
//#region src/probe-store.ts
|
|
641
|
+
/**
|
|
642
|
+
* Local record of reasoning-effort probes.
|
|
643
|
+
*
|
|
644
|
+
* What this stores is an *observation*, never a claim about the upstream: a
|
|
645
|
+
* model's row is only consulted when the catalog carries no explicit
|
|
646
|
+
* `supportedEfforts` set, and it always loses to a declared set. The plan this
|
|
647
|
+
* implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
|
|
648
|
+
* is invalidated whenever the model's catalog row changes, so every record
|
|
649
|
+
* carries a fingerprint of the fields the probe depended on.
|
|
650
|
+
*
|
|
651
|
+
* The file lives beside the plugin's own credential copy under `$DSH_HOME`,
|
|
652
|
+
* never in the desktop app's files, and carries no token, prompt, or response
|
|
653
|
+
* body — only model ids, effort spellings, and timestamps.
|
|
654
|
+
*
|
|
655
|
+
* @module dsh-workbuddy-connect/probe-store
|
|
656
|
+
*/
|
|
657
|
+
/** Basename of the probe record inside the Harness home. */
|
|
658
|
+
const WORKBUDDY_PROBE_FILENAME = ".workbuddy-probe.json";
|
|
659
|
+
/** On-disk format this reader accepts; other versions are discarded. */
|
|
660
|
+
const PROBE_FORMAT_VERSION = 1;
|
|
661
|
+
/**
|
|
662
|
+
* How long an observation stays usable. Conservative on purpose: the plan's
|
|
663
|
+
* whole argument is that upstream metadata moves fast, so a result that has
|
|
664
|
+
* outlived its fingerprint's usefulness should not quietly keep granting a
|
|
665
|
+
* picker entry.
|
|
666
|
+
*/
|
|
667
|
+
const DEFAULT_TTL_MS = 12096e5;
|
|
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);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Fingerprint the catalog fields a probe depends on.
|
|
682
|
+
*
|
|
683
|
+
* Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
|
|
684
|
+
* so a rename or a promo badge does not throw away a valid observation, and
|
|
685
|
+
* deliberately includes the whole reasoning object so any change to the
|
|
686
|
+
* declared shape re-probes.
|
|
687
|
+
*/
|
|
688
|
+
function fingerprintModel(info) {
|
|
689
|
+
const basis = JSON.stringify({
|
|
690
|
+
id: info.id,
|
|
691
|
+
reasoning: info.reasoning ?? null,
|
|
692
|
+
supportsImages: info.supportsImages ?? null
|
|
693
|
+
});
|
|
694
|
+
return createHash("sha256").update(basis).digest("hex").slice(0, 16);
|
|
695
|
+
}
|
|
696
|
+
/** Read-and-validate the documents on disk; anything malformed reads as empty. */
|
|
697
|
+
function readDocument(path) {
|
|
698
|
+
if (!existsSync(path)) return void 0;
|
|
699
|
+
let parsed;
|
|
700
|
+
try {
|
|
701
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
702
|
+
} catch {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
706
|
+
const wrapped = parsed;
|
|
707
|
+
if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
|
|
708
|
+
const records = wrapped["records"];
|
|
709
|
+
if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
|
|
710
|
+
return parsed;
|
|
711
|
+
}
|
|
712
|
+
/** One record's shape check; a bad row is dropped rather than trusted. */
|
|
713
|
+
function isRecord(value) {
|
|
714
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
715
|
+
const wrapped = value;
|
|
716
|
+
const validation = wrapped["validation"];
|
|
717
|
+
if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
|
|
718
|
+
if (typeof wrapped["fingerprint"] !== "string") return false;
|
|
719
|
+
if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
|
|
720
|
+
if (typeof wrapped["pluginVersion"] !== "string") return false;
|
|
721
|
+
const efforts = wrapped["efforts"];
|
|
722
|
+
if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* The plugin's probe records: read once, written atomically, never trusted
|
|
727
|
+
* across a fingerprint change or past the TTL.
|
|
728
|
+
*/
|
|
729
|
+
var WorkBuddyProbeStore = class {
|
|
730
|
+
path;
|
|
731
|
+
ttlMs;
|
|
732
|
+
pluginVersion;
|
|
733
|
+
now;
|
|
734
|
+
records;
|
|
735
|
+
constructor(options) {
|
|
736
|
+
const opts = typeof options === "string" ? {
|
|
737
|
+
path: options,
|
|
738
|
+
pluginVersion: "0.0.0"
|
|
739
|
+
} : options;
|
|
740
|
+
this.path = opts.path ?? workbuddyProbePath();
|
|
741
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
742
|
+
this.pluginVersion = opts.pluginVersion;
|
|
743
|
+
this.now = opts.now ?? (() => Date.now());
|
|
744
|
+
}
|
|
745
|
+
/** Resolved state-file path, for the CLI and tests. */
|
|
746
|
+
filePath() {
|
|
747
|
+
return this.path;
|
|
748
|
+
}
|
|
749
|
+
load() {
|
|
750
|
+
if (this.records === void 0) {
|
|
751
|
+
const document = readDocument(this.path);
|
|
752
|
+
const records = {};
|
|
753
|
+
for (const [id, record] of Object.entries(document?.records ?? {})) if (isRecord(record)) records[id] = record;
|
|
754
|
+
this.records = records;
|
|
755
|
+
}
|
|
756
|
+
return this.records;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* The usable record for a model, or `undefined` when there is none, it is
|
|
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.
|
|
765
|
+
*/
|
|
766
|
+
get(modelId, fingerprint, account) {
|
|
767
|
+
const record = this.load()[modelId];
|
|
768
|
+
if (record === void 0) return void 0;
|
|
769
|
+
if (record.fingerprint !== fingerprint) return void 0;
|
|
770
|
+
if (record.account !== account) return void 0;
|
|
771
|
+
if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
|
|
772
|
+
return record;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Store one observation. Only a decisive answer (`validating` /
|
|
776
|
+
* `non-validating`) replaces an existing decisive record: a transient
|
|
777
|
+
* `unknown` must not erase knowledge the user already paid for.
|
|
778
|
+
*/
|
|
779
|
+
set(modelId, record) {
|
|
780
|
+
const records = this.load();
|
|
781
|
+
const existing = records[modelId];
|
|
782
|
+
if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
|
|
783
|
+
records[modelId] = record;
|
|
784
|
+
this.persist();
|
|
785
|
+
}
|
|
786
|
+
/** Drop every record; used by the card's explicit "clear" action. */
|
|
787
|
+
clear() {
|
|
788
|
+
this.records = {};
|
|
789
|
+
this.persist();
|
|
790
|
+
}
|
|
791
|
+
/** Every record currently held, for status display. */
|
|
792
|
+
all() {
|
|
793
|
+
return { ...this.load() };
|
|
794
|
+
}
|
|
795
|
+
/** Build a record stamped with this store's clock, version, and account. */
|
|
796
|
+
record(fingerprint, validation, efforts, account) {
|
|
797
|
+
return {
|
|
798
|
+
fingerprint,
|
|
799
|
+
validation,
|
|
800
|
+
efforts: validation === "validating" ? [...efforts] : [],
|
|
801
|
+
probedAtMs: this.now(),
|
|
802
|
+
pluginVersion: this.pluginVersion,
|
|
803
|
+
account
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Write through a temporary file and rename, so a crash mid-write cannot
|
|
808
|
+
* leave a half-parsed document that reads as "no records" and silently drops
|
|
809
|
+
* every observation.
|
|
810
|
+
*/
|
|
811
|
+
persist() {
|
|
812
|
+
const directory = dirname(this.path);
|
|
813
|
+
try {
|
|
814
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
|
|
815
|
+
const document = {
|
|
816
|
+
version: PROBE_FORMAT_VERSION,
|
|
817
|
+
records: this.load()
|
|
818
|
+
};
|
|
819
|
+
const temporary = resolve(`${this.path}.tmp`);
|
|
820
|
+
writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
|
|
821
|
+
renameSync(temporary, this.path);
|
|
822
|
+
} catch {}
|
|
823
|
+
}
|
|
824
|
+
};
|
|
825
|
+
/**
|
|
826
|
+
* Order observations newest-first for display.
|
|
827
|
+
*
|
|
828
|
+
* The store keeps insertion order so the file reads chronologically, but the
|
|
829
|
+
* card wants the most recent detection at the top: a sweep the user just ran
|
|
830
|
+
* should not appear below every earlier one, which is what appending to an
|
|
831
|
+
* insertion-ordered list does.
|
|
832
|
+
*/
|
|
833
|
+
function newestFirst(records) {
|
|
834
|
+
return [...records].sort((a, b) => b.probedAt - a.probedAt);
|
|
835
|
+
}
|
|
836
|
+
//#endregion
|
|
837
|
+
//#region src/probe-service.ts
|
|
838
|
+
/**
|
|
839
|
+
* Serial probe runner. One instance is shared by the manual API and any
|
|
840
|
+
* future automatic trigger, so the two can never overlap.
|
|
841
|
+
*/
|
|
842
|
+
var WorkBuddyProbeService = class {
|
|
843
|
+
options;
|
|
844
|
+
queue = Promise.resolve();
|
|
845
|
+
pending = /* @__PURE__ */ new Map();
|
|
846
|
+
running = false;
|
|
847
|
+
constructor(options) {
|
|
848
|
+
this.options = options;
|
|
849
|
+
}
|
|
850
|
+
/** Whether a sweep is in flight right now. */
|
|
851
|
+
isRunning() {
|
|
852
|
+
return this.running;
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* The record the adapter may use for this model, or `undefined`.
|
|
856
|
+
*
|
|
857
|
+
* Applies the plan's precedence (§5): a declared set always wins, so a model
|
|
858
|
+
* that declares `supportedEfforts` is never answered from an observation.
|
|
859
|
+
*/
|
|
860
|
+
recordFor(modelId) {
|
|
861
|
+
const info = this.options.catalog.current().find((model) => model.id === modelId);
|
|
862
|
+
if (info === void 0) return void 0;
|
|
863
|
+
if (info.reasoning?.supportedEfforts !== void 0 && info.reasoning.supportedEfforts.length > 0) return;
|
|
864
|
+
const account = this.options.account();
|
|
865
|
+
if (account === void 0) return void 0;
|
|
866
|
+
return this.options.store.get(modelId, fingerprintModel(info), account);
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Probe one model, serially.
|
|
870
|
+
*
|
|
871
|
+
* The authenticated manual route supplies one-request consent after UI
|
|
872
|
+
* confirmation. Other callers must pass the configured consent gate.
|
|
873
|
+
* Manual consent never changes the automatic-probing configuration.
|
|
874
|
+
* Explicit requests bypass historical results, but share an ongoing run.
|
|
875
|
+
*/
|
|
876
|
+
async probe(modelId, manualConsent = false) {
|
|
877
|
+
if (!manualConsent && !this.options.consent()) return {
|
|
878
|
+
state: "unavailable",
|
|
879
|
+
reason: "probing is not authorized"
|
|
880
|
+
};
|
|
881
|
+
if (this.options.catalog.current().find((model) => model.id === modelId) === void 0) return {
|
|
882
|
+
state: "unavailable",
|
|
883
|
+
reason: `unknown model: ${modelId}`
|
|
884
|
+
};
|
|
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);
|
|
892
|
+
if (pending !== void 0) return pending;
|
|
893
|
+
const run = this.queue.then(async () => {
|
|
894
|
+
const current = this.options.catalog.current().find((model) => model.id === modelId);
|
|
895
|
+
if (current === void 0) return {
|
|
896
|
+
state: "unavailable",
|
|
897
|
+
reason: `unknown model: ${modelId}`
|
|
898
|
+
};
|
|
899
|
+
if (!manualConsent && !this.options.consent()) return {
|
|
900
|
+
state: "unavailable",
|
|
901
|
+
reason: "probing is not authorized"
|
|
902
|
+
};
|
|
903
|
+
if (current.reasoning?.supports !== true || (current.reasoning.supportedEfforts?.length ?? 0) > 0) return {
|
|
904
|
+
state: "unavailable",
|
|
905
|
+
reason: "model does not need detection"
|
|
906
|
+
};
|
|
907
|
+
const cached = this.recordFor(modelId);
|
|
908
|
+
if (!manualConsent && cached !== void 0 && cached.validation !== "unknown") return {
|
|
909
|
+
state: "ok",
|
|
910
|
+
validation: cached.validation,
|
|
911
|
+
efforts: cached.efforts,
|
|
912
|
+
requests: 0
|
|
913
|
+
};
|
|
914
|
+
if (this.options.account() !== account) return {
|
|
915
|
+
state: "unavailable",
|
|
916
|
+
reason: "account changed before detection"
|
|
917
|
+
};
|
|
918
|
+
const credential = await this.options.credentials.current();
|
|
919
|
+
if (credential === void 0) return {
|
|
920
|
+
state: "unavailable",
|
|
921
|
+
reason: "no WorkBuddy credential"
|
|
922
|
+
};
|
|
923
|
+
const send = this.options.send === void 0 ? (effort, signal) => this.options.client.probeEffort(credential, modelId, effort, signal) : this.options.send(modelId);
|
|
924
|
+
this.running = true;
|
|
925
|
+
try {
|
|
926
|
+
const outcome = await probeModel({
|
|
927
|
+
send,
|
|
928
|
+
...this.options.sentinel === void 0 ? {} : { sentinel: this.options.sentinel }
|
|
929
|
+
});
|
|
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);
|
|
935
|
+
this.options.store.set(modelId, record);
|
|
936
|
+
if (outcome.validation === "unknown") return {
|
|
937
|
+
state: "unavailable",
|
|
938
|
+
reason: outcome.reason
|
|
939
|
+
};
|
|
940
|
+
return {
|
|
941
|
+
state: "ok",
|
|
942
|
+
validation: outcome.validation,
|
|
943
|
+
efforts: record.efforts,
|
|
944
|
+
requests: outcome.requests
|
|
945
|
+
};
|
|
946
|
+
} finally {
|
|
947
|
+
this.running = false;
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
this.queue = run.catch(() => void 0);
|
|
951
|
+
this.pending.set(pendingKey, run);
|
|
952
|
+
try {
|
|
953
|
+
return await run;
|
|
954
|
+
} finally {
|
|
955
|
+
this.pending.delete(pendingKey);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
};
|
|
506
959
|
//#endregion
|
|
507
960
|
//#region src/web-status.ts
|
|
508
961
|
/** Redact token-like content before it crosses to the browser. */
|
|
509
962
|
function safeMessage(error) {
|
|
510
963
|
return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|refresh_token|access_token)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
|
|
511
964
|
}
|
|
512
|
-
function json(res, status, body) {
|
|
965
|
+
function json$1(res, status, body) {
|
|
513
966
|
const payload = JSON.stringify(body);
|
|
514
967
|
res.writeHead(status, {
|
|
515
968
|
"Content-Type": "application/json",
|
|
@@ -533,7 +986,10 @@ function loopbackRequest(req) {
|
|
|
533
986
|
*/
|
|
534
987
|
async function workBuddyWebStatus(deps) {
|
|
535
988
|
const authStatus = await deps.store.status();
|
|
536
|
-
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
|
+
};
|
|
537
993
|
const status = {
|
|
538
994
|
status: "signed-in",
|
|
539
995
|
...authStatus.nickname === void 0 ? {} : { nickname: authStatus.nickname },
|
|
@@ -541,61 +997,78 @@ async function workBuddyWebStatus(deps) {
|
|
|
541
997
|
...authStatus.source === void 0 ? {} : { source: authStatus.source },
|
|
542
998
|
...authStatus.expiresAtMs === void 0 ? {} : { expiresAt: authStatus.expiresAtMs }
|
|
543
999
|
};
|
|
544
|
-
const modelsField = deps.models().
|
|
1000
|
+
const modelsField = deps.models().map((model) => {
|
|
545
1001
|
const rate = normalizeCredits(model.billing?.credits);
|
|
1002
|
+
const supported = model.supportedContextWindows ?? [];
|
|
1003
|
+
const maxContextWindow = supported.length > 0 ? Math.max(...supported) : void 0;
|
|
546
1004
|
return {
|
|
547
1005
|
id: model.id,
|
|
548
1006
|
name: model.name,
|
|
549
1007
|
...model.billing?.free === true ? { free: true } : {},
|
|
550
1008
|
...model.billing?.badges !== void 0 && model.billing.badges.length > 0 ? { badges: model.billing.badges } : {},
|
|
551
|
-
...rate === void 0 ? {} : { credits: rate }
|
|
1009
|
+
...rate === void 0 ? {} : { credits: rate },
|
|
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 } : {}
|
|
552
1014
|
};
|
|
553
1015
|
});
|
|
554
|
-
const
|
|
1016
|
+
const catalog = deps.catalog?.();
|
|
1017
|
+
const withCatalog = catalog === void 0 ? status : {
|
|
555
1018
|
...status,
|
|
1019
|
+
catalog
|
|
1020
|
+
};
|
|
1021
|
+
const statusWithModels = modelsField.length > 0 ? {
|
|
1022
|
+
...withCatalog,
|
|
556
1023
|
models: modelsField
|
|
557
|
-
} :
|
|
1024
|
+
} : withCatalog;
|
|
1025
|
+
const probed = deps.probe === void 0 ? statusWithModels : {
|
|
1026
|
+
...statusWithModels,
|
|
1027
|
+
probe: deps.probe(),
|
|
1028
|
+
...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey }
|
|
1029
|
+
};
|
|
558
1030
|
try {
|
|
559
1031
|
const credential = await deps.store.current();
|
|
560
1032
|
if (credential !== void 0) {
|
|
561
1033
|
const credits = await deps.client.fetchCredits(credential);
|
|
562
1034
|
return {
|
|
563
|
-
...
|
|
1035
|
+
...probed,
|
|
564
1036
|
credits
|
|
565
1037
|
};
|
|
566
1038
|
}
|
|
567
1039
|
} catch (error) {
|
|
568
1040
|
return {
|
|
569
|
-
...
|
|
1041
|
+
...probed,
|
|
570
1042
|
creditsError: safeMessage(error)
|
|
571
1043
|
};
|
|
572
1044
|
}
|
|
573
|
-
return
|
|
1045
|
+
return probed;
|
|
574
1046
|
}
|
|
575
1047
|
/** The status route's request handler, extracted so tests can mount it on a bare server. */
|
|
576
1048
|
function workBuddyStatusHandler(deps) {
|
|
577
1049
|
return async (req, res) => {
|
|
578
1050
|
if (req.method !== "GET") {
|
|
579
|
-
json(res, 405, { error: "method not allowed" });
|
|
1051
|
+
json$1(res, 405, { error: "method not allowed" });
|
|
580
1052
|
return;
|
|
581
1053
|
}
|
|
582
1054
|
if (!loopbackRequest(req)) {
|
|
583
|
-
json(res, 403, { error: "request-not-trusted" });
|
|
1055
|
+
json$1(res, 403, { error: "request-not-trusted" });
|
|
584
1056
|
return;
|
|
585
1057
|
}
|
|
586
1058
|
try {
|
|
587
|
-
json(res, 200, await workBuddyWebStatus(deps));
|
|
1059
|
+
json$1(res, 200, await workBuddyWebStatus(deps));
|
|
588
1060
|
} catch (error) {
|
|
589
|
-
json(res, 500, { error: safeMessage(error) });
|
|
1061
|
+
json$1(res, 500, { error: safeMessage(error) });
|
|
590
1062
|
}
|
|
591
1063
|
};
|
|
592
1064
|
}
|
|
593
1065
|
/** Mount the GET status route on an optional webServer context. */
|
|
594
1066
|
function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
1067
|
+
const path = deps.path ?? "/plugins/dsh-workbuddy-connect/status";
|
|
595
1068
|
ctx.effect(() => {
|
|
596
1069
|
const dispose = ctx.webServer.register({
|
|
597
1070
|
kind: "exact",
|
|
598
|
-
path
|
|
1071
|
+
path,
|
|
599
1072
|
handler: workBuddyStatusHandler(deps)
|
|
600
1073
|
});
|
|
601
1074
|
return () => {
|
|
@@ -604,13 +1077,153 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
604
1077
|
}, "dsh-workbuddy-connect: Web status route");
|
|
605
1078
|
}
|
|
606
1079
|
//#endregion
|
|
1080
|
+
//#region src/probe-route.ts
|
|
1081
|
+
/**
|
|
1082
|
+
* Probe control route: the only state-changing endpoint the plugin exposes.
|
|
1083
|
+
*
|
|
1084
|
+
* Two guards, because they stop different things (see `docs/reasoning-effort-probe-plan.md`
|
|
1085
|
+
* §6.4 and the v0.3.1 note in AGENTS.md about their exact scope):
|
|
1086
|
+
*
|
|
1087
|
+
* 1. **Loopback Host + Origin**, shared with the status route. This drops
|
|
1088
|
+
* DNS-rebinding pages, whose requests arrive addressed to the attacker's
|
|
1089
|
+
* domain.
|
|
1090
|
+
* 2. **An in-process random key**, minted per process and handed only to the
|
|
1091
|
+
* same-origin card. Loopback alone is *not* authentication — any local
|
|
1092
|
+
* process can write `Host: 127.0.0.1` — so a route that spends the user's
|
|
1093
|
+
* credit must prove the caller was told the key.
|
|
1094
|
+
*
|
|
1095
|
+
* The route never accepts a prompt, a model id outside the live catalog, or a
|
|
1096
|
+
* sentinel from the browser: a probe request is assembled entirely host-side.
|
|
1097
|
+
*
|
|
1098
|
+
* @module dsh-workbuddy-connect/probe-route
|
|
1099
|
+
*/
|
|
1100
|
+
/** Largest control body accepted; these payloads are a few dozen bytes. */
|
|
1101
|
+
const MAX_BODY_BYTES = 4096;
|
|
1102
|
+
/** Mint the per-process control key. */
|
|
1103
|
+
function createProbeKey() {
|
|
1104
|
+
return randomBytes(24).toString("hex");
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Constant-time key comparison; a length mismatch is a failure, not a crash.
|
|
1108
|
+
*/
|
|
1109
|
+
function keyMatches(expected, presented) {
|
|
1110
|
+
if (presented === void 0 || presented.length !== expected.length) return false;
|
|
1111
|
+
const a = Buffer.from(expected);
|
|
1112
|
+
const b = Buffer.from(presented);
|
|
1113
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1114
|
+
}
|
|
1115
|
+
function json(res, status, body) {
|
|
1116
|
+
const payload = JSON.stringify(body);
|
|
1117
|
+
res.writeHead(status, {
|
|
1118
|
+
"Content-Type": "application/json",
|
|
1119
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
1120
|
+
});
|
|
1121
|
+
res.end(payload);
|
|
1122
|
+
}
|
|
1123
|
+
/** Read the request body with a hard ceiling. */
|
|
1124
|
+
async function readBody(req) {
|
|
1125
|
+
const chunks = [];
|
|
1126
|
+
let total = 0;
|
|
1127
|
+
for await (const chunk of req) {
|
|
1128
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1129
|
+
total += buffer.length;
|
|
1130
|
+
if (total > MAX_BODY_BYTES) return void 0;
|
|
1131
|
+
chunks.push(buffer);
|
|
1132
|
+
}
|
|
1133
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1134
|
+
}
|
|
1135
|
+
/** Parse and shape-check an action; unknown fields are ignored, not trusted. */
|
|
1136
|
+
function parseAction(text) {
|
|
1137
|
+
let parsed;
|
|
1138
|
+
try {
|
|
1139
|
+
parsed = JSON.parse(text);
|
|
1140
|
+
} catch {
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
1144
|
+
const wrapped = parsed;
|
|
1145
|
+
const action = wrapped["action"];
|
|
1146
|
+
if (action === "clear") return { action: "clear" };
|
|
1147
|
+
if (action === "refresh") return { action: "refresh" };
|
|
1148
|
+
if (action === "probe") {
|
|
1149
|
+
const model = wrapped["model"];
|
|
1150
|
+
if (typeof model !== "string" || model.trim() === "") return void 0;
|
|
1151
|
+
return {
|
|
1152
|
+
action: "probe",
|
|
1153
|
+
model: model.trim()
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* The control route's handler, extracted so tests can mount it on a bare
|
|
1159
|
+
* server with a known key.
|
|
1160
|
+
*/
|
|
1161
|
+
function workBuddyProbeHandler(deps, key) {
|
|
1162
|
+
return async (req, res) => {
|
|
1163
|
+
if (req.method !== "POST") {
|
|
1164
|
+
json(res, 405, { error: "method not allowed" });
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
|
|
1168
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (!keyMatches(key, req.headers["x-workbuddy-probe-key"])) {
|
|
1172
|
+
json(res, 403, { error: "invalid-probe-key" });
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const body = await readBody(req);
|
|
1176
|
+
if (body === void 0) {
|
|
1177
|
+
json(res, 413, { error: "body too large" });
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const action = parseAction(body);
|
|
1181
|
+
if (action === void 0) {
|
|
1182
|
+
json(res, 400, { error: "invalid action" });
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
try {
|
|
1186
|
+
if (action.action === "clear") {
|
|
1187
|
+
deps.clear();
|
|
1188
|
+
json(res, 200, { state: "cleared" });
|
|
1189
|
+
return;
|
|
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
|
+
}
|
|
1199
|
+
json(res, 200, await deps.probe(action.model));
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
/** Mount the POST probe-control route on an optional webServer context. */
|
|
1206
|
+
function registerWorkBuddyProbeRoute(ctx, deps, key) {
|
|
1207
|
+
const path = deps.path ?? "/plugins/dsh-workbuddy-connect/probe";
|
|
1208
|
+
ctx.effect(() => {
|
|
1209
|
+
const dispose = ctx.webServer.register({
|
|
1210
|
+
kind: "exact",
|
|
1211
|
+
path,
|
|
1212
|
+
handler: workBuddyProbeHandler(deps, key)
|
|
1213
|
+
});
|
|
1214
|
+
return () => {
|
|
1215
|
+
dispose();
|
|
1216
|
+
};
|
|
1217
|
+
}, "dsh-workbuddy-connect: probe control route");
|
|
1218
|
+
}
|
|
1219
|
+
//#endregion
|
|
607
1220
|
//#region src/index.ts
|
|
608
1221
|
/** Stable Cordis plugin name. */
|
|
609
1222
|
const name = "llm-workbuddy";
|
|
610
1223
|
/** The model registry required before the provider can register. */
|
|
611
1224
|
const inject = ["llm"];
|
|
612
1225
|
/**
|
|
613
|
-
* Settings namespace owning the
|
|
1226
|
+
* Settings namespace owning the CN card's section.
|
|
614
1227
|
*
|
|
615
1228
|
* DSH 0.1.2 dropped the `settingsNamespace()` branding function: a namespace is
|
|
616
1229
|
* now a nominal string, validated by the type system where it is used rather
|
|
@@ -622,106 +1235,563 @@ const inject = ["llm"];
|
|
|
622
1235
|
* their namespaces as plain string literals).
|
|
623
1236
|
*/
|
|
624
1237
|
const WORKBUDDY_SETTINGS_NS = "workbuddy";
|
|
625
|
-
const Config = z.object({ authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)") });
|
|
626
1238
|
/**
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
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.
|
|
631
1248
|
*/
|
|
632
|
-
|
|
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)");
|
|
1295
|
+
const Config = z.object({
|
|
1296
|
+
authFile: AUTH_FILE_FIELD,
|
|
1297
|
+
authFileAI: AUTH_FILE_AI_FIELD,
|
|
1298
|
+
probeConsent: PROBE_CONSENT_FIELD
|
|
1299
|
+
});
|
|
1300
|
+
/**
|
|
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.
|
|
1308
|
+
*/
|
|
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) {
|
|
633
1339
|
const client = new WorkBuddyUpstreamClient();
|
|
1340
|
+
const configured = configuredAuthFile(config, variant);
|
|
634
1341
|
const store = new WorkBuddyCredentialStore({
|
|
635
|
-
|
|
1342
|
+
variant,
|
|
1343
|
+
...configured === void 0 ? {} : { desktopPath: configured },
|
|
636
1344
|
refresh: (credential) => client.refreshToken(credential)
|
|
637
1345
|
});
|
|
638
|
-
const
|
|
639
|
-
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,
|
|
640
1356
|
store,
|
|
641
1357
|
client,
|
|
642
1358
|
catalog,
|
|
643
|
-
|
|
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
|
+
}];
|
|
644
1417
|
});
|
|
645
|
-
|
|
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;
|
|
1437
|
+
const shim = createWorkBuddyShim({
|
|
646
1438
|
store,
|
|
647
1439
|
client,
|
|
648
|
-
|
|
649
|
-
|
|
1440
|
+
catalog,
|
|
1441
|
+
logger: ctx.logger
|
|
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) {
|
|
650
1511
|
let current = () => config;
|
|
1512
|
+
/** Timers and in-flight work belonging to this plugin instance. */
|
|
1513
|
+
let stopped = false;
|
|
1514
|
+
const timers = [];
|
|
1515
|
+
/**
|
|
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.
|
|
1532
|
+
*
|
|
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.
|
|
1542
|
+
*/
|
|
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();
|
|
1579
|
+
};
|
|
1580
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
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
|
+
}
|
|
1633
|
+
});
|
|
651
1634
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
652
|
-
|
|
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, {
|
|
653
1651
|
setSource(source) {
|
|
654
|
-
|
|
1652
|
+
sources.cn = source;
|
|
1653
|
+
current = merged;
|
|
655
1654
|
},
|
|
656
|
-
onChange
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
660
1663
|
});
|
|
661
1664
|
});
|
|
662
|
-
let stopped = false;
|
|
663
1665
|
ctx.effect(() => () => {
|
|
664
1666
|
stopped = true;
|
|
665
|
-
|
|
1667
|
+
for (const timer of timers) clearInterval(timer);
|
|
1668
|
+
timers.length = 0;
|
|
666
1669
|
clearHostHeartbeat();
|
|
667
1670
|
});
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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;
|
|
681
1699
|
try {
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
declared: false
|
|
689
|
-
}]);
|
|
690
|
-
} finally {
|
|
691
|
-
if (releaseAdapter === void 0 || releaseDirectory === void 0) {
|
|
692
|
-
releaseAdapter?.();
|
|
693
|
-
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;
|
|
694
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;
|
|
695
1722
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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);
|
|
708
1769
|
return;
|
|
709
1770
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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);
|
|
724
1794
|
});
|
|
725
1795
|
}
|
|
726
1796
|
//#endregion
|
|
727
|
-
export { Config, FALLBACK_WORKBUDDY_MODELS, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_PROVIDER, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WorkBuddyCatalog, WorkBuddyCredentialStore, WorkBuddyUpstreamClient, apply, classifyUpstreamError, clearHostHeartbeat, createWorkBuddyAdapter, createWorkBuddyShim, defaultDesktopAuthCandidates, defaultDesktopAuthPath, inject, isHeartbeatProcessAlive, name, normalizeCredits, parseWorkBuddyAuth, prepareChatBody, processStartTimeMs, readHostHeartbeat, regionOf, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath };
|
|
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 };
|