md-semantic-search 1.0.3 → 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 (75) hide show
  1. package/README.md +132 -16
  2. package/RESEARCH.md +207 -3
  3. package/bin/cli.mjs +449 -13
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +11 -1
  6. package/dist/core.d.ts +42 -4
  7. package/dist/core.js +208 -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/index-format.js +8 -1
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/indexer.d.ts +18 -1
  18. package/dist/indexer.js +92 -18
  19. package/dist/ivf.d.ts +14 -7
  20. package/dist/ivf.js +126 -18
  21. package/dist/lexical.d.ts +10 -0
  22. package/dist/lexical.js +98 -15
  23. package/dist/markdown-parser.d.ts +62 -0
  24. package/dist/markdown-parser.js +227 -30
  25. package/dist/mcp.d.ts +229 -7
  26. package/dist/mcp.js +743 -44
  27. package/dist/models.d.ts +8 -2
  28. package/dist/models.js +84 -32
  29. package/dist/providers.d.ts +56 -0
  30. package/dist/providers.js +305 -0
  31. package/dist/quantization.d.ts +12 -0
  32. package/dist/quantization.js +54 -0
  33. package/dist/rag.d.ts +132 -7
  34. package/dist/rag.js +314 -38
  35. package/dist/repair.d.ts +1 -1
  36. package/dist/repair.js +53 -2
  37. package/dist/rerank.d.ts +51 -6
  38. package/dist/rerank.js +69 -10
  39. package/dist/search.d.ts +49 -3
  40. package/dist/search.js +387 -78
  41. package/dist/serve.d.ts +35 -0
  42. package/dist/serve.js +574 -58
  43. package/dist/tui.d.ts +14 -0
  44. package/dist/tui.js +132 -15
  45. package/dist/watcher.js +23 -12
  46. package/dist/webui.d.ts +3 -3
  47. package/dist/webui.js +225 -5
  48. package/dist/wikilinks.d.ts +31 -0
  49. package/dist/wikilinks.js +185 -1
  50. package/package.json +3 -3
  51. package/src/binary-format.ts +13 -0
  52. package/src/completions.ts +11 -1
  53. package/src/core.ts +249 -13
  54. package/src/federation.ts +51 -17
  55. package/src/filter.ts +14 -0
  56. package/src/hnsw.ts +414 -0
  57. package/src/index-format.ts +7 -1
  58. package/src/index.ts +7 -2
  59. package/src/indexer.ts +112 -19
  60. package/src/ivf.ts +157 -20
  61. package/src/lexical.ts +91 -13
  62. package/src/markdown-parser.ts +279 -37
  63. package/src/mcp.ts +820 -47
  64. package/src/models.ts +97 -35
  65. package/src/providers.ts +345 -0
  66. package/src/quantization.ts +61 -0
  67. package/src/rag.ts +451 -34
  68. package/src/repair.ts +51 -2
  69. package/src/rerank.ts +121 -14
  70. package/src/search.ts +424 -82
  71. package/src/serve.ts +589 -66
  72. package/src/tui.ts +142 -16
  73. package/src/watcher.ts +21 -10
  74. package/src/webui.ts +225 -5
  75. package/src/wikilinks.ts +220 -1
package/bin/cli.mjs CHANGED
@@ -16,11 +16,12 @@ import fs from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import crypto from 'node:crypto';
18
18
  import { buildIndex } from '../dist/indexer.js';
19
- import { search } from '../dist/search.js';
19
+ import { loadIndex, search } from '../dist/search.js';
20
20
  import { searchFederated } from '../dist/federation.js';
21
21
  import { createServe, DEFAULT_PORT, DEFAULT_HOST, isLoopbackHost, validateBindSecurity, loadApiKeyFile } from '../dist/serve.js';
22
22
  import { MODELS, DEFAULT_MODEL, resolveModel } from '../dist/models.js';
23
23
  import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath, setDownloadProgressListener } from '../dist/core.js';
24
+ import { findRelatedNotes } from '../dist/wikilinks.js';
24
25
  import { DownloadProgressAggregator } from '../dist/download-progress.js';
25
26
  import { generateCompletion, COMPLETION_SHELLS } from '../dist/completions.js';
26
27
  import { openHit, resolveOpenCommand } from '../dist/open.js';
