@mcptoolshop/claude-synergy 0.0.0 → 1.0.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.
@@ -0,0 +1,754 @@
1
+ // src/db.ts
2
+ import Database from "better-sqlite3";
3
+ import { readFileSync, mkdirSync } from "fs";
4
+ import { fileURLToPath } from "url";
5
+ import { dirname, join, resolve } from "path";
6
+ import * as sqliteVec from "sqlite-vec";
7
+ var __dirname = dirname(fileURLToPath(import.meta.url));
8
+ var SCHEMA_VERSION = 2;
9
+ function openDb(path, opts = {}) {
10
+ const abs = resolve(path);
11
+ mkdirSync(dirname(abs), { recursive: true });
12
+ const db = new Database(abs);
13
+ db.pragma("journal_mode = WAL");
14
+ db.pragma("foreign_keys = ON");
15
+ if (opts.loadVec !== false) {
16
+ try {
17
+ sqliteVec.load(db);
18
+ } catch (e) {
19
+ const quiet = process.env.HK_DEBUG_VEC_LOAD_FAIL_SILENT || process.env.CLAUDE_SYNERGY_QUIET || process.env.MCP_QUIET;
20
+ if (!quiet) {
21
+ console.error(`[warn] sqlite-vec load failed: ${e.message}`);
22
+ }
23
+ }
24
+ }
25
+ return db;
26
+ }
27
+ function initSchema(db, schemaPath) {
28
+ const existing = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='products'`).get();
29
+ if (existing) {
30
+ ensureSchemaVersion(db);
31
+ return;
32
+ }
33
+ const resolved = schemaPath ?? resolveSchemaPath();
34
+ const sql = readFileSync(resolved, "utf-8");
35
+ db.exec(sql);
36
+ ensureSchemaVersion(db);
37
+ }
38
+ function ensureSchemaVersion(db) {
39
+ db.exec(`
40
+ CREATE TABLE IF NOT EXISTS schema_meta (
41
+ key TEXT PRIMARY KEY,
42
+ value TEXT NOT NULL
43
+ )
44
+ `);
45
+ const row = db.prepare(`SELECT value FROM schema_meta WHERE key = 'schema_version'`).get();
46
+ const currentVersion = row ? parseInt(row.value, 10) : 0;
47
+ if (currentVersion > SCHEMA_VERSION) {
48
+ throw new Error(
49
+ `Database schema version ${currentVersion} is newer than this tool supports (version ${SCHEMA_VERSION}). Please upgrade claude-synergy: npm update -g @mcptoolshop/claude-synergy`
50
+ );
51
+ }
52
+ if (currentVersion < SCHEMA_VERSION) {
53
+ migrateSchema(db, currentVersion, SCHEMA_VERSION);
54
+ db.prepare(`
55
+ INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', @version)
56
+ `).run({ version: String(SCHEMA_VERSION) });
57
+ }
58
+ }
59
+ function migrateSchema(db, fromVersion, toVersion) {
60
+ void db;
61
+ void fromVersion;
62
+ void toVersion;
63
+ }
64
+ function resolveSchemaPath() {
65
+ const candidates = [
66
+ join(__dirname, "..", "schema.sql"),
67
+ join(process.cwd(), "schema.sql")
68
+ ];
69
+ for (const p of candidates) {
70
+ try {
71
+ readFileSync(p);
72
+ return p;
73
+ } catch {
74
+ }
75
+ }
76
+ throw new Error(`schema.sql not found in: ${candidates.join(", ")}`);
77
+ }
78
+
79
+ // src/query.ts
80
+ function searchChanges(db, query, opts = {}) {
81
+ const limit = opts.limit ?? 20;
82
+ const filters = [];
83
+ const params = { query, limit };
84
+ if (opts.product) {
85
+ filters.push("c.product = @product");
86
+ params.product = opts.product;
87
+ }
88
+ if (opts.since) {
89
+ filters.push("r.released_at >= @since");
90
+ params.since = opts.since;
91
+ }
92
+ if (opts.kind) {
93
+ filters.push("c.kind = @kind");
94
+ params.kind = opts.kind;
95
+ }
96
+ const where = filters.length > 0 ? `AND ${filters.join(" AND ")}` : "";
97
+ const sql = `
98
+ SELECT c.product, c.version, r.released_at,
99
+ c.ordinal, c.kind, c.text AS body,
100
+ snippet(changes_fts, 0, '[[', ']]', '\u2026', 16) AS snippet
101
+ FROM changes_fts
102
+ JOIN changes c ON changes_fts.rowid = c.id
103
+ JOIN releases r ON c.product = r.product AND c.version = r.version
104
+ WHERE changes_fts MATCH @query
105
+ ${where}
106
+ ORDER BY r.released_at DESC, c.ordinal ASC
107
+ LIMIT @limit
108
+ `;
109
+ const rows = db.prepare(sql).all(params);
110
+ return rows.map((r) => ({ ...r, text: r.body }));
111
+ }
112
+ function lookupEntity(db, type, value) {
113
+ const sql = `
114
+ SELECT c.product, c.version, r.released_at, c.ordinal, c.kind, c.text,
115
+ '' AS snippet
116
+ FROM entities e
117
+ JOIN changes c ON e.change_id = c.id
118
+ JOIN releases r ON c.product = r.product AND c.version = r.version
119
+ WHERE e.entity_type = ? AND e.entity_value = ?
120
+ ORDER BY r.released_at ASC, c.product
121
+ `;
122
+ return db.prepare(sql).all(type, value);
123
+ }
124
+ function recentReleases(db, product, limit = 20) {
125
+ if (product) {
126
+ const sql2 = `
127
+ SELECT r.product, r.version, r.released_at, COUNT(c.id) AS change_count
128
+ FROM releases r
129
+ LEFT JOIN changes c ON c.product = r.product AND c.version = r.version
130
+ WHERE r.product = ?
131
+ GROUP BY r.product, r.version
132
+ ORDER BY r.released_at DESC
133
+ LIMIT ?
134
+ `;
135
+ return db.prepare(sql2).all(product, limit);
136
+ }
137
+ const sql = `
138
+ SELECT r.product, r.version, r.released_at, COUNT(c.id) AS change_count
139
+ FROM releases r
140
+ LEFT JOIN changes c ON c.product = r.product AND c.version = r.version
141
+ GROUP BY r.product, r.version
142
+ ORDER BY r.released_at DESC
143
+ LIMIT ?
144
+ `;
145
+ return db.prepare(sql).all(limit);
146
+ }
147
+ function listProducts(db) {
148
+ const sql = `
149
+ SELECT p.name, p.display_name,
150
+ COUNT(DISTINCT r.version) AS release_count,
151
+ (SELECT version FROM releases r2 WHERE r2.product = p.name ORDER BY r2.released_at DESC LIMIT 1) AS latest_version,
152
+ (SELECT released_at FROM releases r2 WHERE r2.product = p.name ORDER BY r2.released_at DESC LIMIT 1) AS latest_date
153
+ FROM products p
154
+ LEFT JOIN releases r ON r.product = p.name
155
+ GROUP BY p.name
156
+ ORDER BY release_count DESC
157
+ `;
158
+ return db.prepare(sql).all();
159
+ }
160
+ function entityFrequency(db, type, limit = 30) {
161
+ const sql = `
162
+ SELECT e.entity_value AS value,
163
+ COUNT(*) AS count,
164
+ MIN(r.released_at) AS first_seen
165
+ FROM entities e
166
+ JOIN changes c ON e.change_id = c.id
167
+ JOIN releases r ON c.product = r.product AND c.version = r.version
168
+ WHERE e.entity_type = ?
169
+ GROUP BY e.entity_value
170
+ ORDER BY count DESC, first_seen ASC
171
+ LIMIT ?
172
+ `;
173
+ return db.prepare(sql).all(type, limit);
174
+ }
175
+
176
+ // src/providers/embedding/ollama.ts
177
+ function providerTimeoutMs() {
178
+ const raw = process.env.CLAUDE_SYNERGY_PROVIDER_TIMEOUT_MS;
179
+ const n = raw === void 0 ? NaN : parseInt(raw, 10);
180
+ return Number.isFinite(n) && n > 0 ? n : 6e4;
181
+ }
182
+ async function safeErrorBody(res, max = 200) {
183
+ try {
184
+ const body = await res.text();
185
+ const safe = body.split("\n").filter((l) => !/x-api-key|authorization|bearer|api[-_]?key/i.test(l)).join("\n");
186
+ return safe.slice(0, max);
187
+ } catch {
188
+ return "<unreadable>";
189
+ }
190
+ }
191
+ var OllamaEmbeddingProvider = class {
192
+ name = "ollama";
193
+ model;
194
+ dimension = 768;
195
+ host;
196
+ constructor(opts = {}) {
197
+ const rawHost = opts.host ?? process.env.OLLAMA_HOST ?? "http://localhost:11434";
198
+ this.host = /^https?:\/\//.test(rawHost) ? rawHost : `http://${rawHost}`;
199
+ this.model = opts.model ?? process.env.OLLAMA_EMBED_MODEL ?? "nomic-embed-text";
200
+ }
201
+ async embed(inputs) {
202
+ if (inputs.length === 0) return [];
203
+ const timeoutMs = providerTimeoutMs();
204
+ let res;
205
+ try {
206
+ res = await fetch(`${this.host}/api/embed`, {
207
+ method: "POST",
208
+ headers: { "content-type": "application/json" },
209
+ body: JSON.stringify({
210
+ model: this.model,
211
+ input: inputs
212
+ }),
213
+ signal: AbortSignal.timeout(timeoutMs)
214
+ });
215
+ } catch (e) {
216
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
217
+ throw new Error(`Ollama embed request timed out after ${timeoutMs}ms \u2014 is the Ollama server responsive?`);
218
+ }
219
+ throw e;
220
+ }
221
+ if (!res.ok) throw new Error(`Ollama embed ${res.status}: ${await safeErrorBody(res)}`);
222
+ const json = await res.json();
223
+ return json.embeddings.map((e) => {
224
+ if (e.length !== this.dimension) {
225
+ throw new Error(
226
+ `expected dimension ${this.dimension} for ${this.model}, got ${e.length}. Set OLLAMA_EMBED_MODEL or update dimension.`
227
+ );
228
+ }
229
+ return Float32Array.from(e);
230
+ });
231
+ }
232
+ };
233
+
234
+ // src/providers/retry.ts
235
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
236
+ async function withRetry(fn, opts = {}) {
237
+ const maxAttempts = opts.maxAttempts ?? 3;
238
+ const baseDelayMs = opts.baseDelayMs ?? 1e3;
239
+ const maxDelayMs = opts.maxDelayMs ?? 3e4;
240
+ let lastError;
241
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
242
+ if (opts.signal?.aborted) {
243
+ throw new Error("Operation cancelled via AbortSignal");
244
+ }
245
+ try {
246
+ const data = await fn(attempt);
247
+ return { data, attempts: attempt };
248
+ } catch (err) {
249
+ lastError = err instanceof Error ? err : new Error(String(err));
250
+ if (attempt >= maxAttempts) break;
251
+ if (!isRetryable(lastError)) break;
252
+ const expDelay = baseDelayMs * Math.pow(2, attempt - 1);
253
+ const cappedDelay = Math.min(expDelay, maxDelayMs);
254
+ const jitteredDelay = Math.random() * cappedDelay;
255
+ await sleep(jitteredDelay, opts.signal);
256
+ }
257
+ }
258
+ throw lastError;
259
+ }
260
+ function isRetryable(err) {
261
+ const msg = err.message;
262
+ for (const status of RETRYABLE_STATUSES) {
263
+ if (msg.includes(String(status))) return true;
264
+ }
265
+ if (err.name === "TimeoutError" || err.name === "AbortError") return true;
266
+ if (msg.includes("timed out")) return true;
267
+ if (msg.includes("ECONNRESET") || msg.includes("ECONNREFUSED")) return true;
268
+ if (msg.includes("fetch failed") || msg.includes("network")) return true;
269
+ return false;
270
+ }
271
+ function sleep(ms, signal) {
272
+ return new Promise((resolve2, reject) => {
273
+ if (signal?.aborted) {
274
+ reject(new Error("Operation cancelled via AbortSignal"));
275
+ return;
276
+ }
277
+ const timer = setTimeout(resolve2, ms);
278
+ signal?.addEventListener("abort", () => {
279
+ clearTimeout(timer);
280
+ reject(new Error("Operation cancelled via AbortSignal"));
281
+ }, { once: true });
282
+ });
283
+ }
284
+ function providerTimeoutMs2() {
285
+ const raw = process.env.CLAUDE_SYNERGY_PROVIDER_TIMEOUT_MS;
286
+ const n = raw === void 0 ? NaN : parseInt(raw, 10);
287
+ return Number.isFinite(n) && n > 0 ? n : 6e4;
288
+ }
289
+ async function safeErrorBody2(res, max = 200) {
290
+ try {
291
+ const body = await res.text();
292
+ const safe = body.split("\n").filter((l) => !/x-api-key|authorization|bearer|api[-_]?key/i.test(l)).join("\n");
293
+ return safe.slice(0, max);
294
+ } catch {
295
+ return "<unreadable>";
296
+ }
297
+ }
298
+
299
+ // src/providers/embedding/voyage.ts
300
+ var VoyageEmbeddingProvider = class {
301
+ name = "voyage";
302
+ model;
303
+ dimension = 768;
304
+ apiKey;
305
+ /** Accumulated usage across all embed() calls on this instance. */
306
+ _usage = { tokens: 0, requests: 0 };
307
+ constructor(opts = {}) {
308
+ this.apiKey = opts.apiKey ?? process.env.VOYAGE_API_KEY ?? "";
309
+ this.model = opts.model ?? "voyage-3-large";
310
+ if (!this.apiKey) {
311
+ throw new Error("voyage embedding provider requires VOYAGE_API_KEY");
312
+ }
313
+ }
314
+ /** Get accumulated usage stats. */
315
+ get usage() {
316
+ return { ...this._usage };
317
+ }
318
+ /** Reset accumulated usage counters. */
319
+ resetUsage() {
320
+ this._usage = { tokens: 0, requests: 0 };
321
+ }
322
+ async embed(inputs, signal) {
323
+ const timeoutMs = providerTimeoutMs2();
324
+ const { data: result } = await withRetry(
325
+ async () => {
326
+ let res;
327
+ try {
328
+ res = await fetch("https://api.voyageai.com/v1/embeddings", {
329
+ method: "POST",
330
+ headers: {
331
+ "authorization": `Bearer ${this.apiKey}`,
332
+ "content-type": "application/json"
333
+ },
334
+ body: JSON.stringify({
335
+ model: this.model,
336
+ input: inputs,
337
+ input_type: "document",
338
+ output_dimension: this.dimension
339
+ }),
340
+ signal: signal ?? AbortSignal.timeout(timeoutMs)
341
+ });
342
+ } catch (e) {
343
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
344
+ throw new Error(`Voyage embed request timed out after ${timeoutMs}ms \u2014 is the API responsive?`);
345
+ }
346
+ throw e;
347
+ }
348
+ if (!res.ok) throw new Error(`Voyage ${res.status}: ${await safeErrorBody2(res)}`);
349
+ const json = await res.json();
350
+ return json;
351
+ },
352
+ { maxAttempts: 3, signal }
353
+ );
354
+ this._usage.requests++;
355
+ if (result.usage?.total_tokens) {
356
+ this._usage.tokens += result.usage.total_tokens;
357
+ }
358
+ return result.data.map((d) => Float32Array.from(d.embedding));
359
+ }
360
+ };
361
+
362
+ // src/providers/rerank/ollama-judge.ts
363
+ function providerTimeoutMs3() {
364
+ const raw = process.env.CLAUDE_SYNERGY_PROVIDER_TIMEOUT_MS;
365
+ const n = raw === void 0 ? NaN : parseInt(raw, 10);
366
+ return Number.isFinite(n) && n > 0 ? n : 6e4;
367
+ }
368
+ async function safeErrorBody3(res, max = 200) {
369
+ try {
370
+ const body = await res.text();
371
+ const safe = body.split("\n").filter((l) => !/x-api-key|authorization|bearer|api[-_]?key/i.test(l)).join("\n");
372
+ return safe.slice(0, max);
373
+ } catch {
374
+ return "<unreadable>";
375
+ }
376
+ }
377
+ var OllamaJudgeRerankProvider = class {
378
+ name = "ollama-judge";
379
+ host;
380
+ model;
381
+ constructor(opts = {}) {
382
+ const rawHost = opts.host ?? process.env.OLLAMA_HOST ?? "http://localhost:11434";
383
+ this.host = /^https?:\/\//.test(rawHost) ? rawHost : `http://${rawHost}`;
384
+ this.model = opts.model ?? process.env.OLLAMA_RERANK_MODEL ?? "qwen3:8b";
385
+ }
386
+ async rerank(query, candidates) {
387
+ if (candidates.length === 0) return [];
388
+ const docs = candidates.map((c, i) => `Doc ${i + 1}: ${c.text.slice(0, 400)}`).join("\n\n");
389
+ const prompt = [
390
+ `You are a relevance judge for a Claude changelog search.`,
391
+ `Rate each document 0-10 for how well it answers the user's query.`,
392
+ `Respond with EXACTLY ${candidates.length} integers, one per line, in order. No prose, no explanations, no doc numbers, just one integer per line.`,
393
+ ``,
394
+ `Query: ${query}`,
395
+ ``,
396
+ docs,
397
+ ``,
398
+ `Scores (one integer per line, ${candidates.length} lines total):`
399
+ ].join("\n");
400
+ const timeoutMs = providerTimeoutMs3();
401
+ let res;
402
+ try {
403
+ res = await fetch(`${this.host}/api/generate`, {
404
+ method: "POST",
405
+ headers: { "content-type": "application/json" },
406
+ body: JSON.stringify({
407
+ model: this.model,
408
+ prompt,
409
+ stream: false,
410
+ think: false,
411
+ // for qwen3/o1-style models; harmless on others
412
+ options: { temperature: 0, num_predict: Math.max(120, candidates.length * 4) }
413
+ }),
414
+ signal: AbortSignal.timeout(timeoutMs)
415
+ });
416
+ } catch (e) {
417
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
418
+ throw new Error(`Ollama judge rerank timed out after ${timeoutMs}ms \u2014 is the Ollama server responsive?`);
419
+ }
420
+ throw e;
421
+ }
422
+ if (!res.ok) throw new Error(`Ollama judge ${res.status}: ${await safeErrorBody3(res)}`);
423
+ const json = await res.json();
424
+ const scores = parseScores(json.response, candidates.length);
425
+ return candidates.map((c, i) => ({ id: c.id, score: scores[i] ?? 0 }));
426
+ }
427
+ };
428
+ function parseScores(response, n) {
429
+ const lines = response.split(/\r?\n/);
430
+ const out = [];
431
+ for (const line of lines) {
432
+ if (out.length >= n) break;
433
+ const m = line.match(/-?\d+(?:\.\d+)?/);
434
+ if (!m) continue;
435
+ const v = parseFloat(m[0]);
436
+ if (Number.isFinite(v)) out.push(Math.max(0, Math.min(10, v)));
437
+ }
438
+ while (out.length < n) out.push(0);
439
+ return out;
440
+ }
441
+
442
+ // src/providers/rerank/voyage.ts
443
+ var VoyageRerankProvider = class {
444
+ name = "voyage";
445
+ apiKey;
446
+ model;
447
+ /** Accumulated usage across all rerank() calls on this instance. */
448
+ _usage = { tokens: 0, requests: 0 };
449
+ constructor(opts = {}) {
450
+ this.apiKey = opts.apiKey ?? process.env.VOYAGE_API_KEY ?? "";
451
+ this.model = opts.model ?? "rerank-2";
452
+ if (!this.apiKey) {
453
+ throw new Error("voyage rerank provider requires VOYAGE_API_KEY");
454
+ }
455
+ }
456
+ /** Get accumulated usage stats. */
457
+ get usage() {
458
+ return { ...this._usage };
459
+ }
460
+ /** Reset accumulated usage counters. */
461
+ resetUsage() {
462
+ this._usage = { tokens: 0, requests: 0 };
463
+ }
464
+ async rerank(query, candidates, signal) {
465
+ if (candidates.length === 0) return [];
466
+ const timeoutMs = providerTimeoutMs2();
467
+ const { data: result } = await withRetry(
468
+ async () => {
469
+ let res;
470
+ try {
471
+ res = await fetch("https://api.voyageai.com/v1/rerank", {
472
+ method: "POST",
473
+ headers: {
474
+ "authorization": `Bearer ${this.apiKey}`,
475
+ "content-type": "application/json"
476
+ },
477
+ body: JSON.stringify({
478
+ model: this.model,
479
+ query,
480
+ documents: candidates.map((c) => c.text),
481
+ top_k: candidates.length
482
+ }),
483
+ signal: signal ?? AbortSignal.timeout(timeoutMs)
484
+ });
485
+ } catch (e) {
486
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
487
+ throw new Error(`Voyage rerank request timed out after ${timeoutMs}ms \u2014 is the API responsive?`);
488
+ }
489
+ throw e;
490
+ }
491
+ if (!res.ok) throw new Error(`Voyage rerank ${res.status}: ${await safeErrorBody2(res)}`);
492
+ const json = await res.json();
493
+ return json;
494
+ },
495
+ { maxAttempts: 3, signal }
496
+ );
497
+ this._usage.requests++;
498
+ if (result.usage?.total_tokens) {
499
+ this._usage.tokens += result.usage.total_tokens;
500
+ }
501
+ return result.data.map((d) => ({
502
+ id: candidates[d.index].id,
503
+ score: d.relevance_score
504
+ }));
505
+ }
506
+ };
507
+
508
+ // src/providers/rerank/cohere.ts
509
+ var CohereRerankProvider = class {
510
+ name = "cohere";
511
+ apiKey;
512
+ model;
513
+ /** Accumulated usage across all rerank() calls on this instance. */
514
+ _usage = { tokens: 0, requests: 0 };
515
+ constructor(opts = {}) {
516
+ this.apiKey = opts.apiKey ?? process.env.COHERE_API_KEY ?? "";
517
+ this.model = opts.model ?? "rerank-v3.5";
518
+ if (!this.apiKey) {
519
+ throw new Error("cohere rerank provider requires COHERE_API_KEY");
520
+ }
521
+ }
522
+ /** Get accumulated usage stats. */
523
+ get usage() {
524
+ return { ...this._usage };
525
+ }
526
+ /** Reset accumulated usage counters. */
527
+ resetUsage() {
528
+ this._usage = { tokens: 0, requests: 0 };
529
+ }
530
+ async rerank(query, candidates, signal) {
531
+ if (candidates.length === 0) return [];
532
+ const timeoutMs = providerTimeoutMs2();
533
+ const { data: result } = await withRetry(
534
+ async () => {
535
+ let res;
536
+ try {
537
+ res = await fetch("https://api.cohere.com/v2/rerank", {
538
+ method: "POST",
539
+ headers: {
540
+ "authorization": `Bearer ${this.apiKey}`,
541
+ "content-type": "application/json"
542
+ },
543
+ body: JSON.stringify({
544
+ model: this.model,
545
+ query,
546
+ documents: candidates.map((c) => c.text),
547
+ top_n: candidates.length
548
+ }),
549
+ signal: signal ?? AbortSignal.timeout(timeoutMs)
550
+ });
551
+ } catch (e) {
552
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
553
+ throw new Error(`Cohere rerank request timed out after ${timeoutMs}ms \u2014 is the API responsive?`);
554
+ }
555
+ throw e;
556
+ }
557
+ if (!res.ok) throw new Error(`Cohere rerank ${res.status}: ${await safeErrorBody2(res)}`);
558
+ const json = await res.json();
559
+ return json;
560
+ },
561
+ { maxAttempts: 3, signal }
562
+ );
563
+ this._usage.requests++;
564
+ if (result.meta?.billed_units?.search_units) {
565
+ this._usage.tokens += result.meta.billed_units.search_units;
566
+ }
567
+ return result.results.map((r) => ({
568
+ id: candidates[r.index].id,
569
+ score: r.relevance_score
570
+ }));
571
+ }
572
+ };
573
+
574
+ // src/hybrid.ts
575
+ var MAX_LIMIT = 500;
576
+ function clampLimit(value, fallback, name) {
577
+ const v = value ?? fallback;
578
+ if (typeof v !== "number" || !Number.isFinite(v)) {
579
+ throw new Error(`hybridSearch: ${name} must be a finite number (got ${String(v)})`);
580
+ }
581
+ const n = v | 0;
582
+ return Math.max(1, Math.min(n, MAX_LIMIT));
583
+ }
584
+ function hasVecTable(db) {
585
+ const tagged = db;
586
+ if (typeof tagged.__synergyHasVec === "boolean") return tagged.__synergyHasVec;
587
+ try {
588
+ db.prepare("SELECT count(*) FROM chunks_vec LIMIT 0").get();
589
+ tagged.__synergyHasVec = true;
590
+ } catch {
591
+ tagged.__synergyHasVec = false;
592
+ }
593
+ return tagged.__synergyHasVec;
594
+ }
595
+ var warnedNoVec = false;
596
+ async function hybridSearch(db, query, opts = {}) {
597
+ const safeLimit = clampLimit(opts.limit, 20, "limit");
598
+ const safeTopK = clampLimit(opts.topK, 60, "topK");
599
+ const rerankRequested = clampLimit(opts.rerankCandidates, 20, "rerankCandidates");
600
+ const safeRerankN = Math.max(safeLimit, rerankRequested);
601
+ const rrfK = opts.rrfK ?? 60;
602
+ if (typeof rrfK !== "number" || !Number.isFinite(rrfK)) {
603
+ throw new Error(`hybridSearch: rrfK must be a finite number (got ${String(rrfK)})`);
604
+ }
605
+ const emb = makeEmbeddingProvider(opts.embedProviderName ?? "ollama");
606
+ const filters = [];
607
+ const params = {};
608
+ if (opts.product) {
609
+ filters.push("ch.product = @product");
610
+ params.product = opts.product;
611
+ }
612
+ if (opts.since) {
613
+ filters.push("ch.released_at >= @since");
614
+ params.since = opts.since;
615
+ }
616
+ if (opts.kind) {
617
+ filters.push("EXISTS (SELECT 1 FROM changes c WHERE c.id = ch.change_id AND c.kind = @kind)");
618
+ params.kind = opts.kind;
619
+ }
620
+ const whereClause = filters.length > 0 ? `AND ${filters.join(" AND ")}` : "";
621
+ let fts = [];
622
+ try {
623
+ const ftsSql = `
624
+ SELECT ch.id AS id, row_number() OVER (ORDER BY bm25(chunks_fts)) AS rank_pos
625
+ FROM chunks_fts
626
+ JOIN chunks ch ON chunks_fts.rowid = ch.id
627
+ WHERE chunks_fts MATCH @query
628
+ ${whereClause}
629
+ ORDER BY bm25(chunks_fts)
630
+ LIMIT @topK
631
+ `;
632
+ fts = db.prepare(ftsSql).all({ ...params, query, topK: safeTopK });
633
+ } catch (e) {
634
+ const sanitized = query.replace(/[^A-Za-z0-9 ]/g, " ").trim();
635
+ if (sanitized) {
636
+ const ftsSql = `
637
+ SELECT ch.id AS id, row_number() OVER (ORDER BY bm25(chunks_fts)) AS rank_pos
638
+ FROM chunks_fts
639
+ JOIN chunks ch ON chunks_fts.rowid = ch.id
640
+ WHERE chunks_fts MATCH @query
641
+ ${whereClause}
642
+ ORDER BY bm25(chunks_fts)
643
+ LIMIT @topK
644
+ `;
645
+ fts = db.prepare(ftsSql).all({ ...params, query: sanitized, topK: safeTopK });
646
+ }
647
+ }
648
+ const vecAvailable = hasVecTable(db);
649
+ let vec = [];
650
+ if (vecAvailable) {
651
+ const [qvec] = await emb.embed([query]);
652
+ const vecSql = `
653
+ SELECT ch.id AS id, row_number() OVER (ORDER BY distance) AS rank_pos
654
+ FROM chunks_vec
655
+ JOIN chunks ch ON ch.id = chunks_vec.rowid
656
+ WHERE chunks_vec.embedding MATCH @qvec AND k = @topK
657
+ ${whereClause}
658
+ ORDER BY distance
659
+ `;
660
+ vec = db.prepare(vecSql).all({ ...params, qvec, topK: safeTopK });
661
+ } else if (!warnedNoVec && !process.env.MCP_QUIET && !process.env.CLAUDE_SYNERGY_QUIET) {
662
+ console.warn("[hybrid] sqlite-vec table not found; using BM25-only");
663
+ warnedNoVec = true;
664
+ }
665
+ const scores = /* @__PURE__ */ new Map();
666
+ for (const r of fts) {
667
+ const cur = scores.get(r.id) ?? { bm25: null, vec: null, score: 0 };
668
+ cur.bm25 = r.rank_pos;
669
+ cur.score += 1 / (rrfK + r.rank_pos);
670
+ scores.set(r.id, cur);
671
+ }
672
+ for (const r of vec) {
673
+ const cur = scores.get(r.id) ?? { bm25: null, vec: null, score: 0 };
674
+ cur.vec = r.rank_pos;
675
+ cur.score += 1 / (rrfK + r.rank_pos);
676
+ scores.set(r.id, cur);
677
+ }
678
+ if (scores.size === 0) return [];
679
+ const rrfRanked = Array.from(scores.entries()).sort(([, a], [, b]) => b.score - a.score).slice(0, safeRerankN);
680
+ const candidateIds = rrfRanked.map(([id]) => id);
681
+ const placeholders = candidateIds.map(() => "?").join(",");
682
+ const rowsSql = `
683
+ SELECT ch.id AS chunk_id, ch.change_id, ch.product, ch.release_version AS version,
684
+ ch.released_at, c.kind, c.text, ch.contextualized
685
+ FROM chunks ch
686
+ JOIN changes c ON c.id = ch.change_id
687
+ WHERE ch.id IN (${placeholders})
688
+ `;
689
+ const rows = db.prepare(rowsSql).all(...candidateIds);
690
+ const byId = /* @__PURE__ */ new Map();
691
+ for (const r of rows) byId.set(r.chunk_id, r);
692
+ const rerankProviderName = opts.rerankProviderName ?? "none";
693
+ let rerankScores = /* @__PURE__ */ new Map();
694
+ if (rerankProviderName !== "none") {
695
+ const reranker = makeRerankProvider(rerankProviderName);
696
+ const candidates = rrfRanked.slice(0, rerankRequested).map(([id]) => ({ id, text: byId.get(id)?.contextualized ?? byId.get(id)?.text ?? "" })).filter((c) => c.text);
697
+ const results = await reranker.rerank(query, candidates);
698
+ for (const r of results) rerankScores.set(r.id, r.score);
699
+ }
700
+ const final = rrfRanked.map(([id, score]) => ({
701
+ id,
702
+ rrf: score.score,
703
+ bm25: score.bm25,
704
+ vec: score.vec,
705
+ rerank: rerankScores.get(id) ?? null
706
+ })).sort((a, b) => {
707
+ if (rerankProviderName !== "none" && a.rerank !== null && b.rerank !== null) {
708
+ return b.rerank - a.rerank;
709
+ }
710
+ return b.rrf - a.rrf;
711
+ }).slice(0, safeLimit);
712
+ return final.map((entry) => {
713
+ const r = byId.get(entry.id);
714
+ if (!r) return null;
715
+ return {
716
+ ...r,
717
+ bm25_rank: entry.bm25,
718
+ vec_rank: entry.vec,
719
+ rrf_score: entry.rrf,
720
+ rerank_score: entry.rerank
721
+ };
722
+ }).filter(Boolean);
723
+ }
724
+ function makeEmbeddingProvider(name) {
725
+ switch (name) {
726
+ case "ollama":
727
+ return new OllamaEmbeddingProvider();
728
+ case "voyage":
729
+ return new VoyageEmbeddingProvider();
730
+ }
731
+ }
732
+ function makeRerankProvider(name) {
733
+ switch (name) {
734
+ case "ollama-judge":
735
+ return new OllamaJudgeRerankProvider();
736
+ case "voyage":
737
+ return new VoyageRerankProvider();
738
+ case "cohere":
739
+ return new CohereRerankProvider();
740
+ }
741
+ }
742
+
743
+ export {
744
+ openDb,
745
+ initSchema,
746
+ searchChanges,
747
+ lookupEntity,
748
+ recentReleases,
749
+ listProducts,
750
+ entityFrequency,
751
+ OllamaEmbeddingProvider,
752
+ VoyageEmbeddingProvider,
753
+ hybridSearch
754
+ };