@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.
@@ -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 { retrieval, candidates: rankedCandidates } = await retrieveAndRerank(input);
404
+ const retrievalResult = await retrieveAndRerank(input);
405
+ const { retrieval, candidates: rankedCandidates } = retrievalResult;
404
406
 
405
- const decisionThresholds = retrieval === "reranked" && config.inference.mode === "remote"
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 = { outcome: "no_match", retrieval, message: NO_MATCH_MESSAGE };
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
- rows.map((r) => ({ skill_id: r.skill_id, text: rerankText(r) })),
583
+ rerankCandidates.map((r) => ({ skill_id: r.skill_id, text: rerankText(r) })),
493
584
  );
494
585
  retrieval = "reranked";
495
- } catch {
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 { retrieval, candidates };
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
- candidate_limit: number;
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;