@klhapp/skillmux 1.7.1 → 1.8.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/src/db.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { mkdirSync } from "node:fs";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import type { AuditCandidate, AuditRow } from "./types";
5
5
  import type { VaultSkill } from "./vault";
@@ -12,6 +12,81 @@ export interface SkillRow {
12
12
  content_sha256: string;
13
13
  }
14
14
 
15
+ export function openAudit(stateDir: string): Database {
16
+ mkdirSync(stateDir, { recursive: true });
17
+ const db = new Database(join(stateDir, "audit.sqlite3"), { create: true });
18
+ // auto_vacuum only takes on an empty database, so it must precede both the
19
+ // journal-mode switch and any CREATE TABLE. It is what lets a retention
20
+ // prune reclaim space without a full VACUUM.
21
+ db.run("PRAGMA auto_vacuum = INCREMENTAL");
22
+ db.run("PRAGMA journal_mode = WAL");
23
+ db.run("PRAGMA busy_timeout = 2000");
24
+ db.run(`CREATE TABLE IF NOT EXISTS audit (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ ts TEXT NOT NULL,
27
+ request_id TEXT,
28
+ query TEXT NOT NULL,
29
+ retrieval TEXT NOT NULL DEFAULT 'lexical',
30
+ degraded_from TEXT,
31
+ degradation_reason TEXT,
32
+ candidates TEXT NOT NULL,
33
+ latency_ms INTEGER NOT NULL
34
+ )`);
35
+ // CREATE TABLE IF NOT EXISTS no-ops on a table opened from before request_id
36
+ // existed (AC4), so add it explicitly when missing.
37
+ const auditColumns = new Set(
38
+ (db.query("PRAGMA table_info(audit)").all() as { name: string }[]).map((c) => c.name),
39
+ );
40
+ if (!auditColumns.has("request_id")) {
41
+ db.run("ALTER TABLE audit ADD COLUMN request_id TEXT");
42
+ }
43
+ db.run(`CREATE TABLE IF NOT EXISTS fetch (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ ts TEXT NOT NULL,
46
+ skill_id TEXT NOT NULL,
47
+ request_id TEXT,
48
+ resolve_audit_id INTEGER,
49
+ rank_at_resolve INTEGER
50
+ )`);
51
+ adoptAuditFromIndex(db, stateDir);
52
+ return db;
53
+ }
54
+
55
+ // Audit rows used to live in index.sqlite3. Move any that remain there into the
56
+ // audit store, then drop the old table so the index carries no user queries.
57
+ function adoptAuditFromIndex(db: Database, stateDir: string): void {
58
+ const indexPath = join(stateDir, "index.sqlite3");
59
+ if (!existsSync(indexPath)) return;
60
+
61
+ db.run("ATTACH DATABASE ? AS legacy", [indexPath]);
62
+ try {
63
+ const legacyAudit = db
64
+ .query("SELECT name FROM legacy.sqlite_master WHERE type = 'table' AND name = 'audit'")
65
+ .get();
66
+ if (!legacyAudit) return;
67
+
68
+ // Older audit tables predate the retrieval columns and carry outcome /
69
+ // degraded / selected_skill_id instead. Select what is actually there and
70
+ // let the canonical defaults stand in for the rest.
71
+ const legacyColumns = new Set(
72
+ (db.query("PRAGMA legacy.table_info(audit)").all() as { name: string }[]).map((c) => c.name),
73
+ );
74
+ const retrieval = legacyColumns.has("retrieval") ? "COALESCE(retrieval, 'lexical')" : "'lexical'";
75
+ const degradedFrom = legacyColumns.has("degraded_from") ? "degraded_from" : "NULL";
76
+ const degradationReason = legacyColumns.has("degradation_reason") ? "degradation_reason" : "NULL";
77
+
78
+ // SQLite commits atomically across attached databases, so the copy and the
79
+ // drop either both land or neither does.
80
+ db.transaction(() => {
81
+ db.run(`INSERT INTO audit (ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
82
+ SELECT ts, query, ${retrieval}, ${degradedFrom}, ${degradationReason}, candidates, latency_ms FROM legacy.audit`);
83
+ db.run("DROP TABLE legacy.audit");
84
+ })();
85
+ } finally {
86
+ db.run("DETACH DATABASE legacy");
87
+ }
88
+ }
89
+
15
90
  export function openIndex(stateDir: string): Database {
16
91
  mkdirSync(stateDir, { recursive: true });
17
92
  const db = new Database(join(stateDir, "index.sqlite3"), { create: true });
@@ -39,48 +114,7 @@ export function openIndex(stateDir: string): Database {
39
114
  if (!vectorColumns.some((column) => column.name === "embedding_fingerprint")) {
40
115
  db.run("ALTER TABLE vectors ADD COLUMN embedding_fingerprint TEXT NOT NULL DEFAULT ''");
41
116
  }
42
- db.run(`CREATE TABLE IF NOT EXISTS audit (
43
- id INTEGER PRIMARY KEY AUTOINCREMENT,
44
- ts TEXT NOT NULL,
45
- query TEXT NOT NULL,
46
- retrieval TEXT NOT NULL DEFAULT 'lexical',
47
- degraded_from TEXT,
48
- degradation_reason TEXT,
49
- candidates TEXT NOT NULL,
50
- latency_ms INTEGER NOT NULL
51
- )`);
52
- const auditColumns = db.query("PRAGMA table_info(audit)").all() as { name: string }[];
53
- const columnNames = new Set(auditColumns.map((column) => column.name));
54
- const hasLegacyColumns =
55
- columnNames.has("outcome") ||
56
- columnNames.has("selected_skill_id") ||
57
- columnNames.has("degraded");
58
- const missingCanonicalColumns =
59
- !columnNames.has("retrieval") ||
60
- !columnNames.has("degraded_from") ||
61
- !columnNames.has("degradation_reason");
62
-
63
- if (hasLegacyColumns || missingCanonicalColumns) {
64
- db.transaction(() => {
65
- db.run(`CREATE TABLE audit_new (
66
- id INTEGER PRIMARY KEY AUTOINCREMENT,
67
- ts TEXT NOT NULL,
68
- query TEXT NOT NULL,
69
- retrieval TEXT NOT NULL DEFAULT 'lexical',
70
- degraded_from TEXT,
71
- degradation_reason TEXT,
72
- candidates TEXT NOT NULL,
73
- latency_ms INTEGER NOT NULL
74
- )`);
75
- const retrievalExpr = columnNames.has("retrieval") ? "COALESCE(retrieval, 'lexical')" : "'lexical'";
76
- const degradedFromExpr = columnNames.has("degraded_from") ? "degraded_from" : "NULL";
77
- const degradationReasonExpr = columnNames.has("degradation_reason") ? "degradation_reason" : "NULL";
78
- db.run(`INSERT INTO audit_new (id, ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
79
- SELECT id, ts, query, ${retrievalExpr}, ${degradedFromExpr}, ${degradationReasonExpr}, candidates, latency_ms FROM audit`);
80
- db.run("DROP TABLE audit");
81
- db.run("ALTER TABLE audit_new RENAME TO audit");
82
- })();
83
- }
117
+ // Audit rows live in audit.sqlite3; openAudit adopts any left here.
84
118
  db.run(`CREATE TABLE IF NOT EXISTS index_meta (
85
119
  key TEXT PRIMARY KEY,
86
120
  value TEXT NOT NULL
@@ -281,6 +315,7 @@ export function vectorTopK(db: Database, query: Float32Array, k: number): SkillR
281
315
 
282
316
  export interface AuditInsert {
283
317
  ts: string;
318
+ request_id?: string | null;
284
319
  query: string;
285
320
  retrieval: AuditRow["retrieval"];
286
321
  degraded_from?: string | null;
@@ -291,10 +326,11 @@ export interface AuditInsert {
291
326
 
292
327
  export function insertAudit(db: Database, row: AuditInsert): void {
293
328
  db.run(
294
- `INSERT INTO audit (ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
295
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
329
+ `INSERT INTO audit (ts, request_id, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
330
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
296
331
  [
297
332
  row.ts,
333
+ row.request_id ?? null,
298
334
  row.query,
299
335
  row.retrieval,
300
336
  row.degraded_from ?? null,
@@ -304,3 +340,74 @@ export function insertAudit(db: Database, row: AuditInsert): void {
304
340
  ],
305
341
  );
306
342
  }
343
+
344
+ /**
345
+ * Correlation lookup for AC5/AC7: looks up the resolve that produced
346
+ * `requestId`, or null when it names no known resolve (including malformed
347
+ * input, which is never validated at the boundary per AC7).
348
+ */
349
+ export function getAuditRowByRequestId(
350
+ db: Database,
351
+ requestId: string,
352
+ ): { id: number; candidates: AuditCandidate[] } | null {
353
+ const row = db
354
+ .query("SELECT id, candidates FROM audit WHERE request_id = ?")
355
+ .get(requestId) as { id: number; candidates: string } | null;
356
+ if (!row) return null;
357
+ return { id: row.id, candidates: JSON.parse(row.candidates) as AuditCandidate[] };
358
+ }
359
+
360
+ export interface FetchInsert {
361
+ ts: string;
362
+ skill_id: string;
363
+ request_id?: string | null;
364
+ resolve_audit_id?: number | null;
365
+ rank_at_resolve?: number | null;
366
+ }
367
+
368
+ export function insertFetch(db: Database, row: FetchInsert): void {
369
+ db.run(
370
+ `INSERT INTO fetch (ts, skill_id, request_id, resolve_audit_id, rank_at_resolve)
371
+ VALUES (?, ?, ?, ?, ?)`,
372
+ [
373
+ row.ts,
374
+ row.skill_id,
375
+ row.request_id ?? null,
376
+ row.resolve_audit_id ?? null,
377
+ row.rank_at_resolve ?? null,
378
+ ],
379
+ );
380
+ }
381
+
382
+ export interface PruneResult {
383
+ audit_deleted: number;
384
+ fetch_deleted: number;
385
+ }
386
+
387
+ /**
388
+ * Deletes resolve and fetch rows with ts before `cutoffIso`, each by its own
389
+ * timestamp; no FK ties them, so a fetch outliving its resolve row simply
390
+ * reads back uncorrelated (AC7's existing null path). Reclaims the freed
391
+ * pages with an incremental vacuum, which only touches audit.sqlite3 (AC16).
392
+ */
393
+ export function pruneAuditBefore(db: Database, cutoffIso: string): PruneResult {
394
+ const auditResult = db.run("DELETE FROM audit WHERE ts < ?", [cutoffIso]);
395
+ const fetchResult = db.run("DELETE FROM fetch WHERE ts < ?", [cutoffIso]);
396
+ db.run("PRAGMA incremental_vacuum");
397
+
398
+ return { audit_deleted: auditResult.changes, fetch_deleted: fetchResult.changes };
399
+ }
400
+
401
+ /** AC12: retentionDays <= 0 disables pruning entirely. */
402
+ export function pruneAudit(db: Database, retentionDays: number, now: Date = new Date()): PruneResult {
403
+ if (retentionDays <= 0) return { audit_deleted: 0, fetch_deleted: 0 };
404
+ const cutoff = new Date(now.getTime() - retentionDays * 86_400_000).toISOString();
405
+ return pruneAuditBefore(db, cutoff);
406
+ }
407
+
408
+ /** Dry-run counterpart of pruneAuditBefore: counts without deleting (AC15). */
409
+ export function countPrunable(db: Database, cutoffIso: string): PruneResult {
410
+ const auditRow = db.query("SELECT count(*) AS n FROM audit WHERE ts < ?").get(cutoffIso) as { n: number };
411
+ const fetchRow = db.query("SELECT count(*) AS n FROM fetch WHERE ts < ?").get(cutoffIso) as { n: number };
412
+ return { audit_deleted: auditRow.n, fetch_deleted: fetchRow.n };
413
+ }
package/src/eval.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Database } from "bun:sqlite";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import {
@@ -58,6 +59,74 @@ export function parseEvalCases(raw: unknown): EvalCase[] {
58
59
  return result;
59
60
  }
60
61
 
62
+ /** AC17: dedup key for promoted cases. Collapses whitespace and case differences that are not meaningfully distinct queries. */
63
+ export function normalizeQuery(query: string): string {
64
+ return query.trim().replace(/\s+/g, " ").toLowerCase();
65
+ }
66
+
67
+ export interface ObservedFetch {
68
+ query: string;
69
+ skill_id: string;
70
+ }
71
+
72
+ /** AC17: groups fetched skill ids by normalized query into observed-split eval cases. */
73
+ export function buildPromotedCases(fetches: ObservedFetch[]): EvalCase[] {
74
+ const byQuery = new Map<string, { query: string; skillIds: string[]; seen: Set<string> }>();
75
+ for (const fetch of fetches) {
76
+ const key = normalizeQuery(fetch.query);
77
+ if (!key) continue;
78
+ let entry = byQuery.get(key);
79
+ if (!entry) {
80
+ entry = { query: fetch.query, skillIds: [], seen: new Set() };
81
+ byQuery.set(key, entry);
82
+ }
83
+ if (!entry.seen.has(fetch.skill_id)) {
84
+ entry.seen.add(fetch.skill_id);
85
+ entry.skillIds.push(fetch.skill_id);
86
+ }
87
+ }
88
+ return [...byQuery.values()].map((entry) => ({
89
+ query: entry.query,
90
+ split: "observed",
91
+ relevant_skill_ids: entry.skillIds,
92
+ }));
93
+ }
94
+
95
+ /** AC18: never rewrites a case whose normalized query already exists in the target file. */
96
+ export function excludeExistingCases(
97
+ cases: EvalCase[],
98
+ existing: EvalCase[],
99
+ ): { cases: EvalCase[]; skipped: number } {
100
+ const existingKeys = new Set(existing.map((c) => normalizeQuery(c.query)));
101
+ const kept: EvalCase[] = [];
102
+ let skipped = 0;
103
+ for (const c of cases) {
104
+ if (existingKeys.has(normalizeQuery(c.query))) {
105
+ skipped++;
106
+ } else {
107
+ kept.push(c);
108
+ }
109
+ }
110
+ return { cases: kept, skipped };
111
+ }
112
+
113
+ /**
114
+ * AC17: joins fetches to the resolve that produced them for promotion. Only
115
+ * correlated fetches (a known resolve_audit_id) carry a query to promote;
116
+ * uncorrelated fetches have no resolve to join against and are excluded.
117
+ */
118
+ export function queryPromotableFetches(db: Database, sinceIso: string): ObservedFetch[] {
119
+ return db
120
+ .query(
121
+ `SELECT audit.query AS query, fetch.skill_id AS skill_id
122
+ FROM fetch
123
+ JOIN audit ON fetch.resolve_audit_id = audit.id
124
+ WHERE fetch.ts >= ?
125
+ ORDER BY fetch.ts ASC`,
126
+ )
127
+ .all(sinceIso) as ObservedFetch[];
128
+ }
129
+
61
130
  export interface EvalMetrics {
62
131
  recall_at_5: number;
63
132
  recall_at_10: number;
@@ -8,11 +8,15 @@ import {
8
8
  deleteSkill,
9
9
  findExactMatch,
10
10
  ftsSearch,
11
+ getAuditRowByRequestId,
11
12
  getIndexMeta,
12
13
  getSkillRow,
13
14
  ingestVault,
14
15
  insertAudit,
16
+ insertFetch,
17
+ openAudit,
15
18
  openIndex,
19
+ pruneAudit,
16
20
  replaceSkills,
17
21
  setIndexMeta,
18
22
  skillCount,
@@ -22,7 +26,7 @@ import {
22
26
  upsertVector,
23
27
  vectorTopK,
24
28
  } from "./db";
25
- import type { SkillRow } from "./db";
29
+ import type { PruneResult, SkillRow } from "./db";
26
30
  import type {
27
31
  RankedCandidate,
28
32
  RetrievalCapability,
@@ -73,26 +77,45 @@ const defaultClients: Clients = {
73
77
  };
74
78
 
75
79
  let overrides: Overrides = {};
76
- let env: { config: Config; db: Database } | null = null;
80
+ type Env = { config: Config; db: Database; auditDb: Database };
81
+
82
+ let envPromise: Promise<Env> | null = null;
83
+ let resolvedEnv: Env | null = null;
84
+ let lastAuditPruneAt: number | null = null;
85
+
86
+ const AUDIT_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
77
87
 
78
88
  /** Replace config/client overrides wholesale (tests, ops). Resets the cached index handle. */
79
89
  export function configure(opts: Overrides): void {
80
90
  overrides = opts;
81
- env = null;
91
+ envPromise = null;
92
+ resolvedEnv = null;
93
+ lastAuditPruneAt = null;
82
94
  }
83
95
 
84
- async function getEnv(): Promise<{ config: Config; db: Database }> {
85
- if (env) return env;
86
- const config = overrides.config ?? (await loadConfig());
87
- const db = openIndex(expandHome(config.state_dir));
88
- if (skillCount(db) === 0) {
89
- const vaultPath = expandHome(config.vault_path);
90
- const localVaultPaths = config.local_vault_paths.map(expandHome);
91
- ingestVault(db, await scanVaults(vaultPath, localVaultPaths));
92
- setIndexMeta(db, "last_indexed_mtime", String(maxVaultMtime(vaultPath, localVaultPaths)));
93
- }
94
- env = { config, db };
95
- return env;
96
+ /**
97
+ * Memoizes the in-flight promise, not just the resolved value: startup fires
98
+ * initializeRuntime()'s getRuntime() and pruneAuditIfDue() back-to-back before
99
+ * either has awaited anything, so caching only the resolved env would let both
100
+ * open their own index/audit handles and race an ingestVault (AC14 regression).
101
+ */
102
+ async function getEnv(): Promise<Env> {
103
+ if (envPromise) return envPromise;
104
+ envPromise = (async () => {
105
+ const config = overrides.config ?? (await loadConfig());
106
+ const stateDir = expandHome(config.state_dir);
107
+ const db = openIndex(stateDir);
108
+ const auditDb = openAudit(stateDir);
109
+ if (skillCount(db) === 0) {
110
+ const vaultPath = expandHome(config.vault_path);
111
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
112
+ ingestVault(db, await scanVaults(vaultPath, localVaultPaths));
113
+ setIndexMeta(db, "last_indexed_mtime", String(maxVaultMtime(vaultPath, localVaultPaths)));
114
+ }
115
+ resolvedEnv = { config, db, auditDb };
116
+ return resolvedEnv;
117
+ })();
118
+ return envPromise;
96
119
  }
97
120
 
98
121
  function getClients(): Clients {
@@ -100,14 +123,21 @@ function getClients(): Clients {
100
123
  }
101
124
 
102
125
  /** Runtime accessor for the eval harness and CLI — not part of the MCP surface. */
103
- export async function getRuntime(): Promise<{ config: Config; db: Database; clients: Clients }> {
104
- const { config, db } = await getEnv();
105
- return { config, db, clients: getClients() };
126
+ export async function getRuntime(): Promise<{
127
+ config: Config;
128
+ db: Database;
129
+ auditDb: Database;
130
+ clients: Clients;
131
+ }> {
132
+ const { config, db, auditDb } = await getEnv();
133
+ return { config, db, auditDb, clients: getClients() };
106
134
  }
107
135
 
108
136
  export function closeRuntime(): void {
109
- env?.db.close();
110
- env = null;
137
+ resolvedEnv?.db.close();
138
+ resolvedEnv?.auditDb.close();
139
+ envPromise = null;
140
+ resolvedEnv = null;
111
141
  }
112
142
 
113
143
  /**
@@ -365,7 +395,7 @@ export async function startVaultWatcher(): Promise<() => void> {
365
395
 
366
396
  export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveResult> {
367
397
  const t0 = performance.now();
368
- const { config, db } = await getEnv();
398
+ const { config, db, auditDb } = await getEnv();
369
399
  await syncVaultIfNeeded();
370
400
 
371
401
  if (input.top_k !== undefined) {
@@ -392,7 +422,10 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
392
422
  score: c.score,
393
423
  }));
394
424
 
425
+ const requestId = crypto.randomUUID();
426
+
395
427
  const result: ResolveResult = {
428
+ request_id: requestId,
396
429
  retrieval,
397
430
  ...(retrievalResult.degraded_from
398
431
  ? {
@@ -404,10 +437,11 @@ export async function resolveSkill(input: ResolveSkillInput): Promise<ResolveRes
404
437
  };
405
438
 
406
439
  insertAudit(
407
- db,
440
+ auditDb,
408
441
  buildAuditRow({
409
442
  id: 0, // assigned by SQLite
410
443
  ts: new Date().toISOString(),
444
+ request_id: requestId,
411
445
  query: input.query,
412
446
  retrieval,
413
447
  degraded_from: retrievalResult.degraded_from ?? null,
@@ -592,11 +626,48 @@ export async function retrieveAndRerank(
592
626
  };
593
627
  }
594
628
 
629
+ /**
630
+ * AC14: runs at most once per 24 hours per process. Callers must not await
631
+ * this on the startup or resolve path -- it is meant to be fired and left to
632
+ * resolve in the background so it never blocks readiness or a resolve.
633
+ */
634
+ export async function pruneAuditIfDue(now: Date = new Date()): Promise<PruneResult | null> {
635
+ const { config, auditDb } = await getEnv();
636
+ const retentionDays = config.audit?.retention_days ?? 90;
637
+ if (retentionDays <= 0) return null;
638
+ if (lastAuditPruneAt !== null && now.getTime() - lastAuditPruneAt < AUDIT_PRUNE_INTERVAL_MS) {
639
+ return null;
640
+ }
641
+ lastAuditPruneAt = now.getTime();
642
+ return pruneAudit(auditDb, retentionDays, now);
643
+ }
644
+
595
645
  export async function fetchSkill(input: FetchSkillInput): Promise<FetchSkillResult> {
596
- const { config, db } = await getEnv();
646
+ const { config, db, auditDb } = await getEnv();
597
647
  await syncVaultIfNeeded();
598
648
  if (getSkillRow(db, input.skill_id) === null) {
599
649
  throw new Error(`SKILL_NOT_FOUND: no skill '${input.skill_id}' in the index`);
600
650
  }
601
- return deliverSkill(db, config, input.skill_id);
651
+ const result = await deliverSkill(db, config, input.skill_id);
652
+
653
+ let resolveAuditId: number | null = null;
654
+ let rankAtResolve: number | null = null;
655
+ if (input.request_id) {
656
+ const resolveRow = getAuditRowByRequestId(auditDb, input.request_id);
657
+ if (resolveRow) {
658
+ resolveAuditId = resolveRow.id;
659
+ const index = resolveRow.candidates.findIndex((c) => c.skill_id === input.skill_id);
660
+ rankAtResolve = index === -1 ? null : index + 1;
661
+ }
662
+ }
663
+
664
+ insertFetch(auditDb, {
665
+ ts: new Date().toISOString(),
666
+ skill_id: input.skill_id,
667
+ request_id: input.request_id ?? null,
668
+ resolve_audit_id: resolveAuditId,
669
+ rank_at_resolve: rankAtResolve,
670
+ });
671
+
672
+ return result;
602
673
  }
package/src/server.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  backfillEmbeddings,
13
13
  configure,
14
14
  fetchSkill,
15
+ pruneAuditIfDue,
15
16
  resolveSkill,
16
17
  } from "./router-core";
17
18
  import { closeRuntime, getRuntime, startVaultWatcher } from "./router-core";
@@ -113,12 +114,16 @@ export function createMcpServer(): McpServer {
113
114
  {
114
115
  description:
115
116
  "Fetch a skill's SKILL.md verbatim by skill_id, with sha256 and supporting-file paths. " +
116
- "Independent of any prior resolve_skill outcome.",
117
- inputSchema: { skill_id: z.string().regex(SKILL_ID_PATTERN) },
117
+ "Independent of any prior resolve_skill outcome. Pass the request_id from a prior " +
118
+ "resolve_skill call to link this fetch to it for quality measurement.",
119
+ inputSchema: {
120
+ skill_id: z.string().regex(SKILL_ID_PATTERN),
121
+ request_id: z.string().min(1).max(128).optional(),
122
+ },
118
123
  },
119
- async ({ skill_id }) => {
124
+ async ({ skill_id, request_id }) => {
120
125
  try {
121
- const result = await fetchSkill({ skill_id });
126
+ const result = await fetchSkill({ skill_id, request_id });
122
127
  const { body, ...meta } = result;
123
128
  return {
124
129
  content: [{ type: "text" as const, text: body }],
@@ -175,6 +180,14 @@ export async function startServer(opts?: {
175
180
  .then(() => metricsRegistry.setReadiness(readinessState.get()))
176
181
  .catch((err) => console.error("skillmux runtime init error:", err));
177
182
 
183
+ // AC14: fire-and-forget so this never delays readiness or blocks a resolve;
184
+ // not chained onto initPromise, which is awaited below for HTTP transport.
185
+ const runAuditPrune = () =>
186
+ pruneAuditIfDue().catch((err) => console.error("skillmux audit prune error:", err));
187
+ runAuditPrune();
188
+ const auditPruneInterval = setInterval(runAuditPrune, 24 * 60 * 60 * 1000);
189
+ auditPruneInterval.unref();
190
+
178
191
  const server = createMcpServer();
179
192
 
180
193
  const transportType = opts?.transport ?? "stdio";
@@ -345,13 +358,13 @@ export async function startServer(opts?: {
345
358
  { status: 400, headers: { "Content-Type": "application/json" } },
346
359
  );
347
360
  }
348
- const { db } = await getRuntime();
361
+ const { auditDb } = await getRuntime();
349
362
  const headers = new Headers({ "Content-Type": "application/json" });
350
363
  if (allowOriginHeader)
351
364
  headers.set("Access-Control-Allow-Origin", allowOriginHeader);
352
365
  for (const [key, value] of Object.entries(rateLimitResult.headers))
353
366
  headers.set(key, value);
354
- return new Response(JSON.stringify(getStats(db, since)), {
367
+ return new Response(JSON.stringify(getStats(auditDb, since)), {
355
368
  status: 200,
356
369
  headers,
357
370
  });
@@ -521,6 +534,7 @@ export async function startServer(opts?: {
521
534
  async stop() {
522
535
  if (stopped) return;
523
536
  stopped = true;
537
+ clearInterval(auditPruneInterval);
524
538
  readinessState.set({ ...readinessState.get(), status: "stopping" });
525
539
  metricsRegistry.setReadiness(readinessState.get());
526
540
  bunServer.stop(true);
@@ -540,6 +554,7 @@ export async function startServer(opts?: {
540
554
  async stop() {
541
555
  if (stopped) return;
542
556
  stopped = true;
557
+ clearInterval(auditPruneInterval);
543
558
  readinessState.set({ ...readinessState.get(), status: "stopping" });
544
559
  metricsRegistry.setReadiness(readinessState.get());
545
560
  configWatcher?.stop();