@klhapp/skillmux 1.9.2 → 1.10.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 +30 -0
- package/README.md +1 -1
- package/docs/README.md +1 -1
- package/docs/cli.md +74 -3
- package/docs/concepts.md +1 -1
- package/docs/configuration.md +52 -1
- package/docs/deployment.md +10 -6
- package/docs/getting-started.md +1 -1
- package/docs/skill-management.md +49 -0
- package/package.json +1 -1
- package/src/adapters.ts +148 -2
- package/src/cli.ts +302 -1291
- package/src/clients.ts +17 -0
- package/src/commands/audit.ts +54 -51
- package/src/commands/config.ts +11 -12
- package/src/commands/context.ts +103 -0
- package/src/commands/core.ts +5 -1
- package/src/commands/doctor.ts +76 -0
- package/src/commands/eval.ts +10 -13
- package/src/commands/init.ts +621 -0
- package/src/commands/install.ts +132 -0
- package/src/commands/local-vault.ts +60 -0
- package/src/commands/models.ts +10 -0
- package/src/commands/outdated.ts +8 -5
- package/src/commands/project.ts +37 -11
- package/src/commands/report.ts +66 -0
- package/src/commands/scan.ts +61 -0
- package/src/commands/skill.ts +32 -0
- package/src/commands/sync.ts +232 -0
- package/src/commands/target.ts +18 -6
- package/src/commands/update.ts +11 -5
- package/src/concurrency-limiter.ts +61 -0
- package/src/config-service.ts +1 -51
- package/src/config.ts +5 -0
- package/src/context.ts +8 -3
- package/src/db-audit.ts +286 -0
- package/src/db-index.ts +238 -0
- package/src/db.ts +3 -413
- package/src/global-flags.ts +46 -0
- package/src/install.ts +15 -0
- package/src/logger.ts +26 -0
- package/src/output.ts +30 -5
- package/src/redact.ts +52 -0
- package/src/router-core.ts +8 -27
- package/src/server.ts +594 -267
- package/src/toml-writer.ts +51 -0
- package/src/types.ts +7 -0
package/src/db.ts
CHANGED
|
@@ -1,413 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import type { AuditCandidate, AuditRow } from "./types";
|
|
5
|
-
import type { VaultSkill } from "./vault";
|
|
6
|
-
|
|
7
|
-
export interface SkillRow {
|
|
8
|
-
skill_id: string;
|
|
9
|
-
title: string;
|
|
10
|
-
description: string;
|
|
11
|
-
aliases: string;
|
|
12
|
-
content_sha256: string;
|
|
13
|
-
}
|
|
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
|
-
|
|
90
|
-
export function openIndex(stateDir: string): Database {
|
|
91
|
-
mkdirSync(stateDir, { recursive: true });
|
|
92
|
-
const db = new Database(join(stateDir, "index.sqlite3"), { create: true });
|
|
93
|
-
db.run("PRAGMA journal_mode = WAL");
|
|
94
|
-
db.run("PRAGMA busy_timeout = 2000");
|
|
95
|
-
db.run(`CREATE TABLE IF NOT EXISTS skills (
|
|
96
|
-
skill_id TEXT PRIMARY KEY,
|
|
97
|
-
title TEXT NOT NULL,
|
|
98
|
-
description TEXT NOT NULL,
|
|
99
|
-
aliases TEXT NOT NULL,
|
|
100
|
-
content_sha256 TEXT NOT NULL
|
|
101
|
-
)`);
|
|
102
|
-
db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
|
|
103
|
-
skill_id UNINDEXED, title, description, aliases,
|
|
104
|
-
tokenize = 'unicode61 remove_diacritics 2'
|
|
105
|
-
)`);
|
|
106
|
-
db.run(`CREATE TABLE IF NOT EXISTS vectors (
|
|
107
|
-
skill_id TEXT PRIMARY KEY,
|
|
108
|
-
content_sha256 TEXT NOT NULL,
|
|
109
|
-
embedding_fingerprint TEXT NOT NULL DEFAULT '',
|
|
110
|
-
dim INTEGER NOT NULL,
|
|
111
|
-
vec BLOB NOT NULL
|
|
112
|
-
)`);
|
|
113
|
-
const vectorColumns = db.query("PRAGMA table_info(vectors)").all() as { name: string }[];
|
|
114
|
-
if (!vectorColumns.some((column) => column.name === "embedding_fingerprint")) {
|
|
115
|
-
db.run("ALTER TABLE vectors ADD COLUMN embedding_fingerprint TEXT NOT NULL DEFAULT ''");
|
|
116
|
-
}
|
|
117
|
-
// Audit rows live in audit.sqlite3; openAudit adopts any left here.
|
|
118
|
-
db.run(`CREATE TABLE IF NOT EXISTS index_meta (
|
|
119
|
-
key TEXT PRIMARY KEY,
|
|
120
|
-
value TEXT NOT NULL
|
|
121
|
-
)`);
|
|
122
|
-
return db;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function upsertSkill(db: Database, skill: VaultSkill): void {
|
|
126
|
-
const aliases = skill.aliases.join(" ");
|
|
127
|
-
db.transaction(() => {
|
|
128
|
-
db.run("DELETE FROM skills WHERE skill_id = ?", [skill.skill_id]);
|
|
129
|
-
db.run("DELETE FROM skills_fts WHERE skill_id = ?", [skill.skill_id]);
|
|
130
|
-
db.run(
|
|
131
|
-
"INSERT INTO skills (skill_id, title, description, aliases, content_sha256) VALUES (?, ?, ?, ?, ?)",
|
|
132
|
-
[skill.skill_id, skill.title, skill.description, aliases, skill.content_sha256],
|
|
133
|
-
);
|
|
134
|
-
db.run(
|
|
135
|
-
"INSERT INTO skills_fts (skill_id, title, description, aliases) VALUES (?, ?, ?, ?)",
|
|
136
|
-
[skill.skill_id, skill.title, skill.description, aliases],
|
|
137
|
-
);
|
|
138
|
-
})();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function toSkillRow(skill: VaultSkill): SkillRow {
|
|
142
|
-
return {
|
|
143
|
-
skill_id: skill.skill_id,
|
|
144
|
-
title: skill.title,
|
|
145
|
-
description: skill.description,
|
|
146
|
-
aliases: skill.aliases.join(" "),
|
|
147
|
-
content_sha256: skill.content_sha256,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Replace the whole lexical index with `rows`; drops vectors of removed skills. */
|
|
152
|
-
export function replaceSkills(db: Database, rows: SkillRow[]): void {
|
|
153
|
-
db.transaction(() => {
|
|
154
|
-
db.run("DELETE FROM skills");
|
|
155
|
-
db.run("DELETE FROM skills_fts");
|
|
156
|
-
for (const row of rows) {
|
|
157
|
-
db.run(
|
|
158
|
-
"INSERT INTO skills (skill_id, title, description, aliases, content_sha256) VALUES (?, ?, ?, ?, ?)",
|
|
159
|
-
[row.skill_id, row.title, row.description, row.aliases, row.content_sha256],
|
|
160
|
-
);
|
|
161
|
-
db.run(
|
|
162
|
-
"INSERT INTO skills_fts (skill_id, title, description, aliases) VALUES (?, ?, ?, ?)",
|
|
163
|
-
[row.skill_id, row.title, row.description, row.aliases],
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
db.run("DELETE FROM vectors WHERE skill_id NOT IN (SELECT skill_id FROM skills)");
|
|
167
|
-
})();
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function ingestVault(db: Database, skills: VaultSkill[]): void {
|
|
171
|
-
replaceSkills(db, skills.map(toSkillRow));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function deleteSkill(db: Database, skillId: string): void {
|
|
175
|
-
db.transaction(() => {
|
|
176
|
-
db.run("DELETE FROM skills WHERE skill_id = ?", [skillId]);
|
|
177
|
-
db.run("DELETE FROM skills_fts WHERE skill_id = ?", [skillId]);
|
|
178
|
-
db.run("DELETE FROM vectors WHERE skill_id = ?", [skillId]);
|
|
179
|
-
})();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function skillCount(db: Database): number {
|
|
183
|
-
return (db.query("SELECT count(*) AS n FROM skills").get() as { n: number }).n;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export function getSkillRow(db: Database, skillId: string): SkillRow | null {
|
|
187
|
-
return db.query("SELECT * FROM skills WHERE skill_id = ?").get(skillId) as SkillRow | null;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Sanitize free text into an FTS5 OR-query; returns null when no usable terms
|
|
192
|
-
* remain. Terms keep any Unicode letters/digits (CJK included) so non-ASCII
|
|
193
|
-
* queries still get lexical recall — unicode61 tokenizes contiguous CJK runs
|
|
194
|
-
* as single tokens, so matching works at that granularity.
|
|
195
|
-
*/
|
|
196
|
-
export function toFtsQuery(text: string): string | null {
|
|
197
|
-
const terms = [
|
|
198
|
-
...new Set(
|
|
199
|
-
text
|
|
200
|
-
.toLowerCase()
|
|
201
|
-
.split(/[^\p{L}\p{N}]+/u)
|
|
202
|
-
.filter((t) => t.length >= 2),
|
|
203
|
-
),
|
|
204
|
-
];
|
|
205
|
-
if (terms.length === 0) return null;
|
|
206
|
-
return terms.map((t) => `"${t}"`).join(" OR ");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export function ftsSearch(db: Database, text: string, k: number): SkillRow[] {
|
|
210
|
-
const query = toFtsQuery(text);
|
|
211
|
-
if (query === null) return [];
|
|
212
|
-
return db
|
|
213
|
-
.query(
|
|
214
|
-
`SELECT s.* FROM skills_fts f
|
|
215
|
-
JOIN skills s ON s.skill_id = f.skill_id
|
|
216
|
-
WHERE skills_fts MATCH ?
|
|
217
|
-
ORDER BY bm25(skills_fts) LIMIT ?`,
|
|
218
|
-
)
|
|
219
|
-
.all(query, k) as SkillRow[];
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export function findExactMatch(db: Database, query: string): SkillRow | null {
|
|
223
|
-
const cleanQuery = query.trim().toLowerCase();
|
|
224
|
-
return db
|
|
225
|
-
.query(
|
|
226
|
-
`SELECT * FROM skills
|
|
227
|
-
WHERE lower(skill_id) = ?
|
|
228
|
-
OR lower(title) = ?
|
|
229
|
-
OR ' ' || lower(aliases) || ' ' LIKE ?`,
|
|
230
|
-
)
|
|
231
|
-
.get(cleanQuery, cleanQuery, `% ${cleanQuery} %`) as SkillRow | null;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function getIndexMeta(db: Database, key: string): string | null {
|
|
235
|
-
const row = db
|
|
236
|
-
.query("SELECT value FROM index_meta WHERE key = ?")
|
|
237
|
-
.get(key) as { value: string } | null;
|
|
238
|
-
return row ? row.value : null;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export function setIndexMeta(db: Database, key: string, value: string): void {
|
|
242
|
-
db.run(
|
|
243
|
-
`INSERT INTO index_meta (key, value) VALUES (?, ?)
|
|
244
|
-
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
245
|
-
[key, value],
|
|
246
|
-
);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export function upsertVector(
|
|
250
|
-
db: Database,
|
|
251
|
-
skillId: string,
|
|
252
|
-
contentSha256: string,
|
|
253
|
-
embeddingFingerprint: string,
|
|
254
|
-
vec: Float32Array,
|
|
255
|
-
): void {
|
|
256
|
-
db.run(
|
|
257
|
-
`INSERT INTO vectors (skill_id, content_sha256, embedding_fingerprint, dim, vec) VALUES (?, ?, ?, ?, ?)
|
|
258
|
-
ON CONFLICT(skill_id) DO UPDATE SET
|
|
259
|
-
content_sha256 = excluded.content_sha256,
|
|
260
|
-
embedding_fingerprint = excluded.embedding_fingerprint,
|
|
261
|
-
dim = excluded.dim,
|
|
262
|
-
vec = excluded.vec`,
|
|
263
|
-
[skillId, contentSha256, embeddingFingerprint, vec.length, new Uint8Array(vec.buffer, vec.byteOffset, vec.byteLength)],
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Skills with no usable stored vector: none at all, content changed since
|
|
269
|
-
* embedding, or embedded at a different dimension than currently configured.
|
|
270
|
-
*/
|
|
271
|
-
export function skillsNeedingVectors(db: Database, dimension: number, embeddingFingerprint: string): SkillRow[] {
|
|
272
|
-
return db
|
|
273
|
-
.query(
|
|
274
|
-
`SELECT s.* FROM skills s
|
|
275
|
-
LEFT JOIN vectors v ON v.skill_id = s.skill_id
|
|
276
|
-
AND v.content_sha256 = s.content_sha256
|
|
277
|
-
AND v.dim = ?
|
|
278
|
-
AND v.embedding_fingerprint = ?
|
|
279
|
-
WHERE v.skill_id IS NULL`,
|
|
280
|
-
)
|
|
281
|
-
.all(dimension, embeddingFingerprint) as SkillRow[];
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function cosine(a: Float32Array, b: Float32Array): number {
|
|
285
|
-
if (a.length !== b.length) return 0;
|
|
286
|
-
let dot = 0;
|
|
287
|
-
let normA = 0;
|
|
288
|
-
let normB = 0;
|
|
289
|
-
for (let i = 0; i < a.length; i++) {
|
|
290
|
-
dot += a[i]! * b[i]!;
|
|
291
|
-
normA += a[i]! * a[i]!;
|
|
292
|
-
normB += b[i]! * b[i]!;
|
|
293
|
-
}
|
|
294
|
-
if (normA === 0 || normB === 0) return 0;
|
|
295
|
-
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/** Brute-force cosine over every stored vector (vault is ~100 skills; no ANN). */
|
|
299
|
-
export function vectorTopK(db: Database, query: Float32Array, k: number): SkillRow[] {
|
|
300
|
-
const rows = db
|
|
301
|
-
.query(
|
|
302
|
-
`SELECT s.skill_id, s.title, s.description, s.aliases, s.content_sha256, v.vec
|
|
303
|
-
FROM vectors v JOIN skills s ON s.skill_id = v.skill_id`,
|
|
304
|
-
)
|
|
305
|
-
.all() as (SkillRow & { vec: Uint8Array })[];
|
|
306
|
-
return rows
|
|
307
|
-
.map(({ vec, ...row }) => ({
|
|
308
|
-
row,
|
|
309
|
-
score: cosine(query, new Float32Array(vec.buffer, vec.byteOffset, vec.byteLength / 4)),
|
|
310
|
-
}))
|
|
311
|
-
.sort((a, b) => b.score - a.score)
|
|
312
|
-
.slice(0, k)
|
|
313
|
-
.map((r) => r.row);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
export interface AuditInsert {
|
|
317
|
-
ts: string;
|
|
318
|
-
request_id?: string | null;
|
|
319
|
-
query: string;
|
|
320
|
-
retrieval: AuditRow["retrieval"];
|
|
321
|
-
degraded_from?: string | null;
|
|
322
|
-
degradation_reason?: string | null;
|
|
323
|
-
candidates: AuditCandidate[];
|
|
324
|
-
latency_ms: number;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export function insertAudit(db: Database, row: AuditInsert): void {
|
|
328
|
-
db.run(
|
|
329
|
-
`INSERT INTO audit (ts, request_id, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
|
|
330
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
331
|
-
[
|
|
332
|
-
row.ts,
|
|
333
|
-
row.request_id ?? null,
|
|
334
|
-
row.query,
|
|
335
|
-
row.retrieval,
|
|
336
|
-
row.degraded_from ?? null,
|
|
337
|
-
row.degradation_reason ?? null,
|
|
338
|
-
JSON.stringify(row.candidates),
|
|
339
|
-
row.latency_ms,
|
|
340
|
-
],
|
|
341
|
-
);
|
|
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
|
-
}
|
|
1
|
+
// Barrel re-export — keeps all existing `import { X } from "./db"` working.
|
|
2
|
+
export * from "./db-index";
|
|
3
|
+
export * from "./db-audit";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type GlobalFlag =
|
|
2
|
+
| "--json"
|
|
3
|
+
| "--allow-insecure"
|
|
4
|
+
| "--verbose"
|
|
5
|
+
| "--dry-run";
|
|
6
|
+
|
|
7
|
+
export type GlobalFlagWithValue = "--context" | "--server";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Checks if an argument is a global flag that takes a value (e.g. `--context`, `--server`).
|
|
11
|
+
* If an `allowed` list is provided, only flags in that subset are matched.
|
|
12
|
+
*/
|
|
13
|
+
export function isGlobalFlagWithValue(
|
|
14
|
+
option: string | undefined,
|
|
15
|
+
allowed?: readonly GlobalFlagWithValue[] | GlobalFlagWithValue,
|
|
16
|
+
...rest: GlobalFlagWithValue[]
|
|
17
|
+
): boolean {
|
|
18
|
+
if (!option) return false;
|
|
19
|
+
if (allowed !== undefined) {
|
|
20
|
+
const list = Array.isArray(allowed) ? allowed : [allowed, ...rest];
|
|
21
|
+
return list.includes(option as GlobalFlagWithValue);
|
|
22
|
+
}
|
|
23
|
+
return option === "--context" || option === "--server";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Checks if an argument is a global flag that takes no value (e.g. `--json`, `--allow-insecure`, `--verbose`, `--dry-run`).
|
|
28
|
+
* If an `allowed` list is provided, only flags in that subset are matched.
|
|
29
|
+
*/
|
|
30
|
+
export function isGlobalFlag(
|
|
31
|
+
option: string | undefined,
|
|
32
|
+
allowed?: readonly GlobalFlag[] | GlobalFlag,
|
|
33
|
+
...rest: GlobalFlag[]
|
|
34
|
+
): boolean {
|
|
35
|
+
if (!option) return false;
|
|
36
|
+
if (allowed !== undefined) {
|
|
37
|
+
const list = Array.isArray(allowed) ? allowed : [allowed, ...rest];
|
|
38
|
+
return list.includes(option as GlobalFlag);
|
|
39
|
+
}
|
|
40
|
+
return (
|
|
41
|
+
option === "--json" ||
|
|
42
|
+
option === "--allow-insecure" ||
|
|
43
|
+
option === "--verbose" ||
|
|
44
|
+
option === "--dry-run"
|
|
45
|
+
);
|
|
46
|
+
}
|
package/src/install.ts
CHANGED
|
@@ -45,6 +45,21 @@ export function resolveRepoSource(repo: string): RepoSource {
|
|
|
45
45
|
return rest.length > 0 ? { url, skillPath: rest.join("/") } : { url };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function extractHost(url: string): string {
|
|
49
|
+
const scpMatch = url.match(/^[^/\s]+@([^/\s]+):/);
|
|
50
|
+
if (scpMatch) return scpMatch[1]!;
|
|
51
|
+
return new URL(url).hostname;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function assertHostAllowed(url: string, allowedHosts: string[] | undefined): void {
|
|
55
|
+
if (!allowedHosts || allowedHosts.length === 0) return;
|
|
56
|
+
if (isLocalFileUrl(url)) return;
|
|
57
|
+
const host = extractHost(url);
|
|
58
|
+
if (!allowedHosts.map((h) => h.toLowerCase()).includes(host.toLowerCase())) {
|
|
59
|
+
throw new Error(`refusing to fetch from host "${host}" — not in [egress] allowed_hosts`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
export function deriveRepoName(url: string): string {
|
|
49
64
|
const cleaned = url.replace(/\.git$/, "");
|
|
50
65
|
const segment = cleaned.split(/[/:]/).filter(Boolean).pop();
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized logging helpers, all writing to stderr via console.error.
|
|
3
|
+
* `log` emits structured JSON ({ level, stage, ...payload }) for machine-
|
|
4
|
+
* readable signals like router-core's retrieval-degradation warnings.
|
|
5
|
+
* `redactedErrorLog` builds a plain-text [prefix, message] pair for
|
|
6
|
+
* operator-facing runtime errors that need secret redaction.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const log = {
|
|
10
|
+
warn(stage: string, payload: Record<string, unknown>): void {
|
|
11
|
+
console.error(JSON.stringify({ level: "warn", stage, ...payload }));
|
|
12
|
+
},
|
|
13
|
+
error(stage: string, payload: Record<string, unknown>): void {
|
|
14
|
+
console.error(JSON.stringify({ level: "error", stage, ...payload }));
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Pairs a fixed log prefix with a redacted error message, for console.error. */
|
|
19
|
+
export function redactedErrorLog(
|
|
20
|
+
prefix: string,
|
|
21
|
+
err: unknown,
|
|
22
|
+
redact: (text: string) => string,
|
|
23
|
+
): [string, string] {
|
|
24
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
25
|
+
return [prefix, redact(msg)];
|
|
26
|
+
}
|
package/src/output.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ResolvedContext } from "./context";
|
|
2
2
|
|
|
3
3
|
export interface JsonEnvelope<T = any> {
|
|
4
4
|
schema_version: 1;
|
|
@@ -10,7 +10,7 @@ export interface JsonEnvelope<T = any> {
|
|
|
10
10
|
|
|
11
11
|
export function formatJsonEnvelope<T>(opts: {
|
|
12
12
|
ok: boolean;
|
|
13
|
-
target:
|
|
13
|
+
target: ResolvedContext | string | { name: string; server: string };
|
|
14
14
|
data?: T;
|
|
15
15
|
error?: { code: string; message: string; details?: any } | null;
|
|
16
16
|
}): JsonEnvelope<T> {
|
|
@@ -51,7 +51,7 @@ export class CliError extends Error {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export function emitSuccess<T>(
|
|
54
|
-
ctx: { isJson: boolean; target?:
|
|
54
|
+
ctx: { isJson: boolean; target?: ResolvedContext | string | { name: string; server: string } },
|
|
55
55
|
data: T,
|
|
56
56
|
renderText: () => void,
|
|
57
57
|
): void {
|
|
@@ -119,7 +119,32 @@ export function isInteractive(
|
|
|
119
119
|
return stdoutIsTTY === true && env.TERM !== "dumb";
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
/** Color is opt-out only: https://no-color.org, plus the same TTY check as isInteractive(). */
|
|
123
|
+
export function isColorEnabled(
|
|
124
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
125
|
+
stdoutIsTTY = process.stdout.isTTY,
|
|
126
|
+
): boolean {
|
|
127
|
+
if (env.NO_COLOR !== undefined) return false;
|
|
128
|
+
return isInteractive(env, stdoutIsTTY);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ANSI = { reset: "\x1b[0m", bold: "\x1b[1m", red: "\x1b[31m", yellow: "\x1b[33m", green: "\x1b[32m" } as const;
|
|
132
|
+
|
|
133
|
+
function paint(code: string, text: string): string {
|
|
134
|
+
return isColorEnabled() ? `${code}${text}${ANSI.reset}` : text;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const red = (text: string): string => paint(ANSI.red, text);
|
|
138
|
+
export const yellow = (text: string): string => paint(ANSI.yellow, text);
|
|
139
|
+
export const green = (text: string): string => paint(ANSI.green, text);
|
|
140
|
+
export const bold = (text: string): string => paint(ANSI.bold, text);
|
|
141
|
+
|
|
142
|
+
/** Prints a "warning: <line>" message to stderr, colored yellow when color is enabled. */
|
|
143
|
+
export function warn(line: string): void {
|
|
144
|
+
console.error(yellow(`warning: ${line}`));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function renderTargetBanner(target: ResolvedContext): void {
|
|
123
148
|
if (!isInteractive()) return;
|
|
124
149
|
if (target.type === "local") {
|
|
125
150
|
console.log(`Target: local`);
|
|
@@ -143,7 +168,7 @@ export function renderTable(columns: { key: string; header: string }[], rows: Re
|
|
|
143
168
|
const headerLine = columns.map((col) => col.header.padEnd(widths.get(col.key) ?? 0)).join(" ");
|
|
144
169
|
const sepLine = columns.map((col) => "-".repeat(widths.get(col.key) ?? 0)).join(" ");
|
|
145
170
|
|
|
146
|
-
console.log(headerLine);
|
|
171
|
+
console.log(bold(headerLine));
|
|
147
172
|
console.log(sepLine);
|
|
148
173
|
for (const row of rows) {
|
|
149
174
|
const line = columns.map((col) => String(row[col.key] ?? "").padEnd(widths.get(col.key) ?? 0)).join(" ");
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const PLACEHOLDER = "[REDACTED]";
|
|
2
|
+
|
|
3
|
+
// Strips userinfo (user:pass@) from URLs unconditionally, since a
|
|
4
|
+
// credential-bearing git URL (private-repo PAT auth) is typed by the user
|
|
5
|
+
// directly and never resolved from a config `*_env` key, so it can't be
|
|
6
|
+
// caught by the config-driven scrub below.
|
|
7
|
+
const URL_USERINFO = /:\/\/[^/\s@]*@/g;
|
|
8
|
+
|
|
9
|
+
function redactUrlCredentials(text: string): string {
|
|
10
|
+
return text.replace(URL_USERINFO, `://${PLACEHOLDER}@`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Walks the config tree for every string-valued key ending in `_env`
|
|
15
|
+
* (api_key_env, token_env, auth_token_env, and any future one) and resolves
|
|
16
|
+
* each to its current environment value.
|
|
17
|
+
*/
|
|
18
|
+
function collectSecretValues(value: unknown): string[] {
|
|
19
|
+
const secrets: string[] = [];
|
|
20
|
+
const visit = (node: unknown): void => {
|
|
21
|
+
if (Array.isArray(node)) {
|
|
22
|
+
for (const item of node) visit(item);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (node === null || typeof node !== "object") return;
|
|
26
|
+
for (const [key, val] of Object.entries(node as Record<string, unknown>)) {
|
|
27
|
+
if (key.endsWith("_env") && typeof val === "string") {
|
|
28
|
+
const resolved = process.env[val];
|
|
29
|
+
if (resolved) secrets.push(resolved);
|
|
30
|
+
} else {
|
|
31
|
+
visit(val);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
visit(value);
|
|
36
|
+
return secrets;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Builds a redactor bound to the currently effective config: a pure
|
|
41
|
+
* `(text) => text` closure that scrubs any resolved `*_env` secret value.
|
|
42
|
+
*/
|
|
43
|
+
export function buildRedactor(config: unknown): (text: string) => string {
|
|
44
|
+
const secrets = collectSecretValues(config);
|
|
45
|
+
return (text: string): string => {
|
|
46
|
+
let result = redactUrlCredentials(text);
|
|
47
|
+
for (const secret of secrets) {
|
|
48
|
+
result = result.split(secret).join(PLACEHOLDER);
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
}
|