simple-dynamsoft-mcp 7.7.1 → 7.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-dynamsoft-mcp",
3
- "version": "7.7.1",
3
+ "version": "7.7.2",
4
4
  "description": "MCP server for Dynamsoft SDKs - Capture Vision, Barcode Reader (Mobile/Python/Web), Dynamic Web TWAIN, and Document Viewer. Provides documentation, code snippets, and API guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  "scripts": {
25
25
  "start": "node src/index.js",
26
26
  "test": "npm run test:lite",
27
- "test:unit": "node --test test/unit/gemini-retry.test.js test/unit/profile-config.test.js test/unit/lexical-provider.test.js test/unit/hydration-mode.test.js test/unit/hydration-policy.test.js test/unit/repo-map.test.js test/unit/download-utils.test.js test/unit/logging.test.js test/unit/request-logging.test.js test/unit/request-log-file.test.js test/unit/rag-signature-manifest.test.js test/unit/startup-timing.test.js test/unit/http-startup-readiness.test.js test/unit/resource-index-startup.test.js test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/update-sdk-versions.test.js test/unit/version-policy.test.js test/unit/query-expansion.test.js test/unit/docs-loader-enrich.test.js test/unit/shared-docs.test.js test/unit/vector-cache.test.js test/unit/doc-slugs.test.js",
27
+ "test:unit": "node --test test/unit/gemini-retry.test.js test/unit/profile-config.test.js test/unit/lexical-provider.test.js test/unit/hydration-mode.test.js test/unit/hydration-policy.test.js test/unit/repo-map.test.js test/unit/download-utils.test.js test/unit/logging.test.js test/unit/request-logging.test.js test/unit/request-log-file.test.js test/unit/rag-signature-manifest.test.js test/unit/startup-timing.test.js test/unit/http-startup-readiness.test.js test/unit/resource-index-startup.test.js test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/update-sdk-versions.test.js test/unit/version-policy.test.js test/unit/query-expansion.test.js test/unit/docs-loader-enrich.test.js test/unit/shared-docs.test.js test/unit/vector-cache.test.js test/unit/doc-slugs.test.js test/unit/incremental-embedding.test.js",
28
28
  "test:lite": "npm run test:stdio && npm run test:http && npm run test:package",
29
29
  "test:lexical": "node --test test/integration/stdio.test.js test/integration/http.test.js",
30
30
  "test:gemini": "node scripts/run-gemini-tests.mjs",
@@ -25,7 +25,7 @@ ensureEnvDefaults();
25
25
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
26
26
  const { prewarmRagIndex, ragConfig } = await import("../src/rag/index.js");
27
27
 
28
- await prewarmRagIndex();
28
+ await prewarmRagIndex({ throwOnError: true });
29
29
 
30
30
  const cacheDir = resolve(ragConfig.cacheDir);
