md-semantic-search 0.4.0 → 1.0.1

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 (83) hide show
  1. package/README.md +125 -1
  2. package/bin/cli.mjs +482 -34
  3. package/dist/binary-format.d.ts +25 -0
  4. package/dist/binary-format.js +178 -0
  5. package/dist/collapse.d.ts +6 -0
  6. package/dist/collapse.js +20 -0
  7. package/dist/core.d.ts +85 -0
  8. package/dist/core.js +471 -0
  9. package/dist/federation.d.ts +15 -0
  10. package/dist/federation.js +94 -0
  11. package/dist/filter.d.ts +29 -0
  12. package/dist/filter.js +317 -0
  13. package/dist/frontmatter.d.ts +20 -0
  14. package/dist/frontmatter.js +150 -0
  15. package/dist/index-format.d.ts +23 -0
  16. package/dist/index-format.js +125 -0
  17. package/dist/index.d.ts +37 -0
  18. package/dist/index.js +37 -0
  19. package/dist/indexer.d.ts +83 -0
  20. package/dist/indexer.js +466 -0
  21. package/dist/ivf.d.ts +33 -0
  22. package/dist/ivf.js +144 -0
  23. package/dist/lexical.d.ts +44 -0
  24. package/dist/lexical.js +226 -0
  25. package/dist/markdown-parser.d.ts +28 -0
  26. package/dist/markdown-parser.js +311 -0
  27. package/dist/mcp.d.ts +159 -0
  28. package/dist/mcp.js +264 -0
  29. package/dist/metrics.d.ts +43 -0
  30. package/dist/metrics.js +97 -0
  31. package/dist/models.d.ts +38 -0
  32. package/dist/models.js +181 -0
  33. package/dist/quantization.d.ts +24 -0
  34. package/dist/quantization.js +85 -0
  35. package/dist/rag.d.ts +49 -0
  36. package/dist/rag.js +128 -0
  37. package/dist/rerank.d.ts +10 -0
  38. package/dist/rerank.js +41 -0
  39. package/dist/search.d.ts +149 -0
  40. package/dist/search.js +644 -0
  41. package/dist/serve.d.ts +49 -0
  42. package/dist/serve.js +341 -0
  43. package/dist/summarize.d.ts +16 -0
  44. package/dist/summarize.js +82 -0
  45. package/dist/tui.d.ts +18 -0
  46. package/dist/tui.js +219 -0
  47. package/dist/watcher.d.ts +11 -0
  48. package/dist/watcher.js +130 -0
  49. package/dist/wikilinks.d.ts +69 -0
  50. package/dist/wikilinks.js +271 -0
  51. package/package.json +21 -5
  52. package/src/binary-format.ts +216 -0
  53. package/src/{collapse.mjs → collapse.ts} +7 -12
  54. package/src/core.ts +551 -0
  55. package/src/federation.ts +118 -0
  56. package/src/filter.ts +368 -0
  57. package/src/{frontmatter.mjs → frontmatter.ts} +27 -50
  58. package/src/{index-format.mjs → index-format.ts} +58 -41
  59. package/src/{index.mjs → index.ts} +54 -8
  60. package/src/indexer.ts +617 -0
  61. package/src/ivf.ts +190 -0
  62. package/src/{lexical.mjs → lexical.ts} +66 -100
  63. package/src/{markdown-parser.mjs → markdown-parser.ts} +135 -109
  64. package/src/mcp.ts +289 -0
  65. package/src/metrics.ts +135 -0
  66. package/src/models.ts +219 -0
  67. package/src/quantization.ts +93 -0
  68. package/src/rag.ts +189 -0
  69. package/src/rerank.ts +54 -0
  70. package/src/search.ts +805 -0
  71. package/src/serve.ts +393 -0
  72. package/src/summarize.ts +98 -0
  73. package/src/tui.ts +251 -0
  74. package/src/watcher.ts +149 -0
  75. package/src/wikilinks.ts +376 -0
  76. package/src/core.mjs +0 -535
  77. package/src/indexer.mjs +0 -540
  78. package/src/metrics.mjs +0 -186
  79. package/src/models.mjs +0 -299
  80. package/src/rerank.mjs +0 -75
  81. package/src/search.mjs +0 -636
  82. package/src/serve.mjs +0 -387
  83. package/src/wikilinks.mjs +0 -241
package/bin/cli.mjs CHANGED
@@ -14,12 +14,14 @@ import path from 'node:path';
14
14
  import { fileURLToPath, pathToFileURL } from 'node:url';
15
15
  import fs from 'node:fs';
16
16
  import os from 'node:os';
