dsh-workbuddy-connect 0.3.2 → 0.4.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 +17 -5
- package/README.md +18 -7
- package/lib/bin.js +1 -1
- package/lib/client.js +866 -31
- package/lib/{host-heartbeat-BSsKUoB8.js → host-heartbeat-0UAWqWac.js} +232 -3
- package/lib/index.d.ts +224 -1
- package/lib/index.js +563 -43
- package/package.json +11 -5
package/lib/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { S as workbuddyOwnAuthPath, _ as
|
|
1
|
+
import { C as defaultDesktopAuthPath, S as defaultDesktopAuthCandidates, T as workbuddyOwnAuthPath, _ as FALLBACK_WORKBUDDY_MODELS, a as readHostHeartbeat, b as WORKBUDDY_AUTH_FILE_ENV, c as WORKBUDDY_CONNECT_VERSION, d as normalizeCredits, f as prepareChatBody, g as randomSentinel, h as probeModel, i as processStartTimeMs, l as WorkBuddyUpstreamClient, m as PROBE_EFFORT_CANDIDATES, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as regionOf, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as classifyUpstreamError, v as WorkBuddyCatalog, w as parseWorkBuddyAuth, x as WorkBuddyCredentialStore, y as WORKBUDDY_AUTH_FILENAME } from "./host-heartbeat-0UAWqWac.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";
|
|
3
5
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
4
6
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
5
7
|
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
6
8
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
7
|
-
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
8
10
|
import { createServer } from "node:http";
|
|
9
11
|
import { Readable } from "node:stream";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
13
|
//#region src/adapter.ts
|
|
11
14
|
/**
|
|
12
15
|
* The `workbuddy` pi-ai provider: one loopback-backed adapter registered
|
|
@@ -107,29 +110,40 @@ function withCatalogDisplay(name, info) {
|
|
|
107
110
|
* `thinkingLevelMap` (every level pinned to its wire spelling or `null` for
|
|
108
111
|
* unsupported), mirroring `dsh-llm-pi-ai`'s own `resolveModelReasoning`.
|
|
109
112
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
113
|
+
* Two sources, strictly ordered (`docs/reasoning-effort-probe-plan.md` §5):
|
|
114
|
+
*
|
|
115
|
+
* 1. **The declared set.** When the upstream declares a non-empty
|
|
116
|
+
* `supportedEfforts`, exactly those values are offered and nothing else.
|
|
117
|
+
* This always wins: an observation never widens or narrows a declared set.
|
|
118
|
+
* 2. **A local observation.** Rows without a declared set (the older
|
|
119
|
+
* `{effort, summary}` shape) normally get no control at all — their
|
|
120
|
+
* selectable set is client-side knowledge the catalog does not carry, and
|
|
121
|
+
* the desktop app differs per model there. If the user authorized a probe
|
|
122
|
+
* and it established that the upstream *validates* the parameter, the
|
|
123
|
+
* verified spellings are offered.
|
|
124
|
+
*
|
|
125
|
+
* A `non-validating` observation deliberately yields no control: the upstream
|
|
126
|
+
* accepts values that cannot exist (measured on `glm-5.2`), so every per-level
|
|
127
|
+
* acceptance it produced would be a false positive.
|
|
128
|
+
*
|
|
129
|
+
* `off` is offered only when the upstream declares `canDisableThinking: true`.
|
|
130
|
+
* It is never probed — disabling thinking is a separate capability, and the
|
|
131
|
+
* per-model acceptance of `off` cannot be inferred from the row's shape.
|
|
132
|
+
*
|
|
133
|
+
* The offered set is described internally as "verified accepted", never as
|
|
134
|
+
* "verified effective": acceptance proves the upstream did not reject the
|
|
135
|
+
* spelling, not that it changes what the model does.
|
|
123
136
|
*/
|
|
124
|
-
function reasoningFields(info) {
|
|
137
|
+
function reasoningFields(info, observed) {
|
|
125
138
|
const reasoning = info.reasoning;
|
|
126
139
|
if (reasoning === void 0 || reasoning.supports !== true) return { reasoning: false };
|
|
127
|
-
const
|
|
128
|
-
|
|
140
|
+
const declared = reasoning.supportedEfforts;
|
|
141
|
+
const efforts = declared !== void 0 && declared.length > 0 ? declared : observed?.validation === "validating" && observed.efforts.length > 0 ? observed.efforts : void 0;
|
|
142
|
+
if (efforts === void 0) return { reasoning: false };
|
|
129
143
|
return {
|
|
130
144
|
reasoning: true,
|
|
131
145
|
thinkingLevelMap: {
|
|
132
|
-
off: reasoning.canDisableThinking === true ? "off" : null,
|
|
146
|
+
off: reasoning.canDisableThinking === true && declared !== void 0 && declared.length > 0 ? "off" : null,
|
|
133
147
|
minimal: null,
|
|
134
148
|
low: efforts.includes("low") ? "low" : null,
|
|
135
149
|
medium: efforts.includes("medium") ? "medium" : null,
|
|
@@ -140,7 +154,7 @@ function reasoningFields(info) {
|
|
|
140
154
|
};
|
|
141
155
|
}
|
|
142
156
|
/** Build one pi-ai model descriptor pointing at the loopback shim. */
|
|
143
|
-
function toPiModel(info, baseUrl) {
|
|
157
|
+
function toPiModel(info, baseUrl, observed) {
|
|
144
158
|
return {
|
|
145
159
|
id: info.id,
|
|
146
160
|
name: info.name,
|
|
@@ -148,7 +162,7 @@ function toPiModel(info, baseUrl) {
|
|
|
148
162
|
provider: WORKBUDDY_PROVIDER,
|
|
149
163
|
baseUrl,
|
|
150
164
|
input: info.supportsImages === true ? ["text", "image"] : ["text"],
|
|
151
|
-
...reasoningFields(info),
|
|
165
|
+
...reasoningFields(info, observed),
|
|
152
166
|
cost: NO_COST,
|
|
153
167
|
contextWindow: info.contextWindow,
|
|
154
168
|
maxTokens: info.maxTokens
|
|
@@ -167,10 +181,10 @@ function toPiModel(info, baseUrl) {
|
|
|
167
181
|
* `modelErrors` since 0.1.5-alpha.2 (#12).
|
|
168
182
|
*/
|
|
169
183
|
function createWorkBuddyAdapter(options) {
|
|
170
|
-
const { shim, store, catalog, resolveAttachments } = options;
|
|
184
|
+
const { shim, store, catalog, resolveAttachments, observe } = options;
|
|
171
185
|
const buildModels = () => {
|
|
172
186
|
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
173
|
-
return catalog.current().map((info) => toPiModel(info, baseUrl));
|
|
187
|
+
return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id)));
|
|
174
188
|
};
|
|
175
189
|
const provider = {
|
|
176
190
|
...createProvider({
|
|
@@ -357,7 +371,7 @@ function writeOpenAIError(res, status, kind, message) {
|
|
|
357
371
|
} });
|
|
358
372
|
}
|
|
359
373
|
/** Read a request body with a size cap; over-limit bodies fail the request. */
|
|
360
|
-
function readBody(req) {
|
|
374
|
+
function readBody$1(req) {
|
|
361
375
|
return new Promise((resolve, reject) => {
|
|
362
376
|
const chunks = [];
|
|
363
377
|
let size = 0;
|
|
@@ -461,7 +475,7 @@ function createWorkBuddyShim(options) {
|
|
|
461
475
|
writeOpenAIError(res, 401, "not_signed_in", String(error));
|
|
462
476
|
return;
|
|
463
477
|
}
|
|
464
|
-
const raw = (await readBody(req)).toString("utf8");
|
|
478
|
+
const raw = (await readBody$1(req)).toString("utf8");
|
|
465
479
|
const prepared = prepareChatBody(raw);
|
|
466
480
|
const controller = new AbortController();
|
|
467
481
|
req.on("close", () => controller.abort());
|
|
@@ -499,17 +513,317 @@ function createWorkBuddyShim(options) {
|
|
|
499
513
|
};
|
|
500
514
|
}
|
|
501
515
|
//#endregion
|
|
516
|
+
//#region src/probe-store.ts
|
|
517
|
+
/**
|
|
518
|
+
* Local record of reasoning-effort probes.
|
|
519
|
+
*
|
|
520
|
+
* What this stores is an *observation*, never a claim about the upstream: a
|
|
521
|
+
* model's row is only consulted when the catalog carries no explicit
|
|
522
|
+
* `supportedEfforts` set, and it always loses to a declared set. The plan this
|
|
523
|
+
* implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
|
|
524
|
+
* is invalidated whenever the model's catalog row changes, so every record
|
|
525
|
+
* carries a fingerprint of the fields the probe depended on.
|
|
526
|
+
*
|
|
527
|
+
* The file lives beside the plugin's own credential copy under `$DSH_HOME`,
|
|
528
|
+
* never in the desktop app's files, and carries no token, prompt, or response
|
|
529
|
+
* body — only model ids, effort spellings, and timestamps.
|
|
530
|
+
*
|
|
531
|
+
* @module dsh-workbuddy-connect/probe-store
|
|
532
|
+
*/
|
|
533
|
+
/** Basename of the probe record inside the Harness home. */
|
|
534
|
+
const WORKBUDDY_PROBE_FILENAME = ".workbuddy-probe.json";
|
|
535
|
+
/** On-disk format this reader accepts; other versions are discarded. */
|
|
536
|
+
const PROBE_FORMAT_VERSION = 1;
|
|
537
|
+
/**
|
|
538
|
+
* How long an observation stays usable. Conservative on purpose: the plan's
|
|
539
|
+
* whole argument is that upstream metadata moves fast, so a result that has
|
|
540
|
+
* outlived its fingerprint's usefulness should not quietly keep granting a
|
|
541
|
+
* picker entry.
|
|
542
|
+
*/
|
|
543
|
+
const DEFAULT_TTL_MS = 12096e5;
|
|
544
|
+
/** Plugin-owned probe record path inside the Harness home. */
|
|
545
|
+
function workbuddyProbePath() {
|
|
546
|
+
return join(resolveDshHome(), WORKBUDDY_PROBE_FILENAME);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Fingerprint the catalog fields a probe depends on.
|
|
550
|
+
*
|
|
551
|
+
* Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
|
|
552
|
+
* so a rename or a promo badge does not throw away a valid observation, and
|
|
553
|
+
* deliberately includes the whole reasoning object so any change to the
|
|
554
|
+
* declared shape re-probes.
|
|
555
|
+
*/
|
|
556
|
+
function fingerprintModel(info) {
|
|
557
|
+
const basis = JSON.stringify({
|
|
558
|
+
id: info.id,
|
|
559
|
+
reasoning: info.reasoning ?? null,
|
|
560
|
+
supportsImages: info.supportsImages ?? null
|
|
561
|
+
});
|
|
562
|
+
return createHash("sha256").update(basis).digest("hex").slice(0, 16);
|
|
563
|
+
}
|
|
564
|
+
/** Read-and-validate the documents on disk; anything malformed reads as empty. */
|
|
565
|
+
function readDocument(path) {
|
|
566
|
+
if (!existsSync(path)) return void 0;
|
|
567
|
+
let parsed;
|
|
568
|
+
try {
|
|
569
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
570
|
+
} catch {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
574
|
+
const wrapped = parsed;
|
|
575
|
+
if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
|
|
576
|
+
const records = wrapped["records"];
|
|
577
|
+
if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
|
|
578
|
+
return parsed;
|
|
579
|
+
}
|
|
580
|
+
/** One record's shape check; a bad row is dropped rather than trusted. */
|
|
581
|
+
function isRecord(value) {
|
|
582
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
583
|
+
const wrapped = value;
|
|
584
|
+
const validation = wrapped["validation"];
|
|
585
|
+
if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
|
|
586
|
+
if (typeof wrapped["fingerprint"] !== "string") return false;
|
|
587
|
+
if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
|
|
588
|
+
if (typeof wrapped["pluginVersion"] !== "string") return false;
|
|
589
|
+
const efforts = wrapped["efforts"];
|
|
590
|
+
if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The plugin's probe records: read once, written atomically, never trusted
|
|
595
|
+
* across a fingerprint change or past the TTL.
|
|
596
|
+
*/
|
|
597
|
+
var WorkBuddyProbeStore = class {
|
|
598
|
+
path;
|
|
599
|
+
ttlMs;
|
|
600
|
+
pluginVersion;
|
|
601
|
+
now;
|
|
602
|
+
records;
|
|
603
|
+
constructor(options) {
|
|
604
|
+
const opts = typeof options === "string" ? {
|
|
605
|
+
path: options,
|
|
606
|
+
pluginVersion: "0.0.0"
|
|
607
|
+
} : options;
|
|
608
|
+
this.path = opts.path ?? workbuddyProbePath();
|
|
609
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
610
|
+
this.pluginVersion = opts.pluginVersion;
|
|
611
|
+
this.now = opts.now ?? (() => Date.now());
|
|
612
|
+
}
|
|
613
|
+
/** Resolved state-file path, for the CLI and tests. */
|
|
614
|
+
filePath() {
|
|
615
|
+
return this.path;
|
|
616
|
+
}
|
|
617
|
+
load() {
|
|
618
|
+
if (this.records === void 0) {
|
|
619
|
+
const document = readDocument(this.path);
|
|
620
|
+
const records = {};
|
|
621
|
+
for (const [id, record] of Object.entries(document?.records ?? {})) if (isRecord(record)) records[id] = record;
|
|
622
|
+
this.records = records;
|
|
623
|
+
}
|
|
624
|
+
return this.records;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* The usable record for a model, or `undefined` when there is none, it is
|
|
628
|
+
* expired, or it was taken against a different catalog row.
|
|
629
|
+
*/
|
|
630
|
+
get(modelId, fingerprint) {
|
|
631
|
+
const record = this.load()[modelId];
|
|
632
|
+
if (record === void 0) return void 0;
|
|
633
|
+
if (record.fingerprint !== fingerprint) return void 0;
|
|
634
|
+
if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
|
|
635
|
+
return record;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Store one observation. Only a decisive answer (`validating` /
|
|
639
|
+
* `non-validating`) replaces an existing decisive record: a transient
|
|
640
|
+
* `unknown` must not erase knowledge the user already paid for.
|
|
641
|
+
*/
|
|
642
|
+
set(modelId, record) {
|
|
643
|
+
const records = this.load();
|
|
644
|
+
const existing = records[modelId];
|
|
645
|
+
if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
|
|
646
|
+
records[modelId] = record;
|
|
647
|
+
this.persist();
|
|
648
|
+
}
|
|
649
|
+
/** Drop every record; used by the card's explicit "clear" action. */
|
|
650
|
+
clear() {
|
|
651
|
+
this.records = {};
|
|
652
|
+
this.persist();
|
|
653
|
+
}
|
|
654
|
+
/** Every record currently held, for status display. */
|
|
655
|
+
all() {
|
|
656
|
+
return { ...this.load() };
|
|
657
|
+
}
|
|
658
|
+
/** Build a record stamped with this store's clock and version. */
|
|
659
|
+
record(fingerprint, validation, efforts) {
|
|
660
|
+
return {
|
|
661
|
+
fingerprint,
|
|
662
|
+
validation,
|
|
663
|
+
efforts: validation === "validating" ? [...efforts] : [],
|
|
664
|
+
probedAtMs: this.now(),
|
|
665
|
+
pluginVersion: this.pluginVersion
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Write through a temporary file and rename, so a crash mid-write cannot
|
|
670
|
+
* leave a half-parsed document that reads as "no records" and silently drops
|
|
671
|
+
* every observation.
|
|
672
|
+
*/
|
|
673
|
+
persist() {
|
|
674
|
+
const directory = dirname(this.path);
|
|
675
|
+
try {
|
|
676
|
+
if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
|
|
677
|
+
const document = {
|
|
678
|
+
version: PROBE_FORMAT_VERSION,
|
|
679
|
+
records: this.load()
|
|
680
|
+
};
|
|
681
|
+
const temporary = resolve(`${this.path}.tmp`);
|
|
682
|
+
writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
|
|
683
|
+
renameSync(temporary, this.path);
|
|
684
|
+
} catch {}
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
/**
|
|
688
|
+
* Order observations newest-first for display.
|
|
689
|
+
*
|
|
690
|
+
* The store keeps insertion order so the file reads chronologically, but the
|
|
691
|
+
* card wants the most recent detection at the top: a sweep the user just ran
|
|
692
|
+
* should not appear below every earlier one, which is what appending to an
|
|
693
|
+
* insertion-ordered list does.
|
|
694
|
+
*/
|
|
695
|
+
function newestFirst(records) {
|
|
696
|
+
return [...records].sort((a, b) => b.probedAt - a.probedAt);
|
|
697
|
+
}
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/probe-service.ts
|
|
700
|
+
/**
|
|
701
|
+
* Serial probe runner. One instance is shared by the manual API and any
|
|
702
|
+
* future automatic trigger, so the two can never overlap.
|
|
703
|
+
*/
|
|
704
|
+
var WorkBuddyProbeService = class {
|
|
705
|
+
options;
|
|
706
|
+
queue = Promise.resolve();
|
|
707
|
+
pending = /* @__PURE__ */ new Map();
|
|
708
|
+
running = false;
|
|
709
|
+
constructor(options) {
|
|
710
|
+
this.options = options;
|
|
711
|
+
}
|
|
712
|
+
/** Whether a sweep is in flight right now. */
|
|
713
|
+
isRunning() {
|
|
714
|
+
return this.running;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The record the adapter may use for this model, or `undefined`.
|
|
718
|
+
*
|
|
719
|
+
* Applies the plan's precedence (§5): a declared set always wins, so a model
|
|
720
|
+
* that declares `supportedEfforts` is never answered from an observation.
|
|
721
|
+
*/
|
|
722
|
+
recordFor(modelId) {
|
|
723
|
+
const info = this.options.catalog.current().find((model) => model.id === modelId);
|
|
724
|
+
if (info === void 0) return void 0;
|
|
725
|
+
if (info.reasoning?.supportedEfforts !== void 0 && info.reasoning.supportedEfforts.length > 0) return;
|
|
726
|
+
return this.options.store.get(modelId, fingerprintModel(info));
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Probe one model, serially.
|
|
730
|
+
*
|
|
731
|
+
* The authenticated manual route supplies one-request consent after UI
|
|
732
|
+
* confirmation. Other callers must pass the configured consent gate.
|
|
733
|
+
* Manual consent never changes the automatic-probing configuration.
|
|
734
|
+
* Explicit requests bypass historical results, but share an ongoing run.
|
|
735
|
+
*/
|
|
736
|
+
async probe(modelId, manualConsent = false) {
|
|
737
|
+
if (!manualConsent && !this.options.consent()) return {
|
|
738
|
+
state: "unavailable",
|
|
739
|
+
reason: "probing is not authorized"
|
|
740
|
+
};
|
|
741
|
+
if (this.options.catalog.current().find((model) => model.id === modelId) === void 0) return {
|
|
742
|
+
state: "unavailable",
|
|
743
|
+
reason: `unknown model: ${modelId}`
|
|
744
|
+
};
|
|
745
|
+
const pending = this.pending.get(modelId);
|
|
746
|
+
if (pending !== void 0) return pending;
|
|
747
|
+
const run = this.queue.then(async () => {
|
|
748
|
+
const current = this.options.catalog.current().find((model) => model.id === modelId);
|
|
749
|
+
if (current === void 0) return {
|
|
750
|
+
state: "unavailable",
|
|
751
|
+
reason: `unknown model: ${modelId}`
|
|
752
|
+
};
|
|
753
|
+
if (!manualConsent && !this.options.consent()) return {
|
|
754
|
+
state: "unavailable",
|
|
755
|
+
reason: "probing is not authorized"
|
|
756
|
+
};
|
|
757
|
+
if (current.reasoning?.supports !== true || (current.reasoning.supportedEfforts?.length ?? 0) > 0) return {
|
|
758
|
+
state: "unavailable",
|
|
759
|
+
reason: "model does not need detection"
|
|
760
|
+
};
|
|
761
|
+
const cached = this.recordFor(modelId);
|
|
762
|
+
if (!manualConsent && cached !== void 0 && cached.validation !== "unknown") return {
|
|
763
|
+
state: "ok",
|
|
764
|
+
validation: cached.validation,
|
|
765
|
+
efforts: cached.efforts,
|
|
766
|
+
requests: 0
|
|
767
|
+
};
|
|
768
|
+
const credential = await this.options.credentials.current();
|
|
769
|
+
if (credential === void 0) return {
|
|
770
|
+
state: "unavailable",
|
|
771
|
+
reason: "no WorkBuddy credential"
|
|
772
|
+
};
|
|
773
|
+
const send = this.options.send === void 0 ? (effort, signal) => this.options.client.probeEffort(credential, modelId, effort, signal) : this.options.send(modelId);
|
|
774
|
+
this.running = true;
|
|
775
|
+
try {
|
|
776
|
+
const outcome = await probeModel({
|
|
777
|
+
send,
|
|
778
|
+
...this.options.sentinel === void 0 ? {} : { sentinel: this.options.sentinel }
|
|
779
|
+
});
|
|
780
|
+
const record = this.options.store.record(fingerprintModel(current), outcome.validation, outcome.efforts);
|
|
781
|
+
this.options.store.set(modelId, record);
|
|
782
|
+
if (outcome.validation === "unknown") return {
|
|
783
|
+
state: "unavailable",
|
|
784
|
+
reason: outcome.reason
|
|
785
|
+
};
|
|
786
|
+
return {
|
|
787
|
+
state: "ok",
|
|
788
|
+
validation: outcome.validation,
|
|
789
|
+
efforts: record.efforts,
|
|
790
|
+
requests: outcome.requests
|
|
791
|
+
};
|
|
792
|
+
} finally {
|
|
793
|
+
this.running = false;
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
this.queue = run.catch(() => void 0);
|
|
797
|
+
this.pending.set(modelId, run);
|
|
798
|
+
try {
|
|
799
|
+
return await run;
|
|
800
|
+
} finally {
|
|
801
|
+
this.pending.delete(modelId);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
//#endregion
|
|
502
806
|
//#region src/status-paths.ts
|
|
503
807
|
/** Node-free constants and types shared by the Host and browser halves. */
|
|
504
808
|
/** Plugin-owned status endpoint consumed by its browser half. */
|
|
505
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";
|
|
506
820
|
//#endregion
|
|
507
821
|
//#region src/web-status.ts
|
|
508
822
|
/** Redact token-like content before it crosses to the browser. */
|
|
509
823
|
function safeMessage(error) {
|
|
510
824
|
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
825
|
}
|
|
512
|
-
function json(res, status, body) {
|
|
826
|
+
function json$1(res, status, body) {
|
|
513
827
|
const payload = JSON.stringify(body);
|
|
514
828
|
res.writeHead(status, {
|
|
515
829
|
"Content-Type": "application/json",
|
|
@@ -541,52 +855,58 @@ async function workBuddyWebStatus(deps) {
|
|
|
541
855
|
...authStatus.source === void 0 ? {} : { source: authStatus.source },
|
|
542
856
|
...authStatus.expiresAtMs === void 0 ? {} : { expiresAt: authStatus.expiresAtMs }
|
|
543
857
|
};
|
|
544
|
-
const modelsField = deps.models().
|
|
858
|
+
const modelsField = deps.models().map((model) => {
|
|
545
859
|
const rate = normalizeCredits(model.billing?.credits);
|
|
546
860
|
return {
|
|
547
861
|
id: model.id,
|
|
548
862
|
name: model.name,
|
|
549
863
|
...model.billing?.free === true ? { free: true } : {},
|
|
550
864
|
...model.billing?.badges !== void 0 && model.billing.badges.length > 0 ? { badges: model.billing.badges } : {},
|
|
551
|
-
...rate === void 0 ? {} : { credits: rate }
|
|
865
|
+
...rate === void 0 ? {} : { credits: rate },
|
|
866
|
+
...typeof model.contextWindow === "number" && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}
|
|
552
867
|
};
|
|
553
868
|
});
|
|
554
869
|
const statusWithModels = modelsField.length > 0 ? {
|
|
555
870
|
...status,
|
|
556
871
|
models: modelsField
|
|
557
872
|
} : status;
|
|
873
|
+
const probed = deps.probe === void 0 ? statusWithModels : {
|
|
874
|
+
...statusWithModels,
|
|
875
|
+
probe: deps.probe(),
|
|
876
|
+
...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey }
|
|
877
|
+
};
|
|
558
878
|
try {
|
|
559
879
|
const credential = await deps.store.current();
|
|
560
880
|
if (credential !== void 0) {
|
|
561
881
|
const credits = await deps.client.fetchCredits(credential);
|
|
562
882
|
return {
|
|
563
|
-
...
|
|
883
|
+
...probed,
|
|
564
884
|
credits
|
|
565
885
|
};
|
|
566
886
|
}
|
|
567
887
|
} catch (error) {
|
|
568
888
|
return {
|
|
569
|
-
...
|
|
889
|
+
...probed,
|
|
570
890
|
creditsError: safeMessage(error)
|
|
571
891
|
};
|
|
572
892
|
}
|
|
573
|
-
return
|
|
893
|
+
return probed;
|
|
574
894
|
}
|
|
575
895
|
/** The status route's request handler, extracted so tests can mount it on a bare server. */
|
|
576
896
|
function workBuddyStatusHandler(deps) {
|
|
577
897
|
return async (req, res) => {
|
|
578
898
|
if (req.method !== "GET") {
|
|
579
|
-
json(res, 405, { error: "method not allowed" });
|
|
899
|
+
json$1(res, 405, { error: "method not allowed" });
|
|
580
900
|
return;
|
|
581
901
|
}
|
|
582
902
|
if (!loopbackRequest(req)) {
|
|
583
|
-
json(res, 403, { error: "request-not-trusted" });
|
|
903
|
+
json$1(res, 403, { error: "request-not-trusted" });
|
|
584
904
|
return;
|
|
585
905
|
}
|
|
586
906
|
try {
|
|
587
|
-
json(res, 200, await workBuddyWebStatus(deps));
|
|
907
|
+
json$1(res, 200, await workBuddyWebStatus(deps));
|
|
588
908
|
} catch (error) {
|
|
589
|
-
json(res, 500, { error: safeMessage(error) });
|
|
909
|
+
json$1(res, 500, { error: safeMessage(error) });
|
|
590
910
|
}
|
|
591
911
|
};
|
|
592
912
|
}
|
|
@@ -604,6 +924,136 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
604
924
|
}, "dsh-workbuddy-connect: Web status route");
|
|
605
925
|
}
|
|
606
926
|
//#endregion
|
|
927
|
+
//#region src/probe-route.ts
|
|
928
|
+
/**
|
|
929
|
+
* Probe control route: the only state-changing endpoint the plugin exposes.
|
|
930
|
+
*
|
|
931
|
+
* Two guards, because they stop different things (see `docs/reasoning-effort-probe-plan.md`
|
|
932
|
+
* §6.4 and the v0.3.1 note in AGENTS.md about their exact scope):
|
|
933
|
+
*
|
|
934
|
+
* 1. **Loopback Host + Origin**, shared with the status route. This drops
|
|
935
|
+
* DNS-rebinding pages, whose requests arrive addressed to the attacker's
|
|
936
|
+
* domain.
|
|
937
|
+
* 2. **An in-process random key**, minted per process and handed only to the
|
|
938
|
+
* same-origin card. Loopback alone is *not* authentication — any local
|
|
939
|
+
* process can write `Host: 127.0.0.1` — so a route that spends the user's
|
|
940
|
+
* credit must prove the caller was told the key.
|
|
941
|
+
*
|
|
942
|
+
* The route never accepts a prompt, a model id outside the live catalog, or a
|
|
943
|
+
* sentinel from the browser: a probe request is assembled entirely host-side.
|
|
944
|
+
*
|
|
945
|
+
* @module dsh-workbuddy-connect/probe-route
|
|
946
|
+
*/
|
|
947
|
+
/** Largest control body accepted; these payloads are a few dozen bytes. */
|
|
948
|
+
const MAX_BODY_BYTES = 4096;
|
|
949
|
+
/** Mint the per-process control key. */
|
|
950
|
+
function createProbeKey() {
|
|
951
|
+
return randomBytes(24).toString("hex");
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Constant-time key comparison; a length mismatch is a failure, not a crash.
|
|
955
|
+
*/
|
|
956
|
+
function keyMatches(expected, presented) {
|
|
957
|
+
if (presented === void 0 || presented.length !== expected.length) return false;
|
|
958
|
+
const a = Buffer.from(expected);
|
|
959
|
+
const b = Buffer.from(presented);
|
|
960
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
961
|
+
}
|
|
962
|
+
function json(res, status, body) {
|
|
963
|
+
const payload = JSON.stringify(body);
|
|
964
|
+
res.writeHead(status, {
|
|
965
|
+
"Content-Type": "application/json",
|
|
966
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
967
|
+
});
|
|
968
|
+
res.end(payload);
|
|
969
|
+
}
|
|
970
|
+
/** Read the request body with a hard ceiling. */
|
|
971
|
+
async function readBody(req) {
|
|
972
|
+
const chunks = [];
|
|
973
|
+
let total = 0;
|
|
974
|
+
for await (const chunk of req) {
|
|
975
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
976
|
+
total += buffer.length;
|
|
977
|
+
if (total > MAX_BODY_BYTES) return void 0;
|
|
978
|
+
chunks.push(buffer);
|
|
979
|
+
}
|
|
980
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
981
|
+
}
|
|
982
|
+
/** Parse and shape-check an action; unknown fields are ignored, not trusted. */
|
|
983
|
+
function parseAction(text) {
|
|
984
|
+
let parsed;
|
|
985
|
+
try {
|
|
986
|
+
parsed = JSON.parse(text);
|
|
987
|
+
} catch {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
|
|
991
|
+
const wrapped = parsed;
|
|
992
|
+
const action = wrapped["action"];
|
|
993
|
+
if (action === "clear") return { action: "clear" };
|
|
994
|
+
if (action === "probe") {
|
|
995
|
+
const model = wrapped["model"];
|
|
996
|
+
if (typeof model !== "string" || model.trim() === "") return void 0;
|
|
997
|
+
return {
|
|
998
|
+
action: "probe",
|
|
999
|
+
model: model.trim()
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* The control route's handler, extracted so tests can mount it on a bare
|
|
1005
|
+
* server with a known key.
|
|
1006
|
+
*/
|
|
1007
|
+
function workBuddyProbeHandler(deps, key) {
|
|
1008
|
+
return async (req, res) => {
|
|
1009
|
+
if (req.method !== "POST") {
|
|
1010
|
+
json(res, 405, { error: "method not allowed" });
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
|
|
1014
|
+
json(res, 403, { error: "request-not-trusted" });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (!keyMatches(key, req.headers["x-workbuddy-probe-key"])) {
|
|
1018
|
+
json(res, 403, { error: "invalid-probe-key" });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const body = await readBody(req);
|
|
1022
|
+
if (body === void 0) {
|
|
1023
|
+
json(res, 413, { error: "body too large" });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const action = parseAction(body);
|
|
1027
|
+
if (action === void 0) {
|
|
1028
|
+
json(res, 400, { error: "invalid action" });
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
try {
|
|
1032
|
+
if (action.action === "clear") {
|
|
1033
|
+
deps.clear();
|
|
1034
|
+
json(res, 200, { state: "cleared" });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
json(res, 200, await deps.probe(action.model));
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
/** Mount the POST probe-control route on an optional webServer context. */
|
|
1044
|
+
function registerWorkBuddyProbeRoute(ctx, deps, key) {
|
|
1045
|
+
ctx.effect(() => {
|
|
1046
|
+
const dispose = ctx.webServer.register({
|
|
1047
|
+
kind: "exact",
|
|
1048
|
+
path: WORKBUDDY_PROBE_PATH,
|
|
1049
|
+
handler: workBuddyProbeHandler(deps, key)
|
|
1050
|
+
});
|
|
1051
|
+
return () => {
|
|
1052
|
+
dispose();
|
|
1053
|
+
};
|
|
1054
|
+
}, "dsh-workbuddy-connect: probe control route");
|
|
1055
|
+
}
|
|
1056
|
+
//#endregion
|
|
607
1057
|
//#region src/index.ts
|
|
608
1058
|
/** Stable Cordis plugin name. */
|
|
609
1059
|
const name = "llm-workbuddy";
|
|
@@ -622,7 +1072,10 @@ const inject = ["llm"];
|
|
|
622
1072
|
* their namespaces as plain string literals).
|
|
623
1073
|
*/
|
|
624
1074
|
const WORKBUDDY_SETTINGS_NS = "workbuddy";
|
|
625
|
-
const Config = z.object({
|
|
1075
|
+
const Config = z.object({
|
|
1076
|
+
authFile: z.string().description("WorkBuddy desktop auth file (defaults to the app's own location)"),
|
|
1077
|
+
probeConsent: z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)")
|
|
1078
|
+
});
|
|
626
1079
|
/**
|
|
627
1080
|
* Start the loopback endpoint, register the `workbuddy` provider, and
|
|
628
1081
|
* refresh the model catalog from the upstream once credentials allow it.
|
|
@@ -642,12 +1095,73 @@ function apply(ctx, config) {
|
|
|
642
1095
|
catalog,
|
|
643
1096
|
logger: ctx.logger
|
|
644
1097
|
});
|
|
645
|
-
ctx.inject(["webServer"], (webCtx) => registerWorkBuddyStatusRoute(webCtx, {
|
|
646
|
-
store,
|
|
647
|
-
client,
|
|
648
|
-
models: () => catalog.current()
|
|
649
|
-
}));
|
|
650
1098
|
let current = () => config;
|
|
1099
|
+
const probeStore = new WorkBuddyProbeStore({ pluginVersion: WORKBUDDY_CONNECT_VERSION });
|
|
1100
|
+
const probeService = new WorkBuddyProbeService({
|
|
1101
|
+
store: probeStore,
|
|
1102
|
+
catalog,
|
|
1103
|
+
credentials: store,
|
|
1104
|
+
client,
|
|
1105
|
+
consent: () => current().probeConsent === true
|
|
1106
|
+
});
|
|
1107
|
+
/**
|
|
1108
|
+
* Whether a model can be probed by hand: it reasons and the upstream declares
|
|
1109
|
+
* no effort set for it.
|
|
1110
|
+
*
|
|
1111
|
+
* Deliberately *not* filtered by whether a result already exists. Dropping a
|
|
1112
|
+
* model once it has been detected made the list shrink with use, so
|
|
1113
|
+
* re-detecting one model — after an upstream change, say — meant clearing
|
|
1114
|
+
* every other result first. The list stays stable and the card marks which
|
|
1115
|
+
* entries already have an answer.
|
|
1116
|
+
*/
|
|
1117
|
+
const isProbeCandidate = (info) => {
|
|
1118
|
+
if (info.reasoning?.supports !== true) return false;
|
|
1119
|
+
return (info.reasoning.supportedEfforts?.length ?? 0) === 0;
|
|
1120
|
+
};
|
|
1121
|
+
/** Compact probe state for the card: consent, candidates, observations. */
|
|
1122
|
+
const probeSection = () => {
|
|
1123
|
+
const config = current();
|
|
1124
|
+
const models = catalog.current();
|
|
1125
|
+
const results = models.flatMap((info) => {
|
|
1126
|
+
const record = probeService.recordFor(info.id);
|
|
1127
|
+
if (record === void 0) return [];
|
|
1128
|
+
return [{
|
|
1129
|
+
id: info.id,
|
|
1130
|
+
name: info.name,
|
|
1131
|
+
validation: record.validation,
|
|
1132
|
+
efforts: record.efforts,
|
|
1133
|
+
probedAt: record.probedAtMs
|
|
1134
|
+
}];
|
|
1135
|
+
});
|
|
1136
|
+
return {
|
|
1137
|
+
consent: config.probeConsent === true,
|
|
1138
|
+
running: probeService.isRunning(),
|
|
1139
|
+
candidates: models.filter(isProbeCandidate).map((info) => info.id),
|
|
1140
|
+
results: newestFirst(results)
|
|
1141
|
+
};
|
|
1142
|
+
};
|
|
1143
|
+
const probeKey = createProbeKey();
|
|
1144
|
+
let refreshProbeModels = () => {};
|
|
1145
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
1146
|
+
registerWorkBuddyStatusRoute(webCtx, {
|
|
1147
|
+
store,
|
|
1148
|
+
client,
|
|
1149
|
+
models: () => catalog.current(),
|
|
1150
|
+
probe: () => probeSection(),
|
|
1151
|
+
probeKey
|
|
1152
|
+
});
|
|
1153
|
+
registerWorkBuddyProbeRoute(webCtx, {
|
|
1154
|
+
probe: async (modelId) => {
|
|
1155
|
+
const result = await probeService.probe(modelId, true);
|
|
1156
|
+
if (result.state === "ok") refreshProbeModels();
|
|
1157
|
+
return result;
|
|
1158
|
+
},
|
|
1159
|
+
clear: () => {
|
|
1160
|
+
probeStore.clear();
|
|
1161
|
+
refreshProbeModels();
|
|
1162
|
+
}
|
|
1163
|
+
}, probeKey);
|
|
1164
|
+
});
|
|
651
1165
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
652
1166
|
settingsCtx.settings.installSection(ctx, WORKBUDDY_SETTINGS_NS, Config, config, {
|
|
653
1167
|
setSource(source) {
|
|
@@ -673,9 +1187,15 @@ function apply(ctx, config) {
|
|
|
673
1187
|
shim,
|
|
674
1188
|
store,
|
|
675
1189
|
catalog,
|
|
676
|
-
resolveAttachments: () => ctx.get("attachments")
|
|
1190
|
+
resolveAttachments: () => ctx.get("attachments"),
|
|
1191
|
+
observe: (modelId) => probeService.recordFor(modelId)
|
|
677
1192
|
});
|
|
678
1193
|
invalidate = workbuddy.invalidate;
|
|
1194
|
+
refreshProbeModels = () => {
|
|
1195
|
+
if (stopped) return;
|
|
1196
|
+
workbuddy.invalidate();
|
|
1197
|
+
ctx.emit("llm/adapters-updated");
|
|
1198
|
+
};
|
|
679
1199
|
let releaseAdapter;
|
|
680
1200
|
let releaseDirectory;
|
|
681
1201
|
try {
|
|
@@ -724,4 +1244,4 @@ function apply(ctx, config) {
|
|
|
724
1244
|
});
|
|
725
1245
|
}
|
|
726
1246
|
//#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 };
|
|
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 };
|