dsh-connect-workbuddy 3.0.2 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +190 -0
- package/README.en.md +49 -20
- package/README.md +49 -22
- package/THIRD_PARTY_NOTICES.md +4 -2
- package/docs/WINDOWS.md +1 -0
- package/docs/assets/account-pool-demo.html +207 -262
- package/lib/bin.js +23 -3
- package/lib/client.js +377 -755
- package/lib/{host-heartbeat-Dwmfxuyw.js → host-heartbeat-t93cLCoW.js} +238 -4
- package/lib/index.d.ts +140 -32
- package/lib/index.js +1409 -265
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
|
6
6
|
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
7
7
|
import { execFile, execFileSync } from "node:child_process";
|
|
8
8
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
9
10
|
//#region src/at-rest.ts
|
|
10
11
|
/**
|
|
11
12
|
* WorkBuddy desktop "at-rest" credential decryption.
|
|
@@ -535,9 +536,53 @@ function billingHeaders(credential) {
|
|
|
535
536
|
return headers;
|
|
536
537
|
}
|
|
537
538
|
/**
|
|
539
|
+
* Stand-in system message for a request that reached the wire carrying none.
|
|
540
|
+
*
|
|
541
|
+
* Not a stylistic default: both WorkBuddy gateways want the conversation to
|
|
542
|
+
* OPEN with a system message, and the international one enforces it — a
|
|
543
|
+
* user-first body there is refused with business code 11128
|
|
544
|
+
* (`first message is not system prompt`), which the gateway surfaces as
|
|
545
|
+
* "blocked by security policy". The domestic gateway tolerates the same body,
|
|
546
|
+
* so the fault only ever shows up on the international route (issue: a global
|
|
547
|
+
* model failing every step while the CN one is fine).
|
|
548
|
+
*
|
|
549
|
+
* A system message can go missing before this module ever sees the body:
|
|
550
|
+
* `dsh-llm-pi-ai` folds a leading `system` message into `Context.systemPrompt`
|
|
551
|
+
* and pi-ai only emits that prompt `if (context.systemPrompt)` — so an EMPTY
|
|
552
|
+
* prompt emits no system message at all, and pi-ai demotes any `system` entry
|
|
553
|
+
* left in `messages` to `user`. By the time the shim holds the JSON, the real
|
|
554
|
+
* prompt is no longer recoverable, and a minimal placeholder is strictly better
|
|
555
|
+
* than a guaranteed 400.
|
|
556
|
+
*
|
|
557
|
+
* Deliberately tiny: this is a last-resort placeholder, not a persona. Inventing
|
|
558
|
+
* a longer one would quietly change model behaviour on every affected request.
|
|
559
|
+
*/
|
|
560
|
+
const WORKBUDDY_FALLBACK_SYSTEM_PROMPT = "You are a helpful assistant.";
|
|
561
|
+
/**
|
|
562
|
+
* Make the conversation open with a system message, prepending the fallback
|
|
563
|
+
* when nothing else supplies one.
|
|
564
|
+
*
|
|
565
|
+
* `developer` is normalized to `system` first (the gateways reject
|
|
566
|
+
* `developer`), so an ordinary DSH request already satisfies this and the
|
|
567
|
+
* function is a no-op for it.
|
|
568
|
+
*/
|
|
569
|
+
function ensureSystemHead(obj) {
|
|
570
|
+
const messages = obj["messages"];
|
|
571
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
572
|
+
const head = messages[0];
|
|
573
|
+
if (typeof head !== "object" || head === null || Array.isArray(head)) return;
|
|
574
|
+
const role = head["role"];
|
|
575
|
+
if (typeof role === "string" && role.trim().toLowerCase() === "system") return;
|
|
576
|
+
messages.unshift({
|
|
577
|
+
role: "system",
|
|
578
|
+
content: WORKBUDDY_FALLBACK_SYSTEM_PROMPT
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
538
582
|
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
539
|
-
* force `stream: true` (the upstream rejects non-streaming)
|
|
540
|
-
* `tool_choice` (the upstream's field is a string; object forms return 400)
|
|
583
|
+
* force `stream: true` (the upstream rejects non-streaming), flatten
|
|
584
|
+
* `tool_choice` (the upstream's field is a string; object forms return 400),
|
|
585
|
+
* and guarantee a leading system message.
|
|
541
586
|
*/
|
|
542
587
|
function prepareChatBody(source) {
|
|
543
588
|
let body;
|
|
@@ -554,6 +599,7 @@ function prepareChatBody(source) {
|
|
|
554
599
|
const message = value;
|
|
555
600
|
if (message["role"] === "developer") message["role"] = "system";
|
|
556
601
|
}
|
|
602
|
+
ensureSystemHead(obj);
|
|
557
603
|
normalizeToolChoice(obj);
|
|
558
604
|
return JSON.stringify(obj);
|
|
559
605
|
}
|
|
@@ -591,6 +637,67 @@ function normalizeToolChoice(obj) {
|
|
|
591
637
|
delete obj["tool_choice"];
|
|
592
638
|
}
|
|
593
639
|
/**
|
|
640
|
+
* What a PREPARED request body declares, as the input to DSML recovery.
|
|
641
|
+
*
|
|
642
|
+
* This exists so the recovery path can answer the one question its gates turn
|
|
643
|
+
* on — "was this tool name offered in THIS request?" — without a second parse
|
|
644
|
+
* of the body and without a second source of truth. `prepareChatBody` has
|
|
645
|
+
* already parsed it; this reads the same prepared JSON.
|
|
646
|
+
*
|
|
647
|
+
* Two facts make it cheap and exact:
|
|
648
|
+
*
|
|
649
|
+
* - `tool_choice: "none"` deletes `tools` outright in
|
|
650
|
+
* {@link normalizeToolChoice}, so "no tools declared" is directly
|
|
651
|
+
* observable here rather than a separate condition to remember;
|
|
652
|
+
* - a pinned `tool_choice` arrives as the bare function name (the object form
|
|
653
|
+
* was flattened), so anything that is not `auto`/`required`/`none` is a pin.
|
|
654
|
+
*
|
|
655
|
+
* Returns `undefined` when the body declares nothing usable — malformed JSON,
|
|
656
|
+
* no `tools` array, or an empty one. Callers must read that as "recovery is
|
|
657
|
+
* off" (gate 3), not as "no information, so guess".
|
|
658
|
+
*/
|
|
659
|
+
function declaredTools(bodyJson) {
|
|
660
|
+
let body;
|
|
661
|
+
try {
|
|
662
|
+
body = JSON.parse(bodyJson);
|
|
663
|
+
} catch {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
|
|
667
|
+
const obj = body;
|
|
668
|
+
const tools = obj["tools"];
|
|
669
|
+
if (!Array.isArray(tools)) return void 0;
|
|
670
|
+
const names = /* @__PURE__ */ new Set();
|
|
671
|
+
const requiredParameters = /* @__PURE__ */ new Map();
|
|
672
|
+
for (const value of tools) {
|
|
673
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
674
|
+
const fn = value["function"];
|
|
675
|
+
if (typeof fn !== "object" || fn === null || Array.isArray(fn)) continue;
|
|
676
|
+
const definition = fn;
|
|
677
|
+
const name = typeof definition["name"] === "string" ? definition["name"] : "";
|
|
678
|
+
if (name === "") continue;
|
|
679
|
+
names.add(name);
|
|
680
|
+
const parameters = definition["parameters"];
|
|
681
|
+
if (typeof parameters !== "object" || parameters === null || Array.isArray(parameters)) continue;
|
|
682
|
+
const required = parameters["required"];
|
|
683
|
+
if (!Array.isArray(required)) continue;
|
|
684
|
+
const list = required.filter((entry) => typeof entry === "string" && entry !== "");
|
|
685
|
+
if (list.length > 0) requiredParameters.set(name, list);
|
|
686
|
+
}
|
|
687
|
+
if (names.size === 0) return void 0;
|
|
688
|
+
const pinned = typeof obj["tool_choice"] === "string" ? obj["tool_choice"].trim() : "";
|
|
689
|
+
const pinnedToolName = pinned !== "" && ![
|
|
690
|
+
"auto",
|
|
691
|
+
"required",
|
|
692
|
+
"none"
|
|
693
|
+
].includes(pinned.toLowerCase()) ? pinned : void 0;
|
|
694
|
+
return {
|
|
695
|
+
names,
|
|
696
|
+
requiredParameters,
|
|
697
|
+
...pinnedToolName === void 0 ? {} : { pinnedToolName }
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
594
701
|
* Gateway (openresty/APISIX) rejection of a token it no longer accepts.
|
|
595
702
|
*
|
|
596
703
|
* The business APIs answer JSON; an edge rejection answers an HTML error page
|
|
@@ -2284,9 +2391,136 @@ var WorkBuddyCatalog = class {
|
|
|
2284
2391
|
}
|
|
2285
2392
|
};
|
|
2286
2393
|
//#endregion
|
|
2394
|
+
//#region src/pi-ai-runtime.ts
|
|
2395
|
+
/**
|
|
2396
|
+
* Which `@earendil-works/pi-ai` copy THIS plugin actually resolves at runtime.
|
|
2397
|
+
*
|
|
2398
|
+
* WHY THIS EXISTS (issues #24 / #25 / #26). The plugin declares pi-ai as a
|
|
2399
|
+
* peer (`>=0.85.0 <0.88.0`), so the module it imports is whatever the host
|
|
2400
|
+
* environment provides — and the two generations consume DIFFERENT context
|
|
2401
|
+
* shapes:
|
|
2402
|
+
*
|
|
2403
|
+
* - 0.87 hands providers a normalized transcript: `{ messages: [ {role:
|
|
2404
|
+
* 'system', content, toolsAdded}, … ] }`. The system prompt and the tool
|
|
2405
|
+
* declarations live ONLY on that leading system message; the top-level
|
|
2406
|
+
* `systemPrompt` / `tools` fields are gone, and 0.87's api never reads them
|
|
2407
|
+
* back.
|
|
2408
|
+
* - 0.85 expects `{ systemPrompt, tools, messages }` and has no branch for a
|
|
2409
|
+
* `system` message inside `messages` at all: its token estimator crashes on
|
|
2410
|
+
* one (issue #24), and its wire converter silently drops it.
|
|
2411
|
+
*
|
|
2412
|
+
* Which copy resolves is NOT predictable from the host version alone: a
|
|
2413
|
+
* nested 0.85.1 on disk shadows the host's 0.87.1 (the plugin author's own
|
|
2414
|
+
* symlinked checkout does exactly that), while a clean market install has no
|
|
2415
|
+
* local copy at all and resolves the host's (issue #26's reporter). The
|
|
2416
|
+
* adapter therefore gates on what THIS plugin resolves.
|
|
2417
|
+
*
|
|
2418
|
+
* HOW it is detected — deliberately WITHOUT importing pi-ai:
|
|
2419
|
+
* `import.meta.resolve` locates the entry file (executing nothing), the
|
|
2420
|
+
* package manifest sitting beside it names the version, and the generation is
|
|
2421
|
+
* decided from that version (0.87+ → modern). A static import would drag
|
|
2422
|
+
* pi-ai into the standalone `doctor` CLI, which must also run from market
|
|
2423
|
+
* installs where NO local pi-ai exists and the module is only provided inside
|
|
2424
|
+
* the DSH host — a static import there is a load-time crash. Resolution
|
|
2425
|
+
* itself does not need the module: within the peer range, the manifest
|
|
2426
|
+
* version and the `normalizeContext` feature (present from 0.87 on, not
|
|
2427
|
+
* before) agree.
|
|
2428
|
+
*
|
|
2429
|
+
* When resolution FAILS — the normal state of a market install probed outside
|
|
2430
|
+
* the host — there is no local copy, so inside DSH the host supplies pi-ai.
|
|
2431
|
+
* Every DSH host that normalizes contexts (0.2.0+, via 0.87.1) also supplies
|
|
2432
|
+
* 0.87.1, so the reported generation defaults to `modern` (pass-through).
|
|
2433
|
+
* That default is safe on older hosts too: they never normalize, and a native
|
|
2434
|
+
* 0.85-shaped context is an identity under BOTH branches of the adapter gate.
|
|
2435
|
+
*
|
|
2436
|
+
* `doctor` prints the resolved version and path so mixed-generation setups —
|
|
2437
|
+
* the silent kind that produced #24 and #26 — become visible on demand.
|
|
2438
|
+
*
|
|
2439
|
+
* @module dsh-connect-workbuddy/pi-ai-runtime
|
|
2440
|
+
*/
|
|
2441
|
+
let cached;
|
|
2442
|
+
/** Resolve (once) which pi-ai copy this plugin runs against. */
|
|
2443
|
+
function piAiRuntimeInfo() {
|
|
2444
|
+
cached ??= piAiRuntimeFor(resolveModuleFile());
|
|
2445
|
+
return cached;
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2448
|
+
* The decision table for one resolution result, exported so the branch that
|
|
2449
|
+
* only ever runs on OTHER machines (a market install with no local pi-ai) is
|
|
2450
|
+
* pinned by tests rather than by this comment.
|
|
2451
|
+
*
|
|
2452
|
+
* `undefined` means nothing local resolved — inside DSH the host supplies the
|
|
2453
|
+
* module, and every host that normalizes contexts (0.2.0+, via 0.87.1) ships
|
|
2454
|
+
* the modern generation, so that is the default. Older hosts never normalize,
|
|
2455
|
+
* and their native 0.85-shaped context is an identity under either gate
|
|
2456
|
+
* branch, so the default cannot hurt them.
|
|
2457
|
+
*/
|
|
2458
|
+
function piAiRuntimeFor(resolvedFrom) {
|
|
2459
|
+
if (resolvedFrom === void 0) return {
|
|
2460
|
+
generation: "modern",
|
|
2461
|
+
resolvedFrom: void 0,
|
|
2462
|
+
version: void 0,
|
|
2463
|
+
resolvedLocally: false
|
|
2464
|
+
};
|
|
2465
|
+
const version = readVersion(resolvedFrom);
|
|
2466
|
+
return {
|
|
2467
|
+
generation: generationOf(version),
|
|
2468
|
+
resolvedFrom,
|
|
2469
|
+
version,
|
|
2470
|
+
resolvedLocally: true
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* The file the main entry resolved to; undefined when resolution itself fails.
|
|
2475
|
+
*
|
|
2476
|
+
* `import.meta.resolve` (not `require.resolve`): pi-ai's exports map exposes
|
|
2477
|
+
* "." under the `import` condition only, so the CJS resolver refuses the bare
|
|
2478
|
+
* specifier outright — the ESM resolver is the one this plugin actually
|
|
2479
|
+
* imports with, so it is also the one whose answer is meaningful here.
|
|
2480
|
+
*/
|
|
2481
|
+
function resolveModuleFile() {
|
|
2482
|
+
try {
|
|
2483
|
+
return fileURLToPath(import.meta.resolve("@earendil-works/pi-ai"));
|
|
2484
|
+
} catch {
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
/**
|
|
2489
|
+
* The version of the package owning `moduleFile`. pi-ai's exports map does
|
|
2490
|
+
* not expose `./package.json`, so the manifest is found by walking up from
|
|
2491
|
+
* the resolved entry — `dist/index.js` sits directly inside the package —
|
|
2492
|
+
* and claiming it only when its `name` matches, so a same-named ancestor
|
|
2493
|
+
* (a pnpm virtual-store parent, for instance) cannot be misread.
|
|
2494
|
+
*/
|
|
2495
|
+
function readVersion(moduleFile) {
|
|
2496
|
+
if (moduleFile === void 0) return void 0;
|
|
2497
|
+
let directory = dirname(moduleFile);
|
|
2498
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
2499
|
+
try {
|
|
2500
|
+
const parsed = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
|
|
2501
|
+
if (parsed.name === "@earendil-works/pi-ai" && typeof parsed.version === "string") return parsed.version;
|
|
2502
|
+
} catch {}
|
|
2503
|
+
const parent = dirname(directory);
|
|
2504
|
+
if (parent === directory) return void 0;
|
|
2505
|
+
directory = parent;
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* The generation a manifest version belongs to. Within the peer range
|
|
2510
|
+
* (<0.88.0) the split is exactly `0.87+` versus earlier; an unreadable
|
|
2511
|
+
* version conservatively reports `legacy` (and doctor shows it as
|
|
2512
|
+
* unreadable, so the guess is visible rather than silent).
|
|
2513
|
+
*/
|
|
2514
|
+
function generationOf(version) {
|
|
2515
|
+
if (version === void 0) return "legacy";
|
|
2516
|
+
const match = /^0\.(\d+)\./u.exec(version);
|
|
2517
|
+
if (match === null) return "legacy";
|
|
2518
|
+
return Number(match[1]) >= 87 ? "modern" : "legacy";
|
|
2519
|
+
}
|
|
2520
|
+
//#endregion
|
|
2287
2521
|
//#region src/version.ts
|
|
2288
2522
|
/** The npm package version this build was produced from. */
|
|
2289
|
-
const WORKBUDDY_CONNECT_VERSION = "3.0
|
|
2523
|
+
const WORKBUDDY_CONNECT_VERSION = "3.3.0";
|
|
2290
2524
|
//#endregion
|
|
2291
2525
|
//#region src/host-heartbeat.ts
|
|
2292
2526
|
/**
|
|
@@ -2444,4 +2678,4 @@ async function clearHostHeartbeat() {
|
|
|
2444
2678
|
await rm(workbuddyHostHeartbeatPath(), { force: true });
|
|
2445
2679
|
}
|
|
2446
2680
|
//#endregion
|
|
2447
|
-
export {
|
|
2681
|
+
export { readAtRestKey as $, CREDENTIAL_REJECTED_CODE as A, prepareChatBody as B, defaultDesktopAuthPath as C, parseWorkBuddyAuth as D, legacyWorkbuddyOwnAuthPath as E, declaredTools as F, deriveAtRestKeyId as G, WORKBUDDY_APP_EXECUTABLE_ENV as H, isCredentialRejectedError as I, isEncryptedFieldWrapper as J, fetchAtRestKeyPayload as K, parseCreditMultiplier as L, WorkBuddyCredentialRejectedError as M, WorkBuddyUpstreamClient as N, workbuddyAccountId as O, classifyUpstreamError as P, openEncryptedField as Q, parseReasoning as R, defaultDesktopAuthDirs as S, isEncryptedCredentialError as T, clearAtRestKeyCache as U, regionOf as V, deriveAtRestKey as W, macosBundleExecutable as X, isWorkbuddyBundle as Y, macosNestedAppBundles as Z, WORKBUDDY_AUTH_FILE_ENV as _, readHostHeartbeat as a, authFileName as b, WORKBUDDY_CONNECT_VERSION as c, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, workbuddyAppExecutableCandidates as et, WorkBuddyCatalog as f, WORKBUDDY_AUTH_FILENAME as g, ENCRYPTED_CREDENTIAL_CODE as h, processStartTimeMs as i, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as j, workbuddyOwnAuthPath as k, piAiRuntimeInfo as l, fallbackModelsFor as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, deriveCatalog as p, findWorkbuddyAppExecutable as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, WorkBuddyCredentialStore as v, hasEncryptedCredentialFields as w, defaultDesktopAuthCandidates as x, WorkBuddyEncryptedCredentialError as y, parseUpstreamModel as z };
|
package/lib/index.d.ts
CHANGED
|
@@ -569,8 +569,9 @@ declare function classifyUpstreamError(status: number, body: string): UpstreamEr
|
|
|
569
569
|
declare function regionOf(domain: string): WorkBuddyRegion;
|
|
570
570
|
/**
|
|
571
571
|
* Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
|
|
572
|
-
* force `stream: true` (the upstream rejects non-streaming)
|
|
573
|
-
* `tool_choice` (the upstream's field is a string; object forms return 400)
|
|
572
|
+
* force `stream: true` (the upstream rejects non-streaming), flatten
|
|
573
|
+
* `tool_choice` (the upstream's field is a string; object forms return 400),
|
|
574
|
+
* and guarantee a leading system message.
|
|
574
575
|
*/
|
|
575
576
|
declare function prepareChatBody(source: string): string;
|
|
576
577
|
/** Marker carried by {@link WorkBuddyCredentialRejectedError}; survives bundling. */
|
|
@@ -792,12 +793,62 @@ interface WorkBuddyShim {
|
|
|
792
793
|
/** Stop serving and destroy open connections. */
|
|
793
794
|
close(): Promise<void>;
|
|
794
795
|
}
|
|
796
|
+
/**
|
|
797
|
+
* Supplies the credential to retry a failed chat request with.
|
|
798
|
+
*
|
|
799
|
+
* Called with every account id already tried for THIS request, the one that just
|
|
800
|
+
* failed LAST, and resolves to the next candidate — or `undefined` when the pool
|
|
801
|
+
* is off, empty, or exhausted, in which case the original failure is reported
|
|
802
|
+
* unchanged.
|
|
803
|
+
*
|
|
804
|
+
* The plugin owns the policy (which pool members count as usable, in what
|
|
805
|
+
* order); the shim owns only the retry loop and the wire-level rule that a
|
|
806
|
+
* retry may happen before anything has been written to the client.
|
|
807
|
+
*/
|
|
808
|
+
type WorkBuddyFailoverAccount = (triedAccountIds: readonly string[]) => Promise<WorkBuddyCredential | undefined>;
|
|
795
809
|
/** Constructor dependencies. */
|
|
796
810
|
interface WorkBuddyShimOptions {
|
|
797
811
|
store: WorkBuddyCredentialStore;
|
|
798
812
|
client: Pick<WorkBuddyUpstreamClient, 'chatStream'>;
|
|
799
813
|
catalog: WorkBuddyCatalog;
|
|
800
814
|
logger?: ShimLogger;
|
|
815
|
+
/**
|
|
816
|
+
* Pool failover for chat requests. Absent means no failover at all — the
|
|
817
|
+
* pre-pool behaviour, where the selected account serves and its failure is
|
|
818
|
+
* reported as-is.
|
|
819
|
+
*/
|
|
820
|
+
failoverAccount?: WorkBuddyFailoverAccount;
|
|
821
|
+
/**
|
|
822
|
+
* Called before the FIRST attempt of every chat request, so the plugin can
|
|
823
|
+
* point the store at the account the pool's ranking says should serve.
|
|
824
|
+
*
|
|
825
|
+
* It exists because "who serves" and "who to retry with" are the same decision
|
|
826
|
+
* made at two moments: the ranking picks the first account, and after a failure
|
|
827
|
+
* picks the next one. The shim resolves the credential itself right after this
|
|
828
|
+
* returns, so the ranking never has to produce a credential — which keeps
|
|
829
|
+
* token REFRESH in one place (`store.resolve()`) instead of duplicating it on
|
|
830
|
+
* a path that would silently send expired tokens.
|
|
831
|
+
*
|
|
832
|
+
* Absent means the store's own selection always serves.
|
|
833
|
+
*/
|
|
834
|
+
prepareAccount?: () => Promise<void>;
|
|
835
|
+
/**
|
|
836
|
+
* Called for every attempt that failed upstream, before the next one is tried.
|
|
837
|
+
*
|
|
838
|
+
* Exists so the plugin can RECORD the failure as a measurement. The ranking
|
|
839
|
+
* that picks the serving account reads the pool's STORED measurements, so
|
|
840
|
+
* without this an account that just answered 429 still reads as untested — and
|
|
841
|
+
* the next request picks it again and pays the same failed round trip before
|
|
842
|
+
* failing over.
|
|
843
|
+
*
|
|
844
|
+
* Fired without awaiting: a slow write must never delay the retry the user is
|
|
845
|
+
* waiting on, and the measurement only matters for LATER requests.
|
|
846
|
+
*/
|
|
847
|
+
onAccountFailure?: (accountId: string, failure: {
|
|
848
|
+
status: number;
|
|
849
|
+
kind: UpstreamErrorKind;
|
|
850
|
+
message: string;
|
|
851
|
+
}) => void;
|
|
801
852
|
}
|
|
802
853
|
/**
|
|
803
854
|
* Start the loopback endpoint. Requests must carry the shim's shared secret;
|
|
@@ -805,6 +856,56 @@ interface WorkBuddyShimOptions {
|
|
|
805
856
|
*/
|
|
806
857
|
declare function createWorkBuddyShim(options: WorkBuddyShimOptions): WorkBuddyShim;
|
|
807
858
|
//#endregion
|
|
859
|
+
//#region src/pi-ai-runtime.d.ts
|
|
860
|
+
/**
|
|
861
|
+
* Which `@earendil-works/pi-ai` copy THIS plugin actually resolves at runtime.
|
|
862
|
+
*
|
|
863
|
+
* WHY THIS EXISTS (issues #24 / #25 / #26). The plugin declares pi-ai as a
|
|
864
|
+
* peer (`>=0.85.0 <0.88.0`), so the module it imports is whatever the host
|
|
865
|
+
* environment provides — and the two generations consume DIFFERENT context
|
|
866
|
+
* shapes:
|
|
867
|
+
*
|
|
868
|
+
* - 0.87 hands providers a normalized transcript: `{ messages: [ {role:
|
|
869
|
+
* 'system', content, toolsAdded}, … ] }`. The system prompt and the tool
|
|
870
|
+
* declarations live ONLY on that leading system message; the top-level
|
|
871
|
+
* `systemPrompt` / `tools` fields are gone, and 0.87's api never reads them
|
|
872
|
+
* back.
|
|
873
|
+
* - 0.85 expects `{ systemPrompt, tools, messages }` and has no branch for a
|
|
874
|
+
* `system` message inside `messages` at all: its token estimator crashes on
|
|
875
|
+
* one (issue #24), and its wire converter silently drops it.
|
|
876
|
+
*
|
|
877
|
+
* Which copy resolves is NOT predictable from the host version alone: a
|
|
878
|
+
* nested 0.85.1 on disk shadows the host's 0.87.1 (the plugin author's own
|
|
879
|
+
* symlinked checkout does exactly that), while a clean market install has no
|
|
880
|
+
* local copy at all and resolves the host's (issue #26's reporter). The
|
|
881
|
+
* adapter therefore gates on what THIS plugin resolves.
|
|
882
|
+
*
|
|
883
|
+
* HOW it is detected — deliberately WITHOUT importing pi-ai:
|
|
884
|
+
* `import.meta.resolve` locates the entry file (executing nothing), the
|
|
885
|
+
* package manifest sitting beside it names the version, and the generation is
|
|
886
|
+
* decided from that version (0.87+ → modern). A static import would drag
|
|
887
|
+
* pi-ai into the standalone `doctor` CLI, which must also run from market
|
|
888
|
+
* installs where NO local pi-ai exists and the module is only provided inside
|
|
889
|
+
* the DSH host — a static import there is a load-time crash. Resolution
|
|
890
|
+
* itself does not need the module: within the peer range, the manifest
|
|
891
|
+
* version and the `normalizeContext` feature (present from 0.87 on, not
|
|
892
|
+
* before) agree.
|
|
893
|
+
*
|
|
894
|
+
* When resolution FAILS — the normal state of a market install probed outside
|
|
895
|
+
* the host — there is no local copy, so inside DSH the host supplies pi-ai.
|
|
896
|
+
* Every DSH host that normalizes contexts (0.2.0+, via 0.87.1) also supplies
|
|
897
|
+
* 0.87.1, so the reported generation defaults to `modern` (pass-through).
|
|
898
|
+
* That default is safe on older hosts too: they never normalize, and a native
|
|
899
|
+
* 0.85-shaped context is an identity under BOTH branches of the adapter gate.
|
|
900
|
+
*
|
|
901
|
+
* `doctor` prints the resolved version and path so mixed-generation setups —
|
|
902
|
+
* the silent kind that produced #24 and #26 — become visible on demand.
|
|
903
|
+
*
|
|
904
|
+
* @module dsh-connect-workbuddy/pi-ai-runtime
|
|
905
|
+
*/
|
|
906
|
+
/** The resolved pi-ai generation, by context-shape capability. */
|
|
907
|
+
type PiAiGeneration = 'modern' | 'legacy';
|
|
908
|
+
//#endregion
|
|
808
909
|
//#region src/adapter.d.ts
|
|
809
910
|
/** Provider route this bundle owns for the domestic (CN) gateway. */
|
|
810
911
|
declare const WORKBUDDY_PROVIDER = "workbuddy";
|
|
@@ -829,6 +930,12 @@ interface WorkBuddyAdapterOptions {
|
|
|
829
930
|
displayName?: string;
|
|
830
931
|
/** Resolve the durable attachment service at request time, when present. */
|
|
831
932
|
resolveAttachments?: () => AttachmentStore | undefined;
|
|
933
|
+
/**
|
|
934
|
+
* Override the pi-ai generation gate (see {@link piAiRuntimeInfo}); tests
|
|
935
|
+
* inject both branches because this checkout only ever RESOLVES one of
|
|
936
|
+
* them. Production callers leave it unset.
|
|
937
|
+
*/
|
|
938
|
+
piAiGeneration?: PiAiGeneration;
|
|
832
939
|
}
|
|
833
940
|
/** What {@link createWorkBuddyAdapter} hands back. */
|
|
834
941
|
interface WorkBuddyAdapter {
|
|
@@ -1850,8 +1957,13 @@ interface WorkBuddyWebPoolAccount {
|
|
|
1850
1957
|
*/
|
|
1851
1958
|
interface WorkBuddyWebPool {
|
|
1852
1959
|
enabled: boolean;
|
|
1853
|
-
|
|
1854
|
-
|
|
1960
|
+
/**
|
|
1961
|
+
* Whether this region offers the daily check-in at all.
|
|
1962
|
+
*
|
|
1963
|
+
* The card hides the action entirely when false, rather than rendering a
|
|
1964
|
+
* button whose only outcome is a refusal.
|
|
1965
|
+
*/
|
|
1966
|
+
checkinSupported: boolean;
|
|
1855
1967
|
/** The model a test would use right now; absent when none can be resolved. */
|
|
1856
1968
|
targetModelId?: string;
|
|
1857
1969
|
/**
|
|
@@ -1879,15 +1991,6 @@ interface WorkBuddyWebPool {
|
|
|
1879
1991
|
* ran on ZERO accounts and reported success.
|
|
1880
1992
|
*/
|
|
1881
1993
|
effectiveMemberAccountIds?: readonly string[];
|
|
1882
|
-
/**
|
|
1883
|
-
* The account rotation most recently moved billing TO, when it moved.
|
|
1884
|
-
*
|
|
1885
|
-
* Reported so the card can record a switch it did not perform: rotation
|
|
1886
|
-
* happens on the Host (including on a timer), and the card is otherwise
|
|
1887
|
-
* unable to tell a switch from an unchanged state — which would make
|
|
1888
|
-
* "every switch is recorded" a promise the UI could not keep.
|
|
1889
|
-
*/
|
|
1890
|
-
rotatedToAccountId?: string;
|
|
1891
1994
|
accounts: readonly WorkBuddyWebPoolAccount[];
|
|
1892
1995
|
/**
|
|
1893
1996
|
* The models a manual target can be chosen from.
|
|
@@ -2036,8 +2139,6 @@ interface WorkBuddyPoolDeps {
|
|
|
2036
2139
|
/** The region's stored preferences, resolved from the Host config. */
|
|
2037
2140
|
preferences(region: WorkBuddyRegion): {
|
|
2038
2141
|
enabled: boolean;
|
|
2039
|
-
rotateByCredits: boolean;
|
|
2040
|
-
autoTestIntervalMinutes: number;
|
|
2041
2142
|
targetModelId: string;
|
|
2042
2143
|
memberAccountIds: readonly string[];
|
|
2043
2144
|
};
|
|
@@ -2053,14 +2154,6 @@ interface WorkBuddyPoolDeps {
|
|
|
2053
2154
|
* checked) instead of passing the guard and running a batch over nothing.
|
|
2054
2155
|
*/
|
|
2055
2156
|
effectiveMemberAccountIds?(region: WorkBuddyRegion): Promise<readonly string[]>;
|
|
2056
|
-
/**
|
|
2057
|
-
* The account rotation last redirected billing to, when it did.
|
|
2058
|
-
*
|
|
2059
|
-
* Reported so the card can log a switch that happened on the Host (possibly
|
|
2060
|
-
* on a timer) — without it, "every switch leaves a record" is a promise the
|
|
2061
|
-
* browser half has no way to keep.
|
|
2062
|
-
*/
|
|
2063
|
-
rotatedToAccountId?(region: WorkBuddyRegion): Promise<string | undefined>;
|
|
2064
2157
|
/**
|
|
2065
2158
|
* Today's check-in state per account id.
|
|
2066
2159
|
*
|
|
@@ -2068,6 +2161,8 @@ interface WorkBuddyPoolDeps {
|
|
|
2068
2161
|
* entry) means "not read", which the card renders as unknown rather than as a
|
|
2069
2162
|
* definite "not checked in" — a claim that would contradict the check-in the
|
|
2070
2163
|
* user just performed.
|
|
2164
|
+
*
|
|
2165
|
+
* Only the CN region has a check-in to report; see {@link checkinSupported}.
|
|
2071
2166
|
*/
|
|
2072
2167
|
checkedInToday?(region: WorkBuddyRegion): Promise<Readonly<Record<string, boolean>>>;
|
|
2073
2168
|
/**
|
|
@@ -2080,6 +2175,16 @@ interface WorkBuddyPoolDeps {
|
|
|
2080
2175
|
}[]>;
|
|
2081
2176
|
/** The account currently billing traffic for the region, when known. */
|
|
2082
2177
|
currentAccountId?(region: WorkBuddyRegion): Promise<string | undefined>;
|
|
2178
|
+
/**
|
|
2179
|
+
* Whether this region offers the daily check-in action at all.
|
|
2180
|
+
*
|
|
2181
|
+
* The CN app rewards a daily check-in; the international region is not offered
|
|
2182
|
+
* one here, so its card must not show the button and its route must refuse the
|
|
2183
|
+
* action. A capability rather than a region name compared in the browser: the
|
|
2184
|
+
* policy belongs in the Host, and the card should not have to know which
|
|
2185
|
+
* regions happen to have the feature.
|
|
2186
|
+
*/
|
|
2187
|
+
checkinSupported?(region: WorkBuddyRegion): boolean;
|
|
2083
2188
|
/**
|
|
2084
2189
|
* Claim the daily reward for every account of one region.
|
|
2085
2190
|
*
|
|
@@ -2167,18 +2272,21 @@ interface WorkBuddyRegionState {
|
|
|
2167
2272
|
* measurement living in the same slot would be rolled back by the next save.
|
|
2168
2273
|
*/
|
|
2169
2274
|
interface WorkBuddyPoolPreferences {
|
|
2170
|
-
/** Whether the pool is active for this region. Opt-in: default false. */
|
|
2171
|
-
enabled?: boolean;
|
|
2172
2275
|
/**
|
|
2173
|
-
* Whether the
|
|
2276
|
+
* Whether the pool is active for this region — which now means the
|
|
2277
|
+
* FAILOVER switch, not a credit ranking.
|
|
2174
2278
|
*
|
|
2175
|
-
* Opt-in
|
|
2176
|
-
*
|
|
2177
|
-
*
|
|
2279
|
+
* Opt-in: default false. While on, a chat request that fails upstream is
|
|
2280
|
+
* retried against the other usable members of this region's pool, in order,
|
|
2281
|
+
* until one serves it or none is left (see `src/shim.ts`). While off the
|
|
2282
|
+
* plugin behaves exactly as it did before the pool existed: the account the
|
|
2283
|
+
* user selected serves every request, and a failure is reported as-is.
|
|
2284
|
+
*
|
|
2285
|
+
* It deliberately does NOT change which account serves NEXT: failover is
|
|
2286
|
+
* per-request borrowing, so the user's selection stays in effect and the
|
|
2287
|
+
* card can keep reporting who really served.
|
|
2178
2288
|
*/
|
|
2179
|
-
|
|
2180
|
-
/** Minutes between automatic tests; clamped to 5..1440. */
|
|
2181
|
-
autoTestIntervalMinutes?: number;
|
|
2289
|
+
enabled?: boolean;
|
|
2182
2290
|
/** Model id to test; `''` means auto-pick a free model from this region. */
|
|
2183
2291
|
targetModelId?: string;
|
|
2184
2292
|
/**
|