pi-pignon 0.1.1

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.
@@ -0,0 +1,439 @@
1
+ /**
2
+ * pignon — Pi agent extension.
3
+ *
4
+ * Shifts to the right model for each prompt by asking a decider (today: the
5
+ * local Laya System-1 model, via a stdio worker it spawns and supervises) how
6
+ * hard the prompt is, then looking the answer up in the routing table.
7
+ *
8
+ * /pignon -> show current mode
9
+ * /pignon live -> apply decisions
10
+ * /pignon shadow -> observe only (default)
11
+ * /pignon off -> stop calling the decider
12
+ * /pignon unpin -> re-enable routing after manual model selection
13
+ * /pignon log -> recent decider diagnostics
14
+ * /pignon config -> routing table and settings in use
15
+ * /pignon config migrate -> convert a laya-router config file
16
+ * /pignon init [preset] -> write a starter config file
17
+ * /pignon doctor -> check deciders, models and config
18
+ * /pignon-stats -> session statistics
19
+ * /pignon-stats compare -> how two deciders agree (parallel strategy)
20
+ * /pignon-stats export [path] -> decisions as JSON lines
21
+ *
22
+ * Reports open in a dismissible overlay (see `report.ts`). The `clear`
23
+ * subcommands are still accepted and remove the widgets older versions left.
24
+ *
25
+ * `/laya` and `/laya-stats` remain as aliases for one release.
26
+ *
27
+ * This module only wires Pi to the router; the routing itself is in
28
+ * `router.ts`, the policy in `policy.ts`, and the deciders in `deciders/`.
29
+ */
30
+
31
+ import type {
32
+ ExtensionAPI,
33
+ ExtensionContext,
34
+ SessionEntry,
35
+ } from "@earendil-works/pi-coding-agent";
36
+
37
+ import { buildCompareLines, defaultExportPath, exportDecisions } from "./compare.js";
38
+ import { describeConfig } from "./config/describe.js";
39
+ import { configPaths, loadConfig } from "./config/load.js";
40
+ import { migrateConfigFile } from "./config/migrate.js";
41
+ import { PRESETS, PRESET_NAMES, isPresetName } from "./config/presets.js";
42
+ import { createDecider as createConfiguredDecider } from "./deciders/create.js";
43
+ import type { Decider } from "./deciders/types.js";
44
+ import { type RouterHost, routePrompt } from "./router.js";
45
+ import { showReport } from "./report.js";
46
+ import { buildStatsLines } from "./stats.js";
47
+ import type { DeciderSpec, RouterConfig, RouterLogEntry, RouterMode } from "./types.js";
48
+ import { hideDeciding, renderDecisionCard, showDeciding } from "./ui.js";
49
+ import {
50
+ type ModelLookup,
51
+ choosePreset,
52
+ detectDeciders,
53
+ modelStatus,
54
+ runDoctor,
55
+ starterConfig,
56
+ writeStarterConfig,
57
+ } from "./onboarding.js";
58
+
59
+ /** Decider log lines shown by `/pignon log` (the report scrolls). */
60
+ const LOG_REPORT_LINES = 200;
61
+
62
+ /** Custom entry type of decision cards. */
63
+ const ENTRY_TYPE = "pignon-decision";
64
+ /** Entry type written by laya-router; still rendered and counted. */
65
+ const LEGACY_ENTRY_TYPE = "laya-decision";
66
+
67
+ const SUBCOMMANDS = [
68
+ "shadow",
69
+ "live",
70
+ "off",
71
+ "unpin",
72
+ "log",
73
+ "config",
74
+ "config migrate",
75
+ "init",
76
+ ...PRESET_NAMES.map((name) => `init ${name}`),
77
+ "doctor",
78
+ ];
79
+
80
+ type PiModel = Parameters<ExtensionAPI["setModel"]>[0];
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // UI helpers (no-ops without a UI, e.g. print or RPC mode)
84
+ // ---------------------------------------------------------------------------
85
+
86
+ function renderStatus(ctx: ExtensionContext, text: string): void {
87
+ if (ctx.hasUI) ctx.ui.setStatus("pignon", text);
88
+ }
89
+
90
+ function notify(ctx: ExtensionContext, text: string, level: "info" | "warning" | "error" = "info"): void {
91
+ if (ctx.hasUI) ctx.ui.notify(text, level);
92
+ }
93
+
94
+ /** Remove a report widget left by an older pignon (reports now use an overlay). */
95
+ function clearWidget(ctx: ExtensionContext, name: string): void {
96
+ if (ctx.hasUI) ctx.ui.setWidget(name, undefined);
97
+ }
98
+
99
+ /** Show report lines whose first line is their title. */
100
+ function showTitledReport(ctx: ExtensionContext, [title = "pignon", ...body]: string[]): Promise<void> {
101
+ return showReport(ctx, title, body);
102
+ }
103
+
104
+ /** Adapt Pi's API and a context to what the router needs. */
105
+ function piHost(
106
+ pi: ExtensionAPI,
107
+ ctx: ExtensionContext,
108
+ switchModel: (model: PiModel) => Promise<boolean>,
109
+ ): RouterHost<PiModel> {
110
+ return {
111
+ get model() {
112
+ return ctx.model;
113
+ },
114
+ get contextTokens() {
115
+ return ctx.getContextUsage()?.tokens ?? 0;
116
+ },
117
+ get signal() {
118
+ return ctx.signal;
119
+ },
120
+ findModel: (provider, modelId) => ctx.modelRegistry.find(provider, modelId),
121
+ switchModel,
122
+ setThinkingLevel: (level) => pi.setThinkingLevel(level),
123
+ status: (text) => renderStatus(ctx, text),
124
+ notify: (text, level) => notify(ctx, text, level),
125
+ showDeciding: (deciderModel) => showDeciding(ctx, deciderModel),
126
+ hideDeciding: () => hideDeciding(ctx),
127
+ };
128
+ }
129
+
130
+ function isDecisionEntry(entry: SessionEntry): boolean {
131
+ if (entry.type !== "custom") return false;
132
+ const type = (entry as { customType?: string }).customType;
133
+ return type === ENTRY_TYPE || type === LEGACY_ENTRY_TYPE;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Entry point
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export interface ExtensionOptions {
141
+ /** Builds the decider from the loaded config. Defaults to the one the config describes. */
142
+ createDecider?: (config: RouterConfig) => Decider;
143
+ /** Finds the deciders `/pignon init` writes. Defaults to probing this machine. */
144
+ detectDeciders?: () => Promise<DeciderSpec[]>;
145
+ }
146
+
147
+ /** Build the extension; tests inject their own decider. */
148
+ export function createExtension(options: ExtensionOptions = {}): (pi: ExtensionAPI) => void {
149
+
150
+ return (pi) => {
151
+ let mode: RouterMode = "shadow";
152
+ let manualPin = false;
153
+ let promptsSinceSwitch: number | undefined;
154
+
155
+ // A small synchronous file read; the decider only starts work later.
156
+ const loaded = loadConfig();
157
+ const { config } = loaded;
158
+
159
+ const { decider, notes: deciderNotes } = options.createDecider
160
+ ? { decider: options.createDecider(config), notes: [] }
161
+ : createConfiguredDecider(config);
162
+
163
+ // pi.setModel() emits model_select with source "set", exactly like a manual
164
+ // /model choice. Flag our own switches so they do not pin the router.
165
+ let routerSwitching = false;
166
+ const switchModel = async (model: PiModel) => {
167
+ routerSwitching = true;
168
+ try {
169
+ return await pi.setModel(model);
170
+ } finally {
171
+ routerSwitching = false;
172
+ }
173
+ };
174
+
175
+ // --- Decision cards ---------------------------------------------------
176
+
177
+ pi.registerEntryRenderer<RouterLogEntry>(ENTRY_TYPE, renderDecisionCard);
178
+ pi.registerEntryRenderer<RouterLogEntry>(LEGACY_ENTRY_TYPE, renderDecisionCard);
179
+
180
+ // Routing runs in before_agent_start, before Pi posts the user message.
181
+ // The entry is held until that message is in so its card renders below
182
+ // the prompt it describes, not above it.
183
+ let pendingEntry: RouterLogEntry | undefined;
184
+ const flushPendingEntry = () => {
185
+ if (!pendingEntry) return;
186
+ pi.appendEntry(ENTRY_TYPE, pendingEntry);
187
+ pendingEntry = undefined;
188
+ };
189
+
190
+ // --- Session lifecycle ------------------------------------------------
191
+
192
+ // Warming up can take a while (the first run downloads the model), so it
193
+ // runs in the background: neither session start nor a prompt waits for it.
194
+ let sessionActive = false;
195
+ let warming: Promise<void> | undefined;
196
+ const warmUp = (ctx: ExtensionContext) => {
197
+ if (warming) return;
198
+ warming = decider
199
+ .warmup()
200
+ .then(
201
+ () => {
202
+ if (sessionActive) renderStatus(ctx, `pignon ${mode} · ${decider.model ?? "unknown model"}`);
203
+ },
204
+ (err) => {
205
+ const msg = err instanceof Error ? err.message : String(err);
206
+ if (sessionActive) renderStatus(ctx, `pignon ${mode} · ⚠ ${msg.slice(0, 60)} (/pignon log)`);
207
+ },
208
+ )
209
+ .finally(() => {
210
+ warming = undefined;
211
+ });
212
+ };
213
+
214
+ pi.on("session_start", async (_event, ctx) => {
215
+ manualPin = false;
216
+ promptsSinceSwitch = undefined;
217
+ sessionActive = true;
218
+ if (loaded.errors.length > 0) {
219
+ notify(ctx, `pignon: config problems, using defaults for:\n${loaded.errors.join("\n")}`, "warning");
220
+ }
221
+ const warnings = [...loaded.warnings, ...deciderNotes];
222
+ if (warnings.length > 0) notify(ctx, `pignon: ${warnings.join("\n")}`, "warning");
223
+ renderStatus(ctx, `pignon ${mode} · loading model`);
224
+ warmUp(ctx);
225
+ });
226
+
227
+ pi.on("session_shutdown", () => {
228
+ flushPendingEntry();
229
+ sessionActive = false;
230
+ decider.stop();
231
+ });
232
+
233
+ // --- Routing hook -----------------------------------------------------
234
+
235
+ pi.on("before_agent_start", async (event, ctx) => {
236
+ flushPendingEntry();
237
+ if (mode === "off") return;
238
+ if (manualPin) {
239
+ renderStatus(ctx, "pignon ⏸ pinned");
240
+ return;
241
+ }
242
+ // Fail open while the decider loads (or reloads after a crash): route
243
+ // nothing rather than hold the prompt.
244
+ if (!decider.isReady) {
245
+ renderStatus(ctx, "pignon ⏳ model loading — prompt not routed");
246
+ warmUp(ctx);
247
+ return;
248
+ }
249
+
250
+ if (promptsSinceSwitch !== undefined) promptsSinceSwitch++;
251
+ const { applied, entry } = await routePrompt(
252
+ piHost(pi, ctx, switchModel),
253
+ { decider, config, mode, promptsSinceSwitch },
254
+ event.prompt,
255
+ );
256
+ pendingEntry = entry;
257
+ if (applied) promptsSinceSwitch = 0;
258
+ });
259
+
260
+ pi.on("message_end", async (event) => {
261
+ if (event.message.role === "user") flushPendingEntry();
262
+ });
263
+
264
+ // Fallback for runs that end without posting a user message.
265
+ pi.on("agent_end", async () => {
266
+ flushPendingEntry();
267
+ });
268
+
269
+ // --- Manual model selection guard -------------------------------------
270
+
271
+ pi.on("model_select", async (event, ctx) => {
272
+ if (event.source === "set" && !routerSwitching) {
273
+ manualPin = true;
274
+ renderStatus(ctx, "pignon ⏸ pinned");
275
+ }
276
+ });
277
+
278
+ // --- Commands ---------------------------------------------------------
279
+
280
+ const modeCommand = async (args: string, ctx: ExtensionContext) => {
281
+ const arg = args.trim();
282
+ if (arg === "log clear") {
283
+ clearWidget(ctx, "pignon-log");
284
+ return;
285
+ }
286
+ if (arg === "log") {
287
+ const lines = decider.recentLogs.slice(-LOG_REPORT_LINES);
288
+ if (lines.length === 0) {
289
+ notify(ctx, "pignon: no decider output yet");
290
+ return;
291
+ }
292
+ await showReport(ctx, "pignon log", lines);
293
+ return;
294
+ }
295
+ if (arg === "config clear") {
296
+ clearWidget(ctx, "pignon-config");
297
+ return;
298
+ }
299
+ if (arg === "config") {
300
+ await showTitledReport(ctx, describeConfig(config, loaded.source));
301
+ return;
302
+ }
303
+ if (arg === "init" || arg.startsWith("init ")) {
304
+ const requested = arg.slice("init".length).trim();
305
+ if (requested && !isPresetName(requested)) {
306
+ notify(ctx, `pignon: unknown preset "${requested}" (${PRESET_NAMES.join(", ")})`, "error");
307
+ return;
308
+ }
309
+ const lookup = ctx.modelRegistry as ModelLookup<unknown>;
310
+ const preset = requested && isPresetName(requested) ? requested : choosePreset(lookup);
311
+ const result = writeStarterConfig(configPaths().path, starterConfig(preset, await (options.detectDeciders ?? detectDeciders)()));
312
+ if (!result.ok) {
313
+ notify(ctx, `pignon: ${result.message}`, "error");
314
+ return;
315
+ }
316
+ const specs = Object.values(PRESETS[preset].models);
317
+ const usable = specs.filter((spec) => modelStatus(spec, lookup) === "ok").length;
318
+ notify(
319
+ ctx,
320
+ `pignon: wrote ${result.path} (preset ${preset}, ${usable}/${specs.length} models usable); /reload to use it, /pignon doctor to check it`,
321
+ );
322
+ return;
323
+ }
324
+ if (arg === "doctor clear") {
325
+ clearWidget(ctx, "pignon-doctor");
326
+ return;
327
+ }
328
+ if (arg === "doctor") {
329
+ const report = runDoctor({
330
+ config,
331
+ configSource: loaded.source,
332
+ configErrors: loaded.errors,
333
+ decider,
334
+ lookup: ctx.modelRegistry as ModelLookup<unknown>,
335
+ });
336
+ // The overlay opens at once; the decider probe can take seconds.
337
+ await showReport(ctx, "pignon doctor", report.then(([, ...body]) => body));
338
+ return;
339
+ }
340
+ if (arg === "config migrate") {
341
+ if (!loaded.legacy) {
342
+ notify(ctx, "pignon: config is already in the pignon format");
343
+ return;
344
+ }
345
+ const result = migrateConfigFile();
346
+ if (!result.ok) {
347
+ notify(ctx, `pignon: ${result.message}`, "error");
348
+ return;
349
+ }
350
+ const backup = result.backup ? ` (previous file kept as ${result.backup})` : "";
351
+ notify(ctx, `pignon: wrote ${result.to} from ${result.from}${backup}; /reload to use it`);
352
+ return;
353
+ }
354
+ if (arg === "unpin") {
355
+ manualPin = false;
356
+ notify(ctx, "pignon: routing re-enabled");
357
+ renderStatus(ctx, `pignon ${mode}`);
358
+ return;
359
+ }
360
+ if (arg === "shadow" || arg === "live" || arg === "off") {
361
+ mode = arg;
362
+ manualPin = false;
363
+ notify(ctx, `pignon: mode ${mode}`);
364
+ renderStatus(ctx, `pignon ${mode}`);
365
+ return;
366
+ }
367
+ const pinned = manualPin ? " (model pinned manually)" : "";
368
+ const configNote = loaded.source ? ` · config ${loaded.source}` : "";
369
+ notify(ctx, `pignon: mode ${mode}${pinned}${configNote}`);
370
+ };
371
+
372
+ const statsCommand = async (args: string, ctx: ExtensionContext) => {
373
+ const [sub = "", ...rest] = args.trim().split(/\s+/);
374
+ if (sub === "clear") {
375
+ clearWidget(ctx, "pignon-stats");
376
+ return;
377
+ }
378
+
379
+ const rows = ctx.sessionManager
380
+ .getEntries()
381
+ .filter(isDecisionEntry)
382
+ .map((e) => (e as { data?: RouterLogEntry }).data)
383
+ .filter((d): d is RouterLogEntry => d !== undefined);
384
+
385
+ if (rows.length === 0) {
386
+ notify(ctx, "pignon: no decisions in this session");
387
+ return;
388
+ }
389
+
390
+ if (sub === "compare") {
391
+ const lines = buildCompareLines(rows, config.table);
392
+ if (lines) await showTitledReport(ctx, lines);
393
+ else notify(ctx, "pignon: no decisions answered by two deciders yet (set strategy.mode to parallel to compare them)");
394
+ return;
395
+ }
396
+ if (sub === "export") {
397
+ const path = rest.join(" ") || defaultExportPath();
398
+ try {
399
+ exportDecisions(rows, path);
400
+ notify(ctx, `pignon: wrote ${rows.length} decisions to ${path}`);
401
+ } catch (err) {
402
+ notify(ctx, `pignon: could not write ${path}: ${err instanceof Error ? err.message : String(err)}`, "error");
403
+ }
404
+ return;
405
+ }
406
+
407
+ await showTitledReport(ctx, buildStatsLines(rows, config.table));
408
+ };
409
+
410
+ const completions = (prefix: string) =>
411
+ SUBCOMMANDS.filter((v) => v.startsWith(prefix)).map((v) => ({ value: v, label: v }));
412
+
413
+ pi.registerCommand("pignon", {
414
+ description: "pignon mode (shadow | live | off | unpin | log | config)",
415
+ getArgumentCompletions: completions,
416
+ handler: modeCommand,
417
+ });
418
+ const statsCompletions = (prefix: string) =>
419
+ ["compare", "export"].filter((v) => v.startsWith(prefix)).map((v) => ({ value: v, label: v }));
420
+
421
+ pi.registerCommand("pignon-stats", {
422
+ description: "Session statistics (compare: decider vs decider · export [path]: JSON lines)",
423
+ getArgumentCompletions: statsCompletions,
424
+ handler: statsCommand,
425
+ });
426
+ pi.registerCommand("laya", {
427
+ description: "Alias of /pignon (deprecated)",
428
+ getArgumentCompletions: completions,
429
+ handler: modeCommand,
430
+ });
431
+ pi.registerCommand("laya-stats", {
432
+ description: "Alias of /pignon-stats (deprecated)",
433
+ getArgumentCompletions: statsCompletions,
434
+ handler: statsCommand,
435
+ });
436
+ };
437
+ }
438
+
439
+ export default createExtension();
@@ -0,0 +1,203 @@
1
+ /**
2
+ * `/pignon init` and `/pignon doctor`: get a working setup, and find out
3
+ * what is wrong with one.
4
+ *
5
+ * Pi-free: the model registry is reached through `ModelLookup`.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+
11
+ import { type PresetName, PRESETS, PRESET_NAMES } from "./config/presets.js";
12
+ import { CONFIG_SCHEMA_URL } from "./config/schema.js";
13
+ import { DEFAULT_API_KEY_ENV } from "./deciders/jev.js";
14
+ import { type WorkerLaunch, layaRuntimeStatus } from "./deciders/laya-local.js";
15
+ import { probeLayaServe } from "./deciders/laya-serve.js";
16
+ import { StrategyDecider } from "./deciders/strategy.js";
17
+ import { type Decider, DeciderError } from "./deciders/types.js";
18
+ import { buildQuestions } from "./deciders/questions.js";
19
+ import type { DeciderSpec, ModelSpec, RouterConfig } from "./types.js";
20
+
21
+ /** The part of Pi's model registry pignon checks models against. */
22
+ export interface ModelLookup<M = unknown> {
23
+ find(provider: string, modelId: string): M | undefined;
24
+ hasConfiguredAuth(model: M): boolean;
25
+ }
26
+
27
+ export type ModelStatus = "ok" | "missing" | "no-auth";
28
+
29
+ export function modelStatus<M>(spec: ModelSpec, lookup: ModelLookup<M>): ModelStatus {
30
+ const model = lookup.find(spec.provider, spec.modelId);
31
+ if (model === undefined) return "missing";
32
+ return lookup.hasConfiguredAuth(model) ? "ok" : "no-auth";
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // init
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * The preset to start from: the first whose four models all exist and have
41
+ * credentials in Pi, else the one with the most usable models.
42
+ */
43
+ export function choosePreset<M>(lookup: ModelLookup<M>): PresetName {
44
+ const usable = (name: PresetName) =>
45
+ Object.values(PRESETS[name].models).filter((spec) => modelStatus(spec, lookup) === "ok").length;
46
+ return PRESET_NAMES.reduce((best, name) => (usable(name) > usable(best) ? name : best), PRESET_NAMES[0]!);
47
+ }
48
+
49
+ /**
50
+ * The decider to start with, local first: a running laya-serve on its default
51
+ * address, else the experimental worker when installed, else Jev when its key
52
+ * is set.
53
+ */
54
+ export async function detectDeciders(
55
+ env: NodeJS.ProcessEnv = process.env,
56
+ layaStatus: typeof layaRuntimeStatus = layaRuntimeStatus,
57
+ probe: () => Promise<boolean> = () => probeLayaServe(),
58
+ ): Promise<DeciderSpec[]> {
59
+ if (await probe()) return [{ type: "laya-serve" }];
60
+ if (layaStatus(env).ok) return [{ type: "laya-local" }];
61
+ if (env[DEFAULT_API_KEY_ENV]?.trim()) return [{ type: "jev" }];
62
+ return [];
63
+ }
64
+
65
+ /**
66
+ * A starter config: the preset's models written out (so they are easy to
67
+ * edit), the deciders that can run here, and the built-in tiers.
68
+ */
69
+ export function starterConfig(preset: PresetName, deciders: DeciderSpec[]): Record<string, unknown> {
70
+ return {
71
+ $schema: CONFIG_SCHEMA_URL,
72
+ version: 2,
73
+ ...(deciders.length > 0 ? { deciders } : {}),
74
+ models: PRESETS[preset].models,
75
+ };
76
+ }
77
+
78
+ export type InitResult = { ok: true; path: string } | { ok: false; message: string };
79
+
80
+ /** Write a starter config; never replaces an existing file. */
81
+ export function writeStarterConfig(path: string, config: Record<string, unknown>): InitResult {
82
+ if (existsSync(path)) {
83
+ return { ok: false, message: `${path} already exists; edit it, or move it away to start over` };
84
+ }
85
+ mkdirSync(dirname(path), { recursive: true });
86
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { flag: "wx" });
87
+ return { ok: true, path };
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // doctor
92
+ // ---------------------------------------------------------------------------
93
+
94
+ export interface DoctorInput<M> {
95
+ config: RouterConfig;
96
+ configSource: string | null;
97
+ configErrors: readonly string[];
98
+ decider: Decider;
99
+ lookup: ModelLookup<M>;
100
+ env?: NodeJS.ProcessEnv;
101
+ layaStatus?: typeof layaRuntimeStatus;
102
+ }
103
+
104
+ /** A fixed, harmless prompt for the decider round trip. */
105
+ const PROBE_PROMPT = "Rename the variable `cnt` to `count` in src/stats.ts";
106
+
107
+ /**
108
+ * One ✓/⚠/✗ line per check: config file, each decider (with one real
109
+ * decision when it is ready), and each model of the routing table.
110
+ */
111
+ export async function runDoctor<M>(input: DoctorInput<M>): Promise<string[]> {
112
+ const { config, decider, lookup } = input;
113
+ const env = input.env ?? process.env;
114
+ const lines: string[] = ["pignon doctor"];
115
+ const ok = (text: string) => lines.push(` ✓ ${text}`);
116
+ const warn = (text: string) => lines.push(` ⚠ ${text}`);
117
+ const fail = (text: string) => lines.push(` ✗ ${text}`);
118
+
119
+ lines.push("config");
120
+ if (input.configErrors.length > 0) {
121
+ fail(`${input.configSource ?? "config"}: ${input.configErrors.length} problem(s), defaults used for those parts`);
122
+ for (const error of input.configErrors) lines.push(` ${error}`);
123
+ } else {
124
+ ok(input.configSource ?? "no config file: built-in defaults (/pignon init writes one)");
125
+ }
126
+
127
+ lines.push("deciders");
128
+ const members = decider instanceof StrategyDecider ? decider.members : [decider];
129
+ if (members.length > 1) ok(`${config.strategy.mode} strategy over ${members.map((d) => d.id).join(", ")}`);
130
+ for (const member of members) {
131
+ await checkDecider(member, config, env, input.layaStatus ?? layaRuntimeStatus, { ok, warn, fail });
132
+ }
133
+
134
+ lines.push("models");
135
+ const seen = new Set<string>();
136
+ for (const tier of config.table) {
137
+ for (const spec of [tier.models.direct, tier.models.exploration]) {
138
+ const name = `${spec.provider}/${spec.modelId}`;
139
+ if (seen.has(name)) continue;
140
+ seen.add(name);
141
+ const status = modelStatus(spec, lookup);
142
+ if (status === "ok") ok(`${name} (${tier.id})`);
143
+ else if (status === "no-auth") warn(`${name} (${tier.id}): no credentials in Pi (/login, or the provider's API key variable)`);
144
+ else fail(`${name} (${tier.id}): not in Pi's model registry (check the id with pi --list-models)`);
145
+ }
146
+ }
147
+ return lines;
148
+ }
149
+
150
+ function describeLaunch(launch: WorkerLaunch): string {
151
+ switch (launch.source) {
152
+ case "config":
153
+ return `the configured command (${[launch.command, ...launch.args].join(" ")})`;
154
+ case "env":
155
+ return `LAYA_PYTHON (${launch.command})`;
156
+ case "checkout":
157
+ return `the source checkout (${launch.cwd})`;
158
+ case "path":
159
+ return launch.command;
160
+ }
161
+ }
162
+
163
+ async function checkDecider(
164
+ decider: Decider,
165
+ config: RouterConfig,
166
+ env: NodeJS.ProcessEnv,
167
+ layaStatus: typeof layaRuntimeStatus,
168
+ report: { ok: (t: string) => void; warn: (t: string) => void; fail: (t: string) => void },
169
+ ): Promise<void> {
170
+ const name = `${decider.id}${decider.remote ? " (remote)" : ""}`;
171
+ if (decider.id === "laya-local") {
172
+ const spec = config.deciders?.find((d) => d.type === "laya-local");
173
+ const command = spec?.type === "laya-local" ? spec.command : undefined;
174
+ const status = layaStatus(env, process.platform, process.arch, command);
175
+ if (!status.ok) return report.fail(`${name}: ${status.reason}`);
176
+ if (status.launch) report.ok(`${name}: worker from ${describeLaunch(status.launch)}`);
177
+ }
178
+ if (!decider.isReady) {
179
+ if (decider.id === "laya-local") {
180
+ // Loading takes seconds to minutes (first run downloads the model): start it, don't wait.
181
+ decider.warmup().catch(() => {});
182
+ return report.warn(`${name}: model not loaded yet; loading now, run /pignon doctor again in a moment`);
183
+ }
184
+ try {
185
+ await decider.warmup();
186
+ } catch (err) {
187
+ return report.fail(`${name}: ${failureOf(decider, err)}`);
188
+ }
189
+ }
190
+ try {
191
+ const result = await decider.decide({ text: PROBE_PROMPT, questions: buildQuestions(config) }, AbortSignal.timeout(15_000));
192
+ const cost = result.costUsd !== undefined ? ` · $${result.costUsd.toFixed(6)}` : "";
193
+ report.ok(`${name}: ${result.model} answered a test prompt in ${result.latencyMs} ms${cost}`);
194
+ } catch (err) {
195
+ report.fail(`${name}: ${failureOf(decider, err)}`);
196
+ }
197
+ }
198
+
199
+ /** An error's message without the decider id it starts with, since the line already names the decider. */
200
+ function failureOf(decider: Decider, err: unknown): string {
201
+ const message = err instanceof DeciderError || err instanceof Error ? err.message : String(err);
202
+ return message.startsWith(`${decider.id}: `) ? message.slice(decider.id.length + 2) : message;
203
+ }