@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.
- package/CHANGELOG.md +15 -0
- package/README.md +20 -10
- package/bin/skillmux.js +89 -0
- package/docs/README.md +4 -4
- package/docs/cli.md +15 -5
- package/docs/concepts.md +4 -3
- package/docs/configuration.md +22 -5
- package/docs/deployment.md +3 -3
- package/docs/getting-started.md +32 -7
- package/docs/releasing.md +17 -2
- package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
- package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
- package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
- package/docs/sdd/cli-remote-target-parity/think.md +183 -0
- package/docs/sdd/cli-surface-consistency/think.md +149 -0
- package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
- package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
- package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
- package/docs/sdd/feat-packaging-deployment/review.md +95 -0
- package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
- package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
- package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
- package/docs/sdd/npm-native-binaries/review.md +103 -0
- package/docs/sdd/npm-native-binaries/spec.md +148 -0
- package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
- package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
- package/docs/sdd/runtime-resource-hardening/review.md +86 -0
- package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
- package/docs/sdd/security-hardening-phase/think.md +129 -0
- package/docs/sdd/security-observability/review.md +96 -0
- package/docs/sdd/security-observability/spec.md +55 -0
- package/docs/sdd/skill-provenance-update/review.md +152 -0
- package/docs/sdd/skill-provenance-update/schema.json +193 -0
- package/docs/sdd/skill-provenance-update/spec.md +134 -0
- package/docs/sdd/supply-chain-hardening/review.md +50 -0
- package/docs/sdd/supply-chain-hardening/spec.md +42 -0
- package/docs/sdd/target-marker-rehome/spec.md +33 -0
- package/docs/skill-management.md +1 -1
- package/docs/troubleshooting.md +18 -2
- package/package.json +15 -8
- package/src/adapters.ts +0 -438
- package/src/audit.ts +0 -21
- package/src/cli.ts +0 -928
- package/src/clients.ts +0 -368
- package/src/commands/audit.ts +0 -85
- package/src/commands/config.ts +0 -224
- package/src/commands/context.ts +0 -104
- package/src/commands/core.ts +0 -56
- package/src/commands/doctor.ts +0 -97
- package/src/commands/eval.ts +0 -88
- package/src/commands/init.ts +0 -672
- package/src/commands/install.ts +0 -155
- package/src/commands/local-vault.ts +0 -60
- package/src/commands/models.ts +0 -10
- package/src/commands/outdated.ts +0 -115
- package/src/commands/project.ts +0 -580
- package/src/commands/report.ts +0 -66
- package/src/commands/scan.ts +0 -71
- package/src/commands/shared.ts +0 -38
- package/src/commands/skill.ts +0 -33
- package/src/commands/sync.ts +0 -233
- package/src/commands/target.ts +0 -225
- package/src/commands/update.ts +0 -277
- package/src/completions.ts +0 -196
- package/src/concurrency-limiter.ts +0 -61
- package/src/config-service.ts +0 -398
- package/src/config-watcher.ts +0 -253
- package/src/config.ts +0 -583
- package/src/context.ts +0 -188
- package/src/db-audit.ts +0 -286
- package/src/db-index.ts +0 -238
- package/src/db.ts +0 -3
- package/src/deployment.ts +0 -39
- package/src/doctor.ts +0 -205
- package/src/eval.ts +0 -302
- package/src/global-flags.ts +0 -46
- package/src/init-agents.ts +0 -329
- package/src/init-instructions.ts +0 -192
- package/src/init.ts +0 -389
- package/src/install.ts +0 -232
- package/src/lifecycle.ts +0 -51
- package/src/logger.ts +0 -26
- package/src/manifest.ts +0 -352
- package/src/mcp-registration.ts +0 -89
- package/src/metrics.ts +0 -121
- package/src/models.ts +0 -20
- package/src/output.ts +0 -214
- package/src/project-setup.ts +0 -36
- package/src/prompts.ts +0 -124
- package/src/provenance.ts +0 -99
- package/src/rate-limiter.ts +0 -137
- package/src/readiness.ts +0 -30
- package/src/redact.ts +0 -52
- package/src/router-core.ts +0 -666
- package/src/rrf.ts +0 -31
- package/src/scan.ts +0 -300
- package/src/server.ts +0 -930
- package/src/setup.ts +0 -145
- package/src/snapshot.ts +0 -135
- package/src/stats.ts +0 -340
- package/src/sync.ts +0 -482
- package/src/toml-writer.ts +0 -51
- package/src/types.ts +0 -212
- 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
|
-
}
|