@mcptoolshop/claude-synergy 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/CONTRIBUTING.md +5 -4
- package/README.es.md +78 -26
- package/README.fr.md +77 -25
- package/README.hi.md +78 -26
- package/README.it.md +75 -23
- package/README.ja.md +78 -26
- package/README.md +78 -27
- package/README.pt-BR.md +77 -25
- package/README.zh.md +77 -25
- package/dist/chunk-H3466JDH.js +1564 -0
- package/dist/{chunk-HCIZPSW4.js → chunk-HZEQG3WT.js} +281 -1
- package/dist/cli.js +279 -457
- package/dist/ingest-Z45YH7OX.js +8 -0
- package/dist/mcp-server.js +252 -17
- package/package.json +1 -1
- package/products.yaml +12 -6
- package/schema-vec.sql +9 -5
- package/dist/chunk-YFGUTT22.js +0 -754
- package/dist/ingest-3LJNQWS7.js +0 -6
|
@@ -0,0 +1,1564 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var AppError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
hint;
|
|
5
|
+
cause;
|
|
6
|
+
retryable;
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
super(opts.message);
|
|
9
|
+
this.name = "AppError";
|
|
10
|
+
this.code = opts.code;
|
|
11
|
+
this.hint = opts.hint;
|
|
12
|
+
this.cause = opts.cause;
|
|
13
|
+
this.retryable = opts.retryable ?? false;
|
|
14
|
+
}
|
|
15
|
+
/** Return the structured JSON shape (useful for --json output and MCP results). */
|
|
16
|
+
toJSON() {
|
|
17
|
+
return {
|
|
18
|
+
code: this.code,
|
|
19
|
+
message: this.message,
|
|
20
|
+
hint: this.hint,
|
|
21
|
+
...this.cause ? { cause: this.cause } : {},
|
|
22
|
+
...this.retryable ? { retryable: this.retryable } : {}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
function formatError(err, verbose = false) {
|
|
27
|
+
if (err instanceof AppError) {
|
|
28
|
+
const lines = [`\u2717 [${err.code}] ${err.message}`];
|
|
29
|
+
lines.push(` hint: ${err.hint}`);
|
|
30
|
+
if (verbose && err.cause) {
|
|
31
|
+
lines.push(` cause: ${err.cause}`);
|
|
32
|
+
}
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
35
|
+
if (err instanceof Error) {
|
|
36
|
+
return `\u2717 ${err.message}`;
|
|
37
|
+
}
|
|
38
|
+
return `\u2717 ${String(err)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/db.ts
|
|
42
|
+
import Database from "better-sqlite3";
|
|
43
|
+
import { readFileSync, mkdirSync } from "fs";
|
|
44
|
+
import { fileURLToPath } from "url";
|
|
45
|
+
import { dirname, join, resolve } from "path";
|
|
46
|
+
import * as sqliteVec from "sqlite-vec";
|
|
47
|
+
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
var SCHEMA_VERSION = 3;
|
|
49
|
+
var DEFAULT_EMBEDDING_DIM = 768;
|
|
50
|
+
function openDb(path, opts = {}) {
|
|
51
|
+
const abs = resolve(path);
|
|
52
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
53
|
+
const db = new Database(abs);
|
|
54
|
+
db.pragma("journal_mode = WAL");
|
|
55
|
+
db.pragma("foreign_keys = ON");
|
|
56
|
+
if (opts.loadVec !== false) {
|
|
57
|
+
try {
|
|
58
|
+
sqliteVec.load(db);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
const quiet = process.env.HK_DEBUG_VEC_LOAD_FAIL_SILENT || process.env.CLAUDE_SYNERGY_QUIET || process.env.MCP_QUIET;
|
|
61
|
+
if (!quiet) {
|
|
62
|
+
console.error(`[warn] sqlite-vec load failed: ${e.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return db;
|
|
67
|
+
}
|
|
68
|
+
function initSchema(db, schemaPath) {
|
|
69
|
+
const existing = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='products'`).get();
|
|
70
|
+
if (existing) {
|
|
71
|
+
ensureSchemaVersion(db);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const resolved = schemaPath ?? resolveSchemaPath();
|
|
75
|
+
const sql = readFileSync(resolved, "utf-8");
|
|
76
|
+
db.exec(sql);
|
|
77
|
+
ensureSchemaVersion(db);
|
|
78
|
+
}
|
|
79
|
+
function ensureSchemaVersion(db) {
|
|
80
|
+
db.exec(`
|
|
81
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
82
|
+
key TEXT PRIMARY KEY,
|
|
83
|
+
value TEXT NOT NULL
|
|
84
|
+
)
|
|
85
|
+
`);
|
|
86
|
+
const row = db.prepare(`SELECT value FROM schema_meta WHERE key = 'schema_version'`).get();
|
|
87
|
+
const currentVersion = row ? parseInt(row.value, 10) : 0;
|
|
88
|
+
if (currentVersion > SCHEMA_VERSION) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Database schema version ${currentVersion} is newer than this tool supports (version ${SCHEMA_VERSION}). Please upgrade claude-synergy: npm update -g @mcptoolshop/claude-synergy`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (currentVersion < SCHEMA_VERSION) {
|
|
94
|
+
migrateSchema(db, currentVersion, SCHEMA_VERSION);
|
|
95
|
+
db.prepare(`
|
|
96
|
+
INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('schema_version', @version)
|
|
97
|
+
`).run({ version: String(SCHEMA_VERSION) });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function migrateSchema(db, fromVersion, toVersion) {
|
|
101
|
+
if (fromVersion < 3 && toVersion >= 3) {
|
|
102
|
+
const hasDim = db.prepare(`SELECT value FROM schema_meta WHERE key = 'embedding_dim'`).get();
|
|
103
|
+
if (!hasDim) {
|
|
104
|
+
db.prepare(`
|
|
105
|
+
INSERT INTO schema_meta (key, value) VALUES ('embedding_dim', @dim)
|
|
106
|
+
`).run({ dim: String(DEFAULT_EMBEDDING_DIM) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
void toVersion;
|
|
110
|
+
}
|
|
111
|
+
function getEmbeddingDim(db) {
|
|
112
|
+
try {
|
|
113
|
+
const row = db.prepare(`SELECT value FROM schema_meta WHERE key = 'embedding_dim'`).get();
|
|
114
|
+
if (!row) return null;
|
|
115
|
+
const n = parseInt(row.value, 10);
|
|
116
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function setEmbeddingDim(db, dim) {
|
|
122
|
+
if (!Number.isFinite(dim) || dim <= 0 || (dim | 0) !== dim) {
|
|
123
|
+
throw new AppError({
|
|
124
|
+
code: "EMBEDDING_DIM_INVALID",
|
|
125
|
+
message: `embedding dimension must be a positive integer (got ${String(dim)})`,
|
|
126
|
+
hint: "pass a positive integer dim, e.g. 768 (nomic-embed-text), 1536 (text-embedding-3-small), or 3072 (text-embedding-3-large)"
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
db.exec(`
|
|
130
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
131
|
+
key TEXT PRIMARY KEY,
|
|
132
|
+
value TEXT NOT NULL
|
|
133
|
+
)
|
|
134
|
+
`);
|
|
135
|
+
const existing = getEmbeddingDim(db);
|
|
136
|
+
if (existing !== null && existing !== dim) {
|
|
137
|
+
const hasChunks = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='chunks'`).get();
|
|
138
|
+
let chunkCount = 0;
|
|
139
|
+
if (hasChunks) {
|
|
140
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM chunks`).get();
|
|
141
|
+
chunkCount = row.n;
|
|
142
|
+
}
|
|
143
|
+
if (chunkCount > 0) {
|
|
144
|
+
throw new AppError({
|
|
145
|
+
code: "EMBEDDING_DIM_MISMATCH",
|
|
146
|
+
message: `database is configured for ${existing}-d embeddings but the requested provider produces ${dim}-d vectors (${chunkCount} existing chunks)`,
|
|
147
|
+
hint: `re-init the DB to switch dimensions: rm <db-path>, then 'hk init' and 'hk embed' again. Existing chunks would be invalid against the new vector index.`
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
db.prepare(`
|
|
152
|
+
INSERT OR REPLACE INTO schema_meta (key, value) VALUES ('embedding_dim', @dim)
|
|
153
|
+
`).run({ dim: String(dim) });
|
|
154
|
+
}
|
|
155
|
+
function resolveSchemaPath() {
|
|
156
|
+
const candidates = [
|
|
157
|
+
join(__dirname2, "..", "schema.sql"),
|
|
158
|
+
join(process.cwd(), "schema.sql")
|
|
159
|
+
];
|
|
160
|
+
for (const p of candidates) {
|
|
161
|
+
try {
|
|
162
|
+
readFileSync(p);
|
|
163
|
+
return p;
|
|
164
|
+
} catch {
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`schema.sql not found in: ${candidates.join(", ")}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/query.ts
|
|
171
|
+
function searchChanges(db, query, opts = {}) {
|
|
172
|
+
const limit = opts.limit ?? 20;
|
|
173
|
+
const filters = [];
|
|
174
|
+
const params = { query, limit };
|
|
175
|
+
if (opts.product) {
|
|
176
|
+
filters.push("c.product = @product");
|
|
177
|
+
params.product = opts.product;
|
|
178
|
+
}
|
|
179
|
+
if (opts.since) {
|
|
180
|
+
filters.push("r.released_at >= @since");
|
|
181
|
+
params.since = opts.since;
|
|
182
|
+
}
|
|
183
|
+
if (opts.until) {
|
|
184
|
+
filters.push("r.released_at <= @until");
|
|
185
|
+
params.until = opts.until;
|
|
186
|
+
}
|
|
187
|
+
if (opts.kind) {
|
|
188
|
+
filters.push("c.kind = @kind");
|
|
189
|
+
params.kind = opts.kind;
|
|
190
|
+
}
|
|
191
|
+
const where = filters.length > 0 ? `AND ${filters.join(" AND ")}` : "";
|
|
192
|
+
const sql = `
|
|
193
|
+
SELECT c.product, c.version, r.released_at,
|
|
194
|
+
c.ordinal, c.kind, c.text AS body,
|
|
195
|
+
snippet(changes_fts, 0, '[[', ']]', '\u2026', 16) AS snippet
|
|
196
|
+
FROM changes_fts
|
|
197
|
+
JOIN changes c ON changes_fts.rowid = c.id
|
|
198
|
+
JOIN releases r ON c.product = r.product AND c.version = r.version
|
|
199
|
+
WHERE changes_fts MATCH @query
|
|
200
|
+
${where}
|
|
201
|
+
ORDER BY r.released_at DESC, c.ordinal ASC
|
|
202
|
+
LIMIT @limit
|
|
203
|
+
`;
|
|
204
|
+
const rows = db.prepare(sql).all(params);
|
|
205
|
+
return rows.map((r) => ({ ...r, text: r.body }));
|
|
206
|
+
}
|
|
207
|
+
function lookupEntity(db, type, value) {
|
|
208
|
+
const sql = `
|
|
209
|
+
SELECT c.product, c.version, r.released_at, c.ordinal, c.kind, c.text,
|
|
210
|
+
'' AS snippet
|
|
211
|
+
FROM entities e
|
|
212
|
+
JOIN changes c ON e.change_id = c.id
|
|
213
|
+
JOIN releases r ON c.product = r.product AND c.version = r.version
|
|
214
|
+
WHERE e.entity_type = ? AND e.entity_value = ?
|
|
215
|
+
ORDER BY r.released_at ASC, c.product
|
|
216
|
+
`;
|
|
217
|
+
return db.prepare(sql).all(type, value);
|
|
218
|
+
}
|
|
219
|
+
function recentReleases(db, product, limit = 20, since) {
|
|
220
|
+
const filters = [];
|
|
221
|
+
const params = { limit };
|
|
222
|
+
if (product) {
|
|
223
|
+
filters.push("r.product = @product");
|
|
224
|
+
params.product = product;
|
|
225
|
+
}
|
|
226
|
+
if (since) {
|
|
227
|
+
filters.push("r.released_at >= @since");
|
|
228
|
+
params.since = since;
|
|
229
|
+
}
|
|
230
|
+
const where = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
|
|
231
|
+
const sql = `
|
|
232
|
+
SELECT r.product, r.version, r.released_at, COUNT(c.id) AS change_count
|
|
233
|
+
FROM releases r
|
|
234
|
+
LEFT JOIN changes c ON c.product = r.product AND c.version = r.version
|
|
235
|
+
${where}
|
|
236
|
+
GROUP BY r.product, r.version
|
|
237
|
+
ORDER BY r.released_at DESC
|
|
238
|
+
LIMIT @limit
|
|
239
|
+
`;
|
|
240
|
+
return db.prepare(sql).all(params);
|
|
241
|
+
}
|
|
242
|
+
function listProducts(db) {
|
|
243
|
+
const sql = `
|
|
244
|
+
SELECT p.name, p.display_name,
|
|
245
|
+
COUNT(DISTINCT r.version) AS release_count,
|
|
246
|
+
(SELECT version FROM releases r2 WHERE r2.product = p.name ORDER BY r2.released_at DESC LIMIT 1) AS latest_version,
|
|
247
|
+
(SELECT released_at FROM releases r2 WHERE r2.product = p.name ORDER BY r2.released_at DESC LIMIT 1) AS latest_date
|
|
248
|
+
FROM products p
|
|
249
|
+
LEFT JOIN releases r ON r.product = p.name
|
|
250
|
+
GROUP BY p.name
|
|
251
|
+
ORDER BY release_count DESC
|
|
252
|
+
`;
|
|
253
|
+
return db.prepare(sql).all();
|
|
254
|
+
}
|
|
255
|
+
function entityFrequency(db, type, limit = 30) {
|
|
256
|
+
const sql = `
|
|
257
|
+
SELECT e.entity_value AS value,
|
|
258
|
+
COUNT(*) AS count,
|
|
259
|
+
MIN(r.released_at) AS first_seen
|
|
260
|
+
FROM entities e
|
|
261
|
+
JOIN changes c ON e.change_id = c.id
|
|
262
|
+
JOIN releases r ON c.product = r.product AND c.version = r.version
|
|
263
|
+
WHERE e.entity_type = ?
|
|
264
|
+
GROUP BY e.entity_value
|
|
265
|
+
ORDER BY count DESC, first_seen ASC
|
|
266
|
+
LIMIT ?
|
|
267
|
+
`;
|
|
268
|
+
return db.prepare(sql).all(type, limit);
|
|
269
|
+
}
|
|
270
|
+
function browseChanges(db, opts = {}) {
|
|
271
|
+
const limit = opts.limit ?? 20;
|
|
272
|
+
const filters = [];
|
|
273
|
+
const params = { limit };
|
|
274
|
+
if (opts.product) {
|
|
275
|
+
filters.push("c.product = @product");
|
|
276
|
+
params.product = opts.product;
|
|
277
|
+
}
|
|
278
|
+
if (opts.since) {
|
|
279
|
+
filters.push("r.released_at >= @since");
|
|
280
|
+
params.since = opts.since;
|
|
281
|
+
}
|
|
282
|
+
if (opts.until) {
|
|
283
|
+
filters.push("r.released_at <= @until");
|
|
284
|
+
params.until = opts.until;
|
|
285
|
+
}
|
|
286
|
+
if (opts.kind) {
|
|
287
|
+
filters.push("c.kind = @kind");
|
|
288
|
+
params.kind = opts.kind;
|
|
289
|
+
}
|
|
290
|
+
const where = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
|
|
291
|
+
const sql = `
|
|
292
|
+
SELECT c.product, c.version, r.released_at,
|
|
293
|
+
c.ordinal, c.kind, c.text AS body,
|
|
294
|
+
'' AS snippet
|
|
295
|
+
FROM changes c
|
|
296
|
+
JOIN releases r ON c.product = r.product AND c.version = r.version
|
|
297
|
+
${where}
|
|
298
|
+
ORDER BY r.released_at DESC, c.product, c.ordinal ASC
|
|
299
|
+
LIMIT @limit
|
|
300
|
+
`;
|
|
301
|
+
const rows = db.prepare(sql).all(params);
|
|
302
|
+
return rows.map((r) => ({ ...r, text: r.body }));
|
|
303
|
+
}
|
|
304
|
+
function getChangesSince(db, opts) {
|
|
305
|
+
if (!opts.since) {
|
|
306
|
+
throw new AppError({
|
|
307
|
+
code: "QUERY_SINCE_REQUIRED",
|
|
308
|
+
message: "getChangesSince requires a `since` date",
|
|
309
|
+
hint: 'pass since as YYYY-MM-DD (e.g. "2026-01-01")'
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const limit = opts.limit ?? 200;
|
|
313
|
+
const filters = ["r.released_at >= @since"];
|
|
314
|
+
const params = { since: opts.since };
|
|
315
|
+
if (opts.until) {
|
|
316
|
+
filters.push("r.released_at <= @until");
|
|
317
|
+
params.until = opts.until;
|
|
318
|
+
}
|
|
319
|
+
if (opts.product) {
|
|
320
|
+
filters.push("c.product = @product");
|
|
321
|
+
params.product = opts.product;
|
|
322
|
+
}
|
|
323
|
+
if (opts.kind) {
|
|
324
|
+
filters.push("c.kind = @kind");
|
|
325
|
+
params.kind = opts.kind;
|
|
326
|
+
}
|
|
327
|
+
const where = `WHERE ${filters.join(" AND ")}`;
|
|
328
|
+
const sql = `
|
|
329
|
+
SELECT c.product, c.version, r.released_at, c.ordinal, c.kind, c.text
|
|
330
|
+
FROM changes c
|
|
331
|
+
JOIN releases r ON c.product = r.product AND c.version = r.version
|
|
332
|
+
${where}
|
|
333
|
+
ORDER BY r.released_at DESC, c.product, c.version, c.ordinal ASC
|
|
334
|
+
`;
|
|
335
|
+
const rows = db.prepare(sql).all(params);
|
|
336
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
337
|
+
let total = 0;
|
|
338
|
+
for (const r of rows) {
|
|
339
|
+
if (total >= limit) break;
|
|
340
|
+
const key = `${r.product}@${r.version}`;
|
|
341
|
+
let bucket = grouped.get(key);
|
|
342
|
+
if (!bucket) {
|
|
343
|
+
bucket = {
|
|
344
|
+
product: r.product,
|
|
345
|
+
version: r.version,
|
|
346
|
+
released_at: r.released_at,
|
|
347
|
+
changes: []
|
|
348
|
+
};
|
|
349
|
+
grouped.set(key, bucket);
|
|
350
|
+
}
|
|
351
|
+
bucket.changes.push({ ordinal: r.ordinal, kind: r.kind, text: r.text });
|
|
352
|
+
total++;
|
|
353
|
+
}
|
|
354
|
+
return Array.from(grouped.values());
|
|
355
|
+
}
|
|
356
|
+
function compareVersions(db, opts) {
|
|
357
|
+
if (!opts.product || !opts.fromVersion || !opts.toVersion) {
|
|
358
|
+
throw new AppError({
|
|
359
|
+
code: "QUERY_COMPARE_ARGS",
|
|
360
|
+
message: "compareVersions requires product, fromVersion, and toVersion",
|
|
361
|
+
hint: 'pass all three as strings, e.g. { product: "claude-code", fromVersion: "2.1.100", toVersion: "2.1.147" }'
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const endpoint = db.prepare(`
|
|
365
|
+
SELECT version, released_at FROM releases
|
|
366
|
+
WHERE product = ? AND version = ?
|
|
367
|
+
`);
|
|
368
|
+
const fromRow = endpoint.get(opts.product, opts.fromVersion);
|
|
369
|
+
const toRow = endpoint.get(opts.product, opts.toVersion);
|
|
370
|
+
if (!fromRow || !toRow) {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
if (!fromRow.released_at || !toRow.released_at) {
|
|
374
|
+
throw new AppError({
|
|
375
|
+
code: "QUERY_VERSION_NO_DATE",
|
|
376
|
+
message: `cannot compare versions without released_at on both endpoints (${opts.product}@${opts.fromVersion} or ${opts.toVersion} is missing a date)`,
|
|
377
|
+
hint: "re-ingest the affected product so released_at is populated"
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const sql = `
|
|
381
|
+
SELECT c.product, c.version, r.released_at, c.ordinal, c.kind, c.text
|
|
382
|
+
FROM changes c
|
|
383
|
+
JOIN releases r ON c.product = r.product AND c.version = r.version
|
|
384
|
+
WHERE c.product = @product
|
|
385
|
+
AND r.released_at > @fromDate
|
|
386
|
+
AND r.released_at <= @toDate
|
|
387
|
+
ORDER BY r.released_at DESC, c.version, c.ordinal ASC
|
|
388
|
+
`;
|
|
389
|
+
const rows = db.prepare(sql).all({
|
|
390
|
+
product: opts.product,
|
|
391
|
+
fromDate: fromRow.released_at,
|
|
392
|
+
toDate: toRow.released_at
|
|
393
|
+
});
|
|
394
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
395
|
+
for (const r of rows) {
|
|
396
|
+
const key = `${r.product}@${r.version}`;
|
|
397
|
+
let bucket = grouped.get(key);
|
|
398
|
+
if (!bucket) {
|
|
399
|
+
bucket = {
|
|
400
|
+
product: r.product,
|
|
401
|
+
version: r.version,
|
|
402
|
+
released_at: r.released_at,
|
|
403
|
+
changes: []
|
|
404
|
+
};
|
|
405
|
+
grouped.set(key, bucket);
|
|
406
|
+
}
|
|
407
|
+
bucket.changes.push({ ordinal: r.ordinal, kind: r.kind, text: r.text });
|
|
408
|
+
}
|
|
409
|
+
return Array.from(grouped.values());
|
|
410
|
+
}
|
|
411
|
+
function listSynergies(db, opts = {}) {
|
|
412
|
+
const filters = [];
|
|
413
|
+
const params = {};
|
|
414
|
+
if (opts.product) {
|
|
415
|
+
filters.push("s.id IN (SELECT synergy_id FROM synergy_products WHERE product = @product)");
|
|
416
|
+
params.product = opts.product;
|
|
417
|
+
}
|
|
418
|
+
if (opts.status) {
|
|
419
|
+
filters.push("s.status = @status");
|
|
420
|
+
params.status = opts.status;
|
|
421
|
+
}
|
|
422
|
+
const where = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
|
|
423
|
+
const limitClause = opts.limit ? "LIMIT @limit" : "";
|
|
424
|
+
if (opts.limit) params.limit = opts.limit;
|
|
425
|
+
const sql = `
|
|
426
|
+
SELECT s.id, s.name, s.title, s.trigger, s.status, s.last_validated, s.notes_path
|
|
427
|
+
FROM synergies s
|
|
428
|
+
${where}
|
|
429
|
+
ORDER BY s.last_validated DESC, s.name
|
|
430
|
+
${limitClause}
|
|
431
|
+
`;
|
|
432
|
+
const rows = db.prepare(sql).all(params);
|
|
433
|
+
if (rows.length === 0) return [];
|
|
434
|
+
const ids = rows.map((r) => r.id);
|
|
435
|
+
const productsByIdSql = `
|
|
436
|
+
SELECT synergy_id, product FROM synergy_products
|
|
437
|
+
WHERE synergy_id IN (${ids.map(() => "?").join(",")})
|
|
438
|
+
ORDER BY synergy_id, product
|
|
439
|
+
`;
|
|
440
|
+
const productRows = db.prepare(productsByIdSql).all(...ids);
|
|
441
|
+
const productsById = /* @__PURE__ */ new Map();
|
|
442
|
+
for (const pr of productRows) {
|
|
443
|
+
if (!productsById.has(pr.synergy_id)) productsById.set(pr.synergy_id, []);
|
|
444
|
+
productsById.get(pr.synergy_id).push(pr.product);
|
|
445
|
+
}
|
|
446
|
+
return rows.map((r) => ({ ...r, products: productsById.get(r.id) ?? [] }));
|
|
447
|
+
}
|
|
448
|
+
function getSynergy(db, name) {
|
|
449
|
+
const synergy = db.prepare(`
|
|
450
|
+
SELECT id, name, title, trigger, status, last_validated, notes_path
|
|
451
|
+
FROM synergies WHERE name = ?
|
|
452
|
+
`).get(name);
|
|
453
|
+
if (!synergy) return null;
|
|
454
|
+
const products = db.prepare(`SELECT product FROM synergy_products WHERE synergy_id = ? ORDER BY product`).all(synergy.id).map((r) => r.product);
|
|
455
|
+
const steps = db.prepare(`SELECT ordinal, text FROM synergy_steps WHERE synergy_id = ? ORDER BY ordinal`).all(synergy.id);
|
|
456
|
+
const evidence = db.prepare(`SELECT source_url, quote, source_kind FROM synergy_evidence WHERE synergy_id = ?`).all(synergy.id);
|
|
457
|
+
const change_refs = db.prepare(`
|
|
458
|
+
SELECT scr.change_id, c.product, c.version, c.text
|
|
459
|
+
FROM synergy_change_refs scr
|
|
460
|
+
JOIN changes c ON c.id = scr.change_id
|
|
461
|
+
WHERE scr.synergy_id = ?
|
|
462
|
+
ORDER BY c.product, c.version
|
|
463
|
+
`).all(synergy.id);
|
|
464
|
+
return {
|
|
465
|
+
...synergy,
|
|
466
|
+
products,
|
|
467
|
+
steps,
|
|
468
|
+
evidence,
|
|
469
|
+
change_refs
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// src/embed.ts
|
|
474
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
475
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
476
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
477
|
+
|
|
478
|
+
// src/providers/context/none.ts
|
|
479
|
+
var NoneContextProvider = class {
|
|
480
|
+
name = "none";
|
|
481
|
+
async contextFor(_chunk, _release) {
|
|
482
|
+
return "";
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// src/providers/context/structured.ts
|
|
487
|
+
var StructuredContextProvider = class {
|
|
488
|
+
name = "structured";
|
|
489
|
+
async contextFor(chunk, release) {
|
|
490
|
+
const date = release.releasedAt ?? "unknown date";
|
|
491
|
+
const positional = `change ${chunk.ordinalInRelease} of ${chunk.totalInRelease}`;
|
|
492
|
+
return `In ${release.product} ${release.version} (${date}), ${chunk.kind} (${positional}):`;
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// src/providers/context/ollama.ts
|
|
497
|
+
function providerTimeoutMs() {
|
|
498
|
+
const raw = process.env.CLAUDE_SYNERGY_PROVIDER_TIMEOUT_MS;
|
|
499
|
+
const n = raw === void 0 ? NaN : parseInt(raw, 10);
|
|
500
|
+
return Number.isFinite(n) && n > 0 ? n : 6e4;
|
|
501
|
+
}
|
|
502
|
+
async function safeErrorBody(res, max = 200) {
|
|
503
|
+
try {
|
|
504
|
+
const body = await res.text();
|
|
505
|
+
const safe = body.split("\n").filter((l) => !/x-api-key|authorization|bearer|api[-_]?key/i.test(l)).join("\n");
|
|
506
|
+
return safe.slice(0, max);
|
|
507
|
+
} catch {
|
|
508
|
+
return "<unreadable>";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
var OllamaContextProvider = class {
|
|
512
|
+
name = "ollama";
|
|
513
|
+
host;
|
|
514
|
+
model;
|
|
515
|
+
constructor(opts = {}) {
|
|
516
|
+
const rawHost = opts.host ?? process.env.OLLAMA_HOST ?? "http://localhost:11434";
|
|
517
|
+
this.host = /^https?:\/\//.test(rawHost) ? rawHost : `http://${rawHost}`;
|
|
518
|
+
this.model = opts.model ?? process.env.OLLAMA_GEN_MODEL ?? "llama3.2:3b";
|
|
519
|
+
}
|
|
520
|
+
async contextFor(chunk, release) {
|
|
521
|
+
const releaseSnippet = release.siblings.map((s, i) => `${i + 1}. ${s.text}`).slice(0, 30).join("\n");
|
|
522
|
+
const prompt = [
|
|
523
|
+
`<document>`,
|
|
524
|
+
`${release.product} ${release.version} release notes (${release.releasedAt ?? "unknown date"}):`,
|
|
525
|
+
releaseSnippet,
|
|
526
|
+
`</document>`,
|
|
527
|
+
``,
|
|
528
|
+
`Here is the chunk we want to situate within the whole document:`,
|
|
529
|
+
`<chunk>`,
|
|
530
|
+
chunk.text,
|
|
531
|
+
`</chunk>`,
|
|
532
|
+
``,
|
|
533
|
+
`Please give a short succinct context (1 sentence, max 30 words) to situate this chunk within the overall document for improving search retrieval of the chunk. Mention any related product names, env vars, command names, or APIs. Answer only with the succinct context and nothing else.`
|
|
534
|
+
].join("\n");
|
|
535
|
+
const timeoutMs = providerTimeoutMs();
|
|
536
|
+
let res;
|
|
537
|
+
try {
|
|
538
|
+
res = await fetch(`${this.host}/api/generate`, {
|
|
539
|
+
method: "POST",
|
|
540
|
+
headers: { "content-type": "application/json" },
|
|
541
|
+
body: JSON.stringify({
|
|
542
|
+
model: this.model,
|
|
543
|
+
prompt,
|
|
544
|
+
stream: false,
|
|
545
|
+
options: { temperature: 0, num_predict: 80 }
|
|
546
|
+
}),
|
|
547
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
548
|
+
});
|
|
549
|
+
} catch (e) {
|
|
550
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
551
|
+
throw new Error(`Ollama context request timed out after ${timeoutMs}ms \u2014 is the Ollama server responsive?`);
|
|
552
|
+
}
|
|
553
|
+
throw e;
|
|
554
|
+
}
|
|
555
|
+
if (!res.ok) throw new Error(`Ollama ${res.status}: ${await safeErrorBody(res)}`);
|
|
556
|
+
const json = await res.json();
|
|
557
|
+
return json.response.trim();
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// src/providers/context/claude-haiku.ts
|
|
562
|
+
function providerTimeoutMs2() {
|
|
563
|
+
const raw = process.env.CLAUDE_SYNERGY_PROVIDER_TIMEOUT_MS;
|
|
564
|
+
const n = raw === void 0 ? NaN : parseInt(raw, 10);
|
|
565
|
+
return Number.isFinite(n) && n > 0 ? n : 6e4;
|
|
566
|
+
}
|
|
567
|
+
async function safeErrorBody2(res, max = 200) {
|
|
568
|
+
try {
|
|
569
|
+
const body = await res.text();
|
|
570
|
+
const safe = body.split("\n").filter((l) => !/x-api-key|authorization|bearer|api[-_]?key/i.test(l)).join("\n");
|
|
571
|
+
return safe.slice(0, max);
|
|
572
|
+
} catch {
|
|
573
|
+
return "<unreadable>";
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
var ClaudeHaikuContextProvider = class {
|
|
577
|
+
name = "claude-haiku";
|
|
578
|
+
apiKey;
|
|
579
|
+
model;
|
|
580
|
+
cachedDocByRelease = /* @__PURE__ */ new Map();
|
|
581
|
+
constructor(opts = {}) {
|
|
582
|
+
this.apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "";
|
|
583
|
+
this.model = opts.model ?? "claude-haiku-4-5-20251001";
|
|
584
|
+
if (!this.apiKey) {
|
|
585
|
+
throw new Error("claude-haiku context provider requires ANTHROPIC_API_KEY");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async contextFor(chunk, release) {
|
|
589
|
+
const releaseKey = `${release.product}@${release.version}`;
|
|
590
|
+
let doc = this.cachedDocByRelease.get(releaseKey);
|
|
591
|
+
if (!doc) {
|
|
592
|
+
doc = release.siblings.map((s, i) => `${i + 1}. ${s.text}`).join("\n");
|
|
593
|
+
this.cachedDocByRelease.set(releaseKey, doc);
|
|
594
|
+
}
|
|
595
|
+
const userPrompt = [
|
|
596
|
+
`Chunk: ${chunk.text}`,
|
|
597
|
+
``,
|
|
598
|
+
`Give a 1-sentence context (max 30 words) situating this chunk in the release. Mention related products, env vars, commands. Output ONLY the context.`
|
|
599
|
+
].join("\n");
|
|
600
|
+
const timeoutMs = providerTimeoutMs2();
|
|
601
|
+
let res;
|
|
602
|
+
try {
|
|
603
|
+
res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
604
|
+
method: "POST",
|
|
605
|
+
headers: {
|
|
606
|
+
"x-api-key": this.apiKey,
|
|
607
|
+
"anthropic-version": "2023-06-01",
|
|
608
|
+
"content-type": "application/json"
|
|
609
|
+
},
|
|
610
|
+
body: JSON.stringify({
|
|
611
|
+
model: this.model,
|
|
612
|
+
max_tokens: 80,
|
|
613
|
+
system: [
|
|
614
|
+
{
|
|
615
|
+
type: "text",
|
|
616
|
+
text: `${release.product} ${release.version} (${release.releasedAt}) release notes:
|
|
617
|
+
${doc}`,
|
|
618
|
+
cache_control: { type: "ephemeral" }
|
|
619
|
+
}
|
|
620
|
+
],
|
|
621
|
+
messages: [{ role: "user", content: userPrompt }]
|
|
622
|
+
}),
|
|
623
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
624
|
+
});
|
|
625
|
+
} catch (e) {
|
|
626
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
627
|
+
throw new Error(`Anthropic API request timed out after ${timeoutMs}ms \u2014 is the API responsive?`);
|
|
628
|
+
}
|
|
629
|
+
throw e;
|
|
630
|
+
}
|
|
631
|
+
if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${await safeErrorBody2(res)}`);
|
|
632
|
+
const json = await res.json();
|
|
633
|
+
return (json.content.find((c) => c.type === "text")?.text ?? "").trim();
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
// src/providers/embedding/ollama.ts
|
|
638
|
+
function providerTimeoutMs3() {
|
|
639
|
+
const raw = process.env.CLAUDE_SYNERGY_PROVIDER_TIMEOUT_MS;
|
|
640
|
+
const n = raw === void 0 ? NaN : parseInt(raw, 10);
|
|
641
|
+
return Number.isFinite(n) && n > 0 ? n : 6e4;
|
|
642
|
+
}
|
|
643
|
+
async function safeErrorBody3(res, max = 200) {
|
|
644
|
+
try {
|
|
645
|
+
const body = await res.text();
|
|
646
|
+
const safe = body.split("\n").filter((l) => !/x-api-key|authorization|bearer|api[-_]?key/i.test(l)).join("\n");
|
|
647
|
+
return safe.slice(0, max);
|
|
648
|
+
} catch {
|
|
649
|
+
return "<unreadable>";
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
var OllamaEmbeddingProvider = class {
|
|
653
|
+
name = "ollama";
|
|
654
|
+
model;
|
|
655
|
+
dimension = 768;
|
|
656
|
+
host;
|
|
657
|
+
constructor(opts = {}) {
|
|
658
|
+
const rawHost = opts.host ?? process.env.OLLAMA_HOST ?? "http://localhost:11434";
|
|
659
|
+
this.host = /^https?:\/\//.test(rawHost) ? rawHost : `http://${rawHost}`;
|
|
660
|
+
this.model = opts.model ?? process.env.OLLAMA_EMBED_MODEL ?? "nomic-embed-text";
|
|
661
|
+
}
|
|
662
|
+
async embed(inputs) {
|
|
663
|
+
if (inputs.length === 0) return [];
|
|
664
|
+
const timeoutMs = providerTimeoutMs3();
|
|
665
|
+
let res;
|
|
666
|
+
try {
|
|
667
|
+
res = await fetch(`${this.host}/api/embed`, {
|
|
668
|
+
method: "POST",
|
|
669
|
+
headers: { "content-type": "application/json" },
|
|
670
|
+
body: JSON.stringify({
|
|
671
|
+
model: this.model,
|
|
672
|
+
input: inputs
|
|
673
|
+
}),
|
|
674
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
675
|
+
});
|
|
676
|
+
} catch (e) {
|
|
677
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
678
|
+
throw new Error(`Ollama embed request timed out after ${timeoutMs}ms \u2014 is the Ollama server responsive?`);
|
|
679
|
+
}
|
|
680
|
+
throw e;
|
|
681
|
+
}
|
|
682
|
+
if (!res.ok) throw new Error(`Ollama embed ${res.status}: ${await safeErrorBody3(res)}`);
|
|
683
|
+
const json = await res.json();
|
|
684
|
+
return json.embeddings.map((e) => {
|
|
685
|
+
if (e.length !== this.dimension) {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`expected dimension ${this.dimension} for ${this.model}, got ${e.length}. Set OLLAMA_EMBED_MODEL or update dimension.`
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
return Float32Array.from(e);
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
// src/providers/retry.ts
|
|
696
|
+
var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
|
|
697
|
+
async function withRetry(fn, opts = {}) {
|
|
698
|
+
const maxAttempts = opts.maxAttempts ?? 3;
|
|
699
|
+
const baseDelayMs = opts.baseDelayMs ?? 1e3;
|
|
700
|
+
const maxDelayMs = opts.maxDelayMs ?? 3e4;
|
|
701
|
+
let lastError;
|
|
702
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
703
|
+
if (opts.signal?.aborted) {
|
|
704
|
+
throw new Error("Operation cancelled via AbortSignal");
|
|
705
|
+
}
|
|
706
|
+
try {
|
|
707
|
+
const data = await fn(attempt);
|
|
708
|
+
return { data, attempts: attempt };
|
|
709
|
+
} catch (err) {
|
|
710
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
711
|
+
if (attempt >= maxAttempts) break;
|
|
712
|
+
if (!isRetryable(lastError)) break;
|
|
713
|
+
const expDelay = baseDelayMs * Math.pow(2, attempt - 1);
|
|
714
|
+
const cappedDelay = Math.min(expDelay, maxDelayMs);
|
|
715
|
+
const jitteredDelay = Math.random() * cappedDelay;
|
|
716
|
+
await sleep(jitteredDelay, opts.signal);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
throw lastError;
|
|
720
|
+
}
|
|
721
|
+
function isRetryable(err) {
|
|
722
|
+
const msg = err.message;
|
|
723
|
+
for (const status of RETRYABLE_STATUSES) {
|
|
724
|
+
if (msg.includes(String(status))) return true;
|
|
725
|
+
}
|
|
726
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") return true;
|
|
727
|
+
if (msg.includes("timed out")) return true;
|
|
728
|
+
if (msg.includes("ECONNRESET") || msg.includes("ECONNREFUSED")) return true;
|
|
729
|
+
if (msg.includes("fetch failed") || msg.includes("network")) return true;
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
function sleep(ms, signal) {
|
|
733
|
+
return new Promise((resolve2, reject) => {
|
|
734
|
+
if (signal?.aborted) {
|
|
735
|
+
reject(new Error("Operation cancelled via AbortSignal"));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const timer = setTimeout(resolve2, ms);
|
|
739
|
+
signal?.addEventListener("abort", () => {
|
|
740
|
+
clearTimeout(timer);
|
|
741
|
+
reject(new Error("Operation cancelled via AbortSignal"));
|
|
742
|
+
}, { once: true });
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
function providerTimeoutMs4() {
|
|
746
|
+
const raw = process.env.CLAUDE_SYNERGY_PROVIDER_TIMEOUT_MS;
|
|
747
|
+
const n = raw === void 0 ? NaN : parseInt(raw, 10);
|
|
748
|
+
return Number.isFinite(n) && n > 0 ? n : 6e4;
|
|
749
|
+
}
|
|
750
|
+
async function safeErrorBody4(res, max = 200) {
|
|
751
|
+
try {
|
|
752
|
+
const body = await res.text();
|
|
753
|
+
const safe = body.split("\n").filter((l) => !/x-api-key|authorization|bearer|api[-_]?key/i.test(l)).join("\n");
|
|
754
|
+
return safe.slice(0, max);
|
|
755
|
+
} catch {
|
|
756
|
+
return "<unreadable>";
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// src/providers/embedding/voyage.ts
|
|
761
|
+
var VoyageEmbeddingProvider = class {
|
|
762
|
+
name = "voyage";
|
|
763
|
+
model;
|
|
764
|
+
dimension = 768;
|
|
765
|
+
apiKey;
|
|
766
|
+
/** Accumulated usage across all embed() calls on this instance. */
|
|
767
|
+
_usage = { tokens: 0, requests: 0 };
|
|
768
|
+
constructor(opts = {}) {
|
|
769
|
+
this.apiKey = opts.apiKey ?? process.env.VOYAGE_API_KEY ?? "";
|
|
770
|
+
this.model = opts.model ?? "voyage-3-large";
|
|
771
|
+
if (!this.apiKey) {
|
|
772
|
+
throw new Error("voyage embedding provider requires VOYAGE_API_KEY");
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/** Get accumulated usage stats. */
|
|
776
|
+
get usage() {
|
|
777
|
+
return { ...this._usage };
|
|
778
|
+
}
|
|
779
|
+
/** Reset accumulated usage counters. */
|
|
780
|
+
resetUsage() {
|
|
781
|
+
this._usage = { tokens: 0, requests: 0 };
|
|
782
|
+
}
|
|
783
|
+
async embed(inputs, signal) {
|
|
784
|
+
const timeoutMs = providerTimeoutMs4();
|
|
785
|
+
const { data: result } = await withRetry(
|
|
786
|
+
async () => {
|
|
787
|
+
let res;
|
|
788
|
+
try {
|
|
789
|
+
res = await fetch("https://api.voyageai.com/v1/embeddings", {
|
|
790
|
+
method: "POST",
|
|
791
|
+
headers: {
|
|
792
|
+
"authorization": `Bearer ${this.apiKey}`,
|
|
793
|
+
"content-type": "application/json"
|
|
794
|
+
},
|
|
795
|
+
body: JSON.stringify({
|
|
796
|
+
model: this.model,
|
|
797
|
+
input: inputs,
|
|
798
|
+
input_type: "document",
|
|
799
|
+
output_dimension: this.dimension
|
|
800
|
+
}),
|
|
801
|
+
signal: signal ?? AbortSignal.timeout(timeoutMs)
|
|
802
|
+
});
|
|
803
|
+
} catch (e) {
|
|
804
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
805
|
+
throw new Error(`Voyage embed request timed out after ${timeoutMs}ms \u2014 is the API responsive?`);
|
|
806
|
+
}
|
|
807
|
+
throw e;
|
|
808
|
+
}
|
|
809
|
+
if (!res.ok) throw new Error(`Voyage ${res.status}: ${await safeErrorBody4(res)}`);
|
|
810
|
+
const json = await res.json();
|
|
811
|
+
return json;
|
|
812
|
+
},
|
|
813
|
+
{ maxAttempts: 3, signal }
|
|
814
|
+
);
|
|
815
|
+
this._usage.requests++;
|
|
816
|
+
if (result.usage?.total_tokens) {
|
|
817
|
+
this._usage.tokens += result.usage.total_tokens;
|
|
818
|
+
}
|
|
819
|
+
return result.data.map((d) => Float32Array.from(d.embedding));
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
// src/providers/embedding/openai.ts
|
|
824
|
+
var NATIVE_DIMS = {
|
|
825
|
+
"text-embedding-3-small": 1536,
|
|
826
|
+
"text-embedding-3-large": 3072,
|
|
827
|
+
"text-embedding-ada-002": 1536
|
|
828
|
+
};
|
|
829
|
+
var OpenAIEmbeddingProvider = class {
|
|
830
|
+
name = "openai";
|
|
831
|
+
model;
|
|
832
|
+
dimension;
|
|
833
|
+
apiKey;
|
|
834
|
+
baseUrl;
|
|
835
|
+
/** Whether the requested dimension matches native (skip sending `dimensions` param). */
|
|
836
|
+
useNativeDim;
|
|
837
|
+
_usage = { tokens: 0, requests: 0 };
|
|
838
|
+
constructor(opts = {}) {
|
|
839
|
+
this.apiKey = opts.apiKey ?? process.env.OPENAI_API_KEY ?? "";
|
|
840
|
+
this.model = opts.model ?? process.env.OPENAI_EMBED_MODEL ?? "text-embedding-3-small";
|
|
841
|
+
this.baseUrl = opts.baseUrl ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
|
|
842
|
+
const native = NATIVE_DIMS[this.model];
|
|
843
|
+
if (opts.dim !== void 0) {
|
|
844
|
+
if (native !== void 0 && opts.dim > native) {
|
|
845
|
+
throw new Error(
|
|
846
|
+
`OpenAI model ${this.model} has native dim ${native}; requested ${opts.dim} exceeds native and cannot be expanded.`
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
this.dimension = opts.dim;
|
|
850
|
+
this.useNativeDim = native !== void 0 && opts.dim === native;
|
|
851
|
+
} else {
|
|
852
|
+
this.dimension = native ?? 1536;
|
|
853
|
+
this.useNativeDim = true;
|
|
854
|
+
}
|
|
855
|
+
if (!this.apiKey) {
|
|
856
|
+
throw new Error("openai embedding provider requires OPENAI_API_KEY");
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/** Get accumulated usage stats. */
|
|
860
|
+
get usage() {
|
|
861
|
+
return { ...this._usage };
|
|
862
|
+
}
|
|
863
|
+
/** Reset accumulated usage counters. */
|
|
864
|
+
resetUsage() {
|
|
865
|
+
this._usage = { tokens: 0, requests: 0 };
|
|
866
|
+
}
|
|
867
|
+
async embed(inputs, signal) {
|
|
868
|
+
if (inputs.length === 0) return [];
|
|
869
|
+
const timeoutMs = providerTimeoutMs4();
|
|
870
|
+
const body = {
|
|
871
|
+
model: this.model,
|
|
872
|
+
input: inputs
|
|
873
|
+
};
|
|
874
|
+
if (!this.useNativeDim) {
|
|
875
|
+
body.dimensions = this.dimension;
|
|
876
|
+
}
|
|
877
|
+
const { data: result } = await withRetry(
|
|
878
|
+
async () => {
|
|
879
|
+
let res;
|
|
880
|
+
try {
|
|
881
|
+
res = await fetch(`${this.baseUrl}/embeddings`, {
|
|
882
|
+
method: "POST",
|
|
883
|
+
headers: {
|
|
884
|
+
"authorization": `Bearer ${this.apiKey}`,
|
|
885
|
+
"content-type": "application/json"
|
|
886
|
+
},
|
|
887
|
+
body: JSON.stringify(body),
|
|
888
|
+
signal: signal ?? AbortSignal.timeout(timeoutMs)
|
|
889
|
+
});
|
|
890
|
+
} catch (e) {
|
|
891
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
892
|
+
throw new Error(`OpenAI embed request timed out after ${timeoutMs}ms \u2014 is the API responsive?`);
|
|
893
|
+
}
|
|
894
|
+
throw e;
|
|
895
|
+
}
|
|
896
|
+
if (!res.ok) throw new Error(`OpenAI ${res.status}: ${await safeErrorBody4(res)}`);
|
|
897
|
+
const json = await res.json();
|
|
898
|
+
return json;
|
|
899
|
+
},
|
|
900
|
+
{ maxAttempts: 3, signal }
|
|
901
|
+
);
|
|
902
|
+
this._usage.requests++;
|
|
903
|
+
const totalTokens = result.usage?.total_tokens ?? result.usage?.prompt_tokens;
|
|
904
|
+
if (typeof totalTokens === "number") {
|
|
905
|
+
this._usage.tokens += totalTokens;
|
|
906
|
+
}
|
|
907
|
+
const sorted = [...result.data].sort((a, b) => a.index - b.index);
|
|
908
|
+
return sorted.map((d) => {
|
|
909
|
+
if (d.embedding.length !== this.dimension) {
|
|
910
|
+
throw new Error(
|
|
911
|
+
`OpenAI returned ${d.embedding.length}-d embedding for ${this.model}; expected ${this.dimension}. Check OPENAI_EMBED_MODEL or re-init the DB.`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
return Float32Array.from(d.embedding);
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
// src/embed.ts
|
|
920
|
+
var __dirname3 = dirname2(fileURLToPath2(import.meta.url));
|
|
921
|
+
function initVecSchema(db, opts = {}) {
|
|
922
|
+
if (opts.dim !== void 0) {
|
|
923
|
+
setEmbeddingDim(db, opts.dim);
|
|
924
|
+
}
|
|
925
|
+
const existing = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='chunks'`).get();
|
|
926
|
+
if (!existing) {
|
|
927
|
+
const schemaPath = resolveSchemaVecPath();
|
|
928
|
+
const sql = readFileSync2(schemaPath, "utf-8");
|
|
929
|
+
db.exec(sql);
|
|
930
|
+
}
|
|
931
|
+
const hasVec = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_vec'`).get();
|
|
932
|
+
if (!hasVec) {
|
|
933
|
+
const dim = getEmbeddingDim(db) ?? DEFAULT_EMBEDDING_DIM;
|
|
934
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding FLOAT[${dim}])`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function resolveSchemaVecPath() {
|
|
938
|
+
const candidates = [
|
|
939
|
+
join2(__dirname3, "..", "schema-vec.sql"),
|
|
940
|
+
join2(process.cwd(), "schema-vec.sql")
|
|
941
|
+
];
|
|
942
|
+
for (const p of candidates) {
|
|
943
|
+
try {
|
|
944
|
+
readFileSync2(p);
|
|
945
|
+
return p;
|
|
946
|
+
} catch {
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
throw new Error(`schema-vec.sql not found in: ${candidates.join(", ")}`);
|
|
950
|
+
}
|
|
951
|
+
async function embedAll(db, opts) {
|
|
952
|
+
const ctx = makeContextProvider(opts.contextProviderName);
|
|
953
|
+
const existingDim = getEmbeddingDim(db);
|
|
954
|
+
const emb = makeEmbeddingProvider(opts.embeddingProviderName, {
|
|
955
|
+
dim: existingDim ?? void 0
|
|
956
|
+
});
|
|
957
|
+
setEmbeddingDim(db, emb.dimension);
|
|
958
|
+
initVecSchema(db);
|
|
959
|
+
const productFilter = opts.product ? "AND c.product = @product" : "";
|
|
960
|
+
const forceFilter = opts.force ? "" : "AND ch.id IS NULL";
|
|
961
|
+
const limitClause = opts.limit ? "LIMIT @limit" : "";
|
|
962
|
+
const pendingSql = `
|
|
963
|
+
SELECT c.id AS change_id, c.product, c.version, r.released_at, c.kind, c.text, c.ordinal
|
|
964
|
+
FROM changes c
|
|
965
|
+
JOIN releases r ON c.product = r.product AND c.version = r.version
|
|
966
|
+
LEFT JOIN chunks ch ON ch.change_id = c.id
|
|
967
|
+
WHERE 1=1
|
|
968
|
+
${productFilter}
|
|
969
|
+
${forceFilter}
|
|
970
|
+
ORDER BY c.product, c.version, c.ordinal
|
|
971
|
+
${limitClause}
|
|
972
|
+
`;
|
|
973
|
+
const params = {};
|
|
974
|
+
if (opts.product) params.product = opts.product;
|
|
975
|
+
if (opts.limit) params.limit = opts.limit;
|
|
976
|
+
const pending = db.prepare(pendingSql).all(params);
|
|
977
|
+
if (pending.length === 0) {
|
|
978
|
+
return {
|
|
979
|
+
contextProvider: ctx.name,
|
|
980
|
+
embeddingProvider: emb.name,
|
|
981
|
+
chunksCreated: 0,
|
|
982
|
+
chunksSkipped: 0,
|
|
983
|
+
contextMs: 0,
|
|
984
|
+
embedMs: 0,
|
|
985
|
+
totalMs: 0
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
const byRelease = /* @__PURE__ */ new Map();
|
|
989
|
+
for (const row of pending) {
|
|
990
|
+
const key = `${row.product}@${row.version}`;
|
|
991
|
+
if (!byRelease.has(key)) byRelease.set(key, []);
|
|
992
|
+
byRelease.get(key).push({
|
|
993
|
+
changeId: row.change_id,
|
|
994
|
+
product: row.product,
|
|
995
|
+
releaseVersion: row.version,
|
|
996
|
+
releasedAt: row.released_at,
|
|
997
|
+
kind: row.kind,
|
|
998
|
+
text: row.text,
|
|
999
|
+
ordinalInRelease: row.ordinal,
|
|
1000
|
+
totalInRelease: 0
|
|
1001
|
+
// backfilled below
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
for (const [, list] of byRelease) {
|
|
1005
|
+
for (const c of list) c.totalInRelease = list.length;
|
|
1006
|
+
}
|
|
1007
|
+
const startTotal = Date.now();
|
|
1008
|
+
let contextMs = 0;
|
|
1009
|
+
let embedMs = 0;
|
|
1010
|
+
const contextsByChange = /* @__PURE__ */ new Map();
|
|
1011
|
+
const allChunks = [];
|
|
1012
|
+
for (const [, siblings] of byRelease) {
|
|
1013
|
+
const release = {
|
|
1014
|
+
product: siblings[0].product,
|
|
1015
|
+
version: siblings[0].releaseVersion,
|
|
1016
|
+
releasedAt: siblings[0].releasedAt,
|
|
1017
|
+
siblings
|
|
1018
|
+
};
|
|
1019
|
+
for (const chunk of siblings) {
|
|
1020
|
+
if (opts.signal?.aborted) break;
|
|
1021
|
+
const t0 = Date.now();
|
|
1022
|
+
const ctxPrefix = await ctx.contextFor(chunk, release);
|
|
1023
|
+
contextMs += Date.now() - t0;
|
|
1024
|
+
contextsByChange.set(chunk.changeId, ctxPrefix);
|
|
1025
|
+
allChunks.push(chunk);
|
|
1026
|
+
}
|
|
1027
|
+
if (opts.signal?.aborted) break;
|
|
1028
|
+
}
|
|
1029
|
+
const batchSize = opts.batchSize ?? 64;
|
|
1030
|
+
const insertChunk = db.prepare(`
|
|
1031
|
+
INSERT OR REPLACE INTO chunks
|
|
1032
|
+
(change_id, product, release_version, released_at, context_prefix, original_text, contextualized, context_provider, embedding_model, embedded_at)
|
|
1033
|
+
VALUES
|
|
1034
|
+
(@change_id, @product, @release_version, @released_at, @context_prefix, @original_text, @contextualized, @context_provider, @embedding_model, @embedded_at)
|
|
1035
|
+
`);
|
|
1036
|
+
const deleteVec = db.prepare(`DELETE FROM chunks_vec WHERE rowid = ?`);
|
|
1037
|
+
const insertVec = db.prepare(`INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)`);
|
|
1038
|
+
let created = 0;
|
|
1039
|
+
let stoppedEarly = false;
|
|
1040
|
+
let stopReason;
|
|
1041
|
+
const usage = { requests: 0, tokens: 0 };
|
|
1042
|
+
const totalBatches = Math.ceil(allChunks.length / batchSize);
|
|
1043
|
+
for (let i = 0; i < allChunks.length; i += batchSize) {
|
|
1044
|
+
if (opts.signal?.aborted) {
|
|
1045
|
+
stoppedEarly = true;
|
|
1046
|
+
stopReason = "cancelled";
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
if (opts.maxRequests !== void 0 && usage.requests >= opts.maxRequests) {
|
|
1050
|
+
stoppedEarly = true;
|
|
1051
|
+
stopReason = "budget_requests";
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
if (opts.maxTokens !== void 0 && usage.tokens >= opts.maxTokens) {
|
|
1055
|
+
stoppedEarly = true;
|
|
1056
|
+
stopReason = "budget_tokens";
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
const batch = allChunks.slice(i, i + batchSize);
|
|
1060
|
+
const texts = batch.map((c) => {
|
|
1061
|
+
const prefix = contextsByChange.get(c.changeId) ?? "";
|
|
1062
|
+
return prefix ? `${prefix}
|
|
1063
|
+
|
|
1064
|
+
${c.text}` : c.text;
|
|
1065
|
+
});
|
|
1066
|
+
const t0 = Date.now();
|
|
1067
|
+
const vectors = await emb.embed(texts);
|
|
1068
|
+
embedMs += Date.now() - t0;
|
|
1069
|
+
usage.requests++;
|
|
1070
|
+
if ("usage" in emb) {
|
|
1071
|
+
const provUsage = emb.usage;
|
|
1072
|
+
if (provUsage && typeof provUsage.tokens === "number") {
|
|
1073
|
+
usage.tokens = provUsage.tokens;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const tx = db.transaction(() => {
|
|
1077
|
+
for (let j = 0; j < batch.length; j++) {
|
|
1078
|
+
const c = batch[j];
|
|
1079
|
+
const prefix = contextsByChange.get(c.changeId) ?? "";
|
|
1080
|
+
const contextualized = prefix ? `${prefix}
|
|
1081
|
+
|
|
1082
|
+
${c.text}` : c.text;
|
|
1083
|
+
const result = insertChunk.run({
|
|
1084
|
+
change_id: c.changeId,
|
|
1085
|
+
product: c.product,
|
|
1086
|
+
release_version: c.releaseVersion,
|
|
1087
|
+
released_at: c.releasedAt,
|
|
1088
|
+
context_prefix: prefix,
|
|
1089
|
+
original_text: c.text,
|
|
1090
|
+
contextualized,
|
|
1091
|
+
context_provider: ctx.name,
|
|
1092
|
+
embedding_model: `${emb.name}:${emb.model}`,
|
|
1093
|
+
embedded_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1094
|
+
});
|
|
1095
|
+
const chunkId = Number(result.lastInsertRowid);
|
|
1096
|
+
deleteVec.run(chunkId);
|
|
1097
|
+
insertVec.run(BigInt(chunkId), vectors[j]);
|
|
1098
|
+
created++;
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
tx();
|
|
1102
|
+
if (opts.onProgress) {
|
|
1103
|
+
opts.onProgress({
|
|
1104
|
+
batchesCompleted: Math.floor(i / batchSize) + 1,
|
|
1105
|
+
batchesTotal: totalBatches,
|
|
1106
|
+
chunksCompleted: created,
|
|
1107
|
+
chunksTotal: allChunks.length,
|
|
1108
|
+
provider: `${emb.name}:${emb.model}`,
|
|
1109
|
+
tokensUsed: usage.tokens,
|
|
1110
|
+
requestsMade: usage.requests
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const stats = {
|
|
1115
|
+
contextProvider: ctx.name,
|
|
1116
|
+
embeddingProvider: `${emb.name}:${emb.model}`,
|
|
1117
|
+
chunksCreated: created,
|
|
1118
|
+
chunksSkipped: 0,
|
|
1119
|
+
contextMs,
|
|
1120
|
+
embedMs,
|
|
1121
|
+
totalMs: Date.now() - startTotal
|
|
1122
|
+
};
|
|
1123
|
+
if (usage.requests > 0) {
|
|
1124
|
+
stats.usage = usage;
|
|
1125
|
+
}
|
|
1126
|
+
if (stoppedEarly) {
|
|
1127
|
+
stats.stoppedEarly = true;
|
|
1128
|
+
stats.stopReason = stopReason;
|
|
1129
|
+
}
|
|
1130
|
+
return stats;
|
|
1131
|
+
}
|
|
1132
|
+
function makeContextProvider(name) {
|
|
1133
|
+
switch (name) {
|
|
1134
|
+
case "none":
|
|
1135
|
+
return new NoneContextProvider();
|
|
1136
|
+
case "structured":
|
|
1137
|
+
return new StructuredContextProvider();
|
|
1138
|
+
case "ollama":
|
|
1139
|
+
return new OllamaContextProvider();
|
|
1140
|
+
case "claude-haiku":
|
|
1141
|
+
return new ClaudeHaikuContextProvider();
|
|
1142
|
+
default:
|
|
1143
|
+
throw new Error(`unknown context provider: ${name}`);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
function makeEmbeddingProvider(name, opts = {}) {
|
|
1147
|
+
switch (name) {
|
|
1148
|
+
case "ollama":
|
|
1149
|
+
return new OllamaEmbeddingProvider({ model: opts.model });
|
|
1150
|
+
case "voyage":
|
|
1151
|
+
return new VoyageEmbeddingProvider({ model: opts.model });
|
|
1152
|
+
case "openai":
|
|
1153
|
+
return new OpenAIEmbeddingProvider({ model: opts.model, dim: opts.dim });
|
|
1154
|
+
default: {
|
|
1155
|
+
const _exhaustive = name;
|
|
1156
|
+
throw new AppError({
|
|
1157
|
+
code: "EMBED_PROVIDER_UNKNOWN",
|
|
1158
|
+
message: `unknown embedding provider: ${String(_exhaustive)}`,
|
|
1159
|
+
hint: "use one of: ollama | voyage | openai"
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// src/providers/rerank/ollama-judge.ts
|
|
1166
|
+
function providerTimeoutMs5() {
|
|
1167
|
+
const raw = process.env.CLAUDE_SYNERGY_PROVIDER_TIMEOUT_MS;
|
|
1168
|
+
const n = raw === void 0 ? NaN : parseInt(raw, 10);
|
|
1169
|
+
return Number.isFinite(n) && n > 0 ? n : 6e4;
|
|
1170
|
+
}
|
|
1171
|
+
async function safeErrorBody5(res, max = 200) {
|
|
1172
|
+
try {
|
|
1173
|
+
const body = await res.text();
|
|
1174
|
+
const safe = body.split("\n").filter((l) => !/x-api-key|authorization|bearer|api[-_]?key/i.test(l)).join("\n");
|
|
1175
|
+
return safe.slice(0, max);
|
|
1176
|
+
} catch {
|
|
1177
|
+
return "<unreadable>";
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
var OllamaJudgeRerankProvider = class {
|
|
1181
|
+
name = "ollama-judge";
|
|
1182
|
+
host;
|
|
1183
|
+
model;
|
|
1184
|
+
constructor(opts = {}) {
|
|
1185
|
+
const rawHost = opts.host ?? process.env.OLLAMA_HOST ?? "http://localhost:11434";
|
|
1186
|
+
this.host = /^https?:\/\//.test(rawHost) ? rawHost : `http://${rawHost}`;
|
|
1187
|
+
this.model = opts.model ?? process.env.OLLAMA_RERANK_MODEL ?? "qwen3:8b";
|
|
1188
|
+
}
|
|
1189
|
+
async rerank(query, candidates) {
|
|
1190
|
+
if (candidates.length === 0) return [];
|
|
1191
|
+
const docs = candidates.map((c, i) => `Doc ${i + 1}: ${c.text.slice(0, 400)}`).join("\n\n");
|
|
1192
|
+
const prompt = [
|
|
1193
|
+
`You are a relevance judge for a Claude changelog search.`,
|
|
1194
|
+
`Rate each document 0-10 for how well it answers the user's query.`,
|
|
1195
|
+
`Respond with EXACTLY ${candidates.length} integers, one per line, in order. No prose, no explanations, no doc numbers, just one integer per line.`,
|
|
1196
|
+
``,
|
|
1197
|
+
`Query: ${query}`,
|
|
1198
|
+
``,
|
|
1199
|
+
docs,
|
|
1200
|
+
``,
|
|
1201
|
+
`Scores (one integer per line, ${candidates.length} lines total):`
|
|
1202
|
+
].join("\n");
|
|
1203
|
+
const timeoutMs = providerTimeoutMs5();
|
|
1204
|
+
let res;
|
|
1205
|
+
try {
|
|
1206
|
+
res = await fetch(`${this.host}/api/generate`, {
|
|
1207
|
+
method: "POST",
|
|
1208
|
+
headers: { "content-type": "application/json" },
|
|
1209
|
+
body: JSON.stringify({
|
|
1210
|
+
model: this.model,
|
|
1211
|
+
prompt,
|
|
1212
|
+
stream: false,
|
|
1213
|
+
think: false,
|
|
1214
|
+
// for qwen3/o1-style models; harmless on others
|
|
1215
|
+
options: { temperature: 0, num_predict: Math.max(120, candidates.length * 4) }
|
|
1216
|
+
}),
|
|
1217
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
1218
|
+
});
|
|
1219
|
+
} catch (e) {
|
|
1220
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
1221
|
+
throw new Error(`Ollama judge rerank timed out after ${timeoutMs}ms \u2014 is the Ollama server responsive?`);
|
|
1222
|
+
}
|
|
1223
|
+
throw e;
|
|
1224
|
+
}
|
|
1225
|
+
if (!res.ok) throw new Error(`Ollama judge ${res.status}: ${await safeErrorBody5(res)}`);
|
|
1226
|
+
const json = await res.json();
|
|
1227
|
+
const scores = parseScores(json.response, candidates.length);
|
|
1228
|
+
return candidates.map((c, i) => ({ id: c.id, score: scores[i] ?? 0 }));
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
function parseScores(response, n) {
|
|
1232
|
+
const lines = response.split(/\r?\n/);
|
|
1233
|
+
const out = [];
|
|
1234
|
+
for (const line of lines) {
|
|
1235
|
+
if (out.length >= n) break;
|
|
1236
|
+
const m = line.match(/-?\d+(?:\.\d+)?/);
|
|
1237
|
+
if (!m) continue;
|
|
1238
|
+
const v = parseFloat(m[0]);
|
|
1239
|
+
if (Number.isFinite(v)) out.push(Math.max(0, Math.min(10, v)));
|
|
1240
|
+
}
|
|
1241
|
+
while (out.length < n) out.push(0);
|
|
1242
|
+
return out;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// src/providers/rerank/voyage.ts
|
|
1246
|
+
var VoyageRerankProvider = class {
|
|
1247
|
+
name = "voyage";
|
|
1248
|
+
apiKey;
|
|
1249
|
+
model;
|
|
1250
|
+
/** Accumulated usage across all rerank() calls on this instance. */
|
|
1251
|
+
_usage = { tokens: 0, requests: 0 };
|
|
1252
|
+
constructor(opts = {}) {
|
|
1253
|
+
this.apiKey = opts.apiKey ?? process.env.VOYAGE_API_KEY ?? "";
|
|
1254
|
+
this.model = opts.model ?? "rerank-2";
|
|
1255
|
+
if (!this.apiKey) {
|
|
1256
|
+
throw new Error("voyage rerank provider requires VOYAGE_API_KEY");
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
/** Get accumulated usage stats. */
|
|
1260
|
+
get usage() {
|
|
1261
|
+
return { ...this._usage };
|
|
1262
|
+
}
|
|
1263
|
+
/** Reset accumulated usage counters. */
|
|
1264
|
+
resetUsage() {
|
|
1265
|
+
this._usage = { tokens: 0, requests: 0 };
|
|
1266
|
+
}
|
|
1267
|
+
async rerank(query, candidates, signal) {
|
|
1268
|
+
if (candidates.length === 0) return [];
|
|
1269
|
+
const timeoutMs = providerTimeoutMs4();
|
|
1270
|
+
const { data: result } = await withRetry(
|
|
1271
|
+
async () => {
|
|
1272
|
+
let res;
|
|
1273
|
+
try {
|
|
1274
|
+
res = await fetch("https://api.voyageai.com/v1/rerank", {
|
|
1275
|
+
method: "POST",
|
|
1276
|
+
headers: {
|
|
1277
|
+
"authorization": `Bearer ${this.apiKey}`,
|
|
1278
|
+
"content-type": "application/json"
|
|
1279
|
+
},
|
|
1280
|
+
body: JSON.stringify({
|
|
1281
|
+
model: this.model,
|
|
1282
|
+
query,
|
|
1283
|
+
documents: candidates.map((c) => c.text),
|
|
1284
|
+
top_k: candidates.length
|
|
1285
|
+
}),
|
|
1286
|
+
signal: signal ?? AbortSignal.timeout(timeoutMs)
|
|
1287
|
+
});
|
|
1288
|
+
} catch (e) {
|
|
1289
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
1290
|
+
throw new Error(`Voyage rerank request timed out after ${timeoutMs}ms \u2014 is the API responsive?`);
|
|
1291
|
+
}
|
|
1292
|
+
throw e;
|
|
1293
|
+
}
|
|
1294
|
+
if (!res.ok) throw new Error(`Voyage rerank ${res.status}: ${await safeErrorBody4(res)}`);
|
|
1295
|
+
const json = await res.json();
|
|
1296
|
+
return json;
|
|
1297
|
+
},
|
|
1298
|
+
{ maxAttempts: 3, signal }
|
|
1299
|
+
);
|
|
1300
|
+
this._usage.requests++;
|
|
1301
|
+
if (result.usage?.total_tokens) {
|
|
1302
|
+
this._usage.tokens += result.usage.total_tokens;
|
|
1303
|
+
}
|
|
1304
|
+
return result.data.map((d) => ({
|
|
1305
|
+
id: candidates[d.index].id,
|
|
1306
|
+
score: d.relevance_score
|
|
1307
|
+
}));
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
|
|
1311
|
+
// src/providers/rerank/cohere.ts
|
|
1312
|
+
var CohereRerankProvider = class {
|
|
1313
|
+
name = "cohere";
|
|
1314
|
+
apiKey;
|
|
1315
|
+
model;
|
|
1316
|
+
/** Accumulated usage across all rerank() calls on this instance. */
|
|
1317
|
+
_usage = { tokens: 0, requests: 0 };
|
|
1318
|
+
constructor(opts = {}) {
|
|
1319
|
+
this.apiKey = opts.apiKey ?? process.env.COHERE_API_KEY ?? "";
|
|
1320
|
+
this.model = opts.model ?? "rerank-v3.5";
|
|
1321
|
+
if (!this.apiKey) {
|
|
1322
|
+
throw new Error("cohere rerank provider requires COHERE_API_KEY");
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
/** Get accumulated usage stats. */
|
|
1326
|
+
get usage() {
|
|
1327
|
+
return { ...this._usage };
|
|
1328
|
+
}
|
|
1329
|
+
/** Reset accumulated usage counters. */
|
|
1330
|
+
resetUsage() {
|
|
1331
|
+
this._usage = { tokens: 0, requests: 0 };
|
|
1332
|
+
}
|
|
1333
|
+
async rerank(query, candidates, signal) {
|
|
1334
|
+
if (candidates.length === 0) return [];
|
|
1335
|
+
const timeoutMs = providerTimeoutMs4();
|
|
1336
|
+
const { data: result } = await withRetry(
|
|
1337
|
+
async () => {
|
|
1338
|
+
let res;
|
|
1339
|
+
try {
|
|
1340
|
+
res = await fetch("https://api.cohere.com/v2/rerank", {
|
|
1341
|
+
method: "POST",
|
|
1342
|
+
headers: {
|
|
1343
|
+
"authorization": `Bearer ${this.apiKey}`,
|
|
1344
|
+
"content-type": "application/json"
|
|
1345
|
+
},
|
|
1346
|
+
body: JSON.stringify({
|
|
1347
|
+
model: this.model,
|
|
1348
|
+
query,
|
|
1349
|
+
documents: candidates.map((c) => c.text),
|
|
1350
|
+
top_n: candidates.length
|
|
1351
|
+
}),
|
|
1352
|
+
signal: signal ?? AbortSignal.timeout(timeoutMs)
|
|
1353
|
+
});
|
|
1354
|
+
} catch (e) {
|
|
1355
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
1356
|
+
throw new Error(`Cohere rerank request timed out after ${timeoutMs}ms \u2014 is the API responsive?`);
|
|
1357
|
+
}
|
|
1358
|
+
throw e;
|
|
1359
|
+
}
|
|
1360
|
+
if (!res.ok) throw new Error(`Cohere rerank ${res.status}: ${await safeErrorBody4(res)}`);
|
|
1361
|
+
const json = await res.json();
|
|
1362
|
+
return json;
|
|
1363
|
+
},
|
|
1364
|
+
{ maxAttempts: 3, signal }
|
|
1365
|
+
);
|
|
1366
|
+
this._usage.requests++;
|
|
1367
|
+
if (result.meta?.billed_units?.search_units) {
|
|
1368
|
+
this._usage.tokens += result.meta.billed_units.search_units;
|
|
1369
|
+
}
|
|
1370
|
+
return result.results.map((r) => ({
|
|
1371
|
+
id: candidates[r.index].id,
|
|
1372
|
+
score: r.relevance_score
|
|
1373
|
+
}));
|
|
1374
|
+
}
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
// src/hybrid.ts
|
|
1378
|
+
var MAX_LIMIT = 500;
|
|
1379
|
+
function clampLimit(value, fallback, name) {
|
|
1380
|
+
const v = value ?? fallback;
|
|
1381
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
1382
|
+
throw new Error(`hybridSearch: ${name} must be a finite number (got ${String(v)})`);
|
|
1383
|
+
}
|
|
1384
|
+
const n = v | 0;
|
|
1385
|
+
return Math.max(1, Math.min(n, MAX_LIMIT));
|
|
1386
|
+
}
|
|
1387
|
+
function hasVecTable(db) {
|
|
1388
|
+
const tagged = db;
|
|
1389
|
+
if (typeof tagged.__synergyHasVec === "boolean") return tagged.__synergyHasVec;
|
|
1390
|
+
try {
|
|
1391
|
+
db.prepare("SELECT count(*) FROM chunks_vec LIMIT 0").get();
|
|
1392
|
+
tagged.__synergyHasVec = true;
|
|
1393
|
+
} catch {
|
|
1394
|
+
tagged.__synergyHasVec = false;
|
|
1395
|
+
}
|
|
1396
|
+
return tagged.__synergyHasVec;
|
|
1397
|
+
}
|
|
1398
|
+
var warnedNoVec = false;
|
|
1399
|
+
async function hybridSearch(db, query, opts = {}) {
|
|
1400
|
+
const safeLimit = clampLimit(opts.limit, 20, "limit");
|
|
1401
|
+
const safeTopK = clampLimit(opts.topK, 60, "topK");
|
|
1402
|
+
const rerankRequested = clampLimit(opts.rerankCandidates, 20, "rerankCandidates");
|
|
1403
|
+
const safeRerankN = Math.max(safeLimit, rerankRequested);
|
|
1404
|
+
const rrfK = opts.rrfK ?? 60;
|
|
1405
|
+
if (typeof rrfK !== "number" || !Number.isFinite(rrfK)) {
|
|
1406
|
+
throw new Error(`hybridSearch: rrfK must be a finite number (got ${String(rrfK)})`);
|
|
1407
|
+
}
|
|
1408
|
+
const embedName = opts.embed ?? opts.embedProviderName ?? "ollama";
|
|
1409
|
+
const dbDim = getEmbeddingDim(db) ?? void 0;
|
|
1410
|
+
const emb = makeEmbeddingProvider(embedName, { dim: dbDim });
|
|
1411
|
+
const filters = [];
|
|
1412
|
+
const params = {};
|
|
1413
|
+
if (opts.product) {
|
|
1414
|
+
filters.push("ch.product = @product");
|
|
1415
|
+
params.product = opts.product;
|
|
1416
|
+
}
|
|
1417
|
+
if (opts.since) {
|
|
1418
|
+
filters.push("ch.released_at >= @since");
|
|
1419
|
+
params.since = opts.since;
|
|
1420
|
+
}
|
|
1421
|
+
if (opts.until) {
|
|
1422
|
+
filters.push("ch.released_at <= @until");
|
|
1423
|
+
params.until = opts.until;
|
|
1424
|
+
}
|
|
1425
|
+
if (opts.kind) {
|
|
1426
|
+
filters.push("EXISTS (SELECT 1 FROM changes c WHERE c.id = ch.change_id AND c.kind = @kind)");
|
|
1427
|
+
params.kind = opts.kind;
|
|
1428
|
+
}
|
|
1429
|
+
const whereClause = filters.length > 0 ? `AND ${filters.join(" AND ")}` : "";
|
|
1430
|
+
let fts = [];
|
|
1431
|
+
try {
|
|
1432
|
+
const ftsSql = `
|
|
1433
|
+
SELECT ch.id AS id, row_number() OVER (ORDER BY bm25(chunks_fts)) AS rank_pos
|
|
1434
|
+
FROM chunks_fts
|
|
1435
|
+
JOIN chunks ch ON chunks_fts.rowid = ch.id
|
|
1436
|
+
WHERE chunks_fts MATCH @query
|
|
1437
|
+
${whereClause}
|
|
1438
|
+
ORDER BY bm25(chunks_fts)
|
|
1439
|
+
LIMIT @topK
|
|
1440
|
+
`;
|
|
1441
|
+
fts = db.prepare(ftsSql).all({ ...params, query, topK: safeTopK });
|
|
1442
|
+
} catch (e) {
|
|
1443
|
+
const sanitized = query.replace(/[^A-Za-z0-9 ]/g, " ").trim();
|
|
1444
|
+
if (sanitized) {
|
|
1445
|
+
const ftsSql = `
|
|
1446
|
+
SELECT ch.id AS id, row_number() OVER (ORDER BY bm25(chunks_fts)) AS rank_pos
|
|
1447
|
+
FROM chunks_fts
|
|
1448
|
+
JOIN chunks ch ON chunks_fts.rowid = ch.id
|
|
1449
|
+
WHERE chunks_fts MATCH @query
|
|
1450
|
+
${whereClause}
|
|
1451
|
+
ORDER BY bm25(chunks_fts)
|
|
1452
|
+
LIMIT @topK
|
|
1453
|
+
`;
|
|
1454
|
+
fts = db.prepare(ftsSql).all({ ...params, query: sanitized, topK: safeTopK });
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
const vecAvailable = hasVecTable(db);
|
|
1458
|
+
let vec = [];
|
|
1459
|
+
if (vecAvailable) {
|
|
1460
|
+
const [qvec] = await emb.embed([query]);
|
|
1461
|
+
const vecSql = `
|
|
1462
|
+
SELECT ch.id AS id, row_number() OVER (ORDER BY distance) AS rank_pos
|
|
1463
|
+
FROM chunks_vec
|
|
1464
|
+
JOIN chunks ch ON ch.id = chunks_vec.rowid
|
|
1465
|
+
WHERE chunks_vec.embedding MATCH @qvec AND k = @topK
|
|
1466
|
+
${whereClause}
|
|
1467
|
+
ORDER BY distance
|
|
1468
|
+
`;
|
|
1469
|
+
vec = db.prepare(vecSql).all({ ...params, qvec, topK: safeTopK });
|
|
1470
|
+
} else if (!warnedNoVec && !process.env.MCP_QUIET && !process.env.CLAUDE_SYNERGY_QUIET) {
|
|
1471
|
+
console.warn("[hybrid] sqlite-vec table not found; using BM25-only");
|
|
1472
|
+
warnedNoVec = true;
|
|
1473
|
+
}
|
|
1474
|
+
const scores = /* @__PURE__ */ new Map();
|
|
1475
|
+
for (const r of fts) {
|
|
1476
|
+
const cur = scores.get(r.id) ?? { bm25: null, vec: null, score: 0 };
|
|
1477
|
+
cur.bm25 = r.rank_pos;
|
|
1478
|
+
cur.score += 1 / (rrfK + r.rank_pos);
|
|
1479
|
+
scores.set(r.id, cur);
|
|
1480
|
+
}
|
|
1481
|
+
for (const r of vec) {
|
|
1482
|
+
const cur = scores.get(r.id) ?? { bm25: null, vec: null, score: 0 };
|
|
1483
|
+
cur.vec = r.rank_pos;
|
|
1484
|
+
cur.score += 1 / (rrfK + r.rank_pos);
|
|
1485
|
+
scores.set(r.id, cur);
|
|
1486
|
+
}
|
|
1487
|
+
if (scores.size === 0) return [];
|
|
1488
|
+
const rrfRanked = Array.from(scores.entries()).sort(([, a], [, b]) => b.score - a.score).slice(0, safeRerankN);
|
|
1489
|
+
const candidateIds = rrfRanked.map(([id]) => id);
|
|
1490
|
+
const placeholders = candidateIds.map(() => "?").join(",");
|
|
1491
|
+
const rowsSql = `
|
|
1492
|
+
SELECT ch.id AS chunk_id, ch.change_id, ch.product, ch.release_version AS version,
|
|
1493
|
+
ch.released_at, c.kind, c.text, ch.contextualized
|
|
1494
|
+
FROM chunks ch
|
|
1495
|
+
JOIN changes c ON c.id = ch.change_id
|
|
1496
|
+
WHERE ch.id IN (${placeholders})
|
|
1497
|
+
`;
|
|
1498
|
+
const rows = db.prepare(rowsSql).all(...candidateIds);
|
|
1499
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1500
|
+
for (const r of rows) byId.set(r.chunk_id, r);
|
|
1501
|
+
const rerankProviderName = opts.rerankProviderName ?? (isKnownReranker(opts.rerank) ? opts.rerank : "none");
|
|
1502
|
+
let rerankScores = /* @__PURE__ */ new Map();
|
|
1503
|
+
if (rerankProviderName !== "none") {
|
|
1504
|
+
const reranker = makeRerankProvider(rerankProviderName);
|
|
1505
|
+
const candidates = rrfRanked.slice(0, rerankRequested).map(([id]) => ({ id, text: byId.get(id)?.contextualized ?? byId.get(id)?.text ?? "" })).filter((c) => c.text);
|
|
1506
|
+
const results = await reranker.rerank(query, candidates);
|
|
1507
|
+
for (const r of results) rerankScores.set(r.id, r.score);
|
|
1508
|
+
}
|
|
1509
|
+
const final = rrfRanked.map(([id, score]) => ({
|
|
1510
|
+
id,
|
|
1511
|
+
rrf: score.score,
|
|
1512
|
+
bm25: score.bm25,
|
|
1513
|
+
vec: score.vec,
|
|
1514
|
+
rerank: rerankScores.get(id) ?? null
|
|
1515
|
+
})).sort((a, b) => {
|
|
1516
|
+
if (rerankProviderName !== "none" && a.rerank !== null && b.rerank !== null) {
|
|
1517
|
+
return b.rerank - a.rerank;
|
|
1518
|
+
}
|
|
1519
|
+
return b.rrf - a.rrf;
|
|
1520
|
+
}).slice(0, safeLimit);
|
|
1521
|
+
return final.map((entry) => {
|
|
1522
|
+
const r = byId.get(entry.id);
|
|
1523
|
+
if (!r) return null;
|
|
1524
|
+
return {
|
|
1525
|
+
...r,
|
|
1526
|
+
bm25_rank: entry.bm25,
|
|
1527
|
+
vec_rank: entry.vec,
|
|
1528
|
+
rrf_score: entry.rrf,
|
|
1529
|
+
rerank_score: entry.rerank
|
|
1530
|
+
};
|
|
1531
|
+
}).filter(Boolean);
|
|
1532
|
+
}
|
|
1533
|
+
function isKnownReranker(v) {
|
|
1534
|
+
return v === "none" || v === "ollama-judge" || v === "voyage" || v === "cohere";
|
|
1535
|
+
}
|
|
1536
|
+
function makeRerankProvider(name) {
|
|
1537
|
+
switch (name) {
|
|
1538
|
+
case "ollama-judge":
|
|
1539
|
+
return new OllamaJudgeRerankProvider();
|
|
1540
|
+
case "voyage":
|
|
1541
|
+
return new VoyageRerankProvider();
|
|
1542
|
+
case "cohere":
|
|
1543
|
+
return new CohereRerankProvider();
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
export {
|
|
1548
|
+
AppError,
|
|
1549
|
+
formatError,
|
|
1550
|
+
openDb,
|
|
1551
|
+
initSchema,
|
|
1552
|
+
searchChanges,
|
|
1553
|
+
lookupEntity,
|
|
1554
|
+
recentReleases,
|
|
1555
|
+
listProducts,
|
|
1556
|
+
entityFrequency,
|
|
1557
|
+
browseChanges,
|
|
1558
|
+
getChangesSince,
|
|
1559
|
+
compareVersions,
|
|
1560
|
+
listSynergies,
|
|
1561
|
+
getSynergy,
|
|
1562
|
+
embedAll,
|
|
1563
|
+
hybridSearch
|
|
1564
|
+
};
|