@klhapp/skillmux 0.2.0 → 0.4.2
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 +70 -0
- package/README.md +79 -7
- package/config.example.toml +5 -0
- package/docs/configuration.md +58 -4
- package/docs/releasing.md +53 -32
- package/package.json +1 -1
- package/src/adapters.ts +381 -0
- package/src/calibrate.ts +797 -0
- package/src/cli.ts +866 -385
- package/src/clients.ts +3 -1
- package/src/completions.ts +84 -0
- package/src/config-service.ts +408 -0
- package/src/config-watcher.ts +179 -0
- package/src/config.ts +7 -0
- package/src/context.ts +183 -0
- package/src/dataset-generator.ts +140 -0
- package/src/doctor.ts +43 -0
- package/src/eval.ts +22 -4
- package/src/init.ts +1 -1
- package/src/manifest.ts +124 -10
- package/src/output.ts +151 -0
- package/src/router-core.ts +60 -12
- package/src/server.ts +119 -0
- package/src/snapshot.ts +135 -0
- package/src/sync.ts +29 -8
- package/src/types.ts +7 -0
- package/src/vault.ts +59 -1
package/src/clients.ts
CHANGED
|
@@ -72,7 +72,9 @@ export function createClients(config: Config): Clients {
|
|
|
72
72
|
|
|
73
73
|
const embedding = config.inference.embedding;
|
|
74
74
|
const apiKey = embedding.api_key_env ? process.env[embedding.api_key_env] : undefined;
|
|
75
|
-
const
|
|
75
|
+
const cleanBase = embedding.base_url.replace(/\/$/, "");
|
|
76
|
+
const embedPath = cleanBase.endsWith("/v1") ? "/embeddings" : "/v1/embeddings";
|
|
77
|
+
const response = await fetch(`${cleanBase}${embedPath}`, {
|
|
76
78
|
method: "POST",
|
|
77
79
|
headers: {
|
|
78
80
|
"content-type": "application/json",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type ShellType = "bash" | "zsh" | "fish";
|
|
2
|
+
|
|
3
|
+
export function generateCompletions(shell: ShellType): string {
|
|
4
|
+
if (shell === "bash") {
|
|
5
|
+
return `# bash completion for skillmux
|
|
6
|
+
_skillmux_completions() {
|
|
7
|
+
local cur prev opts
|
|
8
|
+
COMPREPLY=()
|
|
9
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
10
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
11
|
+
opts="context config calibrate serve index sync init report scan install eval doctor which manifest local-vault models completions --context --server --json --allow-insecure --verbose --dry-run --help"
|
|
12
|
+
|
|
13
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
14
|
+
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
|
|
15
|
+
return 0
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
case "$prev" in
|
|
19
|
+
context)
|
|
20
|
+
COMPREPLY=( $(compgen -W "add list current use remove" -- "$cur") )
|
|
21
|
+
;;
|
|
22
|
+
config)
|
|
23
|
+
COMPREPLY=( $(compgen -W "show get validate diff set status" -- "$cur") )
|
|
24
|
+
;;
|
|
25
|
+
calibrate)
|
|
26
|
+
COMPREPLY=( $(compgen -W "run list show apply generate-dataset" -- "$cur") )
|
|
27
|
+
;;
|
|
28
|
+
completions)
|
|
29
|
+
COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
|
|
30
|
+
;;
|
|
31
|
+
manifest)
|
|
32
|
+
COMPREPLY=( $(compgen -W "pin unpin" -- "$cur") )
|
|
33
|
+
;;
|
|
34
|
+
local-vault)
|
|
35
|
+
COMPREPLY=( $(compgen -W "init" -- "$cur") )
|
|
36
|
+
;;
|
|
37
|
+
esac
|
|
38
|
+
}
|
|
39
|
+
complete -F _skillmux_completions skillmux
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (shell === "zsh") {
|
|
44
|
+
return `#compdef skillmux
|
|
45
|
+
_skillmux() {
|
|
46
|
+
local -a commands
|
|
47
|
+
commands=(
|
|
48
|
+
'context:Manage connection contexts'
|
|
49
|
+
'config:Manage configuration'
|
|
50
|
+
'calibrate:Manage policy calibration'
|
|
51
|
+
'serve:Start MCP server'
|
|
52
|
+
'index:Rebuild local search index'
|
|
53
|
+
'sync:Synchronize vault skills'
|
|
54
|
+
'init:Initialize project targets'
|
|
55
|
+
'report:Generate usage stats'
|
|
56
|
+
'scan:Audit skills for issues'
|
|
57
|
+
'install:Install skills into vault'
|
|
58
|
+
'eval:Evaluate search accuracy'
|
|
59
|
+
'doctor:Check runtime health'
|
|
60
|
+
'which:Show which root resolves a skill_id'
|
|
61
|
+
'manifest:Pin/unpin skills into [core] or [project.*]'
|
|
62
|
+
'local-vault:Manage local_vault_paths discoverability markers'
|
|
63
|
+
'models:Manage local models'
|
|
64
|
+
'completions:Generate shell completions'
|
|
65
|
+
)
|
|
66
|
+
_describe -t commands 'skillmux command' commands
|
|
67
|
+
}
|
|
68
|
+
_skillmux "$@"
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (shell === "fish") {
|
|
73
|
+
return `# fish completion for skillmux
|
|
74
|
+
complete -c skillmux -f
|
|
75
|
+
complete -c skillmux -n "__fish_use_subcommand" -a context -d "Manage connection contexts"
|
|
76
|
+
complete -c skillmux -n "__fish_use_subcommand" -a config -d "Manage configuration"
|
|
77
|
+
complete -c skillmux -n "__fish_use_subcommand" -a calibrate -d "Manage policy calibration"
|
|
78
|
+
complete -c skillmux -n "__fish_use_subcommand" -a serve -d "Start MCP server"
|
|
79
|
+
complete -c skillmux -n "__fish_use_subcommand" -a completions -d "Generate shell completions"
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
throw new Error(`Unsupported shell: ${shell}`);
|
|
84
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
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 type { Config } from "./types";
|
|
6
|
+
|
|
7
|
+
export type ConfigSource = "default" | "toml" | "environment";
|
|
8
|
+
export type ConfigSourceMap = Record<string, ConfigSource>;
|
|
9
|
+
|
|
10
|
+
export interface SetConfigResult {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
key: string;
|
|
13
|
+
prior_val: unknown;
|
|
14
|
+
resulting_val: unknown;
|
|
15
|
+
target: string;
|
|
16
|
+
prior_revision: string;
|
|
17
|
+
resulting_revision: string;
|
|
18
|
+
persistence: "persisted" | "not_persisted" | "failed";
|
|
19
|
+
application: "activated" | "restart_required" | "failed";
|
|
20
|
+
readiness: { status: "ready" | "degraded" | "not_ready" | "stopping"; capability: string };
|
|
21
|
+
restart_required_keys: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ConfigStatusResponse {
|
|
25
|
+
target: string;
|
|
26
|
+
desired_source: string;
|
|
27
|
+
desired_source_hash: string;
|
|
28
|
+
active_revision: string;
|
|
29
|
+
active_source_hash: string;
|
|
30
|
+
last_successful_reload_at: string | null;
|
|
31
|
+
last_reload_error: string | null;
|
|
32
|
+
readiness: { status: "ready" | "degraded" | "not_ready" | "stopping"; capability: string };
|
|
33
|
+
restart_required_keys: string[];
|
|
34
|
+
runtime: "running" | "not_running";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const RESTART_REQUIRED_KEYS = [
|
|
38
|
+
"server.hostname",
|
|
39
|
+
"server.auth_enabled",
|
|
40
|
+
"server.auth_token_env",
|
|
41
|
+
"server.admin.enabled",
|
|
42
|
+
"server.admin.token_env",
|
|
43
|
+
"inference.mode",
|
|
44
|
+
"inference.bundle",
|
|
45
|
+
"inference.models_dir",
|
|
46
|
+
"state_dir",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export const RELOADABLE_KEYS = [
|
|
50
|
+
"vault_path",
|
|
51
|
+
"recall.k_lexical",
|
|
52
|
+
"recall.k_vector",
|
|
53
|
+
"thresholds.candidate_limit",
|
|
54
|
+
"thresholds.match_score",
|
|
55
|
+
"thresholds.match_margin",
|
|
56
|
+
"thresholds.candidate_floor",
|
|
57
|
+
"inference.embedding.model",
|
|
58
|
+
"inference.embedding.dimension",
|
|
59
|
+
"inference.embedding.device",
|
|
60
|
+
"inference.embedding.dtype",
|
|
61
|
+
"inference.embedding.base_url",
|
|
62
|
+
"inference.embedding.api_key_env",
|
|
63
|
+
"server.rate_limit.enabled",
|
|
64
|
+
"server.rate_limit.requests_per_minute",
|
|
65
|
+
"server.rate_limit.trust_proxy",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export function getNestedValue(obj: Record<string, any>, path: string): unknown {
|
|
69
|
+
const parts = path.split(".");
|
|
70
|
+
let cur = obj;
|
|
71
|
+
for (const part of parts) {
|
|
72
|
+
if (cur === undefined || cur === null || typeof cur !== "object") return undefined;
|
|
73
|
+
cur = cur[part];
|
|
74
|
+
}
|
|
75
|
+
return cur;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function setNestedValue(obj: Record<string, any>, path: string, value: unknown): void {
|
|
79
|
+
const parts = path.split(".");
|
|
80
|
+
let cur = obj;
|
|
81
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
82
|
+
const part = parts[i]!;
|
|
83
|
+
if (!cur[part] || typeof cur[part] !== "object") {
|
|
84
|
+
cur[part] = {};
|
|
85
|
+
}
|
|
86
|
+
cur = cur[part];
|
|
87
|
+
}
|
|
88
|
+
const lastPart = parts[parts.length - 1]!;
|
|
89
|
+
cur[lastPart] = value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function deleteNestedValue(obj: Record<string, any>, path: string): void {
|
|
93
|
+
const parts = path.split(".");
|
|
94
|
+
let cur = obj;
|
|
95
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
96
|
+
const part = parts[i]!;
|
|
97
|
+
if (!cur[part]) return;
|
|
98
|
+
cur = cur[part];
|
|
99
|
+
}
|
|
100
|
+
const lastPart = parts[parts.length - 1]!;
|
|
101
|
+
delete cur[lastPart];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function computeHash(data: unknown): string {
|
|
105
|
+
const str = typeof data === "string" ? data : JSON.stringify(data);
|
|
106
|
+
return createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function getEffectiveConfig(configPath?: string): Promise<{
|
|
110
|
+
effective: Config;
|
|
111
|
+
sources: ConfigSourceMap;
|
|
112
|
+
rawToml: Record<string, unknown>;
|
|
113
|
+
}> {
|
|
114
|
+
const path = configPath ?? DEFAULT_CONFIG_PATH;
|
|
115
|
+
const effective = await loadConfig(path);
|
|
116
|
+
const rawToml: Record<string, unknown> = {};
|
|
117
|
+
|
|
118
|
+
const fullPath = expandHome(path);
|
|
119
|
+
if (existsSync(fullPath)) {
|
|
120
|
+
try {
|
|
121
|
+
const text = await Bun.file(fullPath).text();
|
|
122
|
+
const parsed = Bun.TOML.parse(text);
|
|
123
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
124
|
+
Object.assign(rawToml, parsed);
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// empty if unparseable
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const sources: ConfigSourceMap = {};
|
|
132
|
+
|
|
133
|
+
const allKeys = [
|
|
134
|
+
"vault_path",
|
|
135
|
+
"state_dir",
|
|
136
|
+
"recall.k_lexical",
|
|
137
|
+
"recall.k_vector",
|
|
138
|
+
"thresholds.candidate_limit",
|
|
139
|
+
"thresholds.match_score",
|
|
140
|
+
"thresholds.match_margin",
|
|
141
|
+
"thresholds.candidate_floor",
|
|
142
|
+
"inference.mode",
|
|
143
|
+
"inference.bundle",
|
|
144
|
+
"inference.models_dir",
|
|
145
|
+
"inference.embedding.model",
|
|
146
|
+
"inference.embedding.dimension",
|
|
147
|
+
"inference.embedding.device",
|
|
148
|
+
"inference.embedding.dtype",
|
|
149
|
+
"inference.embedding.base_url",
|
|
150
|
+
"inference.embedding.api_key_env",
|
|
151
|
+
"inference.timeout_ms",
|
|
152
|
+
"server.auth_enabled",
|
|
153
|
+
"server.auth_token_env",
|
|
154
|
+
"server.admin.enabled",
|
|
155
|
+
"server.admin.token_env",
|
|
156
|
+
"server.hostname",
|
|
157
|
+
"server.rate_limit.enabled",
|
|
158
|
+
"server.rate_limit.requests_per_minute",
|
|
159
|
+
"server.rate_limit.trust_proxy",
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
for (const key of allKeys) {
|
|
163
|
+
if (isEnvMasked(key)) {
|
|
164
|
+
sources[key] = "environment";
|
|
165
|
+
} else if (getNestedValue(rawToml, key) !== undefined) {
|
|
166
|
+
sources[key] = "toml";
|
|
167
|
+
} else {
|
|
168
|
+
sources[key] = "default";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { effective, sources, rawToml };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function isEnvMasked(key: string): boolean {
|
|
176
|
+
if (key === "vault_path" && process.env.VAULT_PATH) return true;
|
|
177
|
+
if (key === "state_dir" && process.env.STATE_DIR) return true;
|
|
178
|
+
if (key === "inference.models_dir" && (process.env.SKILLMUX_MODELS_DIR || process.env.SKILL_ROUTER_MODELS_DIR)) return true;
|
|
179
|
+
if (key === "inference.embedding.device" && process.env.EMBED_DEVICE) return true;
|
|
180
|
+
if (key === "inference.embedding.dtype" && process.env.EMBED_DTYPE) return true;
|
|
181
|
+
if (key === "inference.embedding.base_url" && (process.env.SKILLMUX_EMBED_BASE_URL || process.env.EMBED_BASE_URL)) return true;
|
|
182
|
+
if (key === "inference.embedding.model" && (process.env.SKILLMUX_EMBED_MODEL || process.env.EMBED_MODEL)) return true;
|
|
183
|
+
if (key === "server.auth_enabled" && process.env.HTTP_AUTH_ENABLED) return true;
|
|
184
|
+
if (key === "server.auth_token_env" && process.env.HTTP_AUTH_TOKEN_ENV) return true;
|
|
185
|
+
if (key === "server.hostname" && process.env.HTTP_HOSTNAME) return true;
|
|
186
|
+
if (key === "server.rate_limit.enabled" && (process.env.SKILLMUX_HTTP_RATE_LIMIT_ENABLED || process.env.HTTP_RATE_LIMIT_ENABLED)) return true;
|
|
187
|
+
if (key === "server.rate_limit.requests_per_minute" && (process.env.SKILLMUX_HTTP_RATE_LIMIT_RPM || process.env.HTTP_RATE_LIMIT_RPM)) return true;
|
|
188
|
+
if (key === "server.rate_limit.trust_proxy" && (process.env.SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY || process.env.HTTP_RATE_LIMIT_TRUST_PROXY)) return true;
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function validateDottedKey(key: string): void {
|
|
193
|
+
const allowed = new Set([
|
|
194
|
+
"vault_path",
|
|
195
|
+
"state_dir",
|
|
196
|
+
"recall.k_lexical",
|
|
197
|
+
"recall.k_vector",
|
|
198
|
+
"thresholds.candidate_limit",
|
|
199
|
+
"thresholds.match_score",
|
|
200
|
+
"thresholds.match_margin",
|
|
201
|
+
"thresholds.candidate_floor",
|
|
202
|
+
"inference.mode",
|
|
203
|
+
"inference.bundle",
|
|
204
|
+
"inference.models_dir",
|
|
205
|
+
"inference.embedding.model",
|
|
206
|
+
"inference.embedding.dimension",
|
|
207
|
+
"inference.embedding.device",
|
|
208
|
+
"inference.embedding.dtype",
|
|
209
|
+
"inference.embedding.base_url",
|
|
210
|
+
"inference.embedding.api_key_env",
|
|
211
|
+
"inference.timeout_ms",
|
|
212
|
+
"server.auth_enabled",
|
|
213
|
+
"server.auth_token_env",
|
|
214
|
+
"server.admin.enabled",
|
|
215
|
+
"server.admin.token_env",
|
|
216
|
+
"server.hostname",
|
|
217
|
+
"server.rate_limit.enabled",
|
|
218
|
+
"server.rate_limit.requests_per_minute",
|
|
219
|
+
"server.rate_limit.trust_proxy",
|
|
220
|
+
]);
|
|
221
|
+
if (!allowed.has(key)) {
|
|
222
|
+
throw new Error(`Unknown configuration key "${key}"`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function parseDottedValue(key: string, valueStr: string): unknown {
|
|
227
|
+
if (valueStr === "true") return true;
|
|
228
|
+
if (valueStr === "false") return false;
|
|
229
|
+
if (/^-?\d+$/.test(valueStr)) return parseInt(valueStr, 10);
|
|
230
|
+
if (/^-?\d+\.\d+$/.test(valueStr)) return parseFloat(valueStr);
|
|
231
|
+
|
|
232
|
+
const numberKeys = new Set([
|
|
233
|
+
"recall.k_lexical",
|
|
234
|
+
"recall.k_vector",
|
|
235
|
+
"thresholds.candidate_limit",
|
|
236
|
+
"thresholds.match_score",
|
|
237
|
+
"thresholds.match_margin",
|
|
238
|
+
"thresholds.candidate_floor",
|
|
239
|
+
"inference.embedding.dimension",
|
|
240
|
+
"inference.timeout_ms",
|
|
241
|
+
"server.rate_limit.requests_per_minute",
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
if (numberKeys.has(key)) {
|
|
245
|
+
const num = Number(valueStr);
|
|
246
|
+
if (isNaN(num)) {
|
|
247
|
+
throw new Error(`Key "${key}" expects a numeric value, got "${valueStr}"`);
|
|
248
|
+
}
|
|
249
|
+
return num;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const booleanKeys = new Set([
|
|
253
|
+
"server.auth_enabled",
|
|
254
|
+
"server.admin.enabled",
|
|
255
|
+
"server.rate_limit.enabled",
|
|
256
|
+
"server.rate_limit.trust_proxy",
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
if (booleanKeys.has(key)) {
|
|
260
|
+
throw new Error(`Key "${key}" expects a boolean value ("true" or "false"), got "${valueStr}"`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return valueStr;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function getDottedKey(key: string, configPath?: string): Promise<unknown> {
|
|
267
|
+
validateDottedKey(key);
|
|
268
|
+
const { effective } = await getEffectiveConfig(configPath);
|
|
269
|
+
return getNestedValue(effective as Record<string, any>, key);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function setDottedKey(
|
|
273
|
+
key: string,
|
|
274
|
+
rawValStr: string,
|
|
275
|
+
opts?: { configPath?: string; dryRun?: boolean; targetName?: string }
|
|
276
|
+
): Promise<SetConfigResult> {
|
|
277
|
+
validateDottedKey(key);
|
|
278
|
+
if (isEnvMasked(key)) {
|
|
279
|
+
throw new Error(`Cannot set environment-masked configuration key "${key}"`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const path = opts?.configPath ?? DEFAULT_CONFIG_PATH;
|
|
283
|
+
const targetName = opts?.targetName ?? "local";
|
|
284
|
+
|
|
285
|
+
const { effective: priorEffective, rawToml } = await getEffectiveConfig(path);
|
|
286
|
+
const priorVal = getNestedValue(priorEffective as Record<string, any>, key);
|
|
287
|
+
const parsedVal = parseDottedValue(key, rawValStr);
|
|
288
|
+
|
|
289
|
+
const updatedToml = structuredClone(rawToml);
|
|
290
|
+
setNestedValue(updatedToml, key, parsedVal);
|
|
291
|
+
|
|
292
|
+
const priorRevision = computeHash(priorEffective);
|
|
293
|
+
|
|
294
|
+
let resultingRevision = priorRevision;
|
|
295
|
+
let persistence: "persisted" | "not_persisted" | "failed" = "not_persisted";
|
|
296
|
+
|
|
297
|
+
if (!opts?.dryRun) {
|
|
298
|
+
const fullPath = expandHome(path);
|
|
299
|
+
const dir = dirname(fullPath);
|
|
300
|
+
mkdirSync(dir, { recursive: true });
|
|
301
|
+
|
|
302
|
+
let existingMode = 0o644;
|
|
303
|
+
if (existsSync(fullPath)) {
|
|
304
|
+
try {
|
|
305
|
+
existingMode = statSync(fullPath).mode;
|
|
306
|
+
} catch {
|
|
307
|
+
// default
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const tmpPath = join(dir, `.config-${Math.random().toString(36).slice(2)}.tmp`);
|
|
312
|
+
const newTomlText = stringifyToml(updatedToml);
|
|
313
|
+
writeFileSync(tmpPath, newTomlText, { mode: existingMode, encoding: "utf-8" });
|
|
314
|
+
renameSync(tmpPath, fullPath);
|
|
315
|
+
|
|
316
|
+
persistence = "persisted";
|
|
317
|
+
|
|
318
|
+
const { effective: newEffective } = await getEffectiveConfig(path);
|
|
319
|
+
resultingRevision = computeHash(newEffective);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const isRestartRequired = RESTART_REQUIRED_KEYS.some((k) => key === k || key.startsWith(k + "."));
|
|
323
|
+
const application = isRestartRequired ? "restart_required" : "activated";
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
ok: true,
|
|
327
|
+
key,
|
|
328
|
+
prior_val: priorVal,
|
|
329
|
+
resulting_val: parsedVal,
|
|
330
|
+
target: targetName,
|
|
331
|
+
prior_revision: priorRevision,
|
|
332
|
+
resulting_revision: resultingRevision,
|
|
333
|
+
persistence,
|
|
334
|
+
application,
|
|
335
|
+
readiness: { status: "ready", capability: "hybrid" },
|
|
336
|
+
restart_required_keys: isRestartRequired ? [key] : [],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function stringifyToml(obj: Record<string, any>): string {
|
|
341
|
+
let out = "";
|
|
342
|
+
const topLevel: Record<string, any> = {};
|
|
343
|
+
const sections: Record<string, any> = {};
|
|
344
|
+
|
|
345
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
346
|
+
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
347
|
+
sections[k] = v;
|
|
348
|
+
} else {
|
|
349
|
+
topLevel[k] = v;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const [k, v] of Object.entries(topLevel)) {
|
|
354
|
+
out += `${k} = ${formatTomlVal(v)}\n`;
|
|
355
|
+
}
|
|
356
|
+
if (Object.keys(topLevel).length > 0) out += "\n";
|
|
357
|
+
|
|
358
|
+
for (const [secName, secObj] of Object.entries(sections)) {
|
|
359
|
+
out += stringifyTomlSection([secName], secObj);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function stringifyTomlSection(path: string[], obj: Record<string, any>): string {
|
|
366
|
+
let out = `[${path.join(".")}]\n`;
|
|
367
|
+
const subSections: Record<string, any> = {};
|
|
368
|
+
|
|
369
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
370
|
+
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
371
|
+
subSections[k] = v;
|
|
372
|
+
} else {
|
|
373
|
+
out += `${k} = ${formatTomlVal(v)}\n`;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
out += "\n";
|
|
377
|
+
|
|
378
|
+
for (const [subName, subObj] of Object.entries(subSections)) {
|
|
379
|
+
out += stringifyTomlSection([...path, subName], subObj);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function formatTomlVal(v: unknown): string {
|
|
386
|
+
if (typeof v === "string") return JSON.stringify(v);
|
|
387
|
+
if (typeof v === "boolean" || typeof v === "number") return String(v);
|
|
388
|
+
if (Array.isArray(v)) return JSON.stringify(v);
|
|
389
|
+
return JSON.stringify(v);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function getLocalConfigStatus(configPath?: string): Promise<ConfigStatusResponse> {
|
|
393
|
+
const { effective } = await getEffectiveConfig(configPath);
|
|
394
|
+
const hash = computeHash(effective);
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
target: "local",
|
|
398
|
+
desired_source: configPath ?? DEFAULT_CONFIG_PATH,
|
|
399
|
+
desired_source_hash: hash,
|
|
400
|
+
active_revision: hash,
|
|
401
|
+
active_source_hash: hash,
|
|
402
|
+
last_successful_reload_at: new Date().toISOString(),
|
|
403
|
+
last_reload_error: null,
|
|
404
|
+
readiness: { status: "ready", capability: "hybrid" },
|
|
405
|
+
restart_required_keys: [],
|
|
406
|
+
runtime: "not_running",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { 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
|
+
"inference.thresholds.match_score",
|
|
15
|
+
"inference.thresholds.match_margin",
|
|
16
|
+
"inference.thresholds.candidate_floor",
|
|
17
|
+
"recall.k_lexical",
|
|
18
|
+
"recall.k_vector",
|
|
19
|
+
"thresholds.candidate_limit",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Status type (AC10)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface ReloadStatus {
|
|
27
|
+
/** ISO timestamp of the last successful reload, or null if never reloaded. */
|
|
28
|
+
last_successful_reload_at: string | null;
|
|
29
|
+
/** Error message from the last failed reload, or null if last reload succeeded. */
|
|
30
|
+
last_reload_error: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Keys that changed outside the live-reload allowlist.
|
|
33
|
+
* Non-empty means a restart is needed to activate those changes.
|
|
34
|
+
*/
|
|
35
|
+
restart_required_keys: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// ConfigWatcher (AC8, AC9, AC10)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export interface ConfigWatcherOptions {
|
|
43
|
+
/** Called on every successful, complete reload. */
|
|
44
|
+
onReload: (config: Config) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Called when the file cannot be read, parsed, or validated.
|
|
47
|
+
* The previous good snapshot remains active — watcher does NOT crash.
|
|
48
|
+
*/
|
|
49
|
+
onError: (error: unknown) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const DEBOUNCE_MS = 300;
|
|
53
|
+
const STABLE_STAT_INTERVAL_MS = 80;
|
|
54
|
+
const STABLE_STAT_MAX_TRIES = 8;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Watch the parent directory of a TOML config file for changes.
|
|
58
|
+
* Parent-dir watching catches both direct writes (change events) and
|
|
59
|
+
* atomic renames (rename events) — both are needed for AC8.
|
|
60
|
+
*
|
|
61
|
+
* Reloading is transactional:
|
|
62
|
+
* 1. Debounce burst events
|
|
63
|
+
* 2. Wait for file size/mtime to stabilise (stable candidate read)
|
|
64
|
+
* 3. Parse + validate the full config
|
|
65
|
+
* 4. Call onReload only on success; on failure call onError (LKG stays)
|
|
66
|
+
*
|
|
67
|
+
* Only keys on LIVE_RELOAD_KEYS may trigger onReload without restart_required.
|
|
68
|
+
*/
|
|
69
|
+
export class ConfigWatcher {
|
|
70
|
+
private status: ReloadStatus = {
|
|
71
|
+
last_successful_reload_at: null,
|
|
72
|
+
last_reload_error: null,
|
|
73
|
+
restart_required_keys: [],
|
|
74
|
+
};
|
|
75
|
+
private stopped = false;
|
|
76
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
77
|
+
private watcher: ReturnType<typeof watch>;
|
|
78
|
+
|
|
79
|
+
private constructor(
|
|
80
|
+
private readonly tomlPath: string,
|
|
81
|
+
private readonly opts: ConfigWatcherOptions,
|
|
82
|
+
) {
|
|
83
|
+
const dir = dirname(tomlPath);
|
|
84
|
+
const filename = tomlPath.split(/[/\\]/).pop()!;
|
|
85
|
+
|
|
86
|
+
this.watcher = watch(dir, { recursive: false }, (_event, changedName) => {
|
|
87
|
+
if (this.stopped) return;
|
|
88
|
+
// Fire for: the config file itself, or any .tmp variant of it (handles
|
|
89
|
+
// pid-numbered atomics: config.toml.12345.tmp → rename → config.toml).
|
|
90
|
+
// null/undefined changedName means directory-level change — treat as a hit.
|
|
91
|
+
if (
|
|
92
|
+
changedName &&
|
|
93
|
+
changedName !== filename &&
|
|
94
|
+
!changedName.startsWith(filename)
|
|
95
|
+
) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.scheduleReload();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.watcher.on("error", (err) => {
|
|
102
|
+
if (!this.stopped) {
|
|
103
|
+
this.status = { ...this.status, last_reload_error: String(err) };
|
|
104
|
+
this.opts.onError(err);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
static async start(
|
|
110
|
+
tomlPath: string,
|
|
111
|
+
opts: ConfigWatcherOptions,
|
|
112
|
+
): Promise<ConfigWatcher> {
|
|
113
|
+
return new ConfigWatcher(tomlPath, opts);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private scheduleReload(): void {
|
|
117
|
+
if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
|
|
118
|
+
this.debounceTimer = setTimeout(() => {
|
|
119
|
+
this.debounceTimer = null;
|
|
120
|
+
void this.doReload();
|
|
121
|
+
}, DEBOUNCE_MS);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async doReload(): Promise<void> {
|
|
125
|
+
if (this.stopped) return;
|
|
126
|
+
|
|
127
|
+
// Wait for stable file (size + mtime stop changing)
|
|
128
|
+
await this.waitForStable();
|
|
129
|
+
if (this.stopped) return;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const config = await loadConfig(this.tomlPath);
|
|
133
|
+
this.status = {
|
|
134
|
+
last_successful_reload_at: new Date().toISOString(),
|
|
135
|
+
last_reload_error: null,
|
|
136
|
+
restart_required_keys: this.status.restart_required_keys,
|
|
137
|
+
};
|
|
138
|
+
this.opts.onReload(config);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
this.status = {
|
|
141
|
+
...this.status,
|
|
142
|
+
last_reload_error: err instanceof Error ? err.message : String(err),
|
|
143
|
+
};
|
|
144
|
+
this.opts.onError(err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async waitForStable(): Promise<void> {
|
|
149
|
+
let previous = "";
|
|
150
|
+
for (let i = 0; i < STABLE_STAT_MAX_TRIES; i++) {
|
|
151
|
+
const file = Bun.file(this.tomlPath);
|
|
152
|
+
if (!(await file.exists())) return;
|
|
153
|
+
const current = `${file.size}:${file.lastModified}`;
|
|
154
|
+
if (current === previous) return;
|
|
155
|
+
previous = current;
|
|
156
|
+
await Bun.sleep(STABLE_STAT_INTERVAL_MS);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Get a snapshot of the current watcher status. */
|
|
161
|
+
reloadStatus(): ReloadStatus {
|
|
162
|
+
return { ...this.status };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Stop the watcher and cancel any pending debounce. Idempotent. */
|
|
166
|
+
stop(): void {
|
|
167
|
+
if (this.stopped) return;
|
|
168
|
+
this.stopped = true;
|
|
169
|
+
if (this.debounceTimer !== null) {
|
|
170
|
+
clearTimeout(this.debounceTimer);
|
|
171
|
+
this.debounceTimer = null;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
this.watcher.close();
|
|
175
|
+
} catch {
|
|
176
|
+
// already closed
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|