@klhapp/skillmux 1.12.0 → 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 (103) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +18 -9
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +14 -4
  6. package/docs/concepts.md +2 -2
  7. package/docs/configuration.md +18 -3
  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/troubleshooting.md +18 -2
  39. package/package.json +15 -8
  40. package/src/adapters.ts +0 -438
  41. package/src/audit.ts +0 -21
  42. package/src/cli.ts +0 -928
  43. package/src/clients.ts +0 -368
  44. package/src/commands/audit.ts +0 -85
  45. package/src/commands/config.ts +0 -224
  46. package/src/commands/context.ts +0 -104
  47. package/src/commands/core.ts +0 -56
  48. package/src/commands/doctor.ts +0 -97
  49. package/src/commands/eval.ts +0 -88
  50. package/src/commands/init.ts +0 -672
  51. package/src/commands/install.ts +0 -155
  52. package/src/commands/local-vault.ts +0 -60
  53. package/src/commands/models.ts +0 -10
  54. package/src/commands/outdated.ts +0 -115
  55. package/src/commands/project.ts +0 -580
  56. package/src/commands/report.ts +0 -66
  57. package/src/commands/scan.ts +0 -71
  58. package/src/commands/shared.ts +0 -38
  59. package/src/commands/skill.ts +0 -33
  60. package/src/commands/sync.ts +0 -233
  61. package/src/commands/target.ts +0 -225
  62. package/src/commands/update.ts +0 -277
  63. package/src/completions.ts +0 -196
  64. package/src/concurrency-limiter.ts +0 -61
  65. package/src/config-service.ts +0 -398
  66. package/src/config-watcher.ts +0 -253
  67. package/src/config.ts +0 -583
  68. package/src/context.ts +0 -188
  69. package/src/db-audit.ts +0 -286
  70. package/src/db-index.ts +0 -238
  71. package/src/db.ts +0 -3
  72. package/src/deployment.ts +0 -39
  73. package/src/doctor.ts +0 -205
  74. package/src/eval.ts +0 -302
  75. package/src/global-flags.ts +0 -46
  76. package/src/init-agents.ts +0 -329
  77. package/src/init-instructions.ts +0 -192
  78. package/src/init.ts +0 -390
  79. package/src/install.ts +0 -232
  80. package/src/lifecycle.ts +0 -51
  81. package/src/logger.ts +0 -26
  82. package/src/manifest.ts +0 -359
  83. package/src/mcp-registration.ts +0 -89
  84. package/src/metrics.ts +0 -121
  85. package/src/models.ts +0 -20
  86. package/src/output.ts +0 -214
  87. package/src/project-setup.ts +0 -36
  88. package/src/prompts.ts +0 -124
  89. package/src/provenance.ts +0 -99
  90. package/src/rate-limiter.ts +0 -137
  91. package/src/readiness.ts +0 -30
  92. package/src/redact.ts +0 -52
  93. package/src/router-core.ts +0 -666
  94. package/src/rrf.ts +0 -31
  95. package/src/scan.ts +0 -300
  96. package/src/server.ts +0 -930
  97. package/src/setup.ts +0 -145
  98. package/src/snapshot.ts +0 -135
  99. package/src/stats.ts +0 -340
  100. package/src/sync.ts +0 -482
  101. package/src/toml-writer.ts +0 -51
  102. package/src/types.ts +0 -212
  103. 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
- }