17
- import { buildIndex } from '../src/indexer.mjs';
18
- import { search } from '../src/search.mjs';
19
- import { createServe, DEFAULT_PORT, DEFAULT_HOST } from '../src/serve.mjs';
20
- import { MODELS, DEFAULT_MODEL, resolveModel } from '../src/models.mjs';
21
- import { decodeVec, walkMarkdown, SCHEMA_VERSION } from '../src/core.mjs';
22
- import { inspectIndexSchema, validateCurrentChunk, validateIndexEnvelope, validateNumericVector } from '../src/index-format.mjs';
17
+ import crypto from 'node:crypto';
18
+ import { buildIndex } from '../dist/indexer.js';
19
+ import { search } from '../dist/search.js';
20
+ import { searchFederated } from '../dist/federation.js';
21
+ import { createServe, DEFAULT_PORT, DEFAULT_HOST } from '../dist/serve.js';
22
+ import { MODELS, DEFAULT_MODEL, resolveModel } from '../dist/models.js';
23
+ import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath } from '../dist/core.js';
24
+ import { inspectIndexSchema, validateCurrentChunk, validateIndexEnvelope, validateNumericVector } from '../dist/index-format.js';
23
25
 
24
26
  const HERE = path.dirname(fileURLToPath(import.meta.url));
25
27
  const PKG_ROOT = path.resolve(HERE, '..');
@@ -40,7 +42,7 @@ function defaultCacheDir() {
40
42
  }
41
43
 
