md-semantic-search 0.3.0 → 0.4.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.
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,90 @@
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';
16
+ import os from 'node:os';
15
17
  import { buildIndex } from '../src/indexer.mjs';
16
18
  import { search } from '../src/search.mjs';
17
- import { MODELS, DEFAULT_MODEL } from '../src/models.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';
18
23
 
19
24
  const HERE = path.dirname(fileURLToPath(import.meta.url));
20
25
  const PKG_ROOT = path.resolve(HERE, '..');
21
- const DEFAULT_CACHE = path.join(PKG_ROOT, '.cache');
26
+ const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
27
+
28
+ /**
29
+ * User-level model cache. Default: ~/.cache/mdss (respecting XDG_CACHE_HOME on
30
+ * POSIX); falls back to <pkg>/.cache when homedir is unavailable. Explicit
31
+ * --cache-dir / MDSS_CACHE_DIR always win (issue #9).
32
+ */
33
+ function defaultCacheDir() {
34
+ try {
35
+ if (process.env.XDG_CACHE_HOME) return path.join(process.env.XDG_CACHE_HOME, 'mdss');
36
+ const home = os.homedir();
37
+ if (home) return path.join(home, '.cache', 'mdss');
38
+ } catch { /* homedir unavailable */ }
39
+ return path.join(PKG_ROOT, '.cache');
40
+ }
22
41
 
23
42
  function parseArgs(argv) {
24
- const opts = { _: [], ignore: [] };
43
+ const opts = { _: [], ignore: [], path: [] };
25
44
  for (let i = 0; i < argv.length; i++) {
26
45
  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;
46
+ if (a === '--json') opts.json = true;
34
47
  else if (a === '--semantic') opts.semantic = true;
35
48
  else if (a === '--offline') opts.offline = true;
49
+ else if (a === '--watch') opts.watch = true;
50
+ else if (a === '--rerank') opts.rerank = true;
51
+ else if (a === '--explain') opts.explain = true;
52
+ else if (a === '--version') opts.version = true;
36
53
  else if (a === '-h' || a === '--help') opts.help = true;
54
+ else if (a === '--db') opts.db = nextValue(argv, ++i, a);
55
+ else if (a === '--index-dir') opts.indexDir = nextValue(argv, ++i, a);
56
+ else if (a === '--cache-dir') opts.cacheDir = nextValue(argv, ++i, a);
57
+ else if (a === '--model') opts.model = nextValue(argv, ++i, a);
58
+ else if (a === '--k') opts.k = nextInt(argv, ++i, a);
59
+ else if (a === '--max-per-file') opts.maxPerFile = nextInt(argv, ++i, a);
60
+ else if (a === '--max-per-doc') opts.maxPerDoc = nextInt(argv, ++i, a);
61
+ else if (a === '--target-tokens') opts.targetTokens = nextInt(argv, ++i, a);
62
+ else if (a === '--port') opts.port = nextInt(argv, ++i, a);
63
+ else if (a === '--host') opts.host = nextValue(argv, ++i, a);
64
+ else if (a === '--watch-delay') opts.watchDelay = nextInt(argv, ++i, a);
65
+ else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
66
+ else if (a === '--since') opts.since = nextValue(argv, ++i, a);
67
+ else if (a === '--path') opts.path.push(nextValue(argv, ++i, a));
68
+ else if (a === '--ignore') opts.ignore.push(nextValue(argv, ++i, a));
69
+ else if (a === '--tag') {
70
+ const v = nextValue(argv, ++i, a);
71
+ opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
72
+ }
73
+ else if (a === '--project') opts.project = nextValue(argv, ++i, a);
74
+ else if (a === '--type') opts.type = nextValue(argv, ++i, a);
75
+ else if (a === '--status') opts.status = nextValue(argv, ++i, a);
76
+ else if (a === '--canonical') opts.canonical = true;
77
+ else if (a.startsWith('-')) die(`unknown option: ${a}. Try \`mdss --help\`.`);
37
78
  else opts._.push(a);
38
79
  }
39
80
  return opts;
40
81
  }
41
82
 
