@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/adapters.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { applyCalibrationRun, getCalibrationRun, listCalibrationRuns, loadDecisionCasesFromFile, openCalibrateDb, runCalibration, type CalibrationResult } from "./calibrate";
|
|
4
|
+
import { createClients } from "./clients";
|
|
5
|
+
import { DEFAULT_CONFIG_PATH, expandHome, loadConfig } from "./config";
|
|
6
|
+
import {
|
|
7
|
+
computeHash,
|
|
8
|
+
getDottedKey,
|
|
9
|
+
getEffectiveConfig,
|
|
10
|
+
getLocalConfigStatus,
|
|
11
|
+
getNestedValue,
|
|
12
|
+
RELOADABLE_KEYS,
|
|
13
|
+
RESTART_REQUIRED_KEYS,
|
|
14
|
+
setDottedKey,
|
|
15
|
+
validateDottedKey,
|
|
16
|
+
type ConfigStatusResponse,
|
|
17
|
+
type SetConfigResult,
|
|
18
|
+
} from "./config-service";
|
|
19
|
+
import type { ResolvedTarget } from "./context";
|
|
20
|
+
import { resolveSkill } from "./router-core";
|
|
21
|
+
import type { Config } from "./types";
|
|
22
|
+
|
|
23
|
+
export interface Capabilities {
|
|
24
|
+
config_read: boolean;
|
|
25
|
+
config_write: boolean;
|
|
26
|
+
calibration: boolean;
|
|
27
|
+
persistence: "writable" | "externally_managed";
|
|
28
|
+
reloadable_keys: string[];
|
|
29
|
+
restart_required_keys: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TargetAdapterOptions {
|
|
33
|
+
configPath?: string;
|
|
34
|
+
allowInsecure?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TargetAdapter {
|
|
38
|
+
getCapabilities(): Promise<Capabilities>;
|
|
39
|
+
getConfigShow(): Promise<{ effective: Config; sources: Record<string, string>; active_revision: string }>;
|
|
40
|
+
getConfigGet(key: string): Promise<unknown>;
|
|
41
|
+
configValidate(): Promise<{ valid: boolean; readiness: unknown }>;
|
|
42
|
+
configDiff(): Promise<{ diff: Record<string, { prior: unknown; resulting: unknown }> }>;
|
|
43
|
+
configSet(key: string, rawValStr: string, opts?: { dryRun?: boolean }): Promise<SetConfigResult>;
|
|
44
|
+
configStatus(): Promise<ConfigStatusResponse>;
|
|
45
|
+
calibrateRun(opts?: { datasetPath?: string }): Promise<{ run_id?: string; result?: CalibrationResult }>;
|
|
46
|
+
calibrateList(): Promise<any[]>;
|
|
47
|
+
calibrateShow(runId: string): Promise<any>;
|
|
48
|
+
calibrateApply(runId: string): Promise<any>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isLoopbackHost(hostname: string): boolean {
|
|
52
|
+
return (
|
|
53
|
+
hostname === "localhost" ||
|
|
54
|
+
hostname === "127.0.0.1" ||
|
|
55
|
+
hostname === "::1" ||
|
|
56
|
+
hostname === "0.0.0.0" ||
|
|
57
|
+
hostname.startsWith("127.")
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class LocalAdapter implements TargetAdapter {
|
|
62
|
+
private configPath: string;
|
|
63
|
+
|
|
64
|
+
constructor(opts?: TargetAdapterOptions) {
|
|
65
|
+
this.configPath = opts?.configPath ?? DEFAULT_CONFIG_PATH;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async getCapabilities(): Promise<Capabilities> {
|
|
69
|
+
const isExternallyManaged = process.env.SKILLMUX_CONFIG_READONLY === "true";
|
|
70
|
+
return {
|
|
71
|
+
config_read: true,
|
|
72
|
+
config_write: !isExternallyManaged,
|
|
73
|
+
calibration: true,
|
|
74
|
+
persistence: isExternallyManaged ? "externally_managed" : "writable",
|
|
75
|
+
reloadable_keys: RELOADABLE_KEYS,
|
|
76
|
+
restart_required_keys: RESTART_REQUIRED_KEYS,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async getConfigShow(): Promise<{ effective: Config; sources: Record<string, string>; active_revision: string }> {
|
|
81
|
+
const { effective, sources } = await getEffectiveConfig(this.configPath);
|
|
82
|
+
return {
|
|
83
|
+
effective,
|
|
84
|
+
sources,
|
|
85
|
+
active_revision: computeHash(effective),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getConfigGet(key: string): Promise<unknown> {
|
|
90
|
+
return getDottedKey(key, this.configPath);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async configValidate(): Promise<{ valid: boolean; readiness: unknown }> {
|
|
94
|
+
const { effective } = await getEffectiveConfig(this.configPath);
|
|
95
|
+
return { valid: !!effective, readiness: { status: "ready", capability: "hybrid" } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async configDiff(): Promise<{ diff: Record<string, { prior: unknown; resulting: unknown }> }> {
|
|
99
|
+
const { effective, sources } = await getEffectiveConfig(this.configPath);
|
|
100
|
+
const diff: Record<string, { prior: unknown; resulting: unknown }> = {};
|
|
101
|
+
for (const [k, src] of Object.entries(sources)) {
|
|
102
|
+
if (src !== "default") {
|
|
103
|
+
diff[k] = { prior: "default", resulting: getNestedValue(effective as any, k) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { diff };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async configSet(key: string, rawValStr: string, opts?: { dryRun?: boolean }): Promise<SetConfigResult> {
|
|
110
|
+
const caps = await this.getCapabilities();
|
|
111
|
+
if (caps.persistence === "externally_managed") {
|
|
112
|
+
throw new Error("Configuration is externally managed and cannot be modified");
|
|
113
|
+
}
|
|
114
|
+
return setDottedKey(key, rawValStr, {
|
|
115
|
+
configPath: this.configPath,
|
|
116
|
+
dryRun: opts?.dryRun,
|
|
117
|
+
targetName: "local",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async configStatus(): Promise<ConfigStatusResponse> {
|
|
122
|
+
return getLocalConfigStatus(this.configPath);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async calibrateRun(opts?: { datasetPath?: string }): Promise<{ run_id?: string; result?: CalibrationResult }> {
|
|
126
|
+
const config = await loadConfig(this.configPath);
|
|
127
|
+
const datasetFile = opts?.datasetPath ?? join(expandHome(config.state_dir), "queries.json");
|
|
128
|
+
const cases = loadDecisionCasesFromFile(datasetFile);
|
|
129
|
+
const clients = createClients(config);
|
|
130
|
+
const result = await runCalibration({
|
|
131
|
+
cases,
|
|
132
|
+
getCandidates: async (query: string) => {
|
|
133
|
+
const res = await resolveSkill({ query, forceLexical: false });
|
|
134
|
+
if (res.outcome === "matched") {
|
|
135
|
+
return [{ skill_id: res.skill_id, text: `${res.title} ${res.body}` }];
|
|
136
|
+
}
|
|
137
|
+
if (res.outcome === "ambiguous") {
|
|
138
|
+
return res.candidates.map((c) => ({ skill_id: c.skill_id, text: `${c.title} ${c.description}` }));
|
|
139
|
+
}
|
|
140
|
+
return [];
|
|
141
|
+
},
|
|
142
|
+
reranker: clients.rerank,
|
|
143
|
+
});
|
|
144
|
+
return { result };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async calibrateList(): Promise<any[]> {
|
|
148
|
+
const config = await loadConfig(this.configPath);
|
|
149
|
+
const db = openCalibrateDb(expandHome(config.state_dir));
|
|
150
|
+
try {
|
|
151
|
+
return listCalibrationRuns(db);
|
|
152
|
+
} finally {
|
|
153
|
+
db.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async calibrateShow(runId: string): Promise<any> {
|
|
158
|
+
const config = await loadConfig(this.configPath);
|
|
159
|
+
const db = openCalibrateDb(expandHome(config.state_dir));
|
|
160
|
+
try {
|
|
161
|
+
const run = getCalibrationRun(db, runId);
|
|
162
|
+
if (!run) throw new Error(`Calibration run "${runId}" not found`);
|
|
163
|
+
return run;
|
|
164
|
+
} finally {
|
|
165
|
+
db.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async calibrateApply(runId: string): Promise<any> {
|
|
170
|
+
const config = await loadConfig(this.configPath);
|
|
171
|
+
const db = openCalibrateDb(expandHome(config.state_dir));
|
|
172
|
+
try {
|
|
173
|
+
const run = getCalibrationRun(db, runId);
|
|
174
|
+
if (!run) throw new Error(`Calibration run "${runId}" not found`);
|
|
175
|
+
await applyCalibrationRun(db, runId, expandHome(this.configPath), {});
|
|
176
|
+
return { ok: true, run_id: runId };
|
|
177
|
+
} finally {
|
|
178
|
+
db.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export class RemoteAdapter implements TargetAdapter {
|
|
184
|
+
private serverUrl: string;
|
|
185
|
+
private tokenEnv?: string;
|
|
186
|
+
private allowInsecure: boolean;
|
|
187
|
+
|
|
188
|
+
constructor(target: { server: string; token_env?: string }, opts?: TargetAdapterOptions) {
|
|
189
|
+
this.serverUrl = target.server.replace(/\/$/, "");
|
|
190
|
+
this.tokenEnv = target.token_env;
|
|
191
|
+
this.allowInsecure = opts?.allowInsecure ?? false;
|
|
192
|
+
|
|
193
|
+
this.validateSecurity();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private validateSecurity(): void {
|
|
197
|
+
try {
|
|
198
|
+
const url = new URL(this.serverUrl);
|
|
199
|
+
if (url.protocol === "http:" && !isLoopbackHost(url.hostname) && !this.allowInsecure) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Plaintext HTTP admin targets are not allowed for non-loopback server "${this.serverUrl}". Pass --allow-insecure to bypass.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
} catch (err: any) {
|
|
205
|
+
if (err.message.includes("Plaintext HTTP")) throw err;
|
|
206
|
+
throw new Error(`Invalid server URL "${this.serverUrl}"`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private getAuthHeader(): Record<string, string> {
|
|
211
|
+
const envVar = this.tokenEnv || "SKILLMUX_ADMIN_TOKEN";
|
|
212
|
+
const token = process.env[envVar];
|
|
213
|
+
if (!token) {
|
|
214
|
+
throw new Error(`Environment variable "${envVar}" for administrative authentication is empty`);
|
|
215
|
+
}
|
|
216
|
+
return { Authorization: `Bearer ${token}` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async fetchJson(path: string, options: RequestInit = {}): Promise<{ status: number; headers: Headers; data: any }> {
|
|
220
|
+
const url = `${this.serverUrl}${path}`;
|
|
221
|
+
const headers = new Headers(options.headers);
|
|
222
|
+
for (const [k, v] of Object.entries(this.getAuthHeader())) {
|
|
223
|
+
headers.set(k, v);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const res = await fetch(url, { ...options, headers });
|
|
228
|
+
const text = await res.text();
|
|
229
|
+
let data: any = text;
|
|
230
|
+
try {
|
|
231
|
+
data = JSON.parse(text);
|
|
232
|
+
} catch {
|
|
233
|
+
// text
|
|
234
|
+
}
|
|
235
|
+
return { status: res.status, headers: res.headers, data };
|
|
236
|
+
} catch (err: any) {
|
|
237
|
+
throw new Error(`Failed to reach remote server "${this.serverUrl}": ${err.message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async getCapabilities(): Promise<Capabilities> {
|
|
242
|
+
const { status, data } = await this.fetchJson("/admin/v1/capabilities");
|
|
243
|
+
if (status !== 200) {
|
|
244
|
+
throw new Error(`Remote capability discovery failed (${status}): ${typeof data === "object" ? data.message || data.error : data}`);
|
|
245
|
+
}
|
|
246
|
+
return data as Capabilities;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async getConfigShow(): Promise<{ effective: Config; sources: Record<string, string>; active_revision: string }> {
|
|
250
|
+
const { status, data } = await this.fetchJson("/admin/v1/config");
|
|
251
|
+
if (status !== 200) {
|
|
252
|
+
throw new Error(`Remote config fetch failed (${status}): ${typeof data === "object" ? data.message || data.error : data}`);
|
|
253
|
+
}
|
|
254
|
+
return data;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async getConfigGet(key: string): Promise<unknown> {
|
|
258
|
+
validateDottedKey(key);
|
|
259
|
+
const { effective } = await this.getConfigShow();
|
|
260
|
+
return getNestedValue(effective as any, key);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async configValidate(): Promise<{ valid: boolean; readiness: unknown }> {
|
|
264
|
+
const show = await this.getConfigShow();
|
|
265
|
+
return { valid: true, readiness: show.effective };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async configDiff(): Promise<{ diff: Record<string, { prior: unknown; resulting: unknown }> }> {
|
|
269
|
+
const { effective, sources } = await this.getConfigShow();
|
|
270
|
+
const diff: Record<string, { prior: unknown; resulting: unknown }> = {};
|
|
271
|
+
for (const [k, src] of Object.entries(sources)) {
|
|
272
|
+
if (src !== "default") {
|
|
273
|
+
diff[k] = { prior: "default", resulting: getNestedValue(effective as any, k) };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { diff };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async configSet(key: string, rawValStr: string, opts?: { dryRun?: boolean }): Promise<SetConfigResult> {
|
|
280
|
+
const caps = await this.getCapabilities();
|
|
281
|
+
if (!caps.config_write || caps.persistence === "externally_managed") {
|
|
282
|
+
throw new Error("Remote server configuration is externally managed or read-only");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const { status: showStatus, headers: showHeaders, data: showData } = await this.fetchJson("/admin/v1/config");
|
|
286
|
+
if (showStatus !== 200) {
|
|
287
|
+
throw new Error(`Remote config fetch failed (${showStatus})`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const etag = showHeaders.get("etag") || `"${showData.active_revision}"`;
|
|
291
|
+
|
|
292
|
+
if (opts?.dryRun) {
|
|
293
|
+
const priorVal = getNestedValue(showData.effective, key);
|
|
294
|
+
return {
|
|
295
|
+
ok: true,
|
|
296
|
+
key,
|
|
297
|
+
prior_val: priorVal,
|
|
298
|
+
resulting_val: rawValStr,
|
|
299
|
+
target: "remote",
|
|
300
|
+
prior_revision: showData.active_revision,
|
|
301
|
+
resulting_revision: showData.active_revision,
|
|
302
|
+
persistence: "not_persisted",
|
|
303
|
+
application: "activated",
|
|
304
|
+
readiness: { status: "ready", capability: "hybrid" },
|
|
305
|
+
restart_required_keys: [],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const { status, data } = await this.fetchJson("/admin/v1/config", {
|
|
310
|
+
method: "PATCH",
|
|
311
|
+
headers: {
|
|
312
|
+
"Content-Type": "application/json",
|
|
313
|
+
"If-Match": etag,
|
|
314
|
+
},
|
|
315
|
+
body: JSON.stringify({ changes: { [key]: rawValStr } }),
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
if (status === 409) {
|
|
319
|
+
if (data?.error === "CONFIG_REVISION_CONFLICT") {
|
|
320
|
+
throw new Error(`Revision conflict: ${data.message || "Remote configuration was modified concurrently"}`);
|
|
321
|
+
}
|
|
322
|
+
if (data?.error === "CONFIG_EXTERNALLY_MANAGED") {
|
|
323
|
+
throw new Error("Configuration is externally managed on the remote server");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (status !== 200) {
|
|
328
|
+
throw new Error(`Remote config set failed (${status}): ${data?.message || data?.error || data}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return data;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async configStatus(): Promise<ConfigStatusResponse> {
|
|
335
|
+
const { status, data } = await this.fetchJson("/admin/v1/config");
|
|
336
|
+
if (status !== 200) {
|
|
337
|
+
throw new Error(`Remote status fetch failed (${status}): ${data?.message || data}`);
|
|
338
|
+
}
|
|
339
|
+
return data.runtime;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async calibrateRun(opts?: { datasetPath?: string }): Promise<{ run_id?: string; result?: CalibrationResult }> {
|
|
343
|
+
const { status, data } = await this.fetchJson("/admin/v1/calibrations", {
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: { "Content-Type": "application/json" },
|
|
346
|
+
body: JSON.stringify({ dataset_path: opts?.datasetPath }),
|
|
347
|
+
});
|
|
348
|
+
if (status !== 202) {
|
|
349
|
+
throw new Error(`Remote calibration start failed (${status}): ${data?.message || data}`);
|
|
350
|
+
}
|
|
351
|
+
return data;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async calibrateList(): Promise<any[]> {
|
|
355
|
+
const { status, data } = await this.fetchJson("/admin/v1/calibrations");
|
|
356
|
+
if (status !== 200) throw new Error(`Remote calibration list failed (${status}): ${data?.message || data}`);
|
|
357
|
+
return data;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async calibrateShow(runId: string): Promise<any> {
|
|
361
|
+
const { status, data } = await this.fetchJson(`/admin/v1/calibrations/${runId}`);
|
|
362
|
+
if (status !== 200) throw new Error(`Remote calibration show failed (${status}): ${data?.message || data}`);
|
|
363
|
+
return data;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async calibrateApply(runId: string): Promise<any> {
|
|
367
|
+
const { status, data } = await this.fetchJson(`/admin/v1/calibrations/${runId}/apply`, {
|
|
368
|
+
method: "POST",
|
|
369
|
+
});
|
|
370
|
+
if (status !== 200) throw new Error(`Remote calibration apply failed (${status}): ${data?.message || data}`);
|
|
371
|
+
return data;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function createTargetAdapter(target: ResolvedTarget, opts?: TargetAdapterOptions): TargetAdapter {
|
|
376
|
+
if (target.type === "local") {
|
|
377
|
+
return new LocalAdapter(opts);
|
|
378
|
+
} else {
|
|
379
|
+
return new RemoteAdapter({ server: target.server, token_env: target.token_env }, opts);
|
|
380
|
+
}
|
|
381
|
+
}
|