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.
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "md-semantic-search",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Local, private semantic (vector) search over any folder of Markdown files. Finds passages by meaning — cross-lingual — using transformers.js. No API keys, no cloud, no vector database.",
5
5
  "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs",
9
+ "./package.json": "./package.json"
10
+ },
6
11
  "bin": {
7
12
  "md-semantic-search": "bin/cli.mjs",
8
13
  "mdss": "bin/cli.mjs"
@@ -15,7 +20,8 @@
15
20
  "LICENSE"
16
21
  ],
17
22
  "scripts": {
18
- "test": "node --test"
23
+ "test": "node --test",
24
+ "lint": "tsc --noEmit"
19
25
  },
20
26
  "keywords": [
21
27
  "semantic-search",
@@ -44,5 +50,8 @@
44
50
  },
45
51
  "dependencies": {
46
52
  "@huggingface/transformers": "^4.2.0"
53
+ },
54
+ "devDependencies": {
55
+ "typescript": "^7.0.2"
47
56
  }
48
57
  }
@@ -0,0 +1,31 @@
1
+ // @ts-check
2
+ /**
3
+ * Result collapse and diversity control helpers (issue #45).
4
+ * Deduplicates / caps multiple chunks per document or canonical identity
5
+ * to increase result diversity in search output.
6
+ */
7
+
8
+ /**
9
+ * Collapse candidate hits so no single file/canonical document exceeds maxPerFile/maxPerDoc.
10
+ * @template T
11
+ * @param {T[]} results
12
+ * @param {(hit: T) => string} getDocId
13
+ * @param {number} [maxPerDoc=1]
14
+ * @returns {T[]}
15
+ */
16
+ export function collapseResults(results, getDocId, maxPerDoc = 1) {
17
+ if (!maxPerDoc || maxPerDoc <= 0) return results;
18
+ const counts = new Map();
19
+ const collapsed = [];
20
+
21
+ for (const hit of results) {
22
+ const docId = getDocId(hit);
23
+ const count = counts.get(docId) || 0;
24
+ if (count < maxPerDoc) {
25
+ counts.set(docId, count + 1);
26
+ collapsed.push(hit);
27
+ }
28
+ }
29
+
30
+ return collapsed;
31
+ }
package/src/core.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * Core helpers: model loading, embeddings, markdown walking + chunking, cosine.
3
4
  * Fully model- and path-agnostic — everything is driven by explicit arguments
@@ -6,56 +7,429 @@
6
7
  import fs from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { resolveModel } from './models.mjs';
10
+ import { chunkMarkdownStructural, PARSER_VERSION } from './markdown-parser.mjs';
11
+
12
+ export { PARSER_VERSION };
9
13
 
10
14
  const _extractors = new Map();
11
15
 
