@klhapp/skillmux 1.0.1 → 1.1.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/CHANGELOG.md +43 -0
- package/README.md +77 -28
- package/config.remote.example.toml +3 -3
- package/docs/configuration.md +39 -4
- package/docs/schema.json +13 -5
- package/package.json +1 -1
- package/src/adapters.ts +98 -18
- package/src/calibrate.ts +461 -119
- package/src/cli.ts +44 -2
- package/src/clients.ts +264 -48
- package/src/config-service.ts +25 -8
- package/src/config-watcher.ts +7 -0
- package/src/config.ts +106 -25
- package/src/decision.ts +4 -1
- package/src/doctor.ts +16 -5
- package/src/eval.ts +2 -1
- package/src/router-core.ts +73 -42
- package/src/server.ts +8 -5
- package/src/types.ts +3 -3
package/src/decision.ts
CHANGED
|
@@ -23,7 +23,10 @@ export function decideResolveOutcome({ reranked, candidates, thresholds }: Decis
|
|
|
23
23
|
|| thresholds.match_margin === undefined
|
|
24
24
|
|| thresholds.candidate_floor === undefined
|
|
25
25
|
) {
|
|
26
|
-
|
|
26
|
+
return {
|
|
27
|
+
outcome: "ambiguous",
|
|
28
|
+
candidates: candidates.slice(0, thresholds.candidate_limit),
|
|
29
|
+
};
|
|
27
30
|
}
|
|
28
31
|
const { match_score, match_margin, candidate_floor } = thresholds;
|
|
29
32
|
|
package/src/doctor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { createClients } from "./clients";
|
|
2
|
+
import { createClients, RemoteInferenceError } from "./clients";
|
|
3
3
|
import { embeddingDimension, expandHome } from "./config";
|
|
4
4
|
import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
|
|
5
5
|
import { readSkillmuxMarker } from "./sync";
|
|
@@ -10,6 +10,7 @@ export interface DoctorCheck {
|
|
|
10
10
|
name: string;
|
|
11
11
|
ok: boolean;
|
|
12
12
|
detail: string;
|
|
13
|
+
failure_kind?: "configuration" | "availability" | "protocol" | "unexpected";
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export interface DoctorReport {
|
|
@@ -99,8 +100,13 @@ export async function diagnose(config: Config): Promise<DoctorReport> {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
const inferenceFailure = (error: unknown): Pick<DoctorCheck, "detail" | "failure_kind"> =>
|
|
104
|
+
error instanceof RemoteInferenceError
|
|
105
|
+
? { detail: error.message, failure_kind: error.kind }
|
|
106
|
+
: { detail: "unexpected inference failure", failure_kind: "unexpected" };
|
|
107
|
+
|
|
108
|
+
const clients = createClients(config);
|
|
102
109
|
try {
|
|
103
|
-
const clients = createClients(config);
|
|
104
110
|
const vectors = await clients.embed(["skill router diagnostic"]);
|
|
105
111
|
const actualDimension = vectors[0]?.length ?? 0;
|
|
106
112
|
checks.push({
|
|
@@ -108,14 +114,19 @@ export async function diagnose(config: Config): Promise<DoctorReport> {
|
|
|
108
114
|
ok: actualDimension === embeddingDimension(config),
|
|
109
115
|
detail: `dimension ${actualDimension}`,
|
|
110
116
|
});
|
|
111
|
-
|
|
117
|
+
} catch (error) {
|
|
118
|
+
checks.push({ name: "embedding", ok: false, ...inferenceFailure(error) });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (clients.rerank) {
|
|
122
|
+
try {
|
|
112
123
|
const scores = await clients.rerank("skill router diagnostic", [
|
|
113
124
|
{ skill_id: "doctor", text: "Routes a task to an appropriate skill." },
|
|
114
125
|
]);
|
|
115
126
|
checks.push({ name: "reranker", ok: scores.length === 1 && Number.isFinite(scores[0]), detail: "one finite score" });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
checks.push({ name: "reranker", ok: false, ...inferenceFailure(error) });
|
|
116
129
|
}
|
|
117
|
-
} catch (error) {
|
|
118
|
-
checks.push({ name: "inference", ok: false, detail: String(error) });
|
|
119
130
|
}
|
|
120
131
|
|
|
121
132
|
const inferenceReady = checks.some((check) => check.name === "embedding" && check.ok);
|
package/src/eval.ts
CHANGED
|
@@ -78,7 +78,8 @@ export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
|
|
|
78
78
|
const hybridRankings: string[][] = [];
|
|
79
79
|
for (const evalCase of cases) {
|
|
80
80
|
const lexical = ftsSearch(db, evalCase.query, config.recall.k_lexical);
|
|
81
|
-
const vector = (await clients.embed([evalCase.query]))[0]
|
|
81
|
+
const vector = (await clients.embed([evalCase.query]))[0];
|
|
82
|
+
if (!vector) throw new Error("Embedding client returned no query vector.");
|
|
82
83
|
const semantic = vectorTopK(db, vector, config.recall.k_vector);
|
|
83
84
|
lexicalRankings.push(lexical.map((row) => row.skill_id));
|
|
84
85
|
hybridRankings.push(reciprocalRankFusion<SkillRow>(lexical, semantic).map((row) => row.skill_id));
|
package/src/router-core.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { watch } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { buildAuditRow } from "./audit";
|
|
5
5
|
import { embeddingDimension, embeddingFingerprint, expandHome, loadConfig } from "./config";
|
|
6
|
+
import { RemoteInferenceError } from "./clients";
|
|
6
7
|
import {
|
|
7
8
|
deleteSkill,
|
|
8
9
|
findExactMatch,
|
|
@@ -269,12 +270,22 @@ export async function backfillEmbeddings(): Promise<number> {
|
|
|
269
270
|
const chunk = pending.slice(i, i + BATCH_SIZE);
|
|
270
271
|
try {
|
|
271
272
|
const vectors = await clients.embed(chunk.map(rerankText));
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
273
|
+
db.transaction(() => {
|
|
274
|
+
chunk.forEach((row, j) => {
|
|
275
|
+
const vector = vectors[j];
|
|
276
|
+
if (!vector) throw new Error("Embedding client returned an incomplete batch.");
|
|
277
|
+
upsertVector(db, row.skill_id, row.content_sha256, fingerprint, vector);
|
|
278
|
+
});
|
|
279
|
+
})();
|
|
275
280
|
count += chunk.length;
|
|
276
281
|
} catch (err) {
|
|
277
|
-
if (
|
|
282
|
+
if (
|
|
283
|
+
i === 0 ||
|
|
284
|
+
(err instanceof RemoteInferenceError &&
|
|
285
|
+
(err.kind === "configuration" || err.kind === "protocol"))
|
|
286
|
+
) {
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
278
289
|
break;
|
|
279
290
|
}
|
|
280
291
|
}
|
|
@@ -384,44 +395,7 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
|
|
|
384
395
|
return result;
|
|
385
396
|
}
|
|
386
397
|
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
const lexical = ftsSearch(db, input.query, config.recall.k_lexical);
|
|
390
|
-
|
|
391
|
-
let retrieval: RetrievalCapability = "lexical";
|
|
392
|
-
let rows = lexical;
|
|
393
|
-
if (!input.forceLexical) {
|
|
394
|
-
try {
|
|
395
|
-
const queryVec = (await clients.embed([input.query]))[0]!;
|
|
396
|
-
const nearest = vectorTopK(db, queryVec, config.recall.k_vector);
|
|
397
|
-
rows = reciprocalRankFusion(lexical, nearest);
|
|
398
|
-
retrieval = "hybrid";
|
|
399
|
-
} catch {
|
|
400
|
-
retrieval = "lexical";
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
let scores: number[] | null = null;
|
|
405
|
-
if (clients.rerank && retrieval === "hybrid" && rows.length > 0) {
|
|
406
|
-
try {
|
|
407
|
-
scores = await clients.rerank(
|
|
408
|
-
input.query,
|
|
409
|
-
rows.map((r) => ({ skill_id: r.skill_id, text: rerankText(r) })),
|
|
410
|
-
);
|
|
411
|
-
retrieval = "reranked";
|
|
412
|
-
} catch {
|
|
413
|
-
scores = null;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const rankedCandidates: RankedCandidate[] = rows
|
|
418
|
-
.map((r, i) => ({
|
|
419
|
-
skill_id: r.skill_id,
|
|
420
|
-
title: r.title,
|
|
421
|
-
description: r.description,
|
|
422
|
-
score: scores?.[i] ?? null,
|
|
423
|
-
}))
|
|
424
|
-
.sort((a, b) => scores === null ? 0 : (b.score ?? -Infinity) - (a.score ?? -Infinity));
|
|
398
|
+
const { retrieval, candidates: rankedCandidates } = await retrieveAndRerank(input);
|
|
425
399
|
|
|
426
400
|
const decisionThresholds = retrieval === "reranked" && config.inference.mode === "remote"
|
|
427
401
|
? { candidate_limit: config.thresholds.candidate_limit, ...config.inference.thresholds }
|
|
@@ -473,6 +447,63 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
|
|
|
473
447
|
return result;
|
|
474
448
|
}
|
|
475
449
|
|
|
450
|
+
export interface RetrievalResult {
|
|
451
|
+
retrieval: Exclude<RetrievalCapability, "exact">;
|
|
452
|
+
candidates: RankedCandidate[];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Retrieve the full fused candidate set and rerank it once without applying
|
|
457
|
+
* decision thresholds. Calibration uses this to avoid observing the policy it
|
|
458
|
+
* is trying to replace.
|
|
459
|
+
*/
|
|
460
|
+
export async function retrieveAndRerank(
|
|
461
|
+
input: ResolveSkillInput,
|
|
462
|
+
): Promise<RetrievalResult> {
|
|
463
|
+
const { config, db } = await getEnv();
|
|
464
|
+
await syncVaultIfNeeded();
|
|
465
|
+
const clients = getClients();
|
|
466
|
+
const lexical = ftsSearch(db, input.query, config.recall.k_lexical);
|
|
467
|
+
|
|
468
|
+
let retrieval: RetrievalResult["retrieval"] = "lexical";
|
|
469
|
+
let rows = lexical;
|
|
470
|
+
if (!input.forceLexical) {
|
|
471
|
+
try {
|
|
472
|
+
const queryVec = (await clients.embed([input.query]))[0];
|
|
473
|
+
if (!queryVec) throw new Error("Embedding client returned no query vector.");
|
|
474
|
+
const nearest = vectorTopK(db, queryVec, config.recall.k_vector);
|
|
475
|
+
rows = reciprocalRankFusion(lexical, nearest);
|
|
476
|
+
retrieval = "hybrid";
|
|
477
|
+
} catch {
|
|
478
|
+
retrieval = "lexical";
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let scores: number[] | null = null;
|
|
483
|
+
if (clients.rerank && retrieval === "hybrid" && rows.length > 0) {
|
|
484
|
+
try {
|
|
485
|
+
scores = await clients.rerank(
|
|
486
|
+
input.query,
|
|
487
|
+
rows.map((r) => ({ skill_id: r.skill_id, text: rerankText(r) })),
|
|
488
|
+
);
|
|
489
|
+
retrieval = "reranked";
|
|
490
|
+
} catch {
|
|
491
|
+
scores = null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const candidates = rows
|
|
496
|
+
.map((r, i) => ({
|
|
497
|
+
skill_id: r.skill_id,
|
|
498
|
+
title: r.title,
|
|
499
|
+
description: r.description,
|
|
500
|
+
score: scores?.[i] ?? null,
|
|
501
|
+
}))
|
|
502
|
+
.sort((a, b) => scores === null ? 0 : (b.score ?? -Infinity) - (a.score ?? -Infinity));
|
|
503
|
+
|
|
504
|
+
return { retrieval, candidates };
|
|
505
|
+
}
|
|
506
|
+
|
|
476
507
|
export async function fetchSkill(input: FetchSkillInput): Promise<FetchSkillResult> {
|
|
477
508
|
const { config, db } = await getEnv();
|
|
478
509
|
await syncVaultIfNeeded();
|
package/src/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { createClients } from "./clients";
|
|
7
|
-
import { loadConfig, resolveConfigPath } from "./config";
|
|
7
|
+
import { expandHome, loadConfig, rerankerFingerprint, resolveConfigPath } from "./config";
|
|
8
8
|
import { ConfigWatcher, type ReloadStatus } from "./config-watcher";
|
|
9
9
|
import { RuntimeSnapshotManager } from "./snapshot";
|
|
10
10
|
import {
|
|
@@ -513,13 +513,16 @@ export async function startServer(opts?: {
|
|
|
513
513
|
JSON.stringify({ error: "Calibration run not found" }),
|
|
514
514
|
{ status: 404, headers },
|
|
515
515
|
);
|
|
516
|
-
const
|
|
517
|
-
|
|
516
|
+
const active = snapshots.acquire();
|
|
517
|
+
const currentRerankerFingerprint = rerankerFingerprint(
|
|
518
|
+
active.snapshot.config,
|
|
519
|
+
);
|
|
520
|
+
active.release();
|
|
518
521
|
await applyCalibrationRun(
|
|
519
522
|
db,
|
|
520
523
|
runId,
|
|
521
|
-
expandHome(
|
|
522
|
-
{},
|
|
524
|
+
expandHome(configPath),
|
|
525
|
+
{ currentRerankerFingerprint },
|
|
523
526
|
);
|
|
524
527
|
return new Response(JSON.stringify({ ok: true, run_id: runId }), {
|
|
525
528
|
status: 200,
|
package/src/types.ts
CHANGED
|
@@ -54,15 +54,15 @@ export interface LocalInferenceConfig {
|
|
|
54
54
|
|
|
55
55
|
export interface RemoteEmbeddingConfig {
|
|
56
56
|
provider: "openai";
|
|
57
|
-
|
|
57
|
+
endpoint: string;
|
|
58
58
|
model: string;
|
|
59
59
|
dimension: number;
|
|
60
60
|
api_key_env?: string;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export interface RemoteRerankerConfig {
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
adapter: "jina-v1" | "bifrost-v1";
|
|
65
|
+
endpoint: string;
|
|
66
66
|
model: string;
|
|
67
67
|
api_key_env?: string;
|
|
68
68
|
}
|