md-semantic-search 1.1.0 → 2.0.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/README.md +88 -14
- package/RESEARCH.md +182 -0
- package/bin/cli.mjs +197 -8
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +5 -0
- package/dist/core.d.ts +16 -4
- package/dist/core.js +105 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/indexer.d.ts +17 -1
- package/dist/indexer.js +91 -17
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +86 -3
- package/dist/mcp.js +259 -31
- package/dist/models.d.ts +7 -1
- package/dist/models.js +32 -1
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +60 -0
- package/dist/rag.js +133 -35
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +32 -3
- package/dist/rerank.js +58 -8
- package/dist/search.d.ts +48 -3
- package/dist/search.js +384 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +478 -58
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +52 -2
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +201 -2
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +5 -0
- package/src/core.ts +115 -12
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/indexer.ts +107 -18
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +323 -34
- package/src/models.ts +39 -2
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +189 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +81 -8
- package/src/search.ts +417 -82
- package/src/serve.ts +499 -66
- package/src/tui.ts +56 -2
- package/src/watcher.ts +21 -10
- package/src/webui.ts +201 -2
package/dist/lexical.js
CHANGED
|
@@ -100,6 +100,90 @@ export function editDistance(a, b) {
|
|
|
100
100
|
}
|
|
101
101
|
return matrix[alen][blen];
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Damerau-Levenshtein distance with a threshold (issue #193): banded DP that
|
|
105
|
+
* never leaves the |i-j| <= maxDist diagonal and aborts as soon as the whole
|
|
106
|
+
* row exceeds the threshold — O(maxDist × min(alen, blen)) instead of the
|
|
107
|
+
* full O(alen × blen) matrix. Returns the EXACT distance when it is
|
|
108
|
+
* <= maxDist and null otherwise; decisions are identical to
|
|
109
|
+
* `editDistance(a, b) <= maxDist` (optimal paths within the threshold never
|
|
110
|
+
* leave the band).
|
|
111
|
+
*/
|
|
112
|
+
export function editDistanceAtMost(a, b, maxDist) {
|
|
113
|
+
if (a === b)
|
|
114
|
+
return 0;
|
|
115
|
+
const alen = a.length;
|
|
116
|
+
const blen = b.length;
|
|
117
|
+
if (Math.abs(alen - blen) > maxDist)
|
|
118
|
+
return null;
|
|
119
|
+
if (alen === 0)
|
|
120
|
+
return blen <= maxDist ? blen : null;
|
|
121
|
+
if (blen === 0)
|
|
122
|
+
return alen <= maxDist ? alen : null;
|
|
123
|
+
if (maxDist < 0)
|
|
124
|
+
return null;
|
|
125
|
+
const INF = maxDist + 1;
|
|
126
|
+
// rows are padded with an INF sentinel column on each side so band-edge
|
|
127
|
+
// lookups (j-1 / j+1) never read a real out-of-band value
|
|
128
|
+
const width = blen + 3;
|
|
129
|
+
const rowA = new Int32Array(width);
|
|
130
|
+
const rowB = new Int32Array(width);
|
|
131
|
+
const rowC = new Int32Array(width);
|
|
132
|
+
let prev2 = null; // row i-2 (for transpositions)
|
|
133
|
+
let prev = rowA;
|
|
134
|
+
let cur = rowB;
|
|
135
|
+
const scratch = rowC;
|
|
136
|
+
for (let j = 1; j <= blen + 1; j++)
|
|
137
|
+
prev[j] = j; // row 0: dp[0][j] = j (col 0 unused sentinel)
|
|
138
|
+
prev[0] = 0;
|
|
139
|
+
for (let i = 1; i <= alen; i++) {
|
|
140
|
+
const jLo = Math.max(1, i - maxDist);
|
|
141
|
+
const jHi = Math.min(blen, i + maxDist);
|
|
142
|
+
cur[0] = i; // dp[i][0] = i (column 0 inside the band only when i <= maxDist)
|
|
143
|
+
if (i > maxDist)
|
|
144
|
+
cur[0] = INF;
|
|
145
|
+
let rowMin = cur[0];
|
|
146
|
+
for (let j = jLo; j <= jHi; j++) {
|
|
147
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
148
|
+
let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
149
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1] && prev2 !== null) {
|
|
150
|
+
v = Math.min(v, prev2[j - 2] + cost);
|
|
151
|
+
}
|
|
152
|
+
cur[j] = v;
|
|
153
|
+
if (v < rowMin)
|
|
154
|
+
rowMin = v;
|
|
155
|
+
}
|
|
156
|
+
// band-out cells stay INF for this row
|
|
157
|
+
for (let j = 1; j <= blen; j++) {
|
|
158
|
+
if (j < jLo || j > jHi)
|
|
159
|
+
cur[j] = INF;
|
|
160
|
+
}
|
|
161
|
+
if (rowMin > maxDist)
|
|
162
|
+
return null;
|
|
163
|
+
// three-row rotation: prev2 <- row(i-1), prev <- row(i), cur <- freed row
|
|
164
|
+
const free = prev2 ?? scratch;
|
|
165
|
+
prev2 = prev;
|
|
166
|
+
prev = cur;
|
|
167
|
+
cur = free;
|
|
168
|
+
}
|
|
169
|
+
const d = prev[blen];
|
|
170
|
+
return d <= maxDist ? d : null;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* issue #193: target tokenization memoized per chunk object — the fuzzy scan
|
|
174
|
+
* runs on every query, but a chunk's title/aliases tokenize to the same tokens
|
|
175
|
+
* every time. WeakMap so runtime chunks (stable objects in serve) pay once.
|
|
176
|
+
*/
|
|
177
|
+
const fuzzyTargetTokens = new WeakMap();
|
|
178
|
+
function targetTokens(chunk) {
|
|
179
|
+
let tokens = fuzzyTargetTokens.get(chunk);
|
|
180
|
+
if (!tokens) {
|
|
181
|
+
const targets = [chunk.title, ...(chunk.meta?.aliases || [])].filter(Boolean);
|
|
182
|
+
tokens = targets.flatMap((t) => tokenize(t));
|
|
183
|
+
fuzzyTargetTokens.set(chunk, tokens);
|
|
184
|
+
}
|
|
185
|
+
return tokens;
|
|
186
|
+
}
|
|
103
187
|
export function fuzzyTitleAliasScores(chunks, query) {
|
|
104
188
|
const scores = new Map();
|
|
105
189
|
const qTerms = tokenize(query);
|
|
@@ -107,23 +191,22 @@ export function fuzzyTitleAliasScores(chunks, query) {
|
|
|
107
191
|
return scores;
|
|
108
192
|
for (let docId = 0; docId < chunks.length; docId++) {
|
|
109
193
|
const c = chunks[docId];
|
|
110
|
-
const
|
|
194
|
+
const tTerms = targetTokens(c);
|
|
111
195
|
let maxScore = 0;
|
|
112
|
-
for (const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
196
|
+
for (const qt of qTerms) {
|
|
197
|
+
if (qt.length < 3)
|
|
198
|
+
continue;
|
|
199
|
+
const maxDist = Math.min(2, Math.floor(qt.length / 3));
|
|
200
|
+
for (const tt of tTerms) {
|
|
201
|
+
if (tt.length < 3)
|
|
116
202
|
continue;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
if (similarity > maxScore)
|
|
125
|
-
maxScore = similarity;
|
|
126
|
-
}
|
|
203
|
+
// exact same decision as editDistance(qt, tt) <= maxDist, without the
|
|
204
|
+
// full O(len²) matrix (issue #193)
|
|
205
|
+
const dist = editDistanceAtMost(qt, tt, maxDist);
|
|
206
|
+
if (dist !== null) {
|
|
207
|
+
const similarity = 1 - dist / Math.max(qt.length, tt.length);
|
|
208
|
+
if (similarity > maxScore)
|
|
209
|
+
maxScore = similarity;
|
|
127
210
|
}
|
|
128
211
|
}
|
|
129
212
|
}
|
|
@@ -21,8 +21,70 @@ export interface StructuralChunk {
|
|
|
21
21
|
}
|
|
22
22
|
export declare function parseMarkdownBlocks(body: string): MarkdownBlock[];
|
|
23
23
|
export declare function resolveChunkBudget(tokens?: number, defaultMax?: number): number;
|
|
24
|
+
/**
|
|
25
|
+
* issue #202: script-calibrated token estimate for the sentencepiece E5/BGE
|
|
26
|
+
* vocab family — the fast-path alternative to running the real tokenizer per
|
|
27
|
+
* block. Ratios are calibrated so the estimate errs slightly HIGH: a budget
|
|
28
|
+
* checked against this estimate never overflows the model's real maxTokens.
|
|
29
|
+
* Latin/digits/punctuation ≈ 4 chars/token, Cyrillic ≈ 1.8 chars/token
|
|
30
|
+
* (RU systematically under-chunked by the old char budget — the point of
|
|
31
|
+
* this issue), CJK ≈ 1 token/char, whitespace mostly merges into the
|
|
32
|
+
* following subword so it counts a little.
|
|
33
|
+
*/
|
|
34
|
+
export declare function estimateTokens(text: string): number;
|
|
35
|
+
/** Token measure: injected exact counter when available, else the calibrated estimate. */
|
|
36
|
+
export type TokenCounter = (text: string) => number;
|
|
37
|
+
export interface ChunkBudget {
|
|
38
|
+
/** Max measured size a single chunk may reach before it is split/flushed. */
|
|
39
|
+
budget: number;
|
|
40
|
+
/** Measures a text in the same unit the budget is expressed in. */
|
|
41
|
+
measure: TokenCounter;
|
|
42
|
+
/** Unit name for diagnostics/tests. */
|
|
43
|
+
unit: 'chars' | 'tokens';
|
|
44
|
+
}
|
|
45
|
+
/** Char budget (pre-#202 behavior) vs token budget (issue #202). */
|
|
46
|
+
export declare function resolveChunkingBudget(opts: {
|
|
47
|
+
maxChunk?: number;
|
|
48
|
+
maxChunkTokens?: number;
|
|
49
|
+
countTokens?: TokenCounter;
|
|
50
|
+
}): ChunkBudget;
|
|
51
|
+
/**
|
|
52
|
+
* issue #202: the DEFAULT chunk budget in tokens, derived from the embedding
|
|
53
|
+
* adapter's context window. Capped at 512 — a passage much larger than that
|
|
54
|
+
* dilutes retrieval granularity even for models with 8k+ context (bge-m3).
|
|
55
|
+
* Returns undefined for adapters without a known maxTokens, which keeps the
|
|
56
|
+
* legacy char budget instead of guessing.
|
|
57
|
+
*/
|
|
58
|
+
export declare function defaultChunkTokenBudget(adapter: {
|
|
59
|
+
maxTokens?: number;
|
|
60
|
+
}): number | undefined;
|
|
24
61
|
export declare function chunkMarkdownStructural(input: string | MarkdownBlock[], opts?: number | {
|
|
25
62
|
maxChunk?: number;
|
|
26
63
|
targetTokens?: number;
|
|
64
|
+
maxChunkTokens?: number;
|
|
65
|
+
countTokens?: TokenCounter;
|
|
27
66
|
}): StructuralChunk[];
|
|
67
|
+
export interface OversizedPiece {
|
|
68
|
+
text: string;
|
|
69
|
+
/** 0-based block-relative range of REAL source lines backing this piece. */
|
|
70
|
+
startLine: number;
|
|
71
|
+
endLine: number;
|
|
72
|
+
}
|
|
28
73
|
export declare function splitOversizedBlock(text: string, maxChunk: number, blockType?: MarkdownBlock['type']): string[];
|
|
74
|
+
/**
|
|
75
|
+
* issue #202: oversized-block splitting under an arbitrary measure (chars or
|
|
76
|
+
* tokens). The heavy lifting (fence repeats, table header repeats, #167 line
|
|
77
|
+
* ranges) stays in the char-based splitOversizedBlockWithRanges; the token
|
|
78
|
+
* budget is converted to a char budget for THIS block via its own chars-per-
|
|
79
|
+
* measured-unit ratio, then every piece is verified — script-skew inside the
|
|
80
|
+
* block (ASCII header + Cyrillic body) is caught by a greedy re-split.
|
|
81
|
+
*/
|
|
82
|
+
export declare function splitOversizedByMeasure(text: string, budget: number, measure: TokenCounter, blockType?: MarkdownBlock['type']): OversizedPiece[];
|
|
83
|
+
/**
|
|
84
|
+
* Issue #167: like splitOversizedBlock, but each piece also reports the
|
|
85
|
+
* block-relative range of source lines it was built from. Synthetic lines
|
|
86
|
+
* added to keep pieces self-contained (fence repeats, table header repeats)
|
|
87
|
+
* do NOT count — only the first piece's opening fence/header and a real
|
|
88
|
+
* closing fence belong to the source.
|
|
89
|
+
*/
|
|
90
|
+
export declare function splitOversizedBlockWithRanges(text: string, maxChunk: number, blockType?: MarkdownBlock['type']): OversizedPiece[];
|
package/dist/markdown-parser.js
CHANGED
|
@@ -139,9 +139,63 @@ export function resolveChunkBudget(tokens, defaultMax = 1200) {
|
|
|
139
139
|
}
|
|
140
140
|
return defaultMax;
|
|
141
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* issue #202: script-calibrated token estimate for the sentencepiece E5/BGE
|
|
144
|
+
* vocab family — the fast-path alternative to running the real tokenizer per
|
|
145
|
+
* block. Ratios are calibrated so the estimate errs slightly HIGH: a budget
|
|
146
|
+
* checked against this estimate never overflows the model's real maxTokens.
|
|
147
|
+
* Latin/digits/punctuation ≈ 4 chars/token, Cyrillic ≈ 1.8 chars/token
|
|
148
|
+
* (RU systematically under-chunked by the old char budget — the point of
|
|
149
|
+
* this issue), CJK ≈ 1 token/char, whitespace mostly merges into the
|
|
150
|
+
* following subword so it counts a little.
|
|
151
|
+
*/
|
|
152
|
+
export function estimateTokens(text) {
|
|
153
|
+
let tokens = 0;
|
|
154
|
+
for (let i = 0; i < text.length; i++) {
|
|
155
|
+
const c = text.charCodeAt(i);
|
|
156
|
+
if (c >= 0x0400 && c <= 0x04ff)
|
|
157
|
+
tokens += 0.55; // Cyrillic (+ Cyrillic supplement edges)
|
|
158
|
+
else if ((c >= 0x3040 && c <= 0x30ff) || // hiragana / katakana
|
|
159
|
+
(c >= 0x3400 && c <= 0x4dbf) || // CJK ext A
|
|
160
|
+
(c >= 0x4e00 && c <= 0x9fff) || // CJK unified
|
|
161
|
+
(c >= 0xff00 && c <= 0xffef) // fullwidth forms
|
|
162
|
+
) {
|
|
163
|
+
tokens += 1.0;
|
|
164
|
+
}
|
|
165
|
+
else if (c === 0x20 || (c >= 0x09 && c <= 0x0d))
|
|
166
|
+
tokens += 0.08;
|
|
167
|
+
else if (c < 0x80)
|
|
168
|
+
tokens += 0.25; // ASCII (latin, digits, punctuation, markdown syntax)
|
|
169
|
+
else
|
|
170
|
+
tokens += 0.35; // other scripts / symbols — conservative middle ground
|
|
171
|
+
}
|
|
172
|
+
return Math.ceil(tokens);
|
|
173
|
+
}
|
|
174
|
+
/** Char budget (pre-#202 behavior) vs token budget (issue #202). */
|
|
175
|
+
export function resolveChunkingBudget(opts) {
|
|
176
|
+
if (opts.maxChunkTokens && Number.isFinite(opts.maxChunkTokens) && opts.maxChunkTokens > 0) {
|
|
177
|
+
const budget = Math.max(32, Math.round(opts.maxChunkTokens));
|
|
178
|
+
return { budget, measure: opts.countTokens ?? estimateTokens, unit: 'tokens' };
|
|
179
|
+
}
|
|
180
|
+
const budget = resolveChunkBudget(undefined, opts.maxChunk ?? 1200);
|
|
181
|
+
return { budget, measure: (t) => t.length, unit: 'chars' };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* issue #202: the DEFAULT chunk budget in tokens, derived from the embedding
|
|
185
|
+
* adapter's context window. Capped at 512 — a passage much larger than that
|
|
186
|
+
* dilutes retrieval granularity even for models with 8k+ context (bge-m3).
|
|
187
|
+
* Returns undefined for adapters without a known maxTokens, which keeps the
|
|
188
|
+
* legacy char budget instead of guessing.
|
|
189
|
+
*/
|
|
190
|
+
export function defaultChunkTokenBudget(adapter) {
|
|
191
|
+
const mt = adapter?.maxTokens;
|
|
192
|
+
if (!mt || !Number.isFinite(mt) || mt <= 0)
|
|
193
|
+
return undefined;
|
|
194
|
+
return Math.min(Math.round(mt), 512);
|
|
195
|
+
}
|
|
142
196
|
export function chunkMarkdownStructural(input, opts) {
|
|
143
197
|
const options = typeof opts === 'number' ? { maxChunk: opts } : (opts || {});
|
|
144
|
-
const maxChunk =
|
|
198
|
+
const { budget: maxChunk, measure } = resolveChunkingBudget(options);
|
|
145
199
|
const blocks = typeof input === 'string' ? parseMarkdownBlocks(input) : input;
|
|
146
200
|
const chunks = [];
|
|
147
201
|
const headingStack = [];
|
|
@@ -149,6 +203,8 @@ export function chunkMarkdownStructural(input, opts) {
|
|
|
149
203
|
let currentLen = 0;
|
|
150
204
|
let groupStartLine = 0;
|
|
151
205
|
let groupEndLine = 0;
|
|
206
|
+
// measured size of the '\n\n' joiner — 2 in char mode, ~1 in token mode
|
|
207
|
+
const joinerSize = measure('\n\n');
|
|
152
208
|
const flushGroup = () => {
|
|
153
209
|
if (currentGroup.length === 0)
|
|
154
210
|
return;
|
|
@@ -180,26 +236,25 @@ export function chunkMarkdownStructural(input, opts) {
|
|
|
180
236
|
const blockText = block.text.trim();
|
|
181
237
|
if (!blockText)
|
|
182
238
|
continue;
|
|
183
|
-
if (blockText
|
|
239
|
+
if (measure(blockText) > maxChunk) {
|
|
184
240
|
flushGroup();
|
|
185
241
|
const headingPath = headingStack.map(h => h.heading);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
242
|
+
// Issue #167: pieces carry the block-relative range of REAL source
|
|
243
|
+
// lines backing them — synthetic fence/header repeats must not advance
|
|
244
|
+
// the line cursor, or every later sub-chunk drifts down the file.
|
|
245
|
+
const subPieces = splitOversizedByMeasure(blockText, maxChunk, measure, block.type);
|
|
246
|
+
for (const piece of subPieces) {
|
|
191
247
|
chunks.push({
|
|
192
248
|
heading: headingPath.at(-1) ?? '',
|
|
193
249
|
headingPath: [...headingPath],
|
|
194
|
-
text: piece,
|
|
195
|
-
startLine:
|
|
196
|
-
endLine: Math.min(block.endLine,
|
|
250
|
+
text: piece.text,
|
|
251
|
+
startLine: block.startLine + piece.startLine,
|
|
252
|
+
endLine: Math.min(block.endLine, block.startLine + piece.endLine),
|
|
197
253
|
});
|
|
198
|
-
lineCursor += pieceLineCount;
|
|
199
254
|
}
|
|
200
255
|
continue;
|
|
201
256
|
}
|
|
202
|
-
const addedLen = currentLen === 0 ? blockText
|
|
257
|
+
const addedLen = currentLen === 0 ? measure(blockText) : currentLen + joinerSize + measure(blockText);
|
|
203
258
|
if (addedLen > maxChunk && currentGroup.length > 0) {
|
|
204
259
|
flushGroup();
|
|
205
260
|
}
|
|
@@ -207,7 +262,7 @@ export function chunkMarkdownStructural(input, opts) {
|
|
|
207
262
|
groupStartLine = block.startLine;
|
|
208
263
|
}
|
|
209
264
|
currentGroup.push(blockText);
|
|
210
|
-
currentLen += (currentGroup.length === 1 ? 0 :
|
|
265
|
+
currentLen += (currentGroup.length === 1 ? 0 : joinerSize) + measure(blockText);
|
|
211
266
|
groupEndLine = block.endLine;
|
|
212
267
|
}
|
|
213
268
|
flushGroup();
|
|
@@ -228,9 +283,121 @@ function splitLongLine(line, maxChunk) {
|
|
|
228
283
|
return pieces;
|
|
229
284
|
}
|
|
230
285
|
export function splitOversizedBlock(text, maxChunk, blockType) {
|
|
286
|
+
return splitOversizedBlockWithRanges(text, maxChunk, blockType).map((p) => p.text);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* issue #202: oversized-block splitting under an arbitrary measure (chars or
|
|
290
|
+
* tokens). The heavy lifting (fence repeats, table header repeats, #167 line
|
|
291
|
+
* ranges) stays in the char-based splitOversizedBlockWithRanges; the token
|
|
292
|
+
* budget is converted to a char budget for THIS block via its own chars-per-
|
|
293
|
+
* measured-unit ratio, then every piece is verified — script-skew inside the
|
|
294
|
+
* block (ASCII header + Cyrillic body) is caught by a greedy re-split.
|
|
295
|
+
*/
|
|
296
|
+
export function splitOversizedByMeasure(text, budget, measure, blockType) {
|
|
297
|
+
if (measure(text) <= budget) {
|
|
298
|
+
return [{ text, startLine: 0, endLine: Number.MAX_SAFE_INTEGER }];
|
|
299
|
+
}
|
|
300
|
+
// char equivalent of the budget for this block's script mix; fall back to
|
|
301
|
+
// a plain char split with the raw budget when measuring is degenerate
|
|
302
|
+
const measured = Math.max(1, measure(text));
|
|
303
|
+
const charsPerUnit = text.length / measured;
|
|
304
|
+
const charBudget = Math.max(32, Math.floor(budget * charsPerUnit));
|
|
305
|
+
const pieces = splitOversizedBlockWithRanges(text, charBudget, blockType);
|
|
306
|
+
const out = [];
|
|
307
|
+
for (const piece of pieces) {
|
|
308
|
+
if (measure(piece.text) <= budget) {
|
|
309
|
+
out.push(piece);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
// script-skew safety net: greedy line/word accumulation under measure;
|
|
313
|
+
// the greedy splitter tracks consumed source lines so #167 ranges stay real
|
|
314
|
+
const spanLines = piece.endLine - piece.startLine + 1;
|
|
315
|
+
const textLines = piece.text.split('\n');
|
|
316
|
+
// when synthetic lines (fence/table repeats) inflated the piece, the text
|
|
317
|
+
// has more lines than the source span — clamp keeps ranges inside the block
|
|
318
|
+
const lineStride = textLines.length > spanLines ? spanLines / textLines.length : 1;
|
|
319
|
+
for (const sub of greedySplitByMeasure(piece.text, budget, measure)) {
|
|
320
|
+
const startLine = piece.startLine + Math.min(spanLines - 1, Math.floor(sub.startLine * lineStride));
|
|
321
|
+
const endLine = piece.startLine + Math.min(spanLines - 1, Math.max(sub.startLine, Math.floor(sub.endLine * lineStride)));
|
|
322
|
+
out.push({ text: sub.text, startLine, endLine });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
function greedySplitByMeasure(text, budget, measure) {
|
|
328
|
+
const out = [];
|
|
329
|
+
const lines = text.split('\n');
|
|
330
|
+
let bufStart = 0;
|
|
331
|
+
let bufEnd = 0;
|
|
332
|
+
let current = '';
|
|
333
|
+
const flush = (uptoLine) => {
|
|
334
|
+
if (current.trim())
|
|
335
|
+
out.push({ text: current.trim(), startLine: bufStart, endLine: uptoLine });
|
|
336
|
+
current = '';
|
|
337
|
+
};
|
|
338
|
+
for (let i = 0; i < lines.length; i++) {
|
|
339
|
+
const line = lines[i];
|
|
340
|
+
const candidate = current ? `${current}\n${line}` : line;
|
|
341
|
+
if (measure(candidate) <= budget) {
|
|
342
|
+
if (!current)
|
|
343
|
+
bufStart = i;
|
|
344
|
+
current = candidate;
|
|
345
|
+
bufEnd = i;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
flush(i - 1);
|
|
349
|
+
if (measure(line) <= budget) {
|
|
350
|
+
bufStart = i;
|
|
351
|
+
current = line;
|
|
352
|
+
bufEnd = i;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// single line over budget: word-level accumulation, all pieces stay on
|
|
356
|
+
// this line's range
|
|
357
|
+
const words = line.split(/\s+/);
|
|
358
|
+
let wordBuf = '';
|
|
359
|
+
for (const word of words) {
|
|
360
|
+
const wc = wordBuf ? `${wordBuf} ${word}` : word;
|
|
361
|
+
if (measure(wc) <= budget) {
|
|
362
|
+
wordBuf = wc;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (wordBuf.trim())
|
|
366
|
+
out.push({ text: wordBuf.trim(), startLine: i, endLine: i });
|
|
367
|
+
if (measure(word) <= budget) {
|
|
368
|
+
wordBuf = word;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
// monster token (no spaces): hard-slice by chars proportional to budget
|
|
372
|
+
const ratio = line.length / Math.max(1, measure(line));
|
|
373
|
+
const sliceLen = Math.max(8, Math.floor(budget * ratio));
|
|
374
|
+
let rest = word;
|
|
375
|
+
while (measure(rest) > budget && rest.length > sliceLen) {
|
|
376
|
+
out.push({ text: rest.slice(0, sliceLen), startLine: i, endLine: i });
|
|
377
|
+
rest = rest.slice(sliceLen);
|
|
378
|
+
}
|
|
379
|
+
wordBuf = rest;
|
|
380
|
+
}
|
|
381
|
+
if (wordBuf.trim()) {
|
|
382
|
+
bufStart = i;
|
|
383
|
+
current = wordBuf;
|
|
384
|
+
bufEnd = i;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
flush(bufEnd);
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Issue #167: like splitOversizedBlock, but each piece also reports the
|
|
392
|
+
* block-relative range of source lines it was built from. Synthetic lines
|
|
393
|
+
* added to keep pieces self-contained (fence repeats, table header repeats)
|
|
394
|
+
* do NOT count — only the first piece's opening fence/header and a real
|
|
395
|
+
* closing fence belong to the source.
|
|
396
|
+
*/
|
|
397
|
+
export function splitOversizedBlockWithRanges(text, maxChunk, blockType) {
|
|
231
398
|
const lines = text.split('\n');
|
|
232
399
|
if (lines.length <= 1) {
|
|
233
|
-
return splitLongLine(text, maxChunk);
|
|
400
|
+
return splitLongLine(text, maxChunk).map((piece) => ({ text: piece, startLine: 0, endLine: 0 }));
|
|
234
401
|
}
|
|
235
402
|
// If code block with opening fence
|
|
236
403
|
if (blockType === 'code' && lines.length > 2 && /^(\s*)(`{3,}|~{3,})/.test(lines[0])) {
|
|
@@ -241,17 +408,30 @@ export function splitOversizedBlock(text, maxChunk, blockType) {
|
|
|
241
408
|
const pieces = [];
|
|
242
409
|
let currentCode = [];
|
|
243
410
|
let currentLen = openFence.length + closeFence.length + 2;
|
|
244
|
-
|
|
411
|
+
// The opening fence is a real source line only in the first piece; a
|
|
412
|
+
// real closing fence only in the final one.
|
|
413
|
+
const flush = (isFinal) => {
|
|
414
|
+
const firstReal = pieces.length === 0 ? 0 : currentCode[0].srcIdx;
|
|
415
|
+
const lastReal = isFinal && hasCloseFence ? lines.length - 1 : currentCode[currentCode.length - 1].srcIdx;
|
|
416
|
+
pieces.push({
|
|
417
|
+
text: [openFence, ...currentCode.map((c) => c.line), closeFence].join('\n'),
|
|
418
|
+
startLine: firstReal,
|
|
419
|
+
endLine: lastReal,
|
|
420
|
+
});
|
|
421
|
+
};
|
|
422
|
+
for (let offset = 0; offset < innerLines.length; offset++) {
|
|
423
|
+
const line = innerLines[offset];
|
|
424
|
+
const srcIdx = 1 + offset;
|
|
245
425
|
if (currentLen + line.length + 1 > maxChunk && currentCode.length > 0) {
|
|
246
|
-
|
|
426
|
+
flush(false);
|
|
247
427
|
currentCode = [];
|
|
248
428
|
currentLen = openFence.length + closeFence.length + 2;
|
|
249
429
|
}
|
|
250
|
-
currentCode.push(line);
|
|
430
|
+
currentCode.push({ line, srcIdx });
|
|
251
431
|
currentLen += line.length + 1;
|
|
252
432
|
}
|
|
253
433
|
if (currentCode.length > 0) {
|
|
254
|
-
|
|
434
|
+
flush(true);
|
|
255
435
|
}
|
|
256
436
|
if (pieces.length > 0)
|
|
257
437
|
return pieces;
|
|
@@ -264,17 +444,27 @@ export function splitOversizedBlock(text, maxChunk, blockType) {
|
|
|
264
444
|
const pieces = [];
|
|
265
445
|
let currentRows = [];
|
|
266
446
|
let currentLen = headerLen;
|
|
267
|
-
|
|
447
|
+
const flush = () => {
|
|
448
|
+
const firstReal = pieces.length === 0 ? 0 : currentRows[0].srcIdx;
|
|
449
|
+
pieces.push({
|
|
450
|
+
text: [...headerRows, ...currentRows.map((r) => r.row)].join('\n'),
|
|
451
|
+
startLine: firstReal,
|
|
452
|
+
endLine: currentRows[currentRows.length - 1].srcIdx,
|
|
453
|
+
});
|
|
454
|
+
};
|
|
455
|
+
for (let offset = 0; offset < dataRows.length; offset++) {
|
|
456
|
+
const row = dataRows[offset];
|
|
457
|
+
const srcIdx = 2 + offset;
|
|
268
458
|
if (currentLen + row.length + 1 > maxChunk && currentRows.length > 0) {
|
|
269
|
-
|
|
459
|
+
flush();
|
|
270
460
|
currentRows = [];
|
|
271
461
|
currentLen = headerLen;
|
|
272
462
|
}
|
|
273
|
-
currentRows.push(row);
|
|
463
|
+
currentRows.push({ row, srcIdx });
|
|
274
464
|
currentLen += row.length + 1;
|
|
275
465
|
}
|
|
276
466
|
if (currentRows.length > 0) {
|
|
277
|
-
|
|
467
|
+
flush();
|
|
278
468
|
}
|
|
279
469
|
if (pieces.length > 0)
|
|
280
470
|
return pieces;
|
|
@@ -283,29 +473,36 @@ export function splitOversizedBlock(text, maxChunk, blockType) {
|
|
|
283
473
|
const pieces = [];
|
|
284
474
|
let currentLines = [];
|
|
285
475
|
let currentLen = 0;
|
|
286
|
-
|
|
476
|
+
const flush = () => {
|
|
477
|
+
pieces.push({
|
|
478
|
+
text: currentLines.map((l) => l.line).join('\n').trim(),
|
|
479
|
+
startLine: currentLines[0].srcIdx,
|
|
480
|
+
endLine: currentLines[currentLines.length - 1].srcIdx,
|
|
481
|
+
});
|
|
482
|
+
};
|
|
483
|
+
for (let srcIdx = 0; srcIdx < lines.length; srcIdx++) {
|
|
484
|
+
const line = lines[srcIdx];
|
|
287
485
|
if (currentLen + line.length + 1 > maxChunk && currentLines.length > 0) {
|
|
288
|
-
|
|
486
|
+
flush();
|
|
289
487
|
currentLines = [];
|
|
290
488
|
currentLen = 0;
|
|
291
489
|
}
|
|
292
490
|
if (line.length > maxChunk) {
|
|
293
491
|
if (currentLines.length > 0) {
|
|
294
|
-
|
|
492
|
+
flush();
|
|
295
493
|
currentLines = [];
|
|
296
494
|
}
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
pieces.push(piece);
|
|
495
|
+
for (const piece of splitLongLine(line, maxChunk)) {
|
|
496
|
+
pieces.push({ text: piece, startLine: srcIdx, endLine: srcIdx });
|
|
300
497
|
}
|
|
301
498
|
currentLen = 0;
|
|
302
499
|
continue;
|
|
303
500
|
}
|
|
304
|
-
currentLines.push(line);
|
|
501
|
+
currentLines.push({ line, srcIdx });
|
|
305
502
|
currentLen += line.length + 1;
|
|
306
503
|
}
|
|
307
504
|
if (currentLines.length > 0) {
|
|
308
|
-
|
|
505
|
+
flush();
|
|
309
506
|
}
|
|
310
507
|
return pieces;
|
|
311
508
|
}
|
package/dist/mcp.d.ts
CHANGED
|
@@ -4,6 +4,64 @@
|
|
|
4
4
|
* via a zero-dependency JSON-RPC 2.0 stdio transport per the MCP specification.
|
|
5
5
|
*/
|
|
6
6
|
import readline from 'node:readline';
|
|
7
|
+
/**
|
|
8
|
+
* Protocol versions this server can speak (issue #197), newest first.
|
|
9
|
+
* `initialize` echoes the client's requested version when we support it,
|
|
10
|
+
* otherwise answers with MCP_SUPPORTED_VERSIONS[0].
|
|
11
|
+
*/
|
|
12
|
+
export declare const MCP_SUPPORTED_VERSIONS: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the protocol version to answer an `initialize` with: the client's
|
|
15
|
+
* exact request when supported, else the newest version we speak (the spec's
|
|
16
|
+
* "respond with another protocol version you support" branch).
|
|
17
|
+
*/
|
|
18
|
+
export declare function negotiateProtocolVersion(requested?: unknown): string;
|
|
19
|
+
export interface McpClientChannelRequestOptions {
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reverse channel for server→client JSON-RPC requests (issue #197) — today
|
|
24
|
+
* only `sampling/createMessage`. Both transports build one: stdio writes
|
|
25
|
+
* requests to its stdout and matches response lines by id; HTTP pushes them
|
|
26
|
+
* onto the GET /mcp SSE stream and matches POSTed responses by id.
|
|
27
|
+
*/
|
|
28
|
+
export interface McpClientChannel {
|
|
29
|
+
request(method: string, params?: any, opts?: McpClientChannelRequestOptions): Promise<any>;
|
|
30
|
+
/**
|
|
31
|
+
* Feeds a client message in. Returns true when it resolved a pending
|
|
32
|
+
* request; false means the message is a client request/notification and
|
|
33
|
+
* belongs to the normal handler.
|
|
34
|
+
*/
|
|
35
|
+
handleResponse(msg: any): boolean;
|
|
36
|
+
}
|
|
37
|
+
/** Server→client requests that outlive this wait degrade, not hang (issue #197). */
|
|
38
|
+
export declare const DEFAULT_SAMPLING_TIMEOUT_MS = 60000;
|
|
39
|
+
/** `sampling/createMessage` requires maxTokens — a grounded citation answer fits comfortably. */
|
|
40
|
+
export declare const DEFAULT_SAMPLING_MAX_TOKENS = 1024;
|
|
41
|
+
export declare function samplingTimeoutMs(envValue?: string): number;
|
|
42
|
+
/**
|
|
43
|
+
* Builds a client channel over a raw sink. Ids are prefixed `mdss-srv-` so
|
|
44
|
+
* they can never collide with the client's own request ids in either
|
|
45
|
+
* direction's logs or pending maps.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createClientChannel(send: (msg: any) => void): McpClientChannel;
|
|
48
|
+
/**
|
|
49
|
+
* A sampling-backed synthesis hook for the RAG layer (issue #197): returns
|
|
50
|
+
* null unless the client both declared the `sampling` capability at
|
|
51
|
+
* initialize AND a reverse channel exists — otherwise ask_knowledge_base goes
|
|
52
|
+
* straight to the local extractive answer. Any in-flight failure (client
|
|
53
|
+
* refusal, timeout, non-text content) throws and degrades the same way.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createSamplingSynthesizer(state: {
|
|
56
|
+
channel?: McpClientChannel;
|
|
57
|
+
clientCapabilities?: any;
|
|
58
|
+
}): {
|
|
59
|
+
fn: (req: {
|
|
60
|
+
system: string;
|
|
61
|
+
user: string;
|
|
62
|
+
}) => Promise<string>;
|
|
63
|
+
used: () => boolean;
|
|
64
|
+
} | null;
|
|
7
65
|
export declare const MCP_TOOLS: ({
|
|
8
66
|
name: string;
|
|
9
67
|
description: string;
|
|
@@ -276,12 +334,23 @@ export declare const MCP_PROMPTS: {
|
|
|
276
334
|
required: boolean;
|
|
277
335
|
}[];
|
|
278
336
|
}[];
|
|
279
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Shared state for both MCP transports (stdio + Streamable HTTP): the
|
|
339
|
+
* transports own the extras — `channel` (server→client requests, issue #197),
|
|
340
|
+
* `clientCapabilities` (recorded at initialize), `output` (stdio sink, test seam).
|
|
341
|
+
*/
|
|
342
|
+
export interface McpHandlerState {
|
|
280
343
|
loaded: any;
|
|
281
344
|
cacheDir: string;
|
|
282
345
|
offline: boolean;
|
|
283
346
|
embedFn?: any;
|
|
284
|
-
|
|
347
|
+
channel?: McpClientChannel;
|
|
348
|
+
clientCapabilities?: any;
|
|
349
|
+
output?: {
|
|
350
|
+
write(chunk: string): void;
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
export declare function handleMcpRequest(req: any, state: McpHandlerState): Promise<any | null>;
|
|
285
354
|
export interface StartMcpServerOptions {
|
|
286
355
|
indexDir: string;
|
|
287
356
|
cacheDir: string;
|
|
@@ -290,9 +359,23 @@ export interface StartMcpServerOptions {
|
|
|
290
359
|
ignore?: string[];
|
|
291
360
|
offline?: boolean;
|
|
292
361
|
embedFn?: any;
|
|
362
|
+
/** Stdio source (default process.stdin) — test/embedding seam (issue #197). */
|
|
363
|
+
input?: NodeJS.ReadableStream;
|
|
364
|
+
/** Stdio sink (default process.stdout) — test/embedding seam (issue #197). */
|
|
365
|
+
output?: {
|
|
366
|
+
write(chunk: string): void;
|
|
367
|
+
};
|
|
293
368
|
log?: (msg: string) => void;
|
|
294
369
|
}
|
|
295
370
|
export declare function startMcpServer(opts: StartMcpServerOptions): Promise<{
|
|
296
|
-
state:
|
|
371
|
+
state: McpHandlerState;
|
|
297
372
|
rl: readline.Interface;
|
|
298
373
|
}>;
|
|
374
|
+
/**
|
|
375
|
+
* One guarded, in-order stdio JSON-RPC exchange (issues #180/#185): caps the
|
|
376
|
+
* line length, turns JSON parse failures into `-32700`, and converts ANY
|
|
377
|
+
* handler escape into a `-32603` response instead of an unhandled rejection
|
|
378
|
+
* that would kill the daemon.
|
|
379
|
+
*/
|
|
380
|
+
export declare const MAX_STDIO_LINE_CHARS: number;
|
|
381
|
+
export declare function handleStdioLine(line: string, state: McpHandlerState): Promise<void>;
|