@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/dist/cli.js CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- OllamaEmbeddingProvider,
4
- VoyageEmbeddingProvider,
3
+ AppError,
4
+ browseChanges,
5
+ embedAll,
5
6
  entityFrequency,
7
+ formatError,
8
+ getChangesSince,
6
9
  hybridSearch,
7
10
  initSchema,
8
11
  listProducts,
@@ -10,411 +13,22 @@ import {
10
13
  openDb,
11
14
  recentReleases,
12
15
  searchChanges
13
- } from "./chunk-YFGUTT22.js";
16
+ } from "./chunk-H3466JDH.js";
14
17
  import {
15
18
  ingestAll,
16
19
  loadProductsConfig
17
- } from "./chunk-HCIZPSW4.js";
20
+ } from "./chunk-HZEQG3WT.js";
18
21
 
19
22
  // src/cli.ts
20
23
  import { Command, Option } from "commander";
21
- import { join as join4, resolve as resolve2 } from "path";
24
+ import { join as join3, resolve as resolve2 } from "path";
22
25
  import { existsSync as existsSync2 } from "fs";
23
26
  import { createRequire } from "module";
24
27
 
25
- // src/embed.ts
26
- import { readFileSync } from "fs";
27
- import { fileURLToPath } from "url";
28
- import { dirname, join } from "path";
29
-
30
- // src/providers/context/none.ts
31
- var NoneContextProvider = class {
32
- name = "none";
33
- async contextFor(_chunk, _release) {
34
- return "";
35
- }
36
- };
37
-
38
- // src/providers/context/structured.ts
39
- var StructuredContextProvider = class {
40
- name = "structured";
41
- async contextFor(chunk, release) {
42
- const date = release.releasedAt ?? "unknown date";
43
- const positional = `change ${chunk.ordinalInRelease} of ${chunk.totalInRelease}`;
44
- return `In ${release.product} ${release.version} (${date}), ${chunk.kind} (${positional}):`;
45
- }
46
- };
47
-
48
- // src/providers/context/ollama.ts
49
- function providerTimeoutMs() {
50
- const raw = process.env.CLAUDE_SYNERGY_PROVIDER_TIMEOUT_MS;
51
- const n = raw === void 0 ? NaN : parseInt(raw, 10);
52
- return Number.isFinite(n) && n > 0 ? n : 6e4;
53
- }
54
- async function safeErrorBody(res, max = 200) {
55
- try {
56
- const body = await res.text();
57
- const safe = body.split("\n").filter((l) => !/x-api-key|authorization|bearer|api[-_]?key/i.test(l)).join("\n");
58
- return safe.slice(0, max);
59
- } catch {
60
- return "<unreadable>";
61
- }
62
- }
63
- var OllamaContextProvider = class {
64
- name = "ollama";
65
- host;
66
- model;
67
- constructor(opts = {}) {
68
- const rawHost = opts.host ?? process.env.OLLAMA_HOST ?? "http://localhost:11434";
69
- this.host = /^https?:\/\//.test(rawHost) ? rawHost : `http://${rawHost}`;
70
- this.model = opts.model ?? process.env.OLLAMA_GEN_MODEL ?? "llama3.2:3b";
71
- }
72
- async contextFor(chunk, release) {
73
- const releaseSnippet = release.siblings.map((s, i) => `${i + 1}. ${s.text}`).slice(0, 30).join("\n");
74
- const prompt = [
75
- `<document>`,
76
- `${release.product} ${release.version} release notes (${release.releasedAt ?? "unknown date"}):`,
77
- releaseSnippet,
78
- `</document>`,
79
- ``,
80
- `Here is the chunk we want to situate within the whole document:`,
81
- `<chunk>`,
82
- chunk.text,
83
- `</chunk>`,
84
- ``,
85
- `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.`
86
- ].join("\n");
87
- const timeoutMs = providerTimeoutMs();
88
- let res;
89
- try {
90
- res = await fetch(`${this.host}/api/generate`, {
91
- method: "POST",
92
- headers: { "content-type": "application/json" },
93
- body: JSON.stringify({
94
- model: this.model,
95
- prompt,
96
- stream: false,
97
- options: { temperature: 0, num_predict: 80 }
98
- }),
99
- signal: AbortSignal.timeout(timeoutMs)
100
- });
101
- } catch (e) {
102
- if (e?.name === "TimeoutError" || e?.name === "AbortError") {
103
- throw new Error(`Ollama context request timed out after ${timeoutMs}ms \u2014 is the Ollama server responsive?`);
104
- }
105
- throw e;
106
- }
107
- if (!res.ok) throw new Error(`Ollama ${res.status}: ${await safeErrorBody(res)}`);
108
- const json = await res.json();
109
- return json.response.trim();
110
- }
111
- };
112
-
113
- // src/providers/context/claude-haiku.ts
114
- function providerTimeoutMs2() {
115
- const raw = process.env.CLAUDE_SYNERGY_PROVIDER_TIMEOUT_MS;
116
- const n = raw === void 0 ? NaN : parseInt(raw, 10);
117
- return Number.isFinite(n) && n > 0 ? n : 6e4;
118
- }
119
- async function safeErrorBody2(res, max = 200) {
120
- try {
121
- const body = await res.text();
122
- const safe = body.split("\n").filter((l) => !/x-api-key|authorization|bearer|api[-_]?key/i.test(l)).join("\n");
123
- return safe.slice(0, max);
124
- } catch {
125
- return "<unreadable>";
126
- }
127
- }
128
- var ClaudeHaikuContextProvider = class {
129
- name = "claude-haiku";
130
- apiKey;
131
- model;
132
- cachedDocByRelease = /* @__PURE__ */ new Map();
133
- constructor(opts = {}) {
134
- this.apiKey = opts.apiKey ?? process.env.ANTHROPIC_API_KEY ?? "";
135
- this.model = opts.model ?? "claude-haiku-4-5-20251001";
136
- if (!this.apiKey) {
137
- throw new Error("claude-haiku context provider requires ANTHROPIC_API_KEY");
138
- }
139
- }
140
- async contextFor(chunk, release) {
141
- const releaseKey = `${release.product}@${release.version}`;
142
- let doc = this.cachedDocByRelease.get(releaseKey);
143
- if (!doc) {
144
- doc = release.siblings.map((s, i) => `${i + 1}. ${s.text}`).join("\n");
145
- this.cachedDocByRelease.set(releaseKey, doc);
146
- }
147
- const userPrompt = [
148
- `Chunk: ${chunk.text}`,
149
- ``,
150
- `Give a 1-sentence context (max 30 words) situating this chunk in the release. Mention related products, env vars, commands. Output ONLY the context.`
151
- ].join("\n");
152
- const timeoutMs = providerTimeoutMs2();
153
- let res;
154
- try {
155
- res = await fetch("https://api.anthropic.com/v1/messages", {
156
- method: "POST",
157
- headers: {
158
- "x-api-key": this.apiKey,
159
- "anthropic-version": "2023-06-01",
160
- "content-type": "application/json"
161
- },
162
- body: JSON.stringify({
163
- model: this.model,
164
- max_tokens: 80,
165
- system: [
166
- {
167
- type: "text",
168
- text: `${release.product} ${release.version} (${release.releasedAt}) release notes:
169
- ${doc}`,
170
- cache_control: { type: "ephemeral" }
171
- }
172
- ],
173
- messages: [{ role: "user", content: userPrompt }]
174
- }),
175
- signal: AbortSignal.timeout(timeoutMs)
176
- });
177
- } catch (e) {
178
- if (e?.name === "TimeoutError" || e?.name === "AbortError") {
179
- throw new Error(`Anthropic API request timed out after ${timeoutMs}ms \u2014 is the API responsive?`);
180
- }
181
- throw e;
182
- }
183
- if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${await safeErrorBody2(res)}`);
184
- const json = await res.json();
185
- return (json.content.find((c) => c.type === "text")?.text ?? "").trim();
186
- }
187
- };
188
-
189
- // src/embed.ts
190
- var __dirname2 = dirname(fileURLToPath(import.meta.url));
191
- function initVecSchema(db) {
192
- const existing = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='chunks'`).get();
193
- if (existing) return;
194
- const schemaPath = resolveSchemaVecPath();
195
- const sql = readFileSync(schemaPath, "utf-8");
196
- db.exec(sql);
197
- }
198
- function resolveSchemaVecPath() {
199
- const candidates = [
200
- join(__dirname2, "..", "schema-vec.sql"),
201
- join(process.cwd(), "schema-vec.sql")
202
- ];
203
- for (const p of candidates) {
204
- try {
205
- readFileSync(p);
206
- return p;
207
- } catch {
208
- }
209
- }
210
- throw new Error(`schema-vec.sql not found in: ${candidates.join(", ")}`);
211
- }
212
- async function embedAll(db, opts) {
213
- initVecSchema(db);
214
- const ctx = makeContextProvider(opts.contextProviderName);
215
- const emb = makeEmbeddingProvider(opts.embeddingProviderName);
216
- const productFilter = opts.product ? "AND c.product = @product" : "";
217
- const forceFilter = opts.force ? "" : "AND ch.id IS NULL";
218
- const limitClause = opts.limit ? "LIMIT @limit" : "";
219
- const pendingSql = `
220
- SELECT c.id AS change_id, c.product, c.version, r.released_at, c.kind, c.text, c.ordinal
221
- FROM changes c
222
- JOIN releases r ON c.product = r.product AND c.version = r.version
223
- LEFT JOIN chunks ch ON ch.change_id = c.id
224
- WHERE 1=1
225
- ${productFilter}
226
- ${forceFilter}
227
- ORDER BY c.product, c.version, c.ordinal
228
- ${limitClause}
229
- `;
230
- const params = {};
231
- if (opts.product) params.product = opts.product;
232
- if (opts.limit) params.limit = opts.limit;
233
- const pending = db.prepare(pendingSql).all(params);
234
- if (pending.length === 0) {
235
- return {
236
- contextProvider: ctx.name,
237
- embeddingProvider: emb.name,
238
- chunksCreated: 0,
239
- chunksSkipped: 0,
240
- contextMs: 0,
241
- embedMs: 0,
242
- totalMs: 0
243
- };
244
- }
245
- const byRelease = /* @__PURE__ */ new Map();
246
- for (const row of pending) {
247
- const key = `${row.product}@${row.version}`;
248
- if (!byRelease.has(key)) byRelease.set(key, []);
249
- byRelease.get(key).push({
250
- changeId: row.change_id,
251
- product: row.product,
252
- releaseVersion: row.version,
253
- releasedAt: row.released_at,
254
- kind: row.kind,
255
- text: row.text,
256
- ordinalInRelease: row.ordinal,
257
- totalInRelease: 0
258
- // backfilled below
259
- });
260
- }
261
- for (const [, list] of byRelease) {
262
- for (const c of list) c.totalInRelease = list.length;
263
- }
264
- const startTotal = Date.now();
265
- let contextMs = 0;
266
- let embedMs = 0;
267
- const contextsByChange = /* @__PURE__ */ new Map();
268
- const allChunks = [];
269
- for (const [, siblings] of byRelease) {
270
- const release = {
271
- product: siblings[0].product,
272
- version: siblings[0].releaseVersion,
273
- releasedAt: siblings[0].releasedAt,
274
- siblings
275
- };
276
- for (const chunk of siblings) {
277
- if (opts.signal?.aborted) break;
278
- const t0 = Date.now();
279
- const ctxPrefix = await ctx.contextFor(chunk, release);
280
- contextMs += Date.now() - t0;
281
- contextsByChange.set(chunk.changeId, ctxPrefix);
282
- allChunks.push(chunk);
283
- }
284
- if (opts.signal?.aborted) break;
285
- }
286
- const batchSize = opts.batchSize ?? 64;
287
- const insertChunk = db.prepare(`
288
- INSERT OR REPLACE INTO chunks
289
- (change_id, product, release_version, released_at, context_prefix, original_text, contextualized, context_provider, embedding_model, embedded_at)
290
- VALUES
291
- (@change_id, @product, @release_version, @released_at, @context_prefix, @original_text, @contextualized, @context_provider, @embedding_model, @embedded_at)
292
- `);
293
- const deleteVec = db.prepare(`DELETE FROM chunks_vec WHERE rowid = ?`);
294
- const insertVec = db.prepare(`INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)`);
295
- let created = 0;
296
- let stoppedEarly = false;
297
- let stopReason;
298
- const usage = { requests: 0, tokens: 0 };
299
- const totalBatches = Math.ceil(allChunks.length / batchSize);
300
- for (let i = 0; i < allChunks.length; i += batchSize) {
301
- if (opts.signal?.aborted) {
302
- stoppedEarly = true;
303
- stopReason = "cancelled";
304
- break;
305
- }
306
- if (opts.maxRequests !== void 0 && usage.requests >= opts.maxRequests) {
307
- stoppedEarly = true;
308
- stopReason = "budget_requests";
309
- break;
310
- }
311
- if (opts.maxTokens !== void 0 && usage.tokens >= opts.maxTokens) {
312
- stoppedEarly = true;
313
- stopReason = "budget_tokens";
314
- break;
315
- }
316
- const batch = allChunks.slice(i, i + batchSize);
317
- const texts = batch.map((c) => {
318
- const prefix = contextsByChange.get(c.changeId) ?? "";
319
- return prefix ? `${prefix}
320
-
321
- ${c.text}` : c.text;
322
- });
323
- const t0 = Date.now();
324
- const vectors = await emb.embed(texts);
325
- embedMs += Date.now() - t0;
326
- usage.requests++;
327
- if ("usage" in emb) {
328
- const provUsage = emb.usage;
329
- if (provUsage && typeof provUsage.tokens === "number") {
330
- usage.tokens = provUsage.tokens;
331
- }
332
- }
333
- const tx = db.transaction(() => {
334
- for (let j = 0; j < batch.length; j++) {
335
- const c = batch[j];
336
- const prefix = contextsByChange.get(c.changeId) ?? "";
337
- const contextualized = prefix ? `${prefix}
338
-
339
- ${c.text}` : c.text;
340
- const result = insertChunk.run({
341
- change_id: c.changeId,
342
- product: c.product,
343
- release_version: c.releaseVersion,
344
- released_at: c.releasedAt,
345
- context_prefix: prefix,
346
- original_text: c.text,
347
- contextualized,
348
- context_provider: ctx.name,
349
- embedding_model: `${emb.name}:${emb.model}`,
350
- embedded_at: (/* @__PURE__ */ new Date()).toISOString()
351
- });
352
- const chunkId = Number(result.lastInsertRowid);
353
- deleteVec.run(chunkId);
354
- insertVec.run(BigInt(chunkId), vectors[j]);
355
- created++;
356
- }
357
- });
358
- tx();
359
- if (opts.onProgress) {
360
- opts.onProgress({
361
- batchesCompleted: Math.floor(i / batchSize) + 1,
362
- batchesTotal: totalBatches,
363
- chunksCompleted: created,
364
- chunksTotal: allChunks.length,
365
- provider: `${emb.name}:${emb.model}`,
366
- tokensUsed: usage.tokens,
367
- requestsMade: usage.requests
368
- });
369
- }
370
- }
371
- const stats = {
372
- contextProvider: ctx.name,
373
- embeddingProvider: `${emb.name}:${emb.model}`,
374
- chunksCreated: created,
375
- chunksSkipped: 0,
376
- contextMs,
377
- embedMs,
378
- totalMs: Date.now() - startTotal
379
- };
380
- if (usage.requests > 0) {
381
- stats.usage = usage;
382
- }
383
- if (stoppedEarly) {
384
- stats.stoppedEarly = true;
385
- stats.stopReason = stopReason;
386
- }
387
- return stats;
388
- }
389
- function makeContextProvider(name) {
390
- switch (name) {
391
- case "none":
392
- return new NoneContextProvider();
393
- case "structured":
394
- return new StructuredContextProvider();
395
- case "ollama":
396
- return new OllamaContextProvider();
397
- case "claude-haiku":
398
- return new ClaudeHaikuContextProvider();
399
- default:
400
- throw new Error(`unknown context provider: ${name}`);
401
- }
402
- }
403
- function makeEmbeddingProvider(name) {
404
- switch (name) {
405
- case "ollama":
406
- return new OllamaEmbeddingProvider();
407
- case "voyage":
408
- return new VoyageEmbeddingProvider();
409
- default:
410
- throw new Error(`unknown embedding provider: ${name}`);
411
- }
412
- }
413
-
414
28
  // src/fetch.ts
