md-semantic-search 0.4.0 → 0.5.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 (61) hide show
  1. package/README.md +81 -0
  2. package/bin/cli.mjs +368 -14
  3. package/dist/collapse.d.ts +6 -0
  4. package/dist/collapse.js +20 -0
  5. package/dist/core.d.ts +84 -0
  6. package/dist/core.js +479 -0
  7. package/dist/frontmatter.d.ts +20 -0
  8. package/dist/frontmatter.js +150 -0
  9. package/dist/index-format.d.ts +23 -0
  10. package/dist/index-format.js +125 -0
  11. package/dist/index.d.ts +29 -0
  12. package/dist/index.js +29 -0
  13. package/dist/indexer.d.ts +70 -0
  14. package/dist/indexer.js +452 -0
  15. package/dist/ivf.d.ts +33 -0
  16. package/dist/ivf.js +144 -0
  17. package/dist/lexical.d.ts +44 -0
  18. package/dist/lexical.js +226 -0
  19. package/dist/markdown-parser.d.ts +27 -0
  20. package/dist/markdown-parser.js +252 -0
  21. package/dist/mcp.d.ts +111 -0
  22. package/dist/mcp.js +216 -0
  23. package/dist/metrics.d.ts +43 -0
  24. package/dist/metrics.js +97 -0
  25. package/dist/models.d.ts +38 -0
  26. package/dist/models.js +181 -0
  27. package/dist/rerank.d.ts +10 -0
  28. package/dist/rerank.js +41 -0
  29. package/dist/search.d.ts +135 -0
  30. package/dist/search.js +492 -0
  31. package/dist/serve.d.ts +49 -0
  32. package/dist/serve.js +321 -0
  33. package/dist/tui.d.ts +17 -0
  34. package/dist/tui.js +196 -0
  35. package/dist/wikilinks.d.ts +47 -0
  36. package/dist/wikilinks.js +171 -0
  37. package/package.json +19 -5
  38. package/src/{collapse.mjs → collapse.ts} +7 -12
  39. package/src/core.ts +546 -0
  40. package/src/{frontmatter.mjs → frontmatter.ts} +27 -50
  41. package/src/{index-format.mjs → index-format.ts} +57 -40
  42. package/src/{index.mjs → index.ts} +16 -8
  43. package/src/indexer.ts +585 -0
  44. package/src/ivf.ts +190 -0
  45. package/src/{lexical.mjs → lexical.ts} +66 -100
  46. package/src/{markdown-parser.mjs → markdown-parser.ts} +56 -100
  47. package/src/mcp.ts +243 -0
  48. package/src/metrics.ts +135 -0
  49. package/src/models.ts +219 -0
  50. package/src/rerank.ts +54 -0
  51. package/src/search.ts +628 -0
  52. package/src/serve.ts +372 -0
  53. package/src/tui.ts +227 -0
  54. package/src/{wikilinks.mjs → wikilinks.ts} +70 -67
  55. package/src/core.mjs +0 -535
  56. package/src/indexer.mjs +0 -540
  57. package/src/metrics.mjs +0 -186
  58. package/src/models.mjs +0 -299
  59. package/src/rerank.mjs +0 -75
  60. package/src/search.mjs +0 -636
  61. package/src/serve.mjs +0 -387
package/README.md CHANGED
@@ -255,6 +255,52 @@ error, and `serve --watch` politely defers its cycle until the other writer
255
255
  finishes. A crashed writer can't wedge the index — the lock self-heals by
256
256
  pid-liveness + staleness (a dead-pid or abandoned lock is reclaimed).
257
257
 
258
+ ### Export the index (`mdss export`)
259
+
260
+ Export chunks and decoded embedding vectors to JSONL, CSV, or Parquet for downstream pipelines (LangChain, LlamaIndex, Qdrant, Pandas):
261
+
262
+ ```bash
263
+ # Export all chunks with decoded float vectors as JSONL:
264
+ mdss export --db ./notes --format jsonl > chunks.jsonl
265
+
266
+ # Export metadata only without vectors (fast & compact):
267
+ mdss export --db ./notes --format jsonl --no-vectors > chunks_meta.jsonl
268
+
269
+ # Export as CSV (metadata only):
270
+ mdss export --db ./notes --format csv > chunks.csv
271
+
272
+ # Export directly to file:
273
+ mdss export --db ./notes --format jsonl --output ./data/corpus.jsonl
274
+ ```
275
+
276
+ #### JSONL output schema:
277
+ ```json
278
+ {
279
+ "file": "runbooks/failover.md",
280
+ "title": "Database Failover",
281
+ "heading": "Step 2: Promote Replica",
282
+ "headingPath": ["Database Failover", "Step 2: Promote Replica"],
283
+ "text": "Run the promote script...",
284
+ "vec": [0.012, -0.045, 0.089],
285
+ "chunkHash": "a1b2c3...",
286
+ "startLine": 14,
287
+ "endLine": 28
288
+ }
289
+ ```
290
+
291
+ #### Downstream integration example (Python / LangChain / LlamaIndex):
292
+ ```python
293
+ import json
294
+
295
+ # Load into custom vector store / pipeline
296
+ with open("chunks.jsonl") as f:
297
+ for line in f:
298
+ chunk = json.loads(line)
299
+ text = chunk["text"]
300
+ vector = chunk["vec"]
301
+ meta = {"file": chunk["file"], "heading": chunk["heading"]}
302
+ ```
303
+
258
304
  ### Diagnose problems (`mdss check` / `mdss doctor`)
