@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/docs/deployment.md
CHANGED
|
@@ -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 `
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
package/docs/getting-started.md
CHANGED
|
@@ -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 |
|
package/docs/mcp-routing.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 (
|
|
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
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
-
:
|
|
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";
|