md-semantic-search 0.3.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 (59) hide show
  1. package/README.md +438 -20
  2. package/RESEARCH.md +117 -37
  3. package/bin/cli.mjs +931 -36
  4. package/dist/collapse.d.ts +6 -0
  5. package/dist/collapse.js +20 -0
  6. package/dist/core.d.ts +84 -0
  7. package/dist/core.js +479 -0
  8. package/dist/frontmatter.d.ts +20 -0
  9. package/dist/frontmatter.js +150 -0
  10. package/dist/index-format.d.ts +23 -0
  11. package/dist/index-format.js +125 -0
  12. package/dist/index.d.ts +29 -0
  13. package/dist/index.js +29 -0
  14. package/dist/indexer.d.ts +70 -0
  15. package/dist/indexer.js +452 -0
  16. package/dist/ivf.d.ts +33 -0
  17. package/dist/ivf.js +144 -0
  18. package/dist/lexical.d.ts +44 -0
  19. package/dist/lexical.js +226 -0
  20. package/dist/markdown-parser.d.ts +27 -0
  21. package/dist/markdown-parser.js +252 -0
  22. package/dist/mcp.d.ts +111 -0
  23. package/dist/mcp.js +216 -0
  24. package/dist/metrics.d.ts +43 -0
  25. package/dist/metrics.js +97 -0
  26. package/dist/models.d.ts +38 -0
  27. package/dist/models.js +181 -0
  28. package/dist/rerank.d.ts +10 -0
  29. package/dist/rerank.js +41 -0
  30. package/dist/search.d.ts +135 -0
  31. package/dist/search.js +492 -0
  32. package/dist/serve.d.ts +49 -0
  33. package/dist/serve.js +321 -0
  34. package/dist/tui.d.ts +17 -0
  35. package/dist/tui.js +196 -0
  36. package/dist/wikilinks.d.ts +47 -0
  37. package/dist/wikilinks.js +171 -0
  38. package/package.json +25 -2
  39. package/src/collapse.ts +26 -0
  40. package/src/core.ts +546 -0
  41. package/src/frontmatter.ts +190 -0
  42. package/src/index-format.ts +151 -0
  43. package/src/index.ts +55 -0
  44. package/src/indexer.ts +585 -0
  45. package/src/ivf.ts +190 -0
  46. package/src/lexical.ts +257 -0
  47. package/src/markdown-parser.ts +303 -0
  48. package/src/mcp.ts +243 -0
  49. package/src/metrics.ts +135 -0
  50. package/src/models.ts +219 -0
  51. package/src/rerank.ts +54 -0
  52. package/src/search.ts +628 -0
  53. package/src/serve.ts +372 -0
  54. package/src/tui.ts +227 -0
  55. package/src/wikilinks.ts +244 -0
  56. package/src/core.mjs +0 -171
  57. package/src/indexer.mjs +0 -197
  58. package/src/models.mjs +0 -67
  59. package/src/search.mjs +0 -107
package/bin/cli.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @ts-check
2
3
  /**
3
4
  * md-semantic-search (mdss) — local semantic search over any folder of .md files.
4
5
  *
@@ -10,35 +11,182 @@
10
11
  * inside this project. The index defaults to <db>/.mdss unless --index-dir is given.
11
12
  */
12
13
  import path from 'node:path';
13
- import { fileURLToPath } from 'node:url';
14
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
15
  import fs from 'node:fs';
15
- import { buildIndex } from '../src/indexer.mjs';
16
- import { search } from '../src/search.mjs';
17
- import { MODELS, DEFAULT_MODEL } from '../src/models.mjs';
16
+ import os from 'node:os';
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';
18
24
 
19
25
  const HERE = path.dirname(fileURLToPath(import.meta.url));
20
26
  const PKG_ROOT = path.resolve(HERE, '..');
21
- const DEFAULT_CACHE = path.join(PKG_ROOT, '.cache');
27
+ const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
28
+
29
+ /**
30
+ * User-level model cache. Default: ~/.cache/mdss (respecting XDG_CACHE_HOME on
31
+ * POSIX); falls back to <pkg>/.cache when homedir is unavailable. Explicit
32
+ * --cache-dir / MDSS_CACHE_DIR always win (issue #9).
33
+ */
34
+ function defaultCacheDir() {
35
+ try {
36
+ if (process.env.XDG_CACHE_HOME) return path.join(process.env.XDG_CACHE_HOME, 'mdss');
37
+ const home = os.homedir();
38
+ if (home) return path.join(home, '.cache', 'mdss');
39
+ } catch { /* homedir unavailable */ }
40
+ return path.join(PKG_ROOT, '.cache');
41
+ }
22
42
 