259
305
 
260
306
  `mdss check` (alias: `mdss doctor`) is an **offline, read-only** diagnostic for
@@ -384,10 +430,15 @@ instead of being silently swallowed.
384
430
 
385
431
  | Flag | Meaning |
386
432
  |------|---------|
433
+ | `--config <file>` | Path to config file (default: `.mdssrc.json`, `mdss.config.json`, `.mdssrc`). |
387
434
  | `--db <dir>` | Folder of `.md` files (or set `MDSS_DB`). Can be anywhere on disk. |
388
435
  | `--index-dir <dir>` | Where to store the index (default: `<db>/.mdss`). |
389
436
  | `--cache-dir <dir>` | Model cache dir. Default: `$XDG_CACHE_HOME/mdss` if set, else `~/.cache/mdss` on **all** platforms (so on Windows that's `C:\Users\<you>\.cache\mdss` — *not* `%LOCALAPPDATA%`; override with `MDSS_CACHE_DIR`). |
390
437
  | `--model <name\|id>` | Embedding model (default `e5-base`). See `mdss models`. |
438
+ | `--workers <n>` | Number of parallel batch workers for embedding (default: 1). |
439
+ | `--format <fmt>` | Export format: `jsonl` (default), `csv`, `parquet` (`export`). |
440
+ | `--no-vectors` | Omit embeddings from export (`export`). |
441
+ | `--output <file>` | Output file path (`export`, default: stdout). |
391
442
  | `--ignore <glob>` | Skip files/paths; repeatable. e.g. `--ignore "log.md" --ignore "**/archive/**"`. |
392
443
  | `--path <glob>` | Search only files matching glob; repeatable. e.g. `--path "docs/**"`. |
393
444
  | `--since <date>` | Search only files modified at/after date (`YYYY-MM-DD` or ISO 8601). |
@@ -395,6 +446,8 @@ instead of being silently swallowed.
395
446
  | `--json` | Machine-readable output: `index` → build result JSON, `stats` → index stats JSON, `search` → hit list JSON (each hit includes `matches` — the query terms found in the chunk). |
396
447
  | `--semantic` | `search` only: pure vector ranking, skip lexical/RRF fusion (zero lexical tokenization). |
397
448
  | `--rerank` | Re-rank the candidate pool with a cross-encoder (`Xenova/bge-reranker-base`, ~280 MB model, downloaded on first use). Slower, sharper results — see *Reranking* below. |
449
+ | `--ann` | Approximate nearest neighbor search via IVF clustering (search; auto-enables for large corpora). |
450
+ | `--nprobe <n>` | Number of nearest centroid clusters to probe during ANN search (default 8). |
398
451
  | `--port <n>` | HTTP port for `serve` (default 8747, or `MDSS_PORT`). |
399
452
  | `--host <ip>` | Bind address for `serve` (default `127.0.0.1` — loopback only; use `0.0.0.0` to expose on the LAN, or `MDSS_HOST`). |
400
453
  | `--watch` | `serve`: re-index incrementally when files change. |
@@ -422,6 +475,30 @@ mdss index
422
475
  mdss search "incident runbook for db failover"
423
476
  ```
424
477
 
478
+ ### Configuration file (`.mdssrc` / `mdss.config.json`)
479
+
480
+ Instead of repeating CLI options on every command, create a `.mdssrc.json` or `mdss.config.json` file.
481
+ `mdss` discovers config files by searching:
482
+ 1. An explicit path passed via `--config <path>` or the `MDSS_CONFIG` environment variable.
483
+ 2. Current working directory up through ancestor directories (`.mdssrc.json`, `mdss.config.json`, `.mdssrc`).
484
+ 3. User global config at `~/.mdssrc.json` or `~/.mdssrc`.
485
+
486
+ CLI flags always override configuration values.
487
+
488
+ Example `.mdssrc.json`:
489
+
490
+ ```json
491
+ {
492
+ "db": "./notes",
493
+ "model": "e5-base",
494
+ "indexDir": "./notes/.mdss",
495
+ "ignore": ["archive/**", "drafts/**"],
496
+ "k": 6
497
+ }
498
+ ```
499
+
500
+ Run `mdss check` to validate your configuration file schema and detect unknown keys.
501
+
425
502
  ## Models
426
503
 
427
504
  ```bash
@@ -527,6 +604,10 @@ section instead of silently producing wrong vectors.
527
604
  No external services, no database — the whole index is one JSON file and search
528
605
  is an in-memory dot-product sweep.
529
606
 
607
+ ## Integrations
608
+
609
+ - **Obsidian**: An official plugin is available in [`integrations/obsidian`](./integrations/obsidian) providing a live semantic search panel directly in your vault connected to `mdss serve`.
610
+
530
611
  ## License
531
612
 
532
613
  MIT © chelslava
package/bin/cli.mjs CHANGED
@@ -14,12 +14,13 @@ 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 { createServe, DEFAULT_PORT, DEFAULT_HOST } from '../dist/serve.js';
21
+ import { MODELS, DEFAULT_MODEL, resolveModel } from '../dist/models.js';
22
+ import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath } from '../dist/core.js';
23
+ import { inspectIndexSchema, validateCurrentChunk, validateIndexEnvelope, validateNumericVector } from '../dist/index-format.js';
23
24
 