42
44
  function parseArgs(argv) {
43
- const opts = { _: [], ignore: [], path: [] };
45
+ const opts = { _: [], ignore: [], path: [], vaults: [] };
44
46
  for (let i = 0; i < argv.length; i++) {
45
47
  const a = argv[i];
46
48
  if (a === '--json') opts.json = true;
@@ -49,9 +51,17 @@ function parseArgs(argv) {
49
51
  else if (a === '--watch') opts.watch = true;
50
52
  else if (a === '--rerank') opts.rerank = true;
51
53
  else if (a === '--explain') opts.explain = true;
54
+ else if (a === '--interactive' || a === '-i') opts.interactive = true;
55
+ else if (a === '--list-tools') opts.listTools = true;
52
56
  else if (a === '--version') opts.version = true;
53
57
  else if (a === '-h' || a === '--help') opts.help = true;
58
+ else if (a === '--workers') opts.workers = nextInt(argv, ++i, a);
59
+ else if (a === '--config') opts.config = nextValue(argv, ++i, a);
60
+ else if (a === '--format') opts.format = nextValue(argv, ++i, a);
61
+ else if (a === '--no-vectors' || a === '--no-vector') opts.noVectors = true;
62
+ else if (a === '--output' || a === '-o') opts.output = nextValue(argv, ++i, a);
54
63
  else if (a === '--db') opts.db = nextValue(argv, ++i, a);
64
+ else if (a === '--vault') opts.vaults.push(nextValue(argv, ++i, a));
55
65
  else if (a === '--index-dir') opts.indexDir = nextValue(argv, ++i, a);
56
66
  else if (a === '--cache-dir') opts.cacheDir = nextValue(argv, ++i, a);
57
67
  else if (a === '--model') opts.model = nextValue(argv, ++i, a);
@@ -61,6 +71,8 @@ function parseArgs(argv) {
61
71
  else if (a === '--target-tokens') opts.targetTokens = nextInt(argv, ++i, a);
62
72
  else if (a === '--port') opts.port = nextInt(argv, ++i, a);
63
73
  else if (a === '--host') opts.host = nextValue(argv, ++i, a);
74
+ else if (a === '--api-key') opts.apiKey = nextValue(argv, ++i, a);
75
+ else if (a === '--health-public') opts.healthPublic = true;
64
76
  else if (a === '--watch-delay') opts.watchDelay = nextInt(argv, ++i, a);
65
77
  else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
66
78
  else if (a === '--since') opts.since = nextValue(argv, ++i, a);
@@ -70,10 +82,21 @@ function parseArgs(argv) {
70
82
  const v = nextValue(argv, ++i, a);
71
83
  opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
72
84
  }
85
+ else if (a === '--ann') opts.ann = true;
86
+ else if (a === '--nprobe') opts.nprobe = nextInt(argv, ++i, a);
73
87
  else if (a === '--project') opts.project = nextValue(argv, ++i, a);
74
88
  else if (a === '--type') opts.type = nextValue(argv, ++i, a);
75
89
  else if (a === '--status') opts.status = nextValue(argv, ++i, a);
76
90
  else if (a === '--canonical') opts.canonical = true;
91
+ else if (a === '--graph-boost') opts.graphBoost = nextFloat(argv, ++i, a);
92
+ else if (a === '--filter') opts.filter = nextValue(argv, ++i, a);
93
+ else if (a === '--quantize') opts.quantize = nextValue(argv, ++i, a);
94
+ else if (a === '--rag') opts.rag = true;
95
+ else if (a === '--auto-tag') opts.autoTag = true;
96
+ else if (a === '--auto-summarize') opts.autoSummarize = true;
97
+ else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
98
+ else if (a === '--llm-model') opts.llmModel = nextValue(argv, ++i, a);
99
+ else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
77
100
  else if (a.startsWith('-')) die(`unknown option: ${a}. Try \`mdss --help\`.`);
78
101
  else opts._.push(a);
79
102
  }
@@ -95,6 +118,96 @@ function nextInt(argv, i, flag) {
95
118
  return k;
96
119
  }
97
120
 
121
+ /** Like nextValue but requires a non-negative float. */
122
+ function nextFloat(argv, i, flag) {
123
+ const v = nextValue(argv, i, flag);
124
+ const f = Number.parseFloat(v);
125
+ if (!Number.isFinite(f) || f < 0) die(`${flag} must be a non-negative number, got "${v}"`);
126
+ return f;
127
+ }
128
+
129
+ const KNOWN_CONFIG_KEYS = new Set([
130
+ 'db', 'model', 'indexDir', 'index-dir', 'cacheDir', 'cache-dir',
131
+ 'ignore', 'path', 'k', 'maxPerFile', 'max-per-file', 'maxPerDoc', 'max-per-doc',
132
+ 'targetTokens', 'target-tokens', 'port', 'host', 'apiKey', 'api-key',
133
+ 'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
134
+ 'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
135
+ 'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
136
+ 'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
137
+ 'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt'
138
+ ]);
139
+
140
+ function findConfigFile(explicitPath) {
141
+ if (explicitPath) {
142
+ const abs = path.resolve(explicitPath);
143
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) die(`config file not found: ${abs}`);
144
+ return abs;
145
+ }
146
+ if (process.env.MDSS_CONFIG) {
147
+ const abs = path.resolve(process.env.MDSS_CONFIG);
148
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) die(`config file from MDSS_CONFIG not found: ${abs}`);
149
+ return abs;
150
+ }
151
+ const configNames = ['.mdssrc.json', 'mdss.config.json', '.mdssrc'];
152
+ let curr = process.cwd();
153
+ while (true) {
154
+ for (const name of configNames) {
155
+ const candidate = path.join(curr, name);
156
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
157
+ return candidate;
158
+ }
159
+ }
160
+ const parent = path.dirname(curr);
161
+ if (parent === curr) break;
162
+ curr = parent;
163
+ }
164
+ try {
165
+ const home = os.homedir();
166
+ if (home) {
167
+ for (const name of ['.mdssrc.json', '.mdssrc']) {
168
+ const candidate = path.join(home, name);
169
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
170
+ return candidate;
171
+ }
172
+ }
173
+ }
174
+ } catch {}
175
+ return null;
176
+ }
177
+
178
+ function loadConfigFile(configPath) {
179
+ if (!configPath) return { config: {}, path: null, unknownKeys: [], error: null };
180
+ try {
181
+ const raw = fs.readFileSync(configPath, 'utf8');
182
+ const parsed = JSON.parse(raw);
183
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
184
+ return { config: {}, path: configPath, unknownKeys: [], error: `Config at ${configPath} must be a JSON object` };
185
+ }
186
+ const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_CONFIG_KEYS.has(k));
187
+ const normalized = {};
188
+ for (const [k, v] of Object.entries(parsed)) {
189
+ const camel = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
190
+ normalized[camel] = v;
191
+ }
192
+ return { config: normalized, path: configPath, unknownKeys, error: null };
193
+ } catch (e) {
194
+ return { config: {}, path: configPath, unknownKeys: [], error: `Failed to parse config at ${configPath}: ${e.message}` };
195
+ }
196
+ }
197
+
198
+ function applyConfigDefaults(opts, config) {
199
+ for (const [k, v] of Object.entries(config)) {
200
+ if (k === 'ignore' || k === 'path') {
201
+ if ((!opts[k] || opts[k].length === 0) && v !== undefined) {
202
+ opts[k] = Array.isArray(v) ? [...v] : [v];
203
+ }
204
+ } else if (opts[k] === undefined && v !== undefined) {
205
+ opts[k] = v;
206
+ }
207
+ }
208
+ return opts;
209
+ }
210
+
98
211
  function resolveDb(opts) {
99
212
  const db = opts.db || process.env.MDSS_DB;
100
213
  if (!db) die('Missing --db <dir> (or set MDSS_DB). Path to your .md folder.');
@@ -102,13 +215,23 @@ function resolveDb(opts) {
102
215
  if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
103
216
  die(`--db is not a directory: ${abs}`);
104
217
  }
105
- return abs;
218
+ let safeDb;
219
+ try {
220
+ safeDb = assertSafePath(abs);
221
+ } catch (e) {
222
+ die(e.message);
223
+ }
224
+ return safeDb;
106
225
  }
107
226
 
108
227
  function resolveIndexDir(opts, db) {
109
228
  // Default: <db>/.mdss. Override with --index-dir or MDSS_INDEX_DIR.
110
229
  const dir = opts.indexDir || process.env.MDSS_INDEX_DIR || path.join(db, '.mdss');
111
- return path.resolve(dir);
230
+ try {
231
+ return assertSafePath(dir);
232
+ } catch (e) {
233
+ die(e.message);
234
+ }
112
235
  }
113
236
 
114
237
  const resolveCache = (opts) =>