23
43
  function parseArgs(argv) {
24
- const opts = { _: [], ignore: [] };
44
+ const opts = { _: [], ignore: [], path: [] };
25
45
  for (let i = 0; i < argv.length; i++) {
26
46
  const a = argv[i];
27
- if (a === '--db') opts.db = argv[++i];
28
- else if (a === '--index-dir') opts.indexDir = argv[++i];
29
- else if (a === '--cache-dir') opts.cacheDir = argv[++i];
30
- else if (a === '--model') opts.model = argv[++i];
31
- else if (a === '--k') opts.k = parseInt(argv[++i], 10);
32
- else if (a === '--ignore') opts.ignore.push(argv[++i]);
33
- else if (a === '--json') opts.json = true;
47
+ if (a === '--json') opts.json = true;
34
48
  else if (a === '--semantic') opts.semantic = true;
35
49
  else if (a === '--offline') opts.offline = true;
50
+ else if (a === '--watch') opts.watch = true;
51
+ else if (a === '--rerank') opts.rerank = true;
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;
55
+ else if (a === '--version') opts.version = true;
36
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);
62
+ else if (a === '--db') opts.db = nextValue(argv, ++i, a);
63
+ else if (a === '--index-dir') opts.indexDir = nextValue(argv, ++i, a);
64
+ else if (a === '--cache-dir') opts.cacheDir = nextValue(argv, ++i, a);
65
+ else if (a === '--model') opts.model = nextValue(argv, ++i, a);
66
+ else if (a === '--k') opts.k = nextInt(argv, ++i, a);
67
+ else if (a === '--max-per-file') opts.maxPerFile = nextInt(argv, ++i, a);
68
+ else if (a === '--max-per-doc') opts.maxPerDoc = nextInt(argv, ++i, a);
69
+ else if (a === '--target-tokens') opts.targetTokens = nextInt(argv, ++i, a);
70
+ else if (a === '--port') opts.port = nextInt(argv, ++i, a);
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;
74
+ else if (a === '--watch-delay') opts.watchDelay = nextInt(argv, ++i, a);
75
+ else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
76
+ else if (a === '--since') opts.since = nextValue(argv, ++i, a);
77
+ else if (a === '--path') opts.path.push(nextValue(argv, ++i, a));
78
+ else if (a === '--ignore') opts.ignore.push(nextValue(argv, ++i, a));
79
+ else if (a === '--tag') {
80
+ const v = nextValue(argv, ++i, a);
81
+ opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
82
+ }
83
+ else if (a === '--ann') opts.ann = true;
84
+ else if (a === '--nprobe') opts.nprobe = nextInt(argv, ++i, a);
85
+ else if (a === '--project') opts.project = nextValue(argv, ++i, a);
86
+ else if (a === '--type') opts.type = nextValue(argv, ++i, a);
87
+ else if (a === '--status') opts.status = nextValue(argv, ++i, a);
88
+ else if (a === '--canonical') opts.canonical = true;
89
+ else if (a.startsWith('-')) die(`unknown option: ${a}. Try \`mdss --help\`.`);
37
90
  else opts._.push(a);
38
91
  }
39
92
  return opts;
40
93
  }
41
94
 
95
+ /** Value of the flag at `i`; errors when missing or followed by another flag. */
96
+ function nextValue(argv, i, flag) {
97
+ const v = argv[i];
98
+ if (v === undefined || v.startsWith('-')) die(`missing value for ${flag}`);
99
+ return v;
100
+ }
101
+
102
+ /** Like nextValue but requires a positive integer (issue #8: --k abc → clear error). */
103
+ function nextInt(argv, i, flag) {
104
+ const v = nextValue(argv, i, flag);
105
+ const k = Number.parseInt(v, 10);
106
+ if (!Number.isInteger(k) || k <= 0) die(`${flag} must be a positive integer, got "${v}"`);
107
+ return k;
108
+ }
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
+
42
190
  function resolveDb(opts) {
43
191
  const db = opts.db || process.env.MDSS_DB;
44
192
  if (!db) die('Missing --db <dir> (or set MDSS_DB). Path to your .md folder.');
@@ -46,17 +194,27 @@ function resolveDb(opts) {
46
194
  if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
47
195
  die(`--db is not a directory: ${abs}`);
48
196
  }
49
- return abs;
197
+ let safeDb;
198
+ try {
199
+ safeDb = assertSafePath(abs);
200
+ } catch (e) {
201
+ die(e.message);
202
+ }
203
+ return safeDb;
50
204
  }
51
205
 
52
206
  function resolveIndexDir(opts, db) {
53
207
  // Default: <db>/.mdss. Override with --index-dir or MDSS_INDEX_DIR.
54
208
  const dir = opts.indexDir || process.env.MDSS_INDEX_DIR || path.join(db, '.mdss');
55
- return path.resolve(dir);
209
+ try {
210
+ return assertSafePath(dir);
211
+ } catch (e) {
212
+ die(e.message);
213
+ }
56
214
  }
57
215
 
58
216
  const resolveCache = (opts) =>
59
- path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR || DEFAULT_CACHE);
217
+ path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR || defaultCacheDir());
60
218
 
61
219
  /** --offline flag or MDSS_OFFLINE=1 env → never touch the network. */
62
220
  const resolveOffline = (opts) => !!opts.offline || !!process.env.MDSS_OFFLINE;
@@ -65,24 +223,46 @@ function die(msg) {
65
223
  process.stderr.write(`error: ${msg}\n`);
66
224
  process.exit(1);
67
225
  }
