@klhapp/skillmux 1.4.1 → 1.5.1
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 +22 -0
- package/config.example.toml +6 -0
- package/config.remote.example.toml +7 -0
- package/docs/configuration.md +43 -19
- package/docs/releasing.md +1 -1
- package/docs/schema.json +60 -7
- package/package.json +1 -1
- package/src/adapters.ts +11 -4
- package/src/audit.ts +8 -1
- package/src/calibrate.ts +17 -2
- package/src/cli.ts +3 -1
- package/src/clients.ts +19 -0
- package/src/commands/config.ts +13 -0
- package/src/config-service.ts +43 -15
- package/src/config-watcher.ts +3 -0
- package/src/config.ts +183 -54
- package/src/db.ts +15 -3
- package/src/doctor.ts +17 -0
- package/src/eval.ts +69 -12
- package/src/metrics.ts +14 -0
- package/src/router-core.ts +131 -15
- package/src/server.ts +4 -0
- package/src/types.ts +29 -1
package/src/router-core.ts
CHANGED
|
@@ -23,12 +23,13 @@ import {
|
|
|
23
23
|
vectorTopK,
|
|
24
24
|
} from "./db";
|
|
25
25
|
import type { SkillRow } from "./db";
|
|
26
|
-
import { decideResolveOutcome } from "./decision";
|
|
26
|
+
import { decideResolveOutcome, type Decision } from "./decision";
|
|
27
27
|
import type {
|
|
28
28
|
RankedCandidate,
|
|
29
29
|
RetrievalCapability,
|
|
30
30
|
Clients,
|
|
31
31
|
Config,
|
|
32
|
+
DegradationReason,
|
|
32
33
|
FetchSkillInput,
|
|
33
34
|
FetchSkillResult,
|
|
34
35
|
ResolveResult,
|
|
@@ -400,16 +401,10 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
|
|
|
400
401
|
return result;
|
|
401
402
|
}
|
|
402
403
|
|
|
403
|
-
const
|
|
404
|
+
const retrievalResult = await retrieveAndRerank(input);
|
|
405
|
+
const { retrieval, candidates: rankedCandidates } = retrievalResult;
|
|
404
406
|
|
|
405
|
-
const
|
|
406
|
-
? { candidate_limit: config.thresholds.candidate_limit, ...config.inference.thresholds }
|
|
407
|
-
: config.thresholds;
|
|
408
|
-
const decision = decideResolveOutcome({
|
|
409
|
-
reranked: retrieval === "reranked",
|
|
410
|
-
candidates: rankedCandidates,
|
|
411
|
-
thresholds: decisionThresholds,
|
|
412
|
-
});
|
|
407
|
+
const decision = decideRetrievalResult(config, retrievalResult);
|
|
413
408
|
|
|
414
409
|
let result: ResolveResult;
|
|
415
410
|
if (decision.outcome === "matched") {
|
|
@@ -417,6 +412,12 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
|
|
|
417
412
|
result = {
|
|
418
413
|
outcome: "matched",
|
|
419
414
|
retrieval: "reranked",
|
|
415
|
+
...(retrievalResult.degraded_from
|
|
416
|
+
? {
|
|
417
|
+
degraded_from: retrievalResult.degraded_from,
|
|
418
|
+
degradation_reason: retrievalResult.degradation_reason,
|
|
419
|
+
}
|
|
420
|
+
: {}),
|
|
420
421
|
skill_id: decision.skill_id,
|
|
421
422
|
title: delivery.title,
|
|
422
423
|
content_sha256: delivery.content_sha256,
|
|
@@ -429,10 +430,26 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
|
|
|
429
430
|
result = {
|
|
430
431
|
outcome: "ambiguous",
|
|
431
432
|
retrieval,
|
|
433
|
+
...(retrievalResult.degraded_from
|
|
434
|
+
? {
|
|
435
|
+
degraded_from: retrievalResult.degraded_from,
|
|
436
|
+
degradation_reason: retrievalResult.degradation_reason,
|
|
437
|
+
}
|
|
438
|
+
: {}),
|
|
432
439
|
candidates: decision.candidates.map(({ score: _score, ...candidate }) => candidate),
|
|
433
440
|
};
|
|
434
441
|
} else {
|
|
435
|
-
result = {
|
|
442
|
+
result = {
|
|
443
|
+
outcome: "no_match",
|
|
444
|
+
retrieval,
|
|
445
|
+
...(retrievalResult.degraded_from
|
|
446
|
+
? {
|
|
447
|
+
degraded_from: retrievalResult.degraded_from,
|
|
448
|
+
degradation_reason: retrievalResult.degradation_reason,
|
|
449
|
+
}
|
|
450
|
+
: {}),
|
|
451
|
+
message: NO_MATCH_MESSAGE,
|
|
452
|
+
};
|
|
436
453
|
}
|
|
437
454
|
|
|
438
455
|
insertAudit(
|
|
@@ -443,6 +460,8 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
|
|
|
443
460
|
query: input.query,
|
|
444
461
|
outcome: result.outcome,
|
|
445
462
|
retrieval,
|
|
463
|
+
degraded_from: retrievalResult.degraded_from ?? null,
|
|
464
|
+
degradation_reason: retrievalResult.degradation_reason ?? null,
|
|
446
465
|
candidates: rankedCandidates.map((c) => ({ skill_id: c.skill_id, score: c.score })),
|
|
447
466
|
selected_skill_id: result.outcome === "matched" ? result.skill_id : null,
|
|
448
467
|
latency_ms: Math.round(performance.now() - t0),
|
|
@@ -454,7 +473,61 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
|
|
|
454
473
|
|
|
455
474
|
export interface RetrievalResult {
|
|
456
475
|
retrieval: Exclude<RetrievalCapability, "exact">;
|
|
476
|
+
degraded_from?: "reranked" | "hybrid";
|
|
477
|
+
degradation_reason?: DegradationReason;
|
|
457
478
|
candidates: RankedCandidate[];
|
|
479
|
+
trace: Array<{
|
|
480
|
+
skill_id: string;
|
|
481
|
+
lexical_rank: number | null;
|
|
482
|
+
fused_rank: number | null;
|
|
483
|
+
reranked_rank: number | null;
|
|
484
|
+
}>;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function decideRetrievalResult(config: Config, result: RetrievalResult): Decision {
|
|
488
|
+
const candidateLimit = config.output?.ambiguous_candidate_limit ?? config.thresholds?.candidate_limit ?? 5;
|
|
489
|
+
const thresholds = result.retrieval === "reranked" && config.inference.mode === "remote"
|
|
490
|
+
? { candidate_limit: candidateLimit, ...config.inference.thresholds }
|
|
491
|
+
: { ...config.thresholds, candidate_limit: candidateLimit };
|
|
492
|
+
return decideResolveOutcome({
|
|
493
|
+
reranked: result.retrieval === "reranked",
|
|
494
|
+
candidates: result.candidates,
|
|
495
|
+
thresholds,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function classifyInferenceError(
|
|
500
|
+
stage: "embedding" | "reranker",
|
|
501
|
+
error: unknown,
|
|
502
|
+
): DegradationReason {
|
|
503
|
+
const isTimeout =
|
|
504
|
+
(error as { name?: string })?.name === "TimeoutError" ||
|
|
505
|
+
(error as { name?: string })?.name === "AbortError" ||
|
|
506
|
+
String(error).toLowerCase().includes("timeout") ||
|
|
507
|
+
String(error).toLowerCase().includes("aborted");
|
|
508
|
+
|
|
509
|
+
if (isTimeout) {
|
|
510
|
+
return stage === "embedding" ? "embedding_timeout" : "reranker_timeout";
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (error instanceof RemoteInferenceError) {
|
|
514
|
+
if (error.kind === "protocol") {
|
|
515
|
+
return stage === "embedding" ? "embedding_protocol_error" : "reranker_protocol_error";
|
|
516
|
+
}
|
|
517
|
+
return stage === "embedding" ? "embedding_unavailable" : "reranker_unavailable";
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
521
|
+
if (
|
|
522
|
+
msg.includes("protocol") ||
|
|
523
|
+
msg.includes("malformed") ||
|
|
524
|
+
msg.includes("invalid") ||
|
|
525
|
+
msg.includes("json")
|
|
526
|
+
) {
|
|
527
|
+
return stage === "embedding" ? "embedding_protocol_error" : "reranker_protocol_error";
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return stage === "embedding" ? "embedding_unavailable" : "reranker_unavailable";
|
|
458
531
|
}
|
|
459
532
|
|
|
460
533
|
/**
|
|
@@ -469,31 +542,60 @@ export async function retrieveAndRerank(
|
|
|
469
542
|
await syncVaultIfNeeded();
|
|
470
543
|
const clients = getClients();
|
|
471
544
|
const lexical = ftsSearch(db, input.query, config.recall.k_lexical);
|
|
545
|
+
const lexicalRanks = new Map(lexical.map((row, index) => [row.skill_id, index + 1]));
|
|
472
546
|
|
|
473
547
|
let retrieval: RetrievalResult["retrieval"] = "lexical";
|
|
548
|
+
let degraded_from: RetrievalResult["degraded_from"] = undefined;
|
|
549
|
+
let degradation_reason: RetrievalResult["degradation_reason"] = undefined;
|
|
474
550
|
let rows = lexical;
|
|
551
|
+
let fusedRows: SkillRow[] | null = null;
|
|
552
|
+
|
|
475
553
|
if (!input.forceLexical) {
|
|
476
554
|
try {
|
|
477
555
|
const queryVec = (await clients.embed([input.query]))[0];
|
|
478
556
|
if (!queryVec) throw new Error("Embedding client returned no query vector.");
|
|
479
557
|
const nearest = vectorTopK(db, queryVec, config.recall.k_vector);
|
|
480
558
|
rows = reciprocalRankFusion(lexical, nearest);
|
|
559
|
+
fusedRows = rows;
|
|
481
560
|
retrieval = "hybrid";
|
|
482
|
-
} catch {
|
|
561
|
+
} catch (embedError) {
|
|
483
562
|
retrieval = "lexical";
|
|
563
|
+
degraded_from = clients.rerank ? "reranked" : "hybrid";
|
|
564
|
+
degradation_reason = classifyInferenceError("embedding", embedError);
|
|
565
|
+
console.error(
|
|
566
|
+
JSON.stringify({
|
|
567
|
+
level: "warn",
|
|
568
|
+
stage: "embedding",
|
|
569
|
+
degraded_from,
|
|
570
|
+
reason: degradation_reason,
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
484
573
|
}
|
|
485
574
|
}
|
|
486
575
|
|
|
487
576
|
let scores: number[] | null = null;
|
|
488
577
|
if (clients.rerank && retrieval === "hybrid" && rows.length > 0) {
|
|
578
|
+
const kRerank = config.recall.k_rerank ?? 10;
|
|
579
|
+
const rerankCandidates = rows.slice(0, kRerank);
|
|
489
580
|
try {
|
|
490
581
|
scores = await clients.rerank(
|
|
491
582
|
input.query,
|
|
492
|
-
|
|
583
|
+
rerankCandidates.map((r) => ({ skill_id: r.skill_id, text: rerankText(r) })),
|
|
493
584
|
);
|
|
494
585
|
retrieval = "reranked";
|
|
495
|
-
|
|
586
|
+
rows = rerankCandidates;
|
|
587
|
+
} catch (rerankError) {
|
|
496
588
|
scores = null;
|
|
589
|
+
degraded_from = "reranked";
|
|
590
|
+
degradation_reason = classifyInferenceError("reranker", rerankError);
|
|
591
|
+
console.error(
|
|
592
|
+
JSON.stringify({
|
|
593
|
+
level: "warn",
|
|
594
|
+
stage: "reranker",
|
|
595
|
+
degraded_from,
|
|
596
|
+
reason: degradation_reason,
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
497
599
|
}
|
|
498
600
|
}
|
|
499
601
|
|
|
@@ -505,8 +607,22 @@ export async function retrieveAndRerank(
|
|
|
505
607
|
score: scores?.[i] ?? null,
|
|
506
608
|
}))
|
|
507
609
|
.sort((a, b) => scores === null ? 0 : (b.score ?? -Infinity) - (a.score ?? -Infinity));
|
|
610
|
+
const rerankedRanks = retrieval === "reranked"
|
|
611
|
+
? new Map(candidates.map((candidate, index) => [candidate.skill_id, index + 1]))
|
|
612
|
+
: new Map<string, number>();
|
|
613
|
+
const traceRows = fusedRows ?? rows;
|
|
508
614
|
|
|
509
|
-
return {
|
|
615
|
+
return {
|
|
616
|
+
retrieval,
|
|
617
|
+
...(degraded_from ? { degraded_from, degradation_reason } : {}),
|
|
618
|
+
candidates,
|
|
619
|
+
trace: traceRows.map((row, index) => ({
|
|
620
|
+
skill_id: row.skill_id,
|
|
621
|
+
lexical_rank: lexicalRanks.get(row.skill_id) ?? null,
|
|
622
|
+
fused_rank: fusedRows ? index + 1 : null,
|
|
623
|
+
reranked_rank: rerankedRanks.get(row.skill_id) ?? null,
|
|
624
|
+
})),
|
|
625
|
+
};
|
|
510
626
|
}
|
|
511
627
|
|
|
512
628
|
export async function fetchSkill(input: FetchSkillInput): Promise<FetchSkillResult> {
|
package/src/server.ts
CHANGED
|
@@ -86,6 +86,10 @@ export function createMcpServer(): McpServer {
|
|
|
86
86
|
const duration = (performance.now() - startTime) / 1000;
|
|
87
87
|
metricsRegistry.recordResolveLatencySeconds(duration);
|
|
88
88
|
metricsRegistry.recordResolveOutcome(result.outcome);
|
|
89
|
+
if (result.degradation_reason) {
|
|
90
|
+
const stage = result.degradation_reason.startsWith("embedding_") ? "embedding" : "reranker";
|
|
91
|
+
metricsRegistry.recordDegradation(stage, result.degradation_reason);
|
|
92
|
+
}
|
|
89
93
|
|
|
90
94
|
if (result.outcome === "matched") {
|
|
91
95
|
const { body, ...meta } = result;
|
package/src/types.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
export interface RecallConfig {
|
|
2
2
|
k_lexical: number;
|
|
3
3
|
k_vector: number;
|
|
4
|
+
k_rerank?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface OutputConfig {
|
|
8
|
+
ambiguous_candidate_limit: number;
|
|
4
9
|
}
|
|
5
10
|
|
|
6
11
|
export interface Thresholds {
|
|
7
|
-
|
|
12
|
+
/** @deprecated Use output.ambiguous_candidate_limit instead */
|
|
13
|
+
candidate_limit?: number;
|
|
8
14
|
match_score?: number;
|
|
9
15
|
match_margin?: number;
|
|
10
16
|
candidate_floor?: number;
|
|
@@ -98,12 +104,18 @@ export interface ServerConfig {
|
|
|
98
104
|
admin?: AdminConfig;
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
export interface ConfigPolicy {
|
|
108
|
+
environment_overrides?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
export interface Config {
|
|
112
|
+
config?: ConfigPolicy;
|
|
102
113
|
vault_path: string;
|
|
103
114
|
local_vault_paths: string[];
|
|
104
115
|
state_dir: string;
|
|
105
116
|
recall: RecallConfig;
|
|
106
117
|
thresholds: Thresholds;
|
|
118
|
+
output: OutputConfig;
|
|
107
119
|
inference: InferenceConfig;
|
|
108
120
|
server?: ServerConfig;
|
|
109
121
|
}
|
|
@@ -120,9 +132,19 @@ export interface RankedCandidate extends Candidate {
|
|
|
120
132
|
|
|
121
133
|
export type RetrievalCapability = "exact" | "reranked" | "hybrid" | "lexical";
|
|
122
134
|
|
|
135
|
+
export type DegradationReason =
|
|
136
|
+
| "embedding_timeout"
|
|
137
|
+
| "embedding_unavailable"
|
|
138
|
+
| "embedding_protocol_error"
|
|
139
|
+
| "reranker_timeout"
|
|
140
|
+
| "reranker_unavailable"
|
|
141
|
+
| "reranker_protocol_error";
|
|
142
|
+
|
|
123
143
|
export interface MatchedResult {
|
|
124
144
|
outcome: "matched";
|
|
125
145
|
retrieval: "exact" | "reranked";
|
|
146
|
+
degraded_from?: "reranked" | "hybrid";
|
|
147
|
+
degradation_reason?: DegradationReason;
|
|
126
148
|
skill_id: string;
|
|
127
149
|
title: string;
|
|
128
150
|
content_sha256: string;
|
|
@@ -135,12 +157,16 @@ export interface MatchedResult {
|
|
|
135
157
|
export interface AmbiguousResult {
|
|
136
158
|
outcome: "ambiguous";
|
|
137
159
|
retrieval: RetrievalCapability;
|
|
160
|
+
degraded_from?: "reranked" | "hybrid";
|
|
161
|
+
degradation_reason?: DegradationReason;
|
|
138
162
|
candidates: Candidate[];
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
export interface NoMatchResult {
|
|
142
166
|
outcome: "no_match";
|
|
143
167
|
retrieval: RetrievalCapability;
|
|
168
|
+
degraded_from?: "reranked" | "hybrid";
|
|
169
|
+
degradation_reason?: DegradationReason;
|
|
144
170
|
message: string;
|
|
145
171
|
}
|
|
146
172
|
|
|
@@ -175,6 +201,8 @@ export interface AuditRow {
|
|
|
175
201
|
query: string;
|
|
176
202
|
outcome: "matched" | "ambiguous" | "no_match";
|
|
177
203
|
retrieval: RetrievalCapability;
|
|
204
|
+
degraded_from?: "reranked" | "hybrid" | null;
|
|
205
|
+
degradation_reason?: DegradationReason | null;
|
|
178
206
|
candidates: AuditCandidate[];
|
|
179
207
|
selected_skill_id: string | null;
|
|
180
208
|
latency_ms: number;
|