@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/cli.ts ADDED
@@ -0,0 +1,482 @@
1
+ #!/usr/bin/env bun
2
+ import { Database } from "bun:sqlite";
3
+ import { existsSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createClients } from "./clients";
6
+ import { loadConfig } from "./config";
7
+ import { expandHome } from "./config";
8
+ import { openIndex } from "./db";
9
+ import { diagnose } from "./doctor";
10
+ import { evalVault } from "./eval";
11
+ import { applyInit, deriveTargetName, detectSurfaces, printLastMile, surfaceCandidates } from "./init";
12
+ import {
13
+ cloneToTemp,
14
+ deriveRepoName,
15
+ installIntoVault,
16
+ resolveRepoSource,
17
+ resolveSkillDir,
18
+ validateSkillCandidate,
19
+ } from "./install";
20
+ import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
21
+ import { downloadLocalModels } from "./models";
22
+ import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
23
+ import { renderScanJson, renderScanText, scanExitCode, scanPath, type ScanSeverity } from "./scan";
24
+ import { getStats, renderStatsText, type StatsResponse } from "./stats";
25
+ import {
26
+ installPostMergeHook,
27
+ restoreMonolith as restoreMonolithTarget,
28
+ syncProjectTargets,
29
+ syncTarget,
30
+ } from "./sync";
31
+ import { scanVault } from "./vault";
32
+
33
+ const [command] = Bun.argv.slice(2);
34
+ type Transport = "stdio" | "http";
35
+
36
+ function parseServeArgs(args: string[]): { transport: Transport; port?: number } {
37
+ let transport: Transport = "stdio";
38
+ let port: number | undefined;
39
+ for (let i = 0; i < args.length; i++) {
40
+ const option = args[i];
41
+ const value = args[i + 1];
42
+ if (option === "--transport") {
43
+ if (value !== "stdio" && value !== "http") {
44
+ throw new Error("--transport must be stdio or http");
45
+ }
46
+ transport = value;
47
+ i++;
48
+ } else if (option === "--port") {
49
+ const parsed = Number(value);
50
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) {
51
+ throw new Error("--port must be an integer between 0 and 65535");
52
+ }
53
+ port = parsed;
54
+ i++;
55
+ } else {
56
+ throw new Error(`unknown serve option: ${option}`);
57
+ }
58
+ }
59
+ return { transport, port };
60
+ }
61
+
62
+ async function runIndex(): Promise<void> {
63
+ const config = await loadConfig();
64
+ configure({ config, clients: createClients(config) });
65
+
66
+ const report = await rebuildIndex((skillId, error) => {
67
+ console.error(
68
+ `warning: keeping previous index entry for ${skillId}: ${error}`,
69
+ );
70
+ });
71
+ const retainedNote =
72
+ report.retained.length > 0
73
+ ? ` (${report.retained.length} retained after parse errors)`
74
+ : "";
75
+ console.log(`indexed ${report.indexed} skills${retainedNote}`);
76
+
77
+ try {
78
+ const backfilled = await backfillEmbeddings();
79
+ console.log(`embeddings: ${backfilled} backfilled`);
80
+ } catch {
81
+ console.log(
82
+ "embeddings: skipped (endpoint unreachable; lexical-only recall until next index)",
83
+ );
84
+ }
85
+ }
86
+
87
+ async function runEval(): Promise<void> {
88
+ const config = await loadConfig();
89
+ configure({ config, clients: createClients(config) });
90
+
91
+ const report = await evalVault().catch((error: unknown) => {
92
+ throw new Error(`eval requires local embeddings: ${String(error)}`);
93
+ });
94
+ console.log(`holdout queries: ${report.queries}`);
95
+ console.log(`lexical recall@3: ${report.lexical.recall_at_3.toFixed(3)}`);
96
+ console.log(`lexical recall@5: ${report.lexical.recall_at_5.toFixed(3)}`);
97
+ console.log(`lexical MRR: ${report.lexical.mrr.toFixed(3)}`);
98
+ console.log(`hybrid recall@3: ${report.hybrid.recall_at_3.toFixed(3)}`);
99
+ console.log(`hybrid recall@5: ${report.hybrid.recall_at_5.toFixed(3)}`);
100
+ console.log(`hybrid MRR: ${report.hybrid.mrr.toFixed(3)}`);
101
+ }
102
+
103
+ async function runDoctor(): Promise<void> {
104
+ const report = await diagnose(await loadConfig());
105
+ console.log(`inference mode: ${report.mode}`);
106
+ console.log(`routing capability: ${report.capability}`);
107
+ for (const check of report.checks)
108
+ console.log(
109
+ `${check.ok ? "ok" : "fail"}: ${check.name} - ${check.detail}`,
110
+ );
111
+ if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
112
+ }
113
+
114
+ async function showConfig(): Promise<void> {
115
+ const config = await loadConfig();
116
+ const inference =
117
+ config.inference.mode === "local"
118
+ ? {
119
+ ...config.inference,
120
+ models_dir: expandHome(config.inference.models_dir),
121
+ }
122
+ : config.inference;
123
+ console.log(
124
+ JSON.stringify(
125
+ {
126
+ ...config,
127
+ vault_path: expandHome(config.vault_path),
128
+ state_dir: expandHome(config.state_dir),
129
+ inference,
130
+ },
131
+ null,
132
+ 2,
133
+ ),
134
+ );
135
+ }
136
+
137
+ async function runModelDownload(): Promise<void> {
138
+ const cacheDir = await downloadLocalModels(await loadConfig());
139
+ console.log(`models ready in ${cacheDir}`);
140
+ }
141
+
142
+ function parseSyncArgs(args: string[]): {
143
+ dryRun: boolean;
144
+ restoreMonolith: boolean;
145
+ installHook: boolean;
146
+ } {
147
+ let dryRun = false;
148
+ let restoreMonolith = false;
149
+ let installHook = false;
150
+ for (const arg of args) {
151
+ if (arg === "--dry-run") dryRun = true;
152
+ else if (arg === "--restore-monolith") restoreMonolith = true;
153
+ else if (arg === "--install-hook") installHook = true;
154
+ else throw new Error(`unknown sync option: ${arg}`);
155
+ }
156
+ return { dryRun, restoreMonolith, installHook };
157
+ }
158
+
159
+ async function runSync(args: string[]): Promise<void> {
160
+ const { dryRun, restoreMonolith, installHook } = parseSyncArgs(args);
161
+ const config = await loadConfig();
162
+ const vaultPath = expandHome(config.vault_path);
163
+
164
+ if (installHook) {
165
+ const result = installPostMergeHook(vaultPath);
166
+ console.log(
167
+ result.installed
168
+ ? "installed post-merge hook"
169
+ : "post-merge hook already installed",
170
+ );
171
+ }
172
+
173
+ const manifestPath = resolveManifestPath(vaultPath);
174
+ if (!manifestPath) {
175
+ console.log("no skillmux.toml found at vault root — nothing to sync");
176
+ return;
177
+ }
178
+
179
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
180
+ const vaultSkillIds = new Set(
181
+ (await scanVault(vaultPath)).map((skill) => skill.skill_id),
182
+ );
183
+ const { notes } = validateManifest(manifest, vaultSkillIds);
184
+ for (const note of notes) console.log(`note: ${note}`);
185
+
186
+ for (const [targetName, target] of Object.entries(manifest.targets)) {
187
+ const targetDir = expandHome(target.dir);
188
+
189
+ if (restoreMonolith) {
190
+ const result = restoreMonolithTarget(targetDir, vaultPath);
191
+ console.log(
192
+ result.restored
193
+ ? `${targetName}: restored to a vault symlink`
194
+ : `${targetName}: not owned by skillmux, left untouched`,
195
+ );
196
+ continue;
197
+ }
198
+
199
+ const suffix = dryRun ? " (dry-run)" : "";
200
+ const result = syncTarget(
201
+ { vaultPath, targetDir, targetName, coreSkillIds: manifest.core.skills },
202
+ { dryRun },
203
+ );
204
+ console.log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
205
+
206
+ if (target.project) {
207
+ const projectResults = syncProjectTargets(
208
+ { vaultPath, targetDir, targetName, projectGroups: manifest.project ?? {} },
209
+ { dryRun },
210
+ );
211
+ for (const projectResult of projectResults) {
212
+ console.log(
213
+ ` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
214
+ );
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ function parseInitArgs(args: string[]): { targets: string[]; yes: boolean } {
221
+ const targets: string[] = [];
222
+ let yes = false;
223
+ for (let i = 0; i < args.length; i++) {
224
+ const option = args[i];
225
+ if (option === "--target") {
226
+ const value = args[i + 1];
227
+ if (!value) throw new Error("--target requires a name");
228
+ targets.push(value);
229
+ i++;
230
+ } else if (option === "--yes") {
231
+ yes = true;
232
+ } else {
233
+ throw new Error(`unknown init option: ${option}`);
234
+ }
235
+ }
236
+ return { targets, yes };
237
+ }
238
+
239
+ async function runInit(args: string[]): Promise<void> {
240
+ const { targets: requestedTargets, yes } = parseInitArgs(args);
241
+ const config = await loadConfig();
242
+ const vaultPath = expandHome(config.vault_path);
243
+
244
+ const candidates = detectSurfaces(surfaceCandidates().map(expandHome));
245
+ for (const candidate of candidates) {
246
+ const name = deriveTargetName(candidate.path);
247
+ if (!candidate.exists) {
248
+ console.log(`${name} (${candidate.path}): not found`);
249
+ continue;
250
+ }
251
+ const kind = candidate.isSymlink ? "symlink" : "real dir";
252
+ const marked = candidate.alreadyMarked ? ", already skillmux-managed" : "";
253
+ console.log(`${name} (${candidate.path}): ${kind}, ${candidate.skillCount} skills${marked}`);
254
+ }
255
+
256
+ if (requestedTargets.length === 0) {
257
+ console.log("\nno --target specified — nothing written. Re-run with --target <name> --yes to adopt a surface.");
258
+ return;
259
+ }
260
+
261
+ if (!yes) {
262
+ throw new Error(
263
+ "usage: skillmux init --target <name> [--target <name>...] --yes (interactive per-target confirm is not available non-interactively)",
264
+ );
265
+ }
266
+
267
+ const byName = new Map(
268
+ candidates.filter((c) => c.exists).map((c) => [deriveTargetName(c.path), c] as const),
269
+ );
270
+ for (const name of requestedTargets) {
271
+ if (!byName.has(name)) throw new Error(`unknown --target "${name}": not among detected surfaces`);
272
+ }
273
+
274
+ const confirmedTargets = requestedTargets.map((name) => ({ name, dir: byName.get(name)!.path }));
275
+ applyInit(vaultPath, confirmedTargets);
276
+
277
+ console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`);
278
+ console.log(`run "skillmux sync" next to materialize [core] skills into these targets.\n`);
279
+ console.log(printLastMile());
280
+ }
281
+
282
+ function parseReportArgs(args: string[]): { server?: string; db?: string; since?: string } {
283
+ let server: string | undefined;
284
+ let db: string | undefined;
285
+ let since: string | undefined;
286
+ for (let i = 0; i < args.length; i++) {
287
+ const option = args[i];
288
+ const value = args[i + 1];
289
+ if (option === "--server") {
290
+ if (!value) throw new Error("--server requires a URL");
291
+ server = value;
292
+ i++;
293
+ } else if (option === "--db") {
294
+ if (!value) throw new Error("--db requires a path");
295
+ db = value;
296
+ i++;
297
+ } else if (option === "--since") {
298
+ if (!value) throw new Error("--since requires a window");
299
+ since = value;
300
+ i++;
301
+ } else {
302
+ throw new Error(`unknown report option: ${option}`);
303
+ }
304
+ }
305
+ if (server && db) throw new Error("--server and --db are mutually exclusive");
306
+ return { server, db, since };
307
+ }
308
+
309
+ async function runReport(args: string[]): Promise<void> {
310
+ const { server, db: dbPath, since } = parseReportArgs(args);
311
+ if (!since) throw new Error("usage: skillmux report [--server <url> | --db <path>] --since <window>");
312
+
313
+ if (server) {
314
+ const url = `${server.replace(/\/$/, "")}/stats?since=${encodeURIComponent(since)}`;
315
+ const res = await fetch(url);
316
+ if (!res.ok) throw new Error(`skillmux report --server failed: ${res.status} ${await res.text()}`);
317
+ console.log(renderStatsText((await res.json()) as StatsResponse));
318
+ return;
319
+ }
320
+
321
+ const db = dbPath ? new Database(dbPath, { readonly: true }) : openIndex(expandHome((await loadConfig()).state_dir));
322
+ console.log(renderStatsText(getStats(db, since)));
323
+ db.close();
324
+ }
325
+
326
+ function parseScanArgs(args: string[]): { path?: string; format: "text" | "json"; failOn?: ScanSeverity } {
327
+ let path: string | undefined;
328
+ let format: "text" | "json" = "text";
329
+ let failOn: ScanSeverity | undefined;
330
+ for (let i = 0; i < args.length; i++) {
331
+ const option = args[i];
332
+ if (option === "--format") {
333
+ const value = args[++i];
334
+ if (value !== "text" && value !== "json") throw new Error("--format must be text or json");
335
+ format = value;
336
+ } else if (option === "--fail-on") {
337
+ const value = args[++i];
338
+ if (value !== "low" && value !== "medium" && value !== "high") {
339
+ throw new Error("--fail-on must be low, medium, or high");
340
+ }
341
+ failOn = value;
342
+ } else if (option?.startsWith("--")) {
343
+ throw new Error(`unknown scan option: ${option}`);
344
+ } else if (path !== undefined) {
345
+ throw new Error("skillmux scan accepts at most one <path> argument");
346
+ } else {
347
+ path = option;
348
+ }
349
+ }
350
+ return { path, format, failOn };
351
+ }
352
+
353
+ async function runScan(args: string[]): Promise<void> {
354
+ const { path, format, failOn } = parseScanArgs(args);
355
+ const rootPath = path ? expandHome(path) : expandHome((await loadConfig()).vault_path);
356
+ const result = await scanPath(rootPath);
357
+ console.log(format === "json" ? renderScanJson(result) : renderScanText(result));
358
+ process.exitCode = scanExitCode(result.findings, failOn);
359
+ }
360
+
361
+ function parseInstallArgs(args: string[]): {
362
+ repo?: string;
363
+ force: boolean;
364
+ dryRun: boolean;
365
+ failOn?: ScanSeverity;
366
+ } {
367
+ let repo: string | undefined;
368
+ let force = false;
369
+ let dryRun = false;
370
+ let failOn: ScanSeverity | undefined;
371
+ for (let i = 0; i < args.length; i++) {
372
+ const option = args[i];
373
+ if (option === "--force") force = true;
374
+ else if (option === "--dry-run") dryRun = true;
375
+ else if (option === "--fail-on") {
376
+ const value = args[++i];
377
+ if (value !== "low" && value !== "medium" && value !== "high") {
378
+ throw new Error("--fail-on must be low, medium, or high");
379
+ }
380
+ failOn = value;
381
+ } else if (option?.startsWith("--")) {
382
+ throw new Error(`unknown install option: ${option}`);
383
+ } else if (repo !== undefined) {
384
+ throw new Error("skillmux install accepts at most one <repo> argument");
385
+ } else {
386
+ repo = option;
387
+ }
388
+ }
389
+ return { repo, force, dryRun, failOn };
390
+ }
391
+
392
+ async function runInstall(args: string[]): Promise<void> {
393
+ const { repo, force, dryRun, failOn } = parseInstallArgs(args);
394
+ if (!repo) {
395
+ throw new Error("usage: skillmux install <repo>[/path] [--force] [--fail-on low|medium|high] [--dry-run]");
396
+ }
397
+
398
+ const source = resolveRepoSource(repo);
399
+ const cloneDir = await cloneToTemp(source.url);
400
+ try {
401
+ const resolved = resolveSkillDir(cloneDir, deriveRepoName(source.url), source.skillPath);
402
+ const { findings } = await validateSkillCandidate(resolved.skillId, resolved.dir);
403
+ console.log(renderScanText({ scanned: 1, findings }));
404
+
405
+ if (scanExitCode(findings, failOn) !== 0) {
406
+ process.exitCode = 1;
407
+ console.error(`aborting install: a finding met the --fail-on ${failOn} threshold`);
408
+ return;
409
+ }
410
+
411
+ const vaultPath = expandHome((await loadConfig()).vault_path);
412
+ if (dryRun) {
413
+ console.log(`dry-run: would install "${resolved.skillId}" into ${join(vaultPath, resolved.skillId)}`);
414
+ return;
415
+ }
416
+
417
+ const targetDir = installIntoVault(vaultPath, resolved.skillId, resolved.dir, force);
418
+ console.log(`installed "${resolved.skillId}" into ${targetDir}`);
419
+ } finally {
420
+ rmSync(cloneDir, { recursive: true, force: true });
421
+ }
422
+ }
423
+
424
+ switch (command) {
425
+ case "serve": {
426
+ const { startServer } = await import("./server");
427
+ const { transport, port } = parseServeArgs(Bun.argv.slice(3));
428
+ const handle = await startServer({ transport, port });
429
+ let stopping = false;
430
+ const shutdown = async () => {
431
+ if (stopping) return;
432
+ stopping = true;
433
+ const timeout = setTimeout(() => process.exit(1), 10_000);
434
+ timeout.unref();
435
+ await handle.stop();
436
+ clearTimeout(timeout);
437
+ process.exit(0);
438
+ };
439
+ process.once("SIGTERM", shutdown);
440
+ process.once("SIGINT", shutdown);
441
+ break;
442
+ }
443
+ case "index":
444
+ await runIndex();
445
+ break;
446
+ case "sync":
447
+ await runSync(Bun.argv.slice(3));
448
+ break;
449
+ case "init":
450
+ await runInit(Bun.argv.slice(3));
451
+ break;
452
+ case "report":
453
+ await runReport(Bun.argv.slice(3));
454
+ break;
455
+ case "scan":
456
+ await runScan(Bun.argv.slice(3));
457
+ break;
458
+ case "install":
459
+ await runInstall(Bun.argv.slice(3));
460
+ break;
461
+ case "eval":
462
+ await runEval();
463
+ break;
464
+ case "doctor":
465
+ await runDoctor();
466
+ break;
467
+ case "config":
468
+ if (Bun.argv[3] !== "show")
469
+ throw new Error("usage: skillmux config show");
470
+ await showConfig();
471
+ break;
472
+ case "models":
473
+ if (Bun.argv[3] !== "download")
474
+ throw new Error("usage: skillmux models download");
475
+ await runModelDownload();
476
+ break;
477
+ default:
478
+ console.error(
479
+ "usage: skillmux <serve|index|sync|init|report|scan|install|eval|doctor|config show|models download> [--transport stdio|http] [--port N] [--dry-run|--restore-monolith|--install-hook] [--target name --yes] [--server url|--db path] --since window [<path>] [--format text|json] [--fail-on low|medium|high] [<repo>[/path] [--force]]",
480
+ );
481
+ process.exit(2);
482
+ }
package/src/clients.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { Clients, Config } from "./types";
2
+ import { expandHome } from "./config";
3
+ import type { pipeline as createPipeline } from "@huggingface/transformers";
4
+
5
+ interface EmbeddingResponse {
6
+ data: { index: number; embedding: number[] }[];
7
+ }
8
+
9
+ interface RerankResponse {
10
+ results: { index: number; relevance_score: number }[];
11
+ }
12
+
13
+ // Lazy-loaded model instances for in-process ONNX inference
14
+ type FeatureExtractor = Awaited<ReturnType<typeof createPipeline<"feature-extraction">>>;
15
+
16
+ let localEmbedder: FeatureExtractor | null = null;
17
+
18
+ function localInference(config: Config) {
19
+ if (config.inference.mode !== "local") throw new Error("Local inference is not configured.");
20
+ return config.inference;
21
+ }
22
+
23
+ async function setupTransformers(cacheDir: string) {
24
+ process.env.HF_HUB_CACHE = cacheDir;
25
+ process.env.HF_HOME = cacheDir;
26
+
27
+ const { env, pipeline } = await import("@huggingface/transformers");
28
+ env.cacheDir = cacheDir;
29
+ return pipeline;
30
+ }
31
+
32
+ async function getLocalEmbedder(config: Config): Promise<FeatureExtractor> {
33
+ if (localEmbedder) return localEmbedder;
34
+
35
+ const inference = localInference(config);
36
+ const cacheDir = expandHome(inference.models_dir);
37
+ const pipeline = await setupTransformers(cacheDir);
38
+
39
+ localEmbedder = await pipeline("feature-extraction", inference.embedding.model, {
40
+ device: inference.embedding.device || "cpu",
41
+ dtype: inference.embedding.dtype || "q8",
42
+ });
43
+ return localEmbedder;
44
+ }
45
+
46
+ /**
47
+ * Real HTTP clients or in-process local ONNX inference clients.
48
+ * Every remote HTTP call is bounded by inference.timeout_ms; timeouts and transport
49
+ * errors reject so resolveSkill can fall back to the strongest available retrieval lane.
50
+ * Local ONNX calls run in-process using @huggingface/transformers.
51
+ */
52
+ export function createClients(config: Config): Clients {
53
+ const clients: Clients = {
54
+ async embed(texts: string[]): Promise<Float32Array[]> {
55
+ if (config.inference.mode === "local") {
56
+ const pipe = await getLocalEmbedder(config);
57
+ const output = await pipe(texts, { pooling: "mean", normalize: true });
58
+ const dim = output.dims[1];
59
+ if (dim === undefined || output.dims.length !== 2 || output.dims[0] !== texts.length) {
60
+ throw new Error(`Embedding model returned unexpected dimensions: ${output.dims.join("x")}`);
61
+ }
62
+ const result: Float32Array[] = [];
63
+ for (let i = 0; i < texts.length; i++) {
64
+ const row = output.slice(i, null).tolist();
65
+ if (!Array.isArray(row) || row.some((value) => typeof value !== "number")) {
66
+ throw new Error("Embedding model returned non-numeric values.");
67
+ }
68
+ result.push(Float32Array.from(row));
69
+ }
70
+ return result;
71
+ }
72
+
73
+ const embedding = config.inference.embedding;
74
+ const apiKey = embedding.api_key_env ? process.env[embedding.api_key_env] : undefined;
75
+ const response = await fetch(`${embedding.base_url.replace(/\/$/, "")}/v1/embeddings`, {
76
+ method: "POST",
77
+ headers: {
78
+ "content-type": "application/json",
79
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
80
+ },
81
+ body: JSON.stringify({ model: embedding.model, input: texts }),
82
+ signal: AbortSignal.timeout(config.inference.timeout_ms),
83
+ });
84
+ if (!response.ok) throw new Error(`embeddings endpoint returned ${response.status}`);
85
+ const parsed = (await response.json()) as EmbeddingResponse;
86
+ const byIndex = [...parsed.data].sort((a, b) => a.index - b.index);
87
+ return byIndex.map((d) => Float32Array.from(d.embedding));
88
+ },
89
+ };
90
+ if (config.inference.mode === "remote" && config.inference.reranker) {
91
+ const inference = config.inference;
92
+ clients.rerank = async (query, docs) => {
93
+ const reranker = inference.reranker;
94
+ if (!reranker) throw new Error("Reranker is not configured.");
95
+ const apiKey = reranker.api_key_env ? process.env[reranker.api_key_env] : undefined;
96
+ const response = await fetch(`${reranker.base_url.replace(/\/$/, "")}/rerank`, {
97
+ method: "POST",
98
+ headers: { "content-type": "application/json", ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) },
99
+ body: JSON.stringify({
100
+ model: reranker.model,
101
+ query,
102
+ documents: docs.map((d) => d.text),
103
+ }),
104
+ signal: AbortSignal.timeout(inference.timeout_ms),
105
+ });
106
+ if (!response.ok) throw new Error(`rerank endpoint returned ${response.status}`);
107
+ const parsed = (await response.json()) as RerankResponse;
108
+ const scores = new Array<number>(docs.length).fill(0);
109
+ for (const result of parsed.results) scores[result.index] = result.relevance_score;
110
+ return scores;
111
+ };
112
+ }
113
+ return clients;
114
+ }