@@ -127,15 +250,22 @@ Usage:
127
250
  mdss index --db <dir> [options] Build/refresh the index
128
251
  mdss stats --db <dir> [--json] Index stats without loading the model
129
252
  mdss check --db <dir> [--json] Diagnose index/db/model cache (alias: doctor)
253
+ mdss export --db <dir> [options] Export index to JSONL / CSV / Parquet
130
254
  mdss search --db <dir> [options] "query" Search by meaning
131
255
  mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
256
+ mdss mcp --db <dir> [--list-tools] Start MCP server over stdio for LLM agents / IDEs
132
257
  mdss models List available models
133
258
 
134
259
  Options:
260
+ --config <file> Path to config file (default: .mdssrc.json / mdss.config.json).
135
261
  --db <dir> Folder of .md files (or env MDSS_DB). Can be anywhere.
136
262
  --index-dir <dir> Where to store the index (default: <db>/.mdss).
137
263
  --cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
138
264
  --model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
265
+ --workers <n> Number of parallel batch workers for indexing (default: 1).
266
+ --format <fmt> Export format: jsonl (default), csv, parquet (export).
267
+ --no-vectors Omit vector embeddings from export (export).
268
+ --output <file> Output file path (export, default: stdout).
139
269
  --ignore <glob> Skip files/paths (repeatable). e.g. --ignore "log.md".
140
270
  --path <glob> Search only files matching glob (repeatable). e.g. --path "docs/**".
141
271
  --since <date> Search only files modified at/after date (YYYY-MM-DD or ISO).
@@ -143,6 +273,10 @@ Options:
143
273
  --json Machine-readable output (index, stats, search).
144
274
  --semantic Pure vector ranking, skip lexical/RRF fusion (search).
145
275
  --rerank Re-rank candidates with a cross-encoder (search; ~280MB model).
276
+ --graph-boost <n> Boost weight for graph PageRank / Obsidian wikilinks (search, default: 0).
277
+ --filter <expr> Rich boolean filter across frontmatter tags and properties (search).
278
+ --ann Approximate nearest neighbor search via IVF (search).
279
+ --nprobe <n> Number of centroids to probe for ANN (search, default: 8).
146
280
  --port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
147
281
  --host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
148
282
  only; use 0.0.0.0 to expose on the LAN, env MDSS_HOST).
@@ -164,11 +298,41 @@ Examples:
164
298
  curl -X POST localhost:8747/search -d '{"query":"rotate api token","k":5}'