68
-
69
226
  const HELP = `md-semantic-search (mdss) — local, private semantic search over Markdown
70
227
 
71
228
  Usage:
72
229
  mdss index --db <dir> [options] Build/refresh the index
230
+ mdss stats --db <dir> [--json] Index stats without loading the model
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
73
233
  mdss search --db <dir> [options] "query" Search by meaning
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
74
236
  mdss models List available models
75
237
 
76
238
  Options:
239
+ --config <file> Path to config file (default: .mdssrc.json / mdss.config.json).
77
240
  --db <dir> Folder of .md files (or env MDSS_DB). Can be anywhere.
78
241
  --index-dir <dir> Where to store the index (default: <db>/.mdss).
79
- --cache-dir <dir> Model cache dir (default: <pkg>/.cache or MDSS_CACHE_DIR).
242
+ --cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
80
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).
81
248
  --ignore <glob> Skip files/paths (repeatable). e.g. --ignore "log.md".
249
+ --path <glob> Search only files matching glob (repeatable). e.g. --path "docs/**".
250
+ --since <date> Search only files modified at/after date (YYYY-MM-DD or ISO).
82
251
  --k <n> Number of results (search, default 6).
83
- --json Machine-readable output (search).
252
+ --json Machine-readable output (index, stats, search).
84
253
  --semantic Pure vector ranking, skip lexical/RRF fusion (search).
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).
257
+ --port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
258
+ --host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
259
+ only; use 0.0.0.0 to expose on the LAN, env MDSS_HOST).
260
+ --watch serve: re-index incrementally on file changes (mtime poll).
261
+ --watch-interval <ms> serve --watch: poll every N ms (default 3000).
262
+ --watch-delay <ms> serve --watch: quiet-period debounce before a burst of
263
+ saves triggers ONE re-index (default 1000; issue #42).
85
264
  --offline Never download the model; require a cached one (env MDSS_OFFLINE=1).
265
+ --version Print the version and exit.
86
266
  -h, --help Show this help.
87
267
 
88
268
  Examples:
@@ -90,30 +270,604 @@ Examples:
90
270
  mdss index --db /abs/path/to/wiki --model bge-m3 --ignore "log.md" --ignore "**/archive/**"
91
271
  mdss search --db ./docs "how do I rotate the api token"
92
272
  MDSS_DB=./docs mdss search "rollback a failed migration" --k 8 --json
273
+ mdss search --db ./docs "incident runbook" --path "docs/**" --since 2026-01-01
274
+ mdss serve --db ./docs --port 8747 --watch
275
+ curl -X POST localhost:8747/search -d '{"query":"rotate api token","k":5}'
93
276
  `;
94
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
+
95
286
  async function cmdIndex(opts) {
96
287
  const db = resolveDb(opts);
97
288
  const indexDir = resolveIndexDir(opts, db);
98
289
  const cacheDir = resolveCache(opts);
99
290
  const t0 = Date.now();
100
- const r = await buildIndex({
101
- db, indexDir, cacheDir,
102
- modelName: opts.model || DEFAULT_MODEL,
103
- ignore: opts.ignore,
104
- offline: resolveOffline(opts),
105
- log: s => process.stderr.write(s + '\n'),
106
- });
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
+
313
+ let r;
314
+ try {
315
+ r = await buildIndex({
316
+ db, indexDir, cacheDir,
317
+ modelName: opts.model || DEFAULT_MODEL,
318
+ ignore: opts.ignore,
319
+ offline: resolveOffline(opts),
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,
327
+ });
328
+ } catch (e) {
329
+ // A held index lock (issue #37) is an EXPECTED operational state (a second
330
+ // terminal also indexing, or a `serve --watch` daemon mid-cycle) — surface
331
+ // it as a clean one-line error + hint, not a stack of "locked by pid".
332
+ if (/(being written by pid|index is being written)/i.test(String(e?.message || ''))) {
333
+ die(`${e.message}\nAnother mdss process is writing to this index. ` +
334
+ 'Retry once it finishes, or remove a stale .mdss.lock if the holder crashed.');
335
+ }
336
+ throw e;
337
+ }
338
+ // Machine-readable build result for scripts/CI (issue #21): the exact
339
+ // buildIndex return value — lets automation assert "0 embedded" (fully
340
+ // incremental) or "N skipped" without parsing prose.
341
+ if (opts.json) {
342
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
343
+ return;
344
+ }
107
345
  const secs = ((Date.now() - t0) / 1000).toFixed(1);
346
+ const skipped = r.skipped > 0 ? `, ${r.skipped} skipped` : '';
108
347
  process.stderr.write(
109
348
  `\nIndexed ${r.files} file(s) → ${r.chunks} chunks ` +
110
349
  `(${r.reused} reused [${r.reusedChunks} chunk-level, ${r.reusedFiles} file-level], ` +
111
- `${r.embedded} embedded), dim=${r.dim}, ` +
350
+ `${r.embedded} embedded)${skipped}, dim=${r.dim}, ` +
112
351
  `model=${r.model}, ${secs}s\n→ ${r.vectorsPath}\n`,
113
352
  );
114
353
  }