83
+ /** Value of the flag at `i`; errors when missing or followed by another flag. */
84
+ function nextValue(argv, i, flag) {
85
+ const v = argv[i];
86
+ if (v === undefined || v.startsWith('-')) die(`missing value for ${flag}`);
87
+ return v;
88
+ }
89
+
90
+ /** Like nextValue but requires a positive integer (issue #8: --k abc → clear error). */
91
+ function nextInt(argv, i, flag) {
92
+ const v = nextValue(argv, i, flag);
93
+ const k = Number.parseInt(v, 10);
94
+ if (!Number.isInteger(k) || k <= 0) die(`${flag} must be a positive integer, got "${v}"`);
95
+ return k;
96
+ }
97
+
42
98
  function resolveDb(opts) {
43
99
  const db = opts.db || process.env.MDSS_DB;
44
100
  if (!db) die('Missing --db <dir> (or set MDSS_DB). Path to your .md folder.');
@@ -56,7 +112,7 @@ function resolveIndexDir(opts, db) {
56
112
  }
57
113
 
58
114
  const resolveCache = (opts) =>
59
- path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR || DEFAULT_CACHE);
115
+ path.resolve(opts.cacheDir || process.env.MDSS_CACHE_DIR || defaultCacheDir());
60
116
 
61
117
  /** --offline flag or MDSS_OFFLINE=1 env → never touch the network. */
62
118
  const resolveOffline = (opts) => !!opts.offline || !!process.env.MDSS_OFFLINE;
@@ -65,24 +121,37 @@ function die(msg) {
65
121
  process.stderr.write(`error: ${msg}\n`);
66
122
  process.exit(1);
67
123
  }
68
-
69
124
  const HELP = `md-semantic-search (mdss) — local, private semantic search over Markdown
70
125
 
71
126
  Usage:
72
127
  mdss index --db <dir> [options] Build/refresh the index
128
+ mdss stats --db <dir> [--json] Index stats without loading the model
129
+ mdss check --db <dir> [--json] Diagnose index/db/model cache (alias: doctor)
73
130
  mdss search --db <dir> [options] "query" Search by meaning
131
+ mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
74
132
  mdss models List available models
75
133
 
76
134
  Options:
77
135
  --db <dir> Folder of .md files (or env MDSS_DB). Can be anywhere.
78
136
  --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).
137
+ --cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
80
138
  --model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
81
139
  --ignore <glob> Skip files/paths (repeatable). e.g. --ignore "log.md".
140
+ --path <glob> Search only files matching glob (repeatable). e.g. --path "docs/**".
141
+ --since <date> Search only files modified at/after date (YYYY-MM-DD or ISO).
82
142
  --k <n> Number of results (search, default 6).
83
- --json Machine-readable output (search).
143
+ --json Machine-readable output (index, stats, search).
84
144
  --semantic Pure vector ranking, skip lexical/RRF fusion (search).
145
+ --rerank Re-rank candidates with a cross-encoder (search; ~280MB model).
146
+ --port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
147
+ --host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
148
+ only; use 0.0.0.0 to expose on the LAN, env MDSS_HOST).
149
+ --watch serve: re-index incrementally on file changes (mtime poll).
150
+ --watch-interval <ms> serve --watch: poll every N ms (default 3000).
151
+ --watch-delay <ms> serve --watch: quiet-period debounce before a burst of
152
+ saves triggers ONE re-index (default 1000; issue #42).
85
153
  --offline Never download the model; require a cached one (env MDSS_OFFLINE=1).
154
+ --version Print the version and exit.
86
155
  -h, --help Show this help.
87
156
 
88
157
  Examples:
@@ -90,6 +159,9 @@ Examples:
90
159
  mdss index --db /abs/path/to/wiki --model bge-m3 --ignore "log.md" --ignore "**/archive/**"
91
160
  mdss search --db ./docs "how do I rotate the api token"
92
161
  MDSS_DB=./docs mdss search "rollback a failed migration" --k 8 --json
162
+ mdss search --db ./docs "incident runbook" --path "docs/**" --since 2026-01-01
163
+ mdss serve --db ./docs --port 8747 --watch
164
+ curl -X POST localhost:8747/search -d '{"query":"rotate api token","k":5}'
93
165
  `;
94
166
 
