@klhapp/skillmux 1.12.0 → 1.13.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +18 -9
  3. package/bin/skillmux.js +89 -0
  4. package/docs/README.md +4 -4
  5. package/docs/cli.md +14 -4
  6. package/docs/concepts.md +2 -2
  7. package/docs/configuration.md +18 -3
  8. package/docs/deployment.md +3 -3
  9. package/docs/getting-started.md +32 -7
  10. package/docs/releasing.md +17 -2
  11. package/docs/sdd/cli-remote-target-parity/schema-bucket-b.yaml +297 -0
  12. package/docs/sdd/cli-remote-target-parity/spec-bucket-b.md +95 -0
  13. package/docs/sdd/cli-remote-target-parity/spec.md +111 -0
  14. package/docs/sdd/cli-remote-target-parity/think.md +183 -0
  15. package/docs/sdd/cli-surface-consistency/think.md +149 -0
  16. package/docs/sdd/config-authority-rerank-resilience/plan.md +338 -0
  17. package/docs/sdd/config-authority-rerank-resilience/review.md +124 -0
  18. package/docs/sdd/config-authority-rerank-resilience/spec.md +78 -0
  19. package/docs/sdd/feat-packaging-deployment/review.md +95 -0
  20. package/docs/sdd/fetch-outcome-flywheel/review.md +103 -0
  21. package/docs/sdd/fetch-outcome-flywheel/schema.json +347 -0
  22. package/docs/sdd/fetch-outcome-flywheel/spec.md +185 -0
  23. package/docs/sdd/npm-native-binaries/review.md +103 -0
  24. package/docs/sdd/npm-native-binaries/spec.md +148 -0
  25. package/docs/sdd/ranked-shortlist-2/pr3-calibration-removal.md +56 -0
  26. package/docs/sdd/refactor-builtin-target-paths/spec.md +41 -0
  27. package/docs/sdd/runtime-resource-hardening/review.md +86 -0
  28. package/docs/sdd/runtime-resource-hardening/spec.md +43 -0
  29. package/docs/sdd/security-hardening-phase/think.md +129 -0
  30. package/docs/sdd/security-observability/review.md +96 -0
  31. package/docs/sdd/security-observability/spec.md +55 -0
  32. package/docs/sdd/skill-provenance-update/review.md +152 -0
  33. package/docs/sdd/skill-provenance-update/schema.json +193 -0
  34. package/docs/sdd/skill-provenance-update/spec.md +134 -0
  35. package/docs/sdd/supply-chain-hardening/review.md +50 -0
  36. package/docs/sdd/supply-chain-hardening/spec.md +42 -0
  37. package/docs/sdd/target-marker-rehome/spec.md +33 -0
  38. package/docs/troubleshooting.md +18 -2
  39. package/package.json +15 -8
  40. package/src/adapters.ts +0 -438
  41. package/src/audit.ts +0 -21
  42. package/src/cli.ts +0 -928
  43. package/src/clients.ts +0 -368
  44. package/src/commands/audit.ts +0 -85
  45. package/src/commands/config.ts +0 -224
  46. package/src/commands/context.ts +0 -104
  47. package/src/commands/core.ts +0 -56
  48. package/src/commands/doctor.ts +0 -97
  49. package/src/commands/eval.ts +0 -88
  50. package/src/commands/init.ts +0 -672
  51. package/src/commands/install.ts +0 -155
  52. package/src/commands/local-vault.ts +0 -60
  53. package/src/commands/models.ts +0 -10
  54. package/src/commands/outdated.ts +0 -115
  55. package/src/commands/project.ts +0 -580
  56. package/src/commands/report.ts +0 -66
  57. package/src/commands/scan.ts +0 -71
  58. package/src/commands/shared.ts +0 -38
  59. package/src/commands/skill.ts +0 -33
  60. package/src/commands/sync.ts +0 -233
  61. package/src/commands/target.ts +0 -225
  62. package/src/commands/update.ts +0 -277
  63. package/src/completions.ts +0 -196
  64. package/src/concurrency-limiter.ts +0 -61
  65. package/src/config-service.ts +0 -398
  66. package/src/config-watcher.ts +0 -253
  67. package/src/config.ts +0 -583
  68. package/src/context.ts +0 -188
  69. package/src/db-audit.ts +0 -286
  70. package/src/db-index.ts +0 -238
  71. package/src/db.ts +0 -3
  72. package/src/deployment.ts +0 -39
  73. package/src/doctor.ts +0 -205
  74. package/src/eval.ts +0 -302
  75. package/src/global-flags.ts +0 -46
  76. package/src/init-agents.ts +0 -329
  77. package/src/init-instructions.ts +0 -192
  78. package/src/init.ts +0 -390
  79. package/src/install.ts +0 -232
  80. package/src/lifecycle.ts +0 -51
  81. package/src/logger.ts +0 -26
  82. package/src/manifest.ts +0 -359
  83. package/src/mcp-registration.ts +0 -89
  84. package/src/metrics.ts +0 -121
  85. package/src/models.ts +0 -20
  86. package/src/output.ts +0 -214
  87. package/src/project-setup.ts +0 -36
  88. package/src/prompts.ts +0 -124
  89. package/src/provenance.ts +0 -99
  90. package/src/rate-limiter.ts +0 -137
  91. package/src/readiness.ts +0 -30
  92. package/src/redact.ts +0 -52
  93. package/src/router-core.ts +0 -666
  94. package/src/rrf.ts +0 -31
  95. package/src/scan.ts +0 -300
  96. package/src/server.ts +0 -930
  97. package/src/setup.ts +0 -145
  98. package/src/snapshot.ts +0 -135
  99. package/src/stats.ts +0 -340
  100. package/src/sync.ts +0 -482
  101. package/src/toml-writer.ts +0 -51
  102. package/src/types.ts +0 -212
  103. package/src/vault.ts +0 -207