@@ -93,6 +94,7 @@ function parseArgs(argv) {
93
94
  else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
94
95
  else if (a === '--rate-limit') opts.rateLimit = nextNonNegInt(argv, ++i, a);
95
96
  else if (a === '--max-concurrency') opts.maxConcurrency = nextNonNegInt(argv, ++i, a);
97
+ else if (a === '--dimensions' || a === '--dim') opts.dimensions = nextInt(argv, ++i, a);
96
98
  else if (a === '--since') opts.since = nextValue(argv, ++i, a);
97
99
  else if (a === '--path') opts.path.push(nextValue(argv, ++i, a));
98
100
  else if (a === '--ignore') opts.ignore.push(nextValue(argv, ++i, a));
@@ -100,7 +102,16 @@ function parseArgs(argv) {
100
102
  const v = nextValue(argv, ++i, a);
101
103
  opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
102
104
  }
103
- 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
+ }
104
115
  else if (a === '--nprobe') opts.nprobe = nextInt(argv, ++i, a);
105
116
  else if (a === '--project') opts.project = nextValue(argv, ++i, a);
106
117
  else if (a === '--type') opts.type = nextValue(argv, ++i, a);
@@ -108,13 +119,31 @@ function parseArgs(argv) {
108
119
  else if (a === '--canonical') opts.canonical = true;
109
120
  else if (a === '--graph-boost') opts.graphBoost = nextFloat(argv, ++i, a);
110
121
  else if (a === '--filter') opts.filter = nextValue(argv, ++i, a);
122
+ else if (a === '--direction') opts.direction = nextValue(argv, ++i, a);
123
+ else if (a === '--no-semantic') opts.noSemantic = true;
111
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);
112
135
  else if (a === '--rag') opts.rag = true;
113
136
  else if (a === '--auto-tag') opts.autoTag = true;
114
137
  else if (a === '--auto-summarize') opts.autoSummarize = true;
115
138
  else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
116
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
+ }
117
144
  else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
