@klhapp/skillmux 0.2.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 +147 -0
- package/LICENSE +21 -0
- package/README.md +410 -0
- package/config.example.toml +5 -0
- package/config.remote.example.toml +25 -0
- package/docs/configuration.md +100 -0
- package/docs/releasing.md +62 -0
- package/docs/schema.json +314 -0
- package/package.json +55 -0
- package/src/audit.ts +15 -0
- package/src/cli.ts +482 -0
- package/src/clients.ts +114 -0
- package/src/config.ts +338 -0
- package/src/db.ts +280 -0
- package/src/decision.ts +42 -0
- package/src/doctor.ts +68 -0
- package/src/eval.ts +74 -0
- package/src/init.ts +122 -0
- package/src/install.ts +113 -0
- package/src/lifecycle.ts +51 -0
- package/src/manifest.ts +105 -0
- package/src/metrics.ts +101 -0
- package/src/models.ts +20 -0
- package/src/rate-limiter.ts +110 -0
- package/src/readiness.ts +30 -0
- package/src/router-core.ts +435 -0
- package/src/rrf.ts +31 -0
- package/src/scan.ts +260 -0
- package/src/server.ts +318 -0
- package/src/stats.ts +165 -0
- package/src/sync.ts +182 -0
- package/src/types.ts +178 -0
- package/src/vault.ts +109 -0
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { createClients } from "./clients";
|
|
3
|
+
import { embeddingDimension, expandHome } from "./config";
|
|
4
|
+
import type { Config } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface DoctorCheck {
|
|
7
|
+
name: string;
|
|
8
|
+
ok: boolean;
|
|
9
|
+
detail: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DoctorReport {
|
|
13
|
+
mode: Config["inference"]["mode"];
|
|
14
|
+
capability: "hybrid" | "lexical-only" | "unavailable";
|
|
15
|
+
checks: DoctorCheck[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function diagnose(config: Config): Promise<DoctorReport> {
|
|
19
|
+
const checks: DoctorCheck[] = [];
|
|
20
|
+
checks.push({ name: "vault", ok: existsSync(expandHome(config.vault_path)), detail: expandHome(config.vault_path) });
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(expandHome(config.state_dir), { recursive: true });
|
|
24
|
+
const probe = Bun.file(expandHome(`${config.state_dir}/.doctor`));
|
|
25
|
+
await probe.write("");
|
|
26
|
+
await probe.delete();
|
|
27
|
+
checks.push({ name: "state", ok: true, detail: expandHome(config.state_dir) });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
checks.push({ name: "state", ok: false, detail: String(error) });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (config.inference.mode === "local") {
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(expandHome(config.inference.models_dir), { recursive: true });
|
|
35
|
+
checks.push({ name: "models", ok: true, detail: expandHome(config.inference.models_dir) });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
checks.push({ name: "models", ok: false, detail: String(error) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const clients = createClients(config);
|
|
43
|
+
const vectors = await clients.embed(["skill router diagnostic"]);
|
|
44
|
+
const actualDimension = vectors[0]?.length ?? 0;
|
|
45
|
+
checks.push({
|
|
46
|
+
name: "embedding",
|
|
47
|
+
ok: actualDimension === embeddingDimension(config),
|
|
48
|
+
detail: `dimension ${actualDimension}`,
|
|
49
|
+
});
|
|
50
|
+
if (clients.rerank) {
|
|
51
|
+
const scores = await clients.rerank("skill router diagnostic", [
|
|
52
|
+
{ skill_id: "doctor", text: "Routes a task to an appropriate skill." },
|
|
53
|
+
]);
|
|
54
|
+
checks.push({ name: "reranker", ok: scores.length === 1 && Number.isFinite(scores[0]), detail: "one finite score" });
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
checks.push({ name: "inference", ok: false, detail: String(error) });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const inferenceReady = checks.some((check) => check.name === "embedding" && check.ok);
|
|
61
|
+
const coreReady = checks.some((check) => check.name === "vault" && check.ok)
|
|
62
|
+
&& checks.some((check) => check.name === "state" && check.ok);
|
|
63
|
+
return {
|
|
64
|
+
mode: config.inference.mode,
|
|
65
|
+
capability: !coreReady ? "unavailable" : inferenceReady ? "hybrid" : "lexical-only",
|
|
66
|
+
checks,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/eval.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { SkillRow } from "./db";
|
|
5
|
+
import { ftsSearch, vectorTopK } from "./db";
|
|
6
|
+
import { reciprocalRankFusion } from "./rrf";
|
|
7
|
+
import { backfillEmbeddings, getRuntime } from "./router-core";
|
|
8
|
+
|
|
9
|
+
export interface EvalCase {
|
|
10
|
+
query: string;
|
|
11
|
+
expected: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const evalCasesSchema = z.array(z.object({
|
|
15
|
+
query: z.string().min(1),
|
|
16
|
+
expected: z.array(z.string().min(1)).min(1),
|
|
17
|
+
}).strict());
|
|
18
|
+
|
|
19
|
+
export interface EvalMetrics {
|
|
20
|
+
recall_at_3: number;
|
|
21
|
+
recall_at_5: number;
|
|
22
|
+
mrr: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EvalReport {
|
|
26
|
+
queries: number;
|
|
27
|
+
lexical: EvalMetrics;
|
|
28
|
+
hybrid: EvalMetrics;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function metrics(rankings: string[][], cases: EvalCase[]): EvalMetrics {
|
|
32
|
+
if (cases.length === 0) return { recall_at_3: 0, recall_at_5: 0, mrr: 0 };
|
|
33
|
+
let recall3 = 0;
|
|
34
|
+
let recall5 = 0;
|
|
35
|
+
let reciprocalRanks = 0;
|
|
36
|
+
rankings.forEach((ranking, index) => {
|
|
37
|
+
const expected = new Set(cases[index]!.expected);
|
|
38
|
+
if (ranking.slice(0, 3).some((id) => expected.has(id))) recall3++;
|
|
39
|
+
if (ranking.slice(0, 5).some((id) => expected.has(id))) recall5++;
|
|
40
|
+
const rank = ranking.findIndex((id) => expected.has(id));
|
|
41
|
+
if (rank >= 0) reciprocalRanks += 1 / (rank + 1);
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
recall_at_3: recall3 / cases.length,
|
|
45
|
+
recall_at_5: recall5 / cases.length,
|
|
46
|
+
mrr: reciprocalRanks / cases.length,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function loadEvalCases(path = join(import.meta.dir, "..", "eval", "queries.json")): EvalCase[] {
|
|
51
|
+
return evalCasesSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function evalVault(cases = loadEvalCases()): Promise<EvalReport> {
|
|
55
|
+
const { config, db, clients } = await getRuntime();
|
|
56
|
+
if (config.inference.mode !== "local") throw new Error('Default evaluation requires inference.mode = "local".');
|
|
57
|
+
await backfillEmbeddings();
|
|
58
|
+
|
|
59
|
+
const lexicalRankings: string[][] = [];
|
|
60
|
+
const hybridRankings: string[][] = [];
|
|
61
|
+
for (const evalCase of cases) {
|
|
62
|
+
const lexical = ftsSearch(db, evalCase.query, config.recall.k_lexical);
|
|
63
|
+
const vector = (await clients.embed([evalCase.query]))[0]!;
|
|
64
|
+
const semantic = vectorTopK(db, vector, config.recall.k_vector);
|
|
65
|
+
lexicalRankings.push(lexical.map((row) => row.skill_id));
|
|
66
|
+
hybridRankings.push(reciprocalRankFusion<SkillRow>(lexical, semantic).map((row) => row.skill_id));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
queries: cases.length,
|
|
71
|
+
lexical: metrics(lexicalRankings, cases),
|
|
72
|
+
hybrid: metrics(hybridRankings, cases),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { serializeManifest, type Manifest, MANIFEST_FILENAME } from "./manifest";
|
|
4
|
+
import { adoptTarget, readSkillmuxMarker } from "./sync";
|
|
5
|
+
import { SKILL_ID_PATTERN } from "./vault";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_SURFACE_CANDIDATES = ["~/.claude/skills", "~/.agents/skills"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Test/ops escape hatch: comma-separated absolute paths overriding
|
|
11
|
+
* DEFAULT_SURFACE_CANDIDATES. Not part of config.toml — "others as
|
|
12
|
+
* configured" (spec.md) is deliberately left as an implementation-time
|
|
13
|
+
* choice, same as the proposal heuristic. Exists primarily so tests never
|
|
14
|
+
* probe the real $HOME's ~/.claude/skills or ~/.agents/skills.
|
|
15
|
+
*/
|
|
16
|
+
export function surfaceCandidates(): string[] {
|
|
17
|
+
const override = process.env.SKILLMUX_INIT_SURFACES;
|
|
18
|
+
return override ? override.split(",").filter((p) => p.length > 0) : DEFAULT_SURFACE_CANDIDATES;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SurfaceCandidate {
|
|
22
|
+
path: string;
|
|
23
|
+
exists: boolean;
|
|
24
|
+
isSymlink: boolean;
|
|
25
|
+
skillCount: number;
|
|
26
|
+
alreadyMarked: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function countSkillDirs(dir: string): number {
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
32
|
+
if (entry.isDirectory() && SKILL_ID_PATTERN.test(entry.name) && existsSync(join(dir, entry.name, "SKILL.md"))) {
|
|
33
|
+
count++;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return count;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function detectSurfaces(candidatePaths: string[]): SurfaceCandidate[] {
|
|
40
|
+
return candidatePaths.map((path) => {
|
|
41
|
+
if (!existsSync(path)) {
|
|
42
|
+
return { path, exists: false, isSymlink: false, skillCount: 0, alreadyMarked: false };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
path,
|
|
46
|
+
exists: true,
|
|
47
|
+
isSymlink: lstatSync(path).isSymbolicLink(),
|
|
48
|
+
skillCount: countSkillDirs(path),
|
|
49
|
+
alreadyMarked: readSkillmuxMarker(path) !== null,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Conservative default: no slash-command/workflow-router detection heuristic
|
|
56
|
+
* at TDD time (spec.md, "skr init" AC) — evidence-only, nothing proposed
|
|
57
|
+
* until a concrete heuristic is agreed.
|
|
58
|
+
*/
|
|
59
|
+
export function proposeManifest(_candidates: SurfaceCandidate[]): Pick<Manifest, "core" | "project"> {
|
|
60
|
+
return { core: { skills: [] }, project: {} };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** e.g. ~/.claude/skills -> "claude"; ~/.agents/skills -> "agents". */
|
|
64
|
+
export function deriveTargetName(path: string): string {
|
|
65
|
+
return basename(dirname(path)).replace(/^\./, "").toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ConfirmedTarget {
|
|
69
|
+
name: string;
|
|
70
|
+
dir: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Writes skillmux.toml with the conservative-default core/project and the
|
|
75
|
+
* confirmed targets, then adopts each confirmed dir in place (creating it
|
|
76
|
+
* first if it doesn't exist yet). Unconfirmed candidates are simply never
|
|
77
|
+
* passed in — this function never discovers paths on its own.
|
|
78
|
+
*/
|
|
79
|
+
export function applyInit(vaultPath: string, confirmedTargets: ConfirmedTarget[]): Manifest {
|
|
80
|
+
const manifest: Manifest = {
|
|
81
|
+
...proposeManifest([]),
|
|
82
|
+
targets: Object.fromEntries(
|
|
83
|
+
confirmedTargets.map((target) => [target.name, { dir: target.dir, project: false }]),
|
|
84
|
+
),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
writeFileSync(join(vaultPath, MANIFEST_FILENAME), serializeManifest(manifest));
|
|
88
|
+
|
|
89
|
+
for (const target of confirmedTargets) {
|
|
90
|
+
if (!existsSync(target.dir)) mkdirSync(target.dir, { recursive: true });
|
|
91
|
+
adoptTarget(target.dir, target.name);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return manifest;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Verbatim from docs/sdd/skr-cli/think.md §3.3 — the shared instruction-stack paragraph. */
|
|
98
|
+
export const DISCOVERY_PARAGRAPH =
|
|
99
|
+
"Skills: only a curated core is loaded statically. Before improvising a " +
|
|
100
|
+
"multi-step workflow, or when a task smells like a domain you have no loaded " +
|
|
101
|
+
"skill for (career/resume, trading, SEO, i18n, design, one-off tooling), " +
|
|
102
|
+
"call `resolve_skill` with a one-line task description. `matched` → follow " +
|
|
103
|
+
"the returned SKILL.md. `ambiguous` → pick from the candidates and " +
|
|
104
|
+
"`fetch_skill`. `no_match` → proceed normally; don't force an unrelated " +
|
|
105
|
+
"skill.";
|
|
106
|
+
|
|
107
|
+
export const MCP_REGISTRATION_SNIPPET = JSON.stringify(
|
|
108
|
+
{ mcpServers: { "skillmux": { command: "skillmux", args: ["serve"] } } },
|
|
109
|
+
null,
|
|
110
|
+
2,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
/** §3.4 step 4: "print the last mile" — MCP registration command + discovery paragraph. */
|
|
114
|
+
export function printLastMile(): string {
|
|
115
|
+
return [
|
|
116
|
+
"Register with your MCP client:",
|
|
117
|
+
MCP_REGISTRATION_SNIPPET,
|
|
118
|
+
"",
|
|
119
|
+
"Add this paragraph to your shared agent instructions (~80 tokens, the entire T3 discovery mechanism):",
|
|
120
|
+
DISCOVERY_PARAGRAPH,
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { type ScanFinding, readTextFileOrNull, scanContent } from "./scan";
|
|
5
|
+
import { decodeUtf8Strict, listSupportingFiles, parseSkillMd } from "./vault";
|
|
6
|
+
|
|
7
|
+
export interface RepoSource {
|
|
8
|
+
url: string;
|
|
9
|
+
skillPath?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const GIT_URL_PREFIXES = ["http://", "https://", "git://", "ssh://", "file://"];
|
|
13
|
+
const SCP_LIKE_URL_PATTERN = /^[^/\s]+@[^/\s]+:/;
|
|
14
|
+
|
|
15
|
+
function isGitUrl(repo: string): boolean {
|
|
16
|
+
return GIT_URL_PREFIXES.some((prefix) => repo.startsWith(prefix)) || SCP_LIKE_URL_PATTERN.test(repo);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveRepoSource(repo: string): RepoSource {
|
|
20
|
+
if (isGitUrl(repo)) return { url: repo };
|
|
21
|
+
|
|
22
|
+
const [owner, name, ...rest] = repo.split("/");
|
|
23
|
+
if (!owner || !name) {
|
|
24
|
+
throw new Error(`invalid repo "${repo}": expected owner/repo, owner/repo/path, or a git URL`);
|
|
25
|
+
}
|
|
26
|
+
const url = `https://github.com/${owner}/${name}.git`;
|
|
27
|
+
return rest.length > 0 ? { url, skillPath: rest.join("/") } : { url };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function deriveRepoName(url: string): string {
|
|
31
|
+
const cleaned = url.replace(/\.git$/, "");
|
|
32
|
+
const segment = cleaned.split(/[/:]/).filter(Boolean).pop();
|
|
33
|
+
if (!segment) throw new Error(`could not derive a repo name from "${url}"`);
|
|
34
|
+
return segment;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function cloneToTemp(url: string): Promise<string> {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), "skillmux-install-"));
|
|
39
|
+
const proc = Bun.spawn(["git", "clone", "--quiet", "--depth", "1", url, dir], {
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "pipe",
|
|
42
|
+
});
|
|
43
|
+
const exitCode = await proc.exited;
|
|
44
|
+
if (exitCode !== 0) {
|
|
45
|
+
const stderr = await new Response(proc.stderr).text();
|
|
46
|
+
rmSync(dir, { recursive: true, force: true });
|
|
47
|
+
throw new Error(`git clone failed for ${url}: ${stderr.trim()}`);
|
|
48
|
+
}
|
|
49
|
+
return dir;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ValidationResult {
|
|
53
|
+
findings: ScanFinding[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function validateSkillCandidate(skillId: string, dir: string): Promise<ValidationResult> {
|
|
57
|
+
const bytes = await Bun.file(join(dir, "SKILL.md")).bytes();
|
|
58
|
+
const body = decodeUtf8Strict(bytes);
|
|
59
|
+
parseSkillMd(skillId, body);
|
|
60
|
+
|
|
61
|
+
const findings: ScanFinding[] = scanContent(body).map((match) => ({
|
|
62
|
+
...match,
|
|
63
|
+
skill_id: skillId,
|
|
64
|
+
file: "SKILL.md",
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
const vaultPath = dirname(dir);
|
|
68
|
+
const dirName = basename(dir);
|
|
69
|
+
for (const rel of listSupportingFiles(vaultPath, dirName)) {
|
|
70
|
+
const content = await readTextFileOrNull(join(dir, rel));
|
|
71
|
+
if (content === null) continue;
|
|
72
|
+
for (const match of scanContent(content)) {
|
|
73
|
+
findings.push({ ...match, skill_id: skillId, file: rel });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { findings };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function installIntoVault(vaultPath: string, skillId: string, sourceDir: string, force = false): string {
|
|
81
|
+
const targetDir = join(vaultPath, skillId);
|
|
82
|
+
if (existsSync(targetDir)) {
|
|
83
|
+
if (!force) {
|
|
84
|
+
throw new Error(`skill "${skillId}" already exists in the vault at ${targetDir} — pass --force to overwrite`);
|
|
85
|
+
}
|
|
86
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
cpSync(sourceDir, targetDir, { recursive: true, filter: (src) => basename(src) !== ".git" });
|
|
89
|
+
return targetDir;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ResolvedSkillDir {
|
|
93
|
+
skillId: string;
|
|
94
|
+
dir: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveSkillDir(cloneDir: string, fallbackName: string, skillPath?: string): ResolvedSkillDir {
|
|
98
|
+
if (skillPath) {
|
|
99
|
+
return { skillId: basename(skillPath), dir: join(cloneDir, skillPath) };
|
|
100
|
+
}
|
|
101
|
+
if (existsSync(join(cloneDir, "SKILL.md"))) {
|
|
102
|
+
return { skillId: fallbackName, dir: cloneDir };
|
|
103
|
+
}
|
|
104
|
+
const discovered = readdirSync(cloneDir, { withFileTypes: true })
|
|
105
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(cloneDir, entry.name, "SKILL.md")))
|
|
106
|
+
.map((entry) => entry.name)
|
|
107
|
+
.sort();
|
|
108
|
+
throw new Error(
|
|
109
|
+
discovered.length > 0
|
|
110
|
+
? `no SKILL.md at repo root; found skill dirs: ${discovered.join(", ")} — pass a path to select one, e.g. owner/repo/${discovered[0]}`
|
|
111
|
+
: "no SKILL.md at repo root and no skill dirs found under it",
|
|
112
|
+
);
|
|
113
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expandHome } from "./config";
|
|
2
|
+
import type { ReadinessState } from "./readiness";
|
|
3
|
+
import { backfillEmbeddings, getRuntime, rebuildIndex } from "./router-core";
|
|
4
|
+
import { getVaultMaxMtime } from "./vault";
|
|
5
|
+
|
|
6
|
+
export async function initializeRuntime(state: ReadinessState): Promise<void> {
|
|
7
|
+
try {
|
|
8
|
+
const { config, db, clients } = await getRuntime();
|
|
9
|
+
const vaultPath = expandHome(config.vault_path);
|
|
10
|
+
const report = await rebuildIndex();
|
|
11
|
+
let embedding: "ready" | "unavailable" = "ready";
|
|
12
|
+
try {
|
|
13
|
+
await backfillEmbeddings();
|
|
14
|
+
await clients.embed(["skill router readiness probe"]);
|
|
15
|
+
} catch {
|
|
16
|
+
embedding = "unavailable";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let reranker: "not_configured" | "ready" | "unavailable" = "not_configured";
|
|
20
|
+
if (clients.rerank) {
|
|
21
|
+
try {
|
|
22
|
+
await clients.rerank("skill router readiness probe", [
|
|
23
|
+
{ skill_id: "readiness", text: "Routes tasks to relevant skills." },
|
|
24
|
+
]);
|
|
25
|
+
reranker = "ready";
|
|
26
|
+
} catch {
|
|
27
|
+
reranker = "unavailable";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
state.set({
|
|
32
|
+
status: "ready",
|
|
33
|
+
retrieval: reranker === "ready" ? "reranked" : embedding === "ready" ? "hybrid" : "lexical",
|
|
34
|
+
skills: report.indexed,
|
|
35
|
+
index_current: getVaultMaxMtime(vaultPath) >= 0 && db.query("SELECT COUNT(*) AS count FROM skills").get() !== null,
|
|
36
|
+
embedding,
|
|
37
|
+
reranker,
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
state.set({
|
|
41
|
+
status: "not_ready",
|
|
42
|
+
retrieval: null,
|
|
43
|
+
skills: 0,
|
|
44
|
+
index_current: false,
|
|
45
|
+
embedding: "unavailable",
|
|
46
|
+
reranker: "not_configured",
|
|
47
|
+
error: error instanceof Error ? error.message : String(error),
|
|
48
|
+
});
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { expandHome } from "./config";
|
|
5
|
+
import { SKILL_ID_PATTERN } from "./vault";
|
|
6
|
+
|
|
7
|
+
export const MANIFEST_FILENAME = "skillmux.toml";
|
|
8
|
+
export const LEGACY_MANIFEST_FILENAME = "skr.toml";
|
|
9
|
+
|
|
10
|
+
export function resolveManifestPath(vaultPath: string): string | null {
|
|
11
|
+
const newPath = join(vaultPath, MANIFEST_FILENAME);
|
|
12
|
+
if (existsSync(newPath)) return newPath;
|
|
13
|
+
const legacyPath = join(vaultPath, LEGACY_MANIFEST_FILENAME);
|
|
14
|
+
if (existsSync(legacyPath)) return legacyPath;
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const groupNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/).max(64);
|
|
19
|
+
const skillIdSchema = z.string().regex(SKILL_ID_PATTERN);
|
|
20
|
+
|
|
21
|
+
const projectGroupSchema = z.object({
|
|
22
|
+
repos: z.array(z.string().min(1)),
|
|
23
|
+
skills: z.array(skillIdSchema),
|
|
24
|
+
}).strict();
|
|
25
|
+
|
|
26
|
+
const targetSchema = z.object({
|
|
27
|
+
dir: z.string().min(1),
|
|
28
|
+
project: z.boolean().default(false),
|
|
29
|
+
}).strict();
|
|
30
|
+
|
|
31
|
+
const manifestSchema = z.object({
|
|
32
|
+
core: z.object({ skills: z.array(skillIdSchema) }).strict(),
|
|
33
|
+
project: z.record(groupNameSchema, projectGroupSchema).optional(),
|
|
34
|
+
targets: z.record(groupNameSchema, targetSchema),
|
|
35
|
+
}).strict();
|
|
36
|
+
|
|
37
|
+
export type ProjectGroup = z.infer<typeof projectGroupSchema>;
|
|
38
|
+
export type Target = z.infer<typeof targetSchema>;
|
|
39
|
+
export type Manifest = z.infer<typeof manifestSchema>;
|
|
40
|
+
|
|
41
|
+
export function parseManifest(toml: string): Manifest {
|
|
42
|
+
const parsed = Bun.TOML.parse(toml) as Record<string, unknown>;
|
|
43
|
+
return manifestSchema.parse(parsed);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tomlStringArray(values: string[]): string {
|
|
47
|
+
return `[${values.map((v) => JSON.stringify(v)).join(", ")}]`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Purpose-built serializer for this manifest's fixed shape — not a general TOML writer. */
|
|
51
|
+
export function serializeManifest(manifest: Manifest): string {
|
|
52
|
+
const sections: string[] = [`[core]\nskills = ${tomlStringArray(manifest.core.skills)}`];
|
|
53
|
+
|
|
54
|
+
for (const [name, group] of Object.entries(manifest.project ?? {})) {
|
|
55
|
+
sections.push(
|
|
56
|
+
`[project.${name}]\nrepos = ${tomlStringArray(group.repos)}\nskills = ${tomlStringArray(group.skills)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const [name, target] of Object.entries(manifest.targets)) {
|
|
61
|
+
sections.push(`[targets.${name}]\ndir = ${JSON.stringify(target.dir)}\nproject = ${target.project}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return `${sections.join("\n\n")}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ManifestValidationResult {
|
|
68
|
+
notes: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const CORE_SKILL_LIMIT = 25;
|
|
72
|
+
|
|
73
|
+
export function validateManifest(manifest: Manifest, vaultSkillIds: Set<string>): ManifestValidationResult {
|
|
74
|
+
if (manifest.core.skills.length > CORE_SKILL_LIMIT) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[core] has ${manifest.core.skills.length} skills, exceeding the limit of ${CORE_SKILL_LIMIT}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const coreSet = new Set(manifest.core.skills);
|
|
81
|
+
for (const skillId of manifest.core.skills) {
|
|
82
|
+
if (!vaultSkillIds.has(skillId)) {
|
|
83
|
+
throw new Error(`[core] skill "${skillId}" does not exist in the vault`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const notes: string[] = [];
|
|
88
|
+
for (const [groupName, group] of Object.entries(manifest.project ?? {})) {
|
|
89
|
+
for (const skillId of group.skills) {
|
|
90
|
+
if (!vaultSkillIds.has(skillId)) {
|
|
91
|
+
throw new Error(`[project.${groupName}] skill "${skillId}" does not exist in the vault`);
|
|
92
|
+
}
|
|
93
|
+
if (coreSet.has(skillId)) {
|
|
94
|
+
throw new Error(`skill "${skillId}" appears in both [core] and [project.${groupName}]`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const repo of group.repos) {
|
|
98
|
+
if (!existsSync(expandHome(repo))) {
|
|
99
|
+
notes.push(`[project.${groupName}] repos path not found locally, skipped: ${repo}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { notes };
|
|
105
|
+
}
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ReadinessSnapshot } from "./readiness";
|
|
2
|
+
|
|
3
|
+
export class MetricsRegistry {
|
|
4
|
+
private requests = new Map<string, number>();
|
|
5
|
+
private outcomes = new Map<string, number>();
|
|
6
|
+
|
|
7
|
+
private buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
|
|
8
|
+
private latencyBuckets = new Array(this.buckets.length).fill(0);
|
|
9
|
+
private latencyInf = 0;
|
|
10
|
+
private latencySum = 0;
|
|
11
|
+
private latencyCount = 0;
|
|
12
|
+
|
|
13
|
+
private errors = 0;
|
|
14
|
+
private rateLimitsExceeded = 0;
|
|
15
|
+
private readiness: ReadinessSnapshot | null = null;
|
|
16
|
+
|
|
17
|
+
setReadiness(readiness: ReadinessSnapshot) {
|
|
18
|
+
this.readiness = readiness;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
recordRequest(method: string) {
|
|
22
|
+
this.requests.set(method, (this.requests.get(method) || 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
recordResolveOutcome(outcome: string) {
|
|
26
|
+
this.outcomes.set(outcome, (this.outcomes.get(outcome) || 0) + 1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
recordResolveLatencySeconds(seconds: number) {
|
|
30
|
+
this.latencySum += seconds;
|
|
31
|
+
this.latencyCount++;
|
|
32
|
+
for (let i = 0; i < this.buckets.length; i++) {
|
|
33
|
+
if (seconds <= this.buckets[i]!) {
|
|
34
|
+
this.latencyBuckets[i]++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
this.latencyInf++;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
recordError() {
|
|
41
|
+
this.errors++;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
recordRateLimitExceeded() {
|
|
45
|
+
this.rateLimitsExceeded++;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
render(): string {
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
|
|
51
|
+
// Requests total
|
|
52
|
+
lines.push("# HELP skill_router_requests_total Total count of incoming MCP requests.");
|
|
53
|
+
lines.push("# TYPE skill_router_requests_total counter");
|
|
54
|
+
for (const [method, count] of this.requests) {
|
|
55
|
+
lines.push(`skill_router_requests_total{method="${method}"} ${count}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve outcomes total
|
|
59
|
+
lines.push("# HELP skill_router_resolve_outcomes_total Total count of resolve_skill query outcomes.");
|
|
60
|
+
lines.push("# TYPE skill_router_resolve_outcomes_total counter");
|
|
61
|
+
for (const [outcome, count] of this.outcomes) {
|
|
62
|
+
lines.push(`skill_router_resolve_outcomes_total{outcome="${outcome}"} ${count}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Latency histogram
|
|
66
|
+
lines.push("# HELP skill_router_resolve_latency_seconds Latency histogram of resolve_skill executions.");
|
|
67
|
+
lines.push("# TYPE skill_router_resolve_latency_seconds histogram");
|
|
68
|
+
for (let i = 0; i < this.buckets.length; i++) {
|
|
69
|
+
let le = this.buckets[i]!.toString();
|
|
70
|
+
// Format to remove trailing .0 if integer to match test
|
|
71
|
+
if (Number.isInteger(this.buckets[i]!)) {
|
|
72
|
+
le = this.buckets[i]!.toFixed(0);
|
|
73
|
+
}
|
|
74
|
+
lines.push(`skill_router_resolve_latency_seconds_bucket{le="${le}"} ${this.latencyBuckets[i]}`);
|
|
75
|
+
}
|
|
76
|
+
lines.push(`skill_router_resolve_latency_seconds_bucket{le="+Inf"} ${this.latencyInf}`);
|
|
77
|
+
lines.push(`skill_router_resolve_latency_seconds_sum ${this.latencySum}`);
|
|
78
|
+
lines.push(`skill_router_resolve_latency_seconds_count ${this.latencyCount}`);
|
|
79
|
+
|
|
80
|
+
// Errors total
|
|
81
|
+
lines.push("# HELP skill_router_errors_total Total count of server and query routing errors.");
|
|
82
|
+
lines.push("# TYPE skill_router_errors_total counter");
|
|
83
|
+
lines.push(`skill_router_errors_total ${this.errors}`);
|
|
84
|
+
|
|
85
|
+
// Rate limits exceeded
|
|
86
|
+
lines.push("# HELP skill_router_rate_limits_exceeded_total Total count of HTTP requests rejected by rate limiting.");
|
|
87
|
+
lines.push("# TYPE skill_router_rate_limits_exceeded_total counter");
|
|
88
|
+
lines.push(`skill_router_rate_limits_exceeded_total ${this.rateLimitsExceeded}`);
|
|
89
|
+
|
|
90
|
+
lines.push("# HELP skill_router_ready Whether the service is ready to route requests.");
|
|
91
|
+
lines.push("# TYPE skill_router_ready gauge");
|
|
92
|
+
lines.push(`skill_router_ready ${this.readiness?.status === "ready" ? 1 : 0}`);
|
|
93
|
+
lines.push("# HELP skill_router_retrieval_capability Active retrieval capability.");
|
|
94
|
+
lines.push("# TYPE skill_router_retrieval_capability gauge");
|
|
95
|
+
for (const capability of ["exact", "reranked", "hybrid", "lexical"]) {
|
|
96
|
+
lines.push(`skill_router_retrieval_capability{capability="${capability}"} ${this.readiness?.retrieval === capability ? 1 : 0}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return lines.join("\n") + "\n";
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { expandHome } from "./config";
|
|
2
|
+
import type { Config } from "./types";
|
|
3
|
+
|
|
4
|
+
export async function downloadLocalModels(config: Config): Promise<string> {
|
|
5
|
+
if (config.inference.mode !== "local") {
|
|
6
|
+
throw new Error("models download is only available for inference.mode = \"local\".");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const cacheDir = expandHome(config.inference.models_dir);
|
|
10
|
+
process.env.HF_HUB_CACHE = cacheDir;
|
|
11
|
+
process.env.HF_HOME = cacheDir;
|
|
12
|
+
const { env, pipeline } = await import("@huggingface/transformers");
|
|
13
|
+
env.cacheDir = cacheDir;
|
|
14
|
+
|
|
15
|
+
await pipeline("feature-extraction", config.inference.embedding.model, {
|
|
16
|
+
device: config.inference.embedding.device ?? "cpu",
|
|
17
|
+
dtype: config.inference.embedding.dtype ?? "q8",
|
|
18
|
+
});
|
|
19
|
+
return cacheDir;
|
|
20
|
+
}
|