@ejstembler/pi-classifier-router 1.0.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.
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Laya (local System One) classifier: a persistent NDJSON sidecar process.
3
+ *
4
+ * Wire protocol, shared verbatim with `python/laya_worker.py`:
5
+ * request {"id":<int>,"op":"predict"|"ping"|"preload"|"shutdown", ...}
6
+ * reply {"id":<int>,"ok":true,"result":{...}}
7
+ * | {"id":<int>,"ok":false,"error":<string>,"kind":"unavailable"|"timeout"|"protocol"}
8
+ * event {"event":"ready","version":<string>,"loaded":<string[]>}
9
+ * | {"event":"error","error":<string>,"kind":...}
10
+ *
11
+ * stdout carries one JSON object per line and nothing else; the worker writes
12
+ * diagnostics to stderr. The child is spawned lazily and respawned after a
13
+ * crash, so a dead worker degrades to `unavailable` without poisoning the
14
+ * session.
15
+ */
16
+
17
+ import { spawn } from "node:child_process";
18
+ import * as path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ import { ClassifierError } from "../types.ts";
22
+ import type {
23
+ Answers,
24
+ ClassificationResult,
25
+ Classifier,
26
+ ClassifierErrorCode,
27
+ ClassifierState,
28
+ ClassifyOptions,
29
+ LayaBackendConfig,
30
+ Questions,
31
+ } from "../types.ts";
32
+ import type { ClassifierDeps } from "./index.ts";
33
+
34
+ const BACKEND = "laya";
35
+
36
+ /** Repo root: two directories above `src/classify/laya.ts`. */
37
+ const DEFAULT_EXTENSION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
38
+
39
+ /** The subset of `node:child_process.ChildProcess` this client needs. */
40
+ export interface ChildProcessLike {
41
+ stdout: AsyncIterable<Uint8Array | string> | null;
42
+ stderr: AsyncIterable<Uint8Array | string> | null;
43
+ stdin: { write(chunk: string): boolean };
44
+ kill(signal?: string): boolean;
45
+ on(event: "exit" | "error", cb: (...args: unknown[]) => void): void;
46
+ pid?: number;
47
+ }
48
+
49
+ export type LayaSpawn = (
50
+ command: string,
51
+ args: string[],
52
+ options: { env: Record<string, string | undefined> },
53
+ ) => ChildProcessLike;
54
+
55
+ interface ReadyInfo {
56
+ version: string;
57
+ loaded: string[];
58
+ }
59
+
60
+ interface PendingCall {
61
+ resolve: (value: unknown) => void;
62
+ reject: (error: ClassifierError) => void;
63
+ }
64
+
65
+ interface ReadyWaiter {
66
+ resolve: (value: ReadyInfo) => void;
67
+ reject: (error: ClassifierError) => void;
68
+ }
69
+
70
+ const defaultSpawn: LayaSpawn = (command, args, options) =>
71
+ spawn(command, args, { env: options.env, stdio: ["pipe", "pipe", "pipe"] }) as unknown as ChildProcessLike;
72
+
73
+ export function createLayaClassifier(config: LayaBackendConfig, deps: ClassifierDeps = {}): Classifier {
74
+ const spawnImpl = deps.spawnImpl ?? defaultSpawn;
75
+ const extensionRoot = deps.extensionRoot ?? DEFAULT_EXTENSION_ROOT;
76
+ const workerScript = path.isAbsolute(config.workerScript)
77
+ ? config.workerScript
78
+ : path.resolve(extensionRoot, config.workerScript);
79
+
80
+ let child: ChildProcessLike | null = null;
81
+ let nextId = 1;
82
+ let ready: ReadyInfo | null = null;
83
+ let warnedStdout = false;
84
+ const pending = new Map<number, PendingCall>();
85
+ let readyWaiters: ReadyWaiter[] = [];
86
+
87
+ function spawnArgs(): string[] {
88
+ const args = ["--repo", config.repo];
89
+ if (config.device) args.push("--device", config.device);
90
+ if (config.router) args.push("--router");
91
+ if (config.preload) args.push("--preload");
92
+ if (config.subfolder) args.push("--subfolder", config.subfolder);
93
+ return args;
94
+ }
95
+
96
+ function childEnv(): Record<string, string | undefined> {
97
+ const env: Record<string, string | undefined> = { ...process.env, ...(deps.env ?? {}) };
98
+ const token = deps.env?.[config.hfTokenEnvVar] ?? process.env[config.hfTokenEnvVar];
99
+ if (token) env["HF_TOKEN"] = token;
100
+ return env;
101
+ }
102
+
103
+ /** Tear down a dead child: fail every in-flight call and allow a respawn. */
104
+ function failChild(proc: ChildProcessLike, message: string): void {
105
+ if (child !== proc) return;
106
+ child = null;
107
+ ready = null;
108
+ warnedStdout = false;
109
+ const error = new ClassifierError(BACKEND, "unavailable", message);
110
+ for (const call of [...pending.values()]) call.reject(error);
111
+ const waiters = readyWaiters;
112
+ readyWaiters = [];
113
+ for (const waiter of waiters) waiter.reject(error);
114
+ }
115
+
116
+ function handleStdoutLine(line: string): void {
117
+ let parsed: unknown;
118
+ try {
119
+ parsed = JSON.parse(line);
120
+ } catch {
121
+ if (!warnedStdout) {
122
+ warnedStdout = true;
123
+ deps.logger?.(`laya worker wrote a non-JSON stdout line: ${line.slice(0, 120)}`);
124
+ }
125
+ return;
126
+ }
127
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
128
+ if (!warnedStdout) {
129
+ warnedStdout = true;
130
+ deps.logger?.(`laya worker wrote a JSON value that is not an object: ${line.slice(0, 120)}`);
131
+ }
132
+ return;
133
+ }
134
+
135
+ const record = parsed as Record<string, unknown>;
136
+ if (typeof record["event"] === "string") {
137
+ const event = record["event"];
138
+ if (event === "ready") {
139
+ ready = {
140
+ version: typeof record["version"] === "string" ? record["version"] : "unknown",
141
+ loaded: Array.isArray(record["loaded"])
142
+ ? record["loaded"].filter((name): name is string => typeof name === "string")
143
+ : [],
144
+ };
145
+ const waiters = readyWaiters;
146
+ readyWaiters = [];
147
+ for (const waiter of waiters) waiter.resolve(ready);
148
+ } else if (event === "error") {
149
+ const proc = child;
150
+ if (proc) failChild(proc, `laya worker reported an error: ${String(record["error"])}`);
151
+ } else {
152
+ deps.logger?.(`laya worker sent an unknown event: ${event}`);
153
+ }
154
+ return;
155
+ }
156
+
157
+ const id = record["id"];
158
+ if (typeof id !== "number" || !Number.isInteger(id)) {
159
+ deps.logger?.(`laya worker sent a reply without a numeric id: ${line.slice(0, 120)}`);
160
+ return;
161
+ }
162
+ const call = pending.get(id);
163
+ if (!call) {
164
+ // A late reply to a call that timed out, aborted, or outlived a dispose.
165
+ if (child) deps.logger?.(`laya worker replied to unknown request id ${id}`);
166
+ return;
167
+ }
168
+ if (record["ok"] === true) {
169
+ call.resolve(record["result"]);
170
+ return;
171
+ }
172
+ const kind = record["kind"];
173
+ const code: ClassifierErrorCode = kind === "timeout" || kind === "unavailable" || kind === "protocol" ? kind : "unavailable";
174
+ call.reject(
175
+ new ClassifierError(BACKEND, code, `laya worker failed: ${String(record["error"] ?? "unknown error")}`),
176
+ );
177
+ }
178
+
179
+ function handleStderrLine(line: string): void {
180
+ deps.logger?.(`laya worker: ${line}`);
181
+ }
182
+
183
+ function ensureChild(): ChildProcessLike {
184
+ if (child) return child;
185
+ const proc = spawnImpl(config.pythonBin, [workerScript, ...spawnArgs()], { env: childEnv() });
186
+ child = proc;
187
+ ready = null;
188
+ warnedStdout = false;
189
+ if (proc.stdout) {
190
+ void pump(proc.stdout, (line) => {
191
+ if (child === proc) handleStdoutLine(line);
192
+ });
193
+ }
194
+ if (proc.stderr) {
195
+ void pump(proc.stderr, (line) => {
196
+ if (child === proc) handleStderrLine(line);
197
+ });
198
+ }
199
+ proc.on("error", (error: unknown) => {
200
+ failChild(proc, `laya worker failed to start: ${error instanceof Error ? error.message : String(error)}`);
201
+ });
202
+ proc.on("exit", (code: unknown) => {
203
+ failChild(proc, `laya worker exited (${typeof code === "number" ? code : "signal"})`);
204
+ });
205
+ return proc;
206
+ }
207
+
208
+ function waitReady(timeoutMs: number, signal?: AbortSignal): Promise<ReadyInfo> {
209
+ const current = ready;
210
+ if (current) return Promise.resolve(current);
211
+ return new Promise<ReadyInfo>((resolve, reject) => {
212
+ let timer: NodeJS.Timeout;
213
+ const waiter: ReadyWaiter = {
214
+ resolve: (value) => {
215
+ clearTimeout(timer);
216
+ signal?.removeEventListener("abort", onAbort);
217
+ resolve(value);
218
+ },
219
+ reject: (error) => {
220
+ clearTimeout(timer);
221
+ signal?.removeEventListener("abort", onAbort);
222
+ reject(error);
223
+ },
224
+ };
225
+ const onAbort = (): void => {
226
+ readyWaiters = readyWaiters.filter((entry) => entry !== waiter);
227
+ waiter.reject(new ClassifierError(BACKEND, "aborted", "laya warmup aborted by caller"));
228
+ };
229
+ timer = setTimeout(() => {
230
+ readyWaiters = readyWaiters.filter((entry) => entry !== waiter);
231
+ waiter.reject(new ClassifierError(BACKEND, "timeout", `laya worker not ready within ${timeoutMs}ms`));
232
+ }, timeoutMs);
233
+ readyWaiters.push(waiter);
234
+ if (signal) {
235
+ if (signal.aborted) onAbort();
236
+ else signal.addEventListener("abort", onAbort, { once: true });
237
+ }
238
+ });
239
+ }
240
+
241
+ function request(
242
+ op: string,
243
+ payload: Record<string, unknown>,
244
+ timeoutMs: number,
245
+ signal?: AbortSignal,
246
+ ): Promise<unknown> {
247
+ return new Promise<unknown>((resolve, reject) => {
248
+ let proc: ChildProcessLike;
249
+ try {
250
+ proc = ensureChild();
251
+ } catch (error) {
252
+ reject(
253
+ new ClassifierError(
254
+ BACKEND,
255
+ "unavailable",
256
+ `could not spawn laya worker: ${error instanceof Error ? error.message : String(error)}`,
257
+ ),
258
+ );
259
+ return;
260
+ }
261
+
262
+ const id = nextId++;
263
+ let timer: NodeJS.Timeout;
264
+ const cleanup = (): void => {
265
+ clearTimeout(timer);
266
+ signal?.removeEventListener("abort", onAbort);
267
+ };
268
+ const failWith = (code: ClassifierErrorCode, message: string): void => {
269
+ if (!pending.has(id)) return;
270
+ pending.delete(id);
271
+ cleanup();
272
+ reject(new ClassifierError(BACKEND, code, message));
273
+ };
274
+ const onAbort = (): void => failWith("aborted", `laya ${op} aborted by caller`);
275
+
276
+ pending.set(id, {
277
+ resolve: (value) => {
278
+ if (!pending.has(id)) return;
279
+ pending.delete(id);
280
+ cleanup();
281
+ resolve(value);
282
+ },
283
+ reject: (error) => {
284
+ if (!pending.has(id)) return;
285
+ pending.delete(id);
286
+ cleanup();
287
+ reject(error);
288
+ },
289
+ });
290
+ timer = setTimeout(() => failWith("timeout", `laya ${op} exceeded ${timeoutMs}ms`), timeoutMs);
291
+ if (signal) {
292
+ if (signal.aborted) onAbort();
293
+ else signal.addEventListener("abort", onAbort, { once: true });
294
+ }
295
+
296
+ try {
297
+ proc.stdin.write(`${JSON.stringify({ id, op, ...payload })}\n`);
298
+ } catch (error) {
299
+ failWith("unavailable", `laya worker stdin closed: ${error instanceof Error ? error.message : String(error)}`);
300
+ }
301
+ });
302
+ }
303
+
304
+ return {
305
+ name: BACKEND,
306
+
307
+ async warmup(options?: ClassifyOptions): Promise<void> {
308
+ ensureChild();
309
+ await waitReady(config.warmupTimeoutMs, options?.signal);
310
+ if (config.preload) await request("preload", {}, config.warmupTimeoutMs, options?.signal);
311
+ },
312
+
313
+ async classify(
314
+ state: ClassifierState,
315
+ questions: Questions,
316
+ options?: ClassifyOptions,
317
+ ): Promise<ClassificationResult> {
318
+ const value = await request("predict", { state, questions }, config.timeoutMs, options?.signal);
319
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
320
+ throw new ClassifierError(BACKEND, "protocol", "laya worker returned no result object");
321
+ }
322
+ const record = value as Record<string, unknown>;
323
+ const answers = record["answers"];
324
+ if (answers === null || typeof answers !== "object" || Array.isArray(answers)) {
325
+ throw new ClassifierError(BACKEND, "protocol", "laya worker returned no answers object");
326
+ }
327
+ const result: ClassificationResult = { answers: answers as Answers };
328
+ if (typeof record["model"] === "string") result.model = record["model"];
329
+ const usage = record["usage"];
330
+ if (usage !== null && typeof usage === "object" && !Array.isArray(usage)) {
331
+ result.usage = usage as ClassificationResult["usage"];
332
+ }
333
+ return result;
334
+ },
335
+
336
+ async dispose(): Promise<void> {
337
+ const proc = child;
338
+ if (!proc) return;
339
+ // Detach first so the imminent exit event cannot tear down a respawn.
340
+ child = null;
341
+ ready = null;
342
+ const error = new ClassifierError(BACKEND, "unavailable", "laya worker disposed");
343
+ for (const call of [...pending.values()]) call.reject(error);
344
+ const waiters = readyWaiters;
345
+ readyWaiters = [];
346
+ for (const waiter of waiters) waiter.reject(error);
347
+ try {
348
+ proc.stdin.write(`${JSON.stringify({ id: nextId++, op: "shutdown" })}\n`);
349
+ } catch {
350
+ // Best effort: the child may already be gone.
351
+ }
352
+ try {
353
+ proc.kill();
354
+ } catch {
355
+ // Best effort.
356
+ }
357
+ },
358
+ };
359
+ }
360
+
361
+ /** Split an async byte/string stream into lines, tolerating chunk boundaries. */
362
+ async function pump(
363
+ stream: AsyncIterable<Uint8Array | string>,
364
+ onLine: (line: string) => void,
365
+ ): Promise<void> {
366
+ let buffer = "";
367
+ try {
368
+ for await (const chunk of stream) {
369
+ buffer += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
370
+ let index = buffer.indexOf("\n");
371
+ while (index >= 0) {
372
+ const line = buffer.slice(0, index).replace(/\r$/, "");
373
+ buffer = buffer.slice(index + 1);
374
+ if (line.trim() !== "") onLine(line);
375
+ index = buffer.indexOf("\n");
376
+ }
377
+ }
378
+ const tail = buffer.trim();
379
+ if (tail !== "") onLine(tail);
380
+ } catch {
381
+ // Stream teardown; the child exit/error handler reports the failure.
382
+ }
383
+ }
package/src/config.ts ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Config discovery, defaults, merge, and validation.
3
+ *
4
+ * Precedence is project over global; the first existing, parseable, valid file
5
+ * wins. A file that fails to read or parse is reported and skipped, but a file
6
+ * that parses yet violates the contract is rejected outright: silently
7
+ * mis-routing is worse than falling back to the session's own model.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+
14
+ import type { Question, RouterConfig, RouterConfigInput } from "./types.ts";
15
+
16
+ export interface ConfigSource {
17
+ path: string;
18
+ scope: "project" | "global";
19
+ }
20
+
21
+ export interface ConfigLoadResult {
22
+ config: RouterConfig | null;
23
+ source: ConfigSource | null;
24
+ errors: string[];
25
+ }
26
+
27
+ /** Project config first, global config second. */
28
+ export function configPaths(cwd: string, home: string): ConfigSource[] {
29
+ return [
30
+ { path: path.join(cwd, ".omp", "class-router.json"), scope: "project" },
31
+ { path: path.join(home, ".omp", "class-router.json"), scope: "global" },
32
+ ];
33
+ }
34
+
35
+ /** A complete, valid configuration. Fresh objects on every call. */
36
+ export function defaultConfig(): RouterConfig {
37
+ return {
38
+ enabled: true,
39
+ backend: "jev",
40
+ jev: {
41
+ endpoint: "https://api.typesafe.ai/v1/systemone",
42
+ model: "jev-latest",
43
+ apiKeyEnvVar: "TYPESAFE_API_KEY",
44
+ timeoutMs: 3000,
45
+ },
46
+ laya: {
47
+ // HTTP transport is opt-in; the default spawns the local sidecar.
48
+ transport: "python",
49
+ endpoint: "",
50
+ apiKeyEnvVar: "",
51
+ pythonBin: "python3",
52
+ workerScript: "python/laya_worker.py",
53
+ repo: "convaiinnovations/laya",
54
+ subfolder: null,
55
+ device: null,
56
+ router: true,
57
+ preload: true,
58
+ timeoutMs: 4000,
59
+ warmupTimeoutMs: 600000,
60
+ hfTokenEnvVar: "HF_TOKEN",
61
+ },
62
+ routing: {
63
+ questions: {
64
+ task_complexity: {
65
+ type: "choice",
66
+ instructions: "How demanding is this request for an AI coding agent?",
67
+ criteria: {
68
+ trivial: "a single lookup, rename, or one-line answer; no exploration",
69
+ standard: "a normal multi-step edit or investigation inside one or two files",
70
+ hard: "large refactor, cross-cutting design, deep debugging, or long multi-file reasoning",
71
+ },
72
+ },
73
+ },
74
+ primaryQuestion: "task_complexity",
75
+ modelMapping: { trivial: "@smol", standard: "@default", hard: "@slow" },
76
+ fallbackChains: {
77
+ "@smol": ["@smol", "@default"],
78
+ "@default": ["@default", "@slow"],
79
+ "@slow": ["@slow", "@default"],
80
+ },
81
+ confidenceThreshold: 0.5,
82
+ defaultCategory: "standard",
83
+ },
84
+ circuitBreaker: { failureThreshold: 3, cooldownMs: 120000, halfOpenMaxTrials: 1 },
85
+ dryRun: false,
86
+ notify: true,
87
+ applyTo: "all",
88
+ };
89
+ }
90
+
91
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
92
+ return typeof value === "object" && value !== null && !Array.isArray(value);
93
+ }
94
+
95
+ function isNonEmptyString(value: unknown): value is string {
96
+ return typeof value === "string" && value.trim() !== "";
97
+ }
98
+
99
+ /**
100
+ * Per-key merge for the routing maps. A non-empty plain object merges over the
101
+ * defaults; anything else (including an explicitly empty object) replaces them,
102
+ * so validation can reject the intent instead of silently keeping defaults.
103
+ */
104
+ function mergeKeyMap<T>(base: Record<string, T>, override: Record<string, T> | undefined): Record<string, T> {
105
+ if (override === undefined) return { ...base };
106
+ if (!isPlainObject(override) || Object.keys(override).length === 0) return override;
107
+ return { ...base, ...(override as Record<string, T>) };
108
+ }
109
+
110
+ /**
111
+ * Layer a partial input over defaults. Sections merge one level deep and the
112
+ * per-key maps (`questions`, `modelMapping`, `fallbackChains`) merge by key;
113
+ * arrays and scalars replace wholesale. The input is never mutated.
114
+ */
115
+ function mergeConfig(base: RouterConfig, input: RouterConfigInput): RouterConfig {
116
+ const inputRouting = input.routing;
117
+ const routing = base.routing;
118
+ return {
119
+ enabled: input.enabled ?? base.enabled,
120
+ backend: input.backend ?? base.backend,
121
+ dryRun: input.dryRun ?? base.dryRun,
122
+ notify: input.notify ?? base.notify,
123
+ applyTo: input.applyTo ?? base.applyTo,
124
+ jev: { ...base.jev, ...(input.jev ?? {}) },
125
+ laya: { ...base.laya, ...(input.laya ?? {}) },
126
+ circuitBreaker: { ...base.circuitBreaker, ...(input.circuitBreaker ?? {}) },
127
+ routing: {
128
+ primaryQuestion: inputRouting?.primaryQuestion ?? routing.primaryQuestion,
129
+ confidenceThreshold: inputRouting?.confidenceThreshold ?? routing.confidenceThreshold,
130
+ // `null` is a meaningful value here, so an explicit null must survive `??`.
131
+ defaultCategory:
132
+ inputRouting?.defaultCategory !== undefined ? inputRouting.defaultCategory : routing.defaultCategory,
133
+ questions: mergeKeyMap(routing.questions, inputRouting?.questions),
134
+ modelMapping: mergeKeyMap(routing.modelMapping, inputRouting?.modelMapping),
135
+ fallbackChains: mergeKeyMap(routing.fallbackChains, inputRouting?.fallbackChains),
136
+ },
137
+ };
138
+ }
139
+
140
+ function validateConfig(config: RouterConfig): string[] {
141
+ const errors: string[] = [];
142
+
143
+ if (config.backend !== "jev" && config.backend !== "laya") {
144
+ errors.push(`backend must be "jev" or "laya", got ${JSON.stringify(config.backend)}`);
145
+ }
146
+
147
+ const routing = config.routing;
148
+ const questions = routing.questions;
149
+ if (!isPlainObject(questions) || Object.keys(questions).length === 0) {
150
+ errors.push("routing.questions must be a non-empty object of questions");
151
+ } else if (!(routing.primaryQuestion in questions)) {
152
+ errors.push(
153
+ `routing.primaryQuestion ${JSON.stringify(routing.primaryQuestion)} is not present in routing.questions`,
154
+ );
155
+ } else {
156
+ // Validate the merged question so a bad override cannot slip through.
157
+ const primary: Question | undefined = questions[routing.primaryQuestion];
158
+ if (primary === undefined || !isPlainObject(primary) || primary.type !== "choice") {
159
+ errors.push(
160
+ `routing.questions[${JSON.stringify(routing.primaryQuestion)}].type must be "choice"; ` +
161
+ "score and noul answers cannot name a model category",
162
+ );
163
+ }
164
+ }
165
+
166
+ const mapping = routing.modelMapping;
167
+ if (!isPlainObject(mapping) || Object.keys(mapping).length === 0) {
168
+ errors.push("routing.modelMapping must be a non-empty object mapping categories to model specs");
169
+ } else {
170
+ for (const [category, spec] of Object.entries(mapping)) {
171
+ if (typeof spec !== "string") {
172
+ errors.push(`routing.modelMapping[${JSON.stringify(category)}] must be a string, got ${typeof spec}`);
173
+ }
174
+ }
175
+ }
176
+
177
+ const threshold = routing.confidenceThreshold;
178
+ if (typeof threshold !== "number" || !Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
179
+ errors.push(`routing.confidenceThreshold must be a number in [0, 1], got ${JSON.stringify(threshold)}`);
180
+ }
181
+
182
+ const chains = routing.fallbackChains;
183
+ if (!isPlainObject(chains)) {
184
+ errors.push("routing.fallbackChains must be an object of spec -> spec[] chains");
185
+ } else {
186
+ for (const [spec, chain] of Object.entries(chains)) {
187
+ if (!Array.isArray(chain) || chain.length === 0 || !chain.every(isNonEmptyString)) {
188
+ errors.push(
189
+ `routing.fallbackChains[${JSON.stringify(spec)}] must be a non-empty array of non-empty strings`,
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ const breaker = config.circuitBreaker;
196
+ if (typeof breaker.failureThreshold !== "number" || breaker.failureThreshold < 1) {
197
+ errors.push(`circuitBreaker.failureThreshold must be >= 1, got ${JSON.stringify(breaker.failureThreshold)}`);
198
+ }
199
+ if (typeof breaker.cooldownMs !== "number" || breaker.cooldownMs < 0) {
200
+ errors.push(`circuitBreaker.cooldownMs must be >= 0, got ${JSON.stringify(breaker.cooldownMs)}`);
201
+ }
202
+ if (typeof breaker.halfOpenMaxTrials !== "number" || breaker.halfOpenMaxTrials < 1) {
203
+ errors.push(`circuitBreaker.halfOpenMaxTrials must be >= 1, got ${JSON.stringify(breaker.halfOpenMaxTrials)}`);
204
+ }
205
+
206
+ if (typeof config.jev.timeoutMs !== "number" || config.jev.timeoutMs <= 0) {
207
+ errors.push(`jev.timeoutMs must be > 0, got ${JSON.stringify(config.jev.timeoutMs)}`);
208
+ }
209
+ if (typeof config.laya.timeoutMs !== "number" || config.laya.timeoutMs <= 0) {
210
+ errors.push(`laya.timeoutMs must be > 0, got ${JSON.stringify(config.laya.timeoutMs)}`);
211
+ }
212
+
213
+ const transport = config.laya.transport;
214
+ if (transport !== "python" && transport !== "http") {
215
+ errors.push(`laya.transport must be "python" or "http", got ${JSON.stringify(transport)}`);
216
+ } else if (transport === "http") {
217
+ const endpoint = config.laya.endpoint;
218
+ if (!isNonEmptyString(endpoint)) {
219
+ errors.push('laya.endpoint must be a non-empty URL when laya.transport is "http"');
220
+ } else {
221
+ let parsed: URL | null = null;
222
+ try {
223
+ parsed = new URL(endpoint);
224
+ } catch {
225
+ parsed = null;
226
+ }
227
+ if (parsed === null || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
228
+ errors.push(`laya.endpoint must be an http(s) URL, got ${JSON.stringify(endpoint)}`);
229
+ }
230
+ }
231
+ }
232
+
233
+ return errors;
234
+ }
235
+
236
+ /**
237
+ * Walk the config paths in precedence order. Missing files everywhere resolve
238
+ * to a null config without error; read/parse failures are recorded and the
239
+ * search continues; a contract-violating file is rejected with a null config.
240
+ */
241
+ export function loadConfig(
242
+ cwd: string,
243
+ home: string = os.homedir(),
244
+ env: Record<string, string | undefined> = process.env,
245
+ ): ConfigLoadResult {
246
+ void env;
247
+ const errors: string[] = [];
248
+
249
+ for (const source of configPaths(cwd, home)) {
250
+ let raw: string;
251
+ try {
252
+ raw = fs.readFileSync(source.path, "utf8");
253
+ } catch (error) {
254
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
255
+ const message = error instanceof Error ? error.message : String(error);
256
+ errors.push(`${source.scope} config ${source.path}: cannot read file: ${message}`);
257
+ }
258
+ continue;
259
+ }
260
+
261
+ let parsed: unknown;
262
+ try {
263
+ parsed = JSON.parse(raw);
264
+ } catch (error) {
265
+ const message = error instanceof Error ? error.message : String(error);
266
+ errors.push(`${source.scope} config ${source.path}: invalid JSON: ${message}`);
267
+ continue;
268
+ }
269
+
270
+ if (!isPlainObject(parsed)) {
271
+ errors.push(`${source.scope} config ${source.path}: expected a JSON object at the top level`);
272
+ continue;
273
+ }
274
+
275
+ const config = mergeConfig(defaultConfig(), parsed as RouterConfigInput);
276
+ const validationErrors = validateConfig(config);
277
+ if (validationErrors.length > 0) {
278
+ for (const message of validationErrors) {
279
+ errors.push(`${source.scope} config ${source.path}: ${message}`);
280
+ }
281
+ return { config: null, source: null, errors };
282
+ }
283
+
284
+ return { config, source, errors };
285
+ }
286
+
287
+ return { config: null, source: null, errors };
288
+ }