@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
@@ -1,398 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { DEFAULT_CONFIG_PATH, expandHome, loadConfig } from "./config";
5
- import { describeDeployment } from "./deployment";
6
- import type { Config } from "./types";
7
- import { stringifyToml } from "./toml-writer";
8
-
9
- export type ConfigSource = "default" | "toml" | "environment";
10
- export type ConfigSourceMap = Record<string, ConfigSource>;
11
-
12
- export interface SetConfigResult {
13
- ok: boolean;
14
- key: string;
15
- prior_val: unknown;
16
- resulting_val: unknown;
17
- target: string;
18
- prior_revision: string;
19
- resulting_revision: string;
20
- persistence: "persisted" | "not_persisted" | "failed";
21
- application: "activated" | "restart_required" | "failed";
22
- readiness: { status: "ready" | "degraded" | "not_ready" | "stopping"; capability: string };
23
- restart_required_keys: string[];
24
- }
25
-
26
- export interface ConfigStatusResponse {
27
- target: string;
28
- desired_source: string;
29
- desired_source_hash: string;
30
- active_revision: string;
31
- active_source_hash: string;
32
- last_successful_reload_at: string | null;
33
- last_reload_error: string | null;
34
- readiness: { status: "ready" | "degraded" | "not_ready" | "stopping"; capability: string };
35
- restart_required_keys: string[];
36
- runtime: "running" | "not_running";
37
- version: string;
38
- deployment_runtime: "host" | "docker";
39
- image_variant: "full" | "slim" | null;
40
- }
41
-
42
- export const RESTART_REQUIRED_KEYS = [
43
- "server.hostname",
44
- "server.auth_enabled",
45
- "server.auth_token_env",
46
- "server.admin.enabled",
47
- "server.admin.token_env",
48
- "inference.mode",
49
- "inference.bundle",
50
- "inference.models_dir",
51
- "state_dir",
52
- "inference.embedding.model",
53
- "inference.embedding.dimension",
54
- "inference.embedding.device",
55
- "inference.embedding.dtype",
56
- ];
57
-
58
- export const RELOADABLE_KEYS = [
59
- "config.environment_overrides",
60
- "vault_path",
61
- "recall.k_lexical",
62
- "recall.k_vector",
63
- "recall.k_rerank",
64
- "output.top_k",
65
- "output.max_top_k",
66
- "inference.embedding.endpoint",
67
- "inference.embedding.api_key_env",
68
- "inference.reranker.adapter",
69
- "inference.reranker.endpoint",
70
- "inference.reranker.model",
71
- "inference.reranker.api_key_env",
72
- "inference.timeout_ms",
73
- "server.rate_limit.enabled",
74
- "server.rate_limit.requests_per_minute",
75
- "server.rate_limit.trust_proxy",
76
- ];
77
-
78
- export function getNestedValue(obj: Record<string, any>, path: string): unknown {
79
- const parts = path.split(".");
80
- let cur = obj;
81
- for (const part of parts) {
82
- if (cur === undefined || cur === null || typeof cur !== "object") return undefined;
83
- cur = cur[part];
84
- }
85
- return cur;
86
- }
87
-
88
- export function setNestedValue(obj: Record<string, any>, path: string, value: unknown): void {
89
- const parts = path.split(".");
90
- let cur = obj;
91
- for (let i = 0; i < parts.length - 1; i++) {
92
- const part = parts[i]!;
93
- if (!cur[part] || typeof cur[part] !== "object") {
94
- cur[part] = {};
95
- }
96
- cur = cur[part];
97
- }
98
- const lastPart = parts[parts.length - 1]!;
99
- cur[lastPart] = value;
100
- }
101
-
102
- export function deleteNestedValue(obj: Record<string, any>, path: string): void {
103
- const parts = path.split(".");
104
- let cur = obj;
105
- for (let i = 0; i < parts.length - 1; i++) {
106
- const part = parts[i]!;
107
- if (!cur[part]) return;
108
- cur = cur[part];
109
- }
110
- const lastPart = parts[parts.length - 1]!;
111
- delete cur[lastPart];
112
- }
113
-
114
- export function computeHash(data: unknown): string {
115
- const str = typeof data === "string" ? data : JSON.stringify(data);
116
- return createHash("sha256").update(str).digest("hex").slice(0, 16);
117
- }
118
-
119
- export async function getEffectiveConfig(configPath?: string): Promise<{
120
- effective: Config;
121
- sources: ConfigSourceMap;
122
- rawToml: Record<string, unknown>;
123
- }> {
124
- const path = configPath ?? process.env.SKILLMUX_CONFIG ?? DEFAULT_CONFIG_PATH;
125
- const effective = await loadConfig(path);
126
- const rawToml: Record<string, unknown> = {};
127
-
128
- const fullPath = expandHome(path);
129
- if (existsSync(fullPath)) {
130
- try {
131
- const text = await Bun.file(fullPath).text();
132
- const parsed = Bun.TOML.parse(text);
133
- if (typeof parsed === "object" && parsed !== null) {
134
- Object.assign(rawToml, parsed);
135
- }
136
- } catch {
137
- // empty if unparseable
138
- }
139
- }
140
-
141
- const sources: ConfigSourceMap = {};
142
- const allowEnvOverrides = effective.config?.environment_overrides !== false;
143
-
144
- const allKeys = [
145
- "config.environment_overrides",
146
- "vault_path",
147
- "state_dir",
148
- "recall.k_lexical",
149
- "recall.k_vector",
150
- "recall.k_rerank",
151
- "output.top_k",
152
- "output.max_top_k",
153
- "inference.mode",
154
- "inference.bundle",
155
- "inference.models_dir",
156
- "inference.embedding.model",
157
- "inference.embedding.dimension",
158
- "inference.embedding.device",
159
- "inference.embedding.dtype",
160
- "inference.embedding.endpoint",
161
- "inference.embedding.api_key_env",
162
- "inference.reranker.adapter",
163
- "inference.reranker.endpoint",
164
- "inference.reranker.model",
165
- "inference.reranker.api_key_env",
166
- "inference.timeout_ms",
167
- "server.auth_enabled",
168
- "server.auth_token_env",
169
- "server.allowed_origins",
170
- "server.admin.enabled",
171
- "server.admin.token_env",
172
- "server.hostname",
173
- "server.rate_limit.enabled",
174
- "server.rate_limit.requests_per_minute",
175
- "server.rate_limit.trust_proxy",
176
- ];
177
-
178
- for (const key of allKeys) {
179
- if (isEnvMasked(key, allowEnvOverrides)) {
180
- sources[key] = "environment";
181
- } else if (getNestedValue(rawToml, key) !== undefined) {
182
- sources[key] = "toml";
183
- } else {
184
- sources[key] = "default";
185
- }
186
- }
187
-
188
- return { effective, sources, rawToml };
189
- }
190
-
191
- export function isEnvMasked(key: string, allowEnvOverrides: boolean = true): boolean {
192
- if (!allowEnvOverrides) return false;
193
- if (key === "vault_path" && (process.env.SKILLMUX_VAULT_PATH || process.env.SKILL_ROUTER_VAULT_PATH || process.env.VAULT_PATH)) return true;
194
- if (key === "state_dir" && (process.env.SKILLMUX_STATE_DIR || process.env.SKILL_ROUTER_STATE_DIR || process.env.STATE_DIR)) return true;
195
- if (key === "recall.k_lexical" && (process.env.SKILLMUX_RECALL_K_LEXICAL || process.env.RECALL_K_LEXICAL)) return true;
196
- if (key === "recall.k_vector" && (process.env.SKILLMUX_RECALL_K_VECTOR || process.env.RECALL_K_VECTOR)) return true;
197
- if (key === "recall.k_rerank" && (process.env.SKILLMUX_RECALL_K_RERANK || process.env.RECALL_K_RERANK)) return true;
198
- if (key === "output.top_k" && (process.env.SKILLMUX_OUTPUT_TOP_K || process.env.OUTPUT_TOP_K)) return true;
199
- if (key === "output.max_top_k" && (process.env.SKILLMUX_OUTPUT_MAX_TOP_K || process.env.OUTPUT_MAX_TOP_K)) return true;
200
- if (key === "inference.models_dir" && (process.env.SKILLMUX_MODELS_DIR || process.env.SKILL_ROUTER_MODELS_DIR)) return true;
201
- if (key === "inference.embedding.device" && (process.env.SKILLMUX_EMBED_DEVICE || process.env.EMBED_DEVICE)) return true;
202
- if (key === "inference.embedding.dtype" && (process.env.SKILLMUX_EMBED_DTYPE || process.env.EMBED_DTYPE)) return true;
203
- if (key === "inference.embedding.endpoint" && (process.env.SKILLMUX_EMBED_ENDPOINT || process.env.SKILL_ROUTER_EMBED_ENDPOINT || process.env.EMBED_ENDPOINT)) return true;
204
- if (key === "inference.embedding.model" && (process.env.SKILLMUX_EMBED_MODEL || process.env.SKILL_ROUTER_EMBED_MODEL || process.env.EMBED_MODEL)) return true;
205
- if (key === "inference.embedding.dimension" && (process.env.SKILLMUX_EMBED_DIMENSION || process.env.SKILL_ROUTER_EMBED_DIMENSION || process.env.EMBED_DIMENSION)) return true;
206
- if (key === "inference.reranker.adapter" && (process.env.SKILLMUX_RERANK_ADAPTER || process.env.SKILL_ROUTER_RERANK_ADAPTER || process.env.RERANK_ADAPTER)) return true;
207
- if (key === "inference.reranker.endpoint" && (process.env.SKILLMUX_RERANK_ENDPOINT || process.env.SKILL_ROUTER_RERANK_ENDPOINT || process.env.RERANK_ENDPOINT)) return true;
208
- if (key === "inference.reranker.model" && (process.env.SKILLMUX_RERANK_MODEL || process.env.SKILL_ROUTER_RERANK_MODEL || process.env.RERANK_MODEL)) return true;
209
- if (key === "server.auth_enabled" && (process.env.SKILLMUX_HTTP_AUTH_ENABLED || process.env.HTTP_AUTH_ENABLED)) return true;
210
- if (key === "server.auth_token_env" && (process.env.SKILLMUX_HTTP_AUTH_TOKEN_ENV || process.env.HTTP_AUTH_TOKEN_ENV)) return true;
211
- if (key === "server.allowed_origins" && (process.env.SKILLMUX_HTTP_ALLOWED_ORIGINS || process.env.HTTP_ALLOWED_ORIGINS)) return true;
212
- if (key === "server.hostname" && (process.env.SKILLMUX_HTTP_HOSTNAME || process.env.HTTP_HOSTNAME)) return true;
213
- if (key === "server.rate_limit.enabled" && (process.env.SKILLMUX_HTTP_RATE_LIMIT_ENABLED || process.env.HTTP_RATE_LIMIT_ENABLED)) return true;
214
- if (key === "server.rate_limit.requests_per_minute" && (process.env.SKILLMUX_HTTP_RATE_LIMIT_RPM || process.env.HTTP_RATE_LIMIT_RPM)) return true;
215
- if (key === "server.rate_limit.trust_proxy" && (process.env.SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY || process.env.HTTP_RATE_LIMIT_TRUST_PROXY)) return true;
216
- return false;
217
- }
218
-
219
- export function validateDottedKey(key: string): void {
220
- if (key === "output.ambiguous_candidate_limit") {
221
- throw new Error("output.ambiguous_candidate_limit is obsolete. Use output.top_k instead.");
222
- }
223
- if (key.startsWith("thresholds.") || key.startsWith("inference.thresholds.")) {
224
- throw new Error("thresholds are obsolete. Threshold calibration was removed; use output.top_k.");
225
- }
226
- const allowed = new Set([
227
- "config.environment_overrides",
228
- "vault_path",
229
- "state_dir",
230
- "recall.k_lexical",
231
- "recall.k_vector",
232
- "recall.k_rerank",
233
- "output.top_k",
234
- "output.max_top_k",
235
- "inference.mode",
236
- "inference.bundle",
237
- "inference.models_dir",
238
- "inference.embedding.model",
239
- "inference.embedding.dimension",
240
- "inference.embedding.device",
241
- "inference.embedding.dtype",
242
- "inference.embedding.endpoint",
243
- "inference.embedding.api_key_env",
244
- "inference.reranker.adapter",
245
- "inference.reranker.endpoint",
246
- "inference.reranker.model",
247
- "inference.reranker.api_key_env",
248
- "inference.timeout_ms",
249
- "server.auth_enabled",
250
- "server.auth_token_env",
251
- "server.admin.enabled",
252
- "server.admin.token_env",
253
- "server.hostname",
254
- "server.rate_limit.enabled",
255
- "server.rate_limit.requests_per_minute",
256
- "server.rate_limit.trust_proxy",
257
- ]);
258
- if (!allowed.has(key)) {
259
- throw new Error(`Unknown configuration key "${key}"`);
260
- }
261
- }
262
-
263
- export function parseDottedValue(key: string, valueStr: string): unknown {
264
- if (valueStr === "true") return true;
265
- if (valueStr === "false") return false;
266
- if (/^-?\d+$/.test(valueStr)) return parseInt(valueStr, 10);
267
- if (/^-?\d+\.\d+$/.test(valueStr)) return parseFloat(valueStr);
268
-
269
- const numberKeys = new Set([
270
- "recall.k_lexical",
271
- "recall.k_vector",
272
- "recall.k_rerank",
273
- "output.top_k",
274
- "output.max_top_k",
275
- "inference.embedding.dimension",
276
- "inference.timeout_ms",
277
- "server.rate_limit.requests_per_minute",
278
- ]);
279
-
280
- if (numberKeys.has(key)) {
281
- const num = Number(valueStr);
282
- if (isNaN(num)) {
283
- throw new Error(`Key "${key}" expects a numeric value, got "${valueStr}"`);
284
- }
285
- return num;
286
- }
287
-
288
- const booleanKeys = new Set([
289
- "config.environment_overrides",
290
- "server.auth_enabled",
291
- "server.admin.enabled",
292
- "server.rate_limit.enabled",
293
- "server.rate_limit.trust_proxy",
294
- ]);
295
-
296
- if (booleanKeys.has(key)) {
297
- throw new Error(`Key "${key}" expects a boolean value ("true" or "false"), got "${valueStr}"`);
298
- }
299
-
300
- return valueStr;
301
- }
302
-
303
- export async function getDottedKey(key: string, configPath?: string): Promise<unknown> {
304
- validateDottedKey(key);
305
- const { effective } = await getEffectiveConfig(configPath);
306
- return getNestedValue(effective as Record<string, any>, key);
307
- }
308
-
309
- export async function setDottedKey(
310
- key: string,
311
- rawValStr: string,
312
- opts?: { configPath?: string; dryRun?: boolean; contextName?: string }
313
- ): Promise<SetConfigResult> {
314
- validateDottedKey(key);
315
- if (isEnvMasked(key)) {
316
- throw new Error(`Cannot set environment-masked configuration key "${key}"`);
317
- }
318
-
319
- const path = opts?.configPath ?? process.env.SKILLMUX_CONFIG ?? DEFAULT_CONFIG_PATH;
320
- const contextName = opts?.contextName ?? "local";
321
-
322
- const { effective: priorEffective, rawToml } = await getEffectiveConfig(path);
323
- const priorVal = getNestedValue(priorEffective as Record<string, any>, key);
324
- const parsedVal = parseDottedValue(key, rawValStr);
325
-
326
- const updatedToml = structuredClone(rawToml);
327
- setNestedValue(updatedToml, key, parsedVal);
328
-
329
- const priorRevision = computeHash(priorEffective);
330
-
331
- let resultingRevision = priorRevision;
332
- let persistence: "persisted" | "not_persisted" | "failed" = "not_persisted";
333
-
334
- if (!opts?.dryRun) {
335
- const fullPath = expandHome(path);
336
- const dir = dirname(fullPath);
337
- mkdirSync(dir, { recursive: true });
338
-
339
- let existingMode = 0o644;
340
- if (existsSync(fullPath)) {
341
- try {
342
- existingMode = statSync(fullPath).mode;
343
- } catch {
344
- // default
345
- }
346
- }
347
-
348
- const tmpPath = join(dir, `.config-${Math.random().toString(36).slice(2)}.tmp`);
349
- const newTomlText = stringifyToml(updatedToml);
350
- writeFileSync(tmpPath, newTomlText, { mode: existingMode, encoding: "utf-8" });
351
- renameSync(tmpPath, fullPath);
352
-
353
- persistence = "persisted";
354
-
355
- const { effective: newEffective } = await getEffectiveConfig(path);
356
- resultingRevision = computeHash(newEffective);
357
- }
358
-
359
- const isRestartRequired = RESTART_REQUIRED_KEYS.some((k) => key === k || key.startsWith(k + "."));
360
- const application = isRestartRequired ? "restart_required" : "activated";
361
-
362
- return {
363
- ok: true,
364
- key,
365
- prior_val: priorVal,
366
- resulting_val: parsedVal,
367
- target: contextName,
368
- prior_revision: priorRevision,
369
- resulting_revision: resultingRevision,
370
- persistence,
371
- application,
372
- readiness: { status: "ready", capability: "hybrid" },
373
- restart_required_keys: isRestartRequired ? [key] : [],
374
- };
375
- }
376
-
377
-
378
- export async function getLocalConfigStatus(configPath?: string): Promise<ConfigStatusResponse> {
379
- const { effective } = await getEffectiveConfig(configPath);
380
- const hash = computeHash(effective);
381
- const deployment = describeDeployment(effective);
382
-
383
- return {
384
- target: "local",
385
- desired_source: configPath ?? process.env.SKILLMUX_CONFIG ?? DEFAULT_CONFIG_PATH,
386
- desired_source_hash: hash,
387
- active_revision: hash,
388
- active_source_hash: hash,
389
- last_successful_reload_at: new Date().toISOString(),
390
- last_reload_error: null,
391
- readiness: { status: "ready", capability: "hybrid" },
392
- restart_required_keys: [],
393
- runtime: "not_running",
394
- version: deployment.version,
395
- deployment_runtime: deployment.runtime,
396
- image_variant: deployment.image_variant,
397
- };
398
- }
@@ -1,253 +0,0 @@
1
- import { existsSync, watch } from "node:fs";
2
- import { dirname } from "node:path";
3
- import { loadConfig } from "./config";
4
- import type { Config } from "./types";
5
-
6
- // ---------------------------------------------------------------------------
7
- // Live-reload allowlist (AC9)
8
- // These are the ONLY top-level config keys that can be hot-reloaded.
9
- // Any change outside this set results in restart_required_keys being populated
10
- // but the current snapshot is NOT replaced (LKG stays active).
11
- // ---------------------------------------------------------------------------
12
-
13
- export const LIVE_RELOAD_KEYS = new Set([
14
- "config.environment_overrides",
15
- "recall.k_lexical",
16
- "recall.k_vector",
17
- "recall.k_rerank",
18
- "output.top_k",
19
- "output.max_top_k",
20
- "inference.embedding.endpoint",
21
- "inference.embedding.api_key_env",
22
- "inference.reranker.adapter",
23
- "inference.reranker.endpoint",
24
- "inference.reranker.model",
25
- "inference.reranker.api_key_env",
26
- "inference.timeout_ms",
27
- ]);
28
-
29
- // ---------------------------------------------------------------------------
30
- // Status type (AC10)
31
- // ---------------------------------------------------------------------------
32
-
33
- export interface ReloadStatus {
34
- /** ISO timestamp of the last successful reload, or null if never reloaded. */
35
- last_successful_reload_at: string | null;
36
- /** Error message from the last failed reload, or null if last reload succeeded. */
37
- last_reload_error: string | null;
38
- /**
39
- * Keys that changed outside the live-reload allowlist.
40
- * Non-empty means a restart is needed to activate those changes.
41
- */
42
- restart_required_keys: string[];
43
- }
44
-
45
- // ---------------------------------------------------------------------------
46
- // ConfigWatcher (AC8, AC9, AC10)
47
- // ---------------------------------------------------------------------------
48
-
49
- export interface ConfigWatcherOptions {
50
- /** Called on every successful, complete reload. */
51
- onReload: (config: Config) => void;
52
- /**
53
- * Called when the file cannot be read, parsed, or validated.
54
- * The previous good snapshot remains active — watcher does NOT crash.
55
- */
56
- onError: (error: unknown) => void;
57
- }
58
-
59
- const DEBOUNCE_MS = 300;
60
- const STABLE_STAT_INTERVAL_MS = 80;
61
- const STABLE_STAT_MAX_TRIES = 8;
62
-
63
- function isRecord(value: unknown): value is Record<string, unknown> {
64
- return typeof value === "object" && value !== null && !Array.isArray(value);
65
- }
66
-
67
- function changedConfigKeys(
68
- previous: Record<string, unknown>,
69
- next: Record<string, unknown>,
70
- prefix = "",
71
- ): string[] {
72
- const changed: string[] = [];
73
- const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
74
-
75
- for (const key of keys) {
76
- const path = prefix ? `${prefix}.${key}` : key;
77
- const previousValue = previous[key];
78
- const nextValue = next[key];
79
- if (!(key in previous) || !(key in next)) {
80
- if (isRecord(previousValue) || isRecord(nextValue)) {
81
- changed.push(
82
- ...changedConfigKeys(
83
- isRecord(previousValue) ? previousValue : {},
84
- isRecord(nextValue) ? nextValue : {},
85
- path,
86
- ),
87
- );
88
- } else {
89
- changed.push(path);
90
- }
91
- continue;
92
- }
93
-
94
- if (isRecord(previousValue) && isRecord(nextValue)) {
95
- changed.push(...changedConfigKeys(previousValue, nextValue, path));
96
- } else if (JSON.stringify(previousValue) !== JSON.stringify(nextValue)) {
97
- changed.push(path);
98
- }
99
- }
100
-
101
- return changed;
102
- }
103
-
104
- /**
105
- * Watch the parent directory of a TOML config file for changes.
106
- * Parent-dir watching catches both direct writes (change events) and
107
- * atomic renames (rename events) — both are needed for AC8.
108
- *
109
- * Reloading is transactional:
110
- * 1. Debounce burst events
111
- * 2. Wait for file size/mtime to stabilise (stable candidate read)
112
- * 3. Parse + validate the full config
113
- * 4. Call onReload only on success; on failure call onError (LKG stays)
114
- *
115
- * Only keys on LIVE_RELOAD_KEYS may trigger onReload without restart_required.
116
- */
117
- export class ConfigWatcher {
118
- private status: ReloadStatus = {
119
- last_successful_reload_at: null,
120
- last_reload_error: null,
121
- restart_required_keys: [],
122
- };
123
- private stopped = false;
124
- private debounceTimer: ReturnType<typeof setTimeout> | null = null;
125
- private watcher: ReturnType<typeof watch> | undefined;
126
-
127
- private constructor(
128
- private readonly tomlPath: string,
129
- private readonly opts: ConfigWatcherOptions,
130
- private activeConfig: Config,
131
- ) {
132
- const dir = dirname(tomlPath);
133
- const filename = tomlPath.split(/[/\\]/).pop()!;
134
-
135
- // A config file is optional. Do not create its parent just to enable
136
- // reloads: a native zero-config server must also work with a read-only or
137
- // entirely absent config location. Reloads stay inactive until a future
138
- // server start finds a watchable parent directory.
139
- if (!existsSync(dir)) return;
140
-
141
- const watcher = watch(dir, { recursive: false }, (_event, changedName) => {
142
- if (this.stopped) return;
143
- // Fire for: the config file itself, or any .tmp variant of it (handles
144
- // pid-numbered atomics: config.toml.12345.tmp → rename → config.toml).
145
- // null/undefined changedName means directory-level change — treat as a hit.
146
- if (
147
- changedName &&
148
- changedName !== filename &&
149
- !changedName.startsWith(filename)
150
- ) {
151
- return;
152
- }
153
- this.scheduleReload();
154
- });
155
- this.watcher = watcher;
156
-
157
- watcher.on("error", (err) => {
158
- if (!this.stopped) {
159
- this.status = { ...this.status, last_reload_error: String(err) };
160
- this.opts.onError(err);
161
- }
162
- });
163
- }
164
-
165
- static async start(
166
- tomlPath: string,
167
- opts: ConfigWatcherOptions,
168
- ): Promise<ConfigWatcher> {
169
- const activeConfig = await loadConfig(tomlPath);
170
- return new ConfigWatcher(tomlPath, opts, activeConfig);
171
- }
172
-
173
- private scheduleReload(): void {
174
- if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
175
- this.debounceTimer = setTimeout(() => {
176
- this.debounceTimer = null;
177
- void this.doReload();
178
- }, DEBOUNCE_MS);
179
- }
180
-
181
- private async doReload(): Promise<void> {
182
- if (this.stopped) return;
183
-
184
- // Wait for stable file (size + mtime stop changing)
185
- await this.waitForStable();
186
- if (this.stopped) return;
187
-
188
- try {
189
- const config = await loadConfig(this.tomlPath);
190
- const restartRequiredKeys = changedConfigKeys(
191
- this.activeConfig as unknown as Record<string, unknown>,
192
- config as unknown as Record<string, unknown>,
193
- )
194
- .filter((key) => !LIVE_RELOAD_KEYS.has(key))
195
- .sort();
196
-
197
- if (restartRequiredKeys.length > 0) {
198
- this.status = {
199
- ...this.status,
200
- last_reload_error: null,
201
- restart_required_keys: restartRequiredKeys,
202
- };
203
- return;
204
- }
205
-
206
- this.activeConfig = config;
207
- this.status = {
208
- last_successful_reload_at: new Date().toISOString(),
209
- last_reload_error: null,
210
- restart_required_keys: [],
211
- };
212
- this.opts.onReload(config);
213
- } catch (err) {
214
- this.status = {
215
- ...this.status,
216
- last_reload_error: err instanceof Error ? err.message : String(err),
217
- };
218
- this.opts.onError(err);
219
- }
220
- }
221
-
222
- private async waitForStable(): Promise<void> {
223
- let previous = "";
224
- for (let i = 0; i < STABLE_STAT_MAX_TRIES; i++) {
225
- const file = Bun.file(this.tomlPath);
226
- if (!(await file.exists())) return;
227
- const current = `${file.size}:${file.lastModified}`;
228
- if (current === previous) return;
229
- previous = current;
230
- await Bun.sleep(STABLE_STAT_INTERVAL_MS);
231
- }
232
- }
233
-
234
- /** Get a snapshot of the current watcher status. */
235
- reloadStatus(): ReloadStatus {
236
- return { ...this.status };
237
- }
238
-
239
- /** Stop the watcher and cancel any pending debounce. Idempotent. */
240
- stop(): void {
241
- if (this.stopped) return;
242
- this.stopped = true;
243
- if (this.debounceTimer !== null) {
244
- clearTimeout(this.debounceTimer);
245
- this.debounceTimer = null;
246
- }
247
- try {
248
- this.watcher?.close();
249
- } catch {
250
- // already closed
251
- }
252
- }
253
- }