@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/CHANGELOG.md +7 -0
- package/README.md +14 -5
- package/docs/assets/architecture-dark.svg +160 -0
- package/docs/assets/{architecture.svg → architecture-light.svg} +40 -34
- package/docs/assets/logo-dark.png +0 -0
- package/docs/assets/logo-light.png +0 -0
- package/docs/cli.md +63 -5
- package/docs/concepts.md +10 -0
- package/docs/configuration.md +20 -20
- package/docs/deployment.md +20 -7
- package/docs/getting-started.md +11 -0
- package/docs/mcp-routing.md +41 -7
- package/docs/schema.json +31 -4
- package/package.json +1 -1
- package/src/audit.ts +1 -0
- package/src/cli.ts +28 -8
- package/src/commands/audit.ts +82 -0
- package/src/commands/eval.ts +81 -0
- package/src/config.ts +6 -0
- package/src/db.ts +152 -45
- package/src/eval.ts +69 -0
- package/src/router-core.ts +95 -24
- package/src/server.ts +21 -6
- package/src/stats.ts +119 -13
- package/src/types.ts +22 -0
- package/docs/assets/logo.png +0 -0
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
|
-
|
|
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;
|
package/src/router-core.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
91
|
+
envPromise = null;
|
|
92
|
+
resolvedEnv = null;
|
|
93
|
+
lastAuditPruneAt = null;
|
|
82
94
|
}
|
|
83
95
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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<{
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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();
|