@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.
@@ -248,7 +248,7 @@ The HTTP server provides:
248
248
 
249
249
  The Docker health check calls `/health/ready`.
250
250
 
251
- `GET /stats` returns JSON aggregated from audit rows over a query window (using `?since=`, defaulting to 24 hours). The response contains `since`, `until`, `total_requests`, `empty_shortlist_count`, `empty_shortlist_rate`, `retrieval_totals` (counts for `exact`, `reranked`, `hybrid`, and `lexical`), `degraded_count`, `average_latency_ms`, per-skill candidate counts in `skills` (`skill_id`, `candidate_count`), and `top_empty_shortlist_queries` (`query`, `count`). See [Managing skills](skill-management.md#use-routing-data-to-tune-tiers) for using these statistics to tune delivery tiers.
251
+ `GET /stats` returns JSON aggregated from audit rows over a query window (using `?since=`, defaulting to 24 hours). The response contains `since`, `until`, `total_requests`, `empty_shortlist_count`, `empty_shortlist_rate`, `retrieval_totals` (counts for `exact`, `reranked`, `hybrid`, and `lexical`), `degraded_count`, `average_latency_ms`, per-skill candidate counts in `skills` (`skill_id`, `candidate_count`), `top_empty_shortlist_queries` (`query`, `count`), an `acceptance` signal derived from fetches correlated to their resolve (`acceptance_rate`, `observed_mrr`, `top1_acceptance_rate`, `accepted_count`, `resolves_with_candidates`, and `uncorrelated_fetch_count`, or `available: false` with just the uncorrelated count when a window has no correlated fetches), and `top_unused_shortlist_queries` (`query`, `count`) for queries that returned candidates but received no correlated fetch. See [Managing skills](skill-management.md#use-routing-data-to-tune-tiers) for using these statistics to tune delivery tiers.
252
252
 
253
253
  Prometheus metrics cover request totals, resolve latency histograms, errors,
254
254
  rate-limit rejections, degraded retrieval totals by stage and reason, readiness
@@ -289,13 +289,26 @@ their mounted vault checkout and do not manage host agent directories.
289
289
 
290
290
  ## Persistent data and backups
291
291
 
292
- Persist `state_dir` to retain the index and audit log.
293
- Skill content remains in the server's vault checkout and should use its own
294
- backup or Git workflow.
295
-
296
- Treat the state database as sensitive because audit rows can contain raw user
292
+ Persist `state_dir` to retain both state files it holds: `index.sqlite3`
293
+ (retrieval index and vector matrix) and `audit.sqlite3` (resolve and fetch
294
+ audit rows). Skill content remains in the server's vault checkout and should
295
+ use its own backup or Git workflow.
296
+
297
+ The two files are independent and can be backed up on different schedules.
298
+ Copying or backing up `index.sqlite3` alone never carries raw user queries;
299
+ only `audit.sqlite3` does. `audit.sqlite3` is created with WAL journaling and
300
+ incremental auto-vacuum, so `skillmux audit prune` reclaims space without
301
+ taking a lock on `index.sqlite3`. On first startup after an upgrade from a
302
+ version that kept audit rows inside `index.sqlite3`, Skillmux migrates them
303
+ into `audit.sqlite3` once, transactionally; the migration is idempotent, so
304
+ it is safe to leave running unattended.
305
+
306
+ Treat `audit.sqlite3` as sensitive because its rows can contain raw user
297
307
  queries. Stop the process or use SQLite-safe backup tooling before copying a
298
- live database.
308
+ live database. `audit.retention_days` (default 90; `0` disables pruning) ages
309
+ out old rows automatically at startup and at most once per 24 hours while the
310
+ server runs; see [CLI reference](cli.md#observability-and-evaluation-skillmux-report-audit-eval)
311
+ for the on-demand `skillmux audit prune` command.
299
312
 
300
313
  ## Native pins with shared retrieval
301
314
 
@@ -3,6 +3,17 @@
3
3
  Skillmux supports three setup paths. Pick the result you want before choosing
4
4
  an installation.
5
5
 
6
+ ```mermaid
7
+ flowchart LR
8
+ Q{What do you need?}
9
+ Q -->|Managed links in client skill directories| A[Manage native skills]
10
+ Q -->|Local stdio MCP| B[Add local MCP retrieval]
11
+ Q -->|Streamable HTTP MCP for several clients| C[Run a shared MCP service]
12
+ A --> A1[Skillmux CLI]
13
+ B --> B1[Skillmux CLI]
14
+ C --> C1[Skillmux server, full image]
15
+ ```
16
+
6
17
  | Goal | Skill delivery | Recommended installation |
7
18
  | --- | --- | --- |
8
19
  | [Manage native skills](#manage-native-skills) | Managed links in client skill directories | Skillmux CLI |
@@ -79,6 +79,7 @@ Skillmux returns a ranked candidates response:
79
79
 
80
80
  ```json
81
81
  {
82
+ "request_id": "3fae2b8e-6c2d-4b1a-9d7a-2b6c5b6a9e10",
82
83
  "retrieval": "reranked",
83
84
  "candidates": [
84
85
  {
@@ -91,6 +92,7 @@ Skillmux returns a ranked candidates response:
91
92
  }
92
93
  ```
93
94
 
95
+ - `request_id`: a unique id minted for this resolve. Pass it back to `fetch_skill` to correlate a fetch outcome with this resolve and the fetched skill's rank in this shortlist.
94
96
  - `retrieval`: the effective retrieval capability (`reranked`, `hybrid`, or `lexical`).
95
97
  - `candidates`: zero through effective `top_k` candidates ordered by descending score with contiguous 1-based ranks.
96
98
  - If reranking or embedding fails, degradation metadata (`degraded_from`, `degradation_reason`) is included.
@@ -101,13 +103,18 @@ Input:
101
103
 
102
104
  ```json
103
105
  {
104
- "skill_id": "csv-formatter"
106
+ "skill_id": "csv-formatter",
107
+ "request_id": "3fae2b8e-6c2d-4b1a-9d7a-2b6c5b6a9e10"
105
108
  }
106
109
  ```
107
110
 
108
111
  The response contains the current `SKILL.md` body as text content.
109
112
  `structuredContent` contains the skill ID, title, content SHA-256, and
110
- supporting-file paths. Fetch does not depend on an earlier resolve call.
113
+ supporting-file paths. `request_id` is optional. When it names a resolve that
114
+ minted it, the recorded fetch outcome links to that resolve and its rank in
115
+ the shortlist. An absent, unknown, or malformed `request_id` still succeeds
116
+ and records an uncorrelated fetch — delivery never fails because telemetry
117
+ could not correlate. Fetch does not depend on an earlier resolve call.
111
118
 
112
119
  The complete wire contract lives in [schema.json](schema.json).
113
120
 
@@ -117,11 +124,26 @@ Give the calling client these rules:
117
124
 
118
125
  1. Call `resolve_skill` when a task may benefit from a specialized workflow.
119
126
  2. Review the returned ranked candidates shortlist.
120
- 3. If a relevant candidate exists, call `fetch_skill` with its `skill_id` to retrieve complete instructions.
127
+ 3. If a relevant candidate exists, call `fetch_skill` with its `skill_id` to retrieve complete instructions, passing back the resolve's `request_id` when the client retains it.
121
128
  4. If no candidate is relevant (or `candidates` is empty), continue under your normal workflow.
122
129
 
130
+ Passing `request_id` is optional and never required for delivery, but it is
131
+ what lets Skillmux measure whether a returned shortlist was actually used.
132
+
123
133
  ## Retrieval pipeline
124
134
 
135
+ ```mermaid
136
+ flowchart LR
137
+ Q[Query] --> L[Lexical: FTS5 + BM25]
138
+ Q --> E[Embeddings: local or remote]
139
+ L --> F[Reciprocal-rank fusion]
140
+ E --> F
141
+ F --> RR[Optional reranker]
142
+ RR --> K[Ranked candidates: top_k]
143
+ F -. reranker unavailable .-> K
144
+ Q -. embedding unavailable: lexical only .-> K
145
+ ```
146
+
125
147
  Skillmux builds candidates in stages:
126
148
 
127
149
  1. SQLite FTS5 ranks lexical matches with BM25.
@@ -173,12 +195,24 @@ access.
173
195
 
174
196
  Each resolve request records:
175
197
 
176
- - timestamp and query;
198
+ - timestamp, `request_id`, and query;
177
199
  - retrieval capability;
178
200
  - degradation metadata (`degraded_from`, `degradation_reason`) when degraded;
179
201
  - candidates with scores;
180
202
  - latency in milliseconds.
181
203
 
182
- Skillmux stores audit rows in the SQLite database under `state_dir`. Use
183
- `skillmux report` to summarize activity. Treat raw queries as private user
184
- data when backing up or sharing the database.
204
+ Each fetch request records:
205
+
206
+ - timestamp and `skill_id`;
207
+ - the `request_id` supplied by the caller, or null when absent, unknown, or malformed;
208
+ - the originating resolve's audit row id, or null when the fetch is uncorrelated;
209
+ - `rank_at_resolve`: the fetched skill's rank in that resolve's shortlist, or null when the fetch is uncorrelated or the skill was absent from that shortlist.
210
+
211
+ Skillmux stores audit rows in `audit.sqlite3` under `state_dir`, a file
212
+ separate from the retrieval index. Use `skillmux report` to summarize
213
+ activity, `skillmux audit prune` to reclaim space under
214
+ `audit.retention_days` (default 90 days), and `skillmux eval promote` to turn
215
+ correlated fetches into eval cases — see [CLI
216
+ reference](cli.md#observability-and-evaluation-skillmux-report-audit-eval)
217
+ for all three. Treat raw queries as private user data when backing up or
218
+ sharing the database.
package/docs/schema.json CHANGED
@@ -55,6 +55,18 @@
55
55
  "required": ["rank", "skill_id", "description", "score"],
56
56
  "additionalProperties": false
57
57
  },
58
+ "RequestId": {
59
+ "type": "string",
60
+ "description": "Per-resolve correlation id minted by resolve_skill. Lowercase UUIDv4.",
61
+ "format": "uuid",
62
+ "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
63
+ },
64
+ "InboundRequestId": {
65
+ "type": "string",
66
+ "description": "request_id as supplied by the caller to fetch_skill. Deliberately not pattern-validated: an unknown or malformed value must still succeed and record an uncorrelated fetch (AC7); correlation validity is decided by the server, not the schema.",
67
+ "minLength": 1,
68
+ "maxLength": 128
69
+ },
58
70
  "ResolveSkillInput": {
59
71
  "type": "object",
60
72
  "properties": {
@@ -76,6 +88,7 @@
76
88
  "type": "object",
77
89
  "description": "Ranked candidates response from resolve_skill.",
78
90
  "properties": {
91
+ "request_id": { "$ref": "#/$defs/RequestId" },
79
92
  "retrieval": { "enum": ["exact", "reranked", "hybrid", "lexical"] },
80
93
  "degraded_from": { "enum": ["reranked", "hybrid"] },
81
94
  "degradation_reason": { "$ref": "#/$defs/DegradationReason" },
@@ -85,13 +98,14 @@
85
98
  "description": "Shortlist of candidates in descending score order with contiguous 1-based ranks."
86
99
  }
87
100
  },
88
- "required": ["retrieval", "candidates"],
101
+ "required": ["request_id", "retrieval", "candidates"],
89
102
  "additionalProperties": false
90
103
  },
91
104
  "FetchSkillInput": {
92
105
  "type": "object",
93
106
  "properties": {
94
- "skill_id": { "$ref": "#/$defs/SkillId" }
107
+ "skill_id": { "$ref": "#/$defs/SkillId" },
108
+ "request_id": { "$ref": "#/$defs/InboundRequestId" }
95
109
  },
96
110
  "required": ["skill_id"],
97
111
  "additionalProperties": false
@@ -146,7 +160,7 @@
146
160
  },
147
161
  "state_dir": {
148
162
  "type": "string",
149
- "description": "All router writes are confined here (AC9): index SQLite (incl. audit log), vector matrix.",
163
+ "description": "All router writes are confined here (AC9): index SQLite (index.sqlite3), audit SQLite (audit.sqlite3), vector matrix.",
150
164
  "default": "~/.local/state/skillmux"
151
165
  },
152
166
  "recall": {
@@ -179,11 +193,24 @@
179
193
  "additionalProperties": false
180
194
  },
181
195
  "inference": { "$ref": "#/$defs/InferenceConfig" },
182
- "server": { "$ref": "#/$defs/ServerConfig" }
196
+ "server": { "$ref": "#/$defs/ServerConfig" },
197
+ "audit": { "$ref": "#/$defs/AuditConfig" }
183
198
  },
184
199
  "required": ["vault_path", "state_dir", "recall", "output", "inference"],
185
200
  "additionalProperties": false
186
201
  },
202
+ "AuditConfig": {
203
+ "type": "object",
204
+ "properties": {
205
+ "retention_days": {
206
+ "type": "integer",
207
+ "minimum": 0,
208
+ "default": 90,
209
+ "description": "Age in days beyond which audit and fetch rows are pruned. 0 disables pruning and retains rows indefinitely."
210
+ }
211
+ },
212
+ "additionalProperties": false
213
+ },
187
214
  "InferenceConfig": {
188
215
  "oneOf": [
189
216
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Skill management and retrieval for AI agents: sync native skills across clients and route the long tail over MCP",
5
5
  "type": "module",
6
6
  "private": false,
package/src/audit.ts CHANGED
@@ -5,6 +5,7 @@ export function buildAuditRow(row: AuditRow): AuditRow {
5
5
  const built: AuditRow = {
6
6
  id: row.id,
7
7
  ts: row.ts,
8
+ request_id: row.request_id,
8
9
  query: row.query,
9
10
  retrieval: row.retrieval,
10
11
  candidates: row.candidates.map((c) => ({ skill_id: c.skill_id, score: c.score })),
package/src/cli.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  migrateLegacyPaths,
13
13
  resolveConfigPath,
14
14
  } from "./config";
15
- import { openIndex } from "./db";
15
+ import { openAudit } from "./db";
16
16
  import { diagnose } from "./doctor";
17
17
  import { getEffectiveConfig } from "./config-service";
18
18
  import { evalVault } from "./eval";
@@ -106,7 +106,9 @@ import {
106
106
  suggestCorrection,
107
107
  } from "./output";
108
108
  import { generateCompletions, type ShellType } from "./completions";
109
+ import { runAudit } from "./commands/audit";
109
110
  import { handleConfigCommand } from "./commands/config";
111
+ import { runEvalPromote } from "./commands/eval";
110
112
  import { runCore } from "./commands/core";
111
113
  import { configuredTargetForSurface, runProject } from "./commands/project";
112
114
  import { confirmAction, confirmIfNeeded } from "./commands/shared";
@@ -124,6 +126,7 @@ const KNOWN_COMMANDS = [
124
126
  "target",
125
127
  "core",
126
128
  "report",
129
+ "audit",
127
130
  "scan",
128
131
  "install",
129
132
  "eval",
@@ -145,12 +148,15 @@ function isDockerHostManagementCommand(command: string, subCommand: string): boo
145
148
  "local-vault",
146
149
  "models",
147
150
  "context",
148
- "eval",
149
151
  ].includes(command)
150
152
  ) {
151
153
  return true;
152
154
  }
153
155
 
156
+ // eval promote only touches the mounted state_dir, unlike bare `eval`
157
+ // (vault ranking evaluation), which needs local embeddings and the vault.
158
+ if (command === "eval" && subCommand !== "promote") return true;
159
+
154
160
  return command === "config" && ["init", "set"].includes(subCommand);
155
161
  }
156
162
 
@@ -322,6 +328,9 @@ async function main() {
322
328
  case "report":
323
329
  await runReport(rawArgv.slice(1), { isJson });
324
330
  break;
331
+ case "audit":
332
+ await runAudit(subCommand, commandArgs, { isJson, dryRun: isDryRun });
333
+ break;
325
334
  case "scan":
326
335
  await runScan(rawArgv.slice(1), { isJson });
327
336
  break;
@@ -329,7 +338,13 @@ async function main() {
329
338
  await runInstall(rawArgv.slice(1), { isJson });
330
339
  break;
331
340
  case "eval":
332
- await runEval({ isJson });
341
+ if (subCommand === "promote") {
342
+ await runEvalPromote(commandArgs, { isJson, dryRun: isDryRun });
343
+ } else if (subCommand === "") {
344
+ await runEval({ isJson });
345
+ } else {
346
+ throw new Error(`usage: skillmux eval [promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]]`);
347
+ }
333
348
  break;
334
349
  case "doctor":
335
350
  await runDoctor({ isJson });
@@ -359,7 +374,7 @@ async function main() {
359
374
  const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
360
375
  const msg = suggestion
361
376
  ? `Unknown command "${command}". Did you mean "${suggestion}"?`
362
- : `usage: skillmux <serve|index|sync|init|project|target|core pin/unpin|report|scan|install|eval|doctor|skill which|local-vault init|config show|models download>`;
377
+ : `usage: skillmux <serve|index|sync|init|project|target|core pin/unpin|report|audit prune|scan|install|eval|doctor|skill which|local-vault init|config show|models download>`;
363
378
  throw new Error(msg);
364
379
  }
365
380
  }
@@ -507,7 +522,7 @@ Default:
507
522
  serve --transport http
508
523
 
509
524
  Supported commands:
510
- serve, index, doctor, report, scan, skill which
525
+ serve, index, doctor, report, audit prune, eval promote, scan, skill which
511
526
  config show|get|validate|diff|status
512
527
 
513
528
  Native skill management:
@@ -540,9 +555,14 @@ Init clients:
540
555
  Init targets:
541
556
  agent-skills, claude-code, codex, custom
542
557
 
558
+ Operations:
559
+ skillmux report [--server <url> | --db <path>] --since <window> [--json]
560
+ skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]
561
+ skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]
562
+
543
563
  Commands:
544
- serve, index, sync, init, project, target, core, report, scan, install, eval, doctor, skill,
545
- local-vault, config, models, context, completions`);
564
+ serve, index, sync, init, project, target, core, report, audit, scan, install, eval, doctor,
565
+ skill, local-vault, config, models, context, completions`);
546
566
  }