16
+ // --- index write lock (issue #37) -------------------------------------------
17
+ // vectors.json and .hashes.json are written by two separate atomic renames
18
+ // (buildIndex's atomicWrite); between them a second `mdss index`/`serve
19
+ // --watch` run can interleave and leave the pair from DIFFERENT runs — a torn
20
+ // logical state. A lockfile in the index dir serializes writers: the holder
21
+ // takes it before any read-compute-write and releases it in a finally. There
22
+ // is intentionally no flock/LOCK_EX — advisory cooperation is enough (all
23
+ // writers are ours), and O_EXCL file creation is the portable atomic primitive.
24
+
25
+ /** Name of the lock file inside the index dir. */
26
+ export const LOCK_FILENAME = '.mdss.lock';
27
+
28
+ /** Locks older than this are presumed abandoned and reclaimed (ms). */
29
+ const LOCK_STALE_MS = 10 * 60 * 1000;
30
+
31
+ /**
32
+ * True when `pid` refers to a live process we can signal. `process.kill(pid, 0)`
33
+ * performs no signal — it just probes; it throws ESRCH when the pid is gone,
34
+ * EPERM when it exists but is owned by another user (counted as alive here —
35
+ * we must NOT steal a live owner's lock).
36
+ */
37
+ export function pidAlive(pid) {
38
+ if (!Number.isInteger(pid) || pid <= 0) return false;
39
+ try { process.kill(pid, 0); return true; }
40
+ catch (e) { return e.code === 'EPERM'; }
41
+ }
42
+
43
+ /** Parse + validate a .mdss.lock body; returns null when unreadable/garbage. */
44
+ function readLock(lockPath) {
45
+ let raw;
46
+ try { raw = fs.readFileSync(lockPath, 'utf8'); }
47
+ catch { return null; }
48
+ try {
49
+ const j = JSON.parse(raw);
50
+ if (!Number.isInteger(j.pid) || typeof j.since !== 'string') return null;
51
+ return j;
52
+ } catch { return null; }
53
+ }
54
+
55
+ /**
56
+ * Try to take the index lock; never throws for a held lock.
57
+ * @param {string} indexDir
58
+ * @returns {{ acquired: true, lockPath: string } | { acquired: false, reason: string, pid: (number|null), heldSince: (string|null) }}
59
+ * `acquired:false` carries enough context for an actionable error/log line.
60
+ */
61
+ export function acquireIndexLock(indexDir) {
62
+ const lockPath = path.join(indexDir, LOCK_FILENAME);
63
+ const payload = JSON.stringify({ pid: process.pid, since: new Date().toISOString() }) + '\n';
64
+ for (let attempt = 0; attempt < 2; attempt++) {
65
+ let fd;
66
+ try {
67
+ fd = fs.openSync(lockPath, 'wx'); // O_EXCL — atomic create-or-fail
68
+ fs.writeFileSync(fd, payload);
69
+ return { acquired: true, lockPath };
70
+ } catch (e) {
71
+ if (e.code !== 'EEXIST') {
72
+ throw new Error(`cannot create index lock ${lockPath}: ${e.message}`);
73
+ }
74
+ // Lock exists — decide whether its holder is still alive / fresh.
75
+ let stat = null;
76
+ try { stat = fs.statSync(lockPath); } catch { /* raced — holder released between our open and stat */ }
77
+ const info = readLock(lockPath);
78
+ const pid = info?.pid ?? null;
79
+ const heldSince = info?.since ?? null;
80
+ // Garbage body → treat as abandoned (a crashed pre-lock-format writer).
81
+ const garbage = info === null;
82
+ const dead = pid !== null && !pidAlive(pid);
83
+ const staleMs = stat ? (Date.now() - stat.mtimeMs) : 0;
84
+ if ((garbage || dead || staleMs > LOCK_STALE_MS) && stat) {
85
+ // Reclaim: unlink then loop to retry acquisition. The unlink is safe
86
+ // because the holder is provably dead/stale — a live owner never gets
87
+ // its lock stolen.
88
+ try { fs.unlinkSync(lockPath); } catch { /* raced reclaim — someone else got it */ }
89
+ continue; // retry O_EXCL create
90
+ }
91
+ return {
92
+ acquired: false,
93
+ reason: garbage
94
+ ? 'lock file is unreadable/abandoned'
95
+ : dead
96
+ ? `lock held by dead pid ${pid} (since ${heldSince})`
97
+ : `index is being written by pid ${pid ?? '?'} (since ${heldSince ?? 'unknown'})`,
98
+ pid, heldSince,
99
+ };
100
+ } finally {
101
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* already closed */ } }
102
+ }
103
+ }
104
+ return { acquired: false, reason: 'lock reclaimed by another process during retry', pid: null, heldSince: null };
105
+ }
106
+
107
+ /** Release the index lock held by THIS process; safe to call twice. */
108
+ export function releaseIndexLock(indexDir) {
109
+ try { fs.unlinkSync(path.join(indexDir, LOCK_FILENAME)); }
110
+ catch { /* already released / never held */ }
111
+ }
112
+
113
+ /**
114
+ * Run `fn` under the index write lock, releasing it in a finally (crash-safe:
115
+ * the pid-liveness/staleness checks above reclaim a lock abandoned by a killed
116
+ * holder, so a hard exit cannot wedge the index forever).
117
+ * @template T
118
+ * @param {string} indexDir
119
+ * @param {() => Promise<T>} fn
120
+ * @returns {Promise<T>}
121
+ * @throws when the lock is already held (see the error message for pid/since).
122
+ */
123
+ export async function withIndexLock(indexDir, fn) {
124
+ // The lock file lives IN the index dir, so the dir must exist before the
125
+ // O_EXCL create — and callers (serve) legitimately call a build on a fresh
126
+ // dir. Create it here, idempotently, BEFORE acquiring so the lock guards the
127
+ // whole build including the very first write.
128
+ fs.mkdirSync(indexDir, { recursive: true });
129
+ const r = acquireIndexLock(indexDir);
130
+ if (!r.acquired) throw new Error(/** @type {{reason:string}} */(r).reason);
131
+ try { return await fn(); }
132
+ finally { releaseIndexLock(indexDir); }
133
+ }
134
+
135
+ /**
136
+ * @typedef {Object} ModelDescriptor
137
+ * @property {string} id - HF repo id (e.g. "Xenova/multilingual-e5-base")
138
+ * @property {string} [revision] - pinned revision (default "main")
139
+ * @property {number} [dim] - embedding dimension (0 for custom ids)
140
+ * @property {string} [queryPrefix] - E5-style "query: " prefix ('' for bge)
141
+ * @property {string} [passagePrefix] - E5-style "passage: " prefix ('' for bge)
142
+ * @property {import('./models.mjs').Pooling} [pooling] - token pooling strategy (default "mean")
143
+ * @property {boolean} [normalize] - whether vectors are L2-normalized (default true)
144
+ * @property {import('./models.mjs').DType} [dtype] - runtime ONNX dtype (default "q8")
145
+ * @property {number} [maxTokens] - declared tokenizer budget (context window)
146
+ * @property {Record<string, unknown>} [sessionOptions] - ONNX Runtime session options (e.g. intraOpNumThreads)
147
+ * @property {boolean} [unknownAdapter] - true for unconfigured raw ids that
148
+ * cannot embed until an explicit adapter is supplied (issue #60)
149
+ * @property {string} [note] - human-readable description
150
+ */
151
+
152
+ /**
153
+ * Select the Transformers.js pipeline source for a model load.
154
+ * @param {ModelDescriptor} model
155
+ * @param {string} cacheDir
156
+ * @param {boolean} offline
157
+ * @returns {string}
158
+ */
159
+ export function getPipelineModelSource(model, cacheDir, offline) {
160
+ const revision = model.revision || 'main';
161
+ return offline && revision !== 'main'
162
+ ? path.join(cacheDir, model.id, revision)
163
+ : model.id;
164
+ }
165
+
166
+ /**
167
+ * Resolve ONNX Runtime session options from model adapter or environment variables (issue #34).
168
+ * @param {ModelDescriptor} [model]
169
+ * @returns {Record<string, unknown>|undefined}
170
+ */
171
+ export function resolveSessionOptions(model) {
172
+ const envIntra = process.env.MDSS_INTRA_OP_THREADS || process.env.MDSS_NUM_THREADS;
173
+ const envInter = process.env.MDSS_INTER_OP_THREADS;
174
+ const intra = envIntra ? parseInt(envIntra, 10) : undefined;
175
+ const inter = envInter ? parseInt(envInter, 10) : undefined;
176
+ const opts = model?.sessionOptions || {};
177
+ const intraOpNumThreads = Number.isSafeInteger(intra) && intra > 0 ? intra : opts.intraOpNumThreads;
178
+ const interOpNumThreads = Number.isSafeInteger(inter) && inter > 0 ? inter : opts.interOpNumThreads;
179
+
180
+ if (intraOpNumThreads === undefined && interOpNumThreads === undefined) {
181
+ return undefined;
182
+ }
183
+ /** @type {Record<string, unknown>} */
184
+ const result = {};
185
+ if (intraOpNumThreads !== undefined) result.intraOpNumThreads = intraOpNumThreads;
186
+ if (interOpNumThreads !== undefined) result.interOpNumThreads = interOpNumThreads;
187
+ return result;
188
+ }
189
+
190
+ /**
191
+ * Get the in-memory cache key for a pipeline extractor session.
192
+ * @param {ModelDescriptor} model
193
+ * @param {string} cacheDir
194
+ * @param {boolean} [offline]
195
+ * @returns {string}
196
+ */
197
+ export function getExtractorCacheKey(model, cacheDir, offline) {
198
+ const revision = model.revision || 'main';
199
+ const source = getPipelineModelSource(model, cacheDir, offline);
200
+ const sessionOptions = resolveSessionOptions(model);
201
+ const sessionKey = sessionOptions ? JSON.stringify(sessionOptions) : 'default';
202
+ return `${model.id}@${revision}|${offline ? 'off' : 'on'}|${source}|${sessionKey}`;
203
+ }
204
+
12
205
  /**
13
206
  * Lazily load (and cache) a feature-extraction pipeline for a model.
14
- * @param {object} model - descriptor from resolveModel() (id + optional revision)
207
+ * A neutral unknown-raw-id adapter is rejected here with an actionable message
208
+ * (issue #60) — the model name is never guessed to be E5/BGE compatible.
209
+ * @param {ModelDescriptor} model - resolved adapter from resolveModel()
15
210
  * @param {string} cacheDir
16
211
  * @param {boolean} [offline=false] - never touch the network; require a cached model
17
212
  */