31
31
  if (!existsSync(cacheDir)) {
@@ -49,20 +49,47 @@ const allHeaders = readdirSync(cacheDir, { withFileTypes: true })
49
49
  .sort();
50
50
 
51
51
  const toVectorFile = (path) => path.replace(/\.json$/i, ".f32");
52
+ const readMeta = (path) => {
53
+ try {
54
+ return JSON.parse(readFileSync(path, "utf8")).meta || {};
55
+ } catch {
56
+ return {};
57
+ }
58
+ };
59
+ // The model the build just used; only its cache is shipped.
60
+ const activeModel = ragConfig.provider === "gemini" ? ragConfig.geminiModel : "";
61
+
52
62
  const cacheFiles = [];
53
63
  for (const path of allHeaders) {
54
- if (existsSync(toVectorFile(path))) {
55
- cacheFiles.push(path);
56
- } else {
64
+ if (!existsSync(toVectorFile(path))) {
57
65
  rmSync(path, { force: true });
58
66
  console.log(`[rag-prebuild] removed legacy cache without vectors: ${basename(path)}`);
67
+ continue;
59
68
  }
69
+ const meta = readMeta(path);
70
+ if (meta.provider === ragConfig.provider && meta.model !== activeModel) {
71
+ // A seeded index for a previous model (e.g. after a model change). The
72
+ // build succeeded with the active model, so it is obsolete; never ship it.
73
+ rmSync(path, { force: true });
74
+ rmSync(toVectorFile(path), { force: true });
75
+ console.log(`[rag-prebuild] removed cache for previous model ${meta.model}: ${basename(path)}`);
76
+ continue;
77
+ }
78
+ cacheFiles.push(path);
60
79
  }
61
80
 
62
81
  if (cacheFiles.length === 0) {
63
82
  throw new Error(`No cache files with vectors found in ${cacheDir}.`);
64
83
  }
65
84
 
85
+ // A successful build prunes every other cache for its provider/model, so exactly
86
+ // one complete cache for the active model must remain; more means a stale
87
+ // seeded index survived and could be shipped under this version.
88
+ const providerCaches = cacheFiles.filter((path) => readMeta(path).provider === ragConfig.provider);
89
+ if (providerCaches.length !== 1) {
90
+ throw new Error(`Expected exactly one ${ragConfig.provider} cache for ${activeModel} after the build, found ${providerCaches.length}: ${providerCaches.map((path) => basename(path)).join(", ")}`);
91
+ }
92
+
66
93
  const vectorFiles = cacheFiles.map(toVectorFile);
67
94
 
68
95
  let indexSignature = "";
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env bash
2
+ # Seed a RAG cache directory with the vectors from the latest published
3
+ # release, so the next build embeds only chunks whose text changed.
4
+ #
5
+ # scripts/seed-rag-cache.sh <cache-dir> [release-tag]
6
+ #
7
+ # release-tag: seed from that release instead of the latest one.
8
+ #
9
+ # Needs `gh` with GH_TOKEN. Skips (exit 0) when the directory already holds a
10
+ # vector file or no release asset exists; a seed is an optimization only.
11
+ set -euo pipefail
12
+
13
+ cache_dir="${1:?usage: seed-rag-cache.sh <cache-dir> [release-tag]}"
14
+ release_tag="${2:-}"
15
+ repo="${GITHUB_REPOSITORY:-yushulx/simple-dynamsoft-mcp}"
16
+ mkdir -p "$cache_dir"
17
+
18
+ # Only a complete cache (header + vectors) counts; a leftover partial
19
+ # checkpoint from an interrupted run must not block seeding.
20
+ for header in "$cache_dir"/rag-gemini-*.json; do
21
+ [[ -e "$header" && "$header" != *.checkpoint.json && -f "${header%.json}.f32" ]] || continue
22
+ echo "[seed] $cache_dir already has a complete cache ($(basename "$header")); not seeding"
23
+ exit 0
24
+ done
25
+
26
+ work="$(mktemp -d)"
27
+ trap 'rm -rf "$work"' EXIT
28
+
29
+ if ! gh release download ${release_tag:+"$release_tag"} --repo "$repo" --pattern 'prebuilt-rag-index-gemini-*.tar.gz' --dir "$work" 2>"$work/err.log"; then
30
+ echo "[seed] no previous release index available: $(tr '\n' ' ' < "$work/err.log")"
31
+ exit 0
32
+ fi
33
+
34
+ archive="$(find "$work" -maxdepth 1 -name 'prebuilt-rag-index-gemini-*.tar.gz' | head -n 1)"
35
+ mkdir -p "$work/extract"
36
+ tar -xzf "$archive" -C "$work/extract"
37
+ copied=0
38
+ while IFS= read -r file; do
39
+ cp "$file" "$cache_dir/"
40
+ copied=$((copied + 1))
41
+ done < <(find "$work/extract" -type f -name 'rag-gemini-*' ! -name '*.checkpoint.*')
42
+ echo "[seed] copied $copied file(s) from $(basename "$archive") into $cache_dir"
package/src/rag/config.js CHANGED
@@ -71,6 +71,9 @@ const ragConfig = {
71
71
  minScore: readFloatEnv("RAG_MIN_SCORE", 0.2),
72
72
  includeScore: readBoolEnv("RAG_INCLUDE_SCORE", false),
73
73
  rebuild: readBoolEnv("RAG_REBUILD", false),
74
+ // Upper bound on chunks one build may embed (0 = no limit). CI sets it so a
75
+ // missing seed or an unexpected change cannot trigger a full paid re-embed.
76
+ maxEmbedChunks: readIntEnv("RAG_MAX_EMBED_CHUNKS", 0),
74
77
  prewarm: readBoolEnv("RAG_PREWARM", defaultPrewarm),
75
78
  prewarmBlock: readBoolEnv("RAG_PREWARM_BLOCK", false),
76
79
  prebuiltIndexAutoDownload: readBoolEnv("RAG_PREBUILT_INDEX_AUTO_DOWNLOAD", true),
package/src/rag/index.js CHANGED
@@ -215,7 +215,9 @@ async function searchResources({ query, product, edition, platform, type, limit,
215
215
  return [];
216
216
  }
217
217
 
218
- async function prewarmRagIndex() {
218
+ // `throwOnError` rethrows build failures; the prebuild script uses it so a
219
+ // failed build cannot publish whatever older index is in the cache dir.
220
+ async function prewarmRagIndex({ throwOnError = false } = {}) {
219
221
  if (!ragConfig.prewarm) return;
220
222
  logRagConfigOnce(ragConfig);
221
223
  const providers = providerOrchestrator.resolveProviderChain();
@@ -245,6 +247,7 @@ async function prewarmRagIndex() {
245
247
  provider: primary,
246
248
  error: error.message
247
249
  }, { level: "error" });
250
+ if (throwOnError) throw error;
248
251
  }
249
252
  }
250
253
 
@@ -275,12 +275,6 @@ function createProviderOrchestrator({
275
275
  const cacheKey = createHash("sha256").update(JSON.stringify(cacheMeta)).digest("hex");
276
276
  const cacheFile = join(ragConfig.cacheDir, vectorCache.makeCacheFileName(name, model, cacheKey));
277
277
  const checkpointFile = join(ragConfig.cacheDir, vectorCache.makeCheckpointFileName(name, model, cacheKey));
278
- const expectedCacheState = {
279
- cacheKey,
280
- signature,
281
- provider: name,
282
- model
283
- };
284
278
  logRag(
285
279
  `provider=${name} cache_file=${cacheFile} rebuild=${ragConfig.rebuild} cache_key=${cacheKey.slice(0, 12)}`
286
280
  );
@@ -289,103 +283,110 @@ function createProviderOrchestrator({
289
283
  const loadIndex = async () => {
290
284
  if (indexPromise) return indexPromise;
291
285
  indexPromise = (async () => {
292
- if (!ragConfig.rebuild) {
293
- let cacheState = vectorCache.loadVectorIndexCache(cacheFile, expectedCacheState);
294
- if (cacheState.hit) {
295
- const cached = cacheState.payload;
296
- logRag(
297
- `cache hit provider=${name} file=${cacheFile} items=${cached.items.length} vectors=${cached.vectors.length}`
298
- );
299
- return {
300
- items: cached.items,
301
- vectors: cached.vectors
302
- };
303
- }
304
- logRag(`cache miss provider=${name} file=${cacheFile} reason=${cacheState.reason}`);
286
+ // Every load goes through chunk-hash validation: a cache file is
287
+ // trusted only for chunks whose text hash still matches, never because
288
+ // its signature or filename matches.
289
+ if (ragConfig.rebuild) {
290
+ logRag(`cache bypass provider=${name} file=${cacheFile} reason=rebuild_true`);
291
+ vectorCache.clearVectorIndexCheckpoint(checkpointFile);
292
+ }
305
293
 
306
- const downloadResult = await vectorCache.maybeDownloadPrebuiltVectorIndex({
294
+ const items = utils.buildEmbeddingItems(resourceIndex, ragConfig);
295
+ // Each chunk is identified by a hash of model + text, so vectors from any
296
+ // earlier cache, checkpoint, or release can be reused for unchanged text
297
+ // and only new or edited chunks are embedded.
298
+ const indexedItems = items.map((item) => ({
299
+ id: item.id,
300
+ uri: item.uri,
301
+ hash: createHash("sha256").update(`${model}\n${item.text}`).digest("hex").slice(0, 32)
302
+ }));
303
+ const checkpointHashes = new Set();
304
+ const collect = () => (ragConfig.rebuild
305
+ ? new Map()
306
+ : vectorCache.collectReusableVectors({ provider: name, model, checkpointHashes }));
307
+ let reusable = collect();
308
+ // Local caches do not cover every chunk (e.g. an older release's index
309
+ // after an upgrade): try this release's prebuilt index before paying to
310
+ // embed, then re-collect.
311
+ if (!ragConfig.rebuild && indexedItems.some((item) => !reusable.has(item.hash))) {
312
+ const download = await vectorCache.maybeDownloadPrebuiltVectorIndex({
307
313
  provider: name,
308
314
  model,
309
315
  cacheKey,
310
316
  signature,
311
317
  cacheFile
312
318
  });
313
- if (downloadResult.downloaded) {
314
- cacheState = vectorCache.loadVectorIndexCache(cacheFile, expectedCacheState);
315
- if (cacheState.hit) {
316
- const cached = cacheState.payload;
317
- logRag(
318
- `cache hit provider=${name} file=${cacheFile} source=prebuilt_download items=${cached.items.length} vectors=${cached.vectors.length}`
319
- );
320
- return {
321
- items: cached.items,
322
- vectors: cached.vectors
323
- };
324
- }
325
- logRag(`cache miss provider=${name} file=${cacheFile} source=prebuilt_download reason=${cacheState.reason}`);
326
- }
327
- } else {
328
- logRag(`cache bypass provider=${name} file=${cacheFile} reason=rebuild_true`);
329
- vectorCache.clearVectorIndexCheckpoint(checkpointFile);
319
+ if (download.downloaded) reusable = collect();
320
+ }
321
+ const vectors = indexedItems.map((item) => reusable.get(item.hash) || null);
322
+ const missing = [];
323
+ vectors.forEach((vector, index) => {
324
+ if (!vector) missing.push(index);
325
+ });
326
+ logRag(
327
+ `index plan provider=${name} items=${indexedItems.length} reused=${indexedItems.length - missing.length} to_embed=${missing.length}`
328
+ );
329
+ if (ragConfig.maxEmbedChunks > 0 && missing.length > ragConfig.maxEmbedChunks) {
330
+ throw new Error(
331
+ `Refusing to embed ${missing.length} chunks (RAG_MAX_EMBED_CHUNKS=${ragConfig.maxEmbedChunks}); ` +
332
+ `only ${indexedItems.length - missing.length}/${indexedItems.length} could be reused. ` +
333
+ "Check that the cache was seeded from a matching release index."
334
+ );
330
335
  }
331
336
 
332
- const items = utils.buildEmbeddingItems(resourceIndex, ragConfig);
333
- const texts = items.map((item) => item.text);
334
- const indexedItems = items.map((item) => ({ id: item.id, uri: item.uri }));
335
- let normalized = [];
336
- let resumeFrom = 0;
337
- if (!ragConfig.rebuild) {
338
- const checkpointState = vectorCache.loadVectorIndexCheckpoint(checkpointFile, cacheKey, indexedItems);
339
- if (checkpointState.hit) {
340
- normalized = checkpointState.payload.vectors;
341
- resumeFrom = normalized.length;
342
- logRag(
343
- `checkpoint resume provider=${name} file=${checkpointFile} completed=${resumeFrom}/${texts.length}`
344
- );
345
- } else if (checkpointState.reason !== "missing") {
346
- logRag(`checkpoint ignored provider=${name} file=${checkpointFile} reason=${checkpointState.reason}`);
347
- }
337
+ if (missing.length === 0 && vectorCache.cacheHeaderMatches({ cacheFile, provider: name, model, items: indexedItems })) {
338
+ vectorCache.clearVectorIndexCheckpoint(checkpointFile);
339
+ vectorCache.pruneOtherCaches({ provider: name, model, keepFile: cacheFile });
340
+ logRag(`cache hit provider=${name} file=${cacheFile} items=${indexedItems.length}`);
341
+ return { items: indexedItems, vectors };
348
342
  }
349
343
 
350
344
  if (name === "gemini" && embedder.resetMetrics) {
351
345
  embedder.resetMetrics();
352
346
  }
353
347
 
348
+ // The checkpoint keeps every vector this build had to produce: newly
349
+ // embedded ones plus those recovered from an earlier checkpoint, so a
350
+ // second interruption does not lose the first attempt's progress.
351
+ const missingSet = new Set(missing);
352
+ const checkpointIndices = indexedItems
353
+ .map((item, index) => index)
354
+ .filter((index) => missingSet.has(index) || checkpointHashes.has(indexedItems[index].hash));
354
355
  const checkpointIntervalMs = 5000;
355
356
  let lastCheckpointAt = 0;
356
357
  const persistCheckpoint = (force = false) => {
357
358
  const now = Date.now();
358
359
  if (!force && now - lastCheckpointAt < checkpointIntervalMs) return;
359
- const payload = {
360
+ const done = checkpointIndices.filter((index) => vectors[index]);
361
+ vectorCache.saveVectorIndexCheckpoint(checkpointFile, {
360
362
  cacheKey,
361
363
  meta: cacheMeta,
362
- items: indexedItems,
363
- vectors: normalized,
364
- completed: normalized.length,
365
- total: texts.length,
364
+ items: done.map((index) => indexedItems[index]),
365
+ vectors: done.map((index) => vectors[index]),
366
366
  updatedAt: new Date().toISOString()
367
- };
368
- vectorCache.saveVectorIndexCheckpoint(checkpointFile, payload);
367
+ });
369
368
  lastCheckpointAt = now;
370
369
  };
371
370
 
372
- if (resumeFrom < texts.length) {
373
- logRag(
374
- `building index provider=${name} embed_items=${texts.length} remaining=${texts.length - resumeFrom} batch_size=${batchSize}`
375
- );
371
+ if (missing.length > 0) {
372
+ logRag(`building index provider=${name} embed_items=${missing.length} batch_size=${batchSize}`);
373
+ let filled = 0;
376
374
  try {
377
375
  const embeddingResult = await embedTextsWithProgress(
378
- texts.slice(resumeFrom),
376
+ missing.map((index) => items[index].text),
379
377
  embedder,
380
378
  batchSize,
381
379
  {
382
- offset: resumeFrom,
383
- total: texts.length,
380
+ offset: 0,
381
+ total: missing.length,
384
382
  providerName: name,
385
383
  logRag,
386
384
  isRateLimitError: (error) => utils.isRateLimitError(error, isRateLimitGeminiStatus),
387
- onChunk: ({ vectors, completed, total }) => {
388
- normalized.push(...vectors.map(utils.normalizeVector));
385
+ onChunk: ({ vectors: chunk, completed, total }) => {
386
+ for (const vector of chunk) {
387
+ vectors[missing[filled]] = utils.normalizeVector(vector);
388
+ filled += 1;
389
+ }
389
390
  persistCheckpoint(completed >= total);
390
391
  }
391
392
  }
@@ -409,24 +410,25 @@ function createProviderOrchestrator({
409
410
  `gemini build failed provider=${name} requests=${metrics.requests || 0} retries=${metrics.retries || 0} ` +
410
411
  `retry_delay_ms=${metrics.retryDelayMs || 0} throttle_events=${metrics.throttleEvents || 0} ` +
411
412
  `throttle_delay_ms=${metrics.throttleDelayMs || 0} rate_limit_retries=${metrics.rateLimitRetries || 0} ` +
412
- `checkpoint_completed=${normalized.length}/${texts.length} error=${error.message}`
413
+ `checkpoint_completed=${filled}/${missing.length} error=${error.message}`
413
414
  );
414
415
  }
415
416
  throw error;
416
417
  }
417
- } else {
418
- logRag(`checkpoint already complete provider=${name} completed=${resumeFrom}/${texts.length}`);
419
418
  }
420
419
 
421
420
  const payload = {
422
421
  cacheKey,
423
422
  meta: cacheMeta,
424
423
  items: indexedItems,
425
- vectors: normalized
424
+ vectors
426
425
  };
427
426
  vectorCache.saveVectorIndexCache(cacheFile, payload);
428
427
  vectorCache.clearVectorIndexCheckpoint(checkpointFile);
429
- logRag(`cache saved provider=${name} file=${cacheFile} items=${payload.items.length} vectors=${payload.vectors.length}`);
428
+ vectorCache.pruneOtherCaches({ provider: name, model, keepFile: cacheFile });
429
+ logRag(
430
+ `cache saved provider=${name} file=${cacheFile} items=${payload.items.length} embedded=${missing.length}`
431
+ );
430
432
  return {
431
433
  items: payload.items,
432
434
  vectors: payload.vectors
@@ -10,14 +10,100 @@ function ensureDirectory(path) {
10
10
  }
11
11
  }
12
12
 
13
- function makeCacheFileName(provider, model, cacheKey) {
13
+ function cacheFilePrefix(provider, model) {
14
14
  const safeModel = String(model || "default").replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 32);
15
- return `rag-${provider}-${safeModel}-${cacheKey.slice(0, 12)}.json`;
15
+ return `rag-${provider}-${safeModel}-`;
16
+ }
17
+
18
+ function makeCacheFileName(provider, model, cacheKey) {
19
+ return `${cacheFilePrefix(provider, model)}${cacheKey.slice(0, 12)}.json`;
16
20
  }
17
21
 
18
22
  function makeCheckpointFileName(provider, model, cacheKey) {
19
- const safeModel = String(model || "default").replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 32);
20
- return `rag-${provider}-${safeModel}-${cacheKey.slice(0, 12)}.checkpoint.json`;
23
+ return `${cacheFilePrefix(provider, model)}${cacheKey.slice(0, 12)}.checkpoint.json`;
24
+ }
25
+
26
+ // Header metadata only (no vectors). Returns null when unreadable.
27
+ function readHeader(headerFile) {
28
+ try {
29
+ return JSON.parse(readFileSync(headerFile, "utf8"));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function headerBelongsTo(header, provider, model) {
36
+ return header?.meta?.provider === provider && header?.meta?.model === model;
37
+ }
38
+
39
+ // Cache and checkpoint headers whose stored provider and model match exactly.
40
+ // The filename prefix only narrows the search: sanitized/truncated model names
41
+ // can share a prefix (`m` vs `m-other`).
42
+ function listProviderCacheHeaders(cacheDir, provider, model) {
43
+ if (!existsSync(cacheDir)) return [];
44
+ const prefix = cacheFilePrefix(provider, model);
45
+ return readdirSync(cacheDir)
46
+ .filter((name) => name.startsWith(prefix) && name.endsWith(".json"))
47
+ .map((name) => join(cacheDir, name))
48
+ .filter((path) => headerBelongsTo(readHeader(path), provider, model));
49
+ }
50
+
51
+ function isCheckpointHeader(path) {
52
+ return path.endsWith(".checkpoint.json");
53
+ }
54
+
55
+ // True when `cacheFile` already holds exactly these items (same order and
56
+ // chunk hashes) with a correctly sized vector file, so a build that embeds
57
+ // nothing does not need to rewrite it.
58
+ function cacheHeaderMatches({ cacheFile, provider, model, items }) {
59
+ const header = readHeader(cacheFile);
60
+ if (!header || !headerBelongsTo(header, provider, model) || header.vectorFormat !== VECTOR_FORMAT) return false;
61
+ if (!Array.isArray(header.items) || header.items.length !== items.length || header.vectorCount !== items.length) return false;
62
+ if (!header.items.every((item, i) => item.hash && item.hash === items[i].hash && item.id === items[i].id && item.uri === items[i].uri)) {
63
+ return false;
64
+ }
65
+ const vectorFile = vectorFileFor(cacheFile);
66
+ return existsSync(vectorFile)
67
+ && statSync(vectorFile).size === header.vectorCount * header.dimensions * Float32Array.BYTES_PER_ELEMENT;
68
+ }
69
+
70
+
71
+ // Vectors already computed for identical chunk text, keyed by item `hash`
72
+ // (see providers.js). Sources are every cache and checkpoint for the same
73
+ // provider/model in cacheDir: a previous version's cache, an extracted
74
+ // previous release, or a partial checkpoint. Only caches whose items carry
75
+ // chunk-text hashes are used; older hash-less caches cannot prove their text
76
+ // is unchanged, so they are ignored.
77
+ // `checkpointHashes` (optional Set) receives the hashes found in checkpoints,
78
+ // so a resumed build can carry them into its own checkpoint.
79
+ function collectReusableVectors({ cacheDir, provider, model, checkpointHashes }) {
80
+ const reusable = new Map();
81
+ for (const headerFile of listProviderCacheHeaders(cacheDir, provider, model)) {
82
+ let payload;
83
+ try {
84
+ const result = readVectorFiles(headerFile);
85
+ if (result.error) continue;
86
+ payload = result.payload;
87
+ } catch {
88
+ continue;
89
+ }
90
+ if (!payload.items.every((item) => item.hash)) continue;
91
+ const fromCheckpoint = isCheckpointHeader(headerFile);
92
+ payload.items.forEach((item, i) => {
93
+ if (!payload.vectors[i]) return;
94
+ if (fromCheckpoint) checkpointHashes?.add(item.hash);
95
+ if (!reusable.has(item.hash)) reusable.set(item.hash, payload.vectors[i]);
96
+ });
97
+ }
98
+ return reusable;
99
+ }
100
+
101
+ // Keep only `keepFile` (and its vectors) among this provider/model's caches,
102
+ // so restored/seeded caches do not accumulate in CI caches or release assets.
103
+ function pruneOtherCaches({ cacheDir, provider, model, keepFile }) {
104
+ for (const headerFile of listProviderCacheHeaders(cacheDir, provider, model)) {
105
+ if (resolve(headerFile) !== resolve(keepFile)) removeVectorFiles(headerFile);
106
+ }
21
107
  }
22
108
 
23
109
  // Cache layout: a JSON header (`<name>.json`: cacheKey, meta, items, vector
@@ -126,35 +212,8 @@ function saveVectorIndexCache(cacheDir, cacheFile, payload) {
126
212
  writeVectorFiles(cacheFile, payload);
127
213
  }
128
214
 
129
- function loadVectorIndexCheckpoint(checkpointFile, expectedKey, expectedItems) {
130
- if (!existsSync(checkpointFile)) {
131
- return { hit: false, reason: "missing", payload: null };
132
- }
133
- try {
134
- const { payload: parsed, error } = readVectorFiles(checkpointFile);
135
- if (error) {
136
- return { hit: false, reason: error, payload: null };
137
- }
138
- if (parsed.cacheKey !== expectedKey) {
139
- return { hit: false, reason: "cache_key_mismatch", payload: null };
140
- }
141
- if (parsed.items.length !== expectedItems.length) {
142
- return { hit: false, reason: "items_length_mismatch", payload: null };
143
- }
144
- for (let i = 0; i < expectedItems.length; i += 1) {
145
- if (parsed.items[i]?.id !== expectedItems[i]?.id || parsed.items[i]?.uri !== expectedItems[i]?.uri) {
146
- return { hit: false, reason: "items_mismatch", payload: null };
147
- }
148
- }
149
- if (parsed.vectors.length > expectedItems.length) {
150
- return { hit: false, reason: "vectors_overflow", payload: null };
151
- }
152
- return { hit: true, reason: "ok", payload: parsed };
153
- } catch {
154
- return { hit: false, reason: "parse_error", payload: null };
155
- }
156
- }
157
-
215
+ // Checkpoints hold only the items embedded so far (each with its `hash`);
216
+ // collectReusableVectors picks them up on the next attempt.
158
217
  function saveVectorIndexCheckpoint(cacheDir, checkpointFile, payload) {
159
218
  ensureDirectory(cacheDir);
160
219
  writeVectorFiles(checkpointFile, payload);
@@ -332,21 +391,18 @@ function createVectorCacheHelpers({ ragConfig, pkgVersion, legacyPrebuiltIndexUr
332
391
  continue;
333
392
  }
334
393
 
335
- const migratedPayload = {
336
- ...candidateCache.payload,
337
- cacheKey,
338
- meta: {
339
- ...(candidateCache.payload.meta || {}),
340
- provider,
341
- model,
342
- signature
343
- }
344
- };
345
- saveVectorIndexCache(ragConfig.cacheDir, cacheFile, migratedPayload);
394
+ // Install unchanged (own cacheKey and signature) under a separate
395
+ // name, so a local cache with the same name (and vectors for local
396
+ // edits) is not overwritten. The build reuses only vectors whose
397
+ // chunk-text hash matches, then prunes this extra copy.
398
+ ensureDirectory(ragConfig.cacheDir);
399
+ const installedFile = join(ragConfig.cacheDir, basename(sourceCacheFile).replace(/\.json$/i, "-prebuilt.json"));
400
+ copyFileSync(sourceCacheFile, installedFile);
401
+ copyFileSync(vectorFileFor(sourceCacheFile), vectorFileFor(installedFile));
346
402
  logRag(
347
- `prebuilt index installed provider=${provider} cache_file=${cacheFile} source=${basename(sourceCacheFile)} mode=version_only_compat version=${cachePackageVersion}`
403
+ `prebuilt index installed provider=${provider} cache_file=${installedFile} version=${cachePackageVersion}`
348
404
  );
349
- return { downloaded: true, reason: "installed_version_only_compat" };
405
+ return { downloaded: true, reason: "installed" };
350
406
  }
351
407
 
352
408
  throw new Error(
@@ -371,9 +427,11 @@ function createVectorCacheHelpers({ ragConfig, pkgVersion, legacyPrebuiltIndexUr
371
427
  makeCheckpointFileName,
372
428
  loadVectorIndexCache,
373
429
  saveVectorIndexCache: (cacheFile, payload) => saveVectorIndexCache(ragConfig.cacheDir, cacheFile, payload),
374
- loadVectorIndexCheckpoint,
375
430
  saveVectorIndexCheckpoint: (checkpointFile, payload) => saveVectorIndexCheckpoint(ragConfig.cacheDir, checkpointFile, payload),
376
431
  clearVectorIndexCheckpoint,
432
+ collectReusableVectors: (options) => collectReusableVectors({ cacheDir: ragConfig.cacheDir, ...options }),
433
+ cacheHeaderMatches,
434
+ pruneOtherCaches: (options) => pruneOtherCaches({ cacheDir: ragConfig.cacheDir, ...options }),
377
435
  maybeDownloadPrebuiltVectorIndex
378
436
  };
379
437
  }
@@ -84,7 +84,11 @@ import {
84
84
  } from "./resource-index/builders.js";
85
85
 
86
86
  const registry = JSON.parse(readFileSync(registryPath, "utf8"));
87
- const registrySha256 = createHash("sha256").update(readFileSync(registryPath)).digest("hex");
87
+ // Hash LF-normalized text so Windows (CRLF) and Linux checkouts of the same
88
+ // registry produce the same RAG index signature.
89
+ const registrySha256 = createHash("sha256")
90
+ .update(readFileSync(registryPath, "utf8").replace(/\r\n/g, "\n"))
91
+ .digest("hex");
88
92
 
89
93
  function inferDbrMobilePlatform(articlePath) {
90
94
  const normalized = String(articlePath || "").replace(/\\/g, "/").toLowerCase();