@mcptoolshop/claude-synergy 1.1.1 → 1.2.1

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