@blackbelt-technology/pi-agent-dashboard 0.4.6 → 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/AGENTS.md +339 -190
- package/README.md +31 -0
- package/docs/architecture.md +238 -23
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/bridge.ts +110 -1
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +5 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +21 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +48 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +8 -7
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +46 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +18 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +57 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { resolveServerCliPath, buildSpawnArgs } from "../server-launcher.js";
|
|
2
|
+
import { resolveServerCliPath, buildSpawnArgs, buildSpawnEnv } from "../server-launcher.js";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
@@ -58,4 +58,27 @@ describe("server-launcher", () => {
|
|
|
58
58
|
expect(args).toEqual(["--port", "3000", "--pi-port", "4000"]);
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
describe("buildSpawnEnv", () => {
|
|
63
|
+
it("always includes DASHBOARD_STARTER=Bridge", () => {
|
|
64
|
+
const env = buildSpawnEnv({});
|
|
65
|
+
expect(env["DASHBOARD_STARTER"]).toBe("Bridge");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("overrides any existing DASHBOARD_STARTER in baseEnv", () => {
|
|
69
|
+
const env = buildSpawnEnv({ DASHBOARD_STARTER: "Standalone" });
|
|
70
|
+
expect(env["DASHBOARD_STARTER"]).toBe("Bridge");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("preserves other env vars from baseEnv", () => {
|
|
74
|
+
const env = buildSpawnEnv({ MY_VAR: "hello" });
|
|
75
|
+
expect(env["MY_VAR"]).toBe("hello");
|
|
76
|
+
expect(env["DASHBOARD_STARTER"]).toBe("Bridge");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("filters out undefined values from baseEnv", () => {
|
|
80
|
+
const env = buildSpawnEnv({ DEFINED: "yes", UNDEF: undefined });
|
|
81
|
+
expect(Object.keys(env)).not.toContain("UNDEF");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
61
84
|
});
|
|
@@ -27,7 +27,7 @@ import { PromptBus } from "./prompt-bus.js";
|
|
|
27
27
|
import { DashboardDefaultAdapter } from "./dashboard-default-adapter.js";
|
|
28
28
|
import { registerAskUserTool } from "./ask-user-tool.js";
|
|
29
29
|
import { decodeMultiselectAnswer } from "./multiselect-decode.js";
|
|
30
|
-
import { activate as activateProviderRegister, onProviderChanged, reloadProviders } from "./provider-register.js";
|
|
30
|
+
import { activate as activateProviderRegister, onProviderChanged, reloadProviders, buildProviderCatalogue } from "./provider-register.js";
|
|
31
31
|
import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
32
32
|
import { startMetricsMonitor, stopMetricsMonitor, collectMetrics } from "./process-metrics.js";
|
|
33
33
|
import { scanChildProcesses } from "./process-scanner.js";
|
|
@@ -37,6 +37,7 @@ import { sendStateSync as _sendStateSync, replaySessionEntries as _replaySession
|
|
|
37
37
|
import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged, sendJjStateIfChanged as _sendJjStateIfChanged, resetReconnectCaches as _resetReconnectCaches } from "./model-tracker.js";
|
|
38
38
|
import { registerFlowEventListeners, FLOW_EVENT_MAP, SUBAGENT_EVENT_MAP } from "./flow-event-wiring.js";
|
|
39
39
|
import { refreshUiModules, subscribeUiInvalidate, handleUiManagement, type UiModulesBridgeCtx } from "./ui-modules.js";
|
|
40
|
+
import { inlineMessageText, type ReadFileOutcome } from "./markdown-image-inliner.js";
|
|
40
41
|
|
|
41
42
|
const HEARTBEAT_INTERVAL = 15_000;
|
|
42
43
|
const GIT_POLL_INTERVAL = 30_000;
|
|
@@ -209,6 +210,96 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
209
210
|
let appendMessageWrapped = false;
|
|
210
211
|
let lastWrappedSm: any = null;
|
|
211
212
|
|
|
213
|
+
// ---------------------------------------------------------------------
|
|
214
|
+
// Markdown-image inliner state (chat-markdown-local-images-and-math).
|
|
215
|
+
// Per-sessionId set of asset hashes for which an `asset_register` has
|
|
216
|
+
// already been emitted on this WebSocket. Survives across message events
|
|
217
|
+
// within the same session; reset when the session id changes (in
|
|
218
|
+
// session_start). The Map keys are sessionId strings.
|
|
219
|
+
// ---------------------------------------------------------------------
|
|
220
|
+
const emittedAssetHashesBySession = new Map<string, Set<string>>();
|
|
221
|
+
function getEmittedAssetHashes(sid: string): Set<string> {
|
|
222
|
+
let s = emittedAssetHashesBySession.get(sid);
|
|
223
|
+
if (!s) {
|
|
224
|
+
s = new Set<string>();
|
|
225
|
+
emittedAssetHashesBySession.set(sid, s);
|
|
226
|
+
}
|
|
227
|
+
return s;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Synchronous fs probe + read for the inliner. Wraps `fs.statSync` /
|
|
232
|
+
* `fs.readFileSync` and maps Node errno strings to the
|
|
233
|
+
* `ReadFileOutcome.kind` enum used by the pure inliner. Order: stat
|
|
234
|
+
* first so directories report EISDIR even when the path has no file
|
|
235
|
+
* extension.
|
|
236
|
+
*/
|
|
237
|
+
function inlinerReadFile(absolutePath: string): ReadFileOutcome {
|
|
238
|
+
try {
|
|
239
|
+
const st = fs.statSync(absolutePath);
|
|
240
|
+
if (st.isDirectory()) return { ok: false, kind: "EISDIR" };
|
|
241
|
+
if (!st.isFile()) return { ok: false, kind: "EOTHER" };
|
|
242
|
+
const bytes = fs.readFileSync(absolutePath);
|
|
243
|
+
return { ok: true, bytes };
|
|
244
|
+
} catch (err: any) {
|
|
245
|
+
const code = err?.code;
|
|
246
|
+
if (code === "ENOENT") return { ok: false, kind: "ENOENT" };
|
|
247
|
+
if (code === "EACCES") return { ok: false, kind: "EACCES" };
|
|
248
|
+
if (code === "EISDIR") return { ok: false, kind: "EISDIR" };
|
|
249
|
+
return { ok: false, kind: "EOTHER" };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Apply the markdown-image inliner to an assistant message_update /
|
|
255
|
+
* message_end event. Mutates `event.message.content` in place (string
|
|
256
|
+
* → rewritten string; array<{type:"text",text}> → rewritten text in
|
|
257
|
+
* each text block). Emits `asset_register` messages BEFORE returning so
|
|
258
|
+
* the caller's subsequent `connection.send(eventForward)` lands AFTER
|
|
259
|
+
* the assets it references. User-role and thinking events are no-ops.
|
|
260
|
+
*/
|
|
261
|
+
function maybeInlineAssistantImages(event: any): void {
|
|
262
|
+
const msg = event?.message;
|
|
263
|
+
if (!msg || typeof msg !== "object") return;
|
|
264
|
+
if (msg.role !== "assistant") return;
|
|
265
|
+
// Use the *current* live cwd if available; fall back to the bridge
|
|
266
|
+
// process cwd. The inliner resolves relative `./pic.png` against this.
|
|
267
|
+
const cwd = (cachedCtx?.cwd as string | undefined) ?? process.cwd();
|
|
268
|
+
const alreadyEmitted = getEmittedAssetHashes(sessionId);
|
|
269
|
+
const allAssets: { hash: string; mimeType: string; data: string }[] = [];
|
|
270
|
+
|
|
271
|
+
const rewriteOne = (text: string): string => {
|
|
272
|
+
const r = inlineMessageText(text, {
|
|
273
|
+
readFile: inlinerReadFile,
|
|
274
|
+
cwd,
|
|
275
|
+
alreadyEmitted,
|
|
276
|
+
});
|
|
277
|
+
for (const a of r.assetsToEmit) allAssets.push(a);
|
|
278
|
+
return r.rewritten;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (typeof msg.content === "string") {
|
|
282
|
+
msg.content = rewriteOne(msg.content);
|
|
283
|
+
} else if (Array.isArray(msg.content)) {
|
|
284
|
+
for (const block of msg.content) {
|
|
285
|
+
if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
|
|
286
|
+
block.text = rewriteOne(block.text);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Send each new asset BEFORE the (rewritten) message event lands.
|
|
292
|
+
for (const a of allAssets) {
|
|
293
|
+
connection.send({
|
|
294
|
+
type: "asset_register",
|
|
295
|
+
sessionId,
|
|
296
|
+
hash: a.hash,
|
|
297
|
+
mimeType: a.mimeType,
|
|
298
|
+
data: a.data,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
212
303
|
/**
|
|
213
304
|
* Wrap ctx.sessionManager.appendMessage once per session so that when pi
|
|
214
305
|
* generates an entry id we capture it in the WeakMap and emit
|
|
@@ -330,6 +421,8 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
330
421
|
id: m.id,
|
|
331
422
|
}));
|
|
332
423
|
connection.send({ type: "models_list", sessionId, models });
|
|
424
|
+
// See change: replace-hardcoded-provider-lists.
|
|
425
|
+
connection.send({ type: "providers_list", sessionId, providers: buildProviderCatalogue() });
|
|
333
426
|
} catch (err) { console.error("[dashboard] models_list push failed:", err); }
|
|
334
427
|
}
|
|
335
428
|
return;
|
|
@@ -765,6 +858,11 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
765
858
|
if (messageRef && typeof messageRef === "object" && !pendingNonces.has(messageRef as object)) {
|
|
766
859
|
pendingNonces.set(messageRef as object, nonce);
|
|
767
860
|
}
|
|
861
|
+
// Apply markdown image inliner to assistant content. Mutates
|
|
862
|
+
// event.message.content in place AND ships any new asset_register
|
|
863
|
+
// messages immediately so they precede the deferred message_end
|
|
864
|
+
// send below. See change: chat-markdown-local-images-and-math.
|
|
865
|
+
maybeInlineAssistantImages(event);
|
|
768
866
|
setTimeout(() => {
|
|
769
867
|
if (!isActive() || !sessionReady) return;
|
|
770
868
|
const entryId =
|
|
@@ -778,6 +876,13 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
778
876
|
return;
|
|
779
877
|
}
|
|
780
878
|
|
|
879
|
+
// Apply markdown image inliner to assistant message_update events.
|
|
880
|
+
// For other event types this is a no-op (role check inside the helper).
|
|
881
|
+
// See change: chat-markdown-local-images-and-math.
|
|
882
|
+
if (eventType === "message_update") {
|
|
883
|
+
maybeInlineAssistantImages(event);
|
|
884
|
+
}
|
|
885
|
+
|
|
781
886
|
const msg = mapEventToProtocol(sessionId, event);
|
|
782
887
|
connection.send(msg);
|
|
783
888
|
}));
|
|
@@ -1155,6 +1260,8 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
1155
1260
|
id: m.id,
|
|
1156
1261
|
}));
|
|
1157
1262
|
connection.send({ type: "models_list", sessionId, models });
|
|
1263
|
+
// See change: replace-hardcoded-provider-lists.
|
|
1264
|
+
connection.send({ type: "providers_list", sessionId, providers: buildProviderCatalogue() });
|
|
1158
1265
|
} catch { /* modelRegistry not available */ }
|
|
1159
1266
|
}
|
|
1160
1267
|
|
|
@@ -1374,6 +1481,8 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
1374
1481
|
id: m.id,
|
|
1375
1482
|
}));
|
|
1376
1483
|
connection.send({ type: "models_list", sessionId, models });
|
|
1484
|
+
// See change: replace-hardcoded-provider-lists.
|
|
1485
|
+
connection.send({ type: "providers_list", sessionId, providers: buildProviderCatalogue() });
|
|
1377
1486
|
} catch { /* ignore */ }
|
|
1378
1487
|
|
|
1379
1488
|
// Retry pending default model — custom provider may now have its models
|
|
@@ -12,6 +12,7 @@ import { killProcessByPgid } from "./process-scanner.js";
|
|
|
12
12
|
import type { FileEntry, PiSessionInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
13
13
|
import { filterHiddenCommands } from "./bridge-context.js";
|
|
14
14
|
import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
|
|
15
|
+
import { buildProviderCatalogue } from "./provider-register.js";
|
|
15
16
|
|
|
16
17
|
const IGNORE_DIRS = new Set([".git", "node_modules", ".next", "dist", "build", ".cache", "__pycache__", ".venv"]);
|
|
17
18
|
const MAX_RESULTS = 20;
|
|
@@ -344,6 +345,11 @@ export function createCommandHandler(
|
|
|
344
345
|
return { type: "models_list", sessionId, models: [] };
|
|
345
346
|
}
|
|
346
347
|
|
|
348
|
+
case "request_providers": {
|
|
349
|
+
// See change: replace-hardcoded-provider-lists.
|
|
350
|
+
return { type: "providers_list", sessionId, providers: buildProviderCatalogue() };
|
|
351
|
+
}
|
|
352
|
+
|
|
347
353
|
case "set_thinking_level":
|
|
348
354
|
if (options?.setThinkingLevel) {
|
|
349
355
|
options.setThinkingLevel(msg.level);
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown image inliner for the bridge.
|
|
3
|
+
*
|
|
4
|
+
* Scans assistant message text for fully-closed `` markdown image
|
|
5
|
+
* tokens, reads any local-path file references, hashes the bytes, and
|
|
6
|
+
* rewrites the token to ``. Bytes ride out of band
|
|
7
|
+
* via `asset_register` events (one per unique hash per session). The text
|
|
8
|
+
* itself only ever carries the short `pi-asset:<hash>` token, keeping
|
|
9
|
+
* streaming `message_update` events bandwidth-bounded.
|
|
10
|
+
*
|
|
11
|
+
* The core helper `inlineMessageText` is **pure** — all I/O is delegated to
|
|
12
|
+
* the injected `readFile` callback so tests can drive every branch with
|
|
13
|
+
* memory fixtures. The bridge wires `readFile = node:fs.readFileSync` plus
|
|
14
|
+
* a per-session `alreadyEmitted: Set<string>` of hashes already shipped.
|
|
15
|
+
*
|
|
16
|
+
* See change: chat-markdown-local-images-and-math.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
/** Per-image hard cap (decision D8). */
|
|
22
|
+
export const MAX_PER_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
/** Per-message cumulative cap on **newly-inlined** asset bytes (decision D8). */
|
|
24
|
+
export const MAX_PER_MESSAGE_BYTES = 20 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
/** MIME allowlist keyed by lowercased extension. Decision D8. */
|
|
27
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
".jpg": "image/jpeg",
|
|
30
|
+
".jpeg": "image/jpeg",
|
|
31
|
+
".gif": "image/gif",
|
|
32
|
+
".webp": "image/webp",
|
|
33
|
+
".svg": "image/svg+xml",
|
|
34
|
+
".avif": "image/avif",
|
|
35
|
+
".bmp": "image/bmp",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Matches a fully-closed `` markdown image token. */
|
|
39
|
+
const IMAGE_TOKEN_RE = /!\[([^\]\n]*)\]\(([^)\n\s]+)\)/g;
|
|
40
|
+
|
|
41
|
+
export interface ParsedImageToken {
|
|
42
|
+
/** The full original `` substring. */
|
|
43
|
+
token: string;
|
|
44
|
+
alt: string;
|
|
45
|
+
src: string;
|
|
46
|
+
/** Start offset within the input text. */
|
|
47
|
+
index: number;
|
|
48
|
+
length: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Find every fully-closed `` token in `text`. Partial tokens
|
|
53
|
+
* (e.g. ``) are NOT returned — the
|
|
54
|
+
* regex requires the closing paren. Tokens spanning newlines are NOT
|
|
55
|
+
* returned (markdown doesn't allow newlines inside the URL portion).
|
|
56
|
+
*/
|
|
57
|
+
export function parseImageTokens(text: string): ParsedImageToken[] {
|
|
58
|
+
const out: ParsedImageToken[] = [];
|
|
59
|
+
let match: RegExpExecArray | null;
|
|
60
|
+
IMAGE_TOKEN_RE.lastIndex = 0;
|
|
61
|
+
while ((match = IMAGE_TOKEN_RE.exec(text)) !== null) {
|
|
62
|
+
out.push({
|
|
63
|
+
token: match[0],
|
|
64
|
+
alt: match[1] ?? "",
|
|
65
|
+
src: match[2] ?? "",
|
|
66
|
+
index: match.index,
|
|
67
|
+
length: match[0].length,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns true iff `src` looks like a local filesystem path (i.e. NOT an
|
|
75
|
+
* already-resolved web URL, data URL, blob URL, fragment, or pre-rewritten
|
|
76
|
+
* `pi-asset:<hash>` token). Idempotency hinges on `pi-asset:` returning
|
|
77
|
+
* `false` here.
|
|
78
|
+
*/
|
|
79
|
+
export function isLocalSrc(src: string): boolean {
|
|
80
|
+
if (!src) return false;
|
|
81
|
+
if (src.startsWith("data:")) return false;
|
|
82
|
+
if (src.startsWith("blob:")) return false;
|
|
83
|
+
if (src.startsWith("http://")) return false;
|
|
84
|
+
if (src.startsWith("https://")) return false;
|
|
85
|
+
if (src.startsWith("pi-asset:")) return false;
|
|
86
|
+
if (src.startsWith("#")) return false;
|
|
87
|
+
// `file://` prefix — treat the rest as a local path.
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve `src` to an absolute path against `cwd`. `file://` prefix is
|
|
93
|
+
* stripped. Absolute paths pass through. Relative paths resolve against
|
|
94
|
+
* `cwd`.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveLocalPath(src: string, cwd: string): string {
|
|
97
|
+
let raw = src;
|
|
98
|
+
if (raw.startsWith("file://")) raw = raw.slice("file://".length);
|
|
99
|
+
if (path.isAbsolute(raw)) return raw;
|
|
100
|
+
return path.resolve(cwd, raw);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Detect MIME from the file extension (case-insensitive). Returns null
|
|
105
|
+
* if the extension is not in the image allowlist.
|
|
106
|
+
*/
|
|
107
|
+
export function mimeFromExtension(filePath: string): string | null {
|
|
108
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
109
|
+
return MIME_BY_EXT[ext] ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Hash file bytes to a 16-hex-char identifier (sha256 truncated). Decision D4.
|
|
114
|
+
*/
|
|
115
|
+
export function hashBytes(buf: Buffer): string {
|
|
116
|
+
return createHash("sha256").update(buf).digest("hex").slice(0, 16);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Format a byte count to a one-decimal MB string for placeholder text. */
|
|
120
|
+
function formatMB(bytes: number): string {
|
|
121
|
+
return (bytes / (1024 * 1024)).toFixed(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Bridge-side per-file read result; thin enough to mock in tests. */
|
|
125
|
+
export interface ReadFileResult {
|
|
126
|
+
ok: true;
|
|
127
|
+
bytes: Buffer;
|
|
128
|
+
}
|
|
129
|
+
export interface ReadFileError {
|
|
130
|
+
ok: false;
|
|
131
|
+
/** Use ENOENT/EACCES/EISDIR/EOTHER. EACCES is folded into ENOENT in placeholder text. */
|
|
132
|
+
kind: "ENOENT" | "EACCES" | "EISDIR" | "EOTHER";
|
|
133
|
+
}
|
|
134
|
+
export type ReadFileOutcome = ReadFileResult | ReadFileError;
|
|
135
|
+
|
|
136
|
+
export interface InlineOptions {
|
|
137
|
+
/** Synchronous file-read callback. Bridge wires this to `node:fs.readFileSync` + `fs.statSync`. */
|
|
138
|
+
readFile: (absolutePath: string) => ReadFileOutcome;
|
|
139
|
+
/** Working directory used to resolve relative srcs. */
|
|
140
|
+
cwd: string;
|
|
141
|
+
/**
|
|
142
|
+
* Per-session set of hashes for which an `asset_register` has already been
|
|
143
|
+
* emitted. The inliner checks-then-adds so dedup is automatic across
|
|
144
|
+
* multiple message events within the same session.
|
|
145
|
+
*/
|
|
146
|
+
alreadyEmitted: Set<string>;
|
|
147
|
+
/** Override the per-image cap. Default `MAX_PER_IMAGE_BYTES`. */
|
|
148
|
+
maxPerImageBytes?: number;
|
|
149
|
+
/** Override the per-message cap. Default `MAX_PER_MESSAGE_BYTES`. */
|
|
150
|
+
maxPerMessageBytes?: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface AssetToEmit {
|
|
154
|
+
hash: string;
|
|
155
|
+
mimeType: string;
|
|
156
|
+
/** Base64-encoded bytes ready for the `asset_register` message. */
|
|
157
|
+
data: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface InlineResult {
|
|
161
|
+
/** The rewritten text with every applicable token replaced. */
|
|
162
|
+
rewritten: string;
|
|
163
|
+
/** Newly-discovered assets to emit BEFORE the message_update / message_end. */
|
|
164
|
+
assetsToEmit: AssetToEmit[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Pure inliner. Scans `text` for image tokens; rewrites local-path tokens
|
|
169
|
+
* either to `` (success) or to a visible placeholder
|
|
170
|
+
* text (file too large / unsupported MIME / read error / message budget
|
|
171
|
+
* exhausted). Tokens with web/data/blob/pi-asset/# srcs pass through
|
|
172
|
+
* unchanged. Idempotent — re-running on already-rewritten text yields the
|
|
173
|
+
* same output (because `pi-asset:` returns `false` from `isLocalSrc`).
|
|
174
|
+
*/
|
|
175
|
+
export function inlineMessageText(text: string, opts: InlineOptions): InlineResult {
|
|
176
|
+
const tokens = parseImageTokens(text);
|
|
177
|
+
if (tokens.length === 0) {
|
|
178
|
+
return { rewritten: text, assetsToEmit: [] };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const maxPerImage = opts.maxPerImageBytes ?? MAX_PER_IMAGE_BYTES;
|
|
182
|
+
const maxPerMessage = opts.maxPerMessageBytes ?? MAX_PER_MESSAGE_BYTES;
|
|
183
|
+
|
|
184
|
+
const assetsToEmit: AssetToEmit[] = [];
|
|
185
|
+
let bytesInThisMessage = 0;
|
|
186
|
+
|
|
187
|
+
// Build the rewritten string by stitching segments separated by token
|
|
188
|
+
// replacements. Walk tokens left-to-right; tokens that pass through
|
|
189
|
+
// unchanged keep their original substring.
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
let cursor = 0;
|
|
192
|
+
|
|
193
|
+
for (const tok of tokens) {
|
|
194
|
+
// Append the segment before this token verbatim.
|
|
195
|
+
out.push(text.slice(cursor, tok.index));
|
|
196
|
+
cursor = tok.index + tok.length;
|
|
197
|
+
|
|
198
|
+
if (!isLocalSrc(tok.src)) {
|
|
199
|
+
// External / data: / pi-asset: / fragment — pass through unchanged.
|
|
200
|
+
out.push(tok.token);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const absPath = resolveLocalPath(tok.src, opts.cwd);
|
|
205
|
+
|
|
206
|
+
// Order matters here:
|
|
207
|
+
// 1. readFile FIRST so EISDIR / ENOENT / EACCES are reported with
|
|
208
|
+
// their proper placeholders even when the path has no extension
|
|
209
|
+
// (e.g. `/home/me` resolving to a directory).
|
|
210
|
+
// 2. mimeFromExtension after a successful read so an existing file
|
|
211
|
+
// with a non-image extension reports "unsupported image type"
|
|
212
|
+
// rather than a generic read failure.
|
|
213
|
+
// 3. hashBytes so we can consult `alreadyEmitted` BEFORE the
|
|
214
|
+
// per-image and per-message caps. Already-registered assets bypass
|
|
215
|
+
// caps because their bytes were paid for on the previous emission.
|
|
216
|
+
// 4. Per-image cap and per-message budget gate ONLY new emissions.
|
|
217
|
+
const outcome = opts.readFile(absPath);
|
|
218
|
+
if (!outcome.ok) {
|
|
219
|
+
// EACCES is folded into ENOENT placeholder to avoid leaking permission
|
|
220
|
+
// existence. EISDIR / EOTHER use the generic "read failed" wording.
|
|
221
|
+
if (outcome.kind === "ENOENT" || outcome.kind === "EACCES") {
|
|
222
|
+
out.push(`[image not found: ${tok.src}]`);
|
|
223
|
+
} else {
|
|
224
|
+
out.push(`[image read failed: ${tok.src}]`);
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const mime = mimeFromExtension(absPath);
|
|
230
|
+
if (!mime) {
|
|
231
|
+
out.push(`[unsupported image type: ${tok.src}]`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const hash = hashBytes(outcome.bytes);
|
|
236
|
+
|
|
237
|
+
if (opts.alreadyEmitted.has(hash)) {
|
|
238
|
+
// Bytes already shipped earlier in the session — only the token rewrites.
|
|
239
|
+
// Caps are bypassed: dedup means no new bytes go on the wire.
|
|
240
|
+
out.push(``);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const size = outcome.bytes.length;
|
|
245
|
+
if (size > maxPerImage) {
|
|
246
|
+
out.push(`[image too large: ${tok.src} (${formatMB(size)} MB)]`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// New asset. Check the per-message budget before committing.
|
|
251
|
+
if (bytesInThisMessage + size > maxPerMessage) {
|
|
252
|
+
out.push(`[message asset budget exhausted: ${tok.src}]`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
bytesInThisMessage += size;
|
|
257
|
+
opts.alreadyEmitted.add(hash);
|
|
258
|
+
assetsToEmit.push({
|
|
259
|
+
hash,
|
|
260
|
+
mimeType: mime,
|
|
261
|
+
data: outcome.bytes.toString("base64"),
|
|
262
|
+
});
|
|
263
|
+
out.push(``);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
out.push(text.slice(cursor));
|
|
267
|
+
return { rewritten: out.join(""), assetsToEmit };
|
|
268
|
+
}
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* by reading template/skill files directly and expanding them.
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync, existsSync } from "node:fs";
|
|
9
|
-
import { join, resolve } from "node:path";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
10
|
import { readdirSync, statSync } from "node:fs";
|
|
11
|
+
import { buildSkillBlock } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
|
|
11
12
|
|
|
12
13
|
/** Scan directories for .md prompt template files */
|
|
13
14
|
function findPromptTemplates(cwd: string): Map<string, string> {
|
|
@@ -97,7 +98,26 @@ export function expandPromptTemplateFromDisk(text: string, cwd: string, pi?: any
|
|
|
97
98
|
|
|
98
99
|
try {
|
|
99
100
|
const content = readTemplate(filePath);
|
|
100
|
-
|
|
101
|
+
|
|
102
|
+
// Skill detection: either the local-scan key starts with `skill:` or the
|
|
103
|
+
// pi.getCommands() fallback resolved a command whose `source === "skill"`
|
|
104
|
+
// (we re-check below). Skill expansions wrap in pi's `<skill>` envelope so
|
|
105
|
+
// the dashboard ingress path is byte-identical to pi's own _expandSkillCommand,
|
|
106
|
+
// which lets the client and server recover the slash-command form.
|
|
107
|
+
// See change: render-skill-invocations-collapsibly.
|
|
108
|
+
const isSkill = isSkillResolution(templateName, filePath, pi);
|
|
109
|
+
if (isSkill) {
|
|
110
|
+
const bareName = templateName.replace(/^skill:/, "");
|
|
111
|
+
return buildSkillBlock({
|
|
112
|
+
name: bareName,
|
|
113
|
+
filePath,
|
|
114
|
+
baseDir: dirname(filePath),
|
|
115
|
+
body: content,
|
|
116
|
+
userArgs: argsString || undefined,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Plain prompt templates: append args after a blank line, no wrapper.
|
|
101
121
|
if (argsString) {
|
|
102
122
|
return `${content}\n\n${argsString}`;
|
|
103
123
|
}
|
|
@@ -106,3 +126,31 @@ export function expandPromptTemplateFromDisk(text: string, cwd: string, pi?: any
|
|
|
106
126
|
return text;
|
|
107
127
|
}
|
|
108
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Detect whether the resolved `filePath` came from a skill source.
|
|
132
|
+
*
|
|
133
|
+
* The local-scan key tells us directly when it starts with `skill:`. For the
|
|
134
|
+
* pi.getCommands() fallback we re-query and check `source === "skill"` against
|
|
135
|
+
* the same templateName.
|
|
136
|
+
*/
|
|
137
|
+
function isSkillResolution(
|
|
138
|
+
templateName: string,
|
|
139
|
+
filePath: string,
|
|
140
|
+
pi: any | undefined,
|
|
141
|
+
): boolean {
|
|
142
|
+
if (templateName.startsWith("skill:")) return true;
|
|
143
|
+
// The colon-alias path (e.g. /opsx:continue) maps to a hyphen filename and is
|
|
144
|
+
// a prompt template, not a skill. Skills always use the `skill:` prefix in
|
|
145
|
+
// both the local scan and pi.getCommands().
|
|
146
|
+
if (!pi?.getCommands) return false;
|
|
147
|
+
try {
|
|
148
|
+
const commands = pi.getCommands();
|
|
149
|
+
const match = commands.find(
|
|
150
|
+
(c: any) => c.name === templateName && c.source === "skill" && c.path === filePath,
|
|
151
|
+
);
|
|
152
|
+
return !!match;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -15,6 +15,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
|
15
15
|
import { existsSync, readFileSync } from "node:fs";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
+
import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
18
19
|
|
|
19
20
|
// -- Types ----------------------------------------------------------------
|
|
20
21
|
|
|
@@ -288,6 +289,122 @@ export function getSessionInfo(): { provider: string; modelId: string } {
|
|
|
288
289
|
return { provider: currentSessionProvider, modelId: currentSessionModelId };
|
|
289
290
|
}
|
|
290
291
|
|
|
292
|
+
// -- Provider catalogue (for dashboard /api/provider-auth/status) -------
|
|
293
|
+
//
|
|
294
|
+
// Pure derivation: given a captured `ModelRegistry` and the pi-ai
|
|
295
|
+
// helpers (`findEnvKeys`, `getEnvApiKey`), build a flat ProviderInfo[]
|
|
296
|
+
// covering every OAuth provider plus every distinct provider id from
|
|
297
|
+
// `getAll()`. The bridge pushes this to the server alongside
|
|
298
|
+
// `models_list`. See change: replace-hardcoded-provider-lists.
|
|
299
|
+
|
|
300
|
+
type PiAiHelpers = {
|
|
301
|
+
findEnvKeys?: (id: string) => string[] | undefined;
|
|
302
|
+
getEnvApiKey?: (id: string) => string | undefined;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
export function _buildProviderCatalogue(
|
|
306
|
+
modelRegistry: any,
|
|
307
|
+
piAi: PiAiHelpers,
|
|
308
|
+
customIds: ReadonlySet<string> = new Set(),
|
|
309
|
+
): ProviderInfo[] {
|
|
310
|
+
if (!modelRegistry) return [];
|
|
311
|
+
const oauthIds = new Set<string>(
|
|
312
|
+
(modelRegistry.authStorage?.getOAuthProviders?.() ?? []).map((p: any) => p.id),
|
|
313
|
+
);
|
|
314
|
+
// The catalogue is the complete picture of what pi knows about —
|
|
315
|
+
// built-in providers, OAuth providers, AND custom providers registered
|
|
316
|
+
// by the dashboard via pi.registerProvider() from ~/.pi/agent/providers.json.
|
|
317
|
+
// Custom providers carry `custom: true` so consumers can decide what
|
|
318
|
+
// to surface where (e.g. the auth UI suppresses their API-key rows
|
|
319
|
+
// because they're managed by the LLM Providers settings section).
|
|
320
|
+
// Filtering decisions belong to consumers, not to this function.
|
|
321
|
+
// See change: replace-hardcoded-provider-lists.
|
|
322
|
+
const allIds = new Set<string>(oauthIds);
|
|
323
|
+
for (const m of (modelRegistry.getAll?.() ?? []) as Array<{ provider?: string }>) {
|
|
324
|
+
if (m.provider) allIds.add(m.provider);
|
|
325
|
+
}
|
|
326
|
+
return [...allIds].map((id) => {
|
|
327
|
+
let displayName = id;
|
|
328
|
+
try {
|
|
329
|
+
displayName = modelRegistry.getProviderDisplayName?.(id) ?? id;
|
|
330
|
+
} catch { /* fallback to id */ }
|
|
331
|
+
let configured = false;
|
|
332
|
+
let source: ProviderInfo["source"];
|
|
333
|
+
try {
|
|
334
|
+
const status = modelRegistry.authStorage?.getAuthStatus?.(id);
|
|
335
|
+
if (status) {
|
|
336
|
+
configured = !!status.configured;
|
|
337
|
+
source = status.source;
|
|
338
|
+
}
|
|
339
|
+
} catch { /* ignore */ }
|
|
340
|
+
let expires: number | undefined;
|
|
341
|
+
try {
|
|
342
|
+
const cred = modelRegistry.authStorage?.get?.(id);
|
|
343
|
+
if (cred?.type === "oauth" && typeof cred.expires === "number") {
|
|
344
|
+
expires = cred.expires;
|
|
345
|
+
}
|
|
346
|
+
} catch { /* ignore */ }
|
|
347
|
+
let envVar: string | undefined;
|
|
348
|
+
let ambient: boolean | undefined;
|
|
349
|
+
try {
|
|
350
|
+
const keys = piAi.findEnvKeys?.(id);
|
|
351
|
+
if (keys && keys.length > 0) envVar = keys[0];
|
|
352
|
+
if (piAi.getEnvApiKey?.(id) === "<authenticated>") ambient = true;
|
|
353
|
+
} catch { /* ignore */ }
|
|
354
|
+
return {
|
|
355
|
+
id,
|
|
356
|
+
displayName,
|
|
357
|
+
hasOAuth: oauthIds.has(id),
|
|
358
|
+
configured,
|
|
359
|
+
source,
|
|
360
|
+
envVar,
|
|
361
|
+
ambient,
|
|
362
|
+
expires,
|
|
363
|
+
custom: customIds.has(id) || undefined,
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Lazy-cached pi-ai module (in scope inside pi's process).
|
|
369
|
+
let _piAiModule: PiAiHelpers | null = null;
|
|
370
|
+
let _piAiLoadAttempted = false;
|
|
371
|
+
async function loadPiAi(): Promise<PiAiHelpers> {
|
|
372
|
+
if (_piAiModule) return _piAiModule;
|
|
373
|
+
if (_piAiLoadAttempted) return {};
|
|
374
|
+
_piAiLoadAttempted = true;
|
|
375
|
+
try {
|
|
376
|
+
const mod: any = await import("@mariozechner/pi-ai");
|
|
377
|
+
_piAiModule = { findEnvKeys: mod.findEnvKeys, getEnvApiKey: mod.getEnvApiKey };
|
|
378
|
+
return _piAiModule;
|
|
379
|
+
} catch {
|
|
380
|
+
return {};
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Eagerly kick off pi-ai load at module import time so env-var hints
|
|
385
|
+
// are populated by the time the first session_register fires. Failure
|
|
386
|
+
// is silent; `buildProviderCatalogue` falls back to {} which still
|
|
387
|
+
// produces a valid catalogue minus envVar/ambient hints.
|
|
388
|
+
void loadPiAi();
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Public wrapper: returns the current provider catalogue, or [] when
|
|
392
|
+
* the model registry has not been captured yet. Marks providers the
|
|
393
|
+
* bridge itself registered (from `~/.pi/agent/providers.json` via
|
|
394
|
+
* `pi.registerProvider()`) with `custom: true` so consumers can
|
|
395
|
+
* suppress their API-key auth rows (those are managed by the LLM
|
|
396
|
+
* Providers settings section). The catalogue itself is complete —
|
|
397
|
+
* including custom providers — so other consumers (e.g. diagnostics)
|
|
398
|
+
* see the full picture.
|
|
399
|
+
*/
|
|
400
|
+
export function buildProviderCatalogue(): ProviderInfo[] {
|
|
401
|
+
const mr = getModelRegistry();
|
|
402
|
+
if (!mr) return [];
|
|
403
|
+
const piAi = _piAiModule ?? {};
|
|
404
|
+
const customIds = new Set<string>(lastRegistered.keys());
|
|
405
|
+
return _buildProviderCatalogue(mr, piAi, customIds);
|
|
406
|
+
}
|
|
407
|
+
|
|
291
408
|
export function getModelDisplayName(modelId: string): string {
|
|
292
409
|
if (piRef) {
|
|
293
410
|
const data: any = {};
|