@klhapp/skillmux 1.11.2 → 1.13.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.
Files changed (104) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +20 -10
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +15 -5
  6. package/docs/concepts.md +4 -3
  7. package/docs/configuration.md +22 -5
  8. package/docs/deployment.md +3 -3
  9. package/docs/getting-started.md +32 -7
  10. package/docs/releasing.md +17 -2
  11. package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
  12. package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
  13. package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
  14. package/docs/sdd/cli-remote-target-parity/think.md +183 -0
  15. package/docs/sdd/cli-surface-consistency/think.md +149 -0
  16. package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
  17. package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
  18. package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
  19. package/docs/sdd/feat-packaging-deployment/review.md +95 -0
  20. package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
  21. package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
  22. package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
  23. package/docs/sdd/npm-native-binaries/review.md +103 -0
  24. package/docs/sdd/npm-native-binaries/spec.md +148 -0
  25. package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
  26. package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
  27. package/docs/sdd/runtime-resource-hardening/review.md +86 -0
  28. package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
  29. package/docs/sdd/security-hardening-phase/think.md +129 -0
  30. package/docs/sdd/security-observability/review.md +96 -0
  31. package/docs/sdd/security-observability/spec.md +55 -0
  32. package/docs/sdd/skill-provenance-update/review.md +152 -0
  33. package/docs/sdd/skill-provenance-update/schema.json +193 -0
  34. package/docs/sdd/skill-provenance-update/spec.md +134 -0
  35. package/docs/sdd/supply-chain-hardening/review.md +50 -0
  36. package/docs/sdd/supply-chain-hardening/spec.md +42 -0
  37. package/docs/sdd/target-marker-rehome/spec.md +33 -0
  38. package/docs/skill-management.md +1 -1
  39. package/docs/troubleshooting.md +18 -2
  40. package/package.json +15 -8
  41. package/src/adapters.ts +0 -438
  42. package/src/audit.ts +0 -21
  43. package/src/cli.ts +0 -928
  44. package/src/clients.ts +0 -368
  45. package/src/commands/audit.ts +0 -85
  46. package/src/commands/config.ts +0 -224
  47. package/src/commands/context.ts +0 -104
  48. package/src/commands/core.ts +0 -56
  49. package/src/commands/doctor.ts +0 -97
  50. package/src/commands/eval.ts +0 -88
  51. package/src/commands/init.ts +0 -672
  52. package/src/commands/install.ts +0 -155
  53. package/src/commands/local-vault.ts +0 -60
  54. package/src/commands/models.ts +0 -10
  55. package/src/commands/outdated.ts +0 -115
  56. package/src/commands/project.ts +0 -580
  57. package/src/commands/report.ts +0 -66
  58. package/src/commands/scan.ts +0 -71
  59. package/src/commands/shared.ts +0 -38
  60. package/src/commands/skill.ts +0 -33
  61. package/src/commands/sync.ts +0 -233
  62. package/src/commands/target.ts +0 -225
  63. package/src/commands/update.ts +0 -277
  64. package/src/completions.ts +0 -196
  65. package/src/concurrency-limiter.ts +0 -61
  66. package/src/config-service.ts +0 -398
  67. package/src/config-watcher.ts +0 -253
  68. package/src/config.ts +0 -583
  69. package/src/context.ts +0 -188
  70. package/src/db-audit.ts +0 -286
  71. package/src/db-index.ts +0 -238
  72. package/src/db.ts +0 -3
  73. package/src/deployment.ts +0 -39
  74. package/src/doctor.ts +0 -205
  75. package/src/eval.ts +0 -302
  76. package/src/global-flags.ts +0 -46
  77. package/src/init-agents.ts +0 -329
  78. package/src/init-instructions.ts +0 -192
  79. package/src/init.ts +0 -389
  80. package/src/install.ts +0 -232
  81. package/src/lifecycle.ts +0 -51
  82. package/src/logger.ts +0 -26
  83. package/src/manifest.ts +0 -352
  84. package/src/mcp-registration.ts +0 -89
  85. package/src/metrics.ts +0 -121
  86. package/src/models.ts +0 -20
  87. package/src/output.ts +0 -214
  88. package/src/project-setup.ts +0 -36
  89. package/src/prompts.ts +0 -124
  90. package/src/provenance.ts +0 -99
  91. package/src/rate-limiter.ts +0 -137
  92. package/src/readiness.ts +0 -30
  93. package/src/redact.ts +0 -52
  94. package/src/router-core.ts +0 -666
  95. package/src/rrf.ts +0 -31
  96. package/src/scan.ts +0 -300
  97. package/src/server.ts +0 -930
  98. package/src/setup.ts +0 -145
  99. package/src/snapshot.ts +0 -135
  100. package/src/stats.ts +0 -340
  101. package/src/sync.ts +0 -482
  102. package/src/toml-writer.ts +0 -51
  103. package/src/types.ts +0 -212
  104. package/src/vault.ts +0 -207
