@klhapp/skillmux 1.6.0 → 1.7.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/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");
@@ -23,7 +23,6 @@ import {
23
23
  vectorTopK,
24
24
  } from "./db";
25
25
  import type { SkillRow } from "./db";
26
- import { decideResolveOutcome, type Decision } from "./decision";
27
26
  import type {
28
27
  RankedCandidate,
29
28
  RetrievalCapability,
@@ -54,7 +53,6 @@ function maxVaultMtime(vaultPath: string, localVaultPaths: string[]): number {
54
53
 
55
54
  export { buildAuditRow } from "./audit";
56
55
  export { loadConfig } from "./config";
57
- export { decideResolveOutcome } from "./decision";
58
56
  export type * from "./types";
59
57
 
60
58
  const NO_MATCH_MESSAGE =
@@ -370,87 +368,40 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
370
368
  const { config, db } = await getEnv();
371
369
  await syncVaultIfNeeded();
372
370
 
373
- // Short-circuit: exact match on skill_id, title, or alias (First Principles #1)
374
- const exactMatch = findExactMatch(db, input.query);
375
- if (exactMatch) {
376
- const delivery = await deliverSkill(db, config, exactMatch.skill_id);
377
- const result: ResolveResult = {
378
- outcome: "matched",
379
- retrieval: "exact",
380
- skill_id: exactMatch.skill_id,
381
- title: delivery.title,
382
- content_sha256: delivery.content_sha256,
383
- score: 1.0,
384
- margin: 1.0,
385
- body: delivery.body,
386
- files: delivery.files,
387
- };
388
- insertAudit(
389
- db,
390
- buildAuditRow({
391
- id: 0,
392
- ts: new Date().toISOString(),
393
- query: input.query,
394
- outcome: "matched",
395
- retrieval: "exact",
396
- candidates: [{ skill_id: exactMatch.skill_id, score: 1.0 }],
397
- selected_skill_id: exactMatch.skill_id,
398
- latency_ms: Math.round(performance.now() - t0),
399
- }),
400
- );
401
- return result;
371
+ if (input.top_k !== undefined) {
372
+ if (!Number.isInteger(input.top_k) || input.top_k < 1) {
373
+ throw new Error(`Invalid top_k: ${input.top_k} must be a positive integer`);
374
+ }
375
+ if (input.top_k > config.output.max_top_k) {
376
+ throw new Error(
377
+ `Invalid top_k: ${input.top_k} exceeds max_top_k of ${config.output.max_top_k}`,
378
+ );
379
+ }
402
380
  }
403
381
 
382
+ const effectiveTopK = input.top_k ?? config.output.top_k;
404
383
  const retrievalResult = await retrieveAndRerank(input);
405
384
  const { retrieval, candidates: rankedCandidates } = retrievalResult;
406
385
 
407
- const decision = decideRetrievalResult(config, retrievalResult);
408
-
409
- let result: ResolveResult;
410
- if (decision.outcome === "matched") {
411
- const delivery = await deliverSkill(db, config, decision.skill_id);
412
- result = {
413
- outcome: "matched",
414
- retrieval: "reranked",
415
- ...(retrievalResult.degraded_from
416
- ? {
417
- degraded_from: retrievalResult.degraded_from,
418
- degradation_reason: retrievalResult.degradation_reason,
419
- }
420
- : {}),
421
- skill_id: decision.skill_id,
422
- title: delivery.title,
423
- content_sha256: delivery.content_sha256,
424
- score: decision.score,
425
- margin: decision.margin,
426
- body: delivery.body,
427
- files: delivery.files,
428
- };
429
- } else if (decision.outcome === "ambiguous") {
430
- result = {
431
- outcome: "ambiguous",
432
- retrieval,
433
- ...(retrievalResult.degraded_from
434
- ? {
435
- degraded_from: retrievalResult.degraded_from,
436
- degradation_reason: retrievalResult.degradation_reason,
437
- }
438
- : {}),
439
- candidates: decision.candidates.map(({ score: _score, ...candidate }) => candidate),
440
- };
441
- } else {
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
- };
453
- }
386
+ const candidates: RankedCandidate[] = rankedCandidates
387
+ .slice(0, effectiveTopK)
388
+ .map((c, index) => ({
389
+ rank: index + 1,
390
+ skill_id: c.skill_id,
391
+ description: c.description,
392
+ score: c.score,
393
+ }));
394
+
395
+ const result: ResolveResult = {
396
+ retrieval,
397
+ ...(retrievalResult.degraded_from
398
+ ? {
399
+ degraded_from: retrievalResult.degraded_from,
400
+ degradation_reason: retrievalResult.degradation_reason,
401
+ }
402
+ : {}),
403
+ candidates,
404
+ };
454
405
 
455
406
  insertAudit(
456
407
  db,
@@ -458,12 +409,10 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
458
409
  id: 0, // assigned by SQLite
459
410
  ts: new Date().toISOString(),
460
411
  query: input.query,
461
- outcome: result.outcome,
462
412
  retrieval,
463
413
  degraded_from: retrievalResult.degraded_from ?? null,
464
414
  degradation_reason: retrievalResult.degradation_reason ?? null,
465
- candidates: rankedCandidates.map((c) => ({ skill_id: c.skill_id, score: c.score })),
466
- selected_skill_id: result.outcome === "matched" ? result.skill_id : null,
415
+ candidates: candidates.map((c) => ({ skill_id: c.skill_id, score: c.score })),
467
416
  latency_ms: Math.round(performance.now() - t0),
468
417
  }),
469
418
  );