547
567
 
548
568
  // ---------------------------------------------------------------------------
@@ -1453,7 +1473,7 @@ async function runReport(
1453
1473
 
1454
1474
  const db = dbPath
1455
1475
  ? new Database(dbPath, { readonly: true })
1456
- : openIndex(expandHome((await loadConfig()).state_dir));
1476
+ : openAudit(expandHome((await loadConfig()).state_dir));
1457
1477
  const stats = getStats(db, since);
1458
1478
  emitSuccess({ isJson: options.isJson }, stats, () =>
1459
1479
  console.log(renderStatsText(stats)),
@@ -0,0 +1,82 @@
1
+ import { expandHome, loadConfig } from "../config";
2
+ import { countPrunable, openAudit, pruneAuditBefore } from "../db";
3
+ import { parseSince } from "../stats";
4
+ import { emitSuccess } from "../output";
5
+ import { confirmIfNeeded } from "./shared";
6
+
7
+ export async function runAudit(
8
+ subCommand: string,
9
+ args: string[],
10
+ options: { isJson: boolean; dryRun: boolean },
11
+ ): Promise<void> {
12
+ if (subCommand !== "prune") {
13
+ throw new Error("usage: skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]");
14
+ }
15
+
16
+ let olderThan: string | undefined;
17
+ let dryRun = options.dryRun;
18
+ let yes = false;
19
+ for (let i = 0; i < args.length; i++) {
20
+ const arg = args[i];
21
+ if (arg === "--older-than") olderThan = args[++i];
22
+ else if (arg === "--dry-run") dryRun = true;
23
+ else if (arg === "--yes") yes = true;
24
+ else if (arg === "--json") {
25
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
26
+ } else if (arg?.startsWith("--")) {
27
+ throw new Error(`unknown audit prune option: ${arg}`);
28
+ }
29
+ }
30
+
31
+ const config = await loadConfig();
32
+ const stateDir = expandHome(config.state_dir);
33
+
34
+ let cutoff: Date;
35
+ if (olderThan) {
36
+ cutoff = parseSince(olderThan);
37
+ } else {
38
+ const retentionDays = config.audit?.retention_days ?? 90;
39
+ if (retentionDays <= 0) {
40
+ emitSuccess(
41
+ { isJson: options.isJson },
42
+ { audit_deleted: 0, fetch_deleted: 0, dry_run: dryRun, cutoff: null },
43
+ () => console.log("prune: audit.retention_days is 0 (pruning disabled); nothing to do"),
44
+ );
45
+ return;
46
+ }
47
+ cutoff = new Date(Date.now() - retentionDays * 86_400_000);
48
+ }
49
+ const cutoffIso = cutoff.toISOString();
50
+
51
+ const db = openAudit(stateDir);
52
+ try {
53
+ if (dryRun) {
54
+ const counts = countPrunable(db, cutoffIso);
55
+ emitSuccess(
56
+ { isJson: options.isJson },
57
+ { ...counts, dry_run: true, cutoff: cutoffIso },
58
+ () => console.log(`prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted} (dry-run)`),
59
+ );
60
+ return;
61
+ }
62
+
63
+ if (
64
+ !(await confirmIfNeeded({
65
+ confirmed: yes,
66
+ isJson: options.isJson,
67
+ prompt: `prune audit rows older than ${cutoffIso}?`,
68
+ nonInteractiveError: "skillmux audit prune requires --yes when run non-interactively",
69
+ }))
70
+ )
71
+ return;
72
+
73
+ const counts = pruneAuditBefore(db, cutoffIso);
74
+ emitSuccess(
75
+ { isJson: options.isJson },
76
+ { ...counts, dry_run: false, cutoff: cutoffIso },
77
+ () => console.log(`prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted}`),
78
+ );
79
+ } finally {
80
+ db.close();
81
+ }
82
+ }
@@ -0,0 +1,81 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { expandHome, loadConfig } from "../config";
4
+ import { openAudit } from "../db";
5
+ import { buildPromotedCases, excludeExistingCases, parseEvalCases, queryPromotableFetches } from "../eval";
6
+ import { emitSuccess } from "../output";
7
+ import { parseSince } from "../stats";
8
+ import { confirmIfNeeded } from "./shared";
9
+
10
+ export async function runEvalPromote(
11
+ args: string[],
12
+ options: { isJson: boolean; dryRun: boolean },
13
+ ): Promise<void> {
14
+ let since: string | undefined;
15
+ let target: string | undefined;
16
+ let dryRun = options.dryRun;
17
+ let yes = false;
18
+ for (let i = 0; i < args.length; i++) {
19
+ const arg = args[i];
20
+ if (arg === "--since") since = args[++i];
21
+ else if (arg === "--target") target = args[++i];
22
+ else if (arg === "--dry-run") dryRun = true;
23
+ else if (arg === "--yes") yes = true;
24
+ else if (arg === "--json") {
25
+ // handled globally by main()'s isJson flag; recognized here so it isn't rejected
26
+ } else if (arg?.startsWith("--")) {
27
+ throw new Error(`unknown eval promote option: ${arg}`);
28
+ }
29
+ }
30
+ if (!since) {
31
+ throw new Error("usage: skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]");
32
+ }
33
+
34
+ const config = await loadConfig();
35
+ const stateDir = expandHome(config.state_dir);
36
+ const targetPath = target ?? join(stateDir, "eval-observed.json");
37
+ const sinceDate = parseSince(since);
38
+ const sinceIso = sinceDate.toISOString();
39
+
40
+ const db = openAudit(stateDir);
41
+ let candidates: ReturnType<typeof buildPromotedCases>;
42
+ try {
43
+ candidates = buildPromotedCases(queryPromotableFetches(db, sinceIso));
44
+ } finally {
45
+ db.close();
46
+ }
47
+
48
+ const existing = existsSync(targetPath) ? parseEvalCases(JSON.parse(readFileSync(targetPath, "utf-8"))) : [];
49
+ const { cases: newCases, skipped } = excludeExistingCases(candidates, existing);
50
+
51
+ console.error("warning: promoted eval cases contain raw user queries");
52
+
53
+ if (dryRun) {
54
+ emitSuccess(
55
+ { isJson: options.isJson },
56
+ { dry_run: true, since: sinceIso, target_path: targetPath, promoted: newCases.length, skipped_existing: skipped },
57
+ () => console.log(`promote: would write ${newCases.length} case(s) to ${targetPath} (skipped_existing=${skipped})`),
58
+ );
59
+ return;
60
+ }
61
+
62
+ if (
63
+ !(await confirmIfNeeded({
64
+ confirmed: yes,
65
+ isJson: options.isJson,
66
+ prompt: `promote ${newCases.length} eval case(s) to ${targetPath}?`,
67
+ nonInteractiveError: "skillmux eval promote requires --yes when run non-interactively",
68
+ }))
69
+ )
70
+ return;
71
+
72
+ if (newCases.length > 0) {
73
+ writeFileSync(targetPath, JSON.stringify([...existing, ...newCases], null, 2) + "\n");
74
+ }
75
+
76
+ emitSuccess(
77
+ { isJson: options.isJson },
78
+ { dry_run: false, since: sinceIso, target_path: targetPath, promoted: newCases.length, skipped_existing: skipped },
79
+ () => console.log(`promote: wrote ${newCases.length} case(s) to ${targetPath} (skipped_existing=${skipped})`),
80
+ );
81
+ }
package/src/config.ts CHANGED
@@ -88,6 +88,9 @@ const configSchema = z.object({
88
88
  token_env: z.string().min(1),
89
89
  }).strict().optional(),
90
90
  }).strict().optional(),
91
+ audit: z.object({
92
+ retention_days: z.number().int().min(0).default(90),
93
+ }).strict().default({ retention_days: 90 }),
91
94
  }).strict().refine((cfg) => {
92
95
  const hasReranker = cfg.inference.mode === "remote" && !!cfg.inference.reranker;
93
96
  if (hasReranker && cfg.output.max_top_k > cfg.recall.k_rerank) {
@@ -139,6 +142,9 @@ const DEFAULTS: Config = {
139
142
  requests_per_minute: 60,
140
143
  },
141
144
  },
145
+ audit: {
146
+ retention_days: 90,
147
+ },
142
148
  };
143
149
 
144
150
  export const DEFAULT_CONFIG_PATH = "~/.config/skillmux/config.toml";