@klhapp/skillmux 1.5.2 → 1.7.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 +21 -0
- package/README.md +7 -7
- package/config.example.toml +5 -0
- package/config.remote.example.toml +4 -7
- package/docs/README.md +4 -4
- package/docs/assets/architecture.svg +1 -1
- package/docs/cli.md +4 -45
- package/docs/concepts.md +12 -14
- package/docs/configuration.md +10 -11
- package/docs/deployment.md +11 -8
- package/docs/getting-started.md +1 -1
- package/docs/mcp-routing.md +28 -23
- package/docs/ranked-shortlist-migration.md +160 -0
- package/docs/schema.json +37 -139
- package/docs/skill-management.md +7 -2
- package/docs/troubleshooting.md +12 -14
- package/package.json +1 -1
- package/src/adapters.ts +1 -422
- package/src/audit.ts +1 -3
- package/src/cli.ts +19 -229
- package/src/completions.ts +0 -4
- package/src/config-service.ts +19 -32
- package/src/config-watcher.ts +2 -6
- package/src/config.ts +61 -60
- package/src/db.ts +32 -18
- package/src/doctor.ts +1 -84
- package/src/eval.ts +143 -60
- package/src/init.ts +4 -5
- package/src/metrics.ts +1 -13
- package/src/router-core.ts +42 -149
- package/src/server.ts +13 -27
- package/src/stats.ts +126 -57
- package/src/types.ts +8 -39
- package/docs/calibration.md +0 -162
- package/src/calibrate.ts +0 -1594
- package/src/config-mutation.ts +0 -65
- package/src/dataset-generator.ts +0 -119
- package/src/decision.ts +0 -45
package/src/cli.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { Database } from "bun:sqlite";
|
|
|
4
4
|
import { existsSync, lstatSync, mkdirSync, rmSync } from "node:fs";
|
|
5
5
|
import { hostname } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { generateDataset } from "./dataset-generator";
|
|
8
7
|
|
|
9
8
|
import { createClients } from "./clients";
|
|
10
9
|
import {
|
|
@@ -116,7 +115,6 @@ import { runTarget } from "./commands/target";
|
|
|
116
115
|
const KNOWN_COMMANDS = [
|
|
117
116
|
"context",
|
|
118
117
|
"config",
|
|
119
|
-
"calibrate",
|
|
120
118
|
"completions",
|
|
121
119
|
"serve",
|
|
122
120
|
"index",
|
|
@@ -147,7 +145,6 @@ function isDockerHostManagementCommand(command: string, subCommand: string): boo
|
|
|
147
145
|
"local-vault",
|
|
148
146
|
"models",
|
|
149
147
|
"context",
|
|
150
|
-
"calibrate",
|
|
151
148
|
"eval",
|
|
152
149
|
].includes(command)
|
|
153
150
|
) {
|
|
@@ -210,7 +207,7 @@ async function main() {
|
|
|
210
207
|
return;
|
|
211
208
|
}
|
|
212
209
|
|
|
213
|
-
// Parse global flags for context/config
|
|
210
|
+
// Parse global flags for context/config
|
|
214
211
|
for (let i = 0; i < rawArgv.length; i++) {
|
|
215
212
|
const arg = rawArgv[i];
|
|
216
213
|
if (arg === "--json") isJson = true;
|
|
@@ -235,10 +232,10 @@ async function main() {
|
|
|
235
232
|
return;
|
|
236
233
|
}
|
|
237
234
|
|
|
238
|
-
// Only resolve target if command is target-aware or context/config
|
|
235
|
+
// Only resolve target if command is target-aware or context/config
|
|
239
236
|
const isLocalConfigInit = command === "config" && rawArgv[1] === "init";
|
|
240
237
|
if (
|
|
241
|
-
(["context", "config"
|
|
238
|
+
(["context", "config"].includes(command) &&
|
|
242
239
|
!isLocalConfigInit) ||
|
|
243
240
|
flagContext ||
|
|
244
241
|
flagServer
|
|
@@ -272,11 +269,9 @@ async function main() {
|
|
|
272
269
|
});
|
|
273
270
|
break;
|
|
274
271
|
case "calibrate":
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
});
|
|
279
|
-
break;
|
|
272
|
+
throw new Error(
|
|
273
|
+
'skillmux calibrate was removed in 2.0. Threshold calibration was removed; use "skillmux eval" for ranking evaluation.',
|
|
274
|
+
);
|
|
280
275
|
case "completions":
|
|
281
276
|
await handleCompletionsCommand(subCommand);
|
|
282
277
|
break;
|
|
@@ -364,7 +359,7 @@ async function main() {
|
|
|
364
359
|
const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
|
|
365
360
|
const msg = suggestion
|
|
366
361
|
? `Unknown command "${command}". Did you mean "${suggestion}"?`
|
|
367
|
-
: `usage: skillmux <serve|index|sync|init|project|target|core pin/unpin|report|scan|install|eval|doctor|skill which|local-vault init|config show|models download
|
|
362
|
+
: `usage: skillmux <serve|index|sync|init|project|target|core pin/unpin|report|scan|install|eval|doctor|skill which|local-vault init|config show|models download>`;
|
|
368
363
|
throw new Error(msg);
|
|
369
364
|
}
|
|
370
365
|
}
|
|
@@ -463,169 +458,6 @@ async function handleContextCommand(
|
|
|
463
458
|
throw new Error("usage: skillmux context <add|list|current|use|remove>");
|
|
464
459
|
}
|
|
465
460
|
|
|
466
|
-
async function handleCalibrateCommand(
|
|
467
|
-
adapter: TargetAdapter,
|
|
468
|
-
sub: string,
|
|
469
|
-
args: string[],
|
|
470
|
-
ctx: { target: ResolvedTarget; isJson: boolean },
|
|
471
|
-
) {
|
|
472
|
-
if (sub === "run") {
|
|
473
|
-
let datasetPath: string | undefined;
|
|
474
|
-
let minAutoMatchPrecision: number | undefined;
|
|
475
|
-
let minRetrievalRecallAtK: number | undefined;
|
|
476
|
-
let minDeliveredShortlistRecallAtK: number | undefined;
|
|
477
|
-
let minAutoMatchCount: number | undefined;
|
|
478
|
-
let concurrency: number | undefined;
|
|
479
|
-
let resumeRunId: string | undefined;
|
|
480
|
-
let timing = false;
|
|
481
|
-
const readNumber = (flag: string, raw: string | undefined): number => {
|
|
482
|
-
if (raw === undefined) throw new Error(`${flag} requires a value`);
|
|
483
|
-
const value = Number(raw);
|
|
484
|
-
if (!Number.isFinite(value)) throw new Error(`${flag} must be a number`);
|
|
485
|
-
return value;
|
|
486
|
-
};
|
|
487
|
-
for (let i = 1; i < args.length; i++) {
|
|
488
|
-
const option = args[i];
|
|
489
|
-
if (option === "--dataset") {
|
|
490
|
-
datasetPath = args[++i];
|
|
491
|
-
if (!datasetPath) throw new Error("--dataset requires a path value");
|
|
492
|
-
} else if (option === "--min-auto-match-precision") {
|
|
493
|
-
minAutoMatchPrecision = readNumber(option, args[++i]);
|
|
494
|
-
} else if (option === "--min-retrieval-recall-at-k") {
|
|
495
|
-
minRetrievalRecallAtK = readNumber(option, args[++i]);
|
|
496
|
-
} else if (option === "--min-delivered-shortlist-recall-at-k") {
|
|
497
|
-
minDeliveredShortlistRecallAtK = readNumber(option, args[++i]);
|
|
498
|
-
} else if (option === "--min-auto-match-count") {
|
|
499
|
-
minAutoMatchCount = readNumber(option, args[++i]);
|
|
500
|
-
if (!Number.isInteger(minAutoMatchCount) || minAutoMatchCount < 1) {
|
|
501
|
-
throw new Error("--min-auto-match-count must be a positive integer");
|
|
502
|
-
}
|
|
503
|
-
} else if (option === "--concurrency") {
|
|
504
|
-
const raw = args[++i];
|
|
505
|
-
if (raw === undefined) throw new Error("--concurrency requires a value");
|
|
506
|
-
const val = Number(raw);
|
|
507
|
-
if (!Number.isInteger(val) || val < 1) {
|
|
508
|
-
throw new Error("--concurrency must be a positive integer");
|
|
509
|
-
}
|
|
510
|
-
concurrency = val;
|
|
511
|
-
} else if (option === "--resume") {
|
|
512
|
-
resumeRunId = args[++i];
|
|
513
|
-
if (!resumeRunId) throw new Error("--resume requires a run_id value");
|
|
514
|
-
} else if (option === "--timing") {
|
|
515
|
-
timing = true;
|
|
516
|
-
} else {
|
|
517
|
-
throw new Error(`unknown calibrate run option: ${option}`);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
for (const [flag, value] of [
|
|
521
|
-
["--min-auto-match-precision", minAutoMatchPrecision],
|
|
522
|
-
["--min-retrieval-recall-at-k", minRetrievalRecallAtK],
|
|
523
|
-
["--min-delivered-shortlist-recall-at-k", minDeliveredShortlistRecallAtK],
|
|
524
|
-
] as const) {
|
|
525
|
-
if (value !== undefined && (value < 0 || value > 1)) {
|
|
526
|
-
throw new Error(`${flag} must be between 0 and 1`);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
const res = await adapter.calibrateRun({
|
|
530
|
-
datasetPath,
|
|
531
|
-
minAutoMatchPrecision,
|
|
532
|
-
minRetrievalRecallAtK,
|
|
533
|
-
minDeliveredShortlistRecallAtK,
|
|
534
|
-
minAutoMatchCount,
|
|
535
|
-
concurrency,
|
|
536
|
-
resumeRunId,
|
|
537
|
-
timing,
|
|
538
|
-
onTimingSummary: timing
|
|
539
|
-
? (summary) => {
|
|
540
|
-
// Write timing report to stderr only — stdout remains valid JSON under --json.
|
|
541
|
-
// Cumulative fields are total worker time across concurrent queries and may
|
|
542
|
-
// exceed wall_ms. They do not sum to wall time.
|
|
543
|
-
process.stderr.write(
|
|
544
|
-
[
|
|
545
|
-
"--- calibrate run timing ---",
|
|
546
|
-
`cases_total: ${summary.cases_total}`,
|
|
547
|
-
`cases_executed: ${summary.cases_executed} (retrieved in this invocation)`,
|
|
548
|
-
`cases_reused: ${summary.cases_reused} (loaded from prior interrupted run)`,
|
|
549
|
-
`wall_ms: ${summary.wall_ms.toFixed(1)}`,
|
|
550
|
-
`vault_sync_ms: ${summary.vault_sync_ms.toFixed(1)}`,
|
|
551
|
-
"cumulative worker time (concurrent totals; may exceed wall_ms):",
|
|
552
|
-
` cumulative_embedding_ms: ${summary.cumulative_embedding_ms.toFixed(1)}`,
|
|
553
|
-
` cumulative_lexical_ms: ${summary.cumulative_lexical_ms.toFixed(1)}`,
|
|
554
|
-
` cumulative_vector_ms: ${summary.cumulative_vector_ms.toFixed(1)}`,
|
|
555
|
-
` cumulative_reranker_ms: ${summary.cumulative_reranker_ms.toFixed(1)}`,
|
|
556
|
-
` cumulative_checkpoint_ms: ${summary.cumulative_checkpoint_ms.toFixed(1)}`,
|
|
557
|
-
`policy_evaluation_ms: ${summary.policy_evaluation_ms.toFixed(1)}`,
|
|
558
|
-
"",
|
|
559
|
-
].join("\n"),
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
: undefined,
|
|
563
|
-
});
|
|
564
|
-
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
565
|
-
renderCalibrationTarget(ctx.target);
|
|
566
|
-
console.log(`Calibration run complete.`);
|
|
567
|
-
if (res.result) console.log(JSON.stringify(res.result, null, 2));
|
|
568
|
-
});
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
if (sub === "list") {
|
|
574
|
-
const res = await adapter.calibrateList();
|
|
575
|
-
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
576
|
-
renderCalibrationTarget(ctx.target);
|
|
577
|
-
renderTable(
|
|
578
|
-
[
|
|
579
|
-
{ key: "run_id", header: "RUN_ID" },
|
|
580
|
-
{ key: "created_at", header: "CREATED_AT" },
|
|
581
|
-
{ key: "status", header: "STATUS" },
|
|
582
|
-
],
|
|
583
|
-
res,
|
|
584
|
-
);
|
|
585
|
-
});
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
if (sub === "show") {
|
|
590
|
-
const runId = args[1];
|
|
591
|
-
if (!runId) throw new Error("usage: skillmux calibrate show <run_id>");
|
|
592
|
-
const res = await adapter.calibrateShow(runId);
|
|
593
|
-
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
594
|
-
renderCalibrationTarget(ctx.target);
|
|
595
|
-
console.log(JSON.stringify(res, null, 2));
|
|
596
|
-
});
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
if (sub === "apply") {
|
|
601
|
-
const runId = args[1];
|
|
602
|
-
if (!runId) throw new Error("usage: skillmux calibrate apply <run_id>");
|
|
603
|
-
const res = await adapter.calibrateApply(runId);
|
|
604
|
-
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
605
|
-
renderCalibrationTarget(ctx.target);
|
|
606
|
-
console.log(`Applied calibration run "${runId}"`);
|
|
607
|
-
});
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
if (sub === "generate-dataset") {
|
|
612
|
-
await runCalibrateGenerateDataset(args.slice(1));
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
throw new Error(
|
|
617
|
-
"usage: skillmux calibrate generate-dataset [--vault <path>] [--out <file>]",
|
|
618
|
-
);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function renderCalibrationTarget(target: ResolvedTarget): void {
|
|
622
|
-
if (target.type === "local") {
|
|
623
|
-
console.log("Target: local");
|
|
624
|
-
} else {
|
|
625
|
-
console.log(`Target: remote (${target.name} -> ${target.server})`);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
461
|
async function handleCompletionsCommand(shell: string) {
|
|
630
462
|
if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
|
|
631
463
|
throw new Error("usage: skillmux completions <bash|zsh|fish>");
|
|
@@ -701,11 +533,6 @@ Setup:
|
|
|
701
533
|
skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
|
|
702
534
|
skillmux skill which <skill_id>
|
|
703
535
|
|
|
704
|
-
Calibration:
|
|
705
|
-
skillmux calibrate run [--dataset <path>] [--concurrency <n>] [--resume <run_id>]
|
|
706
|
-
[--timing] [--json]
|
|
707
|
-
skillmux calibrate <list|show|apply|generate-dataset>
|
|
708
|
-
|
|
709
536
|
Init clients:
|
|
710
537
|
claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
|
|
711
538
|
antigravity, goose, hermes, skillmux-mcp
|
|
@@ -715,7 +542,7 @@ Init targets:
|
|
|
715
542
|
|
|
716
543
|
Commands:
|
|
717
544
|
serve, index, sync, init, project, target, core, report, scan, install, eval, doctor, skill,
|
|
718
|
-
local-vault, config, models,
|
|
545
|
+
local-vault, config, models, context, completions`);
|
|
719
546
|
}
|
|
720
547
|
|
|
721
548
|
// ---------------------------------------------------------------------------
|
|
@@ -785,13 +612,17 @@ async function runEval(options: { isJson: boolean }): Promise<void> {
|
|
|
785
612
|
throw new Error(`eval requires local embeddings: ${String(error)}`);
|
|
786
613
|
});
|
|
787
614
|
emitSuccess({ isJson: options.isJson }, report, () => {
|
|
788
|
-
console.log(`holdout queries:
|
|
789
|
-
console.log(`
|
|
790
|
-
console.log(`
|
|
791
|
-
console.log(`lexical
|
|
792
|
-
console.log(`
|
|
793
|
-
console.log(`
|
|
794
|
-
console.log(`
|
|
615
|
+
console.log(`holdout queries: ${report.queries}`);
|
|
616
|
+
console.log(`judged queries: ${report.judged_queries}`);
|
|
617
|
+
console.log(`unjudged queries: ${report.unjudged_queries}`);
|
|
618
|
+
console.log(`lexical recall@5: ${report.lexical.recall_at_5.toFixed(3)}`);
|
|
619
|
+
console.log(`lexical recall@10: ${report.lexical.recall_at_10.toFixed(3)}`);
|
|
620
|
+
console.log(`lexical MRR: ${report.lexical.mrr.toFixed(3)}`);
|
|
621
|
+
console.log(`lexical nDCG@10: ${report.lexical.ndcg_at_10.toFixed(3)}`);
|
|
622
|
+
console.log(`hybrid recall@5: ${report.hybrid.recall_at_5.toFixed(3)}`);
|
|
623
|
+
console.log(`hybrid recall@10: ${report.hybrid.recall_at_10.toFixed(3)}`);
|
|
624
|
+
console.log(`hybrid MRR: ${report.hybrid.mrr.toFixed(3)}`);
|
|
625
|
+
console.log(`hybrid nDCG@10: ${report.hybrid.ndcg_at_10.toFixed(3)}`);
|
|
795
626
|
});
|
|
796
627
|
}
|
|
797
628
|
|
|
@@ -1777,47 +1608,6 @@ async function runInstall(
|
|
|
1777
1608
|
}
|
|
1778
1609
|
}
|
|
1779
1610
|
|
|
1780
|
-
function parseCalibrateGenerateDatasetArgs(args: string[]): {
|
|
1781
|
-
vault?: string;
|
|
1782
|
-
out?: string;
|
|
1783
|
-
} {
|
|
1784
|
-
let vault: string | undefined;
|
|
1785
|
-
let out: string | undefined;
|
|
1786
|
-
for (let i = 0; i < args.length; i++) {
|
|
1787
|
-
const option = args[i];
|
|
1788
|
-
if (option === "--vault") {
|
|
1789
|
-
const value = args[++i];
|
|
1790
|
-
if (!value) throw new Error("--vault requires a path value");
|
|
1791
|
-
vault = value;
|
|
1792
|
-
} else if (option === "--out") {
|
|
1793
|
-
const value = args[++i];
|
|
1794
|
-
if (!value) throw new Error("--out requires a file path value");
|
|
1795
|
-
out = value;
|
|
1796
|
-
} else {
|
|
1797
|
-
throw new Error(`unknown calibrate option: ${option}`);
|
|
1798
|
-
}
|
|
1799
|
-
}
|
|
1800
|
-
return { vault, out };
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
async function runCalibrateGenerateDataset(args: string[]): Promise<void> {
|
|
1804
|
-
const { vault: vaultArg, out: outArg } =
|
|
1805
|
-
parseCalibrateGenerateDatasetArgs(args);
|
|
1806
|
-
const config = await loadConfig();
|
|
1807
|
-
const vaultPath = expandHome(vaultArg ?? config.vault_path);
|
|
1808
|
-
const outPath = expandHome(outArg ?? join(config.state_dir, "queries.json"));
|
|
1809
|
-
|
|
1810
|
-
const skills = await scanVault(vaultPath);
|
|
1811
|
-
const dataset = generateDataset(skills);
|
|
1812
|
-
|
|
1813
|
-
const parentDir = join(outPath, "..");
|
|
1814
|
-
mkdirSync(parentDir, { recursive: true });
|
|
1815
|
-
await Bun.write(outPath, JSON.stringify(dataset, null, 2) + "\n");
|
|
1816
|
-
console.log(
|
|
1817
|
-
`generated synthetic dataset with ${dataset.length} cases at ${outPath}`,
|
|
1818
|
-
);
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
1611
|
if (import.meta.main) {
|
|
1822
1612
|
await main();
|
|
1823
1613
|
}
|
package/src/completions.ts
CHANGED
|
@@ -3,7 +3,6 @@ export type ShellType = "bash" | "zsh" | "fish";
|
|
|
3
3
|
const TOP_LEVEL_COMMANDS: { name: string; description: string }[] = [
|
|
4
4
|
{ name: "context", description: "Manage connection contexts" },
|
|
5
5
|
{ name: "config", description: "Manage configuration" },
|
|
6
|
-
{ name: "calibrate", description: "Manage policy calibration" },
|
|
7
6
|
{ name: "serve", description: "Start MCP server" },
|
|
8
7
|
{ name: "index", description: "Rebuild local search index" },
|
|
9
8
|
{ name: "sync", description: "Synchronize vault skills" },
|
|
@@ -45,9 +44,6 @@ _skillmux_completions() {
|
|
|
45
44
|
config)
|
|
46
45
|
COMPREPLY=( $(compgen -W "init show get validate diff set status" -- "$cur") )
|
|
47
46
|
;;
|
|
48
|
-
calibrate)
|
|
49
|
-
COMPREPLY=( $(compgen -W "run list show apply generate-dataset" -- "$cur") )
|
|
50
|
-
;;
|
|
51
47
|
completions)
|
|
52
48
|
COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
|
|
53
49
|
;;
|
package/src/config-service.ts
CHANGED
|
@@ -60,11 +60,8 @@ export const RELOADABLE_KEYS = [
|
|
|
60
60
|
"recall.k_lexical",
|
|
61
61
|
"recall.k_vector",
|
|
62
62
|
"recall.k_rerank",
|
|
63
|
-
"output.
|
|
64
|
-
"
|
|
65
|
-
"thresholds.match_score",
|
|
66
|
-
"thresholds.match_margin",
|
|
67
|
-
"thresholds.candidate_floor",
|
|
63
|
+
"output.top_k",
|
|
64
|
+
"output.max_top_k",
|
|
68
65
|
"inference.embedding.endpoint",
|
|
69
66
|
"inference.embedding.api_key_env",
|
|
70
67
|
"inference.reranker.adapter",
|
|
@@ -123,7 +120,7 @@ export async function getEffectiveConfig(configPath?: string): Promise<{
|
|
|
123
120
|
sources: ConfigSourceMap;
|
|
124
121
|
rawToml: Record<string, unknown>;
|
|
125
122
|
}> {
|
|
126
|
-
const path = configPath ?? DEFAULT_CONFIG_PATH;
|
|
123
|
+
const path = configPath ?? process.env.SKILLMUX_CONFIG ?? DEFAULT_CONFIG_PATH;
|
|
127
124
|
const effective = await loadConfig(path);
|
|
128
125
|
const rawToml: Record<string, unknown> = {};
|
|
129
126
|
|
|
@@ -150,11 +147,8 @@ export async function getEffectiveConfig(configPath?: string): Promise<{
|
|
|
150
147
|
"recall.k_lexical",
|
|
151
148
|
"recall.k_vector",
|
|
152
149
|
"recall.k_rerank",
|
|
153
|
-
"output.
|
|
154
|
-
"
|
|
155
|
-
"thresholds.match_score",
|
|
156
|
-
"thresholds.match_margin",
|
|
157
|
-
"thresholds.candidate_floor",
|
|
150
|
+
"output.top_k",
|
|
151
|
+
"output.max_top_k",
|
|
158
152
|
"inference.mode",
|
|
159
153
|
"inference.bundle",
|
|
160
154
|
"inference.models_dir",
|
|
@@ -200,15 +194,8 @@ export function isEnvMasked(key: string, allowEnvOverrides: boolean = true): boo
|
|
|
200
194
|
if (key === "recall.k_lexical" && (process.env.SKILLMUX_RECALL_K_LEXICAL || process.env.RECALL_K_LEXICAL)) return true;
|
|
201
195
|
if (key === "recall.k_vector" && (process.env.SKILLMUX_RECALL_K_VECTOR || process.env.RECALL_K_VECTOR)) return true;
|
|
202
196
|
if (key === "recall.k_rerank" && (process.env.SKILLMUX_RECALL_K_RERANK || process.env.RECALL_K_RERANK)) return true;
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
(process.env.SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT ||
|
|
206
|
-
process.env.AMBIGUOUS_CANDIDATE_LIMIT ||
|
|
207
|
-
process.env.SKILLMUX_CANDIDATE_LIMIT ||
|
|
208
|
-
process.env.CANDIDATE_LIMIT)
|
|
209
|
-
) {
|
|
210
|
-
return true;
|
|
211
|
-
}
|
|
197
|
+
if (key === "output.top_k" && (process.env.SKILLMUX_OUTPUT_TOP_K || process.env.OUTPUT_TOP_K)) return true;
|
|
198
|
+
if (key === "output.max_top_k" && (process.env.SKILLMUX_OUTPUT_MAX_TOP_K || process.env.OUTPUT_MAX_TOP_K)) return true;
|
|
212
199
|
if (key === "inference.models_dir" && (process.env.SKILLMUX_MODELS_DIR || process.env.SKILL_ROUTER_MODELS_DIR)) return true;
|
|
213
200
|
if (key === "inference.embedding.device" && (process.env.SKILLMUX_EMBED_DEVICE || process.env.EMBED_DEVICE)) return true;
|
|
214
201
|
if (key === "inference.embedding.dtype" && (process.env.SKILLMUX_EMBED_DTYPE || process.env.EMBED_DTYPE)) return true;
|
|
@@ -229,6 +216,12 @@ export function isEnvMasked(key: string, allowEnvOverrides: boolean = true): boo
|
|
|
229
216
|
}
|
|
230
217
|
|
|
231
218
|
export function validateDottedKey(key: string): void {
|
|
219
|
+
if (key === "output.ambiguous_candidate_limit") {
|
|
220
|
+
throw new Error("output.ambiguous_candidate_limit is obsolete in 2.0. Use output.top_k instead.");
|
|
221
|
+
}
|
|
222
|
+
if (key.startsWith("thresholds.") || key.startsWith("inference.thresholds.")) {
|
|
223
|
+
throw new Error("thresholds are obsolete in 2.0. Threshold calibration was removed; use output.top_k.");
|
|
224
|
+
}
|
|
232
225
|
const allowed = new Set([
|
|
233
226
|
"config.environment_overrides",
|
|
234
227
|
"vault_path",
|
|
@@ -236,11 +229,8 @@ export function validateDottedKey(key: string): void {
|
|
|
236
229
|
"recall.k_lexical",
|
|
237
230
|
"recall.k_vector",
|
|
238
231
|
"recall.k_rerank",
|
|
239
|
-
"output.
|
|
240
|
-
"
|
|
241
|
-
"thresholds.match_score",
|
|
242
|
-
"thresholds.match_margin",
|
|
243
|
-
"thresholds.candidate_floor",
|
|
232
|
+
"output.top_k",
|
|
233
|
+
"output.max_top_k",
|
|
244
234
|
"inference.mode",
|
|
245
235
|
"inference.bundle",
|
|
246
236
|
"inference.models_dir",
|
|
@@ -279,11 +269,8 @@ export function parseDottedValue(key: string, valueStr: string): unknown {
|
|
|
279
269
|
"recall.k_lexical",
|
|
280
270
|
"recall.k_vector",
|
|
281
271
|
"recall.k_rerank",
|
|
282
|
-
"output.
|
|
283
|
-
"
|
|
284
|
-
"thresholds.match_score",
|
|
285
|
-
"thresholds.match_margin",
|
|
286
|
-
"thresholds.candidate_floor",
|
|
272
|
+
"output.top_k",
|
|
273
|
+
"output.max_top_k",
|
|
287
274
|
"inference.embedding.dimension",
|
|
288
275
|
"inference.timeout_ms",
|
|
289
276
|
"server.rate_limit.requests_per_minute",
|
|
@@ -328,7 +315,7 @@ export async function setDottedKey(
|
|
|
328
315
|
throw new Error(`Cannot set environment-masked configuration key "${key}"`);
|
|
329
316
|
}
|
|
330
317
|
|
|
331
|
-
const path = opts?.configPath ?? DEFAULT_CONFIG_PATH;
|
|
318
|
+
const path = opts?.configPath ?? process.env.SKILLMUX_CONFIG ?? DEFAULT_CONFIG_PATH;
|
|
332
319
|
const targetName = opts?.targetName ?? "local";
|
|
333
320
|
|
|
334
321
|
const { effective: priorEffective, rawToml } = await getEffectiveConfig(path);
|
|
@@ -445,7 +432,7 @@ export async function getLocalConfigStatus(configPath?: string): Promise<ConfigS
|
|
|
445
432
|
|
|
446
433
|
return {
|
|
447
434
|
target: "local",
|
|
448
|
-
desired_source: configPath ?? DEFAULT_CONFIG_PATH,
|
|
435
|
+
desired_source: configPath ?? process.env.SKILLMUX_CONFIG ?? DEFAULT_CONFIG_PATH,
|
|
449
436
|
desired_source_hash: hash,
|
|
450
437
|
active_revision: hash,
|
|
451
438
|
active_source_hash: hash,
|
package/src/config-watcher.ts
CHANGED
|
@@ -12,15 +12,11 @@ import type { Config } from "./types";
|
|
|
12
12
|
|
|
13
13
|
export const LIVE_RELOAD_KEYS = new Set([
|
|
14
14
|
"config.environment_overrides",
|
|
15
|
-
"inference.thresholds.match_score",
|
|
16
|
-
"inference.thresholds.match_margin",
|
|
17
|
-
"inference.thresholds.candidate_floor",
|
|
18
|
-
"inference.calibration.run_id",
|
|
19
15
|
"recall.k_lexical",
|
|
20
16
|
"recall.k_vector",
|
|
21
17
|
"recall.k_rerank",
|
|
22
|
-
"output.
|
|
23
|
-
"
|
|
18
|
+
"output.top_k",
|
|
19
|
+
"output.max_top_k",
|
|
24
20
|
"inference.embedding.endpoint",
|
|
25
21
|
"inference.embedding.api_key_env",
|
|
26
22
|
"inference.reranker.adapter",
|
package/src/config.ts
CHANGED
|
@@ -43,14 +43,11 @@ const configSchema = z.object({
|
|
|
43
43
|
message: "recall.k_rerank cannot exceed k_lexical + k_vector",
|
|
44
44
|
}),
|
|
45
45
|
output: z.object({
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
match_margin: z.number().nonnegative().optional(),
|
|
52
|
-
candidate_floor: z.number().optional(),
|
|
53
|
-
}).strict().optional(),
|
|
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 }),
|
|
54
51
|
inference: z.discriminatedUnion("mode", [
|
|
55
52
|
z.object({
|
|
56
53
|
mode: z.literal("local"),
|
|
@@ -74,8 +71,6 @@ const configSchema = z.object({
|
|
|
74
71
|
model: z.string().min(1),
|
|
75
72
|
api_key_env: z.string().min(1).optional(),
|
|
76
73
|
}).strict().optional(),
|
|
77
|
-
thresholds: remoteThresholdsSchema.optional(),
|
|
78
|
-
calibration: z.object({ run_id: z.string().min(1) }).strict().optional(),
|
|
79
74
|
}).strict(),
|
|
80
75
|
]),
|
|
81
76
|
server: z.object({
|
|
@@ -93,7 +88,15 @@ const configSchema = z.object({
|
|
|
93
88
|
token_env: z.string().min(1),
|
|
94
89
|
}).strict().optional(),
|
|
95
90
|
}).strict().optional(),
|
|
96
|
-
}).strict()
|
|
91
|
+
}).strict().refine((cfg) => {
|
|
92
|
+
const hasReranker = cfg.inference.mode === "remote" && !!cfg.inference.reranker;
|
|
93
|
+
if (hasReranker && cfg.output.max_top_k > cfg.recall.k_rerank) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}, {
|
|
98
|
+
message: "output.max_top_k cannot exceed recall.k_rerank when reranking is enabled",
|
|
99
|
+
});
|
|
97
100
|
|
|
98
101
|
// Fallback values only; a config.toml (SKILLMUX_CONFIG or default path)
|
|
99
102
|
// overrides them. The local bundle is the zero-config OSS path.
|
|
@@ -107,8 +110,7 @@ const DEFAULTS: Config = {
|
|
|
107
110
|
local_vault_paths: [],
|
|
108
111
|
state_dir: "~/.local/state/skillmux",
|
|
109
112
|
recall: { k_lexical: 20, k_vector: 20, k_rerank: 10 },
|
|
110
|
-
|
|
111
|
-
output: { ambiguous_candidate_limit: 5 },
|
|
113
|
+
output: { top_k: 10, max_top_k: 50 },
|
|
112
114
|
inference: {
|
|
113
115
|
mode: "local",
|
|
114
116
|
bundle: LOCAL_BUNDLE_ID,
|
|
@@ -232,6 +234,18 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
232
234
|
"The old client appended /v1/embeddings.",
|
|
233
235
|
);
|
|
234
236
|
}
|
|
237
|
+
const removedLegacyOutputEnv = [
|
|
238
|
+
"SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
|
|
239
|
+
"AMBIGUOUS_CANDIDATE_LIMIT",
|
|
240
|
+
"SKILLMUX_CANDIDATE_LIMIT",
|
|
241
|
+
"CANDIDATE_LIMIT",
|
|
242
|
+
].find((name) => process.env[name] !== undefined);
|
|
243
|
+
if (removedLegacyOutputEnv) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`${removedLegacyOutputEnv} is no longer supported. Use SKILLMUX_OUTPUT_TOP_K instead.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
235
249
|
const configPath = resolveConfigPath(path);
|
|
236
250
|
const file = Bun.file(expandHome(configPath));
|
|
237
251
|
|
|
@@ -248,6 +262,26 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
248
262
|
merged = baseConfig;
|
|
249
263
|
} else {
|
|
250
264
|
const parsed = Bun.TOML.parse(await file.text()) as Record<string, unknown>;
|
|
265
|
+
if ("thresholds" in parsed) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
"The [thresholds] table is obsolete in 2.0. Threshold calibration was removed; use [output] with top_k.",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (isPlainObject(parsed.output) && "ambiguous_candidate_limit" in parsed.output) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
"output.ambiguous_candidate_limit is obsolete in 2.0. Use output.top_k instead.",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (isPlainObject(parsed.inference) && "thresholds" in parsed.inference) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
"inference.thresholds is obsolete in 2.0. Threshold calibration was removed.",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (isPlainObject(parsed.inference) && "calibration" in parsed.inference) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
"inference.calibration is obsolete in 2.0 and should be deleted. Threshold calibration was removed; use skillmux eval for ranking evaluation.",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
251
285
|
if ("embedding" in parsed || "rerank" in parsed || "remote_timeout_ms" in parsed) {
|
|
252
286
|
throw new Error(
|
|
253
287
|
"Legacy inference config is not supported. Move [embedding], [rerank], and remote_timeout_ms under [inference] using config.remote.example.toml.",
|
|
@@ -279,30 +313,9 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
279
313
|
throw new Error("Remote inference requires an inference.embedding section.");
|
|
280
314
|
}
|
|
281
315
|
}
|
|
282
|
-
if (parsed.thresholds && typeof parsed.thresholds === "object" && (parsed.thresholds as Record<string, unknown>).candidate_limit !== undefined) {
|
|
283
|
-
console.error("skillmux: thresholds.candidate_limit is deprecated, use output.ambiguous_candidate_limit instead");
|
|
284
|
-
if (!parsed.output || (parsed.output as Record<string, unknown>).ambiguous_candidate_limit === undefined) {
|
|
285
|
-
parsed.output = {
|
|
286
|
-
...(typeof parsed.output === "object" && parsed.output !== null ? parsed.output : {}),
|
|
287
|
-
ambiguous_candidate_limit: (parsed.thresholds as Record<string, unknown>).candidate_limit,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
316
|
|
|
292
317
|
if (parsed.inference && typeof parsed.inference === "object" && "mode" in parsed.inference) {
|
|
293
318
|
if (parsed.inference.mode === "remote") {
|
|
294
|
-
if ("thresholds" in parsed.inference) {
|
|
295
|
-
const rawRemoteThresholds = (parsed.inference as Record<string, unknown>).thresholds;
|
|
296
|
-
if (
|
|
297
|
-
typeof rawRemoteThresholds === "object" &&
|
|
298
|
-
rawRemoteThresholds !== null &&
|
|
299
|
-
!("match_score" in rawRemoteThresholds) &&
|
|
300
|
-
!("match_margin" in rawRemoteThresholds) &&
|
|
301
|
-
!("candidate_floor" in rawRemoteThresholds)
|
|
302
|
-
) {
|
|
303
|
-
throw new Error("Invalid inference.thresholds in config.toml: must specify at least one threshold.");
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
319
|
const withoutInference = { ...parsed };
|
|
307
320
|
delete withoutInference.inference;
|
|
308
321
|
merged = {
|
|
@@ -318,12 +331,7 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
318
331
|
}
|
|
319
332
|
|
|
320
333
|
if (!merged.output) {
|
|
321
|
-
merged.output = {
|
|
322
|
-
}
|
|
323
|
-
if (!merged.thresholds) {
|
|
324
|
-
merged.thresholds = { candidate_limit: merged.output.ambiguous_candidate_limit };
|
|
325
|
-
} else if (merged.thresholds.candidate_limit === undefined) {
|
|
326
|
-
merged.thresholds.candidate_limit = merged.output.ambiguous_candidate_limit;
|
|
334
|
+
merged.output = { top_k: 10, max_top_k: 50 };
|
|
327
335
|
}
|
|
328
336
|
|
|
329
337
|
// Warn about deprecated generic environment variables regardless of override policy
|
|
@@ -333,8 +341,8 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
333
341
|
RECALL_K_LEXICAL: "SKILLMUX_RECALL_K_LEXICAL",
|
|
334
342
|
RECALL_K_VECTOR: "SKILLMUX_RECALL_K_VECTOR",
|
|
335
343
|
RECALL_K_RERANK: "SKILLMUX_RECALL_K_RERANK",
|
|
336
|
-
|
|
337
|
-
|
|
344
|
+
OUTPUT_TOP_K: "SKILLMUX_OUTPUT_TOP_K",
|
|
345
|
+
OUTPUT_MAX_TOP_K: "SKILLMUX_OUTPUT_MAX_TOP_K",
|
|
338
346
|
EMBED_MODEL: "SKILLMUX_EMBED_MODEL",
|
|
339
347
|
EMBED_ENDPOINT: "SKILLMUX_EMBED_ENDPOINT",
|
|
340
348
|
EMBED_DIMENSION: "SKILLMUX_EMBED_DIMENSION",
|
|
@@ -398,14 +406,17 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
398
406
|
if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid recall.k_rerank: ${kRerankStr}`);
|
|
399
407
|
merged.recall.k_rerank = k;
|
|
400
408
|
}
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
+
const topKStr = getEnv("SKILLMUX_OUTPUT_TOP_K", "OUTPUT_TOP_K");
|
|
410
|
+
if (topKStr) {
|
|
411
|
+
const k = Number(topKStr);
|
|
412
|
+
if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid output.top_k: ${topKStr}`);
|
|
413
|
+
merged.output.top_k = k;
|
|
414
|
+
}
|
|
415
|
+
const maxTopKStr = getEnv("SKILLMUX_OUTPUT_MAX_TOP_K", "OUTPUT_MAX_TOP_K");
|
|
416
|
+
if (maxTopKStr) {
|
|
417
|
+
const k = Number(maxTopKStr);
|
|
418
|
+
if (!Number.isInteger(k) || k < 1) throw new Error(`Invalid output.max_top_k: ${maxTopKStr}`);
|
|
419
|
+
merged.output.max_top_k = k;
|
|
409
420
|
}
|
|
410
421
|
}
|
|
411
422
|
|
|
@@ -441,16 +452,6 @@ export async function loadConfig(path?: string): Promise<Config> {
|
|
|
441
452
|
if (merged.inference.reranker && (!merged.inference.reranker.endpoint || !merged.inference.reranker.model)) {
|
|
442
453
|
throw new Error("Configured inference.reranker requires adapter, endpoint, and model.");
|
|
443
454
|
}
|
|
444
|
-
if (merged.inference.reranker && !merged.inference.thresholds) {
|
|
445
|
-
const warningKey = "inference.reranker.without-thresholds";
|
|
446
|
-
if (!warnedEnv.has(warningKey)) {
|
|
447
|
-
warnedEnv.add(warningKey);
|
|
448
|
-
console.error(
|
|
449
|
-
"skillmux: configured reranker has no calibrated inference.thresholds; " +
|
|
450
|
-
"routing will remain ambiguous until you run `skillmux calibrate run`.",
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
455
|
const embedEndpoint = getEnv("SKILLMUX_EMBED_ENDPOINT", "EMBED_ENDPOINT");
|
|
455
456
|
const embedModel = getEnv("SKILLMUX_EMBED_MODEL", "EMBED_MODEL");
|
|
456
457
|
const embedDimStr = getEnv("SKILLMUX_EMBED_DIMENSION", "EMBED_DIMENSION");
|