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.
Files changed (64) hide show
  1. package/README.md +88 -14
  2. package/RESEARCH.md +182 -0
  3. package/bin/cli.mjs +197 -8
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +5 -0
  6. package/dist/core.d.ts +16 -4
  7. package/dist/core.js +105 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +96 -0
  13. package/dist/hnsw.js +326 -0
  14. package/dist/indexer.d.ts +17 -1
  15. package/dist/indexer.js +91 -17
  16. package/dist/lexical.d.ts +10 -0
  17. package/dist/lexical.js +98 -15
  18. package/dist/markdown-parser.d.ts +62 -0
  19. package/dist/markdown-parser.js +227 -30
  20. package/dist/mcp.d.ts +86 -3
  21. package/dist/mcp.js +259 -31
  22. package/dist/models.d.ts +7 -1
  23. package/dist/models.js +32 -1
  24. package/dist/providers.d.ts +56 -0
  25. package/dist/providers.js +305 -0
  26. package/dist/quantization.d.ts +12 -0
  27. package/dist/quantization.js +54 -0
  28. package/dist/rag.d.ts +60 -0
  29. package/dist/rag.js +133 -35
  30. package/dist/repair.d.ts +1 -1
  31. package/dist/repair.js +53 -2
  32. package/dist/rerank.d.ts +32 -3
  33. package/dist/rerank.js +58 -8
  34. package/dist/search.d.ts +48 -3
  35. package/dist/search.js +384 -78
  36. package/dist/serve.d.ts +35 -0
  37. package/dist/serve.js +478 -58
  38. package/dist/tui.d.ts +6 -0
  39. package/dist/tui.js +52 -2
  40. package/dist/watcher.js +23 -12
  41. package/dist/webui.d.ts +3 -3
  42. package/dist/webui.js +201 -2
  43. package/package.json +3 -3
  44. package/src/binary-format.ts +13 -0
  45. package/src/completions.ts +5 -0
  46. package/src/core.ts +115 -12
  47. package/src/federation.ts +51 -17
  48. package/src/filter.ts +14 -0
  49. package/src/hnsw.ts +414 -0
  50. package/src/indexer.ts +107 -18
  51. package/src/lexical.ts +91 -13
  52. package/src/markdown-parser.ts +279 -37
  53. package/src/mcp.ts +323 -34
  54. package/src/models.ts +39 -2
  55. package/src/providers.ts +345 -0
  56. package/src/quantization.ts +61 -0
  57. package/src/rag.ts +189 -34
  58. package/src/repair.ts +51 -2
  59. package/src/rerank.ts +81 -8
  60. package/src/search.ts +417 -82
  61. package/src/serve.ts +499 -66
  62. package/src/tui.ts +56 -2
  63. package/src/watcher.ts +21 -10
  64. 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 targets = [c.title, ...(c.meta?.aliases || [])].filter(Boolean);
194
+ const tTerms = targetTokens(c);
111
195
  let maxScore = 0;
112
- for (const target of targets) {
113
- const tTerms = tokenize(target);
114
- for (const qt of qTerms) {
115
- if (qt.length < 3)
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
- for (const tt of tTerms) {
118
- if (tt.length < 3)
119
- continue;
120
- const maxDist = Math.min(2, Math.floor(qt.length / 3));
121
- const dist = editDistance(qt, tt);
122
- if (dist <= maxDist) {
123
- const similarity = 1 - dist / Math.max(qt.length, tt.length);
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[];
@@ -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 = resolveChunkBudget(options.targetTokens, options.maxChunk ?? 1200);
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.length > maxChunk) {
239
+ if (measure(blockText) > maxChunk) {
184
240
  flushGroup();
185
241
  const headingPath = headingStack.map(h => h.heading);
186
- const subPieces = splitOversizedBlock(blockText, maxChunk, block.type);
187
- let lineCursor = block.startLine;
188
- for (let idx = 0; idx < subPieces.length; idx++) {
189
- const piece = subPieces[idx];
190
- const pieceLineCount = piece.split('\n').length;
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: lineCursor,
196
- endLine: Math.min(block.endLine, lineCursor + pieceLineCount - 1),
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.length : currentLen + 2 + blockText.length;
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 : 2) + blockText.length;
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
- for (const line of innerLines) {
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
- pieces.push([openFence, ...currentCode, closeFence].join('\n'));
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
- pieces.push([openFence, ...currentCode, closeFence].join('\n'));
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
- for (const row of dataRows) {
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
- pieces.push([...headerRows, ...currentRows].join('\n'));
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
- pieces.push([...headerRows, ...currentRows].join('\n'));
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
- for (const line of lines) {
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
- pieces.push(currentLines.join('\n').trim());
486
+ flush();
289
487
  currentLines = [];
290
488
  currentLen = 0;
291
489
  }
292
490
  if (line.length > maxChunk) {
293
491
  if (currentLines.length > 0) {
294
- pieces.push(currentLines.join('\n').trim());
492
+ flush();
295
493
  currentLines = [];
296
494
  }
297
- const splitLines = splitLongLine(line, maxChunk);
298
- for (const piece of splitLines) {
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
- pieces.push(currentLines.join('\n').trim());
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
- export declare function handleMcpRequest(req: any, state: {
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
- }): Promise<any | null>;
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: any;
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>;