simple-dynamsoft-mcp 7.3.54 → 7.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -108,8 +108,8 @@
108
108
  "branch": "master",
109
109
  "owner": "dynamsoft-docs",
110
110
  "repo": "web-twain-docs",
111
- "commit": "973216bc5f9c5152664882c3ceb11679b74878ef",
112
- "archiveUrl": "https://codeload.github.com/dynamsoft-docs/web-twain-docs/zip/973216bc5f9c5152664882c3ceb11679b74878ef"
111
+ "commit": "84c36dde107cba2d80ed3ee7c42ab232c7434de2",
112
+ "archiveUrl": "https://codeload.github.com/dynamsoft-docs/web-twain-docs/zip/84c36dde107cba2d80ed3ee7c42ab232c7434de2"
113
113
  },
114
114
  {
115
115
  "name": "data/samples/document-scanner-javascript",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-dynamsoft-mcp",
3
- "version": "7.3.54",
3
+ "version": "7.4.1",
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,13 +24,15 @@
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/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",
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/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",
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",
31
31
  "test:stdio": "node --test test/integration/stdio.test.js",
32
32
  "test:http": "node --test test/integration/http.test.js",
33
33
  "test:package": "node --test test/integration/package-runtime.test.js",
34
+ "test:contract": "node --test test/contract/contract.test.js",
35
+ "check:version-drift": "node scripts/check-version-drift.mjs",
34
36
  "test:lazy": "node --test test/integration/lazy-hydration.test.js",
35
37
  "test:regression:unit": "node --test test/unit/create-server.test.js test/unit/server-helpers.test.js test/unit/profile-config.test.js test/unit/hydration-mode.test.js test/unit/logging.test.js",
36
38
  "test:regression:integration": "node --test test/integration/package-runtime.test.js test/integration/lazy-hydration.test.js",
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // Version-drift check (issue #158): compare the SDK versions the server advertises
3
+ // (data/metadata/dynamsoft_sdks.json) against versions pinned inside served
4
+ // samples (gradle bundles, npm deps, CDN URLs). Report-only by default — prints a
5
+ // table and exits 1 when drift is found so CI can flag it.
6
+ //
7
+ // Usage: node scripts/check-version-drift.mjs [--data-dir <path>] (default: ./data or $MCP_DATA_DIR)
8
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ const args = process.argv.slice(2);
12
+ const dirFlagIdx = args.indexOf("--data-dir");
13
+ const dataDir = dirFlagIdx !== -1 ? args[dirFlagIdx + 1] : (process.env.MCP_DATA_DIR || join(process.cwd(), "data"));
14
+
15
+ const manifestPath = join(dataDir, "metadata", "dynamsoft_sdks.json");
16
+ if (!existsSync(manifestPath)) {
17
+ console.error(`No SDK manifest at ${manifestPath}. Pass --data-dir or set MCP_DATA_DIR.`);
18
+ process.exit(2);
19
+ }
20
+
21
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
22
+
23
+ // Collect advertised versions (any "version": "x.y.z" under the sdk registry).
24
+ const advertised = new Set();
25
+ function collectVersions(obj) {
26
+ if (!obj || typeof obj !== "object") return;
27
+ for (const [k, v] of Object.entries(obj)) {
28
+ if (k === "version" && typeof v === "string") advertised.add(v);
29
+ else if (typeof v === "object") collectVersions(v);
30
+ }
31
+ }
32
+ collectVersions(manifest);
33
+
34
+ const PIN_PATTERNS = [
35
+ /barcodereaderbundle:(\d+\.\d+\.\d+)/g,
36
+ /dynamsoft-[a-z-]+["']?\s*:\s*["']\^?(\d+\.\d+\.\d+)/g,
37
+ /@dynamsoft\/[a-z-]+@(\d+\.\d+\.\d+)/g,
38
+ /capture-vision-bundle@(\d+\.\d+\.\d+)/g
39
+ ];
40
+
41
+ const samplesDir = join(dataDir, "samples");
42
+ const pinned = new Map(); // version -> example file
43
+
44
+ function walk(dir, depth) {
45
+ if (depth > 6 || !existsSync(dir)) return;
46
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
47
+ if (entry.name.startsWith(".") || ["node_modules", "build", "Pods"].includes(entry.name)) continue;
48
+ const full = join(dir, entry.name);
49
+ if (entry.isDirectory()) {
50
+ walk(full, depth + 1);
51
+ } else if (/\.(gradle|json|html|md|xml|podspec|txt)$/.test(entry.name)) {
52
+ let content = "";
53
+ try { content = readFileSync(full, "utf8"); } catch { continue; }
54
+ for (const re of PIN_PATTERNS) {
55
+ re.lastIndex = 0;
56
+ let m;
57
+ while ((m = re.exec(content)) !== null) {
58
+ if (!pinned.has(m[1])) pinned.set(m[1], full.replace(dataDir, "."));
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ if (existsSync(samplesDir)) walk(samplesDir, 0);
66
+
67
+ const drift = [...pinned.entries()].filter(([ver]) => !advertised.has(ver));
68
+
69
+ console.log("Advertised SDK versions:", [...advertised].sort().join(", ") || "(none)");
70
+ console.log("\nPinned versions found in samples:");
71
+ for (const [ver, file] of [...pinned.entries()].sort()) {
72
+ const mark = advertised.has(ver) ? "ok " : "DRIFT";
73
+ console.log(` [${mark}] ${ver} e.g. ${file}`);
74
+ }
75
+
76
+ if (drift.length) {
77
+ console.log(`\n${drift.length} pinned version(s) not in the advertised set (possible drift).`);
78
+ process.exit(1);
79
+ }
80
+ console.log("\nNo version drift detected.");
package/src/rag/config.js CHANGED
@@ -63,6 +63,11 @@ const ragConfig = {
63
63
  modelCacheDir: readEnvValue("RAG_MODEL_CACHE_DIR", join(dataRoot, ".rag-cache", "models")),
64
64
  chunkSize: readIntEnv("RAG_CHUNK_SIZE", 1200),
65
65
  chunkOverlap: readIntEnv("RAG_CHUNK_OVERLAP", 200),
66
+ // Keep the total embedded-chunk count within V8's ~512MB max-string limit,
67
+ // which the prebuilt Gemini cache hits via a single JSON.stringify of ~3072-dim
68
+ // vectors. At 24 the release prebuild overflowed ("Invalid string length"); 6 is
69
+ // the count the pre-v7.4 release built successfully on this data. Raising this
70
+ // needs the ceiling-proof cache format tracked in the v7.5 follow-ups.
66
71
  maxChunksPerDoc: readIntEnv("RAG_MAX_CHUNKS_PER_DOC", 6),
67
72
  maxTextChars: readIntEnv("RAG_MAX_TEXT_CHARS", 4000),
68
73
  minScore: readFloatEnv("RAG_MIN_SCORE", 0.2),
package/src/rag/index.js CHANGED
@@ -28,7 +28,9 @@ import {
28
28
  buildIndexSignature,
29
29
  normalizeVector,
30
30
  dotProduct,
31
- isRateLimitError
31
+ isRateLimitError,
32
+ expandQueryTokens,
33
+ extractSnippet
32
34
  } from "./search-utils.js";
33
35
  import { createProviderOrchestrator } from "./providers.js";
34
36
  import { createVectorCacheHelpers } from "./vector-cache.js";
@@ -43,6 +45,8 @@ const searchUtils = {
43
45
  normalizeVector,
44
46
  dotProduct,
45
47
  isRateLimitError,
48
+ expandQueryTokens,
49
+ extractSnippet,
46
50
  entryMatchesScope: (entry, filters) => entryMatchesScope(entry, filters, {
47
51
  editionMatches,
48
52
  platformMatches
@@ -1,12 +1,38 @@
1
1
  import Fuse from "fuse.js";
2
+ import { expandQueryTokens, extractSnippet } from "./search-utils.js";
2
3
 
3
4
  const DEFAULT_FUSE_OPTIONS = {
4
- keys: ["title", "summary", "tags", "uri"],
5
+ keys: [
6
+ { name: "title", weight: 3 },
7
+ { name: "tags", weight: 2 },
8
+ { name: "summary", weight: 1 }
9
+ ],
5
10
  threshold: 0.35,
6
11
  ignoreLocation: true,
7
12
  includeScore: true
8
13
  };
9
14
 
15
+ const BODY_INDEX_CHARS = 4000;
16
+
17
+ // Field-weighted BM25 haystack: title/tags outrank summary; doc/sample body text
18
+ // is indexed at low weight so body-only content is findable; the URI's last slug
19
+ // segment is kept (it is often the most descriptive token) while scheme/version
20
+ // noise is dropped (issue #147).
21
+ function buildLexicalHaystack(entry) {
22
+ const title = entry.title || "";
23
+ const tags = Array.isArray(entry.tags) ? entry.tags.join(" ") : "";
24
+ const summary = entry.summary || "";
25
+ const body = entry.embedText ? String(entry.embedText).slice(0, BODY_INDEX_CHARS) : "";
26
+ const uriSlug = String(entry.uri || "").split("/").filter(Boolean).pop() || "";
27
+ return [
28
+ title, title, title,
29
+ tags, tags,
30
+ summary,
31
+ uriSlug,
32
+ body
33
+ ].join(" \n ");
34
+ }
35
+
10
36
  const BM25_K1 = 1.2;
11
37
  const BM25_B = 0.75;
12
38
 
@@ -41,12 +67,7 @@ function buildBm25Index(entries) {
41
67
  let totalLength = 0;
42
68
 
43
69
  entries.forEach((entry, index) => {
44
- const haystack = [
45
- entry.title,
46
- entry.summary,
47
- Array.isArray(entry.tags) ? entry.tags.join(" ") : "",
48
- entry.uri
49
- ].join(" \n ");
70
+ const haystack = buildLexicalHaystack(entry);
50
71
 
51
72
  const tokens = tokenize(haystack);
52
73
  const termFreq = new Map();
@@ -66,7 +87,8 @@ function buildBm25Index(entries) {
66
87
  index,
67
88
  entry,
68
89
  length: tokens.length,
69
- termFreq
90
+ termFreq,
91
+ snippetText: entry.embedText || entry.summary || entry.title || ""
70
92
  });
71
93
  });
72
94
 
@@ -107,33 +129,40 @@ function createLexicalProvider({
107
129
  const fuse = new Fuse(entries, fuseOptions);
108
130
  const bm25Index = buildBm25Index(entries);
109
131
  const entryByUri = new Map(entries.map((entry) => [entry.uri, entry]));
132
+ const snippetTextByUri = new Map(bm25Index.documents.map((doc) => [doc.entry.uri, doc.snippetText]));
110
133
 
111
134
  return {
112
135
  name: "lexical",
113
136
  search: async (query, filters, limit) => {
114
- const terms = [...new Set(tokenize(query))];
137
+ // Expand with domain synonyms/stems so vocabulary gaps (webcam/camera,
138
+ // datamatrix/data matrix, scanning/scan) do not zero the BM25 component.
139
+ const terms = expandQueryTokens([...new Set(tokenize(query))]);
115
140
  const fuseHits = fuse.search(query);
116
141
  const fuseScoreByUri = new Map();
117
- let maxFuse = 0;
118
142
 
119
143
  for (const hit of fuseHits) {
120
144
  const candidateScore = Number.isFinite(hit.score) ? Math.max(0, 1 - hit.score) : 0;
121
145
  const current = fuseScoreByUri.get(hit.item.uri) || 0;
122
146
  if (candidateScore > current) {
123
147
  fuseScoreByUri.set(hit.item.uri, candidateScore);
124
- if (candidateScore > maxFuse) maxFuse = candidateScore;
125
148
  }
126
149
  }
127
150
 
128
151
  const bm25ScoreByUri = new Map();
129
152
  let maxBm25 = 0;
153
+ // Compute maxFuse over IN-SCOPE hits only. Computing it over all hits let an
154
+ // out-of-scope fuzzy match deflate every in-scope fuse contribution (issue #147).
155
+ let maxFuse = 0;
130
156
 
131
157
  for (const doc of bm25Index.documents) {
132
158
  if (!entryMatchesScope(doc.entry, filters)) continue;
133
159
  const score = computeBm25Score(bm25Index, doc, terms);
134
- if (score <= 0) continue;
135
- bm25ScoreByUri.set(doc.entry.uri, score);
136
- if (score > maxBm25) maxBm25 = score;
160
+ if (score > 0) {
161
+ bm25ScoreByUri.set(doc.entry.uri, score);
162
+ if (score > maxBm25) maxBm25 = score;
163
+ }
164
+ const fuseScore = fuseScoreByUri.get(doc.entry.uri) || 0;
165
+ if (fuseScore > maxFuse) maxFuse = fuseScore;
137
166
  }
138
167
 
139
168
  const scopedUris = new Set();
@@ -155,7 +184,11 @@ function createLexicalProvider({
155
184
  }
156
185
 
157
186
  merged.sort(compareLexicalResults);
158
- const ranked = merged.map((item) => attachScore(item.entry, item.score));
187
+ const ranked = merged.map((item) => {
188
+ const scored = attachScore(item.entry, item.score);
189
+ const snippet = extractSnippet(snippetTextByUri.get(item.entry.uri) || "", terms);
190
+ return snippet ? { ...scored, matchedSnippet: snippet } : scored;
191
+ });
159
192
  if (limit) return ranked.slice(0, limit);
160
193
  return ranked;
161
194
  },
@@ -438,8 +438,16 @@ function createProviderOrchestrator({
438
438
  return {
439
439
  name,
440
440
  search: async (query, filters, limit) => {
441
- const prepared = utils.truncateText(utils.normalizeText(query), ragConfig.maxTextChars);
442
- if (!prepared) return [];
441
+ const baseQuery = utils.normalizeText(query);
442
+ if (!baseQuery) return [];
443
+ // Lightly append domain synonyms/stems so jargon-heavy queries embed with
444
+ // the vocabulary the docs use, without letting expansion dominate the
445
+ // original query (which stays first). (#148)
446
+ const expanded = utils.expandQueryTokens ? utils.expandQueryTokens(baseQuery.toLowerCase().split(/\s+/)) : [];
447
+ const baseTokens = new Set(baseQuery.toLowerCase().split(/\s+/));
448
+ const extraTerms = expanded.filter((t) => !baseTokens.has(t)).slice(0, 8);
449
+ const enriched = extraTerms.length ? `${baseQuery} ${extraTerms.join(" ")}` : baseQuery;
450
+ const prepared = utils.truncateText(enriched, ragConfig.maxTextChars);
443
451
  const index = await loadIndex();
444
452
  const queryVector = utils.normalizeVector(await embedder.embed(prepared));
445
453
  const bestByUri = new Map();
@@ -452,13 +460,26 @@ function createProviderOrchestrator({
452
460
  if (!entry || !utils.entryMatchesScope(entry, filters)) continue;
453
461
  const existing = bestByUri.get(item.uri);
454
462
  if (!existing || score > existing.score) {
455
- bestByUri.set(item.uri, { entry, score });
463
+ bestByUri.set(item.uri, { entry, score, chunkText: item.text });
456
464
  }
457
465
  }
458
466
 
459
- const results = Array.from(bestByUri.values())
460
- .sort((a, b) => b.score - a.score)
461
- .map((item) => utils.attachScore(item.entry, item.score));
467
+ const ordered = Array.from(bestByUri.values()).sort((a, b) => b.score - a.score);
468
+ // Relative cutoff: once we have a confident top hit, drop tail results
469
+ // scoring far below it (the fixed absolute floor above is a near no-op for
470
+ // normalized Gemini embeddings). (#149)
471
+ const topScore = ordered.length ? ordered[0].score : 0;
472
+ // When the top score is non-positive (only reachable with RAG_MIN_SCORE=0
473
+ // and unrelated content), keep everything rather than dropping the top hit.
474
+ const relativeFloor = topScore > 0 ? topScore * 0.85 : -Infinity;
475
+ const kept = ordered.filter((item) => item.score >= relativeFloor);
476
+
477
+ const snippetTerms = baseQuery.toLowerCase().split(/\s+/).filter(Boolean);
478
+ const results = kept.map((item) => {
479
+ const scored = utils.attachScore(item.entry, item.score);
480
+ const snippet = utils.extractSnippet ? utils.extractSnippet(item.chunkText || "", snippetTerms, 240) : "";
481
+ return snippet ? { ...scored, matchedSnippet: snippet } : scored;
482
+ });
462
483
 
463
484
  if (limit) return results.slice(0, limit);
464
485
  return results;
@@ -2,7 +2,11 @@ import Fuse from "fuse.js";
2
2
 
3
3
  function createFuseSearch(resourceIndex) {
4
4
  return new Fuse(resourceIndex, {
5
- keys: ["title", "summary", "tags", "uri"],
5
+ keys: [
6
+ { name: "title", weight: 3 },
7
+ { name: "tags", weight: 2 },
8
+ { name: "summary", weight: 1 }
9
+ ],
6
10
  threshold: 0.35,
7
11
  ignoreLocation: true,
8
12
  includeScore: true
@@ -62,6 +66,135 @@ function chunkText(text, chunkSize, chunkOverlap, maxChunks) {
62
66
  return chunks;
63
67
  }
64
68
 
69
+ // Heading-aware chunking: split a markdown doc on ATX headings, prepend the
70
+ // nearest heading path to each section, then size-bound sections into chunks so
71
+ // the whole document is covered (not just its first ~6k chars) and each chunk
72
+ // keeps section context (issue #149).
73
+ function chunkMarkdown(text, chunkSize, chunkOverlap, maxChunks) {
74
+ const raw = String(text || "");
75
+ if (!raw.trim()) return [];
76
+ const lines = raw.split(/\r?\n/);
77
+ const headingStack = [];
78
+ const sections = [];
79
+ let current = { path: "", lines: [] };
80
+
81
+ const flush = () => {
82
+ const body = current.lines.join(" ").replace(/\s+/g, " ").trim();
83
+ if (body) sections.push({ path: current.path, body });
84
+ };
85
+
86
+ let inFence = false;
87
+ for (const line of lines) {
88
+ // Track fenced code blocks so a `# ` comment inside Python/bash/YAML is not
89
+ // mistaken for a markdown heading (which would fragment the code and hijack
90
+ // the heading breadcrumb for following prose).
91
+ if (/^\s*(```|~~~)/.test(line)) {
92
+ inFence = !inFence;
93
+ current.lines.push(line);
94
+ continue;
95
+ }
96
+ const heading = inFence ? null : line.match(/^(#{1,6})\s+(.*)$/);
97
+ if (heading) {
98
+ flush();
99
+ const level = heading[1].length;
100
+ const title = heading[2].replace(/\s+/g, " ").trim();
101
+ while (headingStack.length && headingStack[headingStack.length - 1].level >= level) {
102
+ headingStack.pop();
103
+ }
104
+ headingStack.push({ level, title });
105
+ current = { path: headingStack.map((h) => h.title).join(" > "), lines: [] };
106
+ } else {
107
+ current.lines.push(line);
108
+ }
109
+ }
110
+ flush();
111
+
112
+ if (sections.length === 0) {
113
+ return chunkText(raw, chunkSize, chunkOverlap, maxChunks);
114
+ }
115
+
116
+ const chunks = [];
117
+ for (const section of sections) {
118
+ const prefix = section.path ? `${section.path}\n` : "";
119
+ const pieces = chunkText(section.body, chunkSize, chunkOverlap, maxChunks);
120
+ for (const piece of pieces) {
121
+ chunks.push(`${prefix}${piece}`.trim());
122
+ if (maxChunks && chunks.length >= maxChunks) return chunks;
123
+ }
124
+ }
125
+ return chunks;
126
+ }
127
+
128
+ // Domain synonym / jargon expansion so lexical scoring survives the vocabulary
129
+ // gap between how developers phrase questions and how docs/samples are titled
130
+ // (issue #148). Kept deliberately small and high-precision.
131
+ const TOKEN_SYNONYMS = {
132
+ datamatrix: ["data", "matrix"],
133
+ pdf417: ["pdf417"],
134
+ qr: ["qrcode"],
135
+ qrcode: ["qr"],
136
+ webcam: ["camera", "video"],
137
+ camera: ["webcam", "video"],
138
+ video: ["camera"],
139
+ scan: ["decode", "read"],
140
+ scanning: ["scan", "decode", "read"],
141
+ decode: ["scan", "read"],
142
+ read: ["scan", "decode"],
143
+ reading: ["read", "scan"],
144
+ deskew: ["normalize", "rectify"],
145
+ normalize: ["deskew"],
146
+ normalization: ["deskew", "normalize"],
147
+ licence: ["license"],
148
+ passport: ["mrz"],
149
+ mrz: ["passport"],
150
+ gs1: ["gs1", "ai"],
151
+ barcode: ["barcode"]
152
+ };
153
+
154
+ // Very light suffix folding to a stem, so "scanning"/"readers" reach "scan"/"reader".
155
+ function stemToken(token) {
156
+ if (token.length <= 4) return token;
157
+ for (const suffix of ["ing", "ers", "er", "es", "s"]) {
158
+ if (token.endsWith(suffix) && token.length - suffix.length >= 3) {
159
+ return token.slice(0, token.length - suffix.length);
160
+ }
161
+ }
162
+ return token;
163
+ }
164
+
165
+ function expandQueryTokens(tokens) {
166
+ const out = new Set();
167
+ for (const token of tokens) {
168
+ if (!token) continue;
169
+ out.add(token);
170
+ const stem = stemToken(token);
171
+ if (stem !== token) out.add(stem);
172
+ const synonyms = TOKEN_SYNONYMS[token] || TOKEN_SYNONYMS[stem];
173
+ if (synonyms) {
174
+ for (const syn of synonyms) out.add(syn);
175
+ }
176
+ }
177
+ return [...out];
178
+ }
179
+
180
+ // A ~maxLen window around the first query term found in text — used to give the
181
+ // agent a matched snippet in search results (issues #146/#149).
182
+ function extractSnippet(text, terms, maxLen = 240) {
183
+ const cleaned = normalizeText(text);
184
+ if (!cleaned) return "";
185
+ const lower = cleaned.toLowerCase();
186
+ let hitAt = -1;
187
+ for (const term of terms) {
188
+ if (!term) continue;
189
+ const at = lower.indexOf(term.toLowerCase());
190
+ if (at !== -1 && (hitAt === -1 || at < hitAt)) hitAt = at;
191
+ }
192
+ if (hitAt === -1) return truncateText(cleaned, maxLen);
193
+ const start = Math.max(0, hitAt - Math.floor(maxLen / 3));
194
+ const snippet = cleaned.slice(start, start + maxLen).trim();
195
+ return (start > 0 ? "…" : "") + snippet + (start + maxLen < cleaned.length ? "…" : "");
196
+ }
197
+
65
198
  function buildEntryBaseText(entry) {
66
199
  const parts = [entry.title, entry.summary];
67
200
  if (Array.isArray(entry.tags) && entry.tags.length > 0) {
@@ -75,8 +208,8 @@ function buildEmbeddingItems(resourceIndex, ragConfig) {
75
208
  for (const entry of resourceIndex) {
76
209
  const baseText = buildEntryBaseText(entry);
77
210
  if (!baseText) continue;
78
- if (entry.type === "doc" && entry.embedText) {
79
- const chunks = chunkText(entry.embedText, ragConfig.chunkSize, ragConfig.chunkOverlap, ragConfig.maxChunksPerDoc);
211
+ if (entry.embedText) {
212
+ const chunks = chunkMarkdown(entry.embedText, ragConfig.chunkSize, ragConfig.chunkOverlap, ragConfig.maxChunksPerDoc);
80
213
  if (chunks.length === 0) {
81
214
  items.push({
82
215
  id: entry.id,
@@ -122,7 +255,9 @@ function buildIndexSignature({ pkgVersion, signatureData, ragConfig }) {
122
255
  chunkSize: ragConfig.chunkSize,
123
256
  chunkOverlap: ragConfig.chunkOverlap,
124
257
  maxChunksPerDoc: ragConfig.maxChunksPerDoc,
125
- maxTextChars: ragConfig.maxTextChars
258
+ maxTextChars: ragConfig.maxTextChars,
259
+ // Bump when the chunking strategy changes so cached vector indexes rebuild.
260
+ chunkStrategy: "heading-aware-v1"
126
261
  });
127
262
  }
128
263
 
@@ -158,6 +293,11 @@ export {
158
293
  entryMatchesScope,
159
294
  normalizeText,
160
295
  truncateText,
296
+ chunkText,
297
+ chunkMarkdown,
298
+ expandQueryTokens,
299
+ stemToken,
300
+ extractSnippet,
161
301
  buildEmbeddingItems,
162
302
  buildIndexSignature,
163
303
  normalizeVector,
@@ -59,7 +59,23 @@ function loadVectorIndexCache(
59
59
 
60
60
  function saveVectorIndexCache(cacheDir, cacheFile, payload) {
61
61
  ensureDirectory(cacheDir);
62
- writeFileSync(cacheFile, JSON.stringify(payload));
62
+ let serialized;
63
+ try {
64
+ serialized = JSON.stringify(payload);
65
+ } catch (error) {
66
+ // JSON.stringify throws "Invalid string length" once the serialized cache
67
+ // exceeds V8's ~512MB max string length (reached with a large index of
68
+ // high-dimensional vectors). Fail with an actionable message instead.
69
+ if (error instanceof RangeError) {
70
+ const itemCount = Array.isArray(payload?.items) ? payload.items.length : "unknown";
71
+ throw new Error(
72
+ `Vector cache too large to serialize (${itemCount} items) — exceeds the JSON string limit. ` +
73
+ "Lower RAG_MAX_CHUNKS_PER_DOC or adopt a ceiling-proof cache format."
74
+ );
75
+ }
76
+ throw error;
77
+ }
78
+ writeFileSync(cacheFile, serialized);
63
79
  }
64
80
 
65
81
  function loadVectorIndexCheckpoint(checkpointFile, expectedKey, expectedItems) {