@klhapp/skillmux 1.3.1 → 1.3.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 CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.2](https://github.com/klhq/skillmux/compare/v1.3.1...v1.3.2) (2026-07-31)
9
+
10
+
11
+ ### Added
12
+
13
+ * **doctor:** flag uncalibrated or stale inference thresholds ([#99](https://github.com/klhq/skillmux/issues/99)) ([c8f684b](https://github.com/klhq/skillmux/commit/c8f684b6aedf6788def4cdc0fe49fff233569844))
14
+
15
+
16
+ ### Chores
17
+
18
+ * pin next release to 1.3.2 instead of 1.4.0 (take 2) ([#102](https://github.com/klhq/skillmux/issues/102)) ([bbbb7dd](https://github.com/klhq/skillmux/commit/bbbb7dd838d691735e797f39de7d4277b95f5187))
19
+
8
20
  ## [1.3.1](https://github.com/klhq/skillmux/compare/v1.3.0...v1.3.1) (2026-07-31)
9
21
 
10
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Skill management and retrieval for AI agents: sync native skills across clients and route the long tail over MCP",
5
5
  "type": "module",
6
6
  "private": false,
package/src/adapters.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { join } from "node:path";
3
- import { applyCalibrationRun, getCalibrationRun, insertCalibrationRun, listCalibrationRuns, loadDecisionCasesFromFile, openCalibrateDb, runCalibration, summarizeDatasetProvenance, type CalibrationResult } from "./calibrate";
3
+ import { applyCalibrationRun, computeCorpusFingerprint, getCalibrationRun, insertCalibrationRun, listCalibrationRuns, loadDecisionCasesFromFile, openCalibrateDb, runCalibration, summarizeDatasetProvenance, type CalibrationResult } from "./calibrate";
4
4
  import { createClients } from "./clients";
5
5
  import { DEFAULT_CONFIG_PATH, embeddingFingerprint, expandHome, loadConfig, rerankerFingerprint } from "./config";
6
6
  import { openIndex } from "./db";
@@ -141,10 +141,12 @@ export class LocalAdapter implements TargetAdapter {
141
141
  const datasetFile = opts?.datasetPath ?? join(expandHome(config.state_dir), "queries.json");
142
142
  const indexDb = openIndex(expandHome(config.state_dir));
143
143
  let indexedSkills: Array<{ skill_id: string; content_sha256: string }>;
144
+ let corpusFingerprint: string;
144
145
  try {
145
146
  indexedSkills = indexDb
146
147
  .query("SELECT skill_id, content_sha256 FROM skills ORDER BY skill_id")
147
148
  .all() as Array<{ skill_id: string; content_sha256: string }>;
149
+ corpusFingerprint = computeCorpusFingerprint(indexDb);
148
150
  } finally {
149
151
  indexDb.close();
150
152
  }
@@ -180,9 +182,6 @@ export class LocalAdapter implements TargetAdapter {
180
182
  throw new Error("A configured remote reranker is required to record calibration.");
181
183
  }
182
184
  const datasetText = await Bun.file(datasetFile).text();
183
- const corpusFingerprint =
184
- "vault:" +
185
- createHash("sha256").update(JSON.stringify(indexedSkills)).digest("hex");
186
185
  const runId = `run_${crypto.randomUUID()}`;
187
186
  const db = openCalibrateDb(expandHome(config.state_dir));
188
187
  try {
package/src/calibrate.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { mkdirSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { Database } from "bun:sqlite";
@@ -900,6 +901,18 @@ export interface CalibrationRunSummary {
900
901
  failed_reason?: CalibrationFailureReason;
901
902
  }
902
903
 
904
+ /**
905
+ * Fingerprint the indexed vault content so calibration runs can detect
906
+ * corpus drift. Must match how `insertCalibrationRun` computes it at
907
+ * `calibrate run` time.
908
+ */
909
+ export function computeCorpusFingerprint(indexDb: Database): string {
910
+ const indexedSkills = indexDb
911
+ .query("SELECT skill_id, content_sha256 FROM skills ORDER BY skill_id")
912
+ .all() as Array<{ skill_id: string; content_sha256: string }>;
913
+ return "vault:" + createHash("sha256").update(JSON.stringify(indexedSkills)).digest("hex");
914
+ }
915
+
903
916
  /**
904
917
  * Open (or create) the calibration evidence database in `stateDir`.
905
918
  * Uses a separate `calibrate.sqlite3` file — never the index.sqlite3 used
package/src/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import packageJson from "../package.json" with { type: "json" };
2
3
  import { Database } from "bun:sqlite";
3
4
  import { existsSync, lstatSync, mkdirSync, rmSync } from "node:fs";
4
5
  import { hostname } from "node:os";
@@ -170,6 +171,11 @@ async function main() {
170
171
  const commandArgs = rawArgv.slice(2);
171
172
 
172
173
  const command = rawArgv[0];
174
+ if (command === "--version" || command === "-V") {
175
+ console.log(packageJson.version);
176
+ return;
177
+ }
178
+
173
179
  if (!command || command === "--help" || command === "-h") {
174
180
  printHelp();
175
181
  return;
package/src/doctor.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
+ import { computeCorpusFingerprint, getCalibrationRun, openCalibrateDb } from "./calibrate";
2
3
  import { createClients, RemoteInferenceError } from "./clients";
3
- import { embeddingDimension, expandHome } from "./config";
4
+ import { embeddingDimension, embeddingFingerprint, expandHome, rerankerFingerprint } from "./config";
5
+ import { openIndex } from "./db";
4
6
  import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
5
7
  import { readSkillmuxMarker } from "./sync";
6
8
  import type { Config } from "./types";
@@ -19,6 +21,78 @@ export interface DoctorReport {
19
21
  checks: DoctorCheck[];
20
22
  }
21
23
 
24
+ /**
25
+ * Warn when live `inference.thresholds` didn't come from an applied
26
+ * `skillmux calibrate` run — e.g. hand-copied from an example config.
27
+ * Reranker score scales are not portable across models/adapters/corpora,
28
+ * so uncalibrated thresholds routinely make automatic matching unreachable
29
+ * without any visible error.
30
+ */
31
+ function checkCalibration(config: Config): DoctorCheck {
32
+ const inference = config.inference;
33
+ if (inference.mode !== "remote") throw new Error("checkCalibration requires remote inference mode");
34
+ const runId = inference.calibration?.run_id;
35
+ if (!runId) {
36
+ return {
37
+ name: "calibration",
38
+ ok: false,
39
+ detail: "inference.thresholds are set but were never produced by `skillmux calibrate apply` — " +
40
+ "likely copied from an example config. Reranker scores are not portable across models, " +
41
+ "adapters, or corpora, so automatic matching may never trigger (or may trigger incorrectly). " +
42
+ "Run `skillmux calibrate`.",
43
+ };
44
+ }
45
+
46
+ const calibrateDb = openCalibrateDb(expandHome(config.state_dir));
47
+ let run;
48
+ try {
49
+ run = getCalibrationRun(calibrateDb, runId);
50
+ } finally {
51
+ calibrateDb.close();
52
+ }
53
+ if (!run) {
54
+ return {
55
+ name: "calibration",
56
+ ok: false,
57
+ detail: `inference.calibration.run_id "${runId}" was not found in the local calibration ` +
58
+ "evidence store (state_dir may differ from where it was calibrated). Recalibrate.",
59
+ };
60
+ }
61
+ if (run.status !== "completed") {
62
+ return {
63
+ name: "calibration",
64
+ ok: false,
65
+ detail: `inference.calibration.run_id "${runId}" has status "${run.status}" and should never ` +
66
+ "have been applied. Recalibrate.",
67
+ };
68
+ }
69
+
70
+ const indexDb = openIndex(expandHome(config.state_dir));
71
+ let currentCorpusFingerprint: string;
72
+ try {
73
+ currentCorpusFingerprint = computeCorpusFingerprint(indexDb);
74
+ } finally {
75
+ indexDb.close();
76
+ }
77
+
78
+ const stale = [
79
+ rerankerFingerprint(config) !== run.reranker_fingerprint ? "reranker" : null,
80
+ embeddingFingerprint(config) !== run.embedding_fingerprint ? "embedding" : null,
81
+ currentCorpusFingerprint !== run.corpus_fingerprint ? "vault contents" : null,
82
+ ].filter((part): part is string => part !== null);
83
+
84
+ if (stale.length > 0) {
85
+ return {
86
+ name: "calibration",
87
+ ok: false,
88
+ detail: `applied calibration run "${runId}" is stale — ${stale.join(", ")} changed since it was ` +
89
+ "calibrated. Recalibrate.",
90
+ };
91
+ }
92
+
93
+ return { name: "calibration", ok: true, detail: `thresholds from applied calibration run "${runId}"` };
94
+ }
95
+
22
96
  export async function diagnose(config: Config): Promise<DoctorReport> {
23
97
  const checks: DoctorCheck[] = [];
24
98
  checks.push({ name: "vault", ok: existsSync(expandHome(config.vault_path)), detail: expandHome(config.vault_path) });
@@ -129,6 +203,10 @@ export async function diagnose(config: Config): Promise<DoctorReport> {
129
203
  }
130
204
  }
131
205
 
206
+ if (config.inference.mode === "remote" && config.inference.reranker && config.inference.thresholds) {
207
+ checks.push(checkCalibration(config));
208
+ }
209
+
132
210
  const inferenceReady = checks.some((check) => check.name === "embedding" && check.ok);
133
211
  const coreReady = checks.some((check) => check.name === "vault" && check.ok)
134
212
  && checks.some((check) => check.name === "state" && check.ok);
package/src/types.ts CHANGED
@@ -73,6 +73,7 @@ export interface RemoteInferenceConfig {
73
73
  embedding: RemoteEmbeddingConfig;
74
74
  reranker?: RemoteRerankerConfig;
75
75
  thresholds?: Required<Omit<Thresholds, "candidate_limit">>;
76
+ calibration?: { run_id: string };
76
77
  }
77
78
 
78
79
  export type InferenceConfig = LocalInferenceConfig | RemoteInferenceConfig;