145
+ else if (a === '--session') opts.session = nextValue(argv, ++i, a);
146
+ else if (a === '--resume') opts.resume = true;
118
147
  else if (a === '--open') {
119
148
  // `--open` opens the top hit; `--open N` opens the Nth hit (issue #110).
120
149
  const next = argv[i + 1];
@@ -202,9 +231,13 @@ const KNOWN_CONFIG_KEYS = new Set([
202
231
  'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
203
232
  'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
204
233
  'watchDebug', 'watch-debug',
205
- 'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
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',
206
237
  'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
207
238
  'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt',
239
+ 'llmProvider', 'llm-provider',
240
+ 'session', 'resume',
208
241
  'completions', 'open', 'dry-run', 'fix', 'yes', 'no-ui', 'noUi',
209
242
  'noQueryCache', 'no-query-cache',
210
243
  'mcp',
@@ -213,6 +246,27 @@ const KNOWN_CONFIG_KEYS = new Set([
213
246
  'expand', 'expandPassages', 'expand-passages', 'llm-endpoint', 'llmEndpoint'
214
247
  ]);
215
248
 
249
+ const CONFIG_NETWORK_KEYS = new Set([
250
+ 'port', 'host', 'apiKey', 'api-key', 'apiKeyFile', 'api-key-file',
251
+ 'allowUnsecured', 'allow-unsecured', 'allowedHost', 'allowed-host',
252
+ 'allowedHosts', 'allowed-hosts', 'corsOrigin', 'cors-origin',
253
+ 'rateLimit', 'rate-limit', 'maxConcurrency', 'max-concurrency',
254
+ 'healthPublic', 'health-public', 'mcp', 'llmEndpoint', 'llm-endpoint',
255
+ 'llmModel', 'llm-model', 'llmProvider', 'llm-provider', 'embedder', 'embedder-model', 'embedder-base-url',
256
+ 'embedder-key-file', 'embedderModel', 'embedderBaseUrl', 'embedderKeyFile'
257
+ ]);
258
+
259
+ const CONFIG_EXEC_KEYS = new Set([
260
+ // No execution surfaces permitted in local repo config
261
+ ]);
262
+
263
+ function getConfigKeyTrustTier(key) {
264
+ if (CONFIG_EXEC_KEYS.has(key)) return 'exec';
265
+ if (CONFIG_NETWORK_KEYS.has(key)) return 'network';
266
+ if (KNOWN_CONFIG_KEYS.has(key)) return 'local-safe';
267
+ return 'unknown';
268
+ }
269
+
216
270
  function findConfigFile(explicitPath) {
217
271
  if (explicitPath) {
218
272
  const abs = path.resolve(explicitPath);
@@ -252,22 +306,26 @@ function findConfigFile(explicitPath) {
252
306
  }
253
307
 
254
308
  function loadConfigFile(configPath) {
255
- if (!configPath) return { config: {}, path: null, unknownKeys: [], error: null };
309
+ if (!configPath) return { config: {}, path: null, unknownKeys: [], trustTiers: {}, error: null };
256
310
  try {
257
311
  const raw = fs.readFileSync(configPath, 'utf8');
258
312
  const parsed = JSON.parse(raw);
259
313
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
260
- return { config: {}, path: configPath, unknownKeys: [], error: `Config at ${configPath} must be a JSON object` };
314
+ return { config: {}, path: configPath, unknownKeys: [], trustTiers: {}, error: `Config at ${configPath} must be a JSON object` };
261
315
  }
262
316
  const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_CONFIG_KEYS.has(k));
317
+ const trustTiers = {};
318
+ for (const k of Object.keys(parsed)) {
319
+ trustTiers[k] = getConfigKeyTrustTier(k);
320
+ }
263
321
  const normalized = {};
264
322
  for (const [k, v] of Object.entries(parsed)) {
265
323
  const camel = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
266
324
  normalized[camel] = v;
267
325
  }
268
- return { config: normalized, path: configPath, unknownKeys, error: null };
326
+ return { config: normalized, path: configPath, unknownKeys, trustTiers, error: null };
269
327
  } catch (e) {
270
- return { config: {}, path: configPath, unknownKeys: [], error: `Failed to parse config at ${configPath}: ${e.message}` };
328
+ return { config: {}, path: configPath, unknownKeys: [], trustTiers: {}, error: `Failed to parse config at ${configPath}: ${e.message}` };
271
329
  }
272
330
  }
273
331
 
@@ -328,6 +386,9 @@ Usage:
328
386
  mdss check --db <dir> [--json] Diagnose index/db/model cache (alias: doctor)
329
387
  mdss export --db <dir> [options] Export index to JSONL / CSV / Parquet
330
388
  mdss search --db <dir> [options] "query" Search by meaning
389
+ mdss related --db <dir> [options] <note> Find related notes via graph links and similarity
390
+ mdss ask --db <dir> [options] "question" Grounded QA synthesis with citations
391
+ mdss chat --db <dir> [options] Multi-turn persistent chat with citation manifest
331
392
  mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
332
393
  mdss mcp --db <dir> [--list-tools] Start MCP server over stdio for LLM agents / IDEs
333
394
  mdss models List available models
@@ -339,7 +400,13 @@ Options:
339
400
  --index-dir <dir> Where to store the index (default: <db>/.mdss).
340
401
  --cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
341
402
  --model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
403
+ --dimensions <n> Truncate vector dimensions for Matryoshka/MRL models (alias: --dim).
342
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).
343
410
  --format <fmt> Export format: jsonl (default), csv, parquet (export).
344
411
  --no-vectors Omit vector embeddings from export (export).
345
412
  --output <file> Output file path (export, default: stdout).
@@ -350,9 +417,21 @@ Options:
350
417
  --json Machine-readable output (index, stats, search).
351
418
  --semantic Pure vector ranking, skip lexical/RRF fusion (search).
352
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.
353
428
  --graph-boost <n> Boost weight for graph PageRank / Obsidian wikilinks (search, default: 0).
354
429
  --filter <expr> Rich boolean filter across frontmatter tags and properties (search).
355
- --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.
356
435
  --nprobe <n> Number of centroids to probe for ANN (search, default: 8).
357
436
  --port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
358
437
  --host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
@@ -408,6 +487,13 @@ Options:
408
487
  ONE embed total; issue #125) or "hyde" (LLM-generated
409
488
  passage via --llm-endpoint; degrades silently without it).
410
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.
411
497
  --open [N] search: open the top (or Nth) hit in your editor at its
412
498
  startLine — MDSS_EDITOR/VISUAL/EDITOR, then VS Code
413
499
  --goto, then the GUI opener (issue #110). With --json on
@@ -426,6 +512,9 @@ Examples:
426
512
  mdss search --db ./docs "incident runbook" --path "docs/**" --since 2026-01-01
427
513
  mdss serve --db ./docs --port 8747 --watch
428
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
429
518
  `;
430
519
 
431
520
  function formatDuration(sec) {
@@ -456,6 +545,9 @@ async function resolveEmbedderOverrides(opts) {
456
545
  baseUrl: opts.embedderBaseUrl,
457
546
  keyFile: opts.embedderKeyFile,
458
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);
459
551
  const descriptor = await emb.descriptor();
460
552
  logErr(`embedder: ${emb.id}/${emb.model} (dim ${descriptor.dim} via ${opts.embedderBaseUrl || 'default endpoint'})`);
461
553
  return { modelName: descriptor, embedFn: emb.embedFn };
@@ -465,6 +557,36 @@ function logErr(s) {
465
557
  process.stderr.write(s + '\n');
466
558
  }
467
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
+
468
590
  async function cmdIndex(opts) {
469
591
  const db = resolveDb(opts);
470
592
  const indexDir = resolveIndexDir(opts, db);
@@ -499,12 +621,15 @@ async function cmdIndex(opts) {
499
621
  r = await buildIndex({
500
622
  db, indexDir, cacheDir,
501
623
  modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
624
+ dimensions: opts.dimensions,
502
625
  ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
503
626
  ignore: opts.ignore,
504
627
  offline: resolveOffline(opts),
505
628
  workers: opts.workers,
506
629
  ann: opts.ann,
507
630
  quantize: opts.quantize,
631
+ // issue #202: token-budget chunking — default min(adapter.maxTokens, 512)
632
+ ...(opts.maxChunkTokens !== undefined ? { maxChunkTokens: opts.maxChunkTokens } : {}),
508
633
  log: s => {
509
634
  if (process.stderr.isTTY && !opts.json && /^\s+\d+\/\d+$/.test(s)) return;
510
635
  process.stderr.write(s + '\n');
@@ -775,6 +900,7 @@ function checkHealth({ db, indexDir, cacheDir, requireOffline = false, config =
775
900
  path: config.path,
776
901
  valid: !config.error && (!config.unknownKeys || config.unknownKeys.length === 0),
777
902
  unknownKeys: config.unknownKeys || [],
903
+ trustTiers: config.trustTiers || {},
778
904
  error: config.error || (config.unknownKeys && config.unknownKeys.length > 0 ? `unknown config keys: ${config.unknownKeys.join(', ')}` : null),
779
905
  };
780
906
  if (!report.config.valid) {
@@ -1088,11 +1214,42 @@ async function cmdTui(opts) {
1088
1214
  semanticOnly: !!opts.semantic,
1089
1215
  offline: resolveOffline(opts),
1090
1216
  path: opts.path.length > 0 ? opts.path : undefined,
1217
+ since: opts.since || undefined,
1218
+ filter: opts.filter || undefined,
1219
+ tag: opts.tag.length > 0 ? opts.tag : undefined,
1220
+ project: opts.project || undefined,
1221
+ type: opts.type || undefined,
1222
+ status: opts.status || undefined,
1223
+ graphBoost: typeof opts.graphBoost === 'number' ? opts.graphBoost : undefined,
1091
1224
  rerank: !!opts.rerank,
1225
+ ...(opts.multiQuery ? { multiQuery: opts.multiQuery, llmEndpoint: opts.llmEndpoint, ...(opts.llmModel ? { llmModel: opts.llmModel } : {}) } : {}),
1226
+ ...(opts.rerankModel ? { rerankModel: opts.rerankModel } : {}),
1092
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,
1093
1234
  });
1094
1235
  }
1095
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
+
1096
1253
  async function cmdAsk(opts) {
1097
1254
  const query = opts._.join(' ').trim();
1098
1255
  if (!query) die('missing question to ask. Usage: mdss ask [--db <dir>] "question"');
@@ -1101,15 +1258,25 @@ async function cmdAsk(opts) {
1101
1258
  const indexDir = resolveIndexDir(opts, db);
1102
1259
  const cacheDir = resolveCache(opts);
1103
1260
  const k = opts.k || 5;
1261
+ const offline = resolveOffline(opts);
1262
+ const embedderOverrides = await resolveEmbedderOverrides(opts);
1104
1263
 
1264
+ if (opts.llmEndpoint) warnDataEgress('--llm-endpoint', opts.llmEndpoint);
1105
1265
  const { askQuestion } = await import('../dist/rag.js');
1266
+ let streamedChars = 0;
1106
1267
  const result = await askQuestion({
1107
1268
  query,
1108
1269
  indexDir,
1109
1270
  cacheDir,
1110
1271
  k,
1272
+ offline,
1273
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1111
1274
  llmEndpoint: opts.llmEndpoint,
1112
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); } }),
1113
1280
  systemPrompt: opts.systemPrompt,
1114
1281
  });
1115
1282
 
@@ -1118,7 +1285,7 @@ async function cmdAsk(opts) {
1118
1285
  return;
1119
1286
  }
1120
1287
 
1121
- process.stdout.write(`\n${result.answer}\n\n`);
1288
+ finishAnswerRender(streamedChars, result);
1122
1289
  if (result.citations && result.citations.length > 0) {
1123
1290
  process.stdout.write('Sources:\n');
1124
1291
  for (const c of result.citations) {
@@ -1129,18 +1296,183 @@ async function cmdAsk(opts) {
1129
1296
  }
1130
1297
  }
1131
1298
 
1299
+ async function cmdChat(opts) {
1300
+ const db = resolveDb(opts);
1301
+ const indexDir = resolveIndexDir(opts, db);
1302
+ const cacheDir = resolveCache(opts);
1303
+ const k = opts.k || 5;
1304
+ const offline = resolveOffline(opts);
1305
+ const embedderOverrides = await resolveEmbedderOverrides(opts);
1306
+
1307
+ const vectorsPath = path.join(indexDir, 'vectors.json');
1308
+ if (!fs.existsSync(vectorsPath)) {
1309
+ die(`No index at ${vectorsPath}. Run \`mdss index --db ${db}\` first.`);
1310
+ }
1311
+
1312
+ if (opts.llmEndpoint) warnDataEgress('--llm-endpoint', opts.llmEndpoint);
1313
+ const { chatTurn, loadChatSession, listChatSessions } = await import('../dist/rag.js');
1314
+ const loaded = loadIndex(indexDir);
1315
+
1316
+ let sessionId = opts.session;
1317
+ if (!sessionId && opts.resume) {
1318
+ const existing = listChatSessions(indexDir);
1319
+ if (existing.length > 0) {
1320
+ sessionId = existing[0].id;
1321
+ }
1322
+ }
1323
+ if (!sessionId) {
1324
+ sessionId = crypto.randomUUID().slice(0, 8);
1325
+ }
1326
+
1327
+ const session = loadChatSession(indexDir, sessionId, (msg) => {
1328
+ if (!opts.json) process.stderr.write(`${msg}\n`);
1329
+ });
1330
+
1331
+ const query = opts._.join(' ').trim();
1332
+ // If one-shot query provided as argument:
1333
+ if (query) {
1334
+ let streamedChars = 0;
1335
+ const res = await chatTurn({
1336
+ session,
1337
+ query,
1338
+ loaded,
1339
+ indexDir,
1340
+ cacheDir,
1341
+ k,
1342
+ offline,
1343
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1344
+ llmEndpoint: opts.llmEndpoint,
1345
+ llmModel: opts.llmModel,
1346
+ llmProvider: opts.llmProvider,
1347
+ ...(opts.json ? {} : { onDelta: (d) => { streamedChars += d.length; process.stdout.write(d); } }),
1348
+ systemPrompt: opts.systemPrompt,
1349
+ });
1350
+
1351
+ if (opts.json) {
1352
+ process.stdout.write(JSON.stringify(res, null, 2) + '\n');
1353
+ return;
1354
+ }
1355
+
1356
+ finishAnswerRender(streamedChars, res);
1357
+ if (res.citations && res.citations.length > 0) {
1358
+ process.stdout.write('Sources:\n');
1359
+ for (const c of res.citations) {
1360
+ const heading = c.heading ? ` › ${c.heading}` : '';
1361
+ process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
1362
+ }
1363
+ process.stdout.write('\n');
1364
+ }
1365
+ return;
1366
+ }
1367
+
1368
+ // Interactive REPL mode:
1369
+ const readline = await import('node:readline');
1370
+ const rl = readline.createInterface({
1371
+ input: process.stdin,
1372
+ output: process.stdout,
1373
+ });
1374
+
1375
+ const banner = `\x1b[1mmdss chat\x1b[0m (session: \x1b[36m${session.id}\x1b[0m, turns: ${session.turns.length}, chunks: ${loaded.index.chunks.length})\nCommands: /session, /sources, /help, /exit\n`;
1376
+ process.stdout.write(banner);
1377
+
1378
+ const promptUser = () => {
1379
+ rl.question('\n\x1b[1mmdss chat>\x1b[0m ', async (input) => {
1380
+ const line = (input || '').trim();
1381
+ if (!line) {
1382
+ promptUser();
1383
+ return;
1384
+ }
1385
+
1386
+ if (line === '/exit' || line === '/quit' || line === ':q') {
1387
+ rl.close();
1388
+ return;
1389
+ }
1390
+
1391
+ if (line === '/help') {
1392
+ process.stdout.write('Available commands:\n /session Show current session information\n /sources Show retrieved sources manifest from last turn\n /help Show this help message\n /exit Quit chat\n');
1393
+ promptUser();
1394
+ return;
1395
+ }
1396
+
1397
+ if (line === '/session') {
1398
+ process.stdout.write(`Session ID: ${session.id}\nCreated: ${session.createdAt}\nTurns: ${session.turns.length}\n`);
1399
+ promptUser();
1400
+ return;
1401
+ }
1402
+
1403
+ if (line === '/sources') {
1404
+ const lastTurn = session.turns[session.turns.length - 1];
1405
+ if (!lastTurn || !lastTurn.manifest || lastTurn.manifest.length === 0) {
1406
+ process.stdout.write('No sources available yet.\n');
1407
+ } else {
1408
+ process.stdout.write('Sources from last turn:\n');
1409
+ for (const m of lastTurn.manifest) {
1410
+ const loc = (m.startLine && m.endLine) ? `:L${m.startLine}-L${m.endLine}` : '';
1411
+ const heading = m.heading ? ` › ${m.heading}` : '';
1412
+ process.stdout.write(` - ${m.file}${heading}${loc} [score: ${m.score.toFixed(3)}]\n`);
1413
+ }
1414
+ }
1415
+ promptUser();
1416
+ return;
1417
+ }
1418
+
1419
+ try {
1420
+ let streamedChars = 0;
1421
+ const res = await chatTurn({
1422
+ session,
1423
+ query: line,
1424
+ loaded,
1425
+ indexDir,
1426
+ cacheDir,
1427
+ k,
1428
+ offline,
1429
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1430
+ llmEndpoint: opts.llmEndpoint,
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); },
1436
+ systemPrompt: opts.systemPrompt,
1437
+ });
1438
+
1439
+ finishAnswerRender(streamedChars, res, '\n');
1440
+ if (res.citations && res.citations.length > 0) {
1441
+ process.stdout.write('\nSources:\n');
1442
+ for (const c of res.citations) {
1443
+ const heading = c.heading ? ` › ${c.heading}` : '';
1444
+ process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
1445
+ }
1446
+ }
1447
+ } catch (e) {
1448
+ process.stdout.write(`\nError: ${e.message}\n`);
1449
+ }
1450
+
1451
+ promptUser();
1452
+ });
1453
+ };
1454
+
1455
+ promptUser();
1456
+ }
1457
+
1132
1458
  async function cmdSearch(opts) {
1133
1459
  if (opts.interactive) return cmdTui(opts);
1134
1460
  const cacheDir = resolveCache(opts);
1135
1461
  const query = opts._.join(' ').trim();
1136
1462
  if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
1137
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
+
1138
1470
  const embedderOverrides = await resolveEmbedderOverrides(opts);
1139
1471
  let results;
1140
1472
  const isFederated = Array.isArray(opts.vaults) && opts.vaults.length > 0;
1141
1473
 
1142
1474
  if (isFederated) {
1143
- results = await searchFederated({
1475
+ const fed = await searchFederated({
1144
1476
  ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1145
1477
  vaults: opts.vaults,
1146
1478
  cacheDir,
@@ -1155,6 +1487,9 @@ async function cmdSearch(opts) {
1155
1487
  path: opts.path.length > 0 ? opts.path : undefined,
1156
1488
  since: opts.since,
1157
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 } : {}),
1158
1493
  tag: opts.tag,
1159
1494
  project: opts.project,
1160
1495
  type: opts.type,
@@ -1167,6 +1502,9 @@ async function cmdSearch(opts) {
1167
1502
  graphBoost: opts.graphBoost,
1168
1503
  filter: opts.filter,
1169
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;
1170
1508
  } else {
1171
1509
  const db = resolveDb(opts);
1172
1510
  const indexDir = resolveIndexDir(opts, db);
@@ -1185,6 +1523,9 @@ async function cmdSearch(opts) {
1185
1523
  path: opts.path.length > 0 ? opts.path : undefined,
1186
1524
  since: opts.since,
1187
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 } : {}),
1188
1529
  tag: opts.tag,
1189
1530
  project: opts.project,
1190
1531
  type: opts.type,
@@ -1196,6 +1537,7 @@ async function cmdSearch(opts) {
1196
1537
  nprobe: opts.nprobe,
1197
1538
  graphBoost: opts.graphBoost,
1198
1539
  filter: opts.filter,
1540
+ dimensions: opts.dimensions,
1199
1541
  });
1200
1542
  }
1201
1543
 
@@ -1289,9 +1631,10 @@ function cmdModels() {
1289
1631
  const pooling = m.pooling ?? 'mean';
1290
1632
  const normalized = m.normalize !== false ? 'L2' : 'raw';
1291
1633
  const rev = m.revision ? ` · rev ${m.revision.slice(0, 8)}` : '';
1634
+ const mrl = m.dimensions ? ` · MRL: ${m.dimensions.join(', ')}` : '';
1292
1635
  process.stdout.write(
1293
1636
  ` ${alias}${star}\n` +
1294
- ` ${m.id} · dim ${m.dim}${rev}\n` +
1637
+ ` ${m.id} · dim ${m.dim}${mrl}${rev}\n` +
1295
1638
  ` ${pooling} · ${normalized} · maxTokens ${m.maxTokens ?? '—'} ` +
1296
1639
  (m.family ? `· family ${m.family}\n` : '\n') +
1297
1640
  ` ${m.note}\n\n`,
@@ -1327,12 +1670,23 @@ async function cmdServe(opts) {
1327
1670
  // Refuse to serve the whole knowledge base to the network unauthenticated (issue #121).
1328
1671
  validateBindSecurity({ host, hasApiKey: !!apiKey, allowUnsecured: !!opts.allowUnsecured });
1329
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
+
1330
1677
  const { server, state, close } = await createServe({
1331
1678
  db, indexDir, cacheDir,
1332
1679
  modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
1333
1680
  ignore: opts.ignore,
1334
1681
  offline: resolveOffline(opts),
1335
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 } : {}),
1336
1690
  watch: !!opts.watch,
1337
1691
  watchInterval: opts.watchInterval,
1338
1692
  watchDelay: opts.watchDelay,
@@ -1357,6 +1711,25 @@ async function cmdServe(opts) {
1357
1711
  log,
1358
1712
  });
1359
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
+
1360
1733
  server.listen(port, host, () => {
1361
1734
  const idx = state.loaded.index;
1362
1735
  log(`mdss serve listening on http://${host}:${port}` +
@@ -1364,6 +1737,7 @@ async function cmdServe(opts) {
1364
1737
  log(` index: ${idx.chunks.length} chunks · model ${state.loaded.model.id}` +
1365
1738
  (state.watching ? ' · watching for changes' : ''));
1366
1739
  log(` POST /search {"query": "...", "k": 6}`);
1740
+ if (state.llmEndpoint) log(` POST /ask {"query": "...", "k": 5} — SSE answer streams via ${state.llmEndpoint}`);
1367
1741
  log(` GET /health`);
1368
1742
  if (!isLoopbackHost(host) && !state.apiKey) {
1369
1743
  log('');
@@ -1378,11 +1752,17 @@ async function cmdServe(opts) {
1378
1752
 
1379
1753
  const stop = async (sig) => {
1380
1754
  log(`\n${sig} — shutting down.`);
1381
- 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
+ }
1382
1761
  process.exit(0);
1383
1762
  };
1384
1763
  process.on('SIGINT', () => stop('SIGINT'));
1385
1764
  process.on('SIGTERM', () => stop('SIGTERM'));
1765
+ if (process.platform === 'win32') process.on('SIGBREAK', () => stop('SIGBREAK'));
1386
1766
  }
1387
1767
 
1388
1768
  async function cmdMcp(opts) {
@@ -1414,7 +1794,7 @@ async function cmdMcp(opts) {
1414
1794
  // on STDERR so stdout stays machine-readable; warm cache emits nothing at all.
1415
1795
  // ---------------------------------------------------------------------------
1416
1796
 
1417
- const DOWNLOAD_COMMANDS = new Set(['index', 'search', 'ask', 'serve']);
1797
+ const DOWNLOAD_COMMANDS = new Set(['index', 'search', 'ask', 'chat', 'serve']);
1418
1798
 
1419
1799
  function formatMb(bytes) {
1420
1800
  if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
@@ -1469,6 +1849,59 @@ async function cmdCompletions(opts) {
1469
1849
  process.stdout.write(generateCompletion(shell));
1470
1850
  }
1471
1851
 
1852
+ async function cmdRelated(opts) {
1853
+ const db = resolveDb(opts);
1854
+ const indexDir = resolveIndexDir(opts, db);
1855
+ const vectorsPath = path.join(indexDir, 'vectors.json');
1856
+ if (!fs.existsSync(vectorsPath)) {
1857
+ die(`No index at ${vectorsPath}. Run \`mdss index --db ${db}\` first.`);
1858
+ }
1859
+ const target = opts._.shift();
1860
+ if (!target) {
1861
+ die('Missing note path/title/alias to find relations for. e.g. `mdss related --db . "Architecture"`');
1862
+ }
1863
+
1864
+ const loaded = loadIndex(indexDir);
1865
+ const k = Number.isInteger(opts.k) && opts.k > 0 ? opts.k : 10;
1866
+ const direction = opts.direction || 'both';
1867
+ const semantic = !opts.noSemantic;
1868
+
1869
+ let r;
1870
+ try {
1871
+ r = findRelatedNotes({
1872
+ loaded,
1873
+ target,
1874
+ k,
1875
+ direction,
1876
+ semantic,
1877
+ });
1878
+ } catch (e) {
1879
+ die(e.message);
1880
+ }
1881
+
1882
+ if (opts.json) {
1883
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
1884
+ return;
1885
+ }
1886
+
1887
+ if (r.results.length === 0) {
1888
+ process.stdout.write(`No related notes found for "${target}" (${r.resolvedFile}).\n`);
1889
+ if (opts.noSemantic) {
1890
+ process.stdout.write('Hint: Try running without `--no-semantic` to discover conceptually related notes without explicit links.\n');
1891
+ }
1892
+ return;
1893
+ }
1894
+
1895
+ process.stdout.write(`Related notes for "${target}" (${r.resolvedFile}):\n\n`);
1896
+ r.results.forEach((hit, idx) => {
1897
+ const scoreStr = hit.score.toFixed(2);
1898
+ process.stdout.write(` ${idx + 1}. \x1b[1m${hit.file}\x1b[0m — score: ${scoreStr} [${hit.reason}]\n`);
1899
+ if (hit.title && hit.title !== hit.file) {
1900
+ process.stdout.write(` title: "${hit.title}"\n`);
1901
+ }
1902
+ });
1903
+ }
1904
+
1472
1905
  async function main() {
1473
1906
  const argv = process.argv.slice(2);
1474
1907
  const opts = parseArgs(argv);
@@ -1496,7 +1929,9 @@ async function main() {
1496
1929
  case 'doctor': return await cmdCheck(opts);
1497
1930
  case 'export': return await cmdExport(opts);
1498
1931
  case 'search': return await cmdSearch(opts);
1932
+ case 'related': return await cmdRelated(opts);
1499
1933
  case 'ask': return await cmdAsk(opts);
1934
+ case 'chat': return await cmdChat(opts);
1500
1935
  case 'tui': return await cmdTui(opts);
1501
1936
  case 'serve': return await cmdServe(opts);
1502
1937
  case 'mcp': return await cmdMcp(opts);
@@ -1542,6 +1977,7 @@ export {
1542
1977
  parseArgs, nextInt, nextFloat, nextValue,
1543
1978
  resolveDb, resolveIndexDir, resolveCache, resolveOffline,
1544
1979
  findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
1980
+ CONFIG_NETWORK_KEYS, CONFIG_EXEC_KEYS, getConfigKeyTrustTier,
1545
1981
  checkHealth, cmdExport,
1546
1982
  die, main, HELP, VERSION,
1547
1983
  };