@@ -471,11 +420,18 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
471
420
  return result;
472
421
  }
473
422
 
423
+ export interface RawCandidate {
424
+ skill_id: string;
425
+ title: string;
426
+ description: string;
427
+ score: number | null;
428
+ }
429
+
474
430
  export interface RetrievalResult {
475
431
  retrieval: Exclude<RetrievalCapability, "exact">;
476
432
  degraded_from?: "reranked" | "hybrid";
477
433
  degradation_reason?: DegradationReason;
478
- candidates: RankedCandidate[];
434
+ candidates: RawCandidate[];
479
435
  trace: Array<{
480
436
  skill_id: string;
481
437
  lexical_rank: number | null;
@@ -484,18 +440,6 @@ export interface RetrievalResult {
484
440
  }>;
485
441
  }
486
442
 
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
443
  export function classifyInferenceError(
500
444
  stage: "embedding" | "reranker",
501
445
  error: unknown,
@@ -530,64 +474,26 @@ export function classifyInferenceError(
530
474
  return stage === "embedding" ? "embedding_unavailable" : "reranker_unavailable";
531
475
  }
532
476
 
533
- export interface StageTiming {
534
- embedding_ms: number;
535
- lexical_ms: number;
536
- vector_ms: number;
537
- reranker_ms: number;
538
- }
539
-
540
- export interface RetrieveSnapshotOptions {
541
- onTiming?: (timing: StageTiming) => void;
542
- }
543
-
544
477
  /**
545
- * Retrieve the full fused candidate set and rerank it once without applying
546
- * decision thresholds, synchronizing the vault before reading the index.
478
+ * Retrieve the full fused candidate set, optionally rerank it, and synchronize
479
+ * the vault before reading the index.
547
480
  */
548
481
  export async function retrieveAndRerank(
549
482
  input: ResolveSkillInput,
550
483
  ): Promise<RetrievalResult> {
551
484
  await syncVaultIfNeeded();
552
- return retrieveAndRerankSnapshot(input);
553
- }
554
-
555
- /**
556
- * Retrieve candidates against an already-synchronized index snapshot without
557
- * triggering syncVaultIfNeeded(). Calibration uses this to avoid observing the
558
- * policy it is trying to replace while keeping one corpus for the whole run.
559
- */
560
- export async function retrieveAndRerankSnapshot(
561
- input: ResolveSkillInput,
562
- options?: RetrieveSnapshotOptions,
563
- ): Promise<RetrievalResult> {
564
485
  const { config, db } = await getEnv();
565
486
  const clients = getClients();
566
- const measureTiming = options?.onTiming !== undefined;
567
487
 
568
- let embedding_ms = 0;
569
- let lexical_ms = 0;
570
- let vector_ms = 0;
571
- let reranker_ms = 0;
572
-
573
- const tEmbed0 = measureTiming ? performance.now() : 0;
574
488
  const embedPromise =
575
489
  !input.forceLexical && clients.embed
576
490
  ? clients.embed([input.query]).then(
577
- (res) => {
578
- if (measureTiming) embedding_ms = Math.max(0, performance.now() - tEmbed0);
579
- return res;
580
- },
581
- (err) => {
582
- if (measureTiming) embedding_ms = Math.max(0, performance.now() - tEmbed0);
583
- return { error: err };
584
- },
491
+ (res) => res,
492
+ (err) => ({ error: err }),
585
493
  )
586
494
  : null;
587
495
 
588
- const tLex0 = measureTiming ? performance.now() : 0;
589
496
  const lexical = ftsSearch(db, input.query, config.recall.k_lexical);
590
- if (measureTiming) lexical_ms = Math.max(0, performance.now() - tLex0);
591
497
  const lexicalRanks = new Map(lexical.map((row, index) => [row.skill_id, index + 1]));
592
498
 
593
499
  let retrieval: RetrievalResult["retrieval"] = "lexical";
@@ -611,17 +517,14 @@ export async function retrieveAndRerankSnapshot(
611
517
  }),
612
518
  );
613
519
  } else {
614
- const tVec0 = measureTiming ? performance.now() : 0;
615
520
  try {
616
521
  const queryVec = (embedRes as Float32Array[])[0];
617
522
  if (!queryVec) throw new Error("Embedding client returned no query vector.");
618
523
  const nearest = vectorTopK(db, queryVec, config.recall.k_vector);
619
- if (measureTiming) vector_ms = Math.max(0, performance.now() - tVec0);
620
524
  rows = reciprocalRankFusion(lexical, nearest);
621
525
  fusedRows = rows;
622
526
  retrieval = "hybrid";
623
527
  } catch (embedError) {
624
- if (measureTiming) vector_ms = Math.max(0, performance.now() - tVec0);
625
528
  retrieval = "lexical";
626
529
  degraded_from = clients.rerank ? "reranked" : "hybrid";
627
530
  degradation_reason = classifyInferenceError("embedding", embedError);
@@ -641,17 +544,14 @@ export async function retrieveAndRerankSnapshot(
641
544
  if (clients.rerank && retrieval === "hybrid" && rows.length > 0) {
642
545
  const kRerank = config.recall.k_rerank ?? 10;
643
546
  const rerankCandidates = rows.slice(0, kRerank);
644
- const tRerank0 = measureTiming ? performance.now() : 0;
645
547
  try {
646
548
  scores = await clients.rerank(
647
549
  input.query,
648
550
  rerankCandidates.map((r) => ({ skill_id: r.skill_id, text: rerankText(r) })),
649
551
  );
650
- if (measureTiming) reranker_ms = Math.max(0, performance.now() - tRerank0);
651
552
  retrieval = "reranked";
652
553
  rows = rerankCandidates;
653
554
  } catch (rerankError) {
654
- if (measureTiming) reranker_ms = Math.max(0, performance.now() - tRerank0);
655
555
  scores = null;
656
556
  degraded_from = "reranked";
657
557
  degradation_reason = classifyInferenceError("reranker", rerankError);
@@ -679,13 +579,6 @@ export async function retrieveAndRerankSnapshot(
679
579
  : new Map<string, number>();
680
580
  const traceRows = fusedRows ?? rows;
681
581
 
682
- options?.onTiming?.({
683
- embedding_ms,
684
- lexical_ms,
685
- vector_ms,
686
- reranker_ms,
687
- });
688
-
689
582
  return {
690
583
  retrieval,
691
584
  ...(degraded_from ? { degraded_from, degradation_reason } : {}),
package/src/server.ts CHANGED
@@ -74,30 +74,29 @@ export function createMcpServer(): McpServer {
74
74
  "resolve_skill",
75
75
  {
76
76
  description:
77
- "Route a natural-language task description to the most relevant skill in the vault. " +
78
- "Returns outcome matched (skill delivered inline), ambiguous (shortlist pick one, then call fetch_skill), " +
79
- "or no_match (proceed under your normal workflow).",
80
- inputSchema: { query: z.string().min(1) },
77
+ "Route a natural-language task description to candidate skills in the vault. " +
78
+ "Returns a ranked shortlist of candidates. Use fetch_skill to retrieve a candidate's complete instructions.",
79
+ inputSchema: {
80
+ query: z.string().min(1).describe("Natural-language task or prompt description to route."),
81
+ top_k: z
82
+ .number()
83
+ .int()
84
+ .min(1)
85
+ .optional()
86
+ .describe("Optional maximum number of ranked candidates to return (subject to server max_top_k limit)."),
87
+ },
81
88
  },
82
- async ({ query }) => {
89
+ async ({ query, top_k }) => {
83
90
  const startTime = performance.now();
84
91
  try {
85
- const result = await resolveSkill({ query });
92
+ const result = await resolveSkill({ query, top_k });
86
93
  const duration = (performance.now() - startTime) / 1000;
87
94
  metricsRegistry.recordResolveLatencySeconds(duration);
88
- metricsRegistry.recordResolveOutcome(result.outcome);
89
95
  if (result.degradation_reason) {
90
96
  const stage = result.degradation_reason.startsWith("embedding_") ? "embedding" : "reranker";
91
97
  metricsRegistry.recordDegradation(stage, result.degradation_reason);
92
98
  }
93
99
 
94
- if (result.outcome === "matched") {
95
- const { body, ...meta } = result;
96
- return {
97
- content: [{ type: "text" as const, text: body }],
98
- structuredContent: { ...meta },
99
- };
100
- }
101
100
  return {
102
101
  content: [{ type: "text" as const, text: JSON.stringify(result) }],
103
102
  structuredContent: { ...result },
@@ -394,7 +393,6 @@ export async function startServer(opts?: {
394
393
  JSON.stringify({
395
394
  config_read: true,
396
395
  config_write: !isExternallyManaged,
397
- calibration: false,
398
396
  persistence: isExternallyManaged
399
397
  ? "externally_managed"
400
398
  : "writable",
@@ -481,18 +479,6 @@ export async function startServer(opts?: {
481
479
  });
482
480
  }
483
481
 
484
- if (url.pathname.startsWith("/admin/v1/calibrations")) {
485
- return new Response(
486
- JSON.stringify({
487
- error: "not_implemented",
488
- message:
489
- "Remote calibration is not implemented in this release. " +
490
- "Run `skillmux calibrate` against a local target.",
491
- }),
492
- { status: 501, headers },
493
- );
494
- }
495
-
496
482
  return new Response("Not Found", { status: 404, headers });
497
483
  }
498
484