165
299
  `;
166
300
 
301
+ function formatDuration(sec) {
302
+ const s = Math.max(0, Math.floor(sec));
303
+ const h = Math.floor(s / 3600);
304
+ const m = Math.floor((s % 3600) / 60);
305
+ const remainingS = s % 60;
306
+ return `${h}:${String(m).padStart(2, '0')}:${String(remainingS).padStart(2, '0')}`;
307
+ }
308
+
167
309
  async function cmdIndex(opts) {
168
310
  const db = resolveDb(opts);
169
311
  const indexDir = resolveIndexDir(opts, db);
170
312
  const cacheDir = resolveCache(opts);
171
313
  const t0 = Date.now();
314
+ let lastProgressUpdate = 0;
315
+
316
+ const onProgress = (done, total, chunksPerSec) => {
317
+ if (!process.stderr.isTTY || opts.json || total <= 100) return;
318
+ const now = Date.now();
319
+ if (now - lastProgressUpdate < 500 && done < total) return;
320
+ lastProgressUpdate = now;
321
+ const percent = Math.floor((done / total) * 100);
322
+ const barWidth = 10;
323
+ const filled = Math.min(barWidth, Math.max(0, Math.round((done / total) * barWidth)));
324
+ const bar = '[' + '#'.repeat(filled) + '-'.repeat(barWidth - filled) + ']';
325
+ const remaining = total - done;
326
+ const etaSec = chunksPerSec > 0 ? remaining / chunksPerSec : 0;
327
+ const etaStr = formatDuration(etaSec);
328
+ const speedStr = `${chunksPerSec.toFixed(1)} chunks/s`;
329
+ const line = `${bar} ${percent}% │ ${done}/${total} chunks │ ${speedStr} │ ETA ${etaStr}`;
330
+ process.stderr.write(`\r${line}`);
331
+ if (done >= total) {
332
+ process.stderr.write('\n');
333
+ }
334
+ };
335
+
172
336
  let r;
173
337
  try {
174
338
  r = await buildIndex({
@@ -176,7 +340,14 @@ async function cmdIndex(opts) {
176
340
  modelName: opts.model || DEFAULT_MODEL,
177
341
  ignore: opts.ignore,
178
342
  offline: resolveOffline(opts),
179
- log: s => process.stderr.write(s + '\n'),
343
+ workers: opts.workers,
344
+ ann: opts.ann,
345
+ quantize: opts.quantize,
346
+ log: s => {
347
+ if (process.stderr.isTTY && !opts.json && /^\s+\d+\/\d+$/.test(s)) return;
348
+ process.stderr.write(s + '\n');
349
+ },
350
+ onProgress,
180
351
  });
181
352
  } catch (e) {
182
353
  // A held index lock (issue #37) is an EXPECTED operational state (a second
@@ -234,7 +405,7 @@ function cmdStats(opts) {
234
405
  // File count = keys of .hashes.json (per-file md5 map written by buildIndex).
235
406
  // Fall back to unique chunk file paths when the hashes file is missing (a
236
407
  // legacy/corrupt index) rather than dying.
237
- let files = 0;
408
+ let files;
238
409
  try {
239
410
  const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
240
411
  files = Object.keys(hashes).length;
@@ -288,6 +459,119 @@ function cmdStats(opts) {
288
459
  );
289
460
  }
290
461
 
462
+ /**
463
+ * `mdss export` — export the index as JSONL / CSV / Parquet for downstream pipelines (issue #77).
464
+ */
465
+ async function cmdExport(opts) {
466
+ const db = resolveDb(opts);
467
+ const indexDir = resolveIndexDir(opts, db);
468
+ const vectorsPath = path.join(indexDir, 'vectors.json');
469
+ if (!fs.existsSync(vectorsPath)) {
470
+ die(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
471
+ }
472
+
473
+ let index;
474
+ try {
475
+ index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
476
+ } catch (e) {
477
+ die(`${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild.`);
478
+ }
479
+
480
+ const format = (opts.format || 'jsonl').toLowerCase();
481
+ const noVectors = !!opts.noVectors;
482
+ const outStream = opts.output ? fs.createWriteStream(path.resolve(opts.output), 'utf8') : process.stdout;
483
+
484
+ const chunks = Array.isArray(index.chunks) ? index.chunks : [];
485
+ const dim = index.dim;
486
+
487
+ if (format === 'jsonl') {
488
+ for (const c of chunks) {
489
+ let vec = undefined;
490
+ if (!noVectors && c.vec !== undefined) {
491
+ if (typeof c.vec === 'string') {
492
+ try {
493
+ vec = Array.from(decodeVec(c.vec, dim));
494
+ } catch {
495
+ vec = null;
496
+ }
497
+ } else if (Array.isArray(c.vec) || ArrayBuffer.isView(c.vec)) {
498
+ vec = Array.from(c.vec);
499
+ }
500
+ }
501
+ const record = {
502
+ file: c.file,
503
+ title: c.title,
504
+ heading: c.heading,
505
+ headingPath: c.headingPath,
506
+ text: c.text,
507
+ ...(noVectors ? {} : { vec }),
508
+ ...(c.chunkHash ? { chunkHash: c.chunkHash } : {}),
509
+ ...(c.startLine !== undefined ? { startLine: c.startLine } : {}),
510
+ ...(c.endLine !== undefined ? { endLine: c.endLine } : {}),
511
+ ...(c.meta ? { meta: c.meta } : {}),
512
+ };
513
+ outStream.write(JSON.stringify(record) + '\n');
514
+ }
515
+ if (opts.output) {
516
+ await new Promise((resolve) => outStream.end(resolve));
517
+ }
518
+ } else if (format === 'csv') {
519
+ const escapeCsv = (val) => {
520
+ const str = String(val ?? '');
521
+ if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
522
+ return '"' + str.replace(/"/g, '""') + '"';
523
+ }
524
+ return str;
525
+ };
526
+ outStream.write('file,title,heading,headingPath,text\n');
527
+ for (const c of chunks) {
528
+ const pathStr = Array.isArray(c.headingPath) ? c.headingPath.join(' > ') : (c.heading || '');
529
+ const row = [
530
+ escapeCsv(c.file),
531
+ escapeCsv(c.title),
532
+ escapeCsv(c.heading),
533
+ escapeCsv(pathStr),
534
+ escapeCsv(c.text),
535
+ ].join(',');
536
+ outStream.write(row + '\n');
537
+ }
538
+ if (opts.output) {
539
+ await new Promise((resolve) => outStream.end(resolve));
540
+ }
541
+ } else if (format === 'parquet') {
542
+ let parquet;
543
+ try {
544
+ // @ts-ignore
545
+ parquet = await import('parquetjs-lite');
546
+ } catch {
547
+ die('Parquet export requires optional dependency "parquetjs-lite". Install with: npm install parquetjs-lite');
548
+ }
549
+ if (!opts.output) {
550
+ die('Parquet export requires an output file via --output <file.parquet>');
551
+ }
552
+ const schemaFields = {
553
+ file: { type: 'UTF8' },
554
+ title: { type: 'UTF8' },
555
+ heading: { type: 'UTF8' },
556
+ text: { type: 'UTF8' },
557
+ };
558
+ const schema = new parquet.ParquetSchema(schemaFields);
559
+ const outFile = path.resolve(opts.output);
560
+ const writer = await parquet.ParquetWriter.openFile(schema, outFile);
561
+ for (const c of chunks) {
562
+ await writer.appendRow({
563
+ file: c.file || '',
564
+ title: c.title || '',
565
+ heading: c.heading || '',
566
+ text: c.text || '',
567
+ });
568
+ }
569
+ await writer.close();
570
+ } else {
571
+ die(`unknown export format: "${format}". Supported formats: jsonl, csv, parquet.`);
572
+ }
573
+ }
574
+
291
575
  /**
292
576
  * Offline diagnostics for the index/db/model-cache trio (issue #43) — the
293
577
  * `mdss check` / `mdss doctor` backend. Pure read-only: parses vectors.json +
@@ -295,7 +579,7 @@ function cmdStats(opts) {
295
579
  * validator the loader uses), walks the db for staleness, and checks the
296
580
  * transformers.js cache layout for the index's model. NEVER loads the
297
581
  * embedding model and NEVER touches the network.
298
- * @param {{db:string, indexDir:string, cacheDir:string, requireOffline?:boolean}} paths
582
+ * @param {{db:string, indexDir:string, cacheDir:string, requireOffline?:boolean, config?:any}} paths
299
583
  * @returns {CheckReport}
300
584
  */
301
585
 
@@ -312,7 +596,7 @@ function cmdStats(opts) {
312
596
  * @property {{exists:boolean, stale:boolean, error:(string|null)}} db
313
597
  * @property {{id:(string|null), cached:boolean, cachePath:(string|null), error:(string|null)}} model
314
598
  */
315
- export function checkHealth({ db, indexDir, cacheDir, requireOffline = false }) {
599
+ function checkHealth({ db, indexDir, cacheDir, requireOffline = false, config = null }) {
316
600
  const report = {
317
601
  healthy: true,
318
602
  index: { exists: false, parses: false, schemaVersion: null, format: null, recognized: true, error: null },
@@ -320,7 +604,21 @@ export function checkHealth({ db, indexDir, cacheDir, requireOffline = false })
320
604
  chunks: { total: 0, valid: 0, invalid: [] },
321
605
  db: { exists: true, stale: false, error: null },
322
606
  model: { id: null, cached: false, cachePath: null, error: null },
607
+ config: null,
323
608
  };
609
+
610
+ // --- config file: exists, valid JSON, schema / unknown keys (issue #68) ---
611
+ if (config && config.path) {
612
+ report.config = {
613
+ path: config.path,
614
+ valid: !config.error && (!config.unknownKeys || config.unknownKeys.length === 0),
615
+ unknownKeys: config.unknownKeys || [],
616
+ error: config.error || (config.unknownKeys && config.unknownKeys.length > 0 ? `unknown config keys: ${config.unknownKeys.join(', ')}` : null),
617
+ };
618
+ if (!report.config.valid) {
619
+ report.healthy = false;
620
+ }
621
+ }
324
622
  const vectorsPath = path.join(indexDir, 'vectors.json');
325
623
  const hashesPath = path.join(indexDir, '.hashes.json');
326
624
 
@@ -332,8 +630,22 @@ export function checkHealth({ db, indexDir, cacheDir, requireOffline = false })
332
630
  }
333
631
  report.index.exists = true;
334
632
  let index;
633
+ const raw = fs.readFileSync(vectorsPath, 'utf8');
634
+ const shaPath = path.join(indexDir, 'vectors.json.sha256');
635
+ if (fs.existsSync(shaPath)) {
636
+ try {
637
+ const shaContent = fs.readFileSync(shaPath, 'utf8').trim();
638
+ const expectedHash = shaContent.split(/\s+/)[0];
639
+ const actualHash = crypto.createHash('sha256').update(raw).digest('hex');
640
+ if (expectedHash && actualHash !== expectedHash) {
641
+ report.index.error = `vectors.json integrity check failed (SHA-256 mismatch) — run \`mdss index\` to rebuild`;
642
+ report.healthy = false;
643
+ return report;
644
+ }
645
+ } catch { /* ignore unreadable sha file */ }
646
+ }
335
647
  try {
336
- index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
648
+ index = JSON.parse(raw);
337
649
  report.index.parses = true;
338
650
  } catch (e) {
339
651
  report.index.error = `${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild`;
@@ -469,7 +781,11 @@ function cmdCheck(opts) {
469
781
  const db = resolveDb(opts);
470
782
  const indexDir = resolveIndexDir(opts, db);
471
783
  const cacheDir = resolveCache(opts);
472
- const report = checkHealth({ db, indexDir, cacheDir, requireOffline: resolveOffline(opts) });
784
+ const report = checkHealth({
785
+ db, indexDir, cacheDir,
786
+ requireOffline: resolveOffline(opts),
787
+ config: opts._config,
788
+ });
473
789
 
474
790
  if (opts.json) {
475
791
  process.stdout.write(JSON.stringify(report, null, 2) + '\n');
@@ -543,6 +859,14 @@ function cmdCheck(opts) {
543
859
  out += warn('model: index has no model field (legacy index)');
544
860
  }
545
861
 
862
+ if (report.config) {
863
+ if (report.config.valid) {
864
+ out += ok(`config: ${report.config.path}`);
865
+ } else {
866
+ out += fail(`config: ${report.config.path} (${report.config.error})`);
867
+ }
868
+ }
869
+
546
870
  out += report.healthy
547
871
  ? 'check: healthy (exit 0)\n'
548
872
  : 'check: 1+ problem(s) found (exit 1)\n';
@@ -550,29 +874,116 @@ function cmdCheck(opts) {
550
874
  process.exitCode = report.healthy ? 0 : 1;
551
875
  }
552
876
 
553
- async function cmdSearch(opts) {
877
+ async function cmdTui(opts) {
554
878
  const db = resolveDb(opts);
555
879
  const indexDir = resolveIndexDir(opts, db);
556
880
  const cacheDir = resolveCache(opts);
557
881
  const query = opts._.join(' ').trim();
558
- if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
559
-
560
- const results = await search({
561
- indexDir, cacheDir, query,
562
- k: opts.k || 6,
882
+ const { runTui } = await import('../dist/tui.js');
883
+ await runTui({
884
+ db, indexDir, cacheDir, query,
885
+ k: opts.k || 20,
563
886
  semanticOnly: !!opts.semantic,
564
887
  offline: resolveOffline(opts),
565
888
  path: opts.path.length > 0 ? opts.path : undefined,
566
- since: opts.since,
567
889
  rerank: !!opts.rerank,
568
- tag: opts.tag,
569
- project: opts.project,
570
- type: opts.type,
571
- status: opts.status,
572
- canonicalOnly: opts.canonical,
573
- explain: !!opts.explain,
574
- maxPerFile: opts.maxPerFile || opts.maxPerDoc,
890
+ rag: !!opts.rag,
575
891
  });
892
+ }
893
+
894
+ async function cmdAsk(opts) {
895
+ const query = opts._.join(' ').trim();
896
+ if (!query) die('missing question to ask. Usage: mdss ask [--db <dir>] "question"');
897
+
898
+ const db = resolveDb(opts);
899
+ const indexDir = resolveIndexDir(opts, db);
900
+ const cacheDir = resolveCache(opts);
901
+ const k = opts.k || 5;
902
+
903
+ const { askQuestion } = await import('../dist/rag.js');
904
+ const result = await askQuestion({
905
+ query,
906
+ indexDir,
907
+ cacheDir,
908
+ k,
909
+ llmEndpoint: opts.llmEndpoint,
910
+ llmModel: opts.llmModel,
911
+ systemPrompt: opts.systemPrompt,
912
+ });
913
+
914
+ if (opts.json) {
915
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
916
+ return;
917
+ }
918
+
919
+ process.stdout.write(`\n${result.answer}\n\n`);
920
+ if (result.citations && result.citations.length > 0) {
921
+ process.stdout.write('Sources:\n');
922
+ for (const c of result.citations) {
923
+ const heading = c.heading ? ` › ${c.heading}` : '';
924
+ process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
925
+ }
926
+ process.stdout.write('\n');
927
+ }
928
+ }
929
+
930
+ async function cmdSearch(opts) {
931
+ if (opts.interactive) return cmdTui(opts);
932
+ const cacheDir = resolveCache(opts);
933
+ const query = opts._.join(' ').trim();
934
+ if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
935
+
936
+ let results;
937
+ const isFederated = Array.isArray(opts.vaults) && opts.vaults.length > 0;
938
+
939
+ if (isFederated) {
940
+ results = await searchFederated({
941
+ vaults: opts.vaults,
942
+ cacheDir,
943
+ query,
944
+ k: opts.k || 6,
945
+ semanticOnly: !!opts.semantic,
946
+ offline: resolveOffline(opts),
947
+ path: opts.path.length > 0 ? opts.path : undefined,
948
+ since: opts.since,
949
+ rerank: !!opts.rerank,
950
+ tag: opts.tag,
951
+ project: opts.project,
952
+ type: opts.type,
953
+ status: opts.status,
954
+ canonicalOnly: opts.canonical,
955
+ explain: !!opts.explain,
956
+ maxPerFile: opts.maxPerFile || opts.maxPerDoc,
957
+ ann: opts.ann,
958
+ nprobe: opts.nprobe,
959
+ graphBoost: opts.graphBoost,
960
+ filter: opts.filter,
961
+ });
962
+ } else {
963
+ const db = resolveDb(opts);
964
+ const indexDir = resolveIndexDir(opts, db);
965
+
966
+ results = await search({
967
+ indexDir, cacheDir, query,
968
+ k: opts.k || 6,
969
+ semanticOnly: !!opts.semantic,
970
+ offline: resolveOffline(opts),
971
+ path: opts.path.length > 0 ? opts.path : undefined,
972
+ since: opts.since,
973
+ rerank: !!opts.rerank,
974
+ tag: opts.tag,
975
+ project: opts.project,
976
+ type: opts.type,
977
+ status: opts.status,
978
+ canonicalOnly: opts.canonical,
979
+ explain: !!opts.explain,
980
+ maxPerFile: opts.maxPerFile || opts.maxPerDoc,
981
+ ann: opts.ann,
982
+ nprobe: opts.nprobe,
983
+ graphBoost: opts.graphBoost,
984
+ filter: opts.filter,
985
+ });
986
+ }
576
987
 
577
988
  if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
578
989
  if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
@@ -582,13 +993,14 @@ async function cmdSearch(opts) {
582
993
  // Group by file (issue #13): each file becomes a header with its hits below.
583
994
  const byFile = new Map();
584
995
  for (const r of results) {
585
- if (!byFile.has(r.file)) byFile.set(r.file, []);
586
- byFile.get(r.file).push(r);
996
+ const fileKey = r.vault ? `[${r.vault}] ${r.file}` : r.file;
997
+ if (!byFile.has(fileKey)) byFile.set(fileKey, []);
998
+ byFile.get(fileKey).push(r);
587
999
  }
588
1000
  const color = !!process.stdout.isTTY; // bold matched terms only on a real terminal
589
1001
  let rank = 0;
590
- for (const [file, hits] of byFile) {
591
- process.stdout.write(`${file}\n`);
1002
+ for (const [fileHeader, hits] of byFile) {
1003
+ process.stdout.write(`${fileHeader}\n`);
592
1004
  for (const r of hits) {
593
1005
  rank++;
594
1006
  let snippet = r.snippet;
@@ -648,6 +1060,8 @@ async function cmdServe(opts) {
648
1060
  watch: !!opts.watch,
649
1061
  watchInterval: opts.watchInterval,
650
1062
  watchDelay: opts.watchDelay,
1063
+ apiKey: opts.apiKey || process.env.MDSS_API_KEY,
1064
+ healthPublic: !!opts.healthPublic || process.env.MDSS_HEALTH_PUBLIC === 'true',
651
1065
  log,
652
1066
  });
653
1067
 
@@ -671,9 +1085,37 @@ async function cmdServe(opts) {
671
1085
  process.on('SIGTERM', () => stop('SIGTERM'));
672
1086
  }
673
1087
 
1088
+ async function cmdMcp(opts) {
1089
+ if (opts.listTools) {
1090
+ const { MCP_TOOLS } = await import('../dist/mcp.js');
1091
+ process.stdout.write(JSON.stringify(MCP_TOOLS, null, 2) + '\n');
1092
+ return;
1093
+ }
1094
+ const db = resolveDb(opts);
1095
+ const indexDir = resolveIndexDir(opts, db);
1096
+ const cacheDir = resolveCache(opts);
1097
+ const log = s => process.stderr.write(s + '\n');
1098
+ const { startMcpServer } = await import('../dist/mcp.js');
1099
+ await startMcpServer({
1100
+ db, indexDir, cacheDir,
1101
+ modelName: opts.model || DEFAULT_MODEL,
1102
+ ignore: opts.ignore,
1103
+ offline: resolveOffline(opts),
1104
+ log,
1105
+ });
1106
+ }
1107
+
674
1108
  async function main() {
675
1109
  const argv = process.argv.slice(2);
676
1110
  const opts = parseArgs(argv);
1111
+ const configPath = findConfigFile(opts.config);
1112
+ const loadedConfig = loadConfigFile(configPath);
1113
+ if (loadedConfig.error && opts.config) {
1114
+ die(loadedConfig.error);
1115
+ }
1116
+ opts._config = loadedConfig;
1117
+ applyConfigDefaults(opts, loadedConfig.config);
1118
+
677
1119
  const cmd = opts._.shift();
678
1120
 
679
1121
  if (opts.version) { process.stdout.write(`${VERSION}\n`); return; }
@@ -683,8 +1125,12 @@ async function main() {
683
1125
  case 'stats': return cmdStats(opts);
684
1126
  case 'check':
685
1127
  case 'doctor': return cmdCheck(opts);
1128
+ case 'export': return cmdExport(opts);
686
1129
  case 'search': return cmdSearch(opts);
1130
+ case 'ask': return cmdAsk(opts);
1131
+ case 'tui': return cmdTui(opts);
687
1132
  case 'serve': return cmdServe(opts);
1133
+ case 'mcp': return cmdMcp(opts);
688
1134
  case 'models': return cmdModels();
689
1135
  default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
690
1136
  }
@@ -700,7 +1146,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
700
1146
  // functions are unit-tested directly; die() is asserted via a mocked
701
1147
  // process.exit so a bad flag cannot kill the test runner.
702
1148
  export {
703
- parseArgs, nextInt, nextValue,
1149
+ parseArgs, nextInt, nextFloat, nextValue,
704
1150
  resolveDb, resolveIndexDir, resolveCache, resolveOffline,
1151
+ findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
1152
+ checkHealth, cmdExport,
705
1153
  die, main, HELP, VERSION,
706
1154
  };