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/bin/cli.mjs CHANGED
@@ -102,7 +102,16 @@ function parseArgs(argv) {
102
102
  const v = nextValue(argv, ++i, a);
103
103
  opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
104
104
  }
105
- else if (a === '--ann') opts.ann = true;
105
+ else if (a === '--ann') {
106
+ // issue #201: optional engine choice — bare --ann keeps the default
107
+ const next = argv[i + 1];
108
+ if (next === 'ivf' || next === 'hnsw') {
109
+ opts.ann = next;
110
+ i++;
111
+ } else {
112
+ opts.ann = true;
113
+ }
114
+ }
106
115
  else if (a === '--nprobe') opts.nprobe = nextInt(argv, ++i, a);
107
116
  else if (a === '--project') opts.project = nextValue(argv, ++i, a);
108
117
  else if (a === '--type') opts.type = nextValue(argv, ++i, a);
@@ -113,11 +122,25 @@ function parseArgs(argv) {
113
122
  else if (a === '--direction') opts.direction = nextValue(argv, ++i, a);
114
123
  else if (a === '--no-semantic') opts.noSemantic = true;
115
124
  else if (a === '--quantize') opts.quantize = nextValue(argv, ++i, a);
125
+ else if (a === '--max-chunk-tokens') {
126
+ // issue #202: 0 explicitly restores the legacy char budget
127
+ opts.maxChunkTokens = nextNonNegInt(argv, ++i, a);
128
+ }
129
+ else if (a === '--multi-query') {
130
+ // issue #203: N total query variants (>=2 activates; LLM required)
131
+ opts.multiQuery = nextInt(argv, ++i, a);
132
+ if (opts.multiQuery > 5) die('--multi-query must be <= 5');
133
+ }
134
+ else if (a === '--rerank-model') opts.rerankModel = nextValue(argv, ++i, a);
116
135
  else if (a === '--rag') opts.rag = true;
117
136
  else if (a === '--auto-tag') opts.autoTag = true;
118
137
  else if (a === '--auto-summarize') opts.autoSummarize = true;
119
138
  else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
120
139
  else if (a === '--llm-model') opts.llmModel = nextValue(argv, ++i, a);
140
+ else if (a === '--llm-provider') {
141
+ opts.llmProvider = nextValue(argv, ++i, a);
142
+ if (!['openai', 'ollama'].includes(opts.llmProvider)) die(`unknown --llm-provider "${opts.llmProvider}" - use openai|ollama`);
143
+ }
121
144
  else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
122
145
  else if (a === '--session') opts.session = nextValue(argv, ++i, a);
123
146
  else if (a === '--resume') opts.resume = true;
@@ -209,8 +232,11 @@ const KNOWN_CONFIG_KEYS = new Set([
209
232
  'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
210
233
  'watchDebug', 'watch-debug',
211
234
  'format', 'noVectors', 'no-vectors', 'output', 'workers', 'dimensions', 'dim', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
235
+ 'maxChunkTokens', 'max-chunk-tokens',
236
+ 'multiQuery', 'multi-query', 'rerankModel', 'rerank-model',
212
237
  'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
213
238
  'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt',
239
+ 'llmProvider', 'llm-provider',
214
240
  'session', 'resume',
215
241
  'completions', 'open', 'dry-run', 'fix', 'yes', 'no-ui', 'noUi',
216
242
  'noQueryCache', 'no-query-cache',
@@ -226,7 +252,7 @@ const CONFIG_NETWORK_KEYS = new Set([
226
252
  'allowedHosts', 'allowed-hosts', 'corsOrigin', 'cors-origin',
227
253
  'rateLimit', 'rate-limit', 'maxConcurrency', 'max-concurrency',
228
254
  'healthPublic', 'health-public', 'mcp', 'llmEndpoint', 'llm-endpoint',
229
- 'llmModel', 'llm-model', 'embedder', 'embedder-model', 'embedder-base-url',
255
+ 'llmModel', 'llm-model', 'llmProvider', 'llm-provider', 'embedder', 'embedder-model', 'embedder-base-url',
230
256
  'embedder-key-file', 'embedderModel', 'embedderBaseUrl', 'embedderKeyFile'
231
257
  ]);
232
258
 
@@ -376,6 +402,11 @@ Options:
376
402
  --model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
377
403
  --dimensions <n> Truncate vector dimensions for Matryoshka/MRL models (alias: --dim).
378
404
  --workers <n> Number of parallel batch workers for indexing (default: 1).
405
+ --max-chunk-tokens <n> index/serve: chunk budget in TOKENS (issue #202) —
406
+ default min(model maxTokens, 512), so Cyrillic notes are
407
+ chunked by the same real budget as English ones instead
408
+ of an English-calibrated char count; 0 restores the
409
+ legacy char budget (1400).
379
410
  --format <fmt> Export format: jsonl (default), csv, parquet (export).
380
411
  --no-vectors Omit vector embeddings from export (export).
381
412
  --output <file> Output file path (export, default: stdout).
@@ -386,9 +417,21 @@ Options:
386
417
  --json Machine-readable output (index, stats, search).
387
418
  --semantic Pure vector ranking, skip lexical/RRF fusion (search).
388
419
  --rerank Re-rank candidates with a cross-encoder (search; ~280MB model).
420
+ --rerank-model <m> Reranker registry alias (issue #203): "bge-reranker-base"
421
+ (default) or "gte-reranker-modernbert" (144MB, 8k ctx,
422
+ Apache-2.0); also accepts id[@revision].
423
+ --multi-query <n> Multi-query expansion (issue #203): N total query
424
+ variants (original + N-1 LLM paraphrases, one extra
425
+ embedding each) fused by union-RRF — recall lift on
426
+ heavy paraphrases. Needs --llm-endpoint; silently falls
427
+ back to the single query without one. 0/1 = off.
389
428
  --graph-boost <n> Boost weight for graph PageRank / Obsidian wikilinks (search, default: 0).
390
429
  --filter <expr> Rich boolean filter across frontmatter tags and properties (search).
391
- --ann Approximate nearest neighbor search via IVF (search).
430
+ --ann [ivf|hnsw] Approximate nearest neighbor search (search/index).
431
+ Engines (issue #201): "ivf" (k-means clusters, the
432
+ historical default) or "hnsw" (graph index — no
433
+ training phase, incremental inserts, hnsw.json
434
+ sidecar). Bare --ann prefers an existing sidecar.
392
435
  --nprobe <n> Number of centroids to probe for ANN (search, default: 8).
393
436
  --port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
394
437
  --host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
@@ -444,6 +487,13 @@ Options:
444
487
  ONE embed total; issue #125) or "hyde" (LLM-generated
445
488
  passage via --llm-endpoint; degrades silently without it).
446
489
  --expand-passages <n> prf feedback pool size (default 3, max 20).
490
+ --llm-endpoint <u> OpenAI-compatible chat endpoint for ask/chat/serve /ask
491
+ and --expand hyde: POST {base}/chat/completions
492
+ (llama.cpp, LM Studio, vLLM, Ollama at .../v1).
493
+ --llm-provider <p> Chat wire format for --llm-endpoint: "openai" (default,
494
+ SSE streaming) or "ollama" (native /api/generate,
495
+ NDJSON streaming) (issue #199).
496
+ --llm-model <m> Provider-side chat model for --llm-endpoint.
447
497
  --open [N] search: open the top (or Nth) hit in your editor at its
448
498
  startLine — MDSS_EDITOR/VISUAL/EDITOR, then VS Code
449
499
  --goto, then the GUI opener (issue #110). With --json on
@@ -462,6 +512,9 @@ Examples:
462
512
  mdss search --db ./docs "incident runbook" --path "docs/**" --since 2026-01-01
463
513
  mdss serve --db ./docs --port 8747 --watch
464
514
  curl -X POST localhost:8747/search -d '{"query":"rotate api token","k":5}'
515
+ mdss ask --db ./docs "how do tokens expire?" \\
516
+ --llm-endpoint http://127.0.0.1:8080/v1 --llm-model qwen2.5
517
+ mdss chat --db ./docs --llm-endpoint http://127.0.0.1:11434/v1 --resume
465
518
  `;
466
519
 
467
520
  function formatDuration(sec) {
@@ -492,6 +545,9 @@ async function resolveEmbedderOverrides(opts) {
492
545
  baseUrl: opts.embedderBaseUrl,
493
546
  keyFile: opts.embedderKeyFile,
494
547
  });
548
+ const egressUrl = opts.embedderBaseUrl ||
549
+ (opts.embedder === 'ollama' ? process.env.OLLAMA_HOST || 'http://127.0.0.1:11434' : 'https://api.openai.com/v1');
550
+ warnDataEgress(`--embedder ${opts.embedder}`, egressUrl);
495
551
  const descriptor = await emb.descriptor();
496
552
  logErr(`embedder: ${emb.id}/${emb.model} (dim ${descriptor.dim} via ${opts.embedderBaseUrl || 'default endpoint'})`);
497
553
  return { modelName: descriptor, embedFn: emb.embedFn };
@@ -501,6 +557,36 @@ function logErr(s) {
501
557
  process.stderr.write(s + '\n');
502
558
  }
503
559
 
560
+ // ---------------------------------------------------------------------------
561
+ // Data egress notice (issue #183): two opt-in paths send PRIVATE note content
562
+ // to non-loopback destinations — external embedders and the RAG LLM bridge.
563
+ // The default local pipeline never transmits anything; the notice fires once
564
+ // per process before the first send and can be silenced via env.
565
+ // ---------------------------------------------------------------------------
566
+
567
+ export function isLoopbackEndpoint(url) {
568
+ try {
569
+ const u = new URL(url);
570
+ const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
571
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1';
572
+ } catch {
573
+ return false;
574
+ }
575
+ }
576
+
577
+ let egressWarned = false;
578
+
579
+ export function warnDataEgress(kind, destination, write = (m) => process.stderr.write(m + '\n')) {
580
+ if (egressWarned || process.env.MDSS_NO_EGRESS_WARNING === '1') return;
581
+ if (isLoopbackEndpoint(destination)) return; // local Ollama/llama.cpp — nothing leaves the machine
582
+ egressWarned = true;
583
+ write(
584
+ `⚠ data egress: ${kind} sends NOTE CONTENT to ${destination} (an external service).\n` +
585
+ ` Everything stays local with --offline, a loopback endpoint, or the default local embedder.\n` +
586
+ ` Set MDSS_NO_EGRESS_WARNING=1 to silence this notice. See SECURITY.md "Data egress".`
587
+ );
588
+ }
589
+
504
590
  async function cmdIndex(opts) {
505
591
  const db = resolveDb(opts);
506
592
  const indexDir = resolveIndexDir(opts, db);
@@ -542,6 +628,8 @@ async function cmdIndex(opts) {
542
628
  workers: opts.workers,
543
629
  ann: opts.ann,
544
630
  quantize: opts.quantize,
631
+ // issue #202: token-budget chunking — default min(adapter.maxTokens, 512)
632
+ ...(opts.maxChunkTokens !== undefined ? { maxChunkTokens: opts.maxChunkTokens } : {}),
545
633
  log: s => {
546
634
  if (process.stderr.isTTY && !opts.json && /^\s+\d+\/\d+$/.test(s)) return;
547
635
  process.stderr.write(s + '\n');
@@ -1134,10 +1222,34 @@ async function cmdTui(opts) {
1134
1222
  status: opts.status || undefined,
1135
1223
  graphBoost: typeof opts.graphBoost === 'number' ? opts.graphBoost : undefined,
1136
1224
  rerank: !!opts.rerank,
1225
+ ...(opts.multiQuery ? { multiQuery: opts.multiQuery, llmEndpoint: opts.llmEndpoint, ...(opts.llmModel ? { llmModel: opts.llmModel } : {}) } : {}),
1226
+ ...(opts.rerankModel ? { rerankModel: opts.rerankModel } : {}),
1137
1227
  rag: !!opts.rag,
1228
+ // issue #199: the RAG panel streams a real LLM answer when an endpoint
1229
+ // is configured; without one it stays on the extractive fallback.
1230
+ llmEndpoint: opts.llmEndpoint,
1231
+ llmModel: opts.llmModel,
1232
+ llmProvider: opts.llmProvider,
1233
+ systemPrompt: opts.systemPrompt,
1138
1234
  });
1139
1235
  }
1140
1236
 
1237
+ /**
1238
+ * issue #199: finish the answer render after synthesis. When LLM deltas
1239
+ * already went to the terminal, only trailing whitespace is added; when the
1240
+ * stream died mid-way and synthesis fell back to extractive, the fallback
1241
+ * answer is printed under an explicit notice so the two texts can't blur.
1242
+ */
1243
+ function finishAnswerRender(streamedChars, result, end = '\n\n') {
1244
+ if (streamedChars === 0) {
1245
+ process.stdout.write(`\n${result.answer}${end}`);
1246
+ } else if (result.mode === 'endpoint') {
1247
+ process.stdout.write(end);
1248
+ } else {
1249
+ process.stdout.write(`\n\n[llm stream interrupted — extractive fallback:]\n\n${result.answer}${end}`);
1250
+ }
1251
+ }
1252
+
1141
1253
  async function cmdAsk(opts) {
1142
1254
  const query = opts._.join(' ').trim();
1143
1255
  if (!query) die('missing question to ask. Usage: mdss ask [--db <dir>] "question"');
@@ -1146,15 +1258,25 @@ async function cmdAsk(opts) {
1146
1258
  const indexDir = resolveIndexDir(opts, db);
1147
1259
  const cacheDir = resolveCache(opts);
1148
1260
  const k = opts.k || 5;
1261
+ const offline = resolveOffline(opts);
1262
+ const embedderOverrides = await resolveEmbedderOverrides(opts);
1149
1263
 
1264
+ if (opts.llmEndpoint) warnDataEgress('--llm-endpoint', opts.llmEndpoint);
1150
1265
  const { askQuestion } = await import('../dist/rag.js');
1266
+ let streamedChars = 0;
1151
1267
  const result = await askQuestion({
1152
1268
  query,
1153
1269
  indexDir,
1154
1270
  cacheDir,
1155
1271
  k,
1272
+ offline,
1273
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1156
1274
  llmEndpoint: opts.llmEndpoint,
1157
1275
  llmModel: opts.llmModel,
1276
+ llmProvider: opts.llmProvider,
1277
+ // issue #199: incremental render — first token lands immediately. JSON
1278
+ // mode stays a single machine-readable write.
1279
+ ...(opts.json ? {} : { onDelta: (d) => { streamedChars += d.length; process.stdout.write(d); } }),
1158
1280
  systemPrompt: opts.systemPrompt,
1159
1281
  });
1160
1282
 
@@ -1163,7 +1285,7 @@ async function cmdAsk(opts) {
1163
1285
  return;
1164
1286
  }
1165
1287
 
1166
- process.stdout.write(`\n${result.answer}\n\n`);
1288
+ finishAnswerRender(streamedChars, result);
1167
1289
  if (result.citations && result.citations.length > 0) {
1168
1290
  process.stdout.write('Sources:\n');
1169
1291
  for (const c of result.citations) {
@@ -1179,12 +1301,15 @@ async function cmdChat(opts) {
1179
1301
  const indexDir = resolveIndexDir(opts, db);
1180
1302
  const cacheDir = resolveCache(opts);
1181
1303
  const k = opts.k || 5;
1304
+ const offline = resolveOffline(opts);
1305
+ const embedderOverrides = await resolveEmbedderOverrides(opts);
1182
1306
 
1183
1307
  const vectorsPath = path.join(indexDir, 'vectors.json');
1184
1308
  if (!fs.existsSync(vectorsPath)) {
1185
1309
  die(`No index at ${vectorsPath}. Run \`mdss index --db ${db}\` first.`);
1186
1310
  }
1187
1311
 
1312
+ if (opts.llmEndpoint) warnDataEgress('--llm-endpoint', opts.llmEndpoint);
1188
1313
  const { chatTurn, loadChatSession, listChatSessions } = await import('../dist/rag.js');
1189
1314
  const loaded = loadIndex(indexDir);
1190
1315
 
@@ -1206,6 +1331,7 @@ async function cmdChat(opts) {
1206
1331
  const query = opts._.join(' ').trim();
1207
1332
  // If one-shot query provided as argument:
1208
1333
  if (query) {
1334
+ let streamedChars = 0;
1209
1335
  const res = await chatTurn({
1210
1336
  session,
1211
1337
  query,
@@ -1213,8 +1339,12 @@ async function cmdChat(opts) {
1213
1339
  indexDir,
1214
1340
  cacheDir,
1215
1341
  k,
1342
+ offline,
1343
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1216
1344
  llmEndpoint: opts.llmEndpoint,
1217
1345
  llmModel: opts.llmModel,
1346
+ llmProvider: opts.llmProvider,
1347
+ ...(opts.json ? {} : { onDelta: (d) => { streamedChars += d.length; process.stdout.write(d); } }),
1218
1348
  systemPrompt: opts.systemPrompt,
1219
1349
  });
1220
1350
 
@@ -1223,7 +1353,7 @@ async function cmdChat(opts) {
1223
1353
  return;
1224
1354
  }
1225
1355
 
1226
- process.stdout.write(`\n${res.answer}\n\n`);
1356
+ finishAnswerRender(streamedChars, res);
1227
1357
  if (res.citations && res.citations.length > 0) {
1228
1358
  process.stdout.write('Sources:\n');
1229
1359
  for (const c of res.citations) {
@@ -1287,6 +1417,7 @@ async function cmdChat(opts) {
1287
1417
  }
1288
1418
 
1289
1419
  try {
1420
+ let streamedChars = 0;
1290
1421
  const res = await chatTurn({
1291
1422
  session,
1292
1423
  query: line,
@@ -1294,12 +1425,18 @@ async function cmdChat(opts) {
1294
1425
  indexDir,
1295
1426
  cacheDir,
1296
1427
  k,
1428
+ offline,
1429
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1297
1430
  llmEndpoint: opts.llmEndpoint,
1298
1431
  llmModel: opts.llmModel,
1432
+ llmProvider: opts.llmProvider,
1433
+ // issue #199: REPL deltas stream as they arrive; session/history
1434
+ // persistence still happens on the complete answer only.
1435
+ onDelta: (d) => { streamedChars += d.length; process.stdout.write(d); },
1299
1436
  systemPrompt: opts.systemPrompt,
1300
1437
  });
1301
1438
 
1302
- process.stdout.write(`\n${res.answer}\n`);
1439
+ finishAnswerRender(streamedChars, res, '\n');
1303
1440
  if (res.citations && res.citations.length > 0) {
1304
1441
  process.stdout.write('\nSources:\n');
1305
1442
  for (const c of res.citations) {
@@ -1324,12 +1461,18 @@ async function cmdSearch(opts) {
1324
1461
  const query = opts._.join(' ').trim();
1325
1462
  if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
1326
1463
 
1464
+ // issue #203: multi-query paraphrases and HyDE both send note-derived text
1465
+ // through the LLM endpoint — same one-time disclosure as ask/chat
1466
+ if ((opts.multiQuery || opts.expand === 'hyde') && opts.llmEndpoint) {
1467
+ warnDataEgress('--llm-endpoint', opts.llmEndpoint);
1468
+ }
1469
+
1327
1470
  const embedderOverrides = await resolveEmbedderOverrides(opts);
1328
1471
  let results;
1329
1472
  const isFederated = Array.isArray(opts.vaults) && opts.vaults.length > 0;
1330
1473
 
1331
1474
  if (isFederated) {
1332
- results = await searchFederated({
1475
+ const fed = await searchFederated({
1333
1476
  ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1334
1477
  vaults: opts.vaults,
1335
1478
  cacheDir,
@@ -1344,6 +1487,9 @@ async function cmdSearch(opts) {
1344
1487
  path: opts.path.length > 0 ? opts.path : undefined,
1345
1488
  since: opts.since,
1346
1489
  rerank: !!opts.rerank,
1490
+ // issue #203: multi-query expansion + reranker registry alias
1491
+ ...(opts.multiQuery ? { multiQuery: opts.multiQuery, llmEndpoint: opts.llmEndpoint, ...(opts.llmModel ? { llmModel: opts.llmModel } : {}) } : {}),
1492
+ ...(opts.rerankModel ? { rerankModel: opts.rerankModel } : {}),
1347
1493
  tag: opts.tag,
1348
1494
  project: opts.project,
1349
1495
  type: opts.type,
@@ -1356,6 +1502,9 @@ async function cmdSearch(opts) {
1356
1502
  graphBoost: opts.graphBoost,
1357
1503
  filter: opts.filter,
1358
1504
  });
1505
+ // issue #189: a broken vault must not look like "no results"
1506
+ for (const ve of fed.vaultErrors) process.stderr.write(`warning: ${ve}\n`);
1507
+ results = fed.hits;
1359
1508
  } else {
1360
1509
  const db = resolveDb(opts);
1361
1510
  const indexDir = resolveIndexDir(opts, db);
@@ -1374,6 +1523,9 @@ async function cmdSearch(opts) {
1374
1523
  path: opts.path.length > 0 ? opts.path : undefined,
1375
1524
  since: opts.since,
1376
1525
  rerank: !!opts.rerank,
1526
+ // issue #203: multi-query expansion + reranker registry alias
1527
+ ...(opts.multiQuery ? { multiQuery: opts.multiQuery, llmEndpoint: opts.llmEndpoint, ...(opts.llmModel ? { llmModel: opts.llmModel } : {}) } : {}),
1528
+ ...(opts.rerankModel ? { rerankModel: opts.rerankModel } : {}),
1377
1529
  tag: opts.tag,
1378
1530
  project: opts.project,
1379
1531
  type: opts.type,
@@ -1518,12 +1670,23 @@ async function cmdServe(opts) {
1518
1670
  // Refuse to serve the whole knowledge base to the network unauthenticated (issue #121).
1519
1671
  validateBindSecurity({ host, hasApiKey: !!apiKey, allowUnsecured: !!opts.allowUnsecured });
1520
1672
 
1673
+ // POST /ask egress (issue #199): the daemon forwards queries to the chat
1674
+ // endpoint — same disclosure gate as the CLI ask/chat paths.
1675
+ if (opts.llmEndpoint) warnDataEgress('--llm-endpoint', opts.llmEndpoint);
1676
+
1521
1677
  const { server, state, close } = await createServe({
1522
1678
  db, indexDir, cacheDir,
1523
1679
  modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
1524
1680
  ignore: opts.ignore,
1525
1681
  offline: resolveOffline(opts),
1526
1682
  ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1683
+ // issue #202: token-budget chunking for serve's initial + watch rebuilds.
1684
+ ...(opts.maxChunkTokens !== undefined ? { maxChunkTokens: opts.maxChunkTokens } : {}),
1685
+ // issue #199: chat endpoint for POST /ask SSE synthesis.
1686
+ ...(opts.llmEndpoint ? { llmEndpoint: opts.llmEndpoint } : {}),
1687
+ ...(opts.llmModel ? { llmModel: opts.llmModel } : {}),
1688
+ ...(opts.llmProvider ? { llmProvider: opts.llmProvider } : {}),
1689
+ ...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}),
1527
1690
  watch: !!opts.watch,
1528
1691
  watchInterval: opts.watchInterval,
1529
1692
  watchDelay: opts.watchDelay,
@@ -1548,6 +1711,25 @@ async function cmdServe(opts) {
1548
1711
  log,
1549
1712
  });
1550
1713
 
1714
+ // issue #186: the most common serve failure (EADDRINUSE) used to surface as
1715
+ // a raw stack trace — name the cause and the fix instead.
1716
+ server.on('error', (err) => {
1717
+ if (err?.code === 'EADDRINUSE') {
1718
+ process.stderr.write(
1719
+ `mdss serve: port ${port} is already in use — another daemon (or program) is listening on it.\n` +
1720
+ ` Fix: pick another port, e.g. mdss serve --port ${port + 1}\n`
1721
+ );
1722
+ } else if (err?.code === 'EACCES') {
1723
+ process.stderr.write(
1724
+ `mdss serve: no permission to bind ${host}:${port}.\n` +
1725
+ ` Fix: use an unprivileged port (1024+), e.g. mdss serve --port 8747\n`
1726
+ );
1727
+ } else {
1728
+ process.stderr.write(`mdss serve: ${err?.message || err}\n`);
1729
+ }
1730
+ process.exit(1);
1731
+ });
1732
+
1551
1733
  server.listen(port, host, () => {
1552
1734
  const idx = state.loaded.index;
1553
1735
  log(`mdss serve listening on http://${host}:${port}` +
@@ -1555,6 +1737,7 @@ async function cmdServe(opts) {
1555
1737
  log(` index: ${idx.chunks.length} chunks · model ${state.loaded.model.id}` +
1556
1738
  (state.watching ? ' · watching for changes' : ''));
1557
1739
  log(` POST /search {"query": "...", "k": 6}`);
1740
+ if (state.llmEndpoint) log(` POST /ask {"query": "...", "k": 5} — SSE answer streams via ${state.llmEndpoint}`);
1558
1741
  log(` GET /health`);
1559
1742
  if (!isLoopbackHost(host) && !state.apiKey) {
1560
1743
  log('');
@@ -1569,11 +1752,17 @@ async function cmdServe(opts) {
1569
1752
 
1570
1753
  const stop = async (sig) => {
1571
1754
  log(`\n${sig} — shutting down.`);
1572
- await close();
1755
+ try {
1756
+ await close();
1757
+ } catch {
1758
+ // issue #186: a second signal (or a listen error) can make close()
1759
+ // reject with ERR_SERVER_NOT_RUNNING — always exit, never hang
1760
+ }
1573
1761
  process.exit(0);
1574
1762
  };
1575
1763
  process.on('SIGINT', () => stop('SIGINT'));
1576
1764
  process.on('SIGTERM', () => stop('SIGTERM'));
1765
+ if (process.platform === 'win32') process.on('SIGBREAK', () => stop('SIGBREAK'));
1577
1766
  }
1578
1767
 
1579
1768
  async function cmdMcp(opts) {
@@ -116,7 +116,16 @@ export function readBinaryHeader(buffer) {
116
116
  throw new Error(`Invalid binary index magic: expected "${BINARY_MAGIC}", got "${magic}"`);
117
117
  }
118
118
  const version = buffer.readUInt32LE(8);
119
+ // issue #189: version was parsed but never checked — a future-format file
120
+ // would silently decode as garbage vectors
121
+ if (version !== BINARY_VERSION) {
122
+ throw new Error(`Invalid binary index: unsupported version ${version} (expected ${BINARY_VERSION}) — run \`mdss index\` to rebuild`);
123
+ }
119
124
  const flags = buffer.readUInt32LE(12);
125
+ // issue #189: unknown flag bits silently decoded as fp32 garbage
126
+ if (flags !== BINARY_FLAG_FP32 && flags !== BINARY_FLAG_INT8) {
127
+ throw new Error(`Invalid binary index: unknown flags 0x${flags.toString(16)} (expected 0 or 1) — run \`mdss index\` to rebuild`);
128
+ }
120
129
  const dim = buffer.readUInt32LE(16);
121
130
  const chunkCount = buffer.readUInt32LE(20);
122
131
  const vectorsOffset = Number(buffer.readBigUInt64LE(24));
@@ -32,9 +32,12 @@ export const FLAGS = [
32
32
  { name: '--embedder-key-file', kind: 'file' },
33
33
  { name: '--expand', kind: 'value' },
34
34
  { name: '--expand-passages', kind: 'number' },
35
+ { name: '--multi-query', kind: 'number' },
36
+ { name: '--rerank-model', kind: 'value' },
35
37
  { name: '--health-public', kind: 'flag' },
36
38
  { name: '--allow-unsecured', kind: 'flag' },
37
39
  { name: '--ann', kind: 'flag' },
40
+ { name: '--ann', kind: 'value' },
38
41
  { name: '--canonical', kind: 'flag' },
39
42
  { name: '--rag', kind: 'flag' },
40
43
  { name: '--auto-tag', kind: 'flag' },
@@ -46,6 +49,7 @@ export const FLAGS = [
46
49
  { name: '--dry-run', kind: 'flag' },
47
50
  // values
48
51
  { name: '--workers', kind: 'number' },
52
+ { name: '--max-chunk-tokens', kind: 'number' },
49
53
  { name: '--config', kind: 'file' },
50
54
  { name: '--format', kind: 'value' },
51
55
  { name: '--output', alias: '-o', kind: 'file' },
@@ -86,6 +90,7 @@ export const FLAGS = [
86
90
  { name: '--resume', kind: 'flag' },
87
91
  { name: '--llm-endpoint', kind: 'value' },
88
92
  { name: '--llm-model', kind: 'value' },
93
+ { name: '--llm-provider', kind: 'value' },
89
94
  { name: '--system-prompt', kind: 'value' },
90
95
  ];
91
96
  export const COMMANDS = [
package/dist/core.d.ts CHANGED
@@ -23,7 +23,9 @@ export type LockAcquireResult = {
23
23
  pid: number | null;
24
24
  heldSince: string | null;
25
25
  };
26
- export declare function acquireIndexLock(indexDir: string): LockAcquireResult;
26
+ export declare function acquireIndexLock(indexDir: string, _testHooks?: {
27
+ onClassified?: () => void;
28
+ }): LockAcquireResult;
27
29
  export declare function releaseIndexLock(indexDir: string): void;
28
30
  export declare function withIndexLock<T>(indexDir: string, fn: () => Promise<T>): Promise<T>;
29
31
  export type ModelDescriptor = ModelAdapter;
@@ -31,6 +33,14 @@ export declare function getPipelineModelSource(model: ModelDescriptor, cacheDir:
31
33
  export declare function resolveSessionOptions(model?: ModelDescriptor): Record<string, unknown> | undefined;
32
34
  export declare function getExtractorCacheKey(model: ModelDescriptor, cacheDir: string, offline?: boolean): string;
33
35
  export declare function isNetworkError(err: any): boolean;
36
+ /**
37
+ * Outbound fetch timeout (issue #184): undici's default ~300s lets one hung
38
+ * embedder/LLM endpoint hold a serve concurrency slot or stall `mdss index`
39
+ * for minutes. All outbound calls carry `AbortSignal.timeout(fetchTimeoutMs())`;
40
+ * override with `MDSS_FETCH_TIMEOUT_MS` (0/negative falls back to the default).
41
+ */
42
+ export declare const DEFAULT_FETCH_TIMEOUT_MS = 30000;
43
+ export declare function fetchTimeoutMs(envValue?: string): number;
34
44
  export declare function retryWithBackoff<T>(fn: () => Promise<T>, opts?: {
35
45
  maxRetries?: number;
36
46
  delays?: number[];
@@ -68,7 +78,7 @@ export declare function migrateToSchemaV2(index: {
68
78
  }): void;
69
79
  export declare function migrateToSchemaV3(): void;
70
80
  export declare const SCHEMA_MIGRATIONS: Record<number, (index: any) => void>;
71
- export declare function walkMarkdown(dir: string, ignore?: string[]): string[];
81
+ export declare function walkMarkdown(dir: string, ignore?: string[], errors?: string[]): string[];
72
82
  export declare function assertSafePath(targetPath: string, allowedRoots?: string[]): string;
73
83
  export declare function validateGlob(glob: string): string;
74
84
  export declare function globToRegExp(glob: string): RegExp;
@@ -77,7 +87,7 @@ export declare function splitFrontmatter(raw: string): {
77
87
  body: string;
78
88
  };
79
89
  export declare function extractTitle(frontmatter: string, body: string, relPath: string): string;
80
- export declare function chunkMarkdown(body: string, maxChunk?: number): StructuralChunk[];
90
+ export declare function chunkMarkdown(body: string, maxChunk?: number, maxChunkTokens?: number): StructuralChunk[];
81
91
  export interface ParsedChunkFile {
82
92
  file: string;
83
93
  title: string;
@@ -88,7 +98,9 @@ export interface ParsedChunkFile {
88
98
  endLine: number;
89
99
  meta: DocumentMetadata;
90
100
  }
91
- export declare function parseFile(absPath: string, dbDir: string, maxChunk?: number, raw?: string): ParsedChunkFile[];
101
+ export declare function parseFile(absPath: string, dbDir: string, maxChunk?: number, raw?: string,
102
+ /** issue #202: token budget overrides the char budget when set. */
103
+ maxChunkTokens?: number): ParsedChunkFile[];
92
104
  export interface GetDocLinesOptions {
93
105
  file: string;
94
106
  fromLine?: number;