24
25
  const HERE = path.dirname(fileURLToPath(import.meta.url));
25
26
  const PKG_ROOT = path.resolve(HERE, '..');
@@ -49,8 +50,15 @@ function parseArgs(argv) {
49
50
  else if (a === '--watch') opts.watch = true;
50
51
  else if (a === '--rerank') opts.rerank = true;
51
52
  else if (a === '--explain') opts.explain = true;
53
+ else if (a === '--interactive' || a === '-i') opts.interactive = true;
54
+ else if (a === '--list-tools') opts.listTools = true;
52
55
  else if (a === '--version') opts.version = true;
53
56
  else if (a === '-h' || a === '--help') opts.help = true;
57
+ else if (a === '--workers') opts.workers = nextInt(argv, ++i, a);
58
+ else if (a === '--config') opts.config = nextValue(argv, ++i, a);
59
+ else if (a === '--format') opts.format = nextValue(argv, ++i, a);
60
+ else if (a === '--no-vectors' || a === '--no-vector') opts.noVectors = true;
61
+ else if (a === '--output' || a === '-o') opts.output = nextValue(argv, ++i, a);
54
62
  else if (a === '--db') opts.db = nextValue(argv, ++i, a);
55
63
  else if (a === '--index-dir') opts.indexDir = nextValue(argv, ++i, a);
56
64
  else if (a === '--cache-dir') opts.cacheDir = nextValue(argv, ++i, a);
@@ -61,6 +69,8 @@ function parseArgs(argv) {
61
69
  else if (a === '--target-tokens') opts.targetTokens = nextInt(argv, ++i, a);
62
70
  else if (a === '--port') opts.port = nextInt(argv, ++i, a);
63
71
  else if (a === '--host') opts.host = nextValue(argv, ++i, a);
72
+ else if (a === '--api-key') opts.apiKey = nextValue(argv, ++i, a);
73
+ else if (a === '--health-public') opts.healthPublic = true;
64
74
  else if (a === '--watch-delay') opts.watchDelay = nextInt(argv, ++i, a);
65
75
  else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
66
76
  else if (a === '--since') opts.since = nextValue(argv, ++i, a);
@@ -70,6 +80,8 @@ function parseArgs(argv) {
70
80
  const v = nextValue(argv, ++i, a);
71
81
  opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
72
82
  }
83
+ else if (a === '--ann') opts.ann = true;
84
+ else if (a === '--nprobe') opts.nprobe = nextInt(argv, ++i, a);
73
85
  else if (a === '--project') opts.project = nextValue(argv, ++i, a);
74
86
  else if (a === '--type') opts.type = nextValue(argv, ++i, a);
75
87
  else if (a === '--status') opts.status = nextValue(argv, ++i, a);
@@ -95,6 +107,86 @@ function nextInt(argv, i, flag) {
95
107
  return k;
96
108
  }
97
109
 
110
+ const KNOWN_CONFIG_KEYS = new Set([
111
+ 'db', 'model', 'indexDir', 'index-dir', 'cacheDir', 'cache-dir',
112
+ 'ignore', 'path', 'k', 'maxPerFile', 'max-per-file', 'maxPerDoc', 'max-per-doc',
113
+ 'targetTokens', 'target-tokens', 'port', 'host', 'apiKey', 'api-key',
114
+ 'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
115
+ 'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
116
+ 'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe'
117
+ ]);
118
+
119
+ function findConfigFile(explicitPath) {
120
+ if (explicitPath) {
121
+ const abs = path.resolve(explicitPath);
122
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) die(`config file not found: ${abs}`);
123
+ return abs;
124
+ }
125
+ if (process.env.MDSS_CONFIG) {
126
+ const abs = path.resolve(process.env.MDSS_CONFIG);
127
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) die(`config file from MDSS_CONFIG not found: ${abs}`);
128
+ return abs;
129
+ }
130
+ const configNames = ['.mdssrc.json', 'mdss.config.json', '.mdssrc'];
131
+ let curr = process.cwd();
132
+ while (true) {
133
+ for (const name of configNames) {
134
+ const candidate = path.join(curr, name);
135
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
136
+ return candidate;
137
+ }
138
+ }
139
+ const parent = path.dirname(curr);
140
+ if (parent === curr) break;
141
+ curr = parent;
142
+ }
143
+ try {
144
+ const home = os.homedir();
145
+ if (home) {
146
+ for (const name of ['.mdssrc.json', '.mdssrc']) {
147
+ const candidate = path.join(home, name);
148
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
149
+ return candidate;
150
+ }
151
+ }
152
+ }
153
+ } catch {}
154
+ return null;
155
+ }
156
+
157
+ function loadConfigFile(configPath) {
158
+ if (!configPath) return { config: {}, path: null, unknownKeys: [], error: null };
159
+ try {
160
+ const raw = fs.readFileSync(configPath, 'utf8');
161
+ const parsed = JSON.parse(raw);
162
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
163
+ return { config: {}, path: configPath, unknownKeys: [], error: `Config at ${configPath} must be a JSON object` };
164
+ }
165
+ const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_CONFIG_KEYS.has(k));
166
+ const normalized = {};
167
+ for (const [k, v] of Object.entries(parsed)) {
168
+ const camel = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
169
+ normalized[camel] = v;
170
+ }
171
+ return { config: normalized, path: configPath, unknownKeys, error: null };
172
+ } catch (e) {
173
+ return { config: {}, path: configPath, unknownKeys: [], error: `Failed to parse config at ${configPath}: ${e.message}` };
174
+ }
175
+ }
176
+
177
+ function applyConfigDefaults(opts, config) {
178
+ for (const [k, v] of Object.entries(config)) {
179
+ if (k === 'ignore' || k === 'path') {
180
+ if ((!opts[k] || opts[k].length === 0) && v !== undefined) {
181
+ opts[k] = Array.isArray(v) ? [...v] : [v];
182
+ }
183
+ } else if (opts[k] === undefined && v !== undefined) {
184
+ opts[k] = v;
185
+ }
186
+ }
187
+ return opts;
188
+ }
189
+
98
190
  function resolveDb(opts) {
99
191
  const db = opts.db || process.env.MDSS_DB;
100
192
  if (!db) die('Missing --db <dir> (or set MDSS_DB). Path to your .md folder.');
@@ -102,13 +194,23 @@ function resolveDb(opts) {
102
194
  if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
103
195
  die(`--db is not a directory: ${abs}`);
104
196
  }
105
- return abs;
197
+ let safeDb;
198
+ try {
199
+ safeDb = assertSafePath(abs);
200
+ } catch (e) {
201
+ die(e.message);
202
+ }
203
+ return safeDb;
106
204
  }
