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.
- package/data/metadata/data-manifest.json +2 -2
- package/package.json +4 -2
- package/scripts/check-version-drift.mjs +80 -0
- package/src/rag/config.js +5 -0
- package/src/rag/index.js +5 -1
- package/src/rag/lexical-provider.js +48 -15
- package/src/rag/providers.js +27 -6
- package/src/rag/search-utils.js +144 -4
- package/src/rag/vector-cache.js +17 -1
- package/src/server/create-server.js +121 -99
- package/src/server/normalizers.js +78 -1
- package/src/server/public-offerings.js +12 -0
- package/src/server/resource-index/builders.js +76 -16
- package/src/server/resource-index/config.js +4 -2
- package/src/server/resource-index/docs-loader.js +22 -2
- package/src/server/resource-index/samples.js +24 -3
- package/src/server/resource-index/version-policy.js +34 -8
- package/src/server/resources/register-resources.js +4 -1
- package/src/server/tools/public-routing.js +8 -0
- package/src/server/tools/register-index-tools.js +64 -10
- package/src/server/tools/register-project-tools.js +160 -18
- package/src/server/tools/register-quickstart-tools.js +357 -40
- package/src/server/tools/register-sample-tools.js +10 -5
- package/src/server/tools/register-version-tools.js +10 -5
|
@@ -108,8 +108,8 @@
|
|
|
108
108
|
"branch": "master",
|
|
109
109
|
"owner": "dynamsoft-docs",
|
|
110
110
|
"repo": "web-twain-docs",
|
|
111
|
-
"commit": "
|
|
112
|
-
"archiveUrl": "https://codeload.github.com/dynamsoft-docs/web-twain-docs/zip/
|
|
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
|
+
"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: [
|
|
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
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
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) =>
|
|
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
|
},
|
package/src/rag/providers.js
CHANGED
|
@@ -438,8 +438,16 @@ function createProviderOrchestrator({
|
|
|
438
438
|
return {
|
|
439
439
|
name,
|
|
440
440
|
search: async (query, filters, limit) => {
|
|
441
|
-
const
|
|
442
|
-
if (!
|
|
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
|
|
460
|
-
|
|
461
|
-
|
|
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;
|
package/src/rag/search-utils.js
CHANGED
|
@@ -2,7 +2,11 @@ import Fuse from "fuse.js";
|
|
|
2
2
|
|
|
3
3
|
function createFuseSearch(resourceIndex) {
|
|
4
4
|
return new Fuse(resourceIndex, {
|
|
5
|
-
keys: [
|
|
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.
|
|
79
|
-
const chunks =
|
|
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,
|
package/src/rag/vector-cache.js
CHANGED
|
@@ -59,7 +59,23 @@ function loadVectorIndexCache(
|
|
|
59
59
|
|
|
60
60
|
function saveVectorIndexCache(cacheDir, cacheFile, payload) {
|
|
61
61
|
ensureDirectory(cacheDir);
|
|
62
|
-
|
|
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) {
|