md-semantic-search 0.3.0 → 0.4.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.
@@ -0,0 +1,291 @@
1
+ // @ts-check
2
+ import crypto from 'node:crypto';
3
+
4
+ /** @typedef {Record<string, number>} TermFrequencies */
5
+ /**
6
+ * @typedef {Object} LexicalIndex
7
+ * @property {'bm25-v1'|'bm25-v2'} format
8
+ * @property {number[]} documentLengths
9
+ * @property {Record<string, Array<[number, number]>>} postings
10
+ */
11
+
12
+ const STOP = new Set([
13
+ 'the', 'and', 'for', 'are', 'was', 'has', 'with', 'this', 'that', 'from',
14
+ 'not', 'but', 'you', 'your', 'can', 'all', 'any', 'its',
15
+ 'все', 'как', 'что', 'это', 'при', 'для', 'или', 'был', 'без', 'над',
16
+ 'под', 'так', 'его', 'нет', 'есть',
17
+ 'of', 'to', 'in', 'on', 'it', 'is', 'at', 'by', 'be', 'we', 'us', 'he',
18
+ 'as', 'or', 'an', 'do', 'so', 'no', 'if', 'up', 'my', 'me', 'am',
19
+ 'по', 'от', 'из', 'на', 'за', 'во', 'со', 'не', 'ни', 'же', 'ли', 'бы',
20
+ 'уж', 'мы', 'вы', 'он', 'то', 'но', 'до', 'ко',
21
+ ]);
22
+
23
+ export const _lexicalStats = { documentsAnalyzed: 0 };
24
+
25
+ /** Normalize lexical context consistently with canonical passage hashing. */
26
+ function normalize(text) {
27
+ return (text ?? '').replace(/\r\n?/g, '\n').trim();
28
+ }
29
+
30
+ /** @param {string} left @param {string} right */
31
+ function equivalent(left, right) {
32
+ return left.toLowerCase() === right.toLowerCase();
33
+ }
34
+
35
+ /** @param {{title:string, heading:string, headingPath?:string[], text:string}} chunk */
36
+ export function lexicalDocument(chunk) {
37
+ const title = normalize(chunk.title);
38
+ const heading = normalize(chunk.heading);
39
+ const ancestors = (chunk.headingPath ?? (heading ? [chunk.heading] : [])).map(normalize);
40
+ if (ancestors.length > 0 && equivalent(ancestors[0], title)) ancestors.shift();
41
+ if (ancestors.length > 0 && equivalent(ancestors[ancestors.length - 1], heading)) ancestors.pop();
42
+ return [title, ...ancestors, heading, normalize(chunk.text)].join('\n');
43
+ }
44
+
45
+ /** Model-independent identity of the exact lexical document input. */
46
+ export function lexicalIdentity(chunk) {
47
+ return crypto.createHash('sha256').update(lexicalDocument(chunk)).digest('hex');
48
+ }
49
+
50
+ /** @param {string} text @returns {string[]} */
51
+ export function tokenize(text) {
52
+ const matches = text.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}#+-]*/gu);
53
+ if (!matches) return [];
54
+ return matches.filter(term => term.length > 1 && !STOP.has(term));
55
+ }
56
+
57
+ /**
58
+ * @param {{title:string, heading:string, headingPath?:string[], text:string}} chunk
59
+ * @returns {TermFrequencies}
60
+ */
61
+ /** Default field weights for BM25F */
62
+ export const DEFAULT_FIELD_WEIGHTS = {
63
+ title: 3.0,
64
+ aliases: 3.0,
65
+ headingPath: 1.8,
66
+ body: 1.0,
67
+ };
68
+
69
+ /**
70
+ * Field-aware lexical analysis: tokenizes fields separately with BM25F weights.
71
+ * @param {{title:string, heading:string, headingPath?:string[], text:string, meta?:import('./frontmatter.mjs').DocumentMetadata}} chunk
72
+ * @param {typeof DEFAULT_FIELD_WEIGHTS} [weights=DEFAULT_FIELD_WEIGHTS]
73
+ * @returns {TermFrequencies}
74
+ */
75
+ export function analyzeLexicalDocument(chunk, weights = DEFAULT_FIELD_WEIGHTS) {
76
+ _lexicalStats.documentsAnalyzed++;
77
+ /** @type {TermFrequencies} */
78
+ const frequencies = Object.create(null);
79
+
80
+ const titleText = chunk.title || '';
81
+ const aliasesText = (chunk.meta?.aliases || []).join(' ');
82
+ const headingText = (chunk.headingPath || [chunk.heading]).join(' ');
83
+ const bodyText = chunk.text || '';
84
+
85
+ const addTokens = (text, weight) => {
86
+ for (const term of tokenize(text)) {
87
+ frequencies[term] = (frequencies[term] ?? 0) + weight;
88
+ }
89
+ };
90
+
91
+ addTokens(titleText, weights.title);
92
+ if (aliasesText) addTokens(aliasesText, weights.aliases);
93
+ if (headingText) addTokens(headingText, weights.headingPath);
94
+ addTokens(bodyText, weights.body);
95
+
96
+ return frequencies;
97
+ }
98
+
99
+ /** @param {TermFrequencies[]} records @returns {LexicalIndex} */
100
+ export function buildLexicalIndex(records) {
101
+ /** @type {Record<string, Array<[number, number]>>} */
102
+ const postings = Object.create(null);
103
+ const documentLengths = records.map((record, docId) => {
104
+ let length = 0;
105
+ for (const [term, tf] of Object.entries(record)) {
106
+ length += tf;
107
+ const posting = postings[term] ?? [];
108
+ posting.push([docId, tf]);
109
+ postings[term] = posting;
110
+ }
111
+ return Number(length.toFixed(3));
112
+ });
113
+ return { format: 'bm25-v2', documentLengths, postings };
114
+ }
115
+
116
+ /**
117
+ * Damerau-Levenshtein edit distance calculation.
118
+ * @param {string} a
119
+ * @param {string} b
120
+ * @returns {number}
121
+ */
122
+ export function editDistance(a, b) {
123
+ if (a === b) return 0;
124
+ const alen = a.length;
125
+ const blen = b.length;
126
+ if (alen === 0) return blen;
127
+ if (blen === 0) return alen;
128
+
129
+ const matrix = Array.from({ length: alen + 1 }, () => new Array(blen + 1).fill(0));
130
+ for (let i = 0; i <= alen; i++) matrix[i][0] = i;
131
+ for (let j = 0; j <= blen; j++) matrix[0][j] = j;
132
+
133
+ for (let i = 1; i <= alen; i++) {
134
+ for (let j = 1; j <= blen; j++) {
135
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
136
+ matrix[i][j] = Math.min(
137
+ matrix[i - 1][j] + 1,
138
+ matrix[i][j - 1] + 1,
139
+ matrix[i - 1][j - 1] + cost,
140
+ );
141
+ if (
142
+ i > 1 &&
143
+ j > 1 &&
144
+ a[i - 1] === b[j - 2] &&
145
+ a[i - 2] === b[j - 1]
146
+ ) {
147
+ matrix[i][j] = Math.min(matrix[i][j], matrix[i - 2][j - 2] + cost);
148
+ }
149
+ }
150
+ }
151
+ return matrix[alen][blen];
152
+ }
153
+
154
+ /**
155
+ * Bounded fuzzy matching for title & aliases (Damerau-Levenshtein dist 1-2).
156
+ * Returns map of docId -> fuzzyScore.
157
+ * @param {Array<{file:string, title:string, meta?:import('./frontmatter.mjs').DocumentMetadata}>} chunks
158
+ * @param {string} query
159
+ * @returns {Map<number, number>}
160
+ */
161
+ export function fuzzyTitleAliasScores(chunks, query) {
162
+ const scores = new Map();
163
+ const qTerms = tokenize(query);
164
+ if (qTerms.length === 0) return scores;
165
+
166
+ for (let docId = 0; docId < chunks.length; docId++) {
167
+ const c = chunks[docId];
168
+ const targets = [c.title, ...(c.meta?.aliases || [])].filter(Boolean);
169
+ let maxScore = 0;
170
+
171
+ for (const target of targets) {
172
+ const tTerms = tokenize(target);
173
+ for (const qt of qTerms) {
174
+ if (qt.length < 3) continue; // skip tiny query tokens
175
+ for (const tt of tTerms) {
176
+ if (tt.length < 3) continue;
177
+ const maxDist = Math.min(2, Math.floor(qt.length / 3));
178
+ const dist = editDistance(qt, tt);
179
+ if (dist <= maxDist) {
180
+ const similarity = 1 - dist / Math.max(qt.length, tt.length);
181
+ if (similarity > maxScore) maxScore = similarity;
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ if (maxScore > 0) {
188
+ scores.set(docId, maxScore * 2.0); // Bounded fuzzy title/alias boost
189
+ }
190
+ }
191
+ return scores;
192
+ }
193
+
194
+ /**
195
+ * @param {unknown} value
196
+ * @param {number} chunkCount
197
+ * @returns {string|null}
198
+ */
199
+ export function validateLexicalIndex(value, chunkCount) {
200
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
201
+ return 'lexical must be an object';
202
+ }
203
+ const lexical = /** @type {Record<string, unknown>} */ (value);
204
+ if (lexical.format !== 'bm25-v1' && lexical.format !== 'bm25-v2') {
205
+ return 'unknown lexical format';
206
+ }
207
+ if (!Array.isArray(lexical.documentLengths) || lexical.documentLengths.length !== chunkCount ||
208
+ !lexical.documentLengths.every(length => Number.isFinite(length) && length >= 0)) {
209
+ return `documentLengths must contain ${chunkCount} non-negative numbers`;
210
+ }
211
+ if (lexical.postings === null || typeof lexical.postings !== 'object' ||
212
+ Array.isArray(lexical.postings)) return 'postings must be an object';
213
+
214
+ const sums = new Array(chunkCount).fill(0);
215
+ for (const [term, rawPosting] of Object.entries(lexical.postings)) {
216
+ if (!Array.isArray(rawPosting)) return `posting for ${term} must be an array`;
217
+ let previous = -1;
218
+ for (const entry of rawPosting) {
219
+ if (!Array.isArray(entry) || entry.length !== 2) return `posting for ${term} has an invalid entry`;
220
+ const [docId, tf] = entry;
221
+ if (!Number.isSafeInteger(docId)) {
222
+ return `posting for ${term} must have a safe integer document ID`;
223
+ }
224
+ if (docId < 0 || docId >= chunkCount) {
225
+ return `posting for ${term} has a document ID out of range`;
226
+ }
227
+ if (docId <= previous) return `posting for ${term} document IDs must be strictly increasing`;
228
+ if (!Number.isFinite(tf) || tf <= 0) return `posting for ${term} must have positive TF`;
229
+ sums[docId] += tf;
230
+ previous = docId;
231
+ }
232
+ }
233
+ for (let docId = 0; docId < chunkCount; docId++) {
234
+ if (Math.abs(sums[docId] - lexical.documentLengths[docId]) > 1e-3) {
235
+ return `document ${docId} TF sum does not equal documentLengths`;
236
+ }
237
+ }
238
+ return null;
239
+ }
240
+
241
+ /** @param {LexicalIndex} lexical @returns {TermFrequencies[]} */
242
+ export function reverseLexicalIndex(lexical) {
243
+ const records = lexical.documentLengths.map(() => /** @type {TermFrequencies} */ (Object.create(null)));
244
+ for (const [term, posting] of Object.entries(lexical.postings)) {
245
+ for (const [docId, tf] of posting) records[docId][term] = tf;
246
+ }
247
+ return records;
248
+ }
249
+
250
+ /**
251
+ * @param {LexicalIndex} lexical
252
+ * @param {string[]} queryTerms
253
+ * @param {Set<number>} [eligible]
254
+ * @returns {Map<number, number>}
255
+ */
256
+ export function bm25Scores(lexical, queryTerms, eligible) {
257
+ const scores = new Map();
258
+ const documentCount = lexical.documentLengths.length;
259
+ if (documentCount === 0) return scores;
260
+ const averageLength = lexical.documentLengths.reduce((sum, length) => sum + length, 0) /
261
+ documentCount || 1;
262
+ if (!Number.isFinite(averageLength)) throw new Error('non-finite BM25 average document length');
263
+ for (const term of new Set(queryTerms)) {
264
+ const posting = Object.prototype.hasOwnProperty.call(lexical.postings, term)
265
+ ? lexical.postings[term] : undefined;
266
+ if (!posting) continue;
267
+ const idf = Math.log(1 + (documentCount - posting.length + 0.5) / (posting.length + 0.5));
268
+ if (!Number.isFinite(idf)) throw new Error(`non-finite BM25 IDF for ${term}`);
269
+ for (const [docId, tf] of posting) {
270
+ if (eligible && !eligible.has(docId)) continue;
271
+ const lengthRatio = lexical.documentLengths[docId] / averageLength;
272
+ const score = idf * (tf * 2.2) / (tf + 1.2 * (0.25 + 0.75 * lengthRatio));
273
+ if (!Number.isFinite(lengthRatio) || !Number.isFinite(score)) {
274
+ throw new Error(`non-finite BM25 score for ${term}`);
275
+ }
276
+ scores.set(docId, (scores.get(docId) ?? 0) + score);
277
+ }
278
+ }
279
+ return scores;
280
+ }
281
+
282
+ /** @param {LexicalIndex} lexical @param {string[]} queryTerms @param {number} docId */
283
+ export function matchingTerms(lexical, queryTerms, docId) {
284
+ const matches = [];
285
+ for (const term of new Set(queryTerms)) {
286
+ const posting = Object.prototype.hasOwnProperty.call(lexical.postings, term)
287
+ ? lexical.postings[term] : undefined;
288
+ if (posting?.some(([candidate]) => candidate === docId)) matches.push(term);
289
+ }
290
+ return matches;
291
+ }
@@ -0,0 +1,347 @@
1
+ // @ts-check
2
+ /**
3
+ * Structural Markdown parser and block chunker (issue #57).
4
+ * Parses Markdown source into structured blocks (headings, fenced code,
5
+ * tables, blockquotes, lists, paragraphs) and emits chunk records with:
6
+ * - heading hierarchy (headingPath)
7
+ * - atomic block preservation (no arbitrary line/blank splits inside fences/tables)
8
+ * - 1-indexed startLine and endLine numbers
9
+ * - oversized block continuation handling
10
+ */
11
+
12
+ export const PARSER_VERSION = 'v1-ast';
13
+
14
+ /**
15
+ * @typedef {{
16
+ * type: 'atx_heading' | 'setext_heading' | 'code' | 'table' | 'blockquote' | 'list' | 'paragraph',
17
+ * level?: number,
18
+ * headingText?: string,
19
+ * text: string,
20
+ * startLine: number,
21
+ * endLine: number,
22
+ * }} MarkdownBlock
23
+ */
24
+
25
+ /**
26
+ * @typedef {{
27
+ * heading: string,
28
+ * headingPath: string[],
29
+ * text: string,
30
+ * startLine: number,
31
+ * endLine: number,
32
+ * }} StructuralChunk
33
+ */
34
+
35
+ /**
36
+ * Parse Markdown body text into structural blocks.
37
+ * Ignores heading-like syntax inside fenced code blocks.
38
+ * Supports ATX (# .. ######) and Setext (=== and ---) headings.
39
+ * @param {string} body
40
+ * @returns {MarkdownBlock[]}
41
+ */
42
+ export function parseMarkdownBlocks(body) {
43
+ const lines = body.split(/\r?\n/);
44
+ /** @type {MarkdownBlock[]} */
45
+ const blocks = [];
46
+
47
+ let i = 0;
48
+ while (i < lines.length) {
49
+ const line = lines[i];
50
+ const lineNum = i + 1;
51
+
52
+ // 1. Fenced Code Blocks (``` or ~~~)
53
+ const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
54
+ if (fenceMatch) {
55
+ const fenceChar = fenceMatch[2][0];
56
+ const fenceLen = fenceMatch[2].length;
57
+ const startLine = lineNum;
58
+ const codeLines = [line];
59
+ i++;
60
+ let closed = false;
61
+ while (i < lines.length) {
62
+ const curLine = lines[i];
63
+ codeLines.push(curLine);
64
+ const closeMatch = curLine.match(/^(\s*)(`{3,}|~{3,})\s*$/);
65
+ if (closeMatch && closeMatch[2][0] === fenceChar && closeMatch[2].length >= fenceLen) {
66
+ closed = true;
67
+ i++;
68
+ break;
69
+ }
70
+ i++;
71
+ }
72
+ blocks.push({
73
+ type: 'code',
74
+ text: codeLines.join('\n'),
75
+ startLine,
76
+ endLine: i, // 1-indexed end line
77
+ });
78
+ continue;
79
+ }
80
+
81
+ // 2. ATX Headings (# .. ######)
82
+ const atxMatch = line.match(/^(\s{0,3})(#{1,6})\s+(.+?)\s*#*\s*$/);
83
+ if (atxMatch) {
84
+ const level = atxMatch[2].length;
85
+ const headingText = atxMatch[3].trim();
86
+ blocks.push({
87
+ type: 'atx_heading',
88
+ level,
89
+ headingText,
90
+ text: line,
91
+ startLine: lineNum,
92
+ endLine: lineNum,
93
+ });
94
+ i++;
95
+ continue;
96
+ }
97
+
98
+ // 3. Setext Headings (Heading text followed by === or ---)
99
+ if (
100
+ i + 1 < lines.length &&
101
+ line.trim().length > 0 &&
102
+ !/^\s*(?:[#>]|```|~~~|[-*+]\s|\d+\.\s)/.test(line)
103
+ ) {
104
+ const nextLine = lines[i + 1];
105
+ const setextMatch = nextLine.match(/^(\s{0,3})(=+|-+)\s*$/);
106
+ if (setextMatch) {
107
+ const level = setextMatch[2][0] === '=' ? 1 : 2;
108
+ const headingText = line.trim();
109
+ blocks.push({
110
+ type: 'setext_heading',
111
+ level,
112
+ headingText,
113
+ text: `${line}\n${nextLine}`,
114
+ startLine: lineNum,
115
+ endLine: lineNum + 1,
116
+ });
117
+ i += 2;
118
+ continue;
119
+ }
120
+ }
121
+
122
+ // 4. Blank lines
123
+ if (line.trim() === '') {
124
+ i++;
125
+ continue;
126
+ }
127
+
128
+ // 5. Tables (Lines starting/containing | with header separator)
129
+ if (/^\s*\|/.test(line) || (/\|/.test(line) && i + 1 < lines.length && /^\s*\|?\s*[-:]+[-|\s:]*$/.test(lines[i + 1]))) {
130
+ const startLine = lineNum;
131
+ const tableLines = [];
132
+ while (i < lines.length && (lines[i].includes('|') || /^\s*\|?\s*[-:]+[-|\s:]*$/.test(lines[i]))) {
133
+ tableLines.push(lines[i]);
134
+ i++;
135
+ }
136
+ blocks.push({
137
+ type: 'table',
138
+ text: tableLines.join('\n'),
139
+ startLine,
140
+ endLine: i,
141
+ });
142
+ continue;
143
+ }
144
+
145
+ // 6. Blockquotes (> ...)
146
+ if (/^\s*>/.test(line)) {
147
+ const startLine = lineNum;
148
+ const bqLines = [];
149
+ while (i < lines.length && (/^\s*>/.test(lines[i]) || (lines[i].trim() !== '' && !/^\s*(?:#{1,6}\s|```|~~~|[-*+]\s|\d+\.\s)/.test(lines[i])))) {
150
+ bqLines.push(lines[i]);
151
+ i++;
152
+ }
153
+ blocks.push({
154
+ type: 'blockquote',
155
+ text: bqLines.join('\n'),
156
+ startLine,
157
+ endLine: i,
158
+ });
159
+ continue;
160
+ }
161
+
162
+ // 7. Generic Paragraph / Block
163
+ const startLine = lineNum;
164
+ const paraLines = [line];
165
+ i++;
166
+ while (i < lines.length) {
167
+ const l = lines[i];
168
+ if (
169
+ l.trim() === '' ||
170
+ /^\s{0,3}#{1,6}\s/.test(l) ||
171
+ /^\s*(?:`{3,}|~{3,})/.test(l) ||
172
+ /^\s*>/.test(l) ||
173
+ /^\s*\|/.test(l)
174
+ ) {
175
+ break;
176
+ }
177
+ if (
178
+ i + 1 < lines.length &&
179
+ /^\s{0,3}(?:=+|-+)\s*$/.test(lines[i + 1])
180
+ ) {
181
+ break;
182
+ }
183
+ paraLines.push(l);
184
+ i++;
185
+ }
186
+ blocks.push({
187
+ type: 'paragraph',
188
+ text: paraLines.join('\n'),
189
+ startLine,
190
+ endLine: i,
191
+ });
192
+ }
193
+
194
+ return blocks;
195
+ }
196
+
197
+ /**
198
+ * Convert token budget to character cap (approx 4 chars/token).
199
+ * @param {number} [tokens]
200
+ * @param {number} [defaultMax=1200]
201
+ * @returns {number}
202
+ */
203
+ export function resolveChunkBudget(tokens, defaultMax = 1200) {
204
+ if (tokens && Number.isFinite(tokens) && tokens > 0) {
205
+ return Math.max(100, Math.min(8192, Math.round(tokens * 4)));
206
+ }
207
+ return defaultMax;
208
+ }
209
+
210
+ /**
211
+ * Chunk Markdown blocks structurally with heading context and oversized block continuation.
212
+ * @param {string | MarkdownBlock[]} input
213
+ * @param {number | {maxChunk?:number, targetTokens?:number}} [opts]
214
+ * @returns {StructuralChunk[]}
215
+ */
216
+ export function chunkMarkdownStructural(input, opts) {
217
+ const options = typeof opts === 'number' ? { maxChunk: opts } : (opts || {});
218
+ const maxChunk = resolveChunkBudget(options.targetTokens, options.maxChunk ?? 1200);
219
+ const blocks = typeof input === 'string' ? parseMarkdownBlocks(input) : input;
220
+ /** @type {StructuralChunk[]} */
221
+ const chunks = [];
222
+
223
+ /** @type {{level:number, heading:string}[]} */
224
+ const headingStack = [];
225
+
226
+ let currentGroup = [];
227
+ let currentLen = 0;
228
+ let groupStartLine = 0;
229
+ let groupEndLine = 0;
230
+
231
+ const flushGroup = () => {
232
+ if (currentGroup.length === 0) return;
233
+ const text = currentGroup.join('\n\n').trim();
234
+ if (text) {
235
+ const headingPath = headingStack.map(h => h.heading);
236
+ chunks.push({
237
+ heading: headingPath.at(-1) ?? '',
238
+ headingPath,
239
+ text,
240
+ startLine: groupStartLine,
241
+ endLine: groupEndLine,
242
+ });
243
+ }
244
+ currentGroup = [];
245
+ currentLen = 0;
246
+ };
247
+
248
+ for (const block of blocks) {
249
+ if (block.type === 'atx_heading' || block.type === 'setext_heading') {
250
+ flushGroup();
251
+ const level = block.level || 1;
252
+ const heading = block.headingText || '';
253
+ while (headingStack.length > 0 && headingStack[headingStack.length - 1].level >= level) {
254
+ headingStack.pop();
255
+ }
256
+ headingStack.push({ level, heading });
257
+ continue;
258
+ }
259
+
260
+ const blockText = block.text.trim();
261
+ if (!blockText) continue;
262
+
263
+ if (blockText.length > maxChunk) {
264
+ flushGroup();
265
+ const headingPath = headingStack.map(h => h.heading);
266
+ const subPieces = splitOversizedBlock(blockText, maxChunk);
267
+ let lineCursor = block.startLine;
268
+ for (let idx = 0; idx < subPieces.length; idx++) {
269
+ const piece = subPieces[idx];
270
+ const pieceLineCount = piece.split('\n').length;
271
+ chunks.push({
272
+ heading: headingPath.at(-1) ?? '',
273
+ headingPath: [...headingPath],
274
+ text: piece,
275
+ startLine: lineCursor,
276
+ endLine: Math.min(block.endLine, lineCursor + pieceLineCount - 1),
277
+ });
278
+ lineCursor += pieceLineCount;
279
+ }
280
+ continue;
281
+ }
282
+
283
+ const addedLen = currentLen === 0 ? blockText.length : currentLen + 2 + blockText.length;
284
+ if (addedLen > maxChunk && currentGroup.length > 0) {
285
+ flushGroup();
286
+ }
287
+
288
+ if (currentGroup.length === 0) {
289
+ groupStartLine = block.startLine;
290
+ }
291
+ currentGroup.push(blockText);
292
+ currentLen += (currentGroup.length === 1 ? 0 : 2) + blockText.length;
293
+ groupEndLine = block.endLine;
294
+ }
295
+
296
+ flushGroup();
297
+
298
+ return chunks.filter(c => c.text.replace(/\s/g, '').length >= 24);
299
+ }
300
+
301
+ /**
302
+ * Split an oversized block (code fence, table, large paragraph) into bounded pieces.
303
+ * @param {string} text
304
+ * @param {number} maxChunk
305
+ * @returns {string[]}
306
+ */
307
+ function splitOversizedBlock(text, maxChunk) {
308
+ const lines = text.split('\n');
309
+ const pieces = [];
310
+ let currentLines = [];
311
+ let currentLen = 0;
312
+
313
+ for (const line of lines) {
314
+ if (currentLen + line.length + 1 > maxChunk && currentLines.length > 0) {
315
+ pieces.push(currentLines.join('\n').trim());
316
+ currentLines = [];
317
+ currentLen = 0;
318
+ }
319
+
320
+ if (line.length > maxChunk) {
321
+ if (currentLines.length > 0) {
322
+ pieces.push(currentLines.join('\n').trim());
323
+ currentLines = [];
324
+ currentLen = 0;
325
+ }
326
+ let rest = line;
327
+ while (rest.length > maxChunk) {
328
+ let cut = rest.lastIndexOf(' ', maxChunk);
329
+ if (cut <= 0) cut = maxChunk;
330
+ pieces.push(rest.slice(0, cut).trim());
331
+ rest = rest.slice(cut).trim();
332
+ }
333
+ if (rest) currentLines.push(rest);
334
+ currentLen = rest.length;
335
+ continue;
336
+ }
337
+
338
+ currentLines.push(line);
339
+ currentLen += line.length + 1;
340
+ }
341
+
342
+ if (currentLines.length > 0) {
343
+ pieces.push(currentLines.join('\n').trim());
344
+ }
345
+
346
+ return pieces;
347
+ }