package/src/setup.ts DELETED
@@ -1,145 +0,0 @@
1
- import {
2
- existsSync,
3
- linkSync,
4
- lstatSync,
5
- mkdirSync,
6
- readdirSync,
7
- realpathSync,
8
- statSync,
9
- unlinkSync,
10
- rmSync,
11
- writeFileSync,
12
- } from "node:fs";
13
- import { basename, dirname, join } from "node:path";
14
- import { SKILL_ID_PATTERN } from "./vault";
15
-
16
- export interface VaultHealth {
17
- path: string;
18
- state: "missing" | "broken-symlink" | "not-directory" | "empty" | "ready";
19
- ok: boolean;
20
- skillCount: number;
21
- message: string;
22
- }
23
-
24
- export interface ConfigInitPlan {
25
- configPath: string;
26
- vaultPath: string;
27
- action: "create" | "preserve";
28
- content?: string;
29
- }
30
-
31
- export function inspectVault(path: string): VaultHealth {
32
- let stat;
33
- try {
34
- stat = lstatSync(path);
35
- } catch (error) {
36
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
37
- return {
38
- path,
39
- state: "missing",
40
- ok: false,
41
- skillCount: 0,
42
- message: `vault does not exist: ${path}`,
43
- };
44
- }
45
- throw error;
46
- }
47
-
48
- if (stat.isSymbolicLink()) {
49
- try {
50
- realpathSync(path);
51
- } catch (error) {
52
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
53
- return {
54
- path,
55
- state: "broken-symlink",
56
- ok: false,
57
- skillCount: 0,
58
- message: `vault is a dangling symlink: ${path}`,
59
- };
60
- }
61
- throw error;
62
- }
63
- }
64
-
65
- const resolvedStat = stat.isSymbolicLink() ? statSync(path) : stat;
66
- if (!resolvedStat.isDirectory()) {
67
- return {
68
- path,
69
- state: "not-directory",
70
- ok: false,
71
- skillCount: 0,
72
- message: `vault is not a directory: ${path}`,
73
- };
74
- }
75
-
76
- const skillCount = readdirSync(path, { withFileTypes: true }).filter(
77
- (entry) =>
78
- entry.isDirectory() &&
79
- SKILL_ID_PATTERN.test(entry.name) &&
80
- existsSync(join(path, entry.name, "SKILL.md")),
81
- ).length;
82
- if (skillCount === 0) {
83
- return {
84
- path,
85
- state: "empty",
86
- ok: false,
87
- skillCount,
88
- message: `vault contains no skill directories: ${path}`,
89
- };
90
- }
91
-
92
- return {
93
- path,
94
- state: "ready",
95
- ok: true,
96
- skillCount,
97
- message: `vault ready: ${path} (${skillCount} ${skillCount === 1 ? "skill" : "skills"})`,
98
- };
99
- }
100
-
101
- export function planConfigInit(configPath: string, vaultPath: string): ConfigInitPlan {
102
- if (existsSync(configPath)) {
103
- return { configPath, vaultPath, action: "preserve" };
104
- }
105
-
106
- const vaultHealth = inspectVault(vaultPath);
107
- if (!vaultHealth.ok) {
108
- throw new Error(vaultHealth.message);
109
- }
110
-
111
- return {
112
- configPath,
113
- vaultPath,
114
- action: "create",
115
- content: `vault_path = ${JSON.stringify(vaultPath)}\n`,
116
- };
117
- }
118
-
119
- export function applyConfigInit(plan: ConfigInitPlan): "created" | "preserved" {
120
- if (plan.action === "preserve" || existsSync(plan.configPath)) {
121
- return "preserved";
122
- }
123
-
124
- mkdirSync(dirname(plan.configPath), { recursive: true });
125
- const temporaryPath = join(
126
- dirname(plan.configPath),
127
- `.${basename(plan.configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
128
- );
129
- writeFileSync(temporaryPath, plan.content as string, { encoding: "utf8", mode: 0o600 });
130
- try {
131
- linkSync(temporaryPath, plan.configPath);
132
- return "created";
133
- } catch (error) {
134
- if ((error as NodeJS.ErrnoException).code === "EEXIST") {
135
- return "preserved";
136
- }
137
- throw error;
138
- } finally {
139
- unlinkSync(temporaryPath);
140
- }
141
- }
142
-
143
- export function rollbackConfigInit(plan: ConfigInitPlan): void {
144
- if (plan.action === "create") rmSync(plan.configPath, { force: true });
145
- }
package/src/snapshot.ts DELETED
@@ -1,135 +0,0 @@
1
- import type { Database } from "bun:sqlite";
2
- import { expandHome } from "./config";
3
- import { openIndex } from "./db";
4
- import type { Clients, Config } from "./types";
5
-
6
- // ---------------------------------------------------------------------------
7
- // Immutable runtime snapshot (AC11)
8
- // ---------------------------------------------------------------------------
9
-
10
- /**
11
- * An immutable, reference-counted view of runtime resources.
12
- * All fields are frozen at construction — callers can never mutate the snapshot.
13
- * The underlying db handle is closed only after every holder calls release().
14
- */
15
- export interface RuntimeSnapshot {
16
- readonly config: Config;
17
- readonly clients: Clients;
18
- readonly db: Database;
19
- }
20
-
21
- /**
22
- * A handle returned by RuntimeSnapshotManager.acquire().
23
- * Call release() exactly once when the request (or operation) is done.
24
- */
25
- export interface SnapshotHandle {
26
- readonly snapshot: RuntimeSnapshot;
27
- release(): void;
28
- }
29
-
30
- // ---------------------------------------------------------------------------
31
- // Internal reference-counted slot
32
- // ---------------------------------------------------------------------------
33
-
34
- class SnapshotSlot {
35
- private refCount = 0;
36
- private closed = false;
37
- readonly snapshot: RuntimeSnapshot;
38
-
39
- constructor(config: Config, clients: Clients) {
40
- const stateDir = expandHome(config.state_dir);
41
- const db = openIndex(stateDir);
42
- // Freeze the snapshot object so callers cannot mutate it
43
- this.snapshot = Object.freeze({ config, clients, db });
44
- }
45
-
46
- acquire(): SnapshotHandle {
47
- this.refCount++;
48
- let released = false;
49
- return {
50
- snapshot: this.snapshot,
51
- release: () => {
52
- if (released) return; // idempotent
53
- released = true;
54
- this.refCount--;
55
- this.maybeClose();
56
- },
57
- };
58
- }
59
-
60
- /** Signal that no new acquires will come from the manager for this slot. */
61
- retire(): void {
62
- this.closed = true;
63
- this.maybeClose();
64
- }
65
-
66
- private maybeClose(): void {
67
- if (this.closed && this.refCount === 0) {
68
- try {
69
- this.snapshot.db.close();
70
- } catch {
71
- // already closed — idempotent
72
- }
73
- }
74
- }
75
- }
76
-
77
- // ---------------------------------------------------------------------------
78
- // Public manager
79
- // ---------------------------------------------------------------------------
80
-
81
- /**
82
- * RuntimeSnapshotManager holds one active SnapshotSlot at a time.
83
- *
84
- * - acquire() → returns a SnapshotHandle for the current slot; increments its refcount.
85
- * - replace() → installs a new slot; retires the previous one (closed when all
86
- * in-flight holders release, i.e. no handle leak).
87
- * - dispose() → retires the current slot and blocks future acquires.
88
- */
89
- export class RuntimeSnapshotManager {
90
- private current: SnapshotSlot;
91
- private disposed = false;
92
-
93
- private constructor(config: Config, clients: Clients) {
94
- this.current = new SnapshotSlot(config, clients);
95
- }
96
-
97
- static create(config: Config, clients: Clients): RuntimeSnapshotManager {
98
- return new RuntimeSnapshotManager(config, clients);
99
- }
100
-
101
- /**
102
- * Acquire a handle to the current snapshot.
103
- * The caller MUST call release() when done — even on error paths.
104
- */
105
- acquire(): SnapshotHandle {
106
- if (this.disposed) {
107
- throw new Error("RuntimeSnapshotManager has been disposed");
108
- }
109
- return this.current.acquire();
110
- }
111
-
112
- /**
113
- * Swap in a new configuration and clients.
114
- * The previous slot is retired and will close its db handle once all
115
- * in-flight holders release.
116
- */
117
- replace(config: Config, clients: Clients): void {
118
- if (this.disposed) {
119
- throw new Error("RuntimeSnapshotManager has been disposed");
120
- }
121
- const outgoing = this.current;
122
- this.current = new SnapshotSlot(config, clients);
123
- outgoing.retire(); // closes when refCount reaches zero
124
- }
125
-
126
- /**
127
- * Permanently shut down. Retires the current slot.
128
- * Any future acquire() will throw.
129
- */
130
- dispose(): void {
131
- if (this.disposed) return;
132
- this.disposed = true;
133
- this.current.retire();
134
- }
135
- }
package/src/stats.ts DELETED
@@ -1,340 +0,0 @@
1
- import type { Database } from "bun:sqlite";
2
- import type { AuditCandidate, AuditRow, FetchAuditRow } from "./types";
3
-
4
- export const SINCE_PATTERN = /^(\d+[hdwmy]|\d{4}-\d{2}-\d{2}([T ].+)?)$/;
5
-
6
- export interface SkillStat {
7
- skill_id: string;
8
- candidate_count: number;
9
- }
10
-
11
- export interface EmptyShortlistQuery {
12
- query: string;
13
- count: number;
14
- }
15
-
16
- export interface RetrievalTotals {
17
- exact: number;
18
- reranked: number;
19
- hybrid: number;
20
- lexical: number;
21
- }
22
-
23
- export type AcceptanceSignal =
24
- | { available: false; uncorrelated_fetch_count: number }
25
- | {
26
- available: true;
27
- resolves_with_candidates: number;
28
- accepted_count: number;
29
- acceptance_rate: number;
30
- observed_mrr: number;
31
- top1_acceptance_rate: number;
32
- uncorrelated_fetch_count: number;
33
- };
34
-
35
- export interface StatsResponse {
36
- since: string;
37
- until: string;
38
- total_requests: number;
39
- empty_shortlist_count: number;
40
- empty_shortlist_rate: number;
41
- retrieval_totals: RetrievalTotals;
42
- degraded_count: number;
43
- average_latency_ms: number;
44
- skills: SkillStat[];
45
- top_empty_shortlist_queries: EmptyShortlistQuery[];
46
- acceptance: AcceptanceSignal;
47
- top_unused_shortlist_queries: EmptyShortlistQuery[];
48
- }
49
-
50
- const RELATIVE_WINDOW = /^(\d+)([hdwmy])$/;
51
- const UNIT_MS: Record<string, number> = {
52
- h: 3_600_000,
53
- d: 86_400_000,
54
- w: 604_800_000,
55
- m: 2_592_000_000,
56
- y: 31_536_000_000,
57
- };
58
-
59
- export function parseSince(since: string, now: Date = new Date()): Date {
60
- if (!SINCE_PATTERN.test(since)) throw new Error(`invalid --since window: ${since}`);
61
-
62
- const relative = RELATIVE_WINDOW.exec(since);
63
- if (relative) {
64
- const amount = Number(relative[1]);
65
- const unitMs = UNIT_MS[relative[2]!]!;
66
- return new Date(now.getTime() - amount * unitMs);
67
- }
68
-
69
- const parsed = new Date(since);
70
- if (Number.isNaN(parsed.getTime())) throw new Error(`invalid --since window: ${since}`);
71
- return parsed;
72
- }
73
-
74
- function compareCodeUnits(a: string, b: string): number {
75
- if (a < b) return -1;
76
- if (a > b) return 1;
77
- return 0;
78
- }
79
-
80
- export function computeStats(
81
- rows: AuditRow[],
82
- since: Date,
83
- until: Date,
84
- fetchRows: FetchAuditRow[] = [],
85
- ): StatsResponse {
86
- const retrieval_totals: RetrievalTotals = { exact: 0, reranked: 0, hybrid: 0, lexical: 0 };
87
- const skillCounts = new Map<string, number>();
88
- const emptyShortlistCounts = new Map<string, number>();
89
- const resolvesWithCandidates = new Map<number, AuditRow>();
90
- let empty_shortlist_count = 0;
91
- let degraded_count = 0;
92
- let total_latency_ms = 0;
93
-
94
- for (const row of rows) {
95
- if (row.retrieval in retrieval_totals) {
96
- retrieval_totals[row.retrieval]++;
97
- }
98
-
99
- if (row.degraded_from || row.degradation_reason) {
100
- degraded_count++;
101
- }
102
-
103
- total_latency_ms += row.latency_ms;
104
-
105
- if (row.candidates.length === 0) {
106
- empty_shortlist_count++;
107
- emptyShortlistCounts.set(row.query, (emptyShortlistCounts.get(row.query) ?? 0) + 1);
108
- } else {
109
- resolvesWithCandidates.set(row.id, row);
110
- const seenInRow = new Set<string>();
111
- for (const candidate of row.candidates) {
112
- if (!candidate.skill_id) continue;
113
- if (seenInRow.has(candidate.skill_id)) continue;
114
- seenInRow.add(candidate.skill_id);
115
- skillCounts.set(candidate.skill_id, (skillCounts.get(candidate.skill_id) ?? 0) + 1);
116
- }
117
- }
118
- }
119
-
120
- let uncorrelated_fetch_count = 0;
121
- const firstFetchByResolve = new Map<number, FetchAuditRow>();
122
- for (const fetch of fetchRows) {
123
- if (fetch.resolve_audit_id === null) {
124
- uncorrelated_fetch_count++;
125
- continue;
126
- }
127
- if (!resolvesWithCandidates.has(fetch.resolve_audit_id)) continue;
128
- const existing = firstFetchByResolve.get(fetch.resolve_audit_id);
129
- if (!existing || fetch.ts < existing.ts) {
130
- firstFetchByResolve.set(fetch.resolve_audit_id, fetch);
131
- }
132
- }
133
-
134
- const acceptedResolveIds = new Set(firstFetchByResolve.keys());
135
- const accepted_count = acceptedResolveIds.size;
136
- const acceptance: AcceptanceSignal =
137
- accepted_count > 0
138
- ? (() => {
139
- let reciprocalRankSum = 0;
140
- let top1Count = 0;
141
- for (const fetch of firstFetchByResolve.values()) {
142
- const rank = fetch.rank_at_resolve;
143
- if (rank !== null) {
144
- reciprocalRankSum += 1 / rank;
145
- if (rank === 1) top1Count++;
146
- }
147
- }
148
- return {
149
- available: true as const,
150
- resolves_with_candidates: resolvesWithCandidates.size,
151
- accepted_count,
152
- acceptance_rate: accepted_count / resolvesWithCandidates.size,
153
- observed_mrr: reciprocalRankSum / accepted_count,
154
- top1_acceptance_rate: top1Count / accepted_count,
155
- uncorrelated_fetch_count,
156
- };
157
- })()
158
- : { available: false as const, uncorrelated_fetch_count };
159
-
160
- const unusedShortlistCounts = new Map<string, number>();
161
- for (const [id, row] of resolvesWithCandidates) {
162
- if (acceptedResolveIds.has(id)) continue;
163
- unusedShortlistCounts.set(row.query, (unusedShortlistCounts.get(row.query) ?? 0) + 1);
164
- }
165
-
166
- const total_requests = rows.length;
167
- const empty_shortlist_rate = total_requests > 0 ? empty_shortlist_count / total_requests : 0;
168
- const average_latency_ms = total_requests > 0 ? total_latency_ms / total_requests : 0;
169
-
170
- const skills: SkillStat[] = [...skillCounts.entries()]
171
- .map(([skill_id, candidate_count]) => ({ skill_id, candidate_count }))
172
- .sort((a, b) => {
173
- if (b.candidate_count !== a.candidate_count) {
174
- return b.candidate_count - a.candidate_count;
175
- }
176
- return compareCodeUnits(a.skill_id, b.skill_id);
177
- });
178
-
179
- const top_empty_shortlist_queries = topQueryCounts(emptyShortlistCounts);
180
- const top_unused_shortlist_queries = topQueryCounts(unusedShortlistCounts);
181
-
182
- return {
183
- since: since.toISOString(),
184
- until: until.toISOString(),
185
- total_requests,
186
- empty_shortlist_count,
187
- empty_shortlist_rate,
188
- retrieval_totals,
189
- degraded_count,
190
- average_latency_ms,
191
- skills,
192
- top_empty_shortlist_queries,
193
- acceptance,
194
- top_unused_shortlist_queries,
195
- };
196
- }
197
-
198
- function topQueryCounts(counts: Map<string, number>): EmptyShortlistQuery[] {
199
- return [...counts.entries()]
200
- .map(([query, count]) => ({ query, count }))
201
- .sort((a, b) => {
202
- if (b.count !== a.count) {
203
- return b.count - a.count;
204
- }
205
- return compareCodeUnits(a.query, b.query);
206
- })
207
- .slice(0, 20);
208
- }
209
-
210
- interface AuditTableRow {
211
- id: number;
212
- ts: string;
213
- request_id: string | null;
214
- query: string;
215
- retrieval: AuditRow["retrieval"];
216
- degraded_from: string | null;
217
- degradation_reason: string | null;
218
- candidates: string;
219
- latency_ms: number;
220
- }
221
-
222
- export function queryAuditRows(db: Database, sinceIso: string): AuditRow[] {
223
- const rows = db
224
- .query(
225
- "SELECT id, ts, request_id, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms FROM audit WHERE ts >= ? ORDER BY ts ASC",
226
- )
227
- .all(sinceIso) as AuditTableRow[];
228
-
229
- return rows.map((row) => {
230
- let parsed: unknown;
231
- try {
232
- parsed = JSON.parse(row.candidates);
233
- } catch {
234
- throw new Error(`Failed to parse candidates JSON for audit row ${row.id}`);
235
- }
236
- if (!Array.isArray(parsed)) {
237
- throw new Error(`Invalid candidates JSON for audit row ${row.id}: expected array, got ${typeof parsed}`);
238
- }
239
- const candidates: AuditCandidate[] = parsed.map((c: any, index: number) => {
240
- if (!c || typeof c !== "object" || Array.isArray(c) || typeof c.skill_id !== "string") {
241
- throw new Error(`Invalid candidate at index ${index} for audit row ${row.id}: missing or invalid skill_id`);
242
- }
243
- const score = c.score;
244
- if (score !== null && (typeof score !== "number" || !Number.isFinite(score))) {
245
- throw new Error(`Invalid candidate at index ${index} for audit row ${row.id}: missing or invalid score`);
246
- }
247
- return {
248
- skill_id: c.skill_id,
249
- score,
250
- };
251
- });
252
-
253
- const result: AuditRow = {
254
- id: row.id,
255
- ts: row.ts,
256
- request_id: row.request_id,
257
- query: row.query,
258
- retrieval: row.retrieval,
259
- candidates,
260
- latency_ms: row.latency_ms,
261
- };
262
- if (row.degraded_from !== null && row.degraded_from !== undefined) {
263
- result.degraded_from = row.degraded_from as AuditRow["degraded_from"];
264
- }
265
- if (row.degradation_reason !== null && row.degradation_reason !== undefined) {
266
- result.degradation_reason = row.degradation_reason as AuditRow["degradation_reason"];
267
- }
268
- return result;
269
- });
270
- }
271
-
272
- export function queryFetchRows(db: Database, sinceIso: string): FetchAuditRow[] {
273
- return db
274
- .query(
275
- "SELECT id, ts, skill_id, request_id, resolve_audit_id, rank_at_resolve FROM fetch WHERE ts >= ? ORDER BY ts ASC",
276
- )
277
- .all(sinceIso) as FetchAuditRow[];
278
- }
279
-
280
- export function getStats(db: Database, since: string, now: Date = new Date()): StatsResponse {
281
- const sinceDate = parseSince(since, now);
282
- const rows = queryAuditRows(db, sinceDate.toISOString());
283
- const fetchRows = queryFetchRows(db, sinceDate.toISOString());
284
- return computeStats(rows, sinceDate, now, fetchRows);
285
- }
286
-
287
- export function renderStatsText(stats: StatsResponse): string {
288
- const lines: string[] = [];
289
- lines.push(`window: ${stats.since} .. ${stats.until}`);
290
- lines.push(
291
- `requests: total=${stats.total_requests} empty_shortlist=${stats.empty_shortlist_count} ` +
292
- `(empty_shortlist_rate=${stats.empty_shortlist_rate.toFixed(3)}) ` +
293
- `degraded=${stats.degraded_count} avg_latency_ms=${stats.average_latency_ms.toFixed(1)}`,
294
- );
295
- lines.push(
296
- `retrieval: exact=${stats.retrieval_totals.exact} reranked=${stats.retrieval_totals.reranked} ` +
297
- `hybrid=${stats.retrieval_totals.hybrid} lexical=${stats.retrieval_totals.lexical}`,
298
- );
299
-
300
- lines.push("skills:");
301
- if (stats.skills.length === 0) {
302
- lines.push(" (none)");
303
- } else {
304
- for (const skill of stats.skills) {
305
- lines.push(` ${skill.skill_id} candidate=${skill.candidate_count}`);
306
- }
307
- }
308
-
309
- lines.push("top empty shortlist queries:");
310
- if (stats.top_empty_shortlist_queries.length === 0) {
311
- lines.push(" (none)");
312
- } else {
313
- for (const entry of stats.top_empty_shortlist_queries) {
314
- lines.push(` "${entry.query}" (${entry.count})`);
315
- }
316
- }
317
-
318
- if (stats.acceptance.available) {
319
- lines.push(
320
- `acceptance: acceptance_rate=${stats.acceptance.acceptance_rate.toFixed(3)} ` +
321
- `observed_mrr=${stats.acceptance.observed_mrr.toFixed(3)} ` +
322
- `top1_acceptance_rate=${stats.acceptance.top1_acceptance_rate.toFixed(3)} ` +
323
- `(accepted=${stats.acceptance.accepted_count}/${stats.acceptance.resolves_with_candidates}, ` +
324
- `uncorrelated_fetch_count=${stats.acceptance.uncorrelated_fetch_count})`,
325
- );
326
- } else {
327
- lines.push(`acceptance: unavailable (uncorrelated_fetch_count=${stats.acceptance.uncorrelated_fetch_count})`);
328
- }
329
-
330
- lines.push("top unused shortlist queries:");
331
- if (stats.top_unused_shortlist_queries.length === 0) {
332
- lines.push(" (none)");
333
- } else {
334
- for (const entry of stats.top_unused_shortlist_queries) {
335
- lines.push(` "${entry.query}" (${entry.count})`);
336
- }
337
- }
338
-
339
- return lines.join("\n");
340
- }