115
354
 
355
+ /**
356
+ * `mdss stats` — machine-readable index statistics WITHOUT loading the
357
+ * embedding model: parses only vectors.json + .hashes.json (issue #21).
358
+ * Gives scripts/CI a cheap sanity check after a re-index (counts, model, dim),
359
+ * staleness detection (ageSeconds), and index-path/format info for migrations.
360
+ */
361
+ function cmdStats(opts) {
362
+ const db = resolveDb(opts);
363
+ const indexDir = resolveIndexDir(opts, db);
364
+ const vectorsPath = path.join(indexDir, 'vectors.json');
365
+ const hashesPath = path.join(indexDir, '.hashes.json');
366
+ if (!fs.existsSync(vectorsPath)) {
367
+ die(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
368
+ }
369
+ let index;
370
+ try {
371
+ index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
372
+ } catch (e) {
373
+ die(`${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild.`);
374
+ }
375
+ try {
376
+ validateIndexEnvelope(index, vectorsPath, { encoding: 'stored' });
377
+ } catch (error) {
378
+ die(error.message);
379
+ }
380
+
381
+ // File count = keys of .hashes.json (per-file md5 map written by buildIndex).
382
+ // Fall back to unique chunk file paths when the hashes file is missing (a
383
+ // legacy/corrupt index) rather than dying.
384
+ let files;
385
+ try {
386
+ const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
387
+ files = Object.keys(hashes).length;
388
+ } catch {
389
+ files = new Set((index.chunks || []).map(c => c.file)).size;
390
+ }
391
+
392
+ const chunks = index.chunkCount ?? (index.chunks ? index.chunks.length : 0);
393
+ const builtMs = index.built ? Date.parse(index.built) : NaN;
394
+ const adapterRepr = (index.model || index.modelAlias)
395
+ ? resolveModel(index.model || index.modelAlias) : null;
396
+ const stats = {
397
+ indexDir,
398
+ schemaVersion: index.schemaVersion ?? 0,
399
+ format: index.format || 'legacy', // binary-v1 vs pre-0.4 decimal
400
+ lexicalFormat: index.schemaVersion >= 3 ? (index.lexical?.format || 'invalid') : null,
401
+ lexicalStatus: index.schemaVersion >= 3 ? 'persisted-bm25' : 'legacy-overlap',
402
+ model: index.model || null, // id@revision (issue #27)
403
+ modelAlias: index.modelAlias || null,
404
+ dim: index.dim ?? null,
405
+ pooling: adapterRepr?.pooling ?? 'mean',
406
+ normalize: adapterRepr?.normalize !== false,
407
+ adapterFamily: adapterRepr?.family ?? null,
408
+ adapterFingerprint: index.adapterFingerprint || null,
409
+ chunks,
410
+ files,
411
+ indexBytes: fs.statSync(vectorsPath).size,
412
+ built: index.built || null,
413
+ ageSeconds: Number.isFinite(builtMs) ? Math.floor((Date.now() - builtMs) / 1000) : null,
414
+ db: index.db || db,
415
+ };
416
+
417
+ if (opts.json) {
418
+ process.stdout.write(JSON.stringify(stats, null, 2) + '\n');
419
+ return;
420
+ }
421
+ const age = stats.ageSeconds === null ? '?' :
422
+ stats.ageSeconds < 60 ? `${stats.ageSeconds}s` :
423
+ stats.ageSeconds < 3600 ? `${Math.floor(stats.ageSeconds / 60)}m ${stats.ageSeconds % 60}s` :
424
+ stats.ageSeconds < 86400 ? `${Math.floor(stats.ageSeconds / 3600)}h ${Math.floor((stats.ageSeconds % 3600) / 60)}m` :
425
+ `${Math.floor(stats.ageSeconds / 86400)}d`;
426
+ process.stdout.write(
427
+ `Index at ${indexDir}\n` +
428
+ ` format: ${stats.format} · model: ${stats.modelAlias || stats.model || '?'} (dim ${stats.dim ?? '?'})\n` +
429
+ ` adapter: ${stats.pooling}${stats.normalize ? '' : ' · raw'}${stats.adapterFamily ? ' · ' + stats.adapterFamily : ''}\n` +
430
+ ` schema: v${stats.schemaVersion} · lexical: ${stats.lexicalFormat || stats.lexicalStatus}\n` +
431
+ ` chunks: ${chunks} · files: ${files}\n` +
432
+ ` size: ${(stats.indexBytes / 1024).toFixed(1)} KiB (vectors.json)\n` +
433
+ ` built: ${stats.built || '?'} (${age} ago)\n` +
434
+ ` db: ${stats.db}\n`,
435
+ );
436
+ }
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
+
551
+ /**
552
+ * Offline diagnostics for the index/db/model-cache trio (issue #43) — the
553
+ * `mdss check` / `mdss doctor` backend. Pure read-only: parses vectors.json +
554
+ * .hashes.json, validates every stored vector with decodeVec (the same
555
+ * validator the loader uses), walks the db for staleness, and checks the
556
+ * transformers.js cache layout for the index's model. NEVER loads the
557
+ * embedding model and NEVER touches the network.
558
+ * @param {{db:string, indexDir:string, cacheDir:string, requireOffline?:boolean, config?:any}} paths
559
+ * @returns {CheckReport}
560
+ */
561
+
562
+ /**
563
+ * Structured result of checkHealth. Every sub-check is a small object with an
564
+ * `error` (string|null) plus check-specific fields; `healthy` is the AND of
565
+ * all checks (a warning — e.g. missing model cache when NOT offline — does not
566
+ * flip it).
567
+ * @typedef {object} CheckReport
568
+ * @property {boolean} healthy
569
+ * @property {{exists:boolean, parses:boolean, schemaVersion:(number|null), format:(string|null), recognized:boolean, error:(string|null)}} index
570
+ * @property {{exists:boolean, parses:boolean, files:number, error:(string|null)}} hashes
571
+ * @property {{total:number, valid:number, invalid:Array<{where:string, error:string}>}} chunks
572
+ * @property {{exists:boolean, stale:boolean, error:(string|null)}} db
573
+ * @property {{id:(string|null), cached:boolean, cachePath:(string|null), error:(string|null)}} model
574
+ */
575
+ function checkHealth({ db, indexDir, cacheDir, requireOffline = false, config = null }) {
576
+ const report = {
577
+ healthy: true,
578
+ index: { exists: false, parses: false, schemaVersion: null, format: null, recognized: true, error: null },
579
+ hashes: { exists: false, parses: false, files: 0, error: null },
580
+ chunks: { total: 0, valid: 0, invalid: [] },
581
+ db: { exists: true, stale: false, error: null },
582
+ model: { id: null, cached: false, cachePath: null, error: null },
583
+ config: null,
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
+ }
598
+ const vectorsPath = path.join(indexDir, 'vectors.json');
599
+ const hashesPath = path.join(indexDir, '.hashes.json');
600
+
601
+ // --- vectors.json: exists, parses, schema + format recognized (#39) ---
602
+ if (!fs.existsSync(vectorsPath)) {
603
+ report.index.error = `No index at ${vectorsPath}. Run \`mdss index\` first.`;
604
+ report.healthy = false;
605
+ return report; // nothing further is checkable without an index
606
+ }
607
+ report.index.exists = true;
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
+ }
623
+ try {
624
+ index = JSON.parse(raw);
625
+ report.index.parses = true;
626
+ } catch (e) {
627
+ report.index.error = `${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild`;
628
+ report.healthy = false;
629
+ return report;
630
+ }
631
+ let schemaVersion;
632
+ try {
633
+ ({ schema: schemaVersion } = inspectIndexSchema(index, 'vectors.json'));
634
+ } catch (error) {
635
+ report.index.recognized = false;
636
+ report.index.error = error.message;
637
+ report.healthy = false;
638
+ return report;
639
+ }
640
+ report.index.schemaVersion = schemaVersion;
641
+ report.index.format = index.format || 'legacy';
642
+ report.index.recognized = schemaVersion <= SCHEMA_VERSION;
643
+ let validated;
644
+ try {
645
+ validated = validateIndexEnvelope(index, 'vectors.json', {
646
+ encoding: 'stored', validateVectors: false,
647
+ });
648
+ } catch (error) {
649
+ report.index.error = error.message;
650
+ report.healthy = false;
651
+ return report;
652
+ }
653
+
654
+ // --- .hashes.json: parses; file count vs chunk count ---
655
+ if (fs.existsSync(hashesPath)) {
656
+ report.hashes.exists = true;
657
+ try {
658
+ const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
659
+ report.hashes.parses = true;
660
+ report.hashes.files = Object.keys(hashes).length;
661
+ } catch (e) {
662
+ report.hashes.error = `${hashesPath} is not valid JSON (${e.message})`;
663
+ report.healthy = false;
664
+ }
665
+ }
666
+
667
+ // --- every chunk's vector: length == dim, no NaN/Infinity, not truncated ---
668
+ // Reuses decodeVec — the exact validator the loader runs (issue #40) — so
669
+ // `mdss check` reports the same "corrupt vector" verdicts without loading
670
+ // the 280MB model.
671
+ const chunks = index.chunks;
672
+ const expectedDim = validated.dim;
673
+ const dim = expectedDim ?? null;
674
+ report.chunks.total = chunks.length;
675
+ for (let position = 0; position < chunks.length; position++) {
676
+ const c = chunks[position];
677
+ const where = c && typeof c === 'object' && !Array.isArray(c)
678
+ ? `${c.file}${c.heading ? ` › ${c.heading}` : ''}` : `chunk ${position}`;
679
+ let bad = null;
680
+ try {
681
+ if (schemaVersion >= 3) {
682
+ validateCurrentChunk(c, position, {
683
+ dim: dim ?? undefined, encoding: 'stored', allowMissingVector: undefined,
684
+ });
685
+ } else if (c === null || typeof c !== 'object' || Array.isArray(c)) {
686
+ throw new Error(`chunk ${position} must be an object — run \`mdss index\` to rebuild`);
687
+ } else if (typeof c.vec === 'string') {
688
+ decodeVec(c.vec, dim ?? undefined);
689
+ } else {
690
+ validateNumericVector(c.vec, dim ?? undefined, `chunk ${where}`);
691
+ }
692
+ } catch (error) {
693
+ bad = error.message;
694
+ }
695
+ if (bad) {
696
+ report.healthy = false;
697
+ report.chunks.invalid.push({ where, error: bad });
698
+ } else {
699
+ report.chunks.valid++;
700
+ }
701
+ }
702
+
703
+ // --- db dir: exists; newest file mtime vs built (stale?) — same 5s grace ---
704
+ // as warnIfStale, so a same-second touch doesn't count as a problem.
705
+ if (!fs.existsSync(db) || !fs.statSync(db).isDirectory()) {
706
+ report.db.exists = false;
707
+ report.db.error = `db dir missing: ${db}`;
708
+ report.healthy = false;
709
+ } else {
710
+ let newest = 0;
711
+ try {
712
+ for (const f of walkMarkdown(db)) {
713
+ const st = fs.statSync(f);
714
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
715
+ }
716
+ } catch { /* unreadable db → newest stays 0, staleness check is moot */ }
717
+ const builtMs = index.built ? Date.parse(index.built) : NaN;
718
+ if (Number.isFinite(builtMs) && newest > builtMs + 5000) {
719
+ report.db.stale = true;
720
+ report.healthy = false;
721
+ }
722
+ }
723
+
724
+ // --- model cache: transformers.js FileCache keys under cacheDir ---
725
+ // Offline readiness — the model must be downloaded before search works in
726
+ // --offline mode. Missing cache is a warning unless the user explicitly
727
+ // demands offline readiness (then it's a failure, issue #43).
728
+ const modelId = index.model ?? null;
729
+ report.model.id = modelId;
730
+ if (modelId) {
731
+ const model = resolveModel(modelId);
732
+ report.model.pooling = model.pooling ?? 'mean';
733
+ report.model.normalize = model.normalize !== false;
734
+ report.model.adapterFamily = model.family ?? null;
735
+ report.model.dim = model.dim ?? null;
736
+ const cachePath = path.join(cacheDir, model.id,
737
+ ...((model.revision ?? 'main') === 'main' ? [] : [model.revision]));
738
+ report.model.cachePath = cachePath;
739
+ report.model.cached = fs.existsSync(cachePath);
740
+ if (!report.model.cached) {
741
+ report.model.error =
742
+ `model cache for ${modelId} not found at ${cachePath} — offline search will fail ` +
743
+ '(the first online run downloads it)';
744
+ if (requireOffline) report.healthy = false;
745
+ }
746
+ }
747
+ return report;
748
+ }
749
+
750
+ /**
751
+ * `mdss check` (alias: `mdss doctor`) — offline diagnostics (issue #43).
752
+ * Reports what is broken about the index/db/model-cache without loading the
753
+ * embedding model or touching the network. Exit code 0 when healthy, 1 with a
754
+ * summary when not; `--json` for scripting.
755
+ */
756
+ function cmdCheck(opts) {
757
+ const db = resolveDb(opts);
758
+ const indexDir = resolveIndexDir(opts, db);
759
+ const cacheDir = resolveCache(opts);
760
+ const report = checkHealth({
761
+ db, indexDir, cacheDir,
762
+ requireOffline: resolveOffline(opts),
763
+ config: opts._config,
764
+ });
765
+
766
+ if (opts.json) {
767
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
768
+ process.exitCode = report.healthy ? 0 : 1;
769
+ return;
770
+ }
771
+
772
+ const ok = (s) => ` ok ${s}\n`;
773
+ const warn = (s) => ` warn ${s}\n`;
774
+ const fail = (s) => ` FAIL ${s}\n`;
775
+ let out = `check: ${indexDir}\n`;
776
+
777
+ // If there is no index or it doesn't parse, the report contains only the
778
+ // index error — chunks/db/model are NOT checkable, show just that.
779
+ if (!report.index.exists || !report.index.parses) {
780
+ out += fail(report.index.error);
781
+ out += 'check: 1+ problem(s) found (exit 1)\n';
782
+ process.stdout.write(out);
783
+ process.exitCode = 1;
784
+ return;
785
+ }
786
+ out += ok(`vectors.json: parses (schema v${report.index.schemaVersion}, ` +
787
+ `${report.index.format})`);
788
+ if (report.index.error) {
789
+ out += fail(report.index.error);
790
+ out += 'check: 1+ problem(s) found (exit 1)\n';
791
+ process.stdout.write(out);
792
+ process.exitCode = 1;
793
+ return;
794
+ }
795
+
796
+ if (report.hashes.exists && report.hashes.parses) {
797
+ out += ok(`.hashes.json: parses (${report.hashes.files} file(s))`);
798
+ } else if (report.hashes.exists) {
799
+ out += fail(report.hashes.error);
800
+ } else {
801
+ out += warn('.hashes.json: missing (file count unknown)');
802
+ }
803
+
804
+ if (report.chunks.invalid.length === 0) {
805
+ out += ok(`chunks: ${report.chunks.valid}/${report.chunks.total} vectors valid (dim from index)`);
806
+ } else {
807
+ out += fail(`chunks: ${report.chunks.valid}/${report.chunks.total} vectors valid, ` +
808
+ `${report.chunks.invalid.length} invalid`);
809
+ for (const bad of report.chunks.invalid.slice(0, 5)) {
810
+ out += ` ${bad.where}: ${bad.error}\n`;
811
+ }
812
+ if (report.chunks.invalid.length > 5) {
813
+ out += ` …and ${report.chunks.invalid.length - 5} more\n`;
814
+ }
815
+ }
816
+
817
+ if (report.db.error) {
818
+ out += fail(report.db.error);
819
+ } else if (report.db.stale) {
820
+ out += fail(`db ${db}: STALE — files changed after the index was built; ` +
821
+ 'run `mdss index` to refresh');
822
+ } else {
823
+ out += ok(`db ${db}: fresh`);
824
+ }
825
+
826
+ if (report.model.id) {
827
+ if (report.model.cached) {
828
+ out += ok(`model cache: ${report.model.id} present at ${report.model.cachePath}`);
829
+ } else if (resolveOffline(opts)) {
830
+ out += fail(report.model.error); // required offline → missing cache is a failure
831
+ } else {
832
+ out += warn(report.model.error);
833
+ }
834
+ } else {
835
+ out += warn('model: index has no model field (legacy index)');
836
+ }
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
+
846
+ out += report.healthy
847
+ ? 'check: healthy (exit 0)\n'
848
+ : 'check: 1+ problem(s) found (exit 1)\n';
849
+ process.stdout.write(out);
850
+ process.exitCode = report.healthy ? 0 : 1;
851
+ }
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
+
116
869
  async function cmdSearch(opts) {
870
+ if (opts.interactive) return cmdTui(opts);
117
871
  const db = resolveDb(opts);
118
872
  const indexDir = resolveIndexDir(opts, db);
119
873
  const cacheDir = resolveCache(opts);
@@ -125,41 +879,182 @@ async function cmdSearch(opts) {
125
879
  k: opts.k || 6,
126
880
  semanticOnly: !!opts.semantic,
127
881
  offline: resolveOffline(opts),
882
+ path: opts.path.length > 0 ? opts.path : undefined,
883
+ since: opts.since,
884
+ rerank: !!opts.rerank,
885
+ tag: opts.tag,
886
+ project: opts.project,
887
+ type: opts.type,
888
+ status: opts.status,
889
+ canonicalOnly: opts.canonical,
890
+ explain: !!opts.explain,
891
+ maxPerFile: opts.maxPerFile || opts.maxPerDoc,
892
+ ann: opts.ann,
893
+ nprobe: opts.nprobe,
128
894
  });
