@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.
@@ -0,0 +1,110 @@
1
+ interface Bucket {
2
+ tokens: number;
3
+ lastRefillMs: number;
4
+ }
5
+
6
+ export interface RateLimitCheckInput {
7
+ nowMs: number;
8
+ auth_enabled: boolean;
9
+ req: Request;
10
+ server: {
11
+ requestIP(request: Request): { address: string } | null;
12
+ };
13
+ }
14
+
15
+ export interface RateLimitCheckResult {
16
+ allowed: boolean;
17
+ retryAfterSeconds?: number;
18
+ headers: Record<string, string>;
19
+ }
20
+
21
+ export class RateLimiter {
22
+ private enabled: boolean;
23
+ private requests_per_minute: number;
24
+ private trust_proxy: boolean;
25
+ private buckets = new Map<string, Bucket>();
26
+
27
+ constructor(config: { enabled: boolean; requests_per_minute: number; trust_proxy?: boolean }) {
28
+ this.enabled = config.enabled;
29
+ this.requests_per_minute = config.requests_per_minute;
30
+ this.trust_proxy = config.trust_proxy ?? false;
31
+ }
32
+
33
+ check(input: RateLimitCheckInput): RateLimitCheckResult {
34
+ if (!this.enabled) {
35
+ return { allowed: true, headers: {} };
36
+ }
37
+
38
+ // 1. Resolve identifier
39
+ let id = "127.0.0.1";
40
+ if (input.auth_enabled) {
41
+ const authHeader = input.req.headers.get("authorization") || "";
42
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
43
+ if (token) {
44
+ id = token;
45
+ }
46
+ } else {
47
+ const ipAddr = input.server.requestIP(input.req)?.address;
48
+ if (ipAddr) {
49
+ id = ipAddr;
50
+ } else if (this.trust_proxy) {
51
+ // X-Forwarded-For is client-supplied and spoofable; only honor it
52
+ // when trust_proxy opts in (i.e. a trusted reverse proxy sets it).
53
+ const xff = input.req.headers.get("x-forwarded-for");
54
+ if (xff) {
55
+ id = xff.split(",", 1)[0]?.trim() || id;
56
+ }
57
+ }
58
+ }
59
+
60
+ // 2. Retrieve or initialize bucket
61
+ let bucket = this.buckets.get(id);
62
+ if (!bucket) {
63
+ bucket = {
64
+ tokens: this.requests_per_minute,
65
+ lastRefillMs: input.nowMs,
66
+ };
67
+ this.buckets.set(id, bucket);
68
+ }
69
+
70
+ // 3. Refill tokens
71
+ const elapsedMs = input.nowMs - bucket.lastRefillMs;
72
+ if (elapsedMs > 0) {
73
+ const refillRatePerMs = this.requests_per_minute / (60 * 1000);
74
+ const refilled = elapsedMs * refillRatePerMs;
75
+ bucket.tokens = Math.min(this.requests_per_minute, bucket.tokens + refilled);
76
+ bucket.lastRefillMs = input.nowMs;
77
+ }
78
+
79
+ // 4. Determine allowed status
80
+ let allowed = false;
81
+ if (bucket.tokens >= 1) {
82
+ bucket.tokens -= 1;
83
+ allowed = true;
84
+ }
85
+
86
+ // Calculate headers
87
+ const headers: Record<string, string> = {
88
+ "X-RateLimit-Limit": this.requests_per_minute.toString(),
89
+ "X-RateLimit-Remaining": Math.floor(bucket.tokens).toString(),
90
+ };
91
+
92
+ const refillRatePerSec = this.requests_per_minute / 60;
93
+ const missingTokens = this.requests_per_minute - bucket.tokens;
94
+ const resetTimeSeconds = Math.ceil(input.nowMs / 1000 + (missingTokens / refillRatePerSec));
95
+ headers["X-RateLimit-Reset"] = resetTimeSeconds.toString();
96
+
97
+ if (allowed) {
98
+ return { allowed, headers };
99
+ } else {
100
+ const neededTokens = 1 - bucket.tokens;
101
+ const retryAfterSeconds = Math.ceil(neededTokens / refillRatePerSec);
102
+ headers["Retry-After"] = retryAfterSeconds.toString();
103
+ return {
104
+ allowed,
105
+ retryAfterSeconds,
106
+ headers,
107
+ };
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,30 @@
1
+ import type { RetrievalCapability } from "./types";
2
+
3
+ export interface ReadinessSnapshot {
4
+ status: "starting" | "ready" | "not_ready" | "stopping";
5
+ retrieval: RetrievalCapability | null;
6
+ skills: number;
7
+ index_current: boolean;
8
+ embedding: "pending" | "ready" | "unavailable";
9
+ reranker: "not_configured" | "pending" | "ready" | "unavailable";
10
+ error?: string;
11
+ }
12
+
13
+ export class ReadinessState {
14
+ private snapshot: ReadinessSnapshot = {
15
+ status: "starting",
16
+ retrieval: null,
17
+ skills: 0,
18
+ index_current: false,
19
+ embedding: "pending",
20
+ reranker: "not_configured",
21
+ };
22
+
23
+ get(): ReadinessSnapshot {
24
+ return { ...this.snapshot };
25
+ }
26
+
27
+ set(snapshot: ReadinessSnapshot): void {
28
+ this.snapshot = { ...snapshot };
29
+ }
30
+ }
@@ -0,0 +1,435 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { watch } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { buildAuditRow } from "./audit";
5
+ import { embeddingDimension, embeddingFingerprint, expandHome, loadConfig } from "./config";
6
+ import {
7
+ deleteSkill,
8
+ findExactMatch,
9
+ ftsSearch,
10
+ getIndexMeta,
11
+ getSkillRow,
12
+ ingestVault,
13
+ insertAudit,
14
+ openIndex,
15
+ replaceSkills,
16
+ setIndexMeta,
17
+ skillCount,
18
+ skillsNeedingVectors,
19
+ toSkillRow,
20
+ upsertSkill,
21
+ upsertVector,
22
+ vectorTopK,
23
+ } from "./db";
24
+ import type { SkillRow } from "./db";
25
+ import { decideResolveOutcome } from "./decision";
26
+ import type {
27
+ RankedCandidate,
28
+ RetrievalCapability,
29
+ Clients,
30
+ Config,
31
+ FetchSkillInput,
32
+ FetchSkillResult,
33
+ ResolveResult,
34
+ ResolveSkillInput,
35
+ } from "./types";
36
+ import { reciprocalRankFusion } from "./rrf";
37
+ import {
38
+ decodeUtf8Strict,
39
+ getVaultMaxMtime,
40
+ listSupportingFiles,
41
+ parseSkillMd,
42
+ readSkill,
43
+ scanVault,
44
+ sha256Hex,
45
+ SKILL_ID_PATTERN,
46
+ } from "./vault";
47
+
48
+ export { buildAuditRow } from "./audit";
49
+ export { loadConfig } from "./config";
50
+ export { decideResolveOutcome } from "./decision";
51
+ export type * from "./types";
52
+
53
+ const NO_MATCH_MESSAGE =
54
+ "No skill in the vault passed the relevance threshold for this task. " +
55
+ "Proceed under your normal workflow; do not load an unrelated skill.";
56
+
57
+ interface Overrides {
58
+ config?: Config;
59
+ clients?: Partial<Clients>;
60
+ }
61
+
62
+ // Remote clients default to failing fast, which routes resolveSkill into the
63
+ // Production clients are installed during server startup; tests can inject fakes.
64
+ const defaultClients: Clients = {
65
+ embed: async () => {
66
+ throw new Error("embedding client not configured");
67
+ },
68
+ };
69
+
70
+ let overrides: Overrides = {};
71
+ let env: { config: Config; db: Database } | null = null;
72
+
73
+ /** Replace config/client overrides wholesale (tests, ops). Resets the cached index handle. */
74
+ export function configure(opts: Overrides): void {
75
+ overrides = opts;
76
+ env = null;
77
+ }
78
+
79
+ async function getEnv(): Promise<{ config: Config; db: Database }> {
80
+ if (env) return env;
81
+ const config = overrides.config ?? (await loadConfig());
82
+ const db = openIndex(expandHome(config.state_dir));
83
+ if (skillCount(db) === 0) {
84
+ const vaultPath = expandHome(config.vault_path);
85
+ ingestVault(db, await scanVault(vaultPath));
86
+ setIndexMeta(db, "last_indexed_mtime", String(getVaultMaxMtime(vaultPath)));
87
+ }
88
+ env = { config, db };
89
+ return env;
90
+ }
91
+
92
+ function getClients(): Clients {
93
+ return { ...defaultClients, ...overrides.clients };
94
+ }
95
+
96
+ /** Runtime accessor for the eval harness and CLI — not part of the MCP surface. */
97
+ export async function getRuntime(): Promise<{ config: Config; db: Database; clients: Clients }> {
98
+ const { config, db } = await getEnv();
99
+ return { config, db, clients: getClients() };
100
+ }
101
+
102
+ export function closeRuntime(): void {
103
+ env?.db.close();
104
+ env = null;
105
+ }
106
+
107
+ /**
108
+ * Zero-loss delivery: read SKILL.md from disk now, hash it, and if the
109
+ * index is stale re-index that skill — never serve stale bytes.
110
+ */
111
+ async function deliverSkill(db: Database, config: Config, skillId: string): Promise<FetchSkillResult> {
112
+ const vaultPath = expandHome(config.vault_path);
113
+ const file = Bun.file(join(vaultPath, skillId, "SKILL.md"));
114
+ if (!(await file.exists())) {
115
+ // Deleted on disk but the watcher hasn't caught up: drop the stale row and
116
+ // surface the schema's error code rather than a raw ENOENT.
117
+ deleteSkill(db, skillId);
118
+ throw new Error(`SKILL_NOT_FOUND: skill '${skillId}' no longer exists in the vault`);
119
+ }
120
+ const bytes = await file.bytes();
121
+ const contentSha256 = sha256Hex(bytes);
122
+ const raw = decodeUtf8Strict(bytes);
123
+ let row = getSkillRow(db, skillId);
124
+ if (row === null || row.content_sha256 !== contentSha256) {
125
+ const fresh = parseSkillMd(skillId, raw);
126
+ upsertSkill(db, fresh);
127
+ row = getSkillRow(db, skillId)!;
128
+ }
129
+ return {
130
+ skill_id: skillId,
131
+ title: row.title,
132
+ content_sha256: contentSha256,
133
+ body: raw,
134
+ files: listSupportingFiles(vaultPath, skillId),
135
+ };
136
+ }
137
+
138
+ const rerankText = (r: SkillRow) => `${r.title}\n${r.description}\n${r.aliases}`;
139
+
140
+ export interface RebuildReport {
141
+ indexed: number;
142
+ retained: string[];
143
+ }
144
+
145
+ /**
146
+ * Full from-scratch rebuild of the lexical index. Skills whose SKILL.md
147
+ * fails to parse keep their previously indexed row (`retained`) so a bad write
148
+ * never evicts a working skill. Vectors persist by content hash; changed
149
+ * content is re-embedded by the next backfill.
150
+ */
151
+ export async function rebuildIndex(
152
+ onInvalid?: (skillId: string, error: unknown) => void,
153
+ ): Promise<RebuildReport> {
154
+ const { config, db } = await getEnv();
155
+ const vaultPath = expandHome(config.vault_path);
156
+ const currentMtime = getVaultMaxMtime(vaultPath);
157
+ const invalidIds: string[] = [];
158
+ const skills = await scanVault(vaultPath, (skillId, error) => {
159
+ invalidIds.push(skillId);
160
+ onInvalid?.(skillId, error);
161
+ });
162
+ const rows = skills.map(toSkillRow);
163
+ const retained: string[] = [];
164
+ for (const skillId of invalidIds) {
165
+ const previous = getSkillRow(db, skillId);
166
+ if (previous) {
167
+ rows.push(previous);
168
+ retained.push(skillId);
169
+ }
170
+ }
171
+ replaceSkills(db, rows);
172
+ setIndexMeta(db, "last_indexed_mtime", String(currentMtime));
173
+ return { indexed: rows.length, retained };
174
+ }
175
+
176
+ /**
177
+ * On-Demand Lazy Indexing (First Principles #2):
178
+ * Checks the max mtime of the vault directory and re-indexes only if files have changed.
179
+ * This runs synchronously to block queries until the lexical index is correct.
180
+ */
181
+ export async function syncVaultIfNeeded(): Promise<void> {
182
+ const { config, db } = await getEnv();
183
+ const vaultPath = expandHome(config.vault_path);
184
+ const currentMtime = getVaultMaxMtime(vaultPath);
185
+ const lastIndexed = getIndexMeta(db, "last_indexed_mtime");
186
+
187
+ if (lastIndexed === null || currentMtime > Number(lastIndexed)) {
188
+ const invalidIds: string[] = [];
189
+ const skills = await scanVault(vaultPath, (skillId, error) => {
190
+ invalidIds.push(skillId);
191
+ console.error(`warning: keeping previous index entry for ${skillId}: ${error}`);
192
+ });
193
+ const rows = skills.map(toSkillRow);
194
+ for (const skillId of invalidIds) {
195
+ const previous = getSkillRow(db, skillId);
196
+ if (previous) {
197
+ rows.push(previous);
198
+ }
199
+ }
200
+ replaceSkills(db, rows);
201
+ setIndexMeta(db, "last_indexed_mtime", String(currentMtime));
202
+ backfillEmbeddings().catch(() => {});
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Embed every skill missing a current vector (new or content changed).
208
+ * Called by `skillmux index` and at server startup; failure is tolerated —
209
+ * resolve falls back to lexical-only recall until vectors exist.
210
+ */
211
+ export async function backfillEmbeddings(): Promise<number> {
212
+ const { config, db } = await getEnv();
213
+ const clients = getClients();
214
+ const fingerprint = embeddingFingerprint(config);
215
+ const pending = skillsNeedingVectors(db, embeddingDimension(config), fingerprint);
216
+ if (pending.length === 0) return 0;
217
+
218
+ const BATCH_SIZE = 10;
219
+ let count = 0;
220
+ for (let i = 0; i < pending.length; i += BATCH_SIZE) {
221
+ const chunk = pending.slice(i, i + BATCH_SIZE);
222
+ try {
223
+ const vectors = await clients.embed(chunk.map(rerankText));
224
+ chunk.forEach((row, j) => {
225
+ upsertVector(db, row.skill_id, row.content_sha256, fingerprint, vectors[j]!);
226
+ });
227
+ count += chunk.length;
228
+ } catch (err) {
229
+ if (i === 0) throw err;
230
+ break;
231
+ }
232
+ }
233
+ return count;
234
+ }
235
+
236
+ const WATCH_DEBOUNCE_MS = 300;
237
+ const STABLE_STAT_INTERVAL_MS = 100;
238
+ const STABLE_STAT_MAX_TRIES = 10;
239
+
240
+ /** Wait until SKILL.md stops changing (two identical stats in a row) or give up. */
241
+ async function waitForStableFile(path: string): Promise<void> {
242
+ let previous = "";
243
+ for (let i = 0; i < STABLE_STAT_MAX_TRIES; i++) {
244
+ const file = Bun.file(path);
245
+ if (!(await file.exists())) return;
246
+ const current = `${file.size}:${file.lastModified}`;
247
+ if (current === previous) return;
248
+ previous = current;
249
+ await Bun.sleep(STABLE_STAT_INTERVAL_MS);
250
+ }
251
+ }
252
+
253
+ async function reindexOneSkill(db: Database, vaultPath: string, skillId: string): Promise<void> {
254
+ const skillMd = join(vaultPath, skillId, "SKILL.md");
255
+ await waitForStableFile(skillMd);
256
+ if (!(await Bun.file(skillMd).exists())) {
257
+ deleteSkill(db, skillId);
258
+ return;
259
+ }
260
+ try {
261
+ upsertSkill(db, await readSkill(vaultPath, skillId));
262
+ backfillEmbeddings().catch(() => {});
263
+ } catch (error) {
264
+ console.error(`warning: keeping previous index entry for ${skillId}: ${error}`);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Watch the vault and fold file changes into the index within seconds.
270
+ * Events are debounced per skill; a write that fails to parse keeps the
271
+ * previous index entry. Returns a stop function.
272
+ */
273
+ export async function startVaultWatcher(): Promise<() => void> {
274
+ const { config, db } = await getEnv();
275
+ const vaultPath = expandHome(config.vault_path);
276
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
277
+
278
+ const watcher = watch(vaultPath, { recursive: true }, (_event, filename) => {
279
+ const skillId = filename?.split(/[\\/]/)[0];
280
+ if (!skillId || !SKILL_ID_PATTERN.test(skillId)) return;
281
+ clearTimeout(timers.get(skillId));
282
+ timers.set(
283
+ skillId,
284
+ setTimeout(() => {
285
+ timers.delete(skillId);
286
+ void reindexOneSkill(db, vaultPath, skillId);
287
+ }, WATCH_DEBOUNCE_MS),
288
+ );
289
+ });
290
+ // A watcher error (e.g. the vault root disappearing) must degrade the index,
291
+ // not crash the server — an unhandled 'error' event would throw.
292
+ watcher.on("error", (error) => {
293
+ console.error(`warning: vault watcher error, live updates paused: ${error}`);
294
+ });
295
+
296
+ return () => {
297
+ watcher.close();
298
+ for (const timer of timers.values()) clearTimeout(timer);
299
+ timers.clear();
300
+ };
301
+ }
302
+
303
+ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveResult> {
304
+ const t0 = performance.now();
305
+ const { config, db } = await getEnv();
306
+ await syncVaultIfNeeded();
307
+
308
+ // Short-circuit: exact match on skill_id, title, or alias (First Principles #1)
309
+ const exactMatch = findExactMatch(db, input.query);
310
+ if (exactMatch) {
311
+ const delivery = await deliverSkill(db, config, exactMatch.skill_id);
312
+ const result: ResolveResult = {
313
+ outcome: "matched",
314
+ retrieval: "exact",
315
+ skill_id: exactMatch.skill_id,
316
+ title: delivery.title,
317
+ content_sha256: delivery.content_sha256,
318
+ score: 1.0,
319
+ margin: 1.0,
320
+ body: delivery.body,
321
+ files: delivery.files,
322
+ };
323
+ insertAudit(
324
+ db,
325
+ buildAuditRow({
326
+ id: 0,
327
+ ts: new Date().toISOString(),
328
+ query: input.query,
329
+ outcome: "matched",
330
+ retrieval: "exact",
331
+ candidates: [{ skill_id: exactMatch.skill_id, score: 1.0 }],
332
+ selected_skill_id: exactMatch.skill_id,
333
+ latency_ms: Math.round(performance.now() - t0),
334
+ }),
335
+ );
336
+ return result;
337
+ }
338
+
339
+ const clients = getClients();
340
+
341
+ const lexical = ftsSearch(db, input.query, config.recall.k_lexical);
342
+
343
+ let retrieval: RetrievalCapability = "lexical";
344
+ let rows = lexical;
345
+ if (!input.forceLexical) {
346
+ try {
347
+ const queryVec = (await clients.embed([input.query]))[0]!;
348
+ const nearest = vectorTopK(db, queryVec, config.recall.k_vector);
349
+ rows = reciprocalRankFusion(lexical, nearest);
350
+ retrieval = "hybrid";
351
+ } catch {
352
+ retrieval = "lexical";
353
+ }
354
+ }
355
+
356
+ let scores: number[] | null = null;
357
+ if (clients.rerank && retrieval === "hybrid" && rows.length > 0) {
358
+ try {
359
+ scores = await clients.rerank(
360
+ input.query,
361
+ rows.map((r) => ({ skill_id: r.skill_id, text: rerankText(r) })),
362
+ );
363
+ retrieval = "reranked";
364
+ } catch {
365
+ scores = null;
366
+ }
367
+ }
368
+
369
+ const rankedCandidates: RankedCandidate[] = rows
370
+ .map((r, i) => ({
371
+ skill_id: r.skill_id,
372
+ title: r.title,
373
+ description: r.description,
374
+ score: scores?.[i] ?? null,
375
+ }))
376
+ .sort((a, b) => scores === null ? 0 : (b.score ?? -Infinity) - (a.score ?? -Infinity));
377
+
378
+ const decisionThresholds = retrieval === "reranked" && config.inference.mode === "remote"
379
+ ? { candidate_limit: config.thresholds.candidate_limit, ...config.inference.thresholds }
380
+ : config.thresholds;
381
+ const decision = decideResolveOutcome({
382
+ reranked: retrieval === "reranked",
383
+ candidates: rankedCandidates,
384
+ thresholds: decisionThresholds,
385
+ });
386
+
387
+ let result: ResolveResult;
388
+ if (decision.outcome === "matched") {
389
+ const delivery = await deliverSkill(db, config, decision.skill_id);
390
+ result = {
391
+ outcome: "matched",
392
+ retrieval: "reranked",
393
+ skill_id: decision.skill_id,
394
+ title: delivery.title,
395
+ content_sha256: delivery.content_sha256,
396
+ score: decision.score,
397
+ margin: decision.margin,
398
+ body: delivery.body,
399
+ files: delivery.files,
400
+ };
401
+ } else if (decision.outcome === "ambiguous") {
402
+ result = {
403
+ outcome: "ambiguous",
404
+ retrieval,
405
+ candidates: decision.candidates.map(({ score: _score, ...candidate }) => candidate),
406
+ };
407
+ } else {
408
+ result = { outcome: "no_match", retrieval, message: NO_MATCH_MESSAGE };
409
+ }
410
+
411
+ insertAudit(
412
+ db,
413
+ buildAuditRow({
414
+ id: 0, // assigned by SQLite
415
+ ts: new Date().toISOString(),
416
+ query: input.query,
417
+ outcome: result.outcome,
418
+ retrieval,
419
+ candidates: rankedCandidates.map((c) => ({ skill_id: c.skill_id, score: c.score })),
420
+ selected_skill_id: result.outcome === "matched" ? result.skill_id : null,
421
+ latency_ms: Math.round(performance.now() - t0),
422
+ }),
423
+ );
424
+
425
+ return result;
426
+ }
427
+
428
+ export async function fetchSkill(input: FetchSkillInput): Promise<FetchSkillResult> {
429
+ const { config, db } = await getEnv();
430
+ await syncVaultIfNeeded();
431
+ if (getSkillRow(db, input.skill_id) === null) {
432
+ throw new Error(`SKILL_NOT_FOUND: no skill '${input.skill_id}' in the index`);
433
+ }
434
+ return deliverSkill(db, config, input.skill_id);
435
+ }
package/src/rrf.ts ADDED
@@ -0,0 +1,31 @@
1
+ export interface RankedItem {
2
+ skill_id: string;
3
+ }
4
+
5
+ export function reciprocalRankFusion<T extends RankedItem>(lexical: T[], semantic: T[], rankConstant = 60): T[] {
6
+ const byId = new Map<string, { item: T; score: number; lexical: number; semantic: number }>();
7
+ const add = (items: T[], lane: "lexical" | "semantic") => {
8
+ items.forEach((item, index) => {
9
+ const current = byId.get(item.skill_id) ?? {
10
+ item,
11
+ score: 0,
12
+ lexical: Number.POSITIVE_INFINITY,
13
+ semantic: Number.POSITIVE_INFINITY,
14
+ };
15
+ current.score += 1 / (rankConstant + index + 1);
16
+ current[lane] = index;
17
+ byId.set(item.skill_id, current);
18
+ });
19
+ };
20
+ add(lexical, "lexical");
21
+ add(semantic, "semantic");
22
+ return [...byId.values()]
23
+ .sort((a, b) =>
24
+ b.score - a.score
25
+ || Number(a.semantic === Infinity) - Number(b.semantic === Infinity)
26
+ || a.semantic - b.semantic
27
+ || a.lexical - b.lexical
28
+ || a.item.skill_id.localeCompare(b.item.skill_id),
29
+ )
30
+ .map((entry) => entry.item);
31
+ }