18
213
  export async function getExtractor(model, cacheDir, offline = false) {
19
- const key = `${model.id}@${model.revision || 'main'}|${offline ? 'off' : 'on'}`;
214
+ if (model.unknownAdapter === true) {
215
+ throw new Error(
216
+ `model "${model.id}" is not a registered adapter and has no explicit ` +
217
+ 'configuration, so it cannot embed safely (no E5 prefixes / pooling are ' +
218
+ 'guessed). Register it in MODELS or pass an explicit adapter descriptor ' +
219
+ '(see README "How to add a model") — e.g. for a raw id, supply ' +
220
+ 'queryPrefix/passagePrefix/dim/pooling explicitly.',
221
+ );
222
+ }
223
+ const revision = model.revision || 'main';
224
+ const source = getPipelineModelSource(model, cacheDir, offline);
225
+ const key = getExtractorCacheKey(model, cacheDir, offline);
20
226
  if (_extractors.has(key)) return _extractors.get(key);
21
227
  const { pipeline, env } = await import('@huggingface/transformers');
22
228
  if (cacheDir) env.cacheDir = cacheDir;
23
229
  env.allowRemoteModels = !offline;
24
- // Prefer the quantized (q8) weights when the model repo ships them — this is
25
- // what Xenova/* repos do (e5-base: ~280MB vs ~1.1GB fp32). Without this the
26
- // v4 default dtype (fp32 on Node) would download the 4x larger weights.
27
- const ext = await pipeline('feature-extraction', model.id, {
28
- revision: model.revision || 'main',
29
- dtype: 'q8',
30
- });
230
+ const sessionOptions = resolveSessionOptions(model);
231
+ // Use the adapter-declared runtime dtype (default q8 — the v4 default on Node
232
+ // is fp32, ~4x larger). The declared dtype is part of the runtime load, not
233
+ // of the vector-producing fingerprint, so a dtype change alone does not
234
+ // invalidate stored vectors.
235
+ const pipeOpts = {
236
+ revision,
237
+ dtype: model.dtype || 'q8',
238
+ ...(sessionOptions ? { session_options: sessionOptions } : {}),
239
+ };
240
+ const ext = await pipeline('feature-extraction', source, pipeOpts);
31
241
  _extractors.set(key, ext);
32
242
  return ext;
33
243
  }