107
205
 
108
206
  function resolveIndexDir(opts, db) {
109
207
  // Default: <db>/.mdss. Override with --index-dir or MDSS_INDEX_DIR.
110
208
  const dir = opts.indexDir || process.env.MDSS_INDEX_DIR || path.join(db, '.mdss');
111
- return path.resolve(dir);
209
+ try {
210
+ return assertSafePath(dir);
211
+ } catch (e) {
212
+ die(e.message);
213
+ }
112
214
  }
113
215
 
114
216
  const resolveCache = (opts) =>
@@ -127,15 +229,22 @@ Usage:
127
229
  mdss index --db <dir> [options] Build/refresh the index
128
230
  mdss stats --db <dir> [--json] Index stats without loading the model
129
231
  mdss check --db <dir> [--json] Diagnose index/db/model cache (alias: doctor)
232
+ mdss export --db <dir> [options] Export index to JSONL / CSV / Parquet
130
233
  mdss search --db <dir> [options] "query" Search by meaning
131
234
  mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
235
+ mdss mcp --db <dir> [--list-tools] Start MCP server over stdio for LLM agents / IDEs
132
236
  mdss models List available models
133
237
 
134
238
  Options:
239
+ --config <file> Path to config file (default: .mdssrc.json / mdss.config.json).
135
240
  --db <dir> Folder of .md files (or env MDSS_DB). Can be anywhere.