95
167
  async function cmdIndex(opts) {
@@ -97,22 +169,387 @@ async function cmdIndex(opts) {
97
169
  const indexDir = resolveIndexDir(opts, db);
98
170
  const cacheDir = resolveCache(opts);
99
171
  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
- });
172
+ let r;
173
+ try {
174
+ r = await buildIndex({
175
+ db, indexDir, cacheDir,
176
+ modelName: opts.model || DEFAULT_MODEL,
177
+ ignore: opts.ignore,
178
+ offline: resolveOffline(opts),
179
+ log: s => process.stderr.write(s + '\n'),
180
+ });
181
+ } catch (e) {
182
+ // A held index lock (issue #37) is an EXPECTED operational state (a second
183
+ // terminal also indexing, or a `serve --watch` daemon mid-cycle) — surface
184
+ // it as a clean one-line error + hint, not a stack of "locked by pid".
185
+ if (/(being written by pid|index is being written)/i.test(String(e?.message || ''))) {
186
+ die(`${e.message}\nAnother mdss process is writing to this index. ` +
187
+ 'Retry once it finishes, or remove a stale .mdss.lock if the holder crashed.');
188
+ }
189
+ throw e;
190
+ }
191
+ // Machine-readable build result for scripts/CI (issue #21): the exact
192
+ // buildIndex return value — lets automation assert "0 embedded" (fully
193
+ // incremental) or "N skipped" without parsing prose.
194
+ if (opts.json) {
195
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
196
+ return;
197
+ }
107
198
  const secs = ((Date.now() - t0) / 1000).toFixed(1);
199
+ const skipped = r.skipped > 0 ? `, ${r.skipped} skipped` : '';
108
200
  process.stderr.write(
109
201
  `\nIndexed ${r.files} file(s) → ${r.chunks} chunks ` +
110
202
  `(${r.reused} reused [${r.reusedChunks} chunk-level, ${r.reusedFiles} file-level], ` +
111
- `${r.embedded} embedded), dim=${r.dim}, ` +
203
+ `${r.embedded} embedded)${skipped}, dim=${r.dim}, ` +
112
204
  `model=${r.model}, ${secs}s\n→ ${r.vectorsPath}\n`,
113
205
  );
114
206
  }
115
207
 