34
244
 
245
+ /**
246
+ * Prepare model-specific feature-extraction inputs and invocation options.
247
+ * All vector-producing semantics come from the resolved adapter (issue #60):
248
+ * query/document formatting, pooling, and normalization are read from the
249
+ * descriptor, never inferred from the model name.
250
+ * @param {ModelDescriptor} model - resolved adapter from resolveModel()
251
+ * @param {string[]} texts
252
+ * @param {'query'|'passage'} kind
253
+ * @returns {{input:string[], options:{pooling:import('./models.mjs').Pooling, normalize:boolean}}}
254
+ */
255
+ export function prepareEmbeddingRequest(model, texts, kind) {
256
+ if (model.unknownAdapter === true) {
257
+ throw new Error(
258
+ `model "${model.id}" is not a registered adapter and has no explicit ` +
259
+ 'configuration, so it cannot embed safely (issue #60) — supply an ' +
260
+ 'explicit adapter descriptor, see README "How to add a model".',
261
+ );
262
+ }
263
+ const prefix = kind === 'query' ? model.queryPrefix : model.passagePrefix;
264
+ const input = prefix ? texts.map(text => prefix + text) : texts;
265
+ return {
266
+ input,
267
+ options: { pooling: model.pooling ?? 'mean', normalize: model.normalize !== false },
268
+ };
269
+ }
270
+
35
271
  /**
36
272
  * Embed texts with the given model descriptor.
37
273
  * @param {string[]} texts
38
274
  * @param {'query'|'passage'} kind
39
- * @param {object} model - descriptor from resolveModel()
275
+ * @param {ModelDescriptor} model - descriptor from resolveModel()
40
276
  * @param {string} cacheDir
41
277
  * @param {boolean} [offline=false] - never touch the network; require a cached model
42
278
  * @returns {Promise<number[][]>} L2-normalized vectors
43
279
  */
