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,95 @@
1
+ /**
2
+ * Built-in configuration: what pignon does with no config file.
3
+ */
4
+
5
+ import type { ModelSpec, QuestionWording, RouterConfig, StrategyConfig, Thresholds } from "../types.js";
6
+ import { PRESETS } from "./presets.js";
7
+ import type { TierFile } from "./schema.js";
8
+
9
+ /**
10
+ * Named models, by role: `fast` and `balanced` for easy work, `reasoner`
11
+ * (spends its budget before acting) and `agent` (spreads it over many small
12
+ * tool turns) for hard work. A config file can redefine any of them, or take
13
+ * a whole set from a preset with `extends`, without rewriting the tier list.
14
+ */
15
+ export const DEFAULT_MODELS: Readonly<Record<string, ModelSpec>> = PRESETS.openrouter.models;
16
+
17
+ /** Difficulty tiers, easiest first, in config-file form. */
18
+ export const DEFAULT_TIERS: readonly TierFile[] = [
19
+ {
20
+ id: "trivial",
21
+ criterion: "Mechanical edit, rename, formatting, or a single factual lookup",
22
+ model: "fast",
23
+ explorationAllowed: false,
24
+ },
25
+ {
26
+ id: "standard",
27
+ criterion: "Localized change across a few files with clear intent",
28
+ model: "balanced",
29
+ },
30
+ {
31
+ id: "hard",
32
+ criterion: "Multi-step investigation, debugging with unclear cause, or cross-cutting design",
33
+ direct: "reasoner",
34
+ exploration: "agent",
35
+ },
36
+ ];
37
+
38
+ export const DEFAULT_QUESTIONS: QuestionWording = {
39
+ version: "q1",
40
+ tierInstructions: "How much reasoning does solving this request demand, regardless of how long the answer should be?",
41
+ explorationInstructions: "Does answering require exploring the codebase before acting?",
42
+ explorationCriteria: {
43
+ yes: "The target files or cause are not identified in the request",
44
+ no: "The request names what to change and where",
45
+ },
46
+ };
47
+
48
+ export const DEFAULT_THRESHOLDS: Readonly<Thresholds> = {
49
+ minConfidenceDowngrade: 0.85,
50
+ minConfidenceUpgrade: 0.5,
51
+ minConfidenceForm: 0.6,
52
+ cacheGuardTokens: 60_000,
53
+ minPromptsBetweenSwitches: 2,
54
+ maxPaybackRequests: 3,
55
+ assumedOutputTokensPerRequest: 1_000,
56
+ layaTimeoutMs: 2_500,
57
+ };
58
+
59
+ export const DEFAULT_STRATEGY: StrategyConfig = {
60
+ mode: "sequential",
61
+ escalateBelow: 0.75,
62
+ pick: "most-confident",
63
+ budgetMs: 3_000,
64
+ };
65
+
66
+ const spec = (name: string): ModelSpec => DEFAULT_MODELS[name]!;
67
+
68
+ /** The defaults, resolved. Kept in sync with DEFAULT_TIERS by a test. */
69
+ export const DEFAULT_CONFIG: RouterConfig = {
70
+ deciders: null,
71
+ strategy: DEFAULT_STRATEGY,
72
+ table: [
73
+ {
74
+ id: "trivial",
75
+ criterion: DEFAULT_TIERS[0]!.criterion,
76
+ models: { direct: spec("fast"), exploration: spec("fast") },
77
+ explorationAllowed: false,
78
+ },
79
+ {
80
+ id: "standard",
81
+ criterion: DEFAULT_TIERS[1]!.criterion,
82
+ models: { direct: spec("balanced"), exploration: spec("balanced") },
83
+ explorationAllowed: true,
84
+ },
85
+ {
86
+ id: "hard",
87
+ criterion: DEFAULT_TIERS[2]!.criterion,
88
+ models: { direct: spec("reasoner"), exploration: spec("agent") },
89
+ explorationAllowed: true,
90
+ },
91
+ ],
92
+ thresholds: DEFAULT_THRESHOLDS,
93
+ questions: DEFAULT_QUESTIONS,
94
+ confidenceSource: "reported",
95
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `/pignon config`: the routing table and settings in use, as report lines.
3
+ */
4
+
5
+ import type { ModelSpec, RouterConfig } from "../types.js";
6
+
7
+ export function describeConfig(config: RouterConfig, source: string | null): string[] {
8
+ const model = (spec: ModelSpec) => `${spec.provider}/${spec.modelId} · ${spec.thinking}`;
9
+ const rows = config.table.map((tier, index) => {
10
+ const direct = model(tier.models.direct);
11
+ let exploration = model(tier.models.exploration);
12
+ if (!tier.explorationAllowed) {
13
+ const next = config.table.find((t, i) => i > index && t.explorationAllowed);
14
+ exploration = `→ ${next?.id ?? "(none)"}`;
15
+ } else if (exploration === direct) {
16
+ exploration = "same";
17
+ }
18
+ return [tier.id, direct, exploration];
19
+ });
20
+
21
+ const widths = [0, 1].map((col) => Math.max(...[["tier", "direct"], ...rows].map((r) => r[col]!.length)));
22
+ const line = (cells: string[]) => ` ${cells[0]!.padEnd(widths[0]! + 2)}${cells[1]!.padEnd(widths[1]! + 2)}${cells[2]}`;
23
+
24
+ return [
25
+ `pignon config · ${source ?? "built-in defaults"}`,
26
+ line(["tier", "direct", "exploration"]),
27
+ ...rows.map(line),
28
+ ` questions ${config.questions.version} · confidence ${config.confidenceSource}`,
29
+ ];
30
+ }
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Optional user configuration: `~/.pi/agent/pignon.json`, or the file named
3
+ * by PIGNON_CONFIG. The laya-router file of earlier versions is still read
4
+ * when there is none.
5
+ *
6
+ * {
7
+ * "models": { "reasoner": { "provider": "anthropic", "modelId": "…", "thinking": "high" } },
8
+ * "thresholds": { "minConfidenceDowngrade": 0.9 }
9
+ * }
10
+ *
11
+ * Every key is optional and merged over the defaults. Each section is checked
12
+ * on its own: an invalid section is reported and falls back to its default,
13
+ * it never prevents the extension from loading.
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ import { Compile } from "typebox/compile";
21
+ import type { TLocalizedValidationError } from "typebox/error";
22
+
23
+ import {
24
+ type ConfidenceSource,
25
+ type DeciderSpec,
26
+ type Form,
27
+ type ModelSpec,
28
+ type QuestionWording,
29
+ type RouterConfig,
30
+ type RoutingTable,
31
+ type StrategyConfig,
32
+ type Thresholds,
33
+ type TierSpec,
34
+ FORMS,
35
+ } from "../types.js";
36
+ import {
37
+ DEFAULT_CONFIG,
38
+ DEFAULT_MODELS,
39
+ DEFAULT_QUESTIONS,
40
+ DEFAULT_STRATEGY,
41
+ DEFAULT_THRESHOLDS,
42
+ DEFAULT_TIERS,
43
+ } from "./defaults.js";
44
+ import { PRESETS, PRESET_NAMES, isPresetName } from "./presets.js";
45
+ import {
46
+ type ModelRef,
47
+ type TierFile,
48
+ ConfidenceSourceSchema,
49
+ ConfigFileSchema,
50
+ DECIDER_SCHEMAS,
51
+ ModelSpecSchema,
52
+ QuestionsSchema,
53
+ StrategySchema,
54
+ ThresholdsSchema,
55
+ TiersSchema,
56
+ } from "./schema.js";
57
+
58
+ export interface LoadedConfig {
59
+ config: RouterConfig;
60
+ /** Where the config was read from, or null when the defaults are used. */
61
+ source: string | null;
62
+ /** Problems found in the file; the affected sections fall back to defaults. */
63
+ errors: string[];
64
+ /** Things that work but should be changed (legacy file name or format). */
65
+ warnings: string[];
66
+ /** Whether the file uses the laya-router name or format, which `/pignon config migrate` converts. */
67
+ legacy: boolean;
68
+ }
69
+
70
+ export interface ConfigPaths {
71
+ /** Where pignon reads its config. */
72
+ path: string;
73
+ /** Config of earlier (laya-router) versions, read when `path` does not exist. */
74
+ legacyPath: string;
75
+ }
76
+
77
+ /** Pi's config directory: PI_CODING_AGENT_DIR, else `~/.pi/agent`. */
78
+ export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
79
+ return env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
80
+ }
81
+
82
+ export function configPaths(env: NodeJS.ProcessEnv = process.env): ConfigPaths {
83
+ const dir = agentDir(env);
84
+ return {
85
+ path: env.PIGNON_CONFIG ?? join(dir, "pignon.json"),
86
+ legacyPath: env.LAYA_ROUTER_CONFIG ?? join(dir, "laya-router.json"),
87
+ };
88
+ }
89
+
90
+ /** Read and validate the config file. A missing file means defaults. */
91
+ export function loadConfig(paths: ConfigPaths = configPaths()): LoadedConfig {
92
+ const primary = readJson(paths.path);
93
+ if (primary.kind !== "missing") return fromFile(paths.path, primary, false);
94
+
95
+ const legacy = readJson(paths.legacyPath);
96
+ if (legacy.kind === "missing") {
97
+ return { config: DEFAULT_CONFIG, source: null, errors: [], warnings: [], legacy: false };
98
+ }
99
+ const loaded = fromFile(paths.legacyPath, legacy, true);
100
+ loaded.warnings.unshift(
101
+ `${paths.legacyPath}: laya-router config file; run /pignon config migrate to write ${paths.path}`,
102
+ );
103
+ return loaded;
104
+ }
105
+
106
+ type ReadResult = { kind: "missing" } | { kind: "error"; error: string } | { kind: "ok"; raw: unknown };
107
+
108
+ /** Read a JSON file; `missing` only when it does not exist. */
109
+ export function readJson(path: string): ReadResult {
110
+ let text: string;
111
+ try {
112
+ text = readFileSync(path, "utf8");
113
+ } catch (err) {
114
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
115
+ return { kind: "error", error: String(err) };
116
+ }
117
+ try {
118
+ return { kind: "ok", raw: JSON.parse(text) };
119
+ } catch (err) {
120
+ return { kind: "error", error: String(err) };
121
+ }
122
+ }
123
+
124
+ function fromFile(path: string, read: Exclude<ReadResult, { kind: "missing" }>, legacyName: boolean): LoadedConfig {
125
+ if (read.kind === "error") {
126
+ return { config: DEFAULT_CONFIG, source: null, errors: [`${path}: ${read.error}`], warnings: [], legacy: false };
127
+ }
128
+ const parsed = parseConfig(read.raw);
129
+ return {
130
+ config: parsed.config,
131
+ source: path,
132
+ errors: parsed.errors.map((e) => `${path}: ${e}`),
133
+ warnings: parsed.warnings.map((w) => `${path}: ${w}`),
134
+ legacy: legacyName || parsed.legacy,
135
+ };
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Parsing
140
+ // ---------------------------------------------------------------------------
141
+
142
+ export interface ParsedConfig {
143
+ config: RouterConfig;
144
+ errors: string[];
145
+ warnings: string[];
146
+ /** Whether `tiers` uses the laya-router (v1) format. */
147
+ legacy: boolean;
148
+ }
149
+
150
+ const checkModelSpec = Compile(ModelSpecSchema);
151
+ const checkTiers = Compile(TiersSchema);
152
+ const checkQuestions = Compile(QuestionsSchema);
153
+ const checkConfidenceSource = Compile(ConfidenceSourceSchema);
154
+ const checkStrategy = Compile(StrategySchema);
155
+ const DECIDER_TYPES = Object.keys(DECIDER_SCHEMAS) as (keyof typeof DECIDER_SCHEMAS)[];
156
+ const checkDecider = {
157
+ "laya-serve": Compile(DECIDER_SCHEMAS["laya-serve"]),
158
+ "laya-local": Compile(DECIDER_SCHEMAS["laya-local"]),
159
+ jev: Compile(DECIDER_SCHEMAS.jev),
160
+ };
161
+
162
+ /** Merge a parsed JSON value over the defaults, collecting validation errors. */
163
+ export function parseConfig(raw: unknown): ParsedConfig {
164
+ if (!isRecord(raw)) {
165
+ return { config: DEFAULT_CONFIG, errors: ["expected a JSON object"], warnings: [], legacy: false };
166
+ }
167
+ const errors: string[] = [];
168
+ const warnings: string[] = [];
169
+
170
+ for (const key of Object.keys(raw)) {
171
+ if (!(key in ConfigFileSchema.properties)) errors.push(`${key}: unknown setting`);
172
+ }
173
+ if (raw.version !== undefined && raw.version !== 2) errors.push("version: expected 2");
174
+
175
+ const deciders = parseDeciders(raw.deciders, errors);
176
+ let strategy: StrategyConfig = DEFAULT_STRATEGY;
177
+ if (raw.strategy !== undefined) {
178
+ if (checkStrategy.Check(raw.strategy)) strategy = { ...DEFAULT_STRATEGY, ...raw.strategy };
179
+ else errors.push(...formatErrors("strategy", checkStrategy.Errors(raw.strategy)));
180
+ }
181
+ const thresholds = parseThresholds(raw.thresholds, errors);
182
+ let base: Readonly<Record<string, ModelSpec>> = DEFAULT_MODELS;
183
+ if (raw.extends !== undefined) {
184
+ if (isPresetName(raw.extends)) base = { ...DEFAULT_MODELS, ...PRESETS[raw.extends].models };
185
+ else errors.push(`extends: expected one of ${PRESET_NAMES.join(", ")}`);
186
+ }
187
+ const models = parseModels(raw.models, base, errors);
188
+ const questions = parseQuestions(raw.questions, errors);
189
+
190
+ let confidenceSource: ConfidenceSource = DEFAULT_CONFIG.confidenceSource;
191
+ if (raw.confidenceSource !== undefined) {
192
+ if (checkConfidenceSource.Check(raw.confidenceSource)) confidenceSource = raw.confidenceSource;
193
+ else errors.push(...formatErrors("confidenceSource", checkConfidenceSource.Errors(raw.confidenceSource)));
194
+ }
195
+
196
+ // Built-in tiers over the (possibly redefined) models. parseModels rejects
197
+ // invalid entries, so every built-in name still resolves.
198
+ const defaultTable = resolveTable(DEFAULT_TIERS, models).table ?? DEFAULT_CONFIG.table;
199
+
200
+ const legacy = isRecord(raw.tiers);
201
+ let table = defaultTable;
202
+ if (legacy) {
203
+ warnings.push("`tiers` uses the laya-router format; run /pignon config migrate to convert it");
204
+ table = migrateTiers(raw.tiers as Record<string, unknown>, defaultTable, errors);
205
+ } else if (raw.tiers !== undefined) {
206
+ table = parseTiers(raw.tiers, models, errors) ?? defaultTable;
207
+ }
208
+
209
+ return { config: { deciders, strategy, table, thresholds, questions, confidenceSource }, errors, warnings, legacy };
210
+ }
211
+
212
+ /**
213
+ * Deciders are checked one by one (by `type`, for precise messages); invalid
214
+ * ones are skipped. None valid means automatic choice, as with no section.
215
+ */
216
+ function parseDeciders(raw: unknown, errors: string[]): DeciderSpec[] | null {
217
+ if (raw === undefined) return null;
218
+ if (!Array.isArray(raw) || raw.length === 0) {
219
+ errors.push("deciders: expected a non-empty list");
220
+ return null;
221
+ }
222
+ if (raw.length > 4) errors.push("deciders: at most 4 deciders; the rest are ignored");
223
+ const deciders: DeciderSpec[] = [];
224
+ raw.slice(0, 4).forEach((entry, index) => {
225
+ const at = `deciders[${index}]`;
226
+ const type = isRecord(entry) ? entry.type : undefined;
227
+ if (!DECIDER_TYPES.includes(type as never)) {
228
+ errors.push(`${at}.type: expected one of ${DECIDER_TYPES.join(", ")}`);
229
+ } else if (isRecord(entry) && "apiKey" in entry) {
230
+ errors.push(`${at}.apiKey: keep secrets out of the config file; name the environment variable with apiKeyEnv`);
231
+ } else if (!checkDecider[type as keyof typeof checkDecider].Check(entry)) {
232
+ errors.push(...formatErrors(at, checkDecider[type as keyof typeof checkDecider].Errors(entry)));
233
+ } else if (deciders.some((d) => d.type === type)) {
234
+ errors.push(`${at}: ${type} is already listed`);
235
+ } else {
236
+ deciders.push({ ...(entry as DeciderSpec) });
237
+ }
238
+ });
239
+ return deciders.length > 0 ? deciders : null;
240
+ }
241
+
242
+ function parseThresholds(raw: unknown, errors: string[]): Thresholds {
243
+ const thresholds: Thresholds = { ...DEFAULT_THRESHOLDS };
244
+ if (raw === undefined) return thresholds;
245
+ if (!isRecord(raw)) {
246
+ errors.push("thresholds: expected an object");
247
+ return thresholds;
248
+ }
249
+ for (const [key, value] of Object.entries(raw)) {
250
+ if (!(key in ThresholdsSchema.properties)) {
251
+ errors.push(`thresholds.${key}: unknown setting`);
252
+ } else if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
253
+ errors.push(`thresholds.${key}: expected a non-negative number`);
254
+ } else {
255
+ thresholds[key as keyof Thresholds] = value;
256
+ }
257
+ }
258
+ return thresholds;
259
+ }
260
+
261
+ /** Built-in (or preset) models with the file's entries merged over them. Invalid entries are skipped. */
262
+ function parseModels(
263
+ raw: unknown,
264
+ base: Readonly<Record<string, ModelSpec>>,
265
+ errors: string[],
266
+ ): Record<string, ModelSpec> {
267
+ const models: Record<string, ModelSpec> = { ...base };
268
+ if (raw === undefined) return models;
269
+ if (!isRecord(raw)) {
270
+ errors.push("models: expected an object");
271
+ return models;
272
+ }
273
+ for (const [name, spec] of Object.entries(raw)) {
274
+ if (checkModelSpec.Check(spec)) models[name] = { provider: spec.provider, modelId: spec.modelId, thinking: spec.thinking };
275
+ else errors.push(...formatErrors(`models.${name}`, checkModelSpec.Errors(spec)));
276
+ }
277
+ return models;
278
+ }
279
+
280
+ function parseQuestions(raw: unknown, errors: string[]): QuestionWording {
281
+ if (raw === undefined) return DEFAULT_QUESTIONS;
282
+ if (!checkQuestions.Check(raw)) {
283
+ errors.push(...formatErrors("questions", checkQuestions.Errors(raw)));
284
+ return DEFAULT_QUESTIONS;
285
+ }
286
+ return {
287
+ version: raw.version ?? DEFAULT_QUESTIONS.version,
288
+ tierInstructions: raw.tierInstructions ?? DEFAULT_QUESTIONS.tierInstructions,
289
+ explorationInstructions: raw.explorationInstructions ?? DEFAULT_QUESTIONS.explorationInstructions,
290
+ explorationCriteria: { ...DEFAULT_QUESTIONS.explorationCriteria, ...raw.explorationCriteria },
291
+ };
292
+ }
293
+
294
+ /** A user tier list replaces the default one as a whole, or not at all (null). */
295
+ function parseTiers(raw: unknown, models: Record<string, ModelSpec>, errors: string[]): RoutingTable | null {
296
+ if (!checkTiers.Check(raw)) {
297
+ errors.push(...formatErrors("tiers", checkTiers.Errors(raw)));
298
+ return null;
299
+ }
300
+ const resolved = resolveTable(raw, models);
301
+ errors.push(...resolved.errors);
302
+ return resolved.table;
303
+ }
304
+
305
+ /** Turn file tiers into a routing table, resolving model names. */
306
+ export function resolveTable(
307
+ tiers: readonly TierFile[],
308
+ models: Readonly<Record<string, ModelSpec>>,
309
+ ): { table: RoutingTable | null; errors: string[] } {
310
+ const errors: string[] = [];
311
+ const seen = new Set<string>();
312
+ const table: TierSpec[] = [];
313
+
314
+ tiers.forEach((tier, index) => {
315
+ const at = `tiers[${index}] (${tier.id})`;
316
+ if (seen.has(tier.id)) errors.push(`${at}: duplicate tier id`);
317
+ seen.add(tier.id);
318
+
319
+ if (tier.model !== undefined && (tier.direct !== undefined || tier.exploration !== undefined)) {
320
+ errors.push(`${at}: use either \`model\` or \`direct\` + \`exploration\`, not both`);
321
+ return;
322
+ }
323
+ const refs: Partial<Record<Form, ModelRef>> =
324
+ tier.model !== undefined
325
+ ? { direct: tier.model, exploration: tier.model }
326
+ : { direct: tier.direct, exploration: tier.exploration };
327
+
328
+ const resolved: Partial<Record<Form, ModelSpec>> = {};
329
+ for (const form of FORMS) {
330
+ const ref = refs[form];
331
+ if (ref === undefined) {
332
+ errors.push(`${at}: missing \`${form}\` model (or set \`model\` for both)`);
333
+ } else if (typeof ref === "string") {
334
+ const spec = models[ref];
335
+ if (spec) resolved[form] = spec;
336
+ else errors.push(`${at}: unknown model name "${ref}" (define it under \`models\`)`);
337
+ } else {
338
+ resolved[form] = { provider: ref.provider, modelId: ref.modelId, thinking: ref.thinking };
339
+ }
340
+ }
341
+ if (resolved.direct && resolved.exploration) {
342
+ table.push({
343
+ id: tier.id,
344
+ criterion: tier.criterion,
345
+ models: { direct: resolved.direct, exploration: resolved.exploration },
346
+ explorationAllowed: tier.explorationAllowed ?? true,
347
+ });
348
+ }
349
+ });
350
+
351
+ if (errors.length === 0 && !table.some((t) => t.explorationAllowed)) {
352
+ errors.push("tiers: at least one tier must allow exploration");
353
+ }
354
+ // A bad name in `model` fails both forms the same way: report it once.
355
+ const unique = [...new Set(errors)];
356
+ return unique.length === 0 ? { table, errors: unique } : { table: null, errors: unique };
357
+ }
358
+
359
+ /**
360
+ * laya-router (v1) tiers: `{ "hard": { "direct": { provider, modelId, thinking } } }`,
361
+ * merged cell by cell over the default table. Invalid cells are skipped.
362
+ */
363
+ function migrateTiers(raw: Record<string, unknown>, base: RoutingTable, errors: string[]): RoutingTable {
364
+ const table = base.map((tier) => ({ ...tier, models: { ...tier.models } }));
365
+ for (const [id, forms] of Object.entries(raw)) {
366
+ const tier = table.find((t) => t.id === id);
367
+ if (!tier) {
368
+ errors.push(`tiers.${id}: unknown tier`);
369
+ continue;
370
+ }
371
+ if (!isRecord(forms)) {
372
+ errors.push(`tiers.${id}: expected an object`);
373
+ continue;
374
+ }
375
+ for (const [form, spec] of Object.entries(forms)) {
376
+ if (!FORMS.includes(form as Form)) {
377
+ errors.push(`tiers.${id}.${form}: unknown form`);
378
+ } else if (!checkModelSpec.Check(spec)) {
379
+ errors.push(`tiers.${id}.${form}: expected { provider, modelId, thinking }`);
380
+ } else {
381
+ tier.models[form as Form] = { provider: spec.provider, modelId: spec.modelId, thinking: spec.thinking };
382
+ }
383
+ }
384
+ }
385
+ return table;
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Error messages
390
+ // ---------------------------------------------------------------------------
391
+
392
+ /**
393
+ * Turn TypeBox errors into one readable line per problem, e.g.
394
+ * `tiers[1].direct: expected a model name or { provider, modelId, thinking }`.
395
+ */
396
+ export function formatErrors(prefix: string, errors: Iterable<TLocalizedValidationError>): string[] {
397
+ const all = [...errors];
398
+ // "boolean" errors repeat what "additionalProperties" already says.
399
+ const list = all.filter((e) => e.keyword !== "boolean");
400
+ // A union (the only one in the schema is "model name or inline model")
401
+ // reports each branch that failed. TypeBox stops collecting after 8 errors
402
+ // and may cut the "anyOf" error itself, so two "type" errors on one path
403
+ // also mark a union.
404
+ const typeErrors = new Map<string, number>();
405
+ for (const e of list) if (e.keyword === "type") typeErrors.set(e.instancePath, (typeErrors.get(e.instancePath) ?? 0) + 1);
406
+ const unionPaths = new Set([
407
+ ...list.filter((e) => e.keyword === "anyOf").map((e) => e.instancePath),
408
+ ...[...typeErrors].filter(([, n]) => n > 1).map(([path]) => path),
409
+ ]);
410
+ const lines: string[] = [];
411
+ for (const error of list) {
412
+ const path = prefix + toDotted(error.instancePath);
413
+ const params = error.params as Record<string, unknown>;
414
+ if (unionPaths.has(error.instancePath)) {
415
+ // Errors inside a branch say what is wrong; without them, one summary line.
416
+ if (!list.some((e) => e.instancePath.startsWith(`${error.instancePath}/`))) {
417
+ lines.push(`${path}: expected a model name or { provider, modelId, thinking }`);
418
+ }
419
+ } else if (error.keyword === "additionalProperties") {
420
+ for (const key of params.additionalProperties as string[]) lines.push(`${path}.${key}: unknown setting`);
421
+ } else if (error.keyword === "enum") {
422
+ lines.push(`${path}: expected one of ${(params.allowedValues as unknown[]).join(", ")}`);
423
+ } else {
424
+ lines.push(`${path}: ${error.message}`);
425
+ }
426
+ }
427
+ if (all.length >= MAX_TYPEBOX_ERRORS) lines.push(`${prefix}: more problems may follow; fix these first`);
428
+ return [...new Set(lines)];
429
+ }
430
+
431
+ /** TypeBox's default `maxErrors`: it stops collecting after this many. */
432
+ const MAX_TYPEBOX_ERRORS = 8;
433
+
434
+ /** `/0/direct` → `[0].direct`, `/fast` → `.fast` (JSON Pointer to readable path). */
435
+ function toDotted(pointer: string): string {
436
+ return pointer
437
+ .split("/")
438
+ .slice(1)
439
+ .map((part) => (/^\d+$/.test(part) ? `[${part}]` : `.${part.replace(/~1/g, "/").replace(/~0/g, "~")}`))
440
+ .join("");
441
+ }
442
+
443
+ function isRecord(value: unknown): value is Record<string, unknown> {
444
+ return typeof value === "object" && value !== null && !Array.isArray(value);
445
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `/pignon config migrate`: rewrite a laya-router config as a pignon (v2)
3
+ * config file.
4
+ *
5
+ * Only the format changes: thresholds and models carry over as they are, and
6
+ * the per-cell overrides of v1 `tiers` become an explicit tier list.
7
+ */
8
+
9
+ import { copyFileSync, existsSync, writeFileSync } from "node:fs";
10
+
11
+ import { FORMS, type ModelSpec } from "../types.js";
12
+ import { DEFAULT_TIERS } from "./defaults.js";
13
+ import { type ConfigPaths, configPaths, readJson } from "./load.js";
14
+ import { type ModelRef, type TierFile, CONFIG_SCHEMA_URL } from "./schema.js";
15
+
16
+ /** The v2 document equivalent to a laya-router (or already v2) document. */
17
+ export function migrateDocument(raw: Record<string, unknown>): Record<string, unknown> {
18
+ const { $schema: _schema, version: _version, tiers, ...rest } = raw;
19
+ const doc: Record<string, unknown> = { $schema: CONFIG_SCHEMA_URL, version: 2, ...rest };
20
+ if (isRecord(tiers)) doc.tiers = migrateTiers(tiers);
21
+ else if (tiers !== undefined) doc.tiers = tiers;
22
+ return doc;
23
+ }
24
+
25
+ /** v1 per-cell overrides applied to the default tier list. Invalid cells are dropped, as when loading. */
26
+ function migrateTiers(v1: Record<string, unknown>): TierFile[] {
27
+ return DEFAULT_TIERS.map((tier) => {
28
+ const overrides = v1[tier.id];
29
+ if (!isRecord(overrides)) return { ...tier };
30
+ const refs: Record<string, ModelRef | undefined> = {
31
+ direct: tier.model ?? tier.direct,
32
+ exploration: tier.model ?? tier.exploration,
33
+ };
34
+ let changed = false;
35
+ for (const form of FORMS) {
36
+ const spec = overrides[form];
37
+ if (isModelSpec(spec)) {
38
+ refs[form] = { provider: spec.provider, modelId: spec.modelId, thinking: spec.thinking };
39
+ changed = true;
40
+ }
41
+ }
42
+ if (!changed) return { ...tier };
43
+ const { model: _model, ...base } = tier;
44
+ return { ...base, direct: refs.direct!, exploration: refs.exploration! };
45
+ });
46
+ }
47
+
48
+ export type MigrateResult = { ok: true; from: string; to: string; backup?: string } | { ok: false; message: string };
49
+
50
+ /**
51
+ * Write the migrated config to `paths.path`. Reads the pignon file when it
52
+ * exists (it may still use the v1 tier format), else the laya-router file.
53
+ * An existing pignon file is backed up to `<path>.bak` before being replaced.
54
+ */
55
+ export function migrateConfigFile(paths: ConfigPaths = configPaths()): MigrateResult {
56
+ const fromPrimary = existsSync(paths.path);
57
+ const from = fromPrimary ? paths.path : paths.legacyPath;
58
+ const read = readJson(from);
59
+ if (read.kind === "missing") return { ok: false, message: `no config file to migrate (${paths.path}, ${paths.legacyPath})` };
60
+ if (read.kind === "error") return { ok: false, message: `${from}: ${read.error}` };
61
+ if (!isRecord(read.raw)) return { ok: false, message: `${from}: expected a JSON object` };
62
+
63
+ const doc = migrateDocument(read.raw);
64
+ let backup: string | undefined;
65
+ if (fromPrimary) {
66
+ backup = `${paths.path}.bak`;
67
+ copyFileSync(paths.path, backup);
68
+ }
69
+ writeFileSync(paths.path, `${JSON.stringify(doc, null, 2)}\n`);
70
+ return { ok: true, from, to: paths.path, ...(backup ? { backup } : {}) };
71
+ }
72
+
73
+ function isModelSpec(value: unknown): value is ModelSpec {
74
+ return (
75
+ isRecord(value) &&
76
+ typeof value.provider === "string" &&
77
+ value.provider !== "" &&
78
+ typeof value.modelId === "string" &&
79
+ value.modelId !== "" &&
80
+ typeof value.thinking === "string"
81
+ );
82
+ }
83
+
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === "object" && value !== null && !Array.isArray(value);
86
+ }