@klhapp/skillmux 0.2.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/src/config.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { existsSync, mkdirSync, renameSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { z } from "zod";
5
+ import type { Config, ONNXDevice, ONNXDtype } from "./types";
6
+
7
+ const onnxDeviceSchema = z.enum([
8
+ "cpu", "auto", "gpu", "wasm", "webgpu", "cuda", "dml", "coreml",
9
+ "webnn", "webnn-npu", "webnn-gpu", "webnn-cpu",
10
+ ]);
11
+ const onnxDtypeSchema = z.enum([
12
+ "q8", "auto", "fp32", "fp16", "int8", "uint8", "q4", "bnb4", "q4f16",
13
+ "q2", "q2f16", "q1", "q1f16",
14
+ ]);
15
+
16
+ const modelSchema = z.object({
17
+ model: z.string().min(1),
18
+ device: onnxDeviceSchema.optional(),
19
+ dtype: onnxDtypeSchema.optional(),
20
+ }).strict();
21
+
22
+ const remoteThresholdsSchema = z.object({
23
+ match_score: z.number(),
24
+ match_margin: z.number().nonnegative(),
25
+ candidate_floor: z.number(),
26
+ }).strict();
27
+
28
+ const configSchema = z.object({
29
+ vault_path: z.string().min(1),
30
+ state_dir: z.string().min(1),
31
+ recall: z.object({ k_lexical: z.number().int().positive(), k_vector: z.number().int().positive() }).strict(),
32
+ thresholds: z.object({
33
+ candidate_limit: z.number().int().positive(),
34
+ match_score: z.number().optional(),
35
+ match_margin: z.number().nonnegative().optional(),
36
+ candidate_floor: z.number().optional(),
37
+ }).strict(),
38
+ inference: z.discriminatedUnion("mode", [
39
+ z.object({
40
+ mode: z.literal("local"),
41
+ bundle: z.string().min(1),
42
+ models_dir: z.string().min(1),
43
+ embedding: modelSchema.extend({ dimension: z.number().int().positive() }),
44
+ }).strict(),
45
+ z.object({
46
+ mode: z.literal("remote"),
47
+ timeout_ms: z.number().int().min(100),
48
+ embedding: z.object({
49
+ provider: z.literal("openai"),
50
+ base_url: z.url(),
51
+ model: z.string().min(1),
52
+ dimension: z.number().int().positive(),
53
+ api_key_env: z.string().min(1).optional(),
54
+ }).strict(),
55
+ reranker: z.object({
56
+ provider: z.literal("infinity"),
57
+ base_url: z.url(),
58
+ model: z.string().min(1),
59
+ api_key_env: z.string().min(1).optional(),
60
+ }).strict().optional(),
61
+ thresholds: remoteThresholdsSchema.optional(),
62
+ }).strict(),
63
+ ]),
64
+ server: z.object({
65
+ auth_enabled: z.boolean(),
66
+ auth_token_env: z.string().min(1),
67
+ allowed_origins: z.array(z.string()),
68
+ hostname: z.string().min(1).optional(),
69
+ rate_limit: z.object({
70
+ enabled: z.boolean(),
71
+ requests_per_minute: z.number().int().positive(),
72
+ trust_proxy: z.boolean().optional(),
73
+ }).strict().optional(),
74
+ }).strict().optional(),
75
+ }).strict();
76
+
77
+ // Fallback values only; a config.toml (SKILLMUX_CONFIG or default path)
78
+ // overrides them. The local bundle is the zero-config OSS path.
79
+ export const LOCAL_BUNDLE_ID = "gte-small-v1";
80
+
81
+ const DEFAULTS: Config = {
82
+ vault_path: "~/skills",
83
+ state_dir: "~/.local/state/skillmux",
84
+ recall: { k_lexical: 20, k_vector: 20 },
85
+ thresholds: { candidate_limit: 5 },
86
+ inference: {
87
+ mode: "local",
88
+ bundle: LOCAL_BUNDLE_ID,
89
+ models_dir: "~/.cache/skillmux/models",
90
+ embedding: {
91
+ model: "Xenova/gte-small",
92
+ dimension: 384,
93
+ device: "cpu",
94
+ dtype: "q8",
95
+ },
96
+ },
97
+ server: {
98
+ auth_enabled: false,
99
+ auth_token_env: "SKILLMUX_AUTH_TOKEN",
100
+ // Deny-by-default CORS: only requests that send no Origin header (curl,
101
+ // MCP clients, server-to-server) pass; browser-issued cross-origin
102
+ // requests are rejected until an origin is explicitly allow-listed.
103
+ allowed_origins: [],
104
+ // Loopback-only by default so a zero-config `skillmux serve --transport http`
105
+ // isn't reachable from the network. Docker overrides this to 0.0.0.0
106
+ // below since the container's own loopback isn't reachable through
107
+ // port-mapping.
108
+ hostname: "127.0.0.1",
109
+ rate_limit: {
110
+ enabled: false,
111
+ requests_per_minute: 60,
112
+ },
113
+ },
114
+ };
115
+
116
+ export const DEFAULT_CONFIG_PATH = "~/.config/skillmux/config.toml";
117
+
118
+ export function embeddingDimension(config: Config): number {
119
+ return config.inference.embedding.dimension;
120
+ }
121
+
122
+ export function embeddingFingerprint(config: Config): string {
123
+ const inference = config.inference;
124
+ const implementation =
125
+ inference.mode === "local" ? `local:${inference.bundle}` : `remote:${inference.embedding.provider}`;
126
+ return `${implementation}:${inference.embedding.model}:${inference.embedding.dimension}`;
127
+ }
128
+
129
+ export function expandHome(path: string): string {
130
+ return path.startsWith("~") ? join(homedir(), path.slice(1)) : path;
131
+ }
132
+
133
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
134
+ return typeof value === "object" && value !== null && !Array.isArray(value);
135
+ }
136
+
137
+ function deepMerge<T>(base: T, override: unknown): T {
138
+ if (!isPlainObject(base) || !isPlainObject(override)) {
139
+ return (override === undefined ? base : override) as T;
140
+ }
141
+ const out: Record<string, unknown> = { ...base };
142
+ for (const [key, value] of Object.entries(override)) {
143
+ out[key] = deepMerge((base as Record<string, unknown>)[key], value);
144
+ }
145
+ return out as T;
146
+ }
147
+
148
+ export const warnedEnv = new Set<string>();
149
+
150
+ function migrateLegacyDir(legacy: string, next: string): void {
151
+ const legacyPath = expandHome(legacy);
152
+ const nextPath = expandHome(next);
153
+ if (existsSync(nextPath) || !existsSync(legacyPath)) return;
154
+ mkdirSync(dirname(nextPath), { recursive: true });
155
+ renameSync(legacyPath, nextPath);
156
+ console.error(`skillmux: migrated ${legacyPath} -> ${nextPath}`);
157
+ }
158
+
159
+ export function migrateLegacyPaths(): void {
160
+ migrateLegacyDir("~/.config/skill-router", "~/.config/skillmux");
161
+ migrateLegacyDir("~/.local/state/skill-router", "~/.local/state/skillmux");
162
+ migrateLegacyDir("~/.cache/skill-router", "~/.cache/skillmux");
163
+ }
164
+
165
+ export async function loadConfig(path?: string): Promise<Config> {
166
+ migrateLegacyPaths();
167
+ let configEnv = process.env.SKILLMUX_CONFIG;
168
+ if (configEnv === undefined && process.env.SKILL_ROUTER_CONFIG !== undefined) {
169
+ if (!warnedEnv.has("SKILL_ROUTER_CONFIG")) {
170
+ warnedEnv.add("SKILL_ROUTER_CONFIG");
171
+ console.error("skillmux: SKILL_ROUTER_CONFIG is deprecated, use SKILLMUX_CONFIG instead");
172
+ }
173
+ configEnv = process.env.SKILL_ROUTER_CONFIG;
174
+ }
175
+ const configPath = path ?? configEnv ?? DEFAULT_CONFIG_PATH;
176
+ const file = Bun.file(expandHome(configPath));
177
+
178
+ const baseConfig = structuredClone(DEFAULTS);
179
+ if (process.env.RUNNING_IN_DOCKER === "true") {
180
+ baseConfig.vault_path = "/vault";
181
+ baseConfig.state_dir = "/data";
182
+ if (baseConfig.inference.mode === "local") baseConfig.inference.models_dir = "/models";
183
+ if (baseConfig.server) baseConfig.server.hostname = "0.0.0.0";
184
+ }
185
+
186
+ let merged: Config;
187
+ if (!(await file.exists())) {
188
+ merged = baseConfig;
189
+ } else {
190
+ const parsed = Bun.TOML.parse(await file.text()) as Record<string, unknown>;
191
+ if ("embedding" in parsed || "rerank" in parsed || "remote_timeout_ms" in parsed) {
192
+ throw new Error(
193
+ "Legacy inference config is not supported. Move [embedding], [rerank], and remote_timeout_ms under [inference] using config.remote.example.toml.",
194
+ );
195
+ }
196
+ if (isPlainObject(parsed.inference) && parsed.inference.mode === "remote") {
197
+ if (!isPlainObject(parsed.inference.embedding)) {
198
+ throw new Error("Remote inference requires an inference.embedding section.");
199
+ }
200
+ const withoutInference = { ...parsed };
201
+ delete withoutInference.inference;
202
+ merged = {
203
+ ...deepMerge(baseConfig, withoutInference),
204
+ inference: configSchema.shape.inference.parse(parsed.inference),
205
+ };
206
+ } else {
207
+ merged = deepMerge(baseConfig, parsed);
208
+ }
209
+ }
210
+
211
+ // Environment variable overrides.
212
+ if (process.env.VAULT_PATH) {
213
+ merged.vault_path = process.env.VAULT_PATH;
214
+ }
215
+ if (process.env.STATE_DIR) {
216
+ merged.state_dir = process.env.STATE_DIR;
217
+ }
218
+ const getEnv = (newPrefixed: string, unprefixed: string) => {
219
+ const legacyPrefixed = newPrefixed.replace(/^SKILLMUX_/, "SKILL_ROUTER_");
220
+ if (process.env[newPrefixed] !== undefined) return process.env[newPrefixed];
221
+ if (process.env[legacyPrefixed] !== undefined) {
222
+ if (!warnedEnv.has(legacyPrefixed)) {
223
+ warnedEnv.add(legacyPrefixed);
224
+ console.error(`skillmux: ${legacyPrefixed} is deprecated, use ${newPrefixed} instead`);
225
+ }
226
+ return process.env[legacyPrefixed];
227
+ }
228
+ return process.env[unprefixed];
229
+ };
230
+
231
+ if (merged.inference.mode === "local") {
232
+ let modelsDirEnv = process.env.SKILLMUX_MODELS_DIR;
233
+ if (modelsDirEnv === undefined && process.env.SKILL_ROUTER_MODELS_DIR !== undefined) {
234
+ if (!warnedEnv.has("SKILL_ROUTER_MODELS_DIR")) {
235
+ warnedEnv.add("SKILL_ROUTER_MODELS_DIR");
236
+ console.error("skillmux: SKILL_ROUTER_MODELS_DIR is deprecated, use SKILLMUX_MODELS_DIR instead");
237
+ }
238
+ modelsDirEnv = process.env.SKILL_ROUTER_MODELS_DIR;
239
+ }
240
+ if (modelsDirEnv) merged.inference.models_dir = modelsDirEnv;
241
+ if (process.env.EMBED_DEVICE) merged.inference.embedding.device = process.env.EMBED_DEVICE as ONNXDevice;
242
+ if (process.env.EMBED_DTYPE) merged.inference.embedding.dtype = process.env.EMBED_DTYPE as ONNXDtype;
243
+ } else if (merged.inference.mode === "remote") {
244
+ if (!merged.inference.embedding) {
245
+ throw new Error("Remote inference requires an inference.embedding section.");
246
+ }
247
+ if (merged.inference.embedding?.provider !== "openai") {
248
+ throw new Error('Remote inference.embedding.provider must be "openai".');
249
+ }
250
+ if (merged.inference.reranker && merged.inference.reranker.provider !== "infinity") {
251
+ throw new Error('Remote inference.reranker.provider must be "infinity".');
252
+ }
253
+ if (!Number.isInteger(merged.inference.timeout_ms) || merged.inference.timeout_ms < 100) {
254
+ throw new Error("Remote inference.timeout_ms must be an integer of at least 100.");
255
+ }
256
+ if (!merged.inference.embedding?.base_url || !merged.inference.embedding.model || !merged.inference.embedding.dimension) {
257
+ throw new Error("Remote inference requires inference.embedding base_url, model, and dimension.");
258
+ }
259
+ if (merged.inference.reranker && (!merged.inference.reranker.base_url || !merged.inference.reranker.model)) {
260
+ throw new Error("Configured inference.reranker requires base_url and model.");
261
+ }
262
+ if (merged.inference.reranker && !merged.inference.thresholds) {
263
+ throw new Error("Configured inference.reranker requires calibrated inference.thresholds.");
264
+ }
265
+ for (const [name, value] of [
266
+ ["inference.embedding.base_url", merged.inference.embedding.base_url],
267
+ ...(merged.inference.reranker ? [["inference.reranker.base_url", merged.inference.reranker.base_url] as const] : []),
268
+ ] as const) {
269
+ try {
270
+ const url = new URL(value);
271
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error();
272
+ } catch {
273
+ throw new Error(`${name} must be an HTTP(S) URL.`);
274
+ }
275
+ }
276
+ const embedUrl = getEnv("SKILLMUX_EMBED_BASE_URL", "EMBED_BASE_URL");
277
+ const embedModel = getEnv("SKILLMUX_EMBED_MODEL", "EMBED_MODEL");
278
+ const embedDimStr = getEnv("SKILLMUX_EMBED_DIMENSION", "EMBED_DIMENSION");
279
+ const rerankUrl = getEnv("SKILLMUX_RERANK_BASE_URL", "RERANK_BASE_URL");
280
+ const rerankModel = getEnv("SKILLMUX_RERANK_MODEL", "RERANK_MODEL");
281
+ if (embedUrl) merged.inference.embedding.base_url = embedUrl;
282
+ if (embedModel) merged.inference.embedding.model = embedModel;
283
+ if (rerankUrl && merged.inference.reranker) merged.inference.reranker.base_url = rerankUrl;
284
+ if (rerankModel && merged.inference.reranker) merged.inference.reranker.model = rerankModel;
285
+ if (embedDimStr) {
286
+ const dimension = Number(embedDimStr);
287
+ if (!Number.isInteger(dimension) || dimension < 1) throw new Error(`Invalid embedding dimension: ${embedDimStr}`);
288
+ merged.inference.embedding.dimension = dimension;
289
+ }
290
+ } else {
291
+ throw new Error(`Invalid inference.mode: ${(merged.inference as { mode?: unknown }).mode}`);
292
+ }
293
+
294
+ // HTTP server environment overrides
295
+ if (merged.server) {
296
+ if (process.env.HTTP_AUTH_ENABLED) {
297
+ merged.server.auth_enabled = process.env.HTTP_AUTH_ENABLED === "true";
298
+ }
299
+ if (process.env.HTTP_AUTH_TOKEN_ENV) {
300
+ merged.server.auth_token_env = process.env.HTTP_AUTH_TOKEN_ENV;
301
+ }
302
+ if (process.env.HTTP_ALLOWED_ORIGINS) {
303
+ merged.server.allowed_origins = process.env.HTTP_ALLOWED_ORIGINS.split(",").map((o) => o.trim());
304
+ }
305
+ if (process.env.HTTP_HOSTNAME) {
306
+ merged.server.hostname = process.env.HTTP_HOSTNAME;
307
+ }
308
+
309
+ if (!merged.server.rate_limit) {
310
+ merged.server.rate_limit = { enabled: false, requests_per_minute: 60 };
311
+ }
312
+
313
+ const rateLimitEnabledStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_ENABLED", "HTTP_RATE_LIMIT_ENABLED");
314
+ if (rateLimitEnabledStr) {
315
+ merged.server.rate_limit.enabled = rateLimitEnabledStr === "true";
316
+ }
317
+
318
+ const rateLimitRPMStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_RPM", "HTTP_RATE_LIMIT_RPM");
319
+ if (rateLimitRPMStr) {
320
+ const rpm = Number(rateLimitRPMStr);
321
+ if (!Number.isInteger(rpm)) {
322
+ throw new Error(`Invalid rate limit RPM: ${rateLimitRPMStr}`);
323
+ }
324
+ merged.server.rate_limit.requests_per_minute = rpm;
325
+ }
326
+
327
+ const rateLimitTrustProxyStr = getEnv("SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY", "HTTP_RATE_LIMIT_TRUST_PROXY");
328
+ if (rateLimitTrustProxyStr) {
329
+ merged.server.rate_limit.trust_proxy = rateLimitTrustProxyStr === "true";
330
+ }
331
+
332
+ if (merged.server.rate_limit.enabled && merged.server.rate_limit.requests_per_minute === undefined) {
333
+ merged.server.rate_limit.requests_per_minute = 60;
334
+ }
335
+ }
336
+
337
+ return configSchema.parse(merged) as Config;
338
+ }
package/src/db.ts ADDED
@@ -0,0 +1,280 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
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 openIndex(stateDir: string): Database {
16
+ mkdirSync(stateDir, { recursive: true });
17
+ const db = new Database(join(stateDir, "index.sqlite3"), { create: true });
18
+ db.run("PRAGMA journal_mode = WAL");
19
+ db.run("PRAGMA busy_timeout = 2000");
20
+ db.run(`CREATE TABLE IF NOT EXISTS skills (
21
+ skill_id TEXT PRIMARY KEY,
22
+ title TEXT NOT NULL,
23
+ description TEXT NOT NULL,
24
+ aliases TEXT NOT NULL,
25
+ content_sha256 TEXT NOT NULL
26
+ )`);
27
+ db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
28
+ skill_id UNINDEXED, title, description, aliases,
29
+ tokenize = 'unicode61 remove_diacritics 2'
30
+ )`);
31
+ db.run(`CREATE TABLE IF NOT EXISTS vectors (
32
+ skill_id TEXT PRIMARY KEY,
33
+ content_sha256 TEXT NOT NULL,
34
+ embedding_fingerprint TEXT NOT NULL DEFAULT '',
35
+ dim INTEGER NOT NULL,
36
+ vec BLOB NOT NULL
37
+ )`);
38
+ const vectorColumns = db.query("PRAGMA table_info(vectors)").all() as { name: string }[];
39
+ if (!vectorColumns.some((column) => column.name === "embedding_fingerprint")) {
40
+ db.run("ALTER TABLE vectors ADD COLUMN embedding_fingerprint TEXT NOT NULL DEFAULT ''");
41
+ }
42
+ db.run(`CREATE TABLE IF NOT EXISTS audit (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ ts TEXT NOT NULL,
45
+ query TEXT NOT NULL,
46
+ outcome TEXT NOT NULL CHECK (outcome IN ('matched', 'ambiguous', 'no_match')),
47
+ degraded INTEGER NOT NULL,
48
+ retrieval TEXT NOT NULL DEFAULT 'lexical',
49
+ candidates TEXT NOT NULL,
50
+ selected_skill_id TEXT,
51
+ latency_ms INTEGER NOT NULL
52
+ )`);
53
+ const auditColumns = db.query("PRAGMA table_info(audit)").all() as { name: string }[];
54
+ if (!auditColumns.some((column) => column.name === "retrieval")) {
55
+ db.run("ALTER TABLE audit ADD COLUMN retrieval TEXT NOT NULL DEFAULT 'lexical'");
56
+ }
57
+ db.run(`CREATE TABLE IF NOT EXISTS index_meta (
58
+ key TEXT PRIMARY KEY,
59
+ value TEXT NOT NULL
60
+ )`);
61
+ return db;
62
+ }
63
+
64
+ export function upsertSkill(db: Database, skill: VaultSkill): void {
65
+ const aliases = skill.aliases.join(" ");
66
+ db.transaction(() => {
67
+ db.run("DELETE FROM skills WHERE skill_id = ?", [skill.skill_id]);
68
+ db.run("DELETE FROM skills_fts WHERE skill_id = ?", [skill.skill_id]);
69
+ db.run(
70
+ "INSERT INTO skills (skill_id, title, description, aliases, content_sha256) VALUES (?, ?, ?, ?, ?)",
71
+ [skill.skill_id, skill.title, skill.description, aliases, skill.content_sha256],
72
+ );
73
+ db.run(
74
+ "INSERT INTO skills_fts (skill_id, title, description, aliases) VALUES (?, ?, ?, ?)",
75
+ [skill.skill_id, skill.title, skill.description, aliases],
76
+ );
77
+ })();
78
+ }
79
+
80
+ export function toSkillRow(skill: VaultSkill): SkillRow {
81
+ return {
82
+ skill_id: skill.skill_id,
83
+ title: skill.title,
84
+ description: skill.description,
85
+ aliases: skill.aliases.join(" "),
86
+ content_sha256: skill.content_sha256,
87
+ };
88
+ }
89
+
90
+ /** Replace the whole lexical index with `rows`; drops vectors of removed skills. */
91
+ export function replaceSkills(db: Database, rows: SkillRow[]): void {
92
+ db.transaction(() => {
93
+ db.run("DELETE FROM skills");
94
+ db.run("DELETE FROM skills_fts");
95
+ for (const row of rows) {
96
+ db.run(
97
+ "INSERT INTO skills (skill_id, title, description, aliases, content_sha256) VALUES (?, ?, ?, ?, ?)",
98
+ [row.skill_id, row.title, row.description, row.aliases, row.content_sha256],
99
+ );
100
+ db.run(
101
+ "INSERT INTO skills_fts (skill_id, title, description, aliases) VALUES (?, ?, ?, ?)",
102
+ [row.skill_id, row.title, row.description, row.aliases],
103
+ );
104
+ }
105
+ db.run("DELETE FROM vectors WHERE skill_id NOT IN (SELECT skill_id FROM skills)");
106
+ })();
107
+ }
108
+
109
+ export function ingestVault(db: Database, skills: VaultSkill[]): void {
110
+ replaceSkills(db, skills.map(toSkillRow));
111
+ }
112
+
113
+ export function deleteSkill(db: Database, skillId: string): void {
114
+ db.transaction(() => {
115
+ db.run("DELETE FROM skills WHERE skill_id = ?", [skillId]);
116
+ db.run("DELETE FROM skills_fts WHERE skill_id = ?", [skillId]);
117
+ db.run("DELETE FROM vectors WHERE skill_id = ?", [skillId]);
118
+ })();
119
+ }
120
+
121
+ export function skillCount(db: Database): number {
122
+ return (db.query("SELECT count(*) AS n FROM skills").get() as { n: number }).n;
123
+ }
124
+
125
+ export function getSkillRow(db: Database, skillId: string): SkillRow | null {
126
+ return db.query("SELECT * FROM skills WHERE skill_id = ?").get(skillId) as SkillRow | null;
127
+ }
128
+
129
+ /**
130
+ * Sanitize free text into an FTS5 OR-query; returns null when no usable terms
131
+ * remain. Terms keep any Unicode letters/digits (CJK included) so non-ASCII
132
+ * queries still get lexical recall — unicode61 tokenizes contiguous CJK runs
133
+ * as single tokens, so matching works at that granularity.
134
+ */
135
+ export function toFtsQuery(text: string): string | null {
136
+ const terms = [
137
+ ...new Set(
138
+ text
139
+ .toLowerCase()
140
+ .split(/[^\p{L}\p{N}]+/u)
141
+ .filter((t) => t.length >= 2),
142
+ ),
143
+ ];
144
+ if (terms.length === 0) return null;
145
+ return terms.map((t) => `"${t}"`).join(" OR ");
146
+ }
147
+
148
+ export function ftsSearch(db: Database, text: string, k: number): SkillRow[] {
149
+ const query = toFtsQuery(text);
150
+ if (query === null) return [];
151
+ return db
152
+ .query(
153
+ `SELECT s.* FROM skills_fts f
154
+ JOIN skills s ON s.skill_id = f.skill_id
155
+ WHERE skills_fts MATCH ?
156
+ ORDER BY bm25(skills_fts) LIMIT ?`,
157
+ )
158
+ .all(query, k) as SkillRow[];
159
+ }
160
+
161
+ export function findExactMatch(db: Database, query: string): SkillRow | null {
162
+ const cleanQuery = query.trim().toLowerCase();
163
+ return db
164
+ .query(
165
+ `SELECT * FROM skills
166
+ WHERE lower(skill_id) = ?
167
+ OR lower(title) = ?
168
+ OR ' ' || lower(aliases) || ' ' LIKE ?`,
169
+ )
170
+ .get(cleanQuery, cleanQuery, `% ${cleanQuery} %`) as SkillRow | null;
171
+ }
172
+
173
+ export function getIndexMeta(db: Database, key: string): string | null {
174
+ const row = db
175
+ .query("SELECT value FROM index_meta WHERE key = ?")
176
+ .get(key) as { value: string } | null;
177
+ return row ? row.value : null;
178
+ }
179
+
180
+ export function setIndexMeta(db: Database, key: string, value: string): void {
181
+ db.run(
182
+ `INSERT INTO index_meta (key, value) VALUES (?, ?)
183
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
184
+ [key, value],
185
+ );
186
+ }
187
+
188
+ export function upsertVector(
189
+ db: Database,
190
+ skillId: string,
191
+ contentSha256: string,
192
+ embeddingFingerprint: string,
193
+ vec: Float32Array,
194
+ ): void {
195
+ db.run(
196
+ `INSERT INTO vectors (skill_id, content_sha256, embedding_fingerprint, dim, vec) VALUES (?, ?, ?, ?, ?)
197
+ ON CONFLICT(skill_id) DO UPDATE SET
198
+ content_sha256 = excluded.content_sha256,
199
+ embedding_fingerprint = excluded.embedding_fingerprint,
200
+ dim = excluded.dim,
201
+ vec = excluded.vec`,
202
+ [skillId, contentSha256, embeddingFingerprint, vec.length, new Uint8Array(vec.buffer, vec.byteOffset, vec.byteLength)],
203
+ );
204
+ }
205
+
206
+ /**
207
+ * Skills with no usable stored vector: none at all, content changed since
208
+ * embedding, or embedded at a different dimension than currently configured.
209
+ */
210
+ export function skillsNeedingVectors(db: Database, dimension: number, embeddingFingerprint: string): SkillRow[] {
211
+ return db
212
+ .query(
213
+ `SELECT s.* FROM skills s
214
+ LEFT JOIN vectors v ON v.skill_id = s.skill_id
215
+ AND v.content_sha256 = s.content_sha256
216
+ AND v.dim = ?
217
+ AND v.embedding_fingerprint = ?
218
+ WHERE v.skill_id IS NULL`,
219
+ )
220
+ .all(dimension, embeddingFingerprint) as SkillRow[];
221
+ }
222
+
223
+ function cosine(a: Float32Array, b: Float32Array): number {
224
+ if (a.length !== b.length) return 0;
225
+ let dot = 0;
226
+ let normA = 0;
227
+ let normB = 0;
228
+ for (let i = 0; i < a.length; i++) {
229
+ dot += a[i]! * b[i]!;
230
+ normA += a[i]! * a[i]!;
231
+ normB += b[i]! * b[i]!;
232
+ }
233
+ if (normA === 0 || normB === 0) return 0;
234
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
235
+ }
236
+
237
+ /** Brute-force cosine over every stored vector (vault is ~100 skills; no ANN). */
238
+ export function vectorTopK(db: Database, query: Float32Array, k: number): SkillRow[] {
239
+ const rows = db
240
+ .query(
241
+ `SELECT s.skill_id, s.title, s.description, s.aliases, s.content_sha256, v.vec
242
+ FROM vectors v JOIN skills s ON s.skill_id = v.skill_id`,
243
+ )
244
+ .all() as (SkillRow & { vec: Uint8Array })[];
245
+ return rows
246
+ .map(({ vec, ...row }) => ({
247
+ row,
248
+ score: cosine(query, new Float32Array(vec.buffer, vec.byteOffset, vec.byteLength / 4)),
249
+ }))
250
+ .sort((a, b) => b.score - a.score)
251
+ .slice(0, k)
252
+ .map((r) => r.row);
253
+ }
254
+
255
+ export interface AuditInsert {
256
+ ts: string;
257
+ query: string;
258
+ outcome: string;
259
+ retrieval: AuditRow["retrieval"];
260
+ candidates: AuditCandidate[];
261
+ selected_skill_id: string | null;
262
+ latency_ms: number;
263
+ }
264
+
265
+ export function insertAudit(db: Database, row: AuditInsert): void {
266
+ db.run(
267
+ `INSERT INTO audit (ts, query, outcome, degraded, retrieval, candidates, selected_skill_id, latency_ms)
268
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
269
+ [
270
+ row.ts,
271
+ row.query,
272
+ row.outcome,
273
+ row.retrieval === "lexical" ? 1 : 0,
274
+ row.retrieval,
275
+ JSON.stringify(row.candidates),
276
+ row.selected_skill_id,
277
+ row.latency_ms,
278
+ ],
279
+ );
280
+ }
@@ -0,0 +1,42 @@
1
+ import type { RankedCandidate, Thresholds } from "./types";
2
+
3
+ export interface DecisionInput {
4
+ reranked: boolean;
5
+ candidates: RankedCandidate[];
6
+ thresholds: Thresholds;
7
+ }
8
+
9
+ export type Decision =
10
+ | { outcome: "matched"; skill_id: string; score: number; margin: number }
11
+ | { outcome: "ambiguous"; candidates: RankedCandidate[] }
12
+ | { outcome: "no_match" };
13
+
14
+ export function decideResolveOutcome({ reranked, candidates, thresholds }: DecisionInput): Decision {
15
+ if (candidates.length === 0) return { outcome: "no_match" };
16
+
17
+ // Degraded lane: no comparable scores exist, so never match; the (BM25-ordered)
18
+ // Without calibrated reranker scores, the shortlist goes to the calling LLM.
19
+ if (!reranked) return { outcome: "ambiguous", candidates: candidates.slice(0, thresholds.candidate_limit) };
20
+
21
+ if (
22
+ thresholds.match_score === undefined
23
+ || thresholds.match_margin === undefined
24
+ || thresholds.candidate_floor === undefined
25
+ ) {
26
+ throw new Error("Reranked decisions require calibrated thresholds.");
27
+ }
28
+ const { match_score, match_margin, candidate_floor } = thresholds;
29
+
30
+ const sorted = [...candidates].sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity));
31
+ const eligible = sorted.filter((c) => (c.score ?? -Infinity) >= candidate_floor);
32
+ if (eligible.length === 0) return { outcome: "no_match" };
33
+
34
+ const top = eligible[0]!;
35
+ const topScore = top.score!;
36
+ const margin = sorted.length === 1 ? topScore : topScore - (sorted[1]!.score ?? 0);
37
+
38
+ if (topScore >= match_score && margin >= match_margin) {
39
+ return { outcome: "matched", skill_id: top.skill_id, score: topScore, margin };
40
+ }
41
+ return { outcome: "ambiguous", candidates: eligible.slice(0, thresholds.candidate_limit) };
42
+ }