44
280
  export async function embed(texts, kind, model, cacheDir, offline = false) {
45
281
  const ext = await getExtractor(model, cacheDir, offline);
46
- const prefix = kind === 'query' ? model.queryPrefix : model.passagePrefix;
47
- const input = prefix ? texts.map(t => prefix + t) : texts;
48
- const out = await ext(input, { pooling: 'mean', normalize: true });
282
+ const { input, options } = prepareEmbeddingRequest(model, texts, kind);
283
+ const out = await ext(input, options);
49
284
  return out.tolist();
50
285
  }
51
286
 
52
- /** Cosine similarity for L2-normalized vectors == dot product. */
53
- export function cosine(a, b) {
287
+ /** Cosine similarity for L2-normalized vectors == dot product. Supports offset in contiguous buffer (issue #30). */
288
+ export function cosine(a, b, bOffset = 0) {
54
289
  let s = 0;
55
- for (let i = 0; i < a.length; i++) s += a[i] * b[i];
290
+ for (let i = 0; i < a.length; i++) s += a[i] * b[bOffset + i];
56
291
  return s;
57
292
  }
58
293
 
294
+ /**
295
+ * Encode a numeric vector as a base64 string of its Float32Array bytes
296
+ * (binary vector storage, issue #4). ~4× smaller than decimal JSON: a 768-dim
297
+ * vector is 3072 raw bytes → 4096 base64 chars, vs ~8-10 chars per number.
298
+ * @param {number[]|Float32Array} vec
299
+ * @returns {string}
300
+ */
301
+ export function encodeVec(vec) {
302
+ const f32 = vec instanceof Float32Array ? vec : Float32Array.from(vec);
303
+ return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength).toString('base64');
304
+ }
305
+
306
+ /**
307
+ * Decode a base64 vector string back to a Float32Array (binary storage, #4).
308
+ *
309
+ * Throws on corrupt input instead of silently producing garbage (issue #40):
310
+ * - base64 whose byte length is not a multiple of 4 — a truncated string would
311
+ * previously decode into a truncated Float32Array (wrong dim, silent NaN
312
+ * scores downstream);
313
+ * - non-finite values (NaN/Infinity) — e.g. from a partial write or bit rot.
314
+ *
315
+ * When `dim` is given, a length mismatch is also rejected.
316
+ * @param {string} s
317
+ * @param {number} [dim] - expected vector length (issue #40)
318
+ * @returns {Float32Array}
319
+ */
320
+ export function decodeVec(s, dim) {
321
+ if (s.length === 0) {
322
+ throw new Error('corrupt base64 vector: empty value — run `mdss index` to rebuild');
323
+ }
324
+ if (s.length % 4 !== 0 ||
325
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s)) {
326
+ throw new Error('corrupt base64 vector: encoding is not canonical — ' +
327
+ 'run `mdss index` to rebuild');
328
+ }
329
+ const buf = Buffer.from(s, 'base64');
330
+ if (buf.toString('base64') !== s) {
331
+ throw new Error('corrupt base64 vector: encoding is not canonical — ' +
332
+ 'run `mdss index` to rebuild');
333
+ }
334
+ if (buf.byteLength % 4 !== 0) {
335
+ throw new Error(
336
+ `corrupt base64 vector: ${buf.byteLength} bytes is not a multiple of 4 ` +
337
+ `(a float32 is 4 bytes) — run \`mdss index\` to rebuild`);
338
+ }
339
+ const vec = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
340
+ if (dim !== undefined && vec.length !== dim) {
341
+ throw new Error(`corrupt vector: ${vec.length} dims, expected ${dim} — ` +
342
+ `run \`mdss index\` to rebuild`);
343
+ }
344
+ for (let i = 0; i < vec.length; i++) {
345
+ if (!Number.isFinite(vec[i])) {
346
+ throw new Error(`corrupt vector: non-finite value at index ${i} ` +
347
+ `(NaN/Infinity) — run \`mdss index\` to rebuild`);
348
+ }
349
+ }
350
+ return vec;
351
+ }
352
+
353
+ /**
354
+ * True when the index uses the binary vector format (vec stored as base64).
355
+ * @param {{format?:string}} index - parsed vectors.json
356
+ * @returns {boolean}
357
+ */
358
+ export function isBinaryIndex(index) {
359
+ return index.format === 'binary-v1';
360
+ }
361
+
362
+ /** @param {unknown} value @returns {number} */
363
+ export function parseSchemaVersion(value) {
364
+ if (value === undefined) return 0;
365
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
366
+ throw new Error('schemaVersion must be a non-negative safe integer — ' +
367
+ 'run `mdss index` to rebuild');
368
+ }
369
+ return value;
370
+ }
371
+
372
+ /**
373
+ * @param {unknown} stored
374
+ * @param {number} known
375
+ * @returns {number|undefined}
376
+ */
377
+ export function resolveIndexDimension(stored, known) {
378
+ if (stored !== undefined) {
379
+ if (typeof stored !== 'number' || !Number.isSafeInteger(stored) || stored <= 0) {
380
+ throw new Error('index.dim must be a positive safe integer — ' +
381
+ 'run `mdss index` to rebuild');
382
+ }
383
+ if (known > 0 && stored !== known) {
384
+ throw new Error(`index.dim ${stored} does not match known model dimension ${known} — ` +
385
+ 'run `mdss index` to rebuild');
386
+ }
387
+ return stored;
388
+ }
389
+ return known > 0 ? known : undefined;
390
+ }
391
+
392
+ /**
393
+ * Index schema version (issue #39). Bump on every breaking change to
394
+ * vectors.json and add a migration step to SCHEMA_MIGRATIONS.
395
+ * v0 (implicit): everything written before schemaVersion existed — the
396
+ * loader's format heuristics (decimal vs binary-v1 vecs, missing model
397
+ * field, missing chunkHash, vec-less chunks) already handle all v0 shapes.
398
+ * v1: first explicit version; written by all builds since 0.6.0.
399
+ * v2: chunks persist leaf-inclusive headingPath for contextual embeddings.
400
+ * v3: vectors.json requires an embedded BM25 lexical index.
401
+ */
402
+ export const SCHEMA_VERSION = 3;
403
+
404
+ /**
405
+ * Mark a legacy index as schema v2 without inventing heading paths it never
406
+ * stored. Legacy readers remain usable; the indexer performs the honest
407
+ * contextual rebuild before it persists schema-v2 chunks.
408
+ * @param {{schemaVersion?:number}} index
409
+ */
410
+ export function migrateToSchemaV2(index) {
411
+ index.schemaVersion = 2;
412
+ }
413
+
414
+ /** Schema-v3 is a format declaration; only a rebuild may write it. */
415
+ export function migrateToSchemaV3() {}
416
+
417
+ /**
418
+ * Migration table: `SCHEMA_MIGRATIONS[n]` upgrades an index at schema n-1 → n.
419
+ * Read by loadIndex/buildIndex before use; writing a version newer than the
420
+ * binary supports is a hard error instead of a silent misparse (issue #39).
421
+ * @type {Record<number, (index: object) => void>}
422
+ */
423
+ export const SCHEMA_MIGRATIONS = {
424
+ // v0 → v1: no structural change — legacy shapes (decimal vecs, missing
425
+ // model/chunkHash, vec-less chunks) are normalized by the loaders' existing
426
+ // format heuristics, not by a data rewrite. This entry exists so the table
427
+ // is explicit: every future format change adds a real step here + a test.
428
+ 1: () => {},
429
+ 2: migrateToSchemaV2,
430
+ 3: migrateToSchemaV3,
431
+ };
432
+
59
433
  /** Recursively collect .md/.markdown files under dir, honoring ignore globs. */
