@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/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/calibrate
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/calibrate
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", "calibrate"].includes(command) &&
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
- await handleCalibrateCommand(adapter, subCommand, rawArgv.slice(1), {
276
- target: resolvedTarget,
277
- isJson,
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|calibrate generate-dataset>`;
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, calibrate, context, completions`);
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: ${report.queries}`);
789
- console.log(`lexical recall@3: ${report.lexical.recall_at_3.toFixed(3)}`);
790
- console.log(`lexical recall@5: ${report.lexical.recall_at_5.toFixed(3)}`);
791
- console.log(`lexical MRR: ${report.lexical.mrr.toFixed(3)}`);
792
- console.log(`hybrid recall@3: ${report.hybrid.recall_at_3.toFixed(3)}`);
793
- console.log(`hybrid recall@5: ${report.hybrid.recall_at_5.toFixed(3)}`);
794
- console.log(`hybrid MRR: ${report.hybrid.mrr.toFixed(3)}`);
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
  }
@@ -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
  ;;
@@ -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.ambiguous_candidate_limit",
64
- "thresholds.candidate_limit",
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.ambiguous_candidate_limit",
154
- "thresholds.candidate_limit",
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
- (key === "output.ambiguous_candidate_limit" || key === "thresholds.candidate_limit") &&
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.ambiguous_candidate_limit",
240
- "thresholds.candidate_limit",
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.ambiguous_candidate_limit",
283
- "thresholds.candidate_limit",
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,
@@ -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.ambiguous_candidate_limit",
23
- "thresholds.candidate_limit",
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
- ambiguous_candidate_limit: z.number().int().positive().default(5),
47
- }).strict().optional(),
48
- thresholds: z.object({
49
- candidate_limit: z.number().int().positive().optional(),
50
- match_score: z.number().optional(),
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
- thresholds: { candidate_limit: 5 },
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 = { ambiguous_candidate_limit: merged.thresholds?.candidate_limit ?? 5 };
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
- AMBIGUOUS_CANDIDATE_LIMIT: "SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
337
- CANDIDATE_LIMIT: "SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT",
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 ambiguousLimitStr =
402
- getEnv("SKILLMUX_OUTPUT_AMBIGUOUS_CANDIDATE_LIMIT", "AMBIGUOUS_CANDIDATE_LIMIT") ??
403
- getEnv("SKILLMUX_CANDIDATE_LIMIT", "CANDIDATE_LIMIT");
404
- if (ambiguousLimitStr) {
405
- const lim = Number(ambiguousLimitStr);
406
- if (!Number.isInteger(lim) || lim < 1) throw new Error(`Invalid output.ambiguous_candidate_limit: ${ambiguousLimitStr}`);
407
- merged.output.ambiguous_candidate_limit = lim;
408
- merged.thresholds.candidate_limit = lim;
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");