208
+ /**
209
+ * `mdss stats` — machine-readable index statistics WITHOUT loading the
210
+ * embedding model: parses only vectors.json + .hashes.json (issue #21).
211
+ * Gives scripts/CI a cheap sanity check after a re-index (counts, model, dim),
212
+ * staleness detection (ageSeconds), and index-path/format info for migrations.
213
+ */
214
+ function cmdStats(opts) {
215
+ const db = resolveDb(opts);
216
+ const indexDir = resolveIndexDir(opts, db);
217
+ const vectorsPath = path.join(indexDir, 'vectors.json');
218
+ const hashesPath = path.join(indexDir, '.hashes.json');
219
+ if (!fs.existsSync(vectorsPath)) {
220
+ die(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
221
+ }
222
+ let index;
223
+ try {
224
+ index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
225
+ } catch (e) {
226
+ die(`${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild.`);
227
+ }
228
+ try {
229
+ validateIndexEnvelope(index, vectorsPath, { encoding: 'stored' });
230
+ } catch (error) {
231
+ die(error.message);
232
+ }
233
+
234
+ // File count = keys of .hashes.json (per-file md5 map written by buildIndex).
235
+ // Fall back to unique chunk file paths when the hashes file is missing (a
236
+ // legacy/corrupt index) rather than dying.
237
+ let files = 0;
238
+ try {
239
+ const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
240
+ files = Object.keys(hashes).length;
241
+ } catch {
242
+ files = new Set((index.chunks || []).map(c => c.file)).size;
243
+ }
244
+
245
+ const chunks = index.chunkCount ?? (index.chunks ? index.chunks.length : 0);
246
+ const builtMs = index.built ? Date.parse(index.built) : NaN;
247
+ const adapterRepr = (index.model || index.modelAlias)
248
+ ? resolveModel(index.model || index.modelAlias) : null;
249
+ const stats = {
250
+ indexDir,
251
+ schemaVersion: index.schemaVersion ?? 0,
252
+ format: index.format || 'legacy', // binary-v1 vs pre-0.4 decimal
253
+ lexicalFormat: index.schemaVersion >= 3 ? (index.lexical?.format || 'invalid') : null,
254
+ lexicalStatus: index.schemaVersion >= 3 ? 'persisted-bm25' : 'legacy-overlap',
255
+ model: index.model || null, // id@revision (issue #27)
256
+ modelAlias: index.modelAlias || null,
257
+ dim: index.dim ?? null,
258
+ pooling: adapterRepr?.pooling ?? 'mean',
259
+ normalize: adapterRepr?.normalize !== false,
260
+ adapterFamily: adapterRepr?.family ?? null,
261
+ adapterFingerprint: index.adapterFingerprint || null,
262
+ chunks,
263
+ files,
264
+ indexBytes: fs.statSync(vectorsPath).size,
265
+ built: index.built || null,
266
+ ageSeconds: Number.isFinite(builtMs) ? Math.floor((Date.now() - builtMs) / 1000) : null,
267
+ db: index.db || db,
268
+ };
269
+
270
+ if (opts.json) {
271
+ process.stdout.write(JSON.stringify(stats, null, 2) + '\n');
272
+ return;
273
+ }
274
+ const age = stats.ageSeconds === null ? '?' :
275
+ stats.ageSeconds < 60 ? `${stats.ageSeconds}s` :
276
+ stats.ageSeconds < 3600 ? `${Math.floor(stats.ageSeconds / 60)}m ${stats.ageSeconds % 60}s` :
277
+ stats.ageSeconds < 86400 ? `${Math.floor(stats.ageSeconds / 3600)}h ${Math.floor((stats.ageSeconds % 3600) / 60)}m` :
278
+ `${Math.floor(stats.ageSeconds / 86400)}d`;
279
+ process.stdout.write(
280
+ `Index at ${indexDir}\n` +
281
+ ` format: ${stats.format} · model: ${stats.modelAlias || stats.model || '?'} (dim ${stats.dim ?? '?'})\n` +
282
+ ` adapter: ${stats.pooling}${stats.normalize ? '' : ' · raw'}${stats.adapterFamily ? ' · ' + stats.adapterFamily : ''}\n` +
283
+ ` schema: v${stats.schemaVersion} · lexical: ${stats.lexicalFormat || stats.lexicalStatus}\n` +
284
+ ` chunks: ${chunks} · files: ${files}\n` +
285
+ ` size: ${(stats.indexBytes / 1024).toFixed(1)} KiB (vectors.json)\n` +
286
+ ` built: ${stats.built || '?'} (${age} ago)\n` +
287
+ ` db: ${stats.db}\n`,
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Offline diagnostics for the index/db/model-cache trio (issue #43) — the
293
+ * `mdss check` / `mdss doctor` backend. Pure read-only: parses vectors.json +
294
+ * .hashes.json, validates every stored vector with decodeVec (the same
295
+ * validator the loader uses), walks the db for staleness, and checks the
296
+ * transformers.js cache layout for the index's model. NEVER loads the
297
+ * embedding model and NEVER touches the network.
298
+ * @param {{db:string, indexDir:string, cacheDir:string, requireOffline?:boolean}} paths
299
+ * @returns {CheckReport}
300
+ */
301
+
302
+ /**
303
+ * Structured result of checkHealth. Every sub-check is a small object with an
304
+ * `error` (string|null) plus check-specific fields; `healthy` is the AND of
305
+ * all checks (a warning — e.g. missing model cache when NOT offline — does not
306
+ * flip it).
307
+ * @typedef {object} CheckReport
308
+ * @property {boolean} healthy
309
+ * @property {{exists:boolean, parses:boolean, schemaVersion:(number|null), format:(string|null), recognized:boolean, error:(string|null)}} index
310
+ * @property {{exists:boolean, parses:boolean, files:number, error:(string|null)}} hashes
311
+ * @property {{total:number, valid:number, invalid:Array<{where:string, error:string}>}} chunks
312
+ * @property {{exists:boolean, stale:boolean, error:(string|null)}} db
313
+ * @property {{id:(string|null), cached:boolean, cachePath:(string|null), error:(string|null)}} model
314
+ */
315
+ export function checkHealth({ db, indexDir, cacheDir, requireOffline = false }) {
316
+ const report = {
317
+ healthy: true,
318
+ index: { exists: false, parses: false, schemaVersion: null, format: null, recognized: true, error: null },
319
+ hashes: { exists: false, parses: false, files: 0, error: null },
320
+ chunks: { total: 0, valid: 0, invalid: [] },
321
+ db: { exists: true, stale: false, error: null },
322
+ model: { id: null, cached: false, cachePath: null, error: null },
323
+ };
324
+ const vectorsPath = path.join(indexDir, 'vectors.json');
325
+ const hashesPath = path.join(indexDir, '.hashes.json');
326
+
327
+ // --- vectors.json: exists, parses, schema + format recognized (#39) ---
328
+ if (!fs.existsSync(vectorsPath)) {
329
+ report.index.error = `No index at ${vectorsPath}. Run \`mdss index\` first.`;
330
+ report.healthy = false;
331
+ return report; // nothing further is checkable without an index
332
+ }
333
+ report.index.exists = true;
334
+ let index;
335
+ try {
336
+ index = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
337
+ report.index.parses = true;
338
+ } catch (e) {
339
+ report.index.error = `${vectorsPath} is not valid JSON (${e.message}); run \`mdss index\` to rebuild`;
340
+ report.healthy = false;
341
+ return report;
342
+ }
343
+ let schemaVersion;
344
+ try {
345
+ ({ schema: schemaVersion } = inspectIndexSchema(index, 'vectors.json'));
346
+ } catch (error) {
347
+ report.index.recognized = false;
348
+ report.index.error = error.message;
349
+ report.healthy = false;
350
+ return report;
351
+ }
352
+ report.index.schemaVersion = schemaVersion;
353
+ report.index.format = index.format || 'legacy';
354
+ report.index.recognized = schemaVersion <= SCHEMA_VERSION;
355
+ let validated;
356
+ try {
357
+ validated = validateIndexEnvelope(index, 'vectors.json', {
358
+ encoding: 'stored', validateVectors: false,
359
+ });
360
+ } catch (error) {
361
+ report.index.error = error.message;
362
+ report.healthy = false;
363
+ return report;
364
+ }
365
+
366
+ // --- .hashes.json: parses; file count vs chunk count ---
367
+ if (fs.existsSync(hashesPath)) {
368
+ report.hashes.exists = true;
369
+ try {
370
+ const hashes = JSON.parse(fs.readFileSync(hashesPath, 'utf8'));
371
+ report.hashes.parses = true;
372
+ report.hashes.files = Object.keys(hashes).length;
373
+ } catch (e) {
374
+ report.hashes.error = `${hashesPath} is not valid JSON (${e.message})`;
375
+ report.healthy = false;
376
+ }
377
+ }
378
+
379
+ // --- every chunk's vector: length == dim, no NaN/Infinity, not truncated ---
380
+ // Reuses decodeVec — the exact validator the loader runs (issue #40) — so
381
+ // `mdss check` reports the same "corrupt vector" verdicts without loading
382
+ // the 280MB model.
383
+ const chunks = index.chunks;
384
+ const expectedDim = validated.dim;
385
+ const dim = expectedDim ?? null;
386
+ report.chunks.total = chunks.length;
387
+ for (let position = 0; position < chunks.length; position++) {
388
+ const c = chunks[position];
389
+ const where = c && typeof c === 'object' && !Array.isArray(c)
390
+ ? `${c.file}${c.heading ? ` › ${c.heading}` : ''}` : `chunk ${position}`;
391
+ let bad = null;
392
+ try {
393
+ if (schemaVersion >= 3) {
394
+ validateCurrentChunk(c, position, {
395
+ dim: dim ?? undefined, encoding: 'stored', allowMissingVector: undefined,
396
+ });
397
+ } else if (c === null || typeof c !== 'object' || Array.isArray(c)) {
398
+ throw new Error(`chunk ${position} must be an object — run \`mdss index\` to rebuild`);
399
+ } else if (typeof c.vec === 'string') {
400
+ decodeVec(c.vec, dim ?? undefined);
401
+ } else {
402
+ validateNumericVector(c.vec, dim ?? undefined, `chunk ${where}`);
403
+ }
404
+ } catch (error) {
405
+ bad = error.message;
406
+ }
407
+ if (bad) {
408
+ report.healthy = false;
409
+ report.chunks.invalid.push({ where, error: bad });
410
+ } else {
411
+ report.chunks.valid++;
412
+ }
413
+ }
414
+
415
+ // --- db dir: exists; newest file mtime vs built (stale?) — same 5s grace ---
416
+ // as warnIfStale, so a same-second touch doesn't count as a problem.
417
+ if (!fs.existsSync(db) || !fs.statSync(db).isDirectory()) {
418
+ report.db.exists = false;
419
+ report.db.error = `db dir missing: ${db}`;
420
+ report.healthy = false;
421
+ } else {
422
+ let newest = 0;
423
+ try {
424
+ for (const f of walkMarkdown(db)) {
425
+ const st = fs.statSync(f);
426
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
427
+ }
428
+ } catch { /* unreadable db → newest stays 0, staleness check is moot */ }
429
+ const builtMs = index.built ? Date.parse(index.built) : NaN;
430
+ if (Number.isFinite(builtMs) && newest > builtMs + 5000) {
431
+ report.db.stale = true;
432
+ report.healthy = false;
433
+ }
434
+ }
435
+
436
+ // --- model cache: transformers.js FileCache keys under cacheDir ---
437
+ // Offline readiness — the model must be downloaded before search works in
438
+ // --offline mode. Missing cache is a warning unless the user explicitly
439
+ // demands offline readiness (then it's a failure, issue #43).
440
+ const modelId = index.model ?? null;
441
+ report.model.id = modelId;
442
+ if (modelId) {
443
+ const model = resolveModel(modelId);
444
+ report.model.pooling = model.pooling ?? 'mean';
445
+ report.model.normalize = model.normalize !== false;
446
+ report.model.adapterFamily = model.family ?? null;
447
+ report.model.dim = model.dim ?? null;
448
+ const cachePath = path.join(cacheDir, model.id,
449
+ ...((model.revision ?? 'main') === 'main' ? [] : [model.revision]));
450
+ report.model.cachePath = cachePath;
451
+ report.model.cached = fs.existsSync(cachePath);
452
+ if (!report.model.cached) {
453
+ report.model.error =
454
+ `model cache for ${modelId} not found at ${cachePath} — offline search will fail ` +
455
+ '(the first online run downloads it)';
456
+ if (requireOffline) report.healthy = false;
457
+ }
458
+ }
459
+ return report;
460
+ }
461
+
462
+ /**
463
+ * `mdss check` (alias: `mdss doctor`) — offline diagnostics (issue #43).
464
+ * Reports what is broken about the index/db/model-cache without loading the
465
+ * embedding model or touching the network. Exit code 0 when healthy, 1 with a
466
+ * summary when not; `--json` for scripting.
467
+ */
468
+ function cmdCheck(opts) {
469
+ const db = resolveDb(opts);
470
+ const indexDir = resolveIndexDir(opts, db);
471
+ const cacheDir = resolveCache(opts);
472
+ const report = checkHealth({ db, indexDir, cacheDir, requireOffline: resolveOffline(opts) });
473
+
474
+ if (opts.json) {
475
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
476
+ process.exitCode = report.healthy ? 0 : 1;
477
+ return;
478
+ }
479
+
480
+ const ok = (s) => ` ok ${s}\n`;
481
+ const warn = (s) => ` warn ${s}\n`;
482
+ const fail = (s) => ` FAIL ${s}\n`;
483
+ let out = `check: ${indexDir}\n`;
484
+
485
+ // If there is no index or it doesn't parse, the report contains only the
486
+ // index error — chunks/db/model are NOT checkable, show just that.
487
+ if (!report.index.exists || !report.index.parses) {
488
+ out += fail(report.index.error);
489
+ out += 'check: 1+ problem(s) found (exit 1)\n';
490
+ process.stdout.write(out);
491
+ process.exitCode = 1;
492
+ return;
493
+ }
494
+ out += ok(`vectors.json: parses (schema v${report.index.schemaVersion}, ` +
495
+ `${report.index.format})`);
496
+ if (report.index.error) {
497
+ out += fail(report.index.error);
498
+ out += 'check: 1+ problem(s) found (exit 1)\n';
499
+ process.stdout.write(out);
500
+ process.exitCode = 1;
501
+ return;
502
+ }
503
+
504
+ if (report.hashes.exists && report.hashes.parses) {
505
+ out += ok(`.hashes.json: parses (${report.hashes.files} file(s))`);
506
+ } else if (report.hashes.exists) {
507
+ out += fail(report.hashes.error);
508
+ } else {
509
+ out += warn('.hashes.json: missing (file count unknown)');
510
+ }
511
+
512
+ if (report.chunks.invalid.length === 0) {
513
+ out += ok(`chunks: ${report.chunks.valid}/${report.chunks.total} vectors valid (dim from index)`);
514
+ } else {
515
+ out += fail(`chunks: ${report.chunks.valid}/${report.chunks.total} vectors valid, ` +
516
+ `${report.chunks.invalid.length} invalid`);
517
+ for (const bad of report.chunks.invalid.slice(0, 5)) {
518
+ out += ` ${bad.where}: ${bad.error}\n`;
519
+ }
520
+ if (report.chunks.invalid.length > 5) {
521
+ out += ` …and ${report.chunks.invalid.length - 5} more\n`;
522
+ }
523
+ }
524
+
525
+ if (report.db.error) {
526
+ out += fail(report.db.error);
527
+ } else if (report.db.stale) {
528
+ out += fail(`db ${db}: STALE — files changed after the index was built; ` +
529
+ 'run `mdss index` to refresh');
530
+ } else {
531
+ out += ok(`db ${db}: fresh`);
532
+ }
533
+
534
+ if (report.model.id) {
535
+ if (report.model.cached) {
536
+ out += ok(`model cache: ${report.model.id} present at ${report.model.cachePath}`);
537
+ } else if (resolveOffline(opts)) {
538
+ out += fail(report.model.error); // required offline → missing cache is a failure
539
+ } else {
540
+ out += warn(report.model.error);
541
+ }
542
+ } else {
543
+ out += warn('model: index has no model field (legacy index)');
544
+ }
545
+
546
+ out += report.healthy
547
+ ? 'check: healthy (exit 0)\n'
548
+ : 'check: 1+ problem(s) found (exit 1)\n';
549
+ process.stdout.write(out);
550
+ process.exitCode = report.healthy ? 0 : 1;
551
+ }
552
+
116
553
  async function cmdSearch(opts) {
117
554
  const db = resolveDb(opts);
118
555
  const indexDir = resolveIndexDir(opts, db);
@@ -125,27 +562,113 @@ async function cmdSearch(opts) {
125
562
  k: opts.k || 6,
126
563
  semanticOnly: !!opts.semantic,
127
564
  offline: resolveOffline(opts),
565
+ path: opts.path.length > 0 ? opts.path : undefined,
566
+ since: opts.since,
567
+ 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,
128
575
  });
129
576
 
130
577
  if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
131
578
  if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
132
579
 
133
580
  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
- });
581
+
582
+ // Group by file (issue #13): each file becomes a header with its hits below.
583
+ const byFile = new Map();
584
+ for (const r of results) {
585
+ if (!byFile.has(r.file)) byFile.set(r.file, []);
586
+ byFile.get(r.file).push(r);
587
+ }
588
+ const color = !!process.stdout.isTTY; // bold matched terms only on a real terminal
589
+ let rank = 0;
590
+ for (const [file, hits] of byFile) {
591
+ process.stdout.write(`${file}\n`);
592
+ for (const r of hits) {
593
+ rank++;
594
+ let snippet = r.snippet;
595
+ if (color && r.matches && r.matches.length > 0) {
596
+ const re = new RegExp(`(${r.matches.map(escRe).join('|')})`, 'gi');
597
+ snippet = snippet.replace(re, '\x1b[1m$1\x1b[0m');
598
+ }
599
+ const loc = r.heading ? ` ${r.heading}\n` : '';
600
+ process.stdout.write(` ${rank}. [cos ${r.cosine}] ${r.title}\n`);
601
+ if (loc) process.stdout.write(loc);
602
+ process.stdout.write(` ${snippet}${r.snippet.length >= 220 ? '…' : ''}\n\n`);
603
+ }
604
+ }
605
+ }
606
+
607
+ /** Escape a string for use inside a RegExp. */
608
+ function escRe(s) {
609
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
140
610
  }
141
611
 
142
612
  function cmdModels() {
143
613
  process.stdout.write('Available models (alias → id):\n\n');
144
614
  for (const [alias, m] of Object.entries(MODELS)) {
145
615
  const star = alias === DEFAULT_MODEL ? ' (default)' : '';
146
- process.stdout.write(` ${alias}${star}\n ${m.id} · dim ${m.dim}\n ${m.note}\n\n`);
616
+ const pooling = m.pooling ?? 'mean';
617
+ const normalized = m.normalize !== false ? 'L2' : 'raw';
618
+ const rev = m.revision ? ` · rev ${m.revision.slice(0, 8)}` : '';
619
+ process.stdout.write(
620
+ ` ${alias}${star}\n` +
621
+ ` ${m.id} · dim ${m.dim}${rev}\n` +
622
+ ` ${pooling} · ${normalized} · maxTokens ${m.maxTokens ?? '—'} ` +
623
+ (m.family ? `· family ${m.family}\n` : '\n') +
624
+ ` ${m.note}\n\n`,
625
+ );
147
626
  }
148
- process.stdout.write('You can also pass any raw Xenova/* model id to --model.\n');
627
+ process.stdout.write(
628
+ 'Pass a registered id or alias to use a built-in adapter. A raw Hugging Face\n' +
629
+ 'id is NOT guessed to be E5-compatible: it fails at embed time until an\n' +
630
+ 'explicit adapter descriptor is supplied (see README "How to add a model").\n',
631
+ );
632
+ }
633
+
634
+ async function cmdServe(opts) {
635
+ const db = resolveDb(opts);
636
+ const indexDir = resolveIndexDir(opts, db);
637
+ const cacheDir = resolveCache(opts);
638
+ const port = opts.port || Number(process.env.MDSS_PORT) || DEFAULT_PORT;
639
+ // Loopback by default — LAN exposure is opt-in via --host (issue #16).
640
+ const host = opts.host || process.env.MDSS_HOST || DEFAULT_HOST;
641
+ const log = s => process.stderr.write(s + '\n');
642
+
643
+ const { server, state, close } = await createServe({
644
+ db, indexDir, cacheDir,
645
+ modelName: opts.model || DEFAULT_MODEL,
646
+ ignore: opts.ignore,
647
+ offline: resolveOffline(opts),
648
+ watch: !!opts.watch,
649
+ watchInterval: opts.watchInterval,
650
+ watchDelay: opts.watchDelay,
651
+ log,
652
+ });
653
+
654
+ server.listen(port, host, () => {
655
+ const idx = state.loaded.index;
656
+ log(`mdss serve listening on http://${host}:${port}` +
657
+ (host === '127.0.0.1' || host === '::1' ? ' (loopback only)' : ''));
658
+ log(` index: ${idx.chunks.length} chunks · model ${state.loaded.model.id}` +
659
+ (state.watching ? ' · watching for changes' : ''));
660
+ log(` POST /search {"query": "...", "k": 6}`);
661
+ log(` GET /health`);
662
+ log(` Ctrl+C to stop.`);
663
+ });
664
+
665
+ const stop = async (sig) => {
666
+ log(`\n${sig} — shutting down.`);
667
+ await close();
668
+ process.exit(0);
669
+ };
670
+ process.on('SIGINT', () => stop('SIGINT'));
671
+ process.on('SIGTERM', () => stop('SIGTERM'));
149
672
  }
150
673
 
151
674
  async function main() {
@@ -153,13 +676,31 @@ async function main() {
153
676
  const opts = parseArgs(argv);
154
677
  const cmd = opts._.shift();
155
678
 
679
+ if (opts.version) { process.stdout.write(`${VERSION}\n`); return; }
156
680
  if (opts.help || !cmd) { process.stdout.write(HELP); return; }
157
681
  switch (cmd) {
158
682
  case 'index': return cmdIndex(opts);
683
+ case 'stats': return cmdStats(opts);
684
+ case 'check':
685
+ case 'doctor': return cmdCheck(opts);
159
686
  case 'search': return cmdSearch(opts);
687
+ case 'serve': return cmdServe(opts);
160
688
  case 'models': return cmdModels();
161
689
  default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
162
690
  }
163
691
  }
164
692
 
165
- main().catch(e => die(e.message));
693
+ // Run only when executed directly (`node bin/cli.mjs`, `mdss`), never when
694
+ // imported by tests or library consumers (issue #29).
695
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
696
+ main().catch(e => die(e.message));
697
+ }
698
+
699
+ // Exported for the CLI harness tests (issue #29): the pure argument/resolution
700
+ // functions are unit-tested directly; die() is asserted via a mocked
701
+ // process.exit so a bad flag cannot kill the test runner.
702
+ export {
703
+ parseArgs, nextInt, nextValue,
704
+ resolveDb, resolveIndexDir, resolveCache, resolveOffline,
705
+ die, main, HELP, VERSION,
706
+ };