60
434
  export function walkMarkdown(dir, ignore = []) {
61
435
  const out = [];
@@ -112,60 +486,50 @@ export function extractTitle(frontmatter, body, relPath) {
112
486
  const DEFAULT_MAX_CHUNK = 1400; // chars; ~350-450 tokens, fits e5/bge context
113
487
 
114
488
  /**
115
- * Chunk markdown by headings; oversized sections split further on blank lines.
116
- * @returns {{heading:string, text:string}[]}
489
+ * Chunk markdown structurally by headings and atomic block boundaries (issue #57).
490
+ * Oversized sections split further on block boundaries and hard wraps.
491
+ * @param {string} body
492
+ * @param {number} [maxChunk=DEFAULT_MAX_CHUNK]
493
+ * @returns {import('./markdown-parser.mjs').StructuralChunk[]}
117
494
  */
118
495
  export function chunkMarkdown(body, maxChunk = DEFAULT_MAX_CHUNK) {
119
- const lines = body.split('\n');
120
- const sections = [];
121
- let curHeading = '';
122
- let buf = [];
123
- const flush = () => {
124
- const text = buf.join('\n').trim();
125
- if (text) sections.push({ heading: curHeading, text });
126
- buf = [];
127
- };
128
- for (const line of lines) {
129
- const h = line.match(/^(#{1,6})\s+(.+)$/);
130
- if (h) { flush(); curHeading = h[2].trim(); }
131
- else buf.push(line);
132
- }
133
- flush();
134
-
135
- const chunks = [];
136
- for (const sec of sections) {
137
- if (sec.text.length <= maxChunk) { chunks.push(sec); continue; }
138
- const paras = sec.text.split(/\n\s*\n/);
139
- let acc = '';
140
- for (const p of paras) {
141
- if ((acc + '\n\n' + p).length > maxChunk && acc) {
142
- chunks.push({ heading: sec.heading, text: acc.trim() });
143
- acc = p;
144
- } else {
145
- acc = acc ? acc + '\n\n' + p : p;
146
- }
147
- }
148
- if (acc.trim()) chunks.push({ heading: sec.heading, text: acc.trim() });
149
- }
150
- return chunks.filter(c => c.text.replace(/\s/g, '').length >= 24);
496
+ return chunkMarkdownStructural(body, maxChunk);
151
497
  }
152
498
 
499
+ import { parseFrontmatter } from './frontmatter.mjs';
500
+
501
+ export { parseFrontmatter };
502
+
153
503
  /**
154
504
  * Parse one file into title + chunk records (no embeddings yet).
155
505
  * @param {string} absPath - absolute file path
156
506
  * @param {string} dbDir - base dir (for relative file labels)
507
+ * @param {number} [maxChunk] - chunk size cap (defaults to DEFAULT_MAX_CHUNK)
508
+ * @param {string} [raw] - already-read file content. When given, the file is
509
+ * NOT read from disk again (issue #35: buildIndex already read it for the
510
+ * md5 fast-path check, so a changed file was being read twice).
511
+ * @returns {{file:string, title:string, heading:string, headingPath:string[], text:string, startLine?:number, endLine?:number, meta?:import('./frontmatter.mjs').DocumentMetadata}[]}
157
512
  */
158
- export function parseFile(absPath, dbDir, maxChunk) {
159
- const raw = fs.readFileSync(absPath, 'utf8');
160
- const { frontmatter, body } = splitFrontmatter(raw);
513
+ export function parseFile(absPath, dbDir, maxChunk, raw) {
514
+ const content = raw ?? fs.readFileSync(absPath, 'utf8');
515
+ const { frontmatter, body } = splitFrontmatter(content);
516
+ const meta = parseFrontmatter(frontmatter);
161
517
  const rel = path.relative(dbDir, absPath).split(path.sep).join('/');
162
- const title = extractTitle(frontmatter, body, rel);
163
- return chunkMarkdown(body, maxChunk).map(c => ({
164
- file: rel,
165
- title,
166
- heading: c.heading,
167
- text: c.text,
168
- }));
518
+ const title = meta.title || extractTitle(frontmatter, body, rel);
519
+ const frontmatterLineCount = frontmatter ? frontmatter.split('\n').length + 2 : 0;
520
+ return chunkMarkdown(body, maxChunk).map(c => {
521
+ const heading = c.heading || title;
522
+ return {
523
+ file: rel,
524
+ title,
525
+ heading,
526
+ headingPath: c.headingPath.length > 0 ? c.headingPath : [heading],
527
+ text: c.text,
528
+ startLine: (c.startLine || 1) + frontmatterLineCount,
529
+ endLine: (c.endLine || 1) + frontmatterLineCount,
530
+ meta,
531
+ };
532
+ });
169
533
  }
170
534
 
171
535
  export { resolveModel };