129
895
 
130
896
  if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
131
897
  if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
132
898
 
133
899
  process.stdout.write(`\nTop ${results.length} for: "${query}"\n\n`);
134
- results.forEach((r, i) => {
135
- const loc = r.heading ? `${r.file} › ${r.heading}` : r.file;
136
- process.stdout.write(`${i + 1}. [cos ${r.cosine}] ${r.title}\n`);
137
- process.stdout.write(` ${loc}\n`);
138
- process.stdout.write(` ${r.snippet}${r.snippet.length >= 220 ? '…' : ''}\n\n`);
139
- });
900
+
901
+ // Group by file (issue #13): each file becomes a header with its hits below.
902
+ const byFile = new Map();
903
+ for (const r of results) {
904
+ if (!byFile.has(r.file)) byFile.set(r.file, []);
905
+ byFile.get(r.file).push(r);
906
+ }
907
+ const color = !!process.stdout.isTTY; // bold matched terms only on a real terminal
908
+ let rank = 0;
909
+ for (const [file, hits] of byFile) {
910
+ process.stdout.write(`${file}\n`);
911
+ for (const r of hits) {
912
+ rank++;
913
+ let snippet = r.snippet;
914
+ if (color && r.matches && r.matches.length > 0) {
915
+ const re = new RegExp(`(${r.matches.map(escRe).join('|')})`, 'gi');
916
+ snippet = snippet.replace(re, '\x1b[1m$1\x1b[0m');
917
+ }
918
+ const loc = r.heading ? ` ${r.heading}\n` : '';
919
+ process.stdout.write(` ${rank}. [cos ${r.cosine}] ${r.title}\n`);
920
+ if (loc) process.stdout.write(loc);
921
+ process.stdout.write(` ${snippet}${r.snippet.length >= 220 ? '…' : ''}\n\n`);
922
+ }
923
+ }
924
+ }
925
+
926
+ /** Escape a string for use inside a RegExp. */
927
+ function escRe(s) {
928
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
140
929
  }