package/src/config.ts DELETED
@@ -1,583 +0,0 @@
1
- import { existsSync, mkdirSync, renameSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
- import { z } from "zod";
5
- import type { Config, ONNXDevice, ONNXDtype } from "./types";
6
-
7
- const onnxDeviceSchema = z.enum([
8
- "cpu", "auto", "gpu", "wasm", "webgpu", "cuda", "dml", "coreml",
9
- "webnn", "webnn-npu", "webnn-gpu", "webnn-cpu",
10
- ]);
11
- const onnxDtypeSchema = z.enum([
12
- "q8", "auto", "fp32", "fp16", "int8", "uint8", "q4", "bnb4", "q4f16",
13
- "q2", "q2f16", "q1", "q1f16",
14
- ]);
15
-
16
- const modelSchema = z.object({
17
- model: z.string().min(1),
18
- device: onnxDeviceSchema.optional(),
19
- dtype: onnxDtypeSchema.optional(),
20
- }).strict();
21
-
22
- const remoteThresholdsSchema = z.object({
23
- match_score: z.number(),
24
- match_margin: z.number().nonnegative(),
25
- candidate_floor: z.number(),
26
- }).strict();
27
-
28
- const configSchema = z.object({
29
- config: z.object({
30
- environment_overrides: z.boolean().default(true),
31
- }).strict().optional(),
32
- vault_path: z.string().min(1),
33
- local_vault_paths: z.array(z.string()),
34
- state_dir: z.string().min(1),
35
- recall: z.object({
36
- k_lexical: z.number().int().positive(),
37
- k_vector: z.number().int().positive(),
38
- k_rerank: z.number().int().positive().optional(),
39
- }).strict().transform((r) => ({
40
- ...r,
41
- k_rerank: r.k_rerank ?? Math.min(10, r.k_lexical + r.k_vector),
42
- })).refine((r) => r.k_rerank <= r.k_lexical + r.k_vector, {
43
- message: "recall.k_rerank cannot exceed k_lexical + k_vector",
44
- }),
45
- output: z.object({
46
- top_k: z.number().int().positive().default(10),
47
- max_top_k: z.number().int().positive().default(50),
48
- }).strict().refine((o) => o.top_k <= o.max_top_k, {
49
- message: "output.top_k cannot exceed output.max_top_k",
50
- }).default({ top_k: 10, max_top_k: 50 }),
51
- inference: z.discriminatedUnion("mode", [
52
- z.object({
53
- mode: z.literal("local"),
54
- bundle: z.string().min(1),
55
- models_dir: z.string().min(1),
56
- embedding: modelSchema.extend({ dimension: z.number().int().positive() }),
57
- }).strict(),
58
- z.object({
59
- mode: z.literal("remote"),
60
- timeout_ms: z.number().int().min(100),
61
- embedding: z.object({
62
- provider: z.literal("openai"),
63
- endpoint: z.url(),
64
- model: z.string().min(1),
65
- dimension: z.number().int().positive(),
66
- api_key_env: z.string().min(1).optional(),
67
- }).strict(),
68
- reranker: z.object({
69
- adapter: z.enum(["jina-v1", "bifrost-v1"]),
70
- endpoint: z.url(),
71
- model: z.string().min(1),
72
- api_key_env: z.string().min(1).optional(),
73
- }).strict().optional(),
74
- }).strict(),
75
- ]),
76
- server: z.object({
77
- auth_enabled: z.boolean(),
78
- auth_token_env: z.string().min(1),
79
- allowed_origins: z.array(z.string()),
80
- hostname: z.string().min(1).optional(),
81
- rate_limit: z.object({
82
- enabled: z.boolean(),
83
- requests_per_minute: z.number().int().positive(),
84
- trust_proxy: z.boolean().optional(),
85
- }).strict().optional(),
86
- admin: z.object({
87
- enabled: z.boolean(),
88
- token_env: z.string().min(1),
89
- }).strict().optional(),
90
- max_body_bytes: z.number().int().positive().optional(),
91
- max_concurrent_requests: z.number().int().min(0).optional(),
92
- }).strict().optional(),
93
- audit: z.object({
94
- retention_days: z.number().int().min(0).default(90),
95
- }).strict().default({ retention_days: 90 }),
96
- egress: z.object({
97
- allowed_hosts: z.array(z.string().min(1)).optional(),
98
- }).strict().optional(),
99
- }).strict().refine((cfg) => {
100
- const hasReranker = cfg.inference.mode === "remote" && !!cfg.inference.reranker;
101
- if (hasReranker && cfg.output.max_top_k > cfg.recall.k_rerank) {
102
- return false;
103
- }
104
- return true;
105
- }, {
106
- message: "output.max_top_k cannot exceed recall.k_rerank when reranking is enabled",
107
- });
108
-
109
- // Fallback values only; a config.toml (SKILLMUX_CONFIG or default path)
110
- // overrides them. The local bundle is the zero-config OSS path.
111
- export const LOCAL_BUNDLE_ID = "gte-small-v1";
112
-
113
- const DEFAULTS: Config = {
114
- config: {
115
- environment_overrides: true,
116
- },
117
- vault_path: "~/skills",
118
- local_vault_paths: [],
119
- state_dir: "~/.local/state/skillmux",
120
- recall: { k_lexical: 20, k_vector: 20, k_rerank: 10 },
121
- output: { top_k: 10, max_top_k: 50 },
122
- inference: {
123
- mode: "local",
124
- bundle: LOCAL_BUNDLE_ID,
125
- models_dir: "~/.cache/skillmux/models",
126
- embedding: {
127
- model: "Xenova/gte-small",
128
- dimension: 384,
129
- device: "cpu",
130
- dtype: "q8",
131
- },
132
- },
133
- server: {
134
- auth_enabled: false,
135
- auth_token_env: "SKILLMUX_AUTH_TOKEN",
136
- // Deny-by-default CORS: only requests that send no Origin header (curl,
137
- // MCP clients, server-to-server) pass; browser-issued cross-origin
138
- // requests are rejected until an origin is explicitly allow-listed.
139
- allowed_origins: [],
140
- // Loopback-only by default so a zero-config `skillmux serve --transport http`
141
- // isn't reachable from the network. Docker overrides this to 0.0.0.0
142
- // below since the container's own loopback isn't reachable through
143
- // port-mapping.
144
- hostname: "127.0.0.1",
145
- rate_limit: {
146
- enabled: false,
147
- requests_per_minute: 60,
148
- },
149
- },
150
- audit: {
151
- retention_days: 90,
152
- },
153
- };
154
-
155
- export const DEFAULT_CONFIG_PATH = "~/.config/skillmux/config.toml";
156
-
157
- export function embeddingDimension(config: Config): number {
158
- return config.inference.embedding.dimension;
159
- }
160
-
161
- export function embeddingFingerprint(config: Config): string {
162
- const inference = config.inference;
163
- const implementation =
164
- inference.mode === "local" ? `local:${inference.bundle}` : `remote:${inference.embedding.provider}`;
165
- return `${implementation}:${inference.embedding.model}:${inference.embedding.dimension}`;
166
- }
167
-
168
- export function rerankerFingerprint(config: Config): string | undefined {
169
- const inference = config.inference;
170
- if (inference.mode !== "remote" || !inference.reranker) return undefined;
171
- return `remote:${inference.reranker.adapter}:${inference.reranker.model}`;
172
- }
173
-
174
- export function expandHome(path: string): string {
175
- return path.startsWith("~") ? join(homedir(), path.slice(1)) : path;
176
- }
177
-
178
- /**
179
- * True only for hostnames the HTTP server can bind while staying unreachable
180
- * from outside this machine. Deliberately narrower than adapters.ts's
181
- * isLoopbackHost, which treats "0.0.0.0" as loopback for a different question
182
- * (whether an admin *client* is talking to the local machine) — here "0.0.0.0"
183
- * (and any other wildcard/public address) must read as non-loopback, since
184
- * binding it is exactly what makes the server reachable from outside (SMX-91).
185
- */
186
- export function isLoopbackBindHost(hostname: string): boolean {
187
- return hostname === "localhost" || hostname === "::1" || hostname === "127.0.0.1" || hostname.startsWith("127.");
188
- }
189
-
190
- function isPlainObject(value: unknown): value is Record<string, unknown> {
191
- return typeof value === "object" && value !== null && !Array.isArray(value);
192
- }
193
-
194
- function deepMerge<T>(base: T, override: unknown): T {
195
- if (!isPlainObject(base) || !isPlainObject(override)) {
196
- return (override === undefined ? base : override) as T;
197
- }
198
- const out: Record<string, unknown> = { ...base };
199
- for (const [key, value] of Object.entries(override)) {
200
- out[key] = deepMerge((base as Record<string, unknown>)[key], value);
201
- }
202
- return out as T;
203
- }
204
-
205
- export const warnedEnv = new Set<string>();
206
-
207
- function migrateLegacyDir(legacy: string, next: string): void {
208
- const legacyPath = expandHome(legacy);
209
- const nextPath = expandHome(next);
210
- if (existsSync(nextPath) || !existsSync(legacyPath)) return;
211
- mkdirSync(dirname(nextPath), { recursive: true });
212
- renameSync(legacyPath, nextPath);
213
- console.error(`skillmux: migrated ${legacyPath} -> ${nextPath}`);
214
- }
215
-
216
- export function migrateLegacyPaths(): void {
217
- migrateLegacyDir("~/.config/skill-router", "~/.config/skillmux");
218
- migrateLegacyDir("~/.local/state/skill-router", "~/.local/state/skillmux");
219
- migrateLegacyDir("~/.cache/skill-router", "~/.cache/skillmux");
220
- }
221
-
222
- export function resolveConfigPath(path?: string): string {
223
- let configEnv = process.env.SKILLMUX_CONFIG;
224
- if (configEnv === undefined && process.env.SKILL_ROUTER_CONFIG !== undefined) {
225
- if (!warnedEnv.has("SKILL_ROUTER_CONFIG")) {
226
- warnedEnv.add("SKILL_ROUTER_CONFIG");
227
- console.error("skillmux: SKILL_ROUTER_CONFIG is deprecated, use SKILLMUX_CONFIG instead");
228
- }
229
- configEnv = process.env.SKILL_ROUTER_CONFIG;
230
- }
231
- return expandHome(path ?? configEnv ?? DEFAULT_CONFIG_PATH);
232
- }
233
-
234
- export async function loadConfig(path?: string): Promise<Config> {
235
- migrateLegacyPaths();
236
- const removedRerankerEnv = [
237
- "SKILLMUX_RERANK_BASE_URL",
238
- "SKILL_ROUTER_RERANK_BASE_URL",
239
- "RERANK_BASE_URL",
240
- ].find((name) => process.env[name] !== undefined);
241
- if (removedRerankerEnv) {
242
- throw new Error(
243
- `${removedRerankerEnv} is no longer supported. Configure ` +
244
- "inference.reranker.endpoint with the complete request URL and " +
245
- 'inference.reranker.adapter (for example, "jina-v1"). The old client appended /rerank.',
246
- );
247
- }
248
- const removedEmbeddingEnv = [
249
- "SKILLMUX_EMBED_BASE_URL",
250
- "SKILL_ROUTER_EMBED_BASE_URL",
251
- "EMBED_BASE_URL",
252
- ].find((name) => process.env[name] !== undefined);
253
- if (removedEmbeddingEnv) {
254
- throw new Error(
255
- `${removedEmbeddingEnv} is no longer supported. Configure ` +
256
- "inference.embedding.endpoint with the complete OpenAI-compatible embeddings request URL. " +
257
- "The old client appended /v1/embeddings.",
258
- );
259
- }
260
- const removedLegacyOutputEnv = [
261
- "SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
262
- "AMBIGUOUS_CANDIDATE_LIMIT",
263
- "SKILLMUX_CANDIDATE_LIMIT",
264
- "CANDIDATE_LIMIT",
265
- ].find((name) => process.env[name] !== undefined);
266
- if (removedLegacyOutputEnv) {
267
- throw new Error(
268
- `${removedLegacyOutputEnv} is no longer supported. Use SKILLMUX_OUTPUT_TOP_K instead.`,
269
- );
270
- }
271
-
272
- const configPath = resolveConfigPath(path);
273
- const file = Bun.file(expandHome(configPath));
274
-
275
- const baseConfig = structuredClone(DEFAULTS);
276
- if (process.env.RUNNING_IN_DOCKER === "true") {
277
- baseConfig.vault_path = "/vault";
278
- baseConfig.state_dir = "/data";
279
- if (baseConfig.inference.mode === "local") baseConfig.inference.models_dir = "/models";
280
- if (baseConfig.server) baseConfig.server.hostname = "0.0.0.0";
281
- }
282
-
283
- let merged: Config;
284
- if (!(await file.exists())) {
285
- merged = baseConfig;
286
- } else {
287
- const parsed = Bun.TOML.parse(await file.text()) as Record<string, unknown>;
288
- if ("thresholds" in parsed) {
289
- throw new Error(
290
- "The [thresholds] table is obsolete. Threshold calibration was removed; use [output] with top_k.",
291
- );
292
- }
293
- if (isPlainObject(parsed.output) && "ambiguous_candidate_limit" in parsed.output) {
294
- throw new Error(
295
- "output.ambiguous_candidate_limit is obsolete. Use output.top_k instead.",
296
- );
297
- }
298
- if (isPlainObject(parsed.inference) && "thresholds" in parsed.inference) {
299
- throw new Error(
300
- "inference.thresholds is obsolete. Threshold calibration was removed.",
301
- );
302
- }
303
- if (isPlainObject(parsed.inference) && "calibration" in parsed.inference) {
304
- throw new Error(
305
- "inference.calibration is obsolete and should be deleted. Threshold calibration was removed; use skillmux eval for ranking evaluation.",
306
- );
307
- }
308
- if ("embedding" in parsed || "rerank" in parsed || "remote_timeout_ms" in parsed) {
309
- throw new Error(
310
- "Legacy inference config is not supported. Move [embedding], [rerank], and remote_timeout_ms under [inference] using config.remote.example.toml.",
311
- );
312
- }
313
- const rawReranker = isPlainObject(parsed.inference)
314
- ? parsed.inference.reranker
315
- : undefined;
316
- const rawEmbedding = isPlainObject(parsed.inference)
317
- ? parsed.inference.embedding
318
- : undefined;
319
- if (
320
- isPlainObject(rawReranker) &&
321
- ("provider" in rawReranker || "base_url" in rawReranker)
322
- ) {
323
- throw new Error(
324
- "inference.reranker.provider and inference.reranker.base_url are no longer supported. " +
325
- "Use adapter and the complete endpoint URL instead; the old client appended /rerank.",
326
- );
327
- }
328
- if (isPlainObject(rawEmbedding) && "base_url" in rawEmbedding) {
329
- throw new Error(
330
- "inference.embedding.base_url is no longer supported. Use inference.embedding.endpoint " +
331
- "with the complete OpenAI-compatible embeddings request URL; the old client appended /v1/embeddings.",
332
- );
333
- }
334
- if (isPlainObject(parsed.inference) && parsed.inference.mode === "remote") {
335
- if (!isPlainObject(parsed.inference.embedding)) {
336
- throw new Error("Remote inference requires an inference.embedding section.");
337
- }
338
- }
339
-
340
- if (parsed.inference && typeof parsed.inference === "object" && "mode" in parsed.inference) {
341
- if (parsed.inference.mode === "remote") {
342
- const withoutInference = { ...parsed };
343
- delete withoutInference.inference;
344
- merged = {
345
- ...deepMerge(baseConfig, withoutInference),
346
- inference: configSchema.shape.inference.parse(parsed.inference),
347
- };
348
- } else {
349
- merged = deepMerge(baseConfig, parsed);
350
- }
351
- } else {
352
- merged = deepMerge(baseConfig, parsed);
353
- }
354
- }
355
-
356
- if (!merged.output) {
357
- merged.output = { top_k: 10, max_top_k: 50 };
358
- }
359
-
360
- // Warn about deprecated generic environment variables regardless of override policy
361
- const GENERIC_ENV_MAPPINGS: Record<string, string> = {
362
- VAULT_PATH: "SKILLMUX_VAULT_PATH",
363
- STATE_DIR: "SKILLMUX_STATE_DIR",
364
- RECALL_K_LEXICAL: "SKILLMUX_RECALL_K_LEXICAL",
365
- RECALL_K_VECTOR: "SKILLMUX_RECALL_K_VECTOR",
366
- RECALL_K_RERANK: "SKILLMUX_RECALL_K_RERANK",
367
- OUTPUT_TOP_K: "SKILLMUX_OUTPUT_TOP_K",
368
- OUTPUT_MAX_TOP_K: "SKILLMUX_OUTPUT_MAX_TOP_K",
369
- EMBED_MODEL: "SKILLMUX_EMBED_MODEL",
370
- EMBED_ENDPOINT: "SKILLMUX_EMBED_ENDPOINT",
371
- EMBED_DIMENSION: "SKILLMUX_EMBED_DIMENSION",
372
- EMBED_DEVICE: "SKILLMUX_EMBED_DEVICE",
373
- EMBED_DTYPE: "SKILLMUX_EMBED_DTYPE",
374
- RERANK_MODEL: "SKILLMUX_RERANK_MODEL",
375
- RERANK_ENDPOINT: "SKILLMUX_RERANK_ENDPOINT",
376
- RERANK_ADAPTER: "SKILLMUX_RERANK_ADAPTER",
377
- HTTP_AUTH_ENABLED: "SKILLMUX_HTTP_AUTH_ENABLED",
378
- HTTP_AUTH_TOKEN_ENV: "SKILLMUX_HTTP_AUTH_TOKEN_ENV",
379
- HTTP_ALLOWED_ORIGINS: "SKILLMUX_HTTP_ALLOWED_ORIGINS",
380
- HTTP_HOSTNAME: "SKILLMUX_HTTP_HOSTNAME",
381
- HTTP_RATE_LIMIT_ENABLED: "SKILLMUX_HTTP_RATE_LIMIT_ENABLED",
382
- HTTP_RATE_LIMIT_RPM: "SKILLMUX_HTTP_RATE_LIMIT_RPM",
383
- HTTP_RATE_LIMIT_TRUST_PROXY: "SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY",
384
- };
385
- for (const [generic, preferred] of Object.entries(GENERIC_ENV_MAPPINGS)) {
386
- if (process.env[generic] !== undefined && !warnedEnv.has(generic)) {
387
- warnedEnv.add(generic);
388
- console.error(`skillmux: ${generic} is deprecated, use ${preferred} instead`);
389
- }
390
- }
391
-
392
- const allowEnvOverrides = merged.config?.environment_overrides !== false;
393
-
394
- const getEnv = (newPrefixed: string, unprefixed: string) => {
395
- if (!allowEnvOverrides) return undefined;
396
- const legacyPrefixed = newPrefixed.replace(/^SKILLMUX_/, "SKILL_ROUTER_");
397
- if (process.env[newPrefixed] !== undefined) return process.env[newPrefixed];
398
- if (process.env[legacyPrefixed] !== undefined) {
399
- if (!warnedEnv.has(legacyPrefixed)) {
400
- warnedEnv.add(legacyPrefixed);
401
- console.error(`skillmux: ${legacyPrefixed} is deprecated, use ${newPrefixed} instead`);
402
- }
403
- return process.env[legacyPrefixed];
404
- }
405
- return process.env[unprefixed];
406
- };
407
-
408
- // Environment variable overrides.
409
- if (allowEnvOverrides) {
410
- const vaultPath = getEnv("SKILLMUX_VAULT_PATH", "VAULT_PATH");
411
- if (vaultPath) merged.vault_path = vaultPath;
412
- const stateDir = getEnv("SKILLMUX_STATE_DIR", "STATE_DIR");
413
- if (stateDir) merged.state_dir = stateDir;
414
- const kLexicalStr = getEnv("SKILLMUX_RECALL_K_LEXICAL", "RECALL_K_LEXICAL");
415
- if (kLexicalStr) {
416
- const k = Number(kLexicalStr);
417
- if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_lexical: ${kLexicalStr}`);
418
- merged.recall.k_lexical = k;
419
- }
420
- const kVectorStr = getEnv("SKILLMUX_RECALL_K_VECTOR", "RECALL_K_VECTOR");
421
- if (kVectorStr) {
422
- const k = Number(kVectorStr);
423
- if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_vector: ${kVectorStr}`);
424
- merged.recall.k_vector = k;
425
- }
426
- const kRerankStr = getEnv("SKILLMUX_RECALL_K_RERANK", "RECALL_K_RERANK");
427
- if (kRerankStr) {
428
- const k = Number(kRerankStr);
429
- if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_rerank: ${kRerankStr}`);
430
- merged.recall.k_rerank = k;
431
- }
432
- const topKStr = getEnv("SKILLMUX_OUTPUT_TOP_K", "OUTPUT_TOP_K");
433
- if (topKStr) {
434
- const k = Number(topKStr);
435
- if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid output.top_k: ${topKStr}`);
436
- merged.output.top_k = k;
437
- }
438
- const maxTopKStr = getEnv("SKILLMUX_OUTPUT_MAX_TOP_K", "OUTPUT_MAX_TOP_K");
439
- if (maxTopKStr) {
440
- const k = Number(maxTopKStr);
441
- if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid output.max_top_k: ${maxTopKStr}`);
442
- merged.output.max_top_k = k;
443
- }
444
- }
445
-
446
- if (merged.inference.mode === "local") {
447
- if (allowEnvOverrides) {
448
- let modelsDirEnv = process.env.SKILLMUX_MODELS_DIR;
449
- if (modelsDirEnv === undefined && process.env.SKILL_ROUTER_MODELS_DIR !== undefined) {
450
- if (!warnedEnv.has("SKILL_ROUTER_MODELS_DIR")) {
451
- warnedEnv.add("SKILL_ROUTER_MODELS_DIR");
452
- console.error("skillmux: SKILL_ROUTER_MODELS_DIR is deprecated, use SKILLMUX_MODELS_DIR instead");
453
- }
454
- modelsDirEnv = process.env.SKILL_ROUTER_MODELS_DIR;
455
- }
456
- if (modelsDirEnv) merged.inference.models_dir = modelsDirEnv;
457
- const embedDevice = getEnv("SKILLMUX_EMBED_DEVICE", "EMBED_DEVICE");
458
- if (embedDevice) merged.inference.embedding.device = embedDevice as ONNXDevice;
459
- const embedDtype = getEnv("SKILLMUX_EMBED_DTYPE", "EMBED_DTYPE");
460
- if (embedDtype) merged.inference.embedding.dtype = embedDtype as ONNXDtype;
461
- }
462
- } else if (merged.inference.mode === "remote") {
463
- if (!merged.inference.embedding) {
464
- throw new Error("Remote inference requires an inference.embedding section.");
465
- }
466
- if (merged.inference.embedding?.provider !== "openai") {
467
- throw new Error('Remote inference.embedding.provider must be "openai".');
468
- }
469
- if (!Number.isInteger(merged.inference.timeout_ms) || merged.inference.timeout_ms < 100) {
470
- throw new Error("Remote inference.timeout_ms must be an integer of at least 100.");
471
- }
472
- if (!merged.inference.embedding?.endpoint || !merged.inference.embedding.model || !merged.inference.embedding.dimension) {
473
- throw new Error("Remote inference requires inference.embedding endpoint, model, and dimension.");
474
- }
475
- if (merged.inference.reranker && (!merged.inference.reranker.endpoint || !merged.inference.reranker.model)) {
476
- throw new Error("Configured inference.reranker requires adapter, endpoint, and model.");
477
- }
478
- const embedEndpoint = getEnv("SKILLMUX_EMBED_ENDPOINT", "EMBED_ENDPOINT");
479
- const embedModel = getEnv("SKILLMUX_EMBED_MODEL", "EMBED_MODEL");
480
- const embedDimStr = getEnv("SKILLMUX_EMBED_DIMENSION", "EMBED_DIMENSION");
481
- const rerankEndpoint = getEnv("SKILLMUX_RERANK_ENDPOINT", "RERANK_ENDPOINT");
482
- const rerankAdapter = getEnv("SKILLMUX_RERANK_ADAPTER", "RERANK_ADAPTER");
483
- const rerankModel = getEnv("SKILLMUX_RERANK_MODEL", "RERANK_MODEL");
484
- if (embedEndpoint) merged.inference.embedding.endpoint = embedEndpoint;
485
- if (embedModel) merged.inference.embedding.model = embedModel;
486
- if (rerankEndpoint && merged.inference.reranker) merged.inference.reranker.endpoint = rerankEndpoint;
487
- if (rerankAdapter && merged.inference.reranker) {
488
- merged.inference.reranker.adapter = rerankAdapter as "jina-v1" | "bifrost-v1";
489
- }
490
- if (rerankModel && merged.inference.reranker) merged.inference.reranker.model = rerankModel;
491
- if (embedDimStr) {
492
- const dimension = Number(embedDimStr);
493
- if (!Number.isInteger(dimension) || dimension < 1) throw new Error(`Invalid embedding dimension: ${embedDimStr}`);
494
- merged.inference.embedding.dimension = dimension;
495
- }
496
- for (const [name, value, exactEndpoint] of [
497
- ["inference.embedding.endpoint", merged.inference.embedding.endpoint, true],
498
- ...(merged.inference.reranker
499
- ? [["inference.reranker.endpoint", merged.inference.reranker.endpoint, true] as const]
500
- : []),
501
- ] as const) {
502
- try {
503
- const url = new URL(value);
504
- if (!["http:", "https:"].includes(url.protocol)) throw new Error();
505
- if (exactEndpoint && (url.username || url.password || url.hash)) throw new Error();
506
- } catch {
507
- throw new Error(
508
- exactEndpoint
509
- ? `${name} must be an absolute HTTP(S) URL without userinfo or a fragment.`
510
- : `${name} must be an HTTP(S) URL.`,
511
- );
512
- }
513
- }
514
- for (const [name, apiKeyEnv] of [
515
- ["inference.embedding.api_key_env", merged.inference.embedding.api_key_env],
516
- ...(merged.inference.reranker
517
- ? [["inference.reranker.api_key_env", merged.inference.reranker.api_key_env] as const]
518
- : []),
519
- ] as const) {
520
- if (
521
- apiKeyEnv !== undefined &&
522
- (process.env[apiKeyEnv] === undefined || process.env[apiKeyEnv] === "")
523
- ) {
524
- throw new Error(
525
- `${name} names environment variable "${apiKeyEnv}", but it is unset or empty.`,
526
- );
527
- }
528
- }
529
- } else {
530
- throw new Error(`Invalid inference.mode: ${(merged.inference as { mode?: unknown }).mode}`);
531
- }
532
-
533
- // HTTP server environment overrides
534
- if (merged.server) {
535
- if (allowEnvOverrides) {
536
- const authEnabledStr = getEnv("SKILLMUX_HTTP_AUTH_ENABLED", "HTTP_AUTH_ENABLED");
537
- if (authEnabledStr !== undefined) {
538
- merged.server.auth_enabled = authEnabledStr === "true";
539
- }
540
- const authTokenEnv = getEnv("SKILLMUX_HTTP_AUTH_TOKEN_ENV", "HTTP_AUTH_TOKEN_ENV");
541
- if (authTokenEnv !== undefined) {
542
- merged.server.auth_token_env = authTokenEnv;
543
- }
544
- const allowedOriginsStr = getEnv("SKILLMUX_HTTP_ALLOWED_ORIGINS", "HTTP_ALLOWED_ORIGINS");
545
- if (allowedOriginsStr !== undefined) {
546
- merged.server.allowed_origins = allowedOriginsStr.split(",").map((o) => o.trim());
547
- }
548
- const hostname = getEnv("SKILLMUX_HTTP_HOSTNAME", "HTTP_HOSTNAME");
549
- if (hostname !== undefined) {
550
- merged.server.hostname = hostname;
551
- }
552
-
553
- if (!merged.server.rate_limit) {
554
- merged.server.rate_limit = { enabled: false, requests_per_minute: 60 };
555
- }
556
-
557
- const rateLimitEnabledStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_ENABLED", "HTTP_RATE_LIMIT_ENABLED");
558
- if (rateLimitEnabledStr) {
559
- merged.server.rate_limit.enabled = rateLimitEnabledStr === "true";
560
- }
561
-
562
- const rateLimitRPMStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_RPM", "HTTP_RATE_LIMIT_RPM");
563
- if (rateLimitRPMStr) {
564
- const rpm = Number(rateLimitRPMStr);
565
- if (!Number.isInteger(rpm)) {
566
- throw new Error(`Invalid rate limit RPM: ${rateLimitRPMStr}`);
567
- }
568
- merged.server.rate_limit.requests_per_minute = rpm;
569
- }
570
-
571
- const rateLimitTrustProxyStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY", "HTTP_RATE_LIMIT_TRUST_PROXY");
572
- if (rateLimitTrustProxyStr) {
573
- merged.server.rate_limit.trust_proxy = rateLimitTrustProxyStr === "true";
574
- }
575
- }
576
-
577
- if (merged.server.rate_limit && merged.server.rate_limit.enabled && merged.server.rate_limit.requests_per_minute === undefined) {
578
- merged.server.rate_limit.requests_per_minute = 60;
579
- }
580
- }
581
-
582
- return configSchema.parse(merged) as Config;
583
- }