415
29
  import { execFileSync } from "child_process";
416
30
  import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync } from "fs";
417
- import { join as join3, resolve } from "path";
31
+ import { join as join2, resolve } from "path";
418
32
 
419
33
  // src/fetch-rss.ts
420
34
  import { XMLParser } from "fast-xml-parser";
@@ -660,6 +274,51 @@ function parseAiderHistory(md) {
660
274
  flush();
661
275
  return out.filter((i) => i.body.length > 0 || i.version !== "main");
662
276
  }
277
+ async function fetchKeepAChangelog(url, sinceIso, signal) {
278
+ const res = await fetchWithRetry(url, {
279
+ headers: { "user-agent": "claude-synergy/0.1.0" },
280
+ signal
281
+ });
282
+ if (!res.ok) throw new Error(`raw-changelog ${url} returned ${res.status}`);
283
+ const md = await res.text();
284
+ const items = parseKeepAChangelog(md);
285
+ return items.filter((i) => i.releasedAt !== null && i.releasedAt > sinceIso);
286
+ }
287
+ function parseKeepAChangelog(md) {
288
+ const lines = md.split(/\r?\n/);
289
+ const out = [];
290
+ const versionLineRe = /^##\s+(?:\[\s*)?(v?\d[\w.+\-]*?)(?:\s*\])?\s*(?:[-–(]\s*(\d{4}-\d{2}-\d{2})\s*\)?)?\s*$/i;
291
+ let currentVersion = null;
292
+ let currentDate = null;
293
+ let currentBody = [];
294
+ const flush = () => {
295
+ if (!currentVersion) return;
296
+ out.push({
297
+ version: currentVersion,
298
+ releasedAt: currentDate,
299
+ body: currentBody.join("\n").trim()
300
+ });
301
+ currentVersion = null;
302
+ currentDate = null;
303
+ currentBody = [];
304
+ };
305
+ for (const line of lines) {
306
+ if (line.startsWith("## ")) {
307
+ const match = line.match(versionLineRe);
308
+ if (match) {
309
+ flush();
310
+ currentVersion = match[1].replace(/^v/i, "");
311
+ currentDate = match[2] ?? null;
312
+ continue;
313
+ }
314
+ flush();
315
+ continue;
316
+ }
317
+ if (currentVersion) currentBody.push(line);
318
+ }
319
+ flush();
320
+ return out.filter((i) => i.body.length > 0);
321
+ }
663
322
  function aiderReleaseDates() {
664
323
  try {
665
324
  const out = execSync(`gh api "repos/Aider-AI/aider/releases?per_page=100"`, {
@@ -902,7 +561,7 @@ async function fetchHtmlReleases(parser, sinceIso, signal) {
902
561
 
903
562
  // src/fetch-mcp-registry.ts
904
563
  import { writeFileSync, mkdirSync } from "fs";
905
- import { join as join2 } from "path";
564
+ import { join } from "path";
906
565
  var UA2 = { "user-agent": "claude-synergy/0.1.0", accept: "application/json" };
907
566
  async function fetchOfficialMcpRegistry(opts = {}) {
908
567
  const maxPages = opts.maxPages ?? 50;
@@ -974,7 +633,7 @@ async function fetchSmitheryRegistry(opts = {}) {
974
633
  }
975
634
  function writeCatalog(productDir, productName, entries) {
976
635
  mkdirSync(productDir, { recursive: true });
977
- const catalogPath = join2(productDir, "CATALOG.md");
636
+ const catalogPath = join(productDir, "CATALOG.md");
978
637
  const lines = [];
979
638
  lines.push(`# ${productName} \u2014 MCP server catalog`);
980
639
  lines.push("");
@@ -1044,6 +703,9 @@ function assertSafeFilename(name, context) {
1044
703
  }
1045
704
  var HARDCODED_FALLBACK_TARGETS = [
1046
705
  // ── Existing Anthropic GH-Releases sources ────────────────────────────────
706
+ // FE-1: claude-code is the flagship product — wired to gh-releases because the
707
+ // repo publishes Releases whose bodies mirror CHANGELOG.md but carry real dates.
708
+ { product: "claude-code", strategy: "gh-releases", repo: "anthropics/claude-code" },
1047
709
  { product: "claude-agent-sdk-python", strategy: "gh-releases", repo: "anthropics/claude-agent-sdk-python" },
1048
710
  { product: "claude-agent-sdk-typescript", strategy: "gh-releases", repo: "anthropics/claude-agent-sdk-typescript" },
1049
711
  { product: "anthropic-cli", strategy: "gh-releases", repo: "anthropics/anthropic-cli" },
@@ -1193,8 +855,8 @@ async function fetchGhReleases(db, outDir, target, since) {
1193
855
  assertSafeFilename(filename, `gh-releases filename for tag ${JSON.stringify(r.tag_name)}`);
1194
856
  const vName = sanitizeFilename(`v${baseName}`);
1195
857
  assertSafeFilename(vName, `gh-releases vName for tag ${JSON.stringify(r.tag_name)}`);
1196
- const path = join3(outDir, `${filename}.md`);
1197
- const vPath = join3(outDir, `${vName}.md`);
858
+ const path = join2(outDir, `${filename}.md`);
859
+ const vPath = join2(outDir, `${vName}.md`);
1198
860
  if (existsSync(path) || existsSync(vPath)) {
1199
861
  if (!latest || r.published_at > latest) latest = r.published_at;
1200
862
  continue;
@@ -1223,7 +885,7 @@ function ghReleases(repo, sinceIso) {
1223
885
  );
1224
886
  } catch (e) {
1225
887
  const stderr = (e.stderr ?? "").toString();
1226
- if (stderr.includes("404")) return page === 1 ? [] : all;
888
+ if (stderr.includes("404")) return page === 1 ? [] : filterAndShape(all, sinceIso);
1227
889
  if (stderr.includes("403") || stderr.includes("429") || stderr.includes("rate limit")) {
1228
890
  throw new Error(
1229
891
  `GitHub API rate limit hit for ${repo}. Set GITHUB_TOKEN env var for 5000 req/hr (vs 60 unauthenticated). Original error: ${e.message}`
@@ -1235,12 +897,19 @@ function ghReleases(repo, sinceIso) {
1235
897
  try {
1236
898
  batch = JSON.parse(out);
1237
899
  } catch {
1238
- return page === 1 ? [] : all;
900
+ return page === 1 ? [] : filterAndShape(all, sinceIso);
1239
901
  }
1240
902
  if (!Array.isArray(batch) || batch.length === 0) break;
1241
903
  all.push(...batch);
904
+ if (batch.length === 100) {
905
+ const lastPubAt = batch[batch.length - 1]?.published_at;
906
+ if (lastPubAt && lastPubAt <= sinceIso) break;
907
+ }
1242
908
  if (batch.length < 100) break;
1243
909
  }
910
+ return filterAndShape(all, sinceIso);
911
+ }
912
+ function filterAndShape(all, sinceIso) {
1244
913
  return all.filter((r) => r.published_at && r.published_at > sinceIso).map((r) => ({
1245
914
  tag_name: r.tag_name,
1246
915
  published_at: r.published_at,
@@ -1286,7 +955,7 @@ async function fetchRss(db, outDir, target, since, signal) {
1286
955
  try {
1287
956
  const safeSlug = sanitizeFilename(item.slug);
1288
957
  assertSafeFilename(safeSlug, `rss slug for ${JSON.stringify(item.slug)}`);
1289
- const path = join3(outDir, `${safeSlug}.md`);
958
+ const path = join2(outDir, `${safeSlug}.md`);
1290
959
  if (existsSync(path)) {
1291
960
  if (!latest || item.pubDate > latest) latest = item.pubDate;
1292
961
  continue;
@@ -1318,10 +987,17 @@ async function fetchRss(db, outDir, target, since, signal) {
1318
987
  }
1319
988
  async function fetchRawChangelog(db, outDir, target, since, signal) {
1320
989
  if (!target.rawChangelogUrl) throw new Error(`${target.product}: raw-changelog requires url`);
1321
- if (target.rawChangelogParser !== "aider-history") {
1322
- throw new Error(`${target.product}: unsupported parser ${target.rawChangelogParser}`);
990
+ let items;
991
+ switch (target.rawChangelogParser) {
992
+ case "aider-history":
993
+ items = await fetchAiderHistory(target.rawChangelogUrl, since, signal);
994
+ break;
995
+ case "keep-a-changelog":
996
+ items = await fetchKeepAChangelog(target.rawChangelogUrl, since, signal);
997
+ break;
998
+ default:
999
+ throw new Error(`${target.product}: unsupported parser ${target.rawChangelogParser}`);
1323
1000
  }
1324
- const items = await fetchAiderHistory(target.rawChangelogUrl, since, signal);
1325
1001
  let latest = null;
1326
1002
  let fetched = 0;
1327
1003
  const errors = [];
@@ -1329,7 +1005,7 @@ async function fetchRawChangelog(db, outDir, target, since, signal) {
1329
1005
  try {
1330
1006
  const safeVersion = sanitizeFilename(item.version);
1331
1007
  assertSafeFilename(safeVersion, `raw-changelog version for ${JSON.stringify(item.version)}`);
1332
- const path = join3(outDir, `${safeVersion}.md`);
1008
+ const path = join2(outDir, `${safeVersion}.md`);
1333
1009
  if (existsSync(path)) {
1334
1010
  if (item.releasedAt && (!latest || item.releasedAt > latest)) latest = item.releasedAt;
1335
1011
  continue;
@@ -1368,7 +1044,7 @@ async function fetchHtmlScrape(db, outDir, target, since, signal) {
1368
1044
  try {
1369
1045
  const safeSlug = sanitizeFilename(item.slug);
1370
1046
  assertSafeFilename(safeSlug, `html-scrape slug for ${JSON.stringify(item.slug)}`);
1371
- const path = join3(outDir, `${safeSlug}.md`);
1047
+ const path = join2(outDir, `${safeSlug}.md`);
1372
1048
  if (existsSync(path)) {
1373
1049
  if (!latest || item.pubDate > latest) latest = item.pubDate;
1374
1050
  continue;
@@ -1408,7 +1084,7 @@ async function fetchPlaywrightStrategy(db, outDir, target, since) {
1408
1084
  try {
1409
1085
  const safeSlug = sanitizeFilename(item.slug);
1410
1086
  assertSafeFilename(safeSlug, `playwright slug for ${JSON.stringify(item.slug)}`);
1411
- const path = join3(outDir, `${safeSlug}.md`);
1087
+ const path = join2(outDir, `${safeSlug}.md`);
1412
1088
  if (existsSync(path)) {
1413
1089
  if (!latest || item.pubDate > latest) latest = item.pubDate;
1414
1090
  continue;
@@ -1487,46 +1163,6 @@ function seedMarkersFromDb(db) {
1487
1163
  return out;
1488
1164
  }
1489
1165
 
1490
- // src/errors.ts
1491
- var AppError = class extends Error {
1492
- code;
1493
- hint;
1494
- cause;
1495
- retryable;
1496
- constructor(opts) {
1497
- super(opts.message);
1498
- this.name = "AppError";
1499
- this.code = opts.code;
1500
- this.hint = opts.hint;
1501
- this.cause = opts.cause;
1502
- this.retryable = opts.retryable ?? false;
1503
- }
1504
- /** Return the structured JSON shape (useful for --json output and MCP results). */
1505
- toJSON() {
1506
- return {
1507
- code: this.code,
1508
- message: this.message,
1509
- hint: this.hint,
1510
- ...this.cause ? { cause: this.cause } : {},
1511
- ...this.retryable ? { retryable: this.retryable } : {}
1512
- };
1513
- }
1514
- };
1515
- function formatError(err, verbose = false) {
1516
- if (err instanceof AppError) {
1517
- const lines = [`\u2717 [${err.code}] ${err.message}`];
1518
- lines.push(` hint: ${err.hint}`);
1519
- if (verbose && err.cause) {
1520
- lines.push(` cause: ${err.cause}`);
1521
- }
1522
- return lines.join("\n");
1523
- }
1524
- if (err instanceof Error) {
1525
- return `\u2717 ${err.message}`;
1526
- }
1527
- return `\u2717 ${String(err)}`;
1528
- }
1529
-
1530
1166
  // src/cli.ts
1531
1167
  var LOG_LEVELS = { silent: 0, normal: 1, verbose: 2, debug: 3 };
1532
1168
  function resolveLogLevel() {
@@ -1555,8 +1191,8 @@ function shutdown(signal) {
1555
1191
  process.on("SIGINT", () => shutdown("SIGINT"));
1556
1192
  process.on("SIGTERM", () => shutdown("SIGTERM"));
1557
1193
  var cwd = process.cwd();
1558
- var DEFAULT_DB = join4(cwd, "data", "claude-synergy.db");
1559
- var DEFAULT_PRODUCTS = join4(cwd, "products");
1194
+ var DEFAULT_DB = join3(cwd, "data", "claude-synergy.db");
1195
+ var DEFAULT_PRODUCTS = join3(cwd, "products");
1560
1196
  var _require = createRequire(import.meta.url);
1561
1197
  var { version: PKG_VERSION } = _require("../package.json");
1562
1198
  function intOpt(name, raw, defaultValue, min = 1, max = 1e4) {
@@ -1573,6 +1209,71 @@ function openTrackedDb(path) {
1573
1209
  activeDb = db;
1574
1210
  return db;
1575
1211
  }
1212
+ function parseRelativeDate(input, now = /* @__PURE__ */ new Date()) {
1213
+ const trimmed = input.trim();
1214
+ if (trimmed.length === 0) {
1215
+ throw new AppError({
1216
+ code: "INVALID_DATE",
1217
+ message: "date is empty",
1218
+ hint: "Pass a date like 7d, 2w, 1m, 1y, or YYYY-MM-DD"
1219
+ });
1220
+ }
1221
+ const relMatch = /^(\d+)([dwmy])$/i.exec(trimmed);
1222
+ if (relMatch) {
1223
+ const n = parseInt(relMatch[1], 10);
1224
+ const unit = relMatch[2].toLowerCase();
1225
+ if (!Number.isFinite(n) || n < 0) {
1226
+ throw new AppError({
1227
+ code: "INVALID_DATE",
1228
+ message: `invalid relative date: ${input}`,
1229
+ hint: "Relative dates must be a non-negative integer (e.g. 7d, 2w, 1m, 1y)"
1230
+ });
1231
+ }
1232
+ const d = new Date(now);
1233
+ switch (unit) {
1234
+ case "d":
1235
+ d.setUTCDate(d.getUTCDate() - n);
1236
+ break;
1237
+ case "w":
1238
+ d.setUTCDate(d.getUTCDate() - n * 7);
1239
+ break;
1240
+ case "m":
1241
+ d.setUTCMonth(d.getUTCMonth() - n);
1242
+ break;
1243
+ case "y":
1244
+ d.setUTCFullYear(d.getUTCFullYear() - n);
1245
+ break;
1246
+ }
1247
+ return d.toISOString().slice(0, 10);
1248
+ }
1249
+ const parsed = new Date(trimmed);
1250
+ if (Number.isNaN(parsed.getTime())) {
1251
+ throw new AppError({
1252
+ code: "INVALID_DATE",
1253
+ message: `invalid date: ${input}`,
1254
+ hint: "Use YYYY-MM-DD, a full ISO timestamp, or a relative form like 7d/2w/1m/1y"
1255
+ });
1256
+ }
1257
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed;
1258
+ return parsed.toISOString();
1259
+ }
1260
+ function resolveDateOrExit(name, raw) {
1261
+ if (raw === void 0) return void 0;
1262
+ try {
1263
+ return parseRelativeDate(raw);
1264
+ } catch (e) {
1265
+ if (e instanceof AppError) {
1266
+ if (program.opts().json) {
1267
+ console.log(JSON.stringify(e.toJSON()));
1268
+ } else {
1269
+ console.error(formatError(e, LOG_LEVELS[logLevel] >= LOG_LEVELS.verbose));
1270
+ }
1271
+ } else {
1272
+ console.error(`error: --${name} ${e.message}`);
1273
+ }
1274
+ process.exit(1);
1275
+ }
1276
+ }
1576
1277
  var CONTEXT_PROVIDERS = ["none", "structured", "ollama", "claude-haiku"];
1577
1278
  var EMBED_PROVIDERS = ["ollama", "voyage"];
1578
1279
  var RERANK_PROVIDERS = ["none", "ollama-judge", "voyage", "cohere"];
@@ -1665,7 +1366,7 @@ program.command("ingest").description("Parse products/*/releases/*.md and load i
1665
1366
  }
1666
1367
  db.close();
1667
1368
  });
1668
- program.command("query <text>").description('Full-text search across all change bullets (FTS5). Quote multi-word: hk query "managed agents"').option("-d, --db <path>", "database path", DEFAULT_DB).option("-p, --product <name>", "limit to one product").option("-s, --since <date>", "YYYY-MM-DD lower bound").option("-k, --kind <kind>", "added|fixed|breaking|deprecated|renamed|removed|improved|changed").option("-l, --limit <n>", "max results", "20").action((text, opts) => {
1369
+ program.command("query <text>").description('Full-text search across all change bullets (FTS5). Quote multi-word: hk query "managed agents"').option("-d, --db <path>", "database path", DEFAULT_DB).option("-p, --product <name>", "limit to one product").option("-s, --since <date>", "lower bound (YYYY-MM-DD or relative like 7d, 2w, 1m)").option("-u, --until <date>", "upper bound (YYYY-MM-DD or relative like 7d, 2w, 1m)").option("-k, --kind <kind>", "added|fixed|breaking|deprecated|renamed|removed|improved|changed").option("-l, --limit <n>", "max results", "20").action((text, opts) => {
1669
1370
  logDebug(`text=${JSON.stringify(text)} opts=${JSON.stringify(opts)}`);
1670
1371
  const db = openTrackedDb(opts.db);
1671
1372
  if (warnIfEmpty(db)) {
@@ -1673,11 +1374,14 @@ program.command("query <text>").description('Full-text search across all change
1673
1374
  return;
1674
1375
  }
1675
1376
  const q = text;
1377
+ const since = resolveDateOrExit("since", opts.since);
1378
+ const until = resolveDateOrExit("until", opts.until);
1676
1379
  let results;
1677
1380
  try {
1678
1381
  results = searchChanges(db, q, {
1679
1382
  product: opts.product,
1680
- since: opts.since,
1383
+ since,
1384
+ until,
1681
1385
  kind: opts.kind,
1682
1386
  limit: intOpt("limit", opts.limit, 20)
1683
1387
  });
@@ -1857,7 +1561,7 @@ program.command("sync").description("Run fetch \u2192 ingest \u2192 embed in seq
1857
1561
  }
1858
1562
  }
1859
1563
  process.stderr.write("\n=== ingest ===\n");
1860
- const { ingestAll: ingestAll2 } = await import("./ingest-3LJNQWS7.js");
1564
+ const { ingestAll: ingestAll2 } = await import("./ingest-Z45YH7OX.js");
1861
1565
  const ingestStats = ingestAll2(db, opts.productsRoot);
1862
1566
  console.log(`ingested ${ingestStats.releasesAdded} releases, ${ingestStats.changesAdded} changes, ${ingestStats.entitiesAdded} entities`);
1863
1567
  if (!opts.skipEmbed) {
@@ -1945,18 +1649,21 @@ program.command("embed").description("Generate contextual chunks + embeddings (T
1945
1649
  }
1946
1650
  }
1947
1651
  );
1948
- program.command("hybrid <text>").description("Hybrid FTS5 + sqlite-vec search via RRF, optional rerank (requires `hk embed` first)").option("-d, --db <path>", "database path", DEFAULT_DB).option("-p, --product <name>", "limit to one product").option("-s, --since <date>", "YYYY-MM-DD lower bound").option("-k, --kind <kind>", "added|fixed|breaking|deprecated|renamed|removed|improved|changed").addOption(
1652
+ program.command("hybrid <text>").description("Hybrid FTS5 + sqlite-vec search via RRF, optional rerank (requires `hk embed` first)").option("-d, --db <path>", "database path", DEFAULT_DB).option("-p, --product <name>", "limit to one product").option("-s, --since <date>", "lower bound (YYYY-MM-DD or relative like 7d, 2w, 1m)").option("-u, --until <date>", "upper bound (YYYY-MM-DD or relative like 7d, 2w, 1m)").option("-k, --kind <kind>", "added|fixed|breaking|deprecated|renamed|removed|improved|changed").addOption(
1949
1653
  new Option("-e, --embed <provider>", "embedding provider for query").default("ollama").choices([...EMBED_PROVIDERS])
1950
1654
  ).addOption(
1951
1655
  new Option("-r, --rerank <provider>", "rerank provider").default("none").choices([...RERANK_PROVIDERS])
1952
1656
  ).option("-l, --limit <n>", "max results", "10").option("--top-k <n>", "per-channel pull before fusion", "60").option("--rerank-candidates <n>", "how many RRF candidates to rerank", "20").action(
1953
1657
  async (text, opts) => {
1954
1658
  const db = openTrackedDb(opts.db);
1659
+ const since = resolveDateOrExit("since", opts.since);
1660
+ const until = resolveDateOrExit("until", opts.until);
1955
1661
  try {
1956
1662
  const t0 = Date.now();
1957
1663
  const results = await hybridSearch(db, text, {
1958
1664
  product: opts.product,
1959
- since: opts.since,
1665
+ since,
1666
+ until,
1960
1667
  kind: opts.kind,
1961
1668
  embedProviderName: opts.embed,
1962
1669
  rerankProviderName: opts.rerank,
@@ -2008,13 +1715,14 @@ ${results.length} result${results.length === 1 ? "" : "s"} in ${ms}ms (rerank: $
2008
1715
  }
2009
1716
  }
2010
1717
  );
2011
- program.command("latest").description("Recent releases across all products (or one)").option("-d, --db <path>", "database path", DEFAULT_DB).option("-p, --product <name>", "limit to one product").option("-l, --limit <n>", "max results", "20").action((opts) => {
1718
+ program.command("latest").description("Recent releases across all products (or one)").option("-d, --db <path>", "database path", DEFAULT_DB).option("-p, --product <name>", "limit to one product").option("-s, --since <date>", "lower bound (YYYY-MM-DD or relative like 7d, 2w, 1m)").option("-l, --limit <n>", "max results", "20").action((opts) => {
2012
1719
  const db = openTrackedDb(opts.db);
2013
1720
  if (warnIfEmpty(db)) {
2014
1721
  db.close();
2015
1722
  return;
2016
1723
  }
2017
- const releases = recentReleases(db, opts.product, intOpt("limit", opts.limit, 20));
1724
+ const since = resolveDateOrExit("since", opts.since);
1725
+ const releases = recentReleases(db, opts.product, intOpt("limit", opts.limit, 20), since);
2018
1726
  if (program.opts().json) {
2019
1727
  console.log(JSON.stringify(releases));
2020
1728
  } else if (releases.length === 0) {
@@ -2047,6 +1755,117 @@ program.command("products").description("List all products in the DB with releas
2047
1755
  }
2048
1756
  db.close();
2049
1757
  });
1758
+ program.command("diff [product]").description("Show changes in a date window, grouped by release. Default: last 7 days across all products.").option("-d, --db <path>", "database path", DEFAULT_DB).option("-s, --since <date>", "lower bound (YYYY-MM-DD or relative like 7d, 2w, 1m)", "7d").option("-u, --until <date>", "upper bound (YYYY-MM-DD or relative); default: now").option("-k, --kind <kind>", "added|fixed|breaking|deprecated|renamed|removed|improved|changed").option("-l, --limit <n>", "max change rows", "200").action((product, opts) => {
1759
+ const db = openTrackedDb(opts.db);
1760
+ if (warnIfEmpty(db)) {
1761
+ db.close();
1762
+ return;
1763
+ }
1764
+ const since = resolveDateOrExit("since", opts.since);
1765
+ const until = resolveDateOrExit("until", opts.until);
1766
+ if (!since) {
1767
+ console.error("error: --since is required");
1768
+ db.close();
1769
+ process.exit(1);
1770
+ return;
1771
+ }
1772
+ let results;
1773
+ try {
1774
+ results = getChangesSince(db, {
1775
+ since,
1776
+ until,
1777
+ product,
1778
+ kind: opts.kind,
1779
+ limit: intOpt("limit", opts.limit, 200, 1, 1e4)
1780
+ });
1781
+ } catch (e) {
1782
+ const appErr = new AppError({
1783
+ code: "DIFF_FAILED",
1784
+ message: `diff failed: ${e.message}`,
1785
+ hint: "Verify --since / --until parse correctly and the DB is initialized.",
1786
+ cause: e.message
1787
+ });
1788
+ if (program.opts().json) console.log(JSON.stringify(appErr.toJSON()));
1789
+ else console.error(formatError(appErr, LOG_LEVELS[logLevel] >= LOG_LEVELS.verbose));
1790
+ db.close();
1791
+ process.exit(1);
1792
+ return;
1793
+ }
1794
+ if (program.opts().json) {
1795
+ console.log(JSON.stringify(results));
1796
+ } else if (results.length === 0) {
1797
+ const scope = product ? ` for product "${product}"` : "";
1798
+ console.log(`(no changes in window since ${since}${until ? ` until ${until}` : ""}${scope})`);
1799
+ console.log("Try widening the date window: hk diff --since 30d, or remove --kind / --product filters.");
1800
+ } else {
1801
+ let totalChanges = 0;
1802
+ for (const rel of results) {
1803
+ const date = rel.released_at ?? "????-??-??";
1804
+ console.log(`
1805
+ ${date} ${rel.product}@${rel.version} (${rel.changes.length} change${rel.changes.length === 1 ? "" : "s"})`);
1806
+ for (const c of rel.changes) {
1807
+ console.log(` - [${c.kind}] ${c.text}`);
1808
+ }
1809
+ totalChanges += rel.changes.length;
1810
+ }
1811
+ const sinceLbl = opts.since;
1812
+ const untilLbl = opts.until ? ` until ${opts.until}` : "";
1813
+ console.log(`
1814
+ ${totalChanges} change${totalChanges === 1 ? "" : "s"} across ${results.length} release${results.length === 1 ? "" : "s"} since ${sinceLbl}${untilLbl}`);
1815
+ }
1816
+ db.close();
1817
+ });
1818
+ program.command("breaking").description("List breaking changes, most recent first. Filter by product / date window.").option("-d, --db <path>", "database path", DEFAULT_DB).option("-p, --product <name>", "limit to one product").option("-s, --since <date>", "lower bound (YYYY-MM-DD or relative like 7d, 2w, 1m)").option("-u, --until <date>", "upper bound (YYYY-MM-DD or relative like 7d, 2w, 1m)").option("-l, --limit <n>", "max results", "50").action((opts) => {
1819
+ const db = openTrackedDb(opts.db);
1820
+ if (warnIfEmpty(db)) {
1821
+ db.close();
1822
+ return;
1823
+ }
1824
+ const since = resolveDateOrExit("since", opts.since);
1825
+ const until = resolveDateOrExit("until", opts.until);
1826
+ let results;
1827
+ try {
1828
+ results = browseChanges(db, {
1829
+ product: opts.product,
1830
+ since,
1831
+ until,
1832
+ kind: "breaking",
1833
+ limit: intOpt("limit", opts.limit, 50, 1, 1e4)
1834
+ });
1835
+ } catch (e) {
1836
+ const appErr = new AppError({
1837
+ code: "BREAKING_FAILED",
1838
+ message: `breaking query failed: ${e.message}`,
1839
+ hint: "Verify --since / --until parse correctly and the DB is initialized.",
1840
+ cause: e.message
1841
+ });
1842
+ if (program.opts().json) console.log(JSON.stringify(appErr.toJSON()));
1843
+ else console.error(formatError(appErr, LOG_LEVELS[logLevel] >= LOG_LEVELS.verbose));
1844
+ db.close();
1845
+ process.exit(1);
1846
+ return;
1847
+ }
1848
+ if (program.opts().json) {
1849
+ console.log(JSON.stringify(results.map((r) => ({
1850
+ released_at: r.released_at,
1851
+ product: r.product,
1852
+ version: r.version,
1853
+ kind: r.kind,
1854
+ text: r.text
1855
+ }))));
1856
+ } else if (results.length === 0) {
1857
+ console.log("(no breaking changes)");
1858
+ console.log('Note: ingest may not have classified any changes as breaking yet \u2014 try `hk query "" --kind breaking` patterns, or widen --since.');
1859
+ } else {
1860
+ for (const r of results) {
1861
+ console.log(`${r.released_at ?? "????-??-??"} ${r.product}@${r.version} [${r.kind}]`);
1862
+ console.log(` ${r.text}`);
1863
+ }
1864
+ console.log(`
1865
+ ${results.length} breaking change${results.length === 1 ? "" : "s"}`);
1866
+ }
1867
+ db.close();
1868
+ });
2050
1869
  program.command("top <entity-type>").description("Most-mentioned entities of a type (env_var, slash_command, cli_option, model_id, beta_header, cve, ghsa, hook_event, setting_key)").option("-d, --db <path>", "database path", DEFAULT_DB).option("-l, --limit <n>", "max results", "30").action((entityType, opts) => {
2051
1870
  const db = openTrackedDb(opts.db);
2052
1871
  if (warnIfEmpty(db)) {
@@ -2088,3 +1907,6 @@ program.parseAsync(process.argv).catch((e) => {
2088
1907
  }
2089
1908
  process.exitCode = 2;
2090
1909
  });
1910
+ export {
1911
+ parseRelativeDate
1912
+ };