141
930
 
142
931
  function cmdModels() {
143
932
  process.stdout.write('Available models (alias → id):\n\n');
144
933
  for (const [alias, m] of Object.entries(MODELS)) {
145
934
  const star = alias === DEFAULT_MODEL ? ' (default)' : '';
146
- process.stdout.write(` ${alias}${star}\n ${m.id} · dim ${m.dim}\n ${m.note}\n\n`);
935
+ const pooling = m.pooling ?? 'mean';
936
+ const normalized = m.normalize !== false ? 'L2' : 'raw';
937
+ const rev = m.revision ? ` · rev ${m.revision.slice(0, 8)}` : '';
938
+ process.stdout.write(
939
+ ` ${alias}${star}\n` +
940
+ ` ${m.id} · dim ${m.dim}${rev}\n` +
941
+ ` ${pooling} · ${normalized} · maxTokens ${m.maxTokens ?? '—'} ` +
942
+ (m.family ? `· family ${m.family}\n` : '\n') +
943
+ ` ${m.note}\n\n`,
944
+ );
147
945
  }
148
- process.stdout.write('You can also pass any raw Xenova/* model id to --model.\n');
946
+ process.stdout.write(
947
+ 'Pass a registered id or alias to use a built-in adapter. A raw Hugging Face\n' +
948
+ 'id is NOT guessed to be E5-compatible: it fails at embed time until an\n' +
949
+ 'explicit adapter descriptor is supplied (see README "How to add a model").\n',
950
+ );
951
+ }
952
+
953
+ async function cmdServe(opts) {
954
+ const db = resolveDb(opts);
955
+ const indexDir = resolveIndexDir(opts, db);
956
+ const cacheDir = resolveCache(opts);
957
+ const port = opts.port || Number(process.env.MDSS_PORT) || DEFAULT_PORT;
958
+ // Loopback by default — LAN exposure is opt-in via --host (issue #16).
959
+ const host = opts.host || process.env.MDSS_HOST || DEFAULT_HOST;
960
+ const log = s => process.stderr.write(s + '\n');
961
+
962
+ const { server, state, close } = await createServe({
963
+ db, indexDir, cacheDir,
964
+ modelName: opts.model || DEFAULT_MODEL,
965
+ ignore: opts.ignore,
966
+ offline: resolveOffline(opts),
967
+ watch: !!opts.watch,
968
+ watchInterval: opts.watchInterval,
969
+ watchDelay: opts.watchDelay,
970
+ apiKey: opts.apiKey || process.env.MDSS_API_KEY,
971
+ healthPublic: !!opts.healthPublic || process.env.MDSS_HEALTH_PUBLIC === 'true',
972
+ log,
973
+ });
974
+
975
+ server.listen(port, host, () => {
976
+ const idx = state.loaded.index;
977
+ log(`mdss serve listening on http://${host}:${port}` +
978
+ (host === '127.0.0.1' || host === '::1' ? ' (loopback only)' : ''));
979
+ log(` index: ${idx.chunks.length} chunks · model ${state.loaded.model.id}` +
980
+ (state.watching ? ' · watching for changes' : ''));
981
+ log(` POST /search {"query": "...", "k": 6}`);
982
+ log(` GET /health`);
983
+ log(` Ctrl+C to stop.`);
984
+ });
985
+
986
+ const stop = async (sig) => {
987
+ log(`\n${sig} — shutting down.`);
988
+ await close();
989
+ process.exit(0);
990
+ };
991
+ process.on('SIGINT', () => stop('SIGINT'));
992
+ process.on('SIGTERM', () => stop('SIGTERM'));
993
+ }
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
+ });
149
1013
  }
150
1014
 
151
1015
  async function main() {
152
1016
  const argv = process.argv.slice(2);
153
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
+
154
1026
  const cmd = opts._.shift();
155
1027
 
1028
+ if (opts.version) { process.stdout.write(`${VERSION}\n`); return; }
156
1029
  if (opts.help || !cmd) { process.stdout.write(HELP); return; }
157
1030
  switch (cmd) {
158
1031
  case 'index': return cmdIndex(opts);
1032
+ case 'stats': return cmdStats(opts);
1033
+ case 'check':
1034
+ case 'doctor': return cmdCheck(opts);
1035
+ case 'export': return cmdExport(opts);
159
1036
  case 'search': return cmdSearch(opts);
1037
+ case 'tui': return cmdTui(opts);
1038
+ case 'serve': return cmdServe(opts);
1039
+ case 'mcp': return cmdMcp(opts);
160
1040
  case 'models': return cmdModels();
161
1041
  default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
162
1042
  }
163
1043
  }
164
1044
 
165
- main().catch(e => die(e.message));
1045
+ // Run only when executed directly (`node bin/cli.mjs`, `mdss`), never when
1046
+ // imported by tests or library consumers (issue #29).
1047
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1048
+ main().catch(e => die(e.message));
1049
+ }
1050
+
1051
+ // Exported for the CLI harness tests (issue #29): the pure argument/resolution
1052
+ // functions are unit-tested directly; die() is asserted via a mocked
1053
+ // process.exit so a bad flag cannot kill the test runner.
1054
+ export {
1055
+ parseArgs, nextInt, nextValue,
1056
+ resolveDb, resolveIndexDir, resolveCache, resolveOffline,
1057
+ findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
1058
+ checkHealth, cmdExport,
1059
+ die, main, HELP, VERSION,
1060
+ };