@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/db.ts CHANGED
@@ -43,24 +43,43 @@ export function openIndex(stateDir: string): Database {
43
43
  id INTEGER PRIMARY KEY AUTOINCREMENT,
44
44
  ts TEXT NOT NULL,
45
45
  query TEXT NOT NULL,
46
- outcome TEXT NOT NULL CHECK (outcome IN ('matched', 'ambiguous', 'no_match')),
47
- degraded INTEGER NOT NULL,
48
46
  retrieval TEXT NOT NULL DEFAULT 'lexical',
49
47
  degraded_from TEXT,
50
48
  degradation_reason TEXT,
51
49
  candidates TEXT NOT NULL,
52
- selected_skill_id TEXT,
53
50
  latency_ms INTEGER NOT NULL
54
51
  )`);
55
52
  const auditColumns = db.query("PRAGMA table_info(audit)").all() as { name: string }[];
56
- if (!auditColumns.some((column) => column.name === "retrieval")) {
57
- db.run("ALTER TABLE audit ADD COLUMN retrieval TEXT NOT NULL DEFAULT 'lexical'");
58
- }
59
- if (!auditColumns.some((column) => column.name === "degraded_from")) {
60
- db.run("ALTER TABLE audit ADD COLUMN degraded_from TEXT");
61
- }
62
- if (!auditColumns.some((column) => column.name === "degradation_reason")) {
63
- db.run("ALTER TABLE audit ADD COLUMN degradation_reason TEXT");
53
+ const columnNames = new Set(auditColumns.map((column) => column.name));
54
+ const hasLegacyColumns =
55
+ columnNames.has("outcome") ||
56
+ columnNames.has("selected_skill_id") ||
57
+ columnNames.has("degraded");
58
+ const missingCanonicalColumns =
59
+ !columnNames.has("retrieval") ||
60
+ !columnNames.has("degraded_from") ||
61
+ !columnNames.has("degradation_reason");
62
+
63
+ if (hasLegacyColumns || missingCanonicalColumns) {
64
+ db.transaction(() => {
65
+ db.run(`CREATE TABLE audit_new (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ ts TEXT NOT NULL,
68
+ query TEXT NOT NULL,
69
+ retrieval TEXT NOT NULL DEFAULT 'lexical',
70
+ degraded_from TEXT,
71
+ degradation_reason TEXT,
72
+ candidates TEXT NOT NULL,
73
+ latency_ms INTEGER NOT NULL
74
+ )`);
75
+ const retrievalExpr = columnNames.has("retrieval") ? "COALESCE(retrieval, 'lexical')" : "'lexical'";
76
+ const degradedFromExpr = columnNames.has("degraded_from") ? "degraded_from" : "NULL";
77
+ const degradationReasonExpr = columnNames.has("degradation_reason") ? "degradation_reason" : "NULL";
78
+ db.run(`INSERT INTO audit_new (id, ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
79
+ SELECT id, ts, query, ${retrievalExpr}, ${degradedFromExpr}, ${degradationReasonExpr}, candidates, latency_ms FROM audit`);
80
+ db.run("DROP TABLE audit");
81
+ db.run("ALTER TABLE audit_new RENAME TO audit");
82
+ })();
64
83
  }
65
84
  db.run(`CREATE TABLE IF NOT EXISTS index_meta (
66
85
  key TEXT PRIMARY KEY,
@@ -263,29 +282,24 @@ export function vectorTopK(db: Database, query: Float32Array, k: number): SkillR
263
282
  export interface AuditInsert {
264
283
  ts: string;
265
284
  query: string;
266
- outcome: string;
267
285
  retrieval: AuditRow["retrieval"];
268
286
  degraded_from?: string | null;
269
287
  degradation_reason?: string | null;
270
288
  candidates: AuditCandidate[];
271
- selected_skill_id: string | null;
272
289
  latency_ms: number;
273
290
  }
274
291
 
275
292
  export function insertAudit(db: Database, row: AuditInsert): void {
276
293
  db.run(
277
- `INSERT INTO audit (ts, query, outcome, degraded, retrieval, degraded_from, degradation_reason, candidates, selected_skill_id, latency_ms)
278
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
294
+ `INSERT INTO audit (ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
295
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
279
296
  [
280
297
  row.ts,
281
298
  row.query,
282
- row.outcome,
283
- row.retrieval === "lexical" || row.degradation_reason ? 1 : 0,
284
299
  row.retrieval,
285
300
  row.degraded_from ?? null,
286
301
  row.degradation_reason ?? null,
287
302
  JSON.stringify(row.candidates),
288
- row.selected_skill_id,
289
303
  row.latency_ms,
290
304
  ],
291
305
  );
package/src/doctor.ts CHANGED
@@ -1,9 +1,7 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
- import { computeCorpusFingerprint, getCalibrationRun, openCalibrateDb } from "./calibrate";
3
2
  import { createClients, RemoteInferenceError } from "./clients";
4
- import { embeddingDimension, embeddingFingerprint, expandHome, rerankerFingerprint } from "./config";
3
+ import { embeddingDimension, expandHome } from "./config";
5
4
  import { describeDeployment, type DeploymentIdentity } from "./deployment";
6
- import { openIndex } from "./db";
7
5
  import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
8
6
  import { readSkillmuxMarker } from "./sync";
9
7
  import type { Config } from "./types";
@@ -32,83 +30,6 @@ export interface DoctorReport {
32
30
  checks: DoctorCheck[];
33
31
  }
34
32
 
35
- /**
36
- * Warn when live `inference.thresholds` didn't come from an applied
37
- * `skillmux calibrate` run — e.g. hand-copied from an example config.
38
- * Reranker score scales are not portable across models/adapters/corpora,
39
- * so uncalibrated thresholds routinely make automatic matching unreachable
40
- * without any visible error.
41
- */
42
- function checkCalibration(config: Config): DoctorCheck {
43
- const inference = config.inference;
44
- if (inference.mode !== "remote") throw new Error("checkCalibration requires remote inference mode");
45
- const runId = inference.calibration?.run_id;
46
- if (!runId) {
47
- return {
48
- name: "calibration",
49
- ok: false,
50
- detail: "inference.thresholds are set but were never produced by `skillmux calibrate apply` — " +
51
- "likely copied from an example config. Reranker scores are not portable across models, " +
52
- "adapters, or corpora, so automatic matching may never trigger (or may trigger incorrectly). " +
53
- "Run `skillmux calibrate`.",
54
- };
55
- }
56
-
57
- const calibrateDb = openCalibrateDb(expandHome(config.state_dir));
58
- let run;
59
- try {
60
- run = getCalibrationRun(calibrateDb, runId);
61
- } finally {
62
- calibrateDb.close();
63
- }
64
- if (!run) {
65
- return {
66
- name: "calibration",
67
- ok: false,
68
- detail: `inference.calibration.run_id "${runId}" was not found in the local calibration ` +
69
- "evidence store (state_dir may differ from where it was calibrated). Recalibrate.",
70
- };
71
- }
72
- if (run.status !== "completed") {
73
- return {
74
- name: "calibration",
75
- ok: false,
76
- detail: `inference.calibration.run_id "${runId}" has status "${run.status}" and should never ` +
77
- "have been applied. Recalibrate.",
78
- };
79
- }
80
-
81
- const indexDb = openIndex(expandHome(config.state_dir));
82
- let currentCorpusFingerprint: string;
83
- try {
84
- currentCorpusFingerprint = computeCorpusFingerprint(indexDb);
85
- } finally {
86
- indexDb.close();
87
- }
88
-
89
- const stale = [
90
- rerankerFingerprint(config) !== run.reranker_fingerprint ? "reranker" : null,
91
- embeddingFingerprint(config) !== run.embedding_fingerprint ? "embedding" : null,
92
- currentCorpusFingerprint !== run.corpus_fingerprint ? "vault contents" : null,
93
- run.recall_settings && (
94
- run.recall_settings.k_lexical !== config.recall.k_lexical ||
95
- run.recall_settings.k_vector !== config.recall.k_vector ||
96
- run.recall_settings.k_rerank !== (config.recall.k_rerank ?? Math.min(10, config.recall.k_lexical + config.recall.k_vector))
97
- ) ? "recall settings" : null,
98
- ].filter((part): part is string => part !== null);
99
-
100
- if (stale.length > 0) {
101
- return {
102
- name: "calibration",
103
- ok: false,
104
- detail: `applied calibration run "${runId}" is stale — ${stale.join(", ")} changed since it was ` +
105
- "calibrated. Recalibrate.",
106
- };
107
- }
108
-
109
- return { name: "calibration", ok: true, detail: `thresholds from applied calibration run "${runId}"` };
110
- }
111
-
112
33
  export { describeDeployment };
113
34
 
114
35
  export async function diagnose(
@@ -246,10 +167,6 @@ export async function diagnose(
246
167
  }
247
168
  }
248
169
 
249
- if (config.inference.mode === "remote" && config.inference.reranker && config.inference.thresholds) {
250
- checks.push(checkCalibration(config));
251
- }
252
-
253
170
  const inferenceReady = checks.some((check) => check.name === "embedding" && check.ok);
254
171
  const rerankerReady = checks.some((check) => check.name === "reranker" && check.ok);
255
172
  const coreReady = checks.some((check) => check.name === "vault" && check.ok)
package/src/eval.ts CHANGED
@@ -1,31 +1,68 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { z } from "zod";
4
3
  import {
5
4
  backfillEmbeddings,
6
- decideRetrievalResult,
7
5
  getRuntime,
8
6
  retrieveAndRerank,
9
7
  } from "./router-core";
10
8
 
11
9
  export interface EvalCase {
12
10
  query: string;
13
- expected: string[];
11
+ split?: string;
12
+ relevant_skill_ids: string[];
14
13
  }
15
14
 
16
- const rawEvalItemSchema = z.object({
17
- query: z.string().min(1),
18
- expected: z.array(z.string().min(1)).optional(),
19
- relevant_skill_ids: z.array(z.string()).optional(),
20
- split: z.string().optional(),
21
- expected_outcome: z.string().optional(),
22
- });
23
-
15
+ export function parseEvalCases(raw: unknown): EvalCase[] {
16
+ if (!Array.isArray(raw)) throw new Error("Eval cases file must contain a JSON array");
17
+ const result: EvalCase[] = [];
18
+ for (let i = 0; i < raw.length; i++) {
19
+ const item = raw[i];
20
+ if (typeof item !== "object" || item === null) {
21
+ throw new Error(`Eval case at index ${i} must be an object`);
22
+ }
23
+ if ("expected" in item) {
24
+ throw new Error(
25
+ `Legacy 'expected' field at case ${i} is no longer supported in eval datasets; use 'relevant_skill_ids' instead.`,
26
+ );
27
+ }
28
+ if ("expected_outcome" in item) {
29
+ throw new Error(
30
+ `Legacy 'expected_outcome' field at case ${i} is no longer supported in eval datasets; remove it and provide 'relevant_skill_ids' instead.`,
31
+ );
32
+ }
33
+ if (typeof (item as any).query !== "string" || (item as any).query.trim().length === 0) {
34
+ throw new Error(`Eval case at index ${i} has invalid 'query': must be a non-empty string`);
35
+ }
36
+ if (
37
+ !Array.isArray((item as any).relevant_skill_ids) ||
38
+ (item as any).relevant_skill_ids.some((id: unknown) => typeof id !== "string")
39
+ ) {
40
+ throw new Error(`Eval case at index ${i} has invalid 'relevant_skill_ids': must be an array of strings`);
41
+ }
42
+ const relevantSkillIds = (item as any).relevant_skill_ids as string[];
43
+ if (relevantSkillIds.some((id) => id.trim().length === 0)) {
44
+ throw new Error(`Eval case at index ${i} has invalid 'relevant_skill_ids': IDs must be non-empty strings`);
45
+ }
46
+ if (new Set(relevantSkillIds).size !== relevantSkillIds.length) {
47
+ throw new Error(`Eval case at index ${i} has invalid 'relevant_skill_ids': duplicate IDs are not allowed`);
48
+ }
49
+ if ("split" in item && (item as any).split !== undefined && typeof (item as any).split !== "string") {
50
+ throw new Error(`Eval case at index ${i} has invalid 'split': must be a string if present`);
51
+ }
52
+ result.push({
53
+ query: (item as any).query,
54
+ ...((item as any).split !== undefined ? { split: (item as any).split } : {}),
55
+ relevant_skill_ids: relevantSkillIds,
56
+ });
57
+ }
58
+ return result;
59
+ }
24
60
 
25
61
  export interface EvalMetrics {
26
- recall_at_3: number;
27
62
  recall_at_5: number;
63
+ recall_at_10: number;
28
64
  mrr: number;
65
+ ndcg_at_10: number;
29
66
  }
30
67
 
31
68
  export interface CandidateEvalDetail {
@@ -37,8 +74,7 @@ export interface CandidateEvalDetail {
37
74
 
38
75
  export interface EvalCaseResult {
39
76
  query: string;
40
- expected: string[];
41
- outcome: "matched" | "ambiguous" | "no_match";
77
+ relevant_skill_ids: string[];
42
78
  retrieval: string;
43
79
  degraded_from?: string | null;
44
80
  degradation_reason?: string | null;
@@ -53,48 +89,88 @@ export interface EvalCaseResult {
53
89
 
54
90
  export interface EvalReport {
55
91
  queries: number;
92
+ judged_queries: number;
93
+ unjudged_queries: number;
56
94
  lexical: EvalMetrics;
57
95
  hybrid: EvalMetrics;
58
96
  cases?: EvalCaseResult[];
59
97
  }
60
98
 
61
- function metrics(rankings: string[][], cases: EvalCase[]): EvalMetrics {
62
- if (cases.length === 0) return { recall_at_3: 0, recall_at_5: 0, mrr: 0 };
63
- let recall3 = 0;
64
- let recall5 = 0;
65
- let reciprocalRanks = 0;
66
- rankings.forEach((ranking, index) => {
67
- const expected = new Set(cases[index]!.expected);
68
- if (ranking.slice(0, 3).some((id) => expected.has(id))) recall3++;
69
- if (ranking.slice(0, 5).some((id) => expected.has(id))) recall5++;
70
- const rank = ranking.findIndex((id) => expected.has(id));
71
- if (rank >= 0) reciprocalRanks += 1 / (rank + 1);
72
- });
99
+ export function computeRankingMetrics(rankings: string[][], cases: EvalCase[]): EvalMetrics {
100
+ const judgedIndices: number[] = [];
101
+ for (let i = 0; i < cases.length; i++) {
102
+ if (cases[i]!.relevant_skill_ids && cases[i]!.relevant_skill_ids.length > 0) {
103
+ judgedIndices.push(i);
104
+ }
105
+ }
106
+
107
+ if (judgedIndices.length === 0) {
108
+ return {
109
+ recall_at_5: 0,
110
+ recall_at_10: 0,
111
+ mrr: 0,
112
+ ndcg_at_10: 0,
113
+ };
114
+ }
115
+
116
+ let totalRecall5 = 0;
117
+ let totalRecall10 = 0;
118
+ let totalMRR = 0;
119
+ let totalNDCG10 = 0;
120
+
121
+ for (const idx of judgedIndices) {
122
+ const c = cases[idx]!;
123
+ const ranking = rankings[idx] ?? [];
124
+ const relevantSet = new Set(c.relevant_skill_ids);
125
+ const numRelevant = c.relevant_skill_ids.length;
126
+
127
+ // Recall@5: fraction of relevant skill IDs present in the first 5 results
128
+ const top5 = ranking.slice(0, 5);
129
+ const count5 = top5.filter((id) => relevantSet.has(id)).length;
130
+ totalRecall5 += count5 / numRelevant;
131
+
132
+ // Recall@10: fraction of relevant skill IDs present in the first 10 results
133
+ const top10 = ranking.slice(0, 10);
134
+ const count10 = top10.filter((id) => relevantSet.has(id)).length;
135
+ totalRecall10 += count10 / numRelevant;
136
+
137
+ // MRR: reciprocal rank of the first relevant result (1-based), 0 if missing
138
+ const firstRelevantRank = ranking.findIndex((id) => relevantSet.has(id));
139
+ if (firstRelevantRank >= 0) {
140
+ totalMRR += 1 / (firstRelevantRank + 1);
141
+ }
142
+
143
+ // nDCG@10: binary relevance, DCG gain 1/log2(rank+1), IDCG over min(numRelevant, 10)
144
+ let dcg10 = 0;
145
+ for (let r = 0; r < Math.min(10, ranking.length); r++) {
146
+ if (relevantSet.has(ranking[r]!)) {
147
+ dcg10 += 1 / Math.log2(r + 2);
148
+ }
149
+ }
150
+ let idcg10 = 0;
151
+ const idealCount = Math.min(numRelevant, 10);
152
+ for (let r = 0; r < idealCount; r++) {
153
+ idcg10 += 1 / Math.log2(r + 2);
154
+ }
155
+ if (idcg10 > 0) {
156
+ totalNDCG10 += dcg10 / idcg10;
157
+ }
158
+ }
159
+
160
+ const judgedCount = judgedIndices.length;
73
161
  return {
74
- recall_at_3: recall3 / cases.length,
75
- recall_at_5: recall5 / cases.length,
76
- mrr: reciprocalRanks / cases.length,
162
+ recall_at_5: totalRecall5 / judgedCount,
163
+ recall_at_10: totalRecall10 / judgedCount,
164
+ mrr: totalMRR / judgedCount,
165
+ ndcg_at_10: totalNDCG10 / judgedCount,
77
166
  };
78
167
  }
79
168
 
80
169
  export function loadEvalCases(path = join(import.meta.dir, "..", "eval", "queries.json")): EvalCase[] {
81
170
  const raw = JSON.parse(readFileSync(path, "utf8"));
82
- if (!Array.isArray(raw)) throw new Error("Eval cases file must contain a JSON array");
83
- const parsed = z.array(rawEvalItemSchema).parse(raw);
84
- const result: EvalCase[] = [];
85
- for (const item of parsed) {
86
- const expected = item.expected ?? item.relevant_skill_ids;
87
- if (expected && expected.length > 0) {
88
- result.push({ query: item.query, expected });
89
- }
90
- }
91
- if (parsed.length > 0 && result.length === 0) {
92
- throw new Error("Eval cases file contained no cases with expected targets");
93
- }
94
- return result;
171
+ return parseEvalCases(raw);
95
172
  }
96
173
 
97
-
98
174
  export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
99
175
  const { config } = await getRuntime();
100
176
  await backfillEmbeddings();
@@ -107,27 +183,27 @@ export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
107
183
  for (const evalCase of cases) {
108
184
  const start = performance.now();
109
185
  const retrievalResult = await retrieveAndRerank({ query: evalCase.query });
110
- const decision = decideRetrievalResult(config, retrievalResult);
111
- const fusedRanking = retrievalResult.trace
112
- .filter((candidate) => candidate.fused_rank !== null)
113
- .sort((a, b) => a.fused_rank! - b.fused_rank!)
114
- .map((candidate) => candidate.skill_id);
115
-
116
- lexicalRankings.push(retrievalResult.trace
117
- .filter((candidate) => candidate.lexical_rank !== null)
118
- .sort((a, b) => a.lexical_rank! - b.lexical_rank!)
119
- .map((candidate) => candidate.skill_id));
120
- hybridRankings.push(fusedRanking.length > 0
121
- ? fusedRanking
122
- : retrievalResult.candidates.map((candidate) => candidate.skill_id));
186
+
187
+ // lexical metrics use lexical rank
188
+ lexicalRankings.push(
189
+ retrievalResult.trace
190
+ .filter((candidate) => candidate.lexical_rank !== null)
191
+ .sort((a, b) => a.lexical_rank! - b.lexical_rank!)
192
+ .map((candidate) => candidate.skill_id),
193
+ );
194
+
195
+ // retrieveAndRerank returns candidates in the exact order delivered to the
196
+ // caller: reranked when available, otherwise fused or lexical fallback.
197
+ hybridRankings.push(
198
+ retrievalResult.candidates.map((candidate) => candidate.skill_id),
199
+ );
123
200
 
124
201
  const latency_ms = Math.round(performance.now() - start);
125
202
  const candidateDetails: CandidateEvalDetail[] = retrievalResult.trace;
126
203
 
127
204
  caseResults.push({
128
205
  query: evalCase.query,
129
- expected: evalCase.expected,
130
- outcome: decision.outcome,
206
+ relevant_skill_ids: evalCase.relevant_skill_ids,
131
207
  retrieval: retrievalResult.retrieval,
132
208
  degraded_from: retrievalResult.degraded_from ?? null,
133
209
  degradation_reason: retrievalResult.degradation_reason ?? null,
@@ -141,10 +217,17 @@ export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
141
217
  });
142
218
  }
143
219
 
220
+ const judged_queries = cases.filter(
221
+ (c) => c.relevant_skill_ids && c.relevant_skill_ids.length > 0,
222
+ ).length;
223
+ const unjudged_queries = cases.length - judged_queries;
224
+
144
225
  return {
145
226
  queries: cases.length,
146
- lexical: metrics(lexicalRankings, cases),
147
- hybrid: metrics(hybridRankings, cases),
227
+ judged_queries,
228
+ unjudged_queries,
229
+ lexical: computeRankingMetrics(lexicalRankings, cases),
230
+ hybrid: computeRankingMetrics(hybridRankings, cases),
148
231
  cases: caseResults,
149
232
  };
150
233
  }
package/src/init.ts CHANGED
@@ -357,15 +357,14 @@ export function applyInit(
357
357
  return manifest;
358
358
  }
359
359
 
360
- /** Verbatim from docs/sdd/skr-cli/think.md §3.3 — the shared instruction-stack paragraph. */
361
360
  export const DISCOVERY_PARAGRAPH =
362
361
  "Skills: only a curated core is loaded statically. Before improvising a " +
363
362
  "multi-step workflow, or when a task smells like a domain you have no loaded " +
364
363
  "skill for (career/resume, trading, SEO, i18n, design, one-off tooling), " +
365
- "call `resolve_skill` with a one-line task description. `matched` → follow " +
366
- "the returned SKILL.md. `ambiguous` pick from the candidates and " +
367
- "`fetch_skill`. `no_match` proceed normally; don't force an unrelated " +
368
- "skill.";
364
+ "call `resolve_skill` with a one-line task description. `resolve_skill` " +
365
+ "returns a ranked shortlist; inspect candidates, fetch one or more " +
366
+ "relevant skills with `fetch_skill`, or ignore all and proceed normally " +
367
+ "when none are useful.";
369
368
 
370
369
  export const MCP_REGISTRATION_SNIPPET = JSON.stringify(
371
370
  { mcpServers: { "skillmux": { command: "skillmux", args: ["serve"] } } },
package/src/metrics.ts CHANGED
@@ -8,8 +8,7 @@ type MetricsDeploymentIdentity = Pick<
8
8
 
9
9
  export class MetricsRegistry {
10
10
  private requests = new Map<string, number>();
11
- private outcomes = new Map<string, number>();
12
-
11
+
13
12
  private buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
14
13
  private latencyBuckets = new Array(this.buckets.length).fill(0);
15
14
  private latencyInf = 0;
@@ -34,10 +33,6 @@ export class MetricsRegistry {
34
33
  this.requests.set(method, (this.requests.get(method) || 0) + 1);
35
34
  }
36
35
 
37
- recordResolveOutcome(outcome: string) {
38
- this.outcomes.set(outcome, (this.outcomes.get(outcome) || 0) + 1);
39
- }
40
-
41
36
  recordDegradation(stage: "embedding" | "reranker", reason: string) {
42
37
  const key = `${stage}:${reason}`;
43
38
  this.degradations.set(key, (this.degradations.get(key) || 0) + 1);
@@ -72,13 +67,6 @@ export class MetricsRegistry {
72
67
  lines.push(`skill_router_requests_total{method="${method}"} ${count}`);
73
68
  }
74
69
 
75
- // Resolve outcomes total
76
- lines.push("# HELP skill_router_resolve_outcomes_total Total count of resolve_skill query outcomes.");
77
- lines.push("# TYPE skill_router_resolve_outcomes_total counter");
78
- for (const [outcome, count] of this.outcomes) {
79
- lines.push(`skill_router_resolve_outcomes_total{outcome="${outcome}"} ${count}`);
80
- }
81
-
82
70
  // Latency histogram
83
71
  lines.push("# HELP skill_router_resolve_latency_seconds Latency histogram of resolve_skill executions.");
84
72
  lines.push("# TYPE skill_router_resolve_latency_seconds histogram");