136
241
  --index-dir <dir> Where to store the index (default: <db>/.mdss).
137
242
  --cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
138
243
  --model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
244
+ --workers <n> Number of parallel batch workers for indexing (default: 1).
245
+ --format <fmt> Export format: jsonl (default), csv, parquet (export).
246
+ --no-vectors Omit vector embeddings from export (export).
247
+ --output <file> Output file path (export, default: stdout).
139
248
  --ignore <glob> Skip files/paths (repeatable). e.g. --ignore "log.md".
140
249
  --path <glob> Search only files matching glob (repeatable). e.g. --path "docs/**".
141
250
  --since <date> Search only files modified at/after date (YYYY-MM-DD or ISO).
@@ -143,6 +252,8 @@ Options:
143
252
  --json Machine-readable output (index, stats, search).
144
253
  --semantic Pure vector ranking, skip lexical/RRF fusion (search).
145
254
  --rerank Re-rank candidates with a cross-encoder (search; ~280MB model).
255
+ --ann Approximate nearest neighbor search via IVF (search).
256
+ --nprobe <n> Number of centroids to probe for ANN (search, default: 8).
146
257
  --port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
147
258
  --host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
148
259
  only; use 0.0.0.0 to expose on the LAN, env MDSS_HOST).
@@ -164,11 +275,41 @@ Examples:
164
275
  curl -X POST localhost:8747/search -d '{"query":"rotate api token","k":5}'
165
276
  `;
166
277
 
278
+ function formatDuration(sec) {
279
+ const s = Math.max(0, Math.floor(sec));
280
+ const h = Math.floor(s / 3600);
281
+ const m = Math.floor((s % 3600) / 60);
282
+ const remainingS = s % 60;
283
+ return `${h}:${String(m).padStart(2, '0')}:${String(remainingS).padStart(2, '0')}`;
284
+ }
285
+
167
286
  async function cmdIndex(opts) {
168
287
  const db = resolveDb(opts);
169
288
  const indexDir = resolveIndexDir(opts, db);
170
289
  const cacheDir = resolveCache(opts);
171
290
  const t0 = Date.now();
291
+ let lastProgressUpdate = 0;
292
+
293
+ const onProgress = (done, total, chunksPerSec) => {
294
+ if (!process.stderr.isTTY || opts.json || total <= 100) return;
295
+ const now = Date.now();
296
+ if (now - lastProgressUpdate < 500 && done < total) return;
297
+ lastProgressUpdate = now;
298
+ const percent = Math.floor((done / total) * 100);
299
+ const barWidth = 10;
300
+ const filled = Math.min(barWidth, Math.max(0, Math.round((done / total) * barWidth)));
301
+ const bar = '[' + '#'.repeat(filled) + '-'.repeat(barWidth - filled) + ']';
302
+ const remaining = total - done;
303
+ const etaSec = chunksPerSec > 0 ? remaining / chunksPerSec : 0;
304
+ const etaStr = formatDuration(etaSec);
305
+ const speedStr = `${chunksPerSec.toFixed(1)} chunks/s`;
306
+ const line = `${bar} ${percent}% │ ${done}/${total} chunks │ ${speedStr} │ ETA ${etaStr}`;
307
+ process.stderr.write(`\r${line}`);
308
+ if (done >= total) {
309
+ process.stderr.write('\n');
310
+ }
311
+ };
312
+
172
313
  let r;
173
314
  try {
174
315
  r = await buildIndex({
@@ -176,7 +317,13 @@ async function cmdIndex(opts) {
176
317
  modelName: opts.model || DEFAULT_MODEL,
177
318
  ignore: opts.ignore,
178
319
  offline: resolveOffline(opts),
179
- log: s => process.stderr.write(s + '\n'),
320
+ workers: opts.workers,
321
+ ann: opts.ann,
322
+ log: s => {
323
+ if (process.stderr.isTTY && !opts.json && /^\s+\d+\/\d+$/.test(s)) return;
324
+ process.stderr.write(s + '\n');
325
+ },
326
+ onProgress,
180
327
  });
181
328
  } catch (e) {
182
329
  // A held index lock (issue #37) is an EXPECTED operational state (a second
@@ -234,7 +381,7 @@ function cmdStats(opts) {
234
381
  // File count = keys of .hashes.json (per-file md5 map written by buildIndex).
235
382
  // Fall back to unique chunk file paths when the hashes file is missing (a
236
383
  // legacy/corrupt index) rather than dying.
237
- let files = 0;
384
+ let files;
238
385
  try {
239
386
  const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
240
387
  files = Object.keys(hashes).length;
@@ -288,6 +435,119 @@ function cmdStats(opts) {
288
435
  );
289
436
  }
290
437
 
438
+ /**
439
+ * `mdss export` — export the index as JSONL / CSV / Parquet for downstream pipelines (issue #77).
440
+ */
441
+ async function cmdExport(opts) {
442
+ const db = resolveDb(opts);
443
+ const indexDir = resolveIndexDir(opts, db);
444
+ const vectorsPath = path.join(indexDir, 'vectors.json');
445
+ if (!fs.existsSync(vectorsPath)) {
446
+ die(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
447
+ }
448
+
449
+ let index;
450
+ try {
451
+ index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
452
+ } catch (e) {
453
+ die(`${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild.`);
454
+ }
455
+
456
+ const format = (opts.format || 'jsonl').toLowerCase();
457
+ const noVectors = !!opts.noVectors;
458
+ const outStream = opts.output ? fs.createWriteStream(path.resolve(opts.output), 'utf8') : process.stdout;
459
+
460
+ const chunks = Array.isArray(index.chunks) ? index.chunks : [];
461
+ const dim = index.dim;
462
+
463
+ if (format === 'jsonl') {
464
+ for (const c of chunks) {
465
+ let vec = undefined;
466
+ if (!noVectors && c.vec !== undefined) {
467
+ if (typeof c.vec === 'string') {
468
+ try {
469
+ vec = Array.from(decodeVec(c.vec, dim));
470
+ } catch {
471
+ vec = null;
472
+ }
473
+ } else if (Array.isArray(c.vec) || ArrayBuffer.isView(c.vec)) {
474
+ vec = Array.from(c.vec);
475
+ }
476
+ }
477
+ const record = {
478
+ file: c.file,
479
+ title: c.title,
480
+ heading: c.heading,
481
+ headingPath: c.headingPath,
482
+ text: c.text,
483
+ ...(noVectors ? {} : { vec }),
484
+ ...(c.chunkHash ? { chunkHash: c.chunkHash } : {}),
485
+ ...(c.startLine !== undefined ? { startLine: c.startLine } : {}),
486
+ ...(c.endLine !== undefined ? { endLine: c.endLine } : {}),
487
+ ...(c.meta ? { meta: c.meta } : {}),
488
+ };
489
+ outStream.write(JSON.stringify(record) + '\n');
490
+ }
491
+ if (opts.output) {
492
+ await new Promise((resolve) => outStream.end(resolve));
493
+ }
494
+ } else if (format === 'csv') {
495
+ const escapeCsv = (val) => {
496
+ const str = String(val ?? '');
497
+ if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
498
+ return '"' + str.replace(/"/g, '""') + '"';
499
+ }
500
+ return str;
501
+ };
502
+ outStream.write('file,title,heading,headingPath,text\n');
503
+ for (const c of chunks) {
504
+ const pathStr = Array.isArray(c.headingPath) ? c.headingPath.join(' > ') : (c.heading || '');
505
+ const row = [
506
+ escapeCsv(c.file),
507
+ escapeCsv(c.title),
508
+ escapeCsv(c.heading),
509
+ escapeCsv(pathStr),
510
+ escapeCsv(c.text),
511
+ ].join(',');
512
+ outStream.write(row + '\n');
513
+ }
514
+ if (opts.output) {
515
+ await new Promise((resolve) => outStream.end(resolve));
516
+ }
517
+ } else if (format === 'parquet') {
518
+ let parquet;
519
+ try {
520
+ // @ts-ignore
521
+ parquet = await import('parquetjs-lite');
522
+ } catch {
523
+ die('Parquet export requires optional dependency "parquetjs-lite". Install with: npm install parquetjs-lite');
524
+ }
525
+ if (!opts.output) {
526
+ die('Parquet export requires an output file via --output <file.parquet>');
527
+ }
528
+ const schemaFields = {
529
+ file: { type: 'UTF8' },
530
+ title: { type: 'UTF8' },
531
+ heading: { type: 'UTF8' },
532
+ text: { type: 'UTF8' },
533
+ };
534
+ const schema = new parquet.ParquetSchema(schemaFields);
535
+ const outFile = path.resolve(opts.output);
536
+ const writer = await parquet.ParquetWriter.openFile(schema, outFile);
537
+ for (const c of chunks) {
538
+ await writer.appendRow({
539
+ file: c.file || '',
540
+ title: c.title || '',
541
+ heading: c.heading || '',
542
+ text: c.text || '',
543
+ });
544
+ }
545
+ await writer.close();
546
+ } else {
547
+ die(`unknown export format: "${format}". Supported formats: jsonl, csv, parquet.`);
548
+ }
549
+ }
550
+
291
551
  /**
292
552
  * Offline diagnostics for the index/db/model-cache trio (issue #43) — the
293
553
  * `mdss check` / `mdss doctor` backend. Pure read-only: parses vectors.json +
@@ -295,7 +555,7 @@ function cmdStats(opts) {
295
555
  * validator the loader uses), walks the db for staleness, and checks the
296
556
  * transformers.js cache layout for the index's model. NEVER loads the
297
557
  * embedding model and NEVER touches the network.
298
- * @param {{db:string, indexDir:string, cacheDir:string, requireOffline?:boolean}} paths
558
+ * @param {{db:string, indexDir:string, cacheDir:string, requireOffline?:boolean, config?:any}} paths
299
559
  * @returns {CheckReport}
300
560
  */
301
561
 
@@ -312,7 +572,7 @@ function cmdStats(opts) {
312
572
  * @property {{exists:boolean, stale:boolean, error:(string|null)}} db
313
573
  * @property {{id:(string|null), cached:boolean, cachePath:(string|null), error:(string|null)}} model
314
574
  */
315
- export function checkHealth({ db, indexDir, cacheDir, requireOffline = false }) {
575
+ function checkHealth({ db, indexDir, cacheDir, requireOffline = false, config = null }) {
316
576
  const report = {
317
577
  healthy: true,
318
578
  index: { exists: false, parses: false, schemaVersion: null, format: null, recognized: true, error: null },
@@ -320,7 +580,21 @@ export function checkHealth({ db, indexDir, cacheDir, requireOffline = false })
320
580
  chunks: { total: 0, valid: 0, invalid: [] },
321
581
  db: { exists: true, stale: false, error: null },
322
582
  model: { id: null, cached: false, cachePath: null, error: null },
583
+ config: null,
323
584
  };
585
+
586
+ // --- config file: exists, valid JSON, schema / unknown keys (issue #68) ---
587
+ if (config && config.path) {
588
+ report.config = {
589
+ path: config.path,
590
+ valid: !config.error && (!config.unknownKeys || config.unknownKeys.length === 0),
591
+ unknownKeys: config.unknownKeys || [],
592
+ error: config.error || (config.unknownKeys && config.unknownKeys.length > 0 ? `unknown config keys: ${config.unknownKeys.join(', ')}` : null),
593
+ };
594
+ if (!report.config.valid) {
595
+ report.healthy = false;
596
+ }
597
+ }
324
598
  const vectorsPath = path.join(indexDir, 'vectors.json');
325
599
  const hashesPath = path.join(indexDir, '.hashes.json');
326
600
 
@@ -332,8 +606,22 @@ export function checkHealth({ db, indexDir, cacheDir, requireOffline = false })
332
606
  }
333
607
  report.index.exists = true;
334
608
  let index;
609
+ const raw = fs.readFileSync(vectorsPath, 'utf8');
610
+ const shaPath = path.join(indexDir, 'vectors.json.sha256');
611
+ if (fs.existsSync(shaPath)) {
612
+ try {
613
+ const shaContent = fs.readFileSync(shaPath, 'utf8').trim();
614
+ const expectedHash = shaContent.split(/\s+/)[0];
615
+ const actualHash = crypto.createHash('sha256').update(raw).digest('hex');
616
+ if (expectedHash && actualHash !== expectedHash) {
617
+ report.index.error = `vectors.json integrity check failed (SHA-256 mismatch) — run \`mdss index\` to rebuild`;
618
+ report.healthy = false;
619
+ return report;
620
+ }
621
+ } catch { /* ignore unreadable sha file */ }
622
+ }
335
623
  try {
336
- index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
624
+ index = JSON.parse(raw);
337
625
  report.index.parses = true;
338
626
  } catch (e) {
339
627
  report.index.error = `${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild`;
@@ -469,7 +757,11 @@ function cmdCheck(opts) {
469
757
  const db = resolveDb(opts);
470
758
  const indexDir = resolveIndexDir(opts, db);
471
759
  const cacheDir = resolveCache(opts);
472
- const report = checkHealth({ db, indexDir, cacheDir, requireOffline: resolveOffline(opts) });
760
+ const report = checkHealth({
761
+ db, indexDir, cacheDir,
762
+ requireOffline: resolveOffline(opts),
763
+ config: opts._config,
764
+ });
473
765
 
474
766
  if (opts.json) {
475
767
  process.stdout.write(JSON.stringify(report, null, 2) + '\n');
@@ -543,6 +835,14 @@ function cmdCheck(opts) {
543
835
  out += warn('model: index has no model field (legacy index)');
544
836
  }
545
837
 
838
+ if (report.config) {
839
+ if (report.config.valid) {
840
+ out += ok(`config: ${report.config.path}`);
841
+ } else {
842
+ out += fail(`config: ${report.config.path} (${report.config.error})`);
843
+ }
844
+ }
845
+
546
846
  out += report.healthy
547
847
  ? 'check: healthy (exit 0)\n'
548
848
  : 'check: 1+ problem(s) found (exit 1)\n';
@@ -550,7 +850,24 @@ function cmdCheck(opts) {
550
850
  process.exitCode = report.healthy ? 0 : 1;
551
851
  }
552
852
 
853
+ async function cmdTui(opts) {
854
+ const db = resolveDb(opts);
855
+ const indexDir = resolveIndexDir(opts, db);
856
+ const cacheDir = resolveCache(opts);
857
+ const query = opts._.join(' ').trim();
858
+ const { runTui } = await import('../dist/tui.js');
859
+ await runTui({
860
+ db, indexDir, cacheDir, query,
861
+ k: opts.k || 20,
862
+ semanticOnly: !!opts.semantic,
863
+ offline: resolveOffline(opts),
864
+ path: opts.path.length > 0 ? opts.path : undefined,
865
+ rerank: !!opts.rerank,
866
+ });
867
+ }
868
+
553
869
  async function cmdSearch(opts) {
870
+ if (opts.interactive) return cmdTui(opts);
554
871
  const db = resolveDb(opts);
555
872
  const indexDir = resolveIndexDir(opts, db);
556
873
  const cacheDir = resolveCache(opts);
@@ -572,6 +889,8 @@ async function cmdSearch(opts) {
572
889
  canonicalOnly: opts.canonical,
573
890
  explain: !!opts.explain,
574
891
  maxPerFile: opts.maxPerFile || opts.maxPerDoc,
892
+ ann: opts.ann,
893
+ nprobe: opts.nprobe,
575
894
  });
576
895
 
577
896
  if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
@@ -648,6 +967,8 @@ async function cmdServe(opts) {
648
967
  watch: !!opts.watch,
649
968
  watchInterval: opts.watchInterval,
650
969
  watchDelay: opts.watchDelay,
970
+ apiKey: opts.apiKey || process.env.MDSS_API_KEY,
971
+ healthPublic: !!opts.healthPublic || process.env.MDSS_HEALTH_PUBLIC === 'true',
651
972
  log,
652
973
  });
653
974
 
@@ -671,9 +992,37 @@ async function cmdServe(opts) {
671
992
  process.on('SIGTERM', () => stop('SIGTERM'));
672
993
  }
673
994
 
995
+ async function cmdMcp(opts) {
996
+ if (opts.listTools) {
997
+ const { MCP_TOOLS } = await import('../dist/mcp.js');
998
+ process.stdout.write(JSON.stringify(MCP_TOOLS, null, 2) + '\n');
999
+ return;
1000
+ }
1001
+ const db = resolveDb(opts);
1002
+ const indexDir = resolveIndexDir(opts, db);
1003
+ const cacheDir = resolveCache(opts);
1004
+ const log = s => process.stderr.write(s + '\n');
1005
+ const { startMcpServer } = await import('../dist/mcp.js');
1006
+ await startMcpServer({
1007
+ db, indexDir, cacheDir,
1008
+ modelName: opts.model || DEFAULT_MODEL,
1009
+ ignore: opts.ignore,
1010
+ offline: resolveOffline(opts),
1011
+ log,
1012
+ });
1013
+ }
1014
+
674
1015
  async function main() {
675
1016
  const argv = process.argv.slice(2);
676
1017
  const opts = parseArgs(argv);
1018
+ const configPath = findConfigFile(opts.config);
1019
+ const loadedConfig = loadConfigFile(configPath);
1020
+ if (loadedConfig.error && opts.config) {
1021
+ die(loadedConfig.error);
1022
+ }
1023
+ opts._config = loadedConfig;
1024
+ applyConfigDefaults(opts, loadedConfig.config);
1025
+
677
1026
  const cmd = opts._.shift();
678
1027
 
679
1028
  if (opts.version) { process.stdout.write(`${VERSION}\n`); return; }
@@ -683,8 +1032,11 @@ async function main() {
683
1032
  case 'stats': return cmdStats(opts);
684
1033
  case 'check':
685
1034
  case 'doctor': return cmdCheck(opts);
1035
+ case 'export': return cmdExport(opts);
686
1036
  case 'search': return cmdSearch(opts);
1037
+ case 'tui': return cmdTui(opts);
687
1038
  case 'serve': return cmdServe(opts);
1039
+ case 'mcp': return cmdMcp(opts);
688
1040
  case 'models': return cmdModels();
689
1041
  default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
690
1042
  }
@@ -702,5 +1054,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
702
1054
  export {
703
1055
  parseArgs, nextInt, nextValue,
704
1056
  resolveDb, resolveIndexDir, resolveCache, resolveOffline,
1057
+ findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
1058
+ checkHealth, cmdExport,
705
1059
  die, main, HELP, VERSION,
706
1060
  };