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.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/examples/pignon.json +35 -0
- package/package.json +68 -0
- package/schema/config.schema.json +446 -0
- package/src/compare.ts +109 -0
- package/src/config/defaults.ts +95 -0
- package/src/config/describe.ts +30 -0
- package/src/config/load.ts +445 -0
- package/src/config/migrate.ts +86 -0
- package/src/config/presets.ts +54 -0
- package/src/config/schema.ts +224 -0
- package/src/deciders/create.ts +102 -0
- package/src/deciders/jev.ts +226 -0
- package/src/deciders/laya-local.ts +718 -0
- package/src/deciders/laya-serve.ts +53 -0
- package/src/deciders/parse.ts +48 -0
- package/src/deciders/questions.ts +34 -0
- package/src/deciders/strategy.ts +225 -0
- package/src/deciders/types.ts +70 -0
- package/src/extension.ts +439 -0
- package/src/onboarding.ts +203 -0
- package/src/policy.ts +215 -0
- package/src/report.ts +171 -0
- package/src/router.ts +215 -0
- package/src/stats.ts +55 -0
- package/src/types.ts +341 -0
- package/src/ui.ts +153 -0
package/src/extension.ts
ADDED
|
@@ -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
|
+
}
|