md-semantic-search 1.0.2 → 1.1.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 (66) hide show
  1. package/README.md +198 -8
  2. package/RESEARCH.md +25 -3
  3. package/bin/cli.mjs +657 -29
  4. package/dist/completions.d.ts +25 -0
  5. package/dist/completions.js +238 -0
  6. package/dist/core.d.ts +31 -0
  7. package/dist/core.js +121 -1
  8. package/dist/download-progress.d.ts +52 -0
  9. package/dist/download-progress.js +112 -0
  10. package/dist/index-format.js +8 -1
  11. package/dist/index.d.ts +6 -4
  12. package/dist/index.js +5 -4
  13. package/dist/indexer.d.ts +1 -0
  14. package/dist/indexer.js +5 -5
  15. package/dist/ivf.d.ts +14 -7
  16. package/dist/ivf.js +126 -18
  17. package/dist/mcp.d.ts +143 -4
  18. package/dist/mcp.js +499 -23
  19. package/dist/models.d.ts +1 -1
  20. package/dist/models.js +52 -31
  21. package/dist/open.d.ts +43 -0
  22. package/dist/open.js +84 -0
  23. package/dist/providers.d.ts +21 -0
  24. package/dist/providers.js +104 -0
  25. package/dist/query-cache-disk.d.ts +14 -0
  26. package/dist/query-cache-disk.js +175 -0
  27. package/dist/rag.d.ts +72 -7
  28. package/dist/rag.js +191 -13
  29. package/dist/repair.d.ts +36 -0
  30. package/dist/repair.js +208 -0
  31. package/dist/rerank.d.ts +20 -4
  32. package/dist/rerank.js +14 -4
  33. package/dist/search.d.ts +35 -0
  34. package/dist/search.js +175 -5
  35. package/dist/serve.d.ts +126 -0
  36. package/dist/serve.js +676 -46
  37. package/dist/tui.d.ts +8 -0
  38. package/dist/tui.js +80 -13
  39. package/dist/watcher.d.ts +16 -0
  40. package/dist/watcher.js +41 -0
  41. package/dist/webui.d.ts +13 -0
  42. package/dist/webui.js +252 -0
  43. package/dist/wikilinks.d.ts +31 -0
  44. package/dist/wikilinks.js +185 -1
  45. package/package.json +1 -1
  46. package/src/completions.ts +255 -0
  47. package/src/core.ts +160 -1
  48. package/src/download-progress.ts +146 -0
  49. package/src/index-format.ts +7 -1
  50. package/src/index.ts +11 -2
  51. package/src/indexer.ts +8 -4
  52. package/src/ivf.ts +157 -20
  53. package/src/mcp.ts +514 -23
  54. package/src/models.ts +58 -33
  55. package/src/open.ts +119 -0
  56. package/src/providers.ts +134 -0
  57. package/src/query-cache-disk.ts +191 -0
  58. package/src/rag.ts +279 -17
  59. package/src/repair.ts +245 -0
  60. package/src/rerank.ts +43 -8
  61. package/src/search.ts +199 -6
  62. package/src/serve.ts +726 -43
  63. package/src/tui.ts +86 -14
  64. package/src/watcher.ts +46 -0
  65. package/src/webui.ts +255 -0
  66. package/src/wikilinks.ts +220 -1
package/bin/cli.mjs CHANGED
@@ -16,11 +16,17 @@ import fs from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import crypto from 'node:crypto';
18
18
  import { buildIndex } from '../dist/indexer.js';
19
- import { search } from '../dist/search.js';
19
+ import { loadIndex, search } from '../dist/search.js';
20
20
  import { searchFederated } from '../dist/federation.js';
21
- import { createServe, DEFAULT_PORT, DEFAULT_HOST } from '../dist/serve.js';
21
+ import { createServe, DEFAULT_PORT, DEFAULT_HOST, isLoopbackHost, validateBindSecurity, loadApiKeyFile } from '../dist/serve.js';
22
22
  import { MODELS, DEFAULT_MODEL, resolveModel } from '../dist/models.js';
23
- import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath } from '../dist/core.js';
23
+ import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath, setDownloadProgressListener } from '../dist/core.js';
24
+ import { findRelatedNotes } from '../dist/wikilinks.js';
25
+ import { DownloadProgressAggregator } from '../dist/download-progress.js';
26
+ import { generateCompletion, COMPLETION_SHELLS } from '../dist/completions.js';
27
+ import { openHit, resolveOpenCommand } from '../dist/open.js';
28
+ import { planRepairs, applyRepairs } from '../dist/repair.js';
29
+ import { resolveExternalEmbedder } from '../dist/providers.js';
24
30
  import { inspectIndexSchema, validateCurrentChunk, validateIndexEnvelope, validateNumericVector } from '../dist/index-format.js';
25
31
 
26
32
  const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -49,6 +55,7 @@ function parseArgs(argv) {
49
55
  else if (a === '--semantic') opts.semantic = true;
50
56
  else if (a === '--offline') opts.offline = true;
51
57
  else if (a === '--watch') opts.watch = true;
58
+ else if (a === '--watch-debug') opts.watchDebug = true;
52
59
  else if (a === '--rerank') opts.rerank = true;
53
60
  else if (a === '--explain') opts.explain = true;
54
61
  else if (a === '--interactive' || a === '-i') opts.interactive = true;
@@ -72,9 +79,22 @@ function parseArgs(argv) {
72
79
  else if (a === '--port') opts.port = nextInt(argv, ++i, a);
73
80
  else if (a === '--host') opts.host = nextValue(argv, ++i, a);
74
81
  else if (a === '--api-key') opts.apiKey = nextValue(argv, ++i, a);
82
+ else if (a === '--api-key-file') opts.apiKeyFile = nextValue(argv, ++i, a);
83
+ else if (a === '--allow-unsecured') opts.allowUnsecured = true;
84
+ else if (a === '--allowed-host') {
85
+ const v = nextValue(argv, ++i, a);
86
+ opts.allowedHost = opts.allowedHost ? [...opts.allowedHost, v] : [v];
87
+ }
88
+ else if (a === '--cors-origin') {
89
+ const v = nextValue(argv, ++i, a);
90
+ opts.corsOrigin = opts.corsOrigin ? [...opts.corsOrigin, v] : [v];
91
+ }
75
92
  else if (a === '--health-public') opts.healthPublic = true;
76
93
  else if (a === '--watch-delay') opts.watchDelay = nextInt(argv, ++i, a);
77
94
  else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
95
+ else if (a === '--rate-limit') opts.rateLimit = nextNonNegInt(argv, ++i, a);
96
+ else if (a === '--max-concurrency') opts.maxConcurrency = nextNonNegInt(argv, ++i, a);
97
+ else if (a === '--dimensions' || a === '--dim') opts.dimensions = nextInt(argv, ++i, a);
78
98
  else if (a === '--since') opts.since = nextValue(argv, ++i, a);
79
99
  else if (a === '--path') opts.path.push(nextValue(argv, ++i, a));
80
100
  else if (a === '--ignore') opts.ignore.push(nextValue(argv, ++i, a));
@@ -90,6 +110,8 @@ function parseArgs(argv) {
90
110
  else if (a === '--canonical') opts.canonical = true;
91
111
  else if (a === '--graph-boost') opts.graphBoost = nextFloat(argv, ++i, a);
92
112
  else if (a === '--filter') opts.filter = nextValue(argv, ++i, a);
113
+ else if (a === '--direction') opts.direction = nextValue(argv, ++i, a);
114
+ else if (a === '--no-semantic') opts.noSemantic = true;
93
115
  else if (a === '--quantize') opts.quantize = nextValue(argv, ++i, a);
94
116
  else if (a === '--rag') opts.rag = true;
95
117
  else if (a === '--auto-tag') opts.autoTag = true;
@@ -97,6 +119,39 @@ function parseArgs(argv) {
97
119
  else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
98
120
  else if (a === '--llm-model') opts.llmModel = nextValue(argv, ++i, a);
99
121
  else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
122
+ else if (a === '--session') opts.session = nextValue(argv, ++i, a);
123
+ else if (a === '--resume') opts.resume = true;
124
+ else if (a === '--open') {
125
+ // `--open` opens the top hit; `--open N` opens the Nth hit (issue #110).
126
+ const next = argv[i + 1];
127
+ if (next !== undefined && /^\d+$/.test(next)) {
128
+ opts.openRank = Number.parseInt(next, 10);
129
+ i++;
130
+ } else {
131
+ opts.open = true;
132
+ }
133
+ }
134
+ else if (a === '--fix') opts.fix = true;
135
+ else if (a === '--dry-run') opts.dryRun = true;
136
+ else if (a === '--no-ui') opts.noUi = true;
137
+ else if (a === '--no-query-cache') opts.noQueryCache = true;
138
+ else if (a === '--mcp') opts.mcp = true;
139
+ else if (a === '--recency') {
140
+ opts.recency = nextFloat(argv, ++i, a);
141
+ if (!(opts.recency > 0)) die(`--recency must be a positive half-life in days, got "${opts.recency}"`);
142
+ }
143
+ else if (a === '--embedder') opts.embedder = nextValue(argv, ++i, a);
144
+ else if (a === '--embedder-model') opts.embedderModel = nextValue(argv, ++i, a);
145
+ else if (a === '--embedder-base-url') opts.embedderBaseUrl = nextValue(argv, ++i, a);
146
+ else if (a === '--embedder-key-file') opts.embedderKeyFile = nextValue(argv, ++i, a);
147
+ else if (a === '--expand') {
148
+ opts.expand = nextValue(argv, ++i, a);
149
+ if (!['prf', 'hyde'].includes(opts.expand)) die(`unknown --expand "${opts.expand}" - use prf|hyde`);
150
+ }
151
+ else if (a === '--expand-passages') {
152
+ opts.expandPassages = nextInt(argv, ++i, a);
153
+ if (opts.expandPassages > 20) die(`--expand-passages must be <= 20`);
154
+ }
100
155
  else if (a.startsWith('-')) die(`unknown option: ${a}. Try \`mdss --help\`.`);
101
156
  else opts._.push(a);
102
157
  }
@@ -126,17 +181,66 @@ function nextFloat(argv, i, flag) {
126
181
  return f;
127
182
  }
128
183
 
184
+ /** Like nextInt but allows 0 (used by serve limit flags where 0 = disabled). */
185
+ function nextNonNegInt(argv, i, flag) {
186
+ const v = nextValue(argv, i, flag);
187
+ const n = Number.parseInt(v, 10);
188
+ if (!Number.isInteger(n) || n < 0) die(`${flag} must be a non-negative integer (0 disables), got "${v}"`);
189
+ return n;
190
+ }
191
+
192
+ /** Non-negative integer from an env var; undefined when unset/invalid. */
193
+ function envNonNegInt(name) {
194
+ const raw = process.env[name];
195
+ if (!raw) return undefined;
196
+ const n = Number.parseInt(raw, 10);
197
+ return Number.isInteger(n) && n >= 0 ? n : undefined;
198
+ }
199
+
129
200
  const KNOWN_CONFIG_KEYS = new Set([
130
201
  'db', 'model', 'indexDir', 'index-dir', 'cacheDir', 'cache-dir',
131
202
  'ignore', 'path', 'k', 'maxPerFile', 'max-per-file', 'maxPerDoc', 'max-per-doc',
132
203
  'targetTokens', 'target-tokens', 'port', 'host', 'apiKey', 'api-key',
204
+ 'apiKeyFile', 'api-key-file', 'allowUnsecured', 'allow-unsecured',
205
+ 'allowedHost', 'allowed-host', 'allowedHosts', 'allowed-hosts',
206
+ 'corsOrigin', 'cors-origin',
207
+ 'rateLimit', 'rate-limit', 'maxConcurrency', 'max-concurrency',
133
208
  'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
134
209
  'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
135
- 'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
210
+ 'watchDebug', 'watch-debug',
211
+ 'format', 'noVectors', 'no-vectors', 'output', 'workers', 'dimensions', 'dim', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
136
212
  'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
137
- 'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt'
213
+ 'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt',
214
+ 'session', 'resume',
215
+ 'completions', 'open', 'dry-run', 'fix', 'yes', 'no-ui', 'noUi',
216
+ 'noQueryCache', 'no-query-cache',
217
+ 'mcp',
218
+ 'embedder', 'embedder-model', 'embedder-base-url', 'embedder-key-file',
219
+ 'embedderModel', 'embedderBaseUrl', 'embedderKeyFile',
220
+ 'expand', 'expandPassages', 'expand-passages', 'llm-endpoint', 'llmEndpoint'
221
+ ]);
222
+
223
+ const CONFIG_NETWORK_KEYS = new Set([
224
+ 'port', 'host', 'apiKey', 'api-key', 'apiKeyFile', 'api-key-file',
225
+ 'allowUnsecured', 'allow-unsecured', 'allowedHost', 'allowed-host',
226
+ 'allowedHosts', 'allowed-hosts', 'corsOrigin', 'cors-origin',
227
+ 'rateLimit', 'rate-limit', 'maxConcurrency', 'max-concurrency',
228
+ 'healthPublic', 'health-public', 'mcp', 'llmEndpoint', 'llm-endpoint',
229
+ 'llmModel', 'llm-model', 'embedder', 'embedder-model', 'embedder-base-url',
230
+ 'embedder-key-file', 'embedderModel', 'embedderBaseUrl', 'embedderKeyFile'
231
+ ]);
232
+
233
+ const CONFIG_EXEC_KEYS = new Set([
234
+ // No execution surfaces permitted in local repo config
138
235
  ]);
139
236
 
237
+ function getConfigKeyTrustTier(key) {
238
+ if (CONFIG_EXEC_KEYS.has(key)) return 'exec';
239
+ if (CONFIG_NETWORK_KEYS.has(key)) return 'network';
240
+ if (KNOWN_CONFIG_KEYS.has(key)) return 'local-safe';
241
+ return 'unknown';
242
+ }
243
+
140
244
  function findConfigFile(explicitPath) {
141
245
  if (explicitPath) {
142
246
  const abs = path.resolve(explicitPath);
@@ -176,22 +280,26 @@ function findConfigFile(explicitPath) {
176
280
  }
177
281
 
178
282
  function loadConfigFile(configPath) {
179
- if (!configPath) return { config: {}, path: null, unknownKeys: [], error: null };
283
+ if (!configPath) return { config: {}, path: null, unknownKeys: [], trustTiers: {}, error: null };
180
284
  try {
181
285
  const raw = fs.readFileSync(configPath, 'utf8');
182
286
  const parsed = JSON.parse(raw);
183
287
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
184
- return { config: {}, path: configPath, unknownKeys: [], error: `Config at ${configPath} must be a JSON object` };
288
+ return { config: {}, path: configPath, unknownKeys: [], trustTiers: {}, error: `Config at ${configPath} must be a JSON object` };
185
289
  }
186
290
  const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_CONFIG_KEYS.has(k));
291
+ const trustTiers = {};
292
+ for (const k of Object.keys(parsed)) {
293
+ trustTiers[k] = getConfigKeyTrustTier(k);
294
+ }
187
295
  const normalized = {};
188
296
  for (const [k, v] of Object.entries(parsed)) {
189
297
  const camel = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
190
298
  normalized[camel] = v;
191
299
  }
192
- return { config: normalized, path: configPath, unknownKeys, error: null };
300
+ return { config: normalized, path: configPath, unknownKeys, trustTiers, error: null };
193
301
  } catch (e) {
194
- return { config: {}, path: configPath, unknownKeys: [], error: `Failed to parse config at ${configPath}: ${e.message}` };
302
+ return { config: {}, path: configPath, unknownKeys: [], trustTiers: {}, error: `Failed to parse config at ${configPath}: ${e.message}` };
195
303
  }
196
304
  }
197
305
 
@@ -252,9 +360,13 @@ Usage:
252
360
  mdss check --db <dir> [--json] Diagnose index/db/model cache (alias: doctor)
253
361
  mdss export --db <dir> [options] Export index to JSONL / CSV / Parquet
254
362
  mdss search --db <dir> [options] "query" Search by meaning
363
+ mdss related --db <dir> [options] <note> Find related notes via graph links and similarity
364
+ mdss ask --db <dir> [options] "question" Grounded QA synthesis with citations
365
+ mdss chat --db <dir> [options] Multi-turn persistent chat with citation manifest
255
366
  mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
256
367
  mdss mcp --db <dir> [--list-tools] Start MCP server over stdio for LLM agents / IDEs
257
368
  mdss models List available models
369
+ mdss completions <bash|zsh|fish|powershell> Print a self-contained shell completion script
258
370
 
259
371
  Options:
260
372
  --config <file> Path to config file (default: .mdssrc.json / mdss.config.json).
@@ -262,6 +374,7 @@ Options:
262
374
  --index-dir <dir> Where to store the index (default: <db>/.mdss).
263
375
  --cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
264
376
  --model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
377
+ --dimensions <n> Truncate vector dimensions for Matryoshka/MRL models (alias: --dim).
265
378
  --workers <n> Number of parallel batch workers for indexing (default: 1).
266
379
  --format <fmt> Export format: jsonl (default), csv, parquet (export).
267
380
  --no-vectors Omit vector embeddings from export (export).
@@ -280,11 +393,64 @@ Options:
280
393
  --port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
281
394
  --host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
282
395
  only; use 0.0.0.0 to expose on the LAN, env MDSS_HOST).
396
+ --api-key <key> Require Bearer auth on every request (env MDSS_API_KEY).
397
+ --api-key-file <p> Read the API key from a file instead of a literal value —
398
+ avoids shell-history/CI-log leaks (env MDSS_API_KEY_FILE;
399
+ trailing newline trimmed, POSIX chmod-checked).
400
+ --allow-unsecured Explicit opt-in to serve non-loopback WITHOUT auth — a loud
401
+ banner is printed; without it (and without an api key) a
402
+ non-loopback bind refuses to start (issue #121).
403
+ --allowed-host <h> Extra Host header value to accept (repeatable) — requests
404
+ naming any other Host get 403 (DNS-rebinding guard, issue #120).
405
+ --cors-origin <o> Allow cross-origin browser access from this exact origin
406
+ (repeatable); CORS is off unless set, and only exact
407
+ matches are reflected with Vary: Origin (issue #120).
408
+ --rate-limit <n> serve: max /search requests per minute per client, burst
409
+ 10 → 429 + Retry-After above it; 0 disables (default 60,
410
+ env MDSS_RATE_LIMIT; issue #119).
411
+ --max-concurrency <n> serve: max concurrent in-flight searches, queue cap
412
+ 2× → 503 when full; 0 disables (default = CPU count,
413
+ env MDSS_MAX_CONCURRENCY; issue #119).
283
414
  --watch serve: re-index incrementally on file changes (mtime poll).
415
+ --no-ui serve: disable the built-in web UI at the root path
416
+ (issue #111) - the JSON API help is served there instead.
417
+ --mcp serve: also mount Streamable HTTP MCP at /mcp
418
+ (issue #123) - same auth/host/rate-limit gates as
419
+ /search; stdio via the mcp command remains default.
420
+ --watch-debug serve --watch: verbose trace of polls, FS-error
421
+ classifications and retries (issue #116).
284
422
  --watch-interval <ms> serve --watch: poll every N ms (default 3000).
285
423
  --watch-delay <ms> serve --watch: quiet-period debounce before a burst of
286
424
  saves triggers ONE re-index (default 1000; issue #42).
287
425
  --offline Never download the model; require a cached one (env MDSS_OFFLINE=1).
426
+ --no-query-cache search: skip the persistent query-embedding cache
427
+ (<cacheDir>/query-cache.json, issue #114); in-memory
428
+ caching stays on.
429
+ --recency <days> search: time-decay half-life — fresher passages get
430
+ 0.5^(age/halfLife) boost post-fusion (frontmatter
431
+ created/updated, then file mtime; issue #127).
432
+ --embedder <name> Use an EXTERNAL embedding provider instead of local
433
+ transformers.js: "ollama" or "openai" (issue #124).
434
+ Local stays the zero-config default and the only
435
+ fully-offline path.
436
+ --embedder-model <m> Provider-side model (e.g. nomic-embed-text).
437
+ --embedder-base-url <u> Endpoint (default: OLLAMA_HOST /
438
+ http://127.0.0.1:11434 for ollama,
439
+ https://api.openai.com/v1 for openai).
440
+ --embedder-key-file <p> Bearer key file for openai-compatible endpoints
441
+ (or OPENAI_API_KEY env).
442
+ --expand <mode> search: query expansion - "prf" (offline pseudo-
443
+ relevance feedback: top passages feed salient terms,
444
+ ONE embed total; issue #125) or "hyde" (LLM-generated
445
+ passage via --llm-endpoint; degrades silently without it).
446
+ --expand-passages <n> prf feedback pool size (default 3, max 20).
447
+ --open [N] search: open the top (or Nth) hit in your editor at its
448
+ startLine — MDSS_EDITOR/VISUAL/EDITOR, then VS Code
449
+ --goto, then the GUI opener (issue #110). With --json on
450
+ a non-TTY prints what WOULD be opened instead of launching.
451
+ --fix check: apply safe auto-repairs (stale lock, broken
452
+ vectors.bin sidecar, stale ivf.json); never touches notes.
453
+ --dry-run check --fix: print planned repairs without mutating.
288
454
  --version Print the version and exit.
289
455
  -h, --help Show this help.
290
456
 
@@ -306,6 +472,35 @@ function formatDuration(sec) {
306
472
  return `${h}:${String(m).padStart(2, '0')}:${String(remainingS).padStart(2, '0')}`;
307
473
  }
308
474
 
475
+ /**
476
+ * External embedder wiring (issue #124): when --embedder is set, build the
477
+ * synthetic adapter + embedFn and pass BOTH into the pipeline. Provider
478
+ * mismatch across rebuilds surfaces through the existing adapter-fingerprint
479
+ * invalidation, with the index `model` field naming the provider explicitly.
480
+ */
481
+ async function resolveEmbedderOverrides(opts) {
482
+ if (!opts.embedder) return {};
483
+ if (!['ollama', 'openai'].includes(opts.embedder)) {
484
+ die(`unknown --embedder "${opts.embedder}" - use "ollama" or "openai"`);
485
+ }
486
+ if (!opts.embedderModel) {
487
+ die(`--embedder ${opts.embedder} requires --embedder-model <name>`);
488
+ }
489
+ const emb = resolveExternalEmbedder({
490
+ embedder: opts.embedder,
491
+ model: opts.embedderModel,
492
+ baseUrl: opts.embedderBaseUrl,
493
+ keyFile: opts.embedderKeyFile,
494
+ });
495
+ const descriptor = await emb.descriptor();
496
+ logErr(`embedder: ${emb.id}/${emb.model} (dim ${descriptor.dim} via ${opts.embedderBaseUrl || 'default endpoint'})`);
497
+ return { modelName: descriptor, embedFn: emb.embedFn };
498
+ }
499
+
500
+ function logErr(s) {
501
+ process.stderr.write(s + '\n');
502
+ }
503
+
309
504
  async function cmdIndex(opts) {
310
505
  const db = resolveDb(opts);
311
506
  const indexDir = resolveIndexDir(opts, db);
@@ -333,11 +528,15 @@ async function cmdIndex(opts) {
333
528
  }
334
529
  };
335
530
 
531
+ const embedderOverrides = await resolveEmbedderOverrides(opts);
532
+
336
533
  let r;
337
534
  try {
338
535
  r = await buildIndex({
339
536
  db, indexDir, cacheDir,
340
- modelName: opts.model || DEFAULT_MODEL,
537
+ modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
538
+ dimensions: opts.dimensions,
539
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
341
540
  ignore: opts.ignore,
342
541
  offline: resolveOffline(opts),
343
542
  workers: opts.workers,
@@ -613,6 +812,7 @@ function checkHealth({ db, indexDir, cacheDir, requireOffline = false, config =
613
812
  path: config.path,
614
813
  valid: !config.error && (!config.unknownKeys || config.unknownKeys.length === 0),
615
814
  unknownKeys: config.unknownKeys || [],
815
+ trustTiers: config.trustTiers || {},
616
816
  error: config.error || (config.unknownKeys && config.unknownKeys.length > 0 ? `unknown config keys: ${config.unknownKeys.join(', ')}` : null),
617
817
  };
618
818
  if (!report.config.valid) {
@@ -777,7 +977,7 @@ const modelId = index.model ?? null;
777
977
  * embedding model or touching the network. Exit code 0 when healthy, 1 with a
778
978
  * summary when not; `--json` for scripting.
779
979
  */
780
- function cmdCheck(opts) {
980
+ async function cmdCheck(opts) {
781
981
  const db = resolveDb(opts);
782
982
  const indexDir = resolveIndexDir(opts, db);
783
983
  const cacheDir = resolveCache(opts);
@@ -787,6 +987,46 @@ function cmdCheck(opts) {
787
987
  config: opts._config,
788
988
  });
789
989
 
990
+ // --fix (issue #117): plan safe repairs, optionally apply them. Never
991
+ // touches source Markdown; --dry-run prints the plan without mutating.
992
+ if (opts.fix) {
993
+ const plan = planRepairs(indexDir, { db: fs.existsSync(db) ? db : null });
994
+ const result = await applyRepairs(indexDir, plan, {
995
+ dryRun: !!opts.dryRun,
996
+ db: fs.existsSync(db) ? db : null,
997
+ cacheDir,
998
+ modelName: opts.model || undefined,
999
+ log: (s) => process.stderr.write(s + '\n'),
1000
+ });
1001
+ report.fix = {
1002
+ dryRun: !!opts.dryRun,
1003
+ planned: plan.actions,
1004
+ performed: result.performed,
1005
+ skipped: plan.skipped,
1006
+ failed: result.failed,
1007
+ };
1008
+
1009
+ if (opts.json) {
1010
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
1011
+ process.exitCode = result.failed.length > 0 ? 1 : 0;
1012
+ return;
1013
+ }
1014
+
1015
+ let out = '';
1016
+ for (const a of (opts.dryRun ? plan.actions : result.performed)) {
1017
+ out += ` ${opts.dryRun ? 'would' : 'fixed'} ${a.action}: ${a.detail}\n`;
1018
+ }
1019
+ for (const s of plan.skipped) out += ` skip ${s.action}: ${s.detail}\n`;
1020
+ for (const f of result.failed) out += ` FAIL ${f.action}: ${f.error}\n`;
1021
+ if (plan.actions.length === 0 && plan.skipped.length === 0) out += ' ok nothing to repair\n';
1022
+ process.stdout.write(out);
1023
+ process.exitCode = result.failed.length > 0 ? 1 : 0;
1024
+ if (!opts.dryRun && result.performed.length > 0) {
1025
+ process.stdout.write('check: repairs applied — run `mdss check` to verify (issue #117)\n');
1026
+ }
1027
+ return;
1028
+ }
1029
+
790
1030
  if (opts.json) {
791
1031
  process.stdout.write(JSON.stringify(report, null, 2) + '\n');
792
1032
  process.exitCode = report.healthy ? 0 : 1;
@@ -886,6 +1126,13 @@ async function cmdTui(opts) {
886
1126
  semanticOnly: !!opts.semantic,
887
1127
  offline: resolveOffline(opts),
888
1128
  path: opts.path.length > 0 ? opts.path : undefined,
1129
+ since: opts.since || undefined,
1130
+ filter: opts.filter || undefined,
1131
+ tag: opts.tag.length > 0 ? opts.tag : undefined,
1132
+ project: opts.project || undefined,
1133
+ type: opts.type || undefined,
1134
+ status: opts.status || undefined,
1135
+ graphBoost: typeof opts.graphBoost === 'number' ? opts.graphBoost : undefined,
889
1136
  rerank: !!opts.rerank,
890
1137
  rag: !!opts.rag,
891
1138
  });
@@ -927,23 +1174,173 @@ async function cmdAsk(opts) {
927
1174
  }
928
1175
  }
929
1176
 
1177
+ async function cmdChat(opts) {
1178
+ const db = resolveDb(opts);
1179
+ const indexDir = resolveIndexDir(opts, db);
1180
+ const cacheDir = resolveCache(opts);
1181
+ const k = opts.k || 5;
1182
+
1183
+ const vectorsPath = path.join(indexDir, 'vectors.json');
1184
+ if (!fs.existsSync(vectorsPath)) {
1185
+ die(`No index at ${vectorsPath}. Run \`mdss index --db ${db}\` first.`);
1186
+ }
1187
+
1188
+ const { chatTurn, loadChatSession, listChatSessions } = await import('../dist/rag.js');
1189
+ const loaded = loadIndex(indexDir);
1190
+
1191
+ let sessionId = opts.session;
1192
+ if (!sessionId && opts.resume) {
1193
+ const existing = listChatSessions(indexDir);
1194
+ if (existing.length > 0) {
1195
+ sessionId = existing[0].id;
1196
+ }
1197
+ }
1198
+ if (!sessionId) {
1199
+ sessionId = crypto.randomUUID().slice(0, 8);
1200
+ }
1201
+
1202
+ const session = loadChatSession(indexDir, sessionId, (msg) => {
1203
+ if (!opts.json) process.stderr.write(`${msg}\n`);
1204
+ });
1205
+
1206
+ const query = opts._.join(' ').trim();
1207
+ // If one-shot query provided as argument:
1208
+ if (query) {
1209
+ const res = await chatTurn({
1210
+ session,
1211
+ query,
1212
+ loaded,
1213
+ indexDir,
1214
+ cacheDir,
1215
+ k,
1216
+ llmEndpoint: opts.llmEndpoint,
1217
+ llmModel: opts.llmModel,
1218
+ systemPrompt: opts.systemPrompt,
1219
+ });
1220
+
1221
+ if (opts.json) {
1222
+ process.stdout.write(JSON.stringify(res, null, 2) + '\n');
1223
+ return;
1224
+ }
1225
+
1226
+ process.stdout.write(`\n${res.answer}\n\n`);
1227
+ if (res.citations && res.citations.length > 0) {
1228
+ process.stdout.write('Sources:\n');
1229
+ for (const c of res.citations) {
1230
+ const heading = c.heading ? ` › ${c.heading}` : '';
1231
+ process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
1232
+ }
1233
+ process.stdout.write('\n');
1234
+ }
1235
+ return;
1236
+ }
1237
+
1238
+ // Interactive REPL mode:
1239
+ const readline = await import('node:readline');
1240
+ const rl = readline.createInterface({
1241
+ input: process.stdin,
1242
+ output: process.stdout,
1243
+ });
1244
+
1245
+ const banner = `\x1b[1mmdss chat\x1b[0m (session: \x1b[36m${session.id}\x1b[0m, turns: ${session.turns.length}, chunks: ${loaded.index.chunks.length})\nCommands: /session, /sources, /help, /exit\n`;
1246
+ process.stdout.write(banner);
1247
+
1248
+ const promptUser = () => {
1249
+ rl.question('\n\x1b[1mmdss chat>\x1b[0m ', async (input) => {
1250
+ const line = (input || '').trim();
1251
+ if (!line) {
1252
+ promptUser();
1253
+ return;
1254
+ }
1255
+
1256
+ if (line === '/exit' || line === '/quit' || line === ':q') {
1257
+ rl.close();
1258
+ return;
1259
+ }
1260
+
1261
+ if (line === '/help') {
1262
+ process.stdout.write('Available commands:\n /session Show current session information\n /sources Show retrieved sources manifest from last turn\n /help Show this help message\n /exit Quit chat\n');
1263
+ promptUser();
1264
+ return;
1265
+ }
1266
+
1267
+ if (line === '/session') {
1268
+ process.stdout.write(`Session ID: ${session.id}\nCreated: ${session.createdAt}\nTurns: ${session.turns.length}\n`);
1269
+ promptUser();
1270
+ return;
1271
+ }
1272
+
1273
+ if (line === '/sources') {
1274
+ const lastTurn = session.turns[session.turns.length - 1];
1275
+ if (!lastTurn || !lastTurn.manifest || lastTurn.manifest.length === 0) {
1276
+ process.stdout.write('No sources available yet.\n');
1277
+ } else {
1278
+ process.stdout.write('Sources from last turn:\n');
1279
+ for (const m of lastTurn.manifest) {
1280
+ const loc = (m.startLine && m.endLine) ? `:L${m.startLine}-L${m.endLine}` : '';
1281
+ const heading = m.heading ? ` › ${m.heading}` : '';
1282
+ process.stdout.write(` - ${m.file}${heading}${loc} [score: ${m.score.toFixed(3)}]\n`);
1283
+ }
1284
+ }
1285
+ promptUser();
1286
+ return;
1287
+ }
1288
+
1289
+ try {
1290
+ const res = await chatTurn({
1291
+ session,
1292
+ query: line,
1293
+ loaded,
1294
+ indexDir,
1295
+ cacheDir,
1296
+ k,
1297
+ llmEndpoint: opts.llmEndpoint,
1298
+ llmModel: opts.llmModel,
1299
+ systemPrompt: opts.systemPrompt,
1300
+ });
1301
+
1302
+ process.stdout.write(`\n${res.answer}\n`);
1303
+ if (res.citations && res.citations.length > 0) {
1304
+ process.stdout.write('\nSources:\n');
1305
+ for (const c of res.citations) {
1306
+ const heading = c.heading ? ` › ${c.heading}` : '';
1307
+ process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
1308
+ }
1309
+ }
1310
+ } catch (e) {
1311
+ process.stdout.write(`\nError: ${e.message}\n`);
1312
+ }
1313
+
1314
+ promptUser();
1315
+ });
1316
+ };
1317
+
1318
+ promptUser();
1319
+ }
1320
+
930
1321
  async function cmdSearch(opts) {
931
1322
  if (opts.interactive) return cmdTui(opts);
932
1323
  const cacheDir = resolveCache(opts);
933
1324
  const query = opts._.join(' ').trim();
934
1325
  if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
935
1326
 
1327
+ const embedderOverrides = await resolveEmbedderOverrides(opts);
936
1328
  let results;
937
1329
  const isFederated = Array.isArray(opts.vaults) && opts.vaults.length > 0;
938
1330
 
939
1331
  if (isFederated) {
940
1332
  results = await searchFederated({
1333
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
941
1334
  vaults: opts.vaults,
942
1335
  cacheDir,
943
1336
  query,
944
1337
  k: opts.k || 6,
945
1338
  semanticOnly: !!opts.semantic,
946
1339
  offline: resolveOffline(opts),
1340
+ queryDiskCache: !opts.noQueryCache,
1341
+ recency: opts.recency,
1342
+ expand: opts.expand,
1343
+ expandPassages: opts.expandPassages,
947
1344
  path: opts.path.length > 0 ? opts.path : undefined,
948
1345
  since: opts.since,
949
1346
  rerank: !!opts.rerank,
@@ -964,10 +1361,16 @@ async function cmdSearch(opts) {
964
1361
  const indexDir = resolveIndexDir(opts, db);
965
1362
 
966
1363
  results = await search({
1364
+ ...(embedderOverrides.modelName ? { model: embedderOverrides.modelName } : {}),
1365
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
967
1366
  indexDir, cacheDir, query,
968
1367
  k: opts.k || 6,
969
1368
  semanticOnly: !!opts.semantic,
970
1369
  offline: resolveOffline(opts),
1370
+ queryDiskCache: !opts.noQueryCache,
1371
+ recency: opts.recency,
1372
+ expand: opts.expand,
1373
+ expandPassages: opts.expandPassages,
971
1374
  path: opts.path.length > 0 ? opts.path : undefined,
972
1375
  since: opts.since,
973
1376
  rerank: !!opts.rerank,
@@ -982,12 +1385,19 @@ async function cmdSearch(opts) {
982
1385
  nprobe: opts.nprobe,
983
1386
  graphBoost: opts.graphBoost,
984
1387
  filter: opts.filter,
1388
+ dimensions: opts.dimensions,
985
1389
  });
986
1390
  }
987
1391
 
988
1392
  if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
989
1393
  if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
990
1394
 
1395
+ // --open (issue #110): jump into the editor at the hit's exact line.
1396
+ if (opts.open || opts.openRank) {
1397
+ await openSearchHit(opts, results);
1398
+ return;
1399
+ }
1400
+
991
1401
  process.stdout.write(`\nTop ${results.length} for: "${query}"\n\n`);
992
1402
 
993
1403
  // Group by file (issue #13): each file becomes a header with its hits below.
@@ -1016,9 +1426,50 @@ async function cmdSearch(opts) {
1016
1426
  }
1017
1427
  }
1018
1428
 
1429
+ /**
1430
+ * --open (issue #110): resolve the Nth hit to an absolute path and jump to it
1431
+ * in the user's editor at startLine. With --json on a non-TTY nothing is
1432
+ * launched — the intent is printed as a JSON line on stderr instead, keeping
1433
+ * stdout machine-readable.
1434
+ */
1435
+ async function openSearchHit(opts, results) {
1436
+ const rank = opts.openRank || 1;
1437
+ if (!Number.isInteger(rank) || rank < 1) die(`--open N must be a positive hit number, got ${rank}`);
1438
+ const hit = results[rank - 1];
1439
+ if (!hit) {
1440
+ die(`--open ${rank}: only ${results.length} hit(s) available`);
1441
+ }
1442
+
1443
+ // Resolve relative hit paths against the right base (db or the matched vault).
1444
+ let base = null;
1445
+ if (hit.vault && Array.isArray(opts.vaults)) {
1446
+ base = opts.vaults.find((v) => v === hit.vault || path.basename(v) === hit.vault) || null;
1447
+ }
1448
+ if (!base) base = resolveDb(opts);
1449
+ const absFile = path.isAbsolute(hit.file) ? hit.file : path.join(base, hit.file);
1450
+ const target = { file: absFile, line: hit.startLine };
1451
+ const envEditor = process.env.MDSS_EDITOR || process.env.VISUAL || process.env.EDITOR || undefined;
1452
+
1453
+ // --json on a non-TTY: print what WOULD be launched, launch nothing (issue #110).
1454
+ if (opts.json && !process.stdout.isTTY) {
1455
+ const would = resolveOpenCommand(target, { editor: envEditor, platform: process.platform });
1456
+ if (!would) die(`could not resolve an editor for ${absFile} — set $MDSS_EDITOR/$VISUAL/$EDITOR`);
1457
+ process.stderr.write(JSON.stringify({
1458
+ type: 'open-intent',
1459
+ file: absFile,
1460
+ line: hit.startLine ?? null,
1461
+ via: would.via,
1462
+ }) + '\n');
1463
+ return;
1464
+ }
1465
+
1466
+ const resolved = openHit(target, { editor: envEditor });
1467
+ if (!resolved) die(`could not resolve an editor for ${absFile} — set $MDSS_EDITOR/$VISUAL/$EDITOR`);
1468
+ process.stderr.write(`→ opened ${absFile}${hit.startLine ? `:${hit.startLine}` : ''} via ${resolved.via}\n`);
1469
+ }
1470
+
1019
1471
  /** Escape a string for use inside a RegExp. */
1020
- function escRe(s) {
1021
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1472
+ function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1022
1473
  }
1023
1474
 
1024
1475
  function cmdModels() {
@@ -1028,9 +1479,10 @@ function cmdModels() {
1028
1479
  const pooling = m.pooling ?? 'mean';
1029
1480
  const normalized = m.normalize !== false ? 'L2' : 'raw';
1030
1481
  const rev = m.revision ? ` · rev ${m.revision.slice(0, 8)}` : '';
1482
+ const mrl = m.dimensions ? ` · MRL: ${m.dimensions.join(', ')}` : '';
1031
1483
  process.stdout.write(
1032
1484
  ` ${alias}${star}\n` +
1033
- ` ${m.id} · dim ${m.dim}${rev}\n` +
1485
+ ` ${m.id} · dim ${m.dim}${mrl}${rev}\n` +
1034
1486
  ` ${pooling} · ${normalized} · maxTokens ${m.maxTokens ?? '—'} ` +
1035
1487
  (m.family ? `· family ${m.family}\n` : '\n') +
1036
1488
  ` ${m.note}\n\n`,
@@ -1052,16 +1504,47 @@ async function cmdServe(opts) {
1052
1504
  const host = opts.host || process.env.MDSS_HOST || DEFAULT_HOST;
1053
1505
  const log = s => process.stderr.write(s + '\n');
1054
1506
 
1507
+ const embedderOverrides = await resolveEmbedderOverrides(opts);
1508
+
1509
+ // API key precedence (issue #121): literal flag/env first, then key file —
1510
+ // the file form avoids shell-history and CI-log leaks of the literal value.
1511
+ let apiKey = opts.apiKey || process.env.MDSS_API_KEY || undefined;
1512
+ const keyFile = opts.apiKeyFile || process.env.MDSS_API_KEY_FILE;
1513
+ if (!apiKey && keyFile) {
1514
+ apiKey = loadApiKeyFile(keyFile, log);
1515
+ log(`api-key-file: key loaded from ${keyFile}`);
1516
+ }
1517
+
1518
+ // Refuse to serve the whole knowledge base to the network unauthenticated (issue #121).
1519
+ validateBindSecurity({ host, hasApiKey: !!apiKey, allowUnsecured: !!opts.allowUnsecured });
1520
+
1055
1521
  const { server, state, close } = await createServe({
1056
1522
  db, indexDir, cacheDir,
1057
- modelName: opts.model || DEFAULT_MODEL,
1523
+ modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
1058
1524
  ignore: opts.ignore,
1059
1525
  offline: resolveOffline(opts),
1526
+ ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
1060
1527
  watch: !!opts.watch,
1061
1528
  watchInterval: opts.watchInterval,
1062
1529
  watchDelay: opts.watchDelay,
1063
- apiKey: opts.apiKey || process.env.MDSS_API_KEY,
1530
+ watchDebug: !!opts.watchDebug,
1531
+ apiKey,
1064
1532
  healthPublic: !!opts.healthPublic || process.env.MDSS_HEALTH_PUBLIC === 'true',
1533
+ // Web UI on by default at `/` (issue #111); --no-ui restores pure JSON.
1534
+ ui: !opts.noUi,
1535
+ mcp: !!opts.mcp,
1536
+ // DNS-rebinding allowlist (issue #120): loopback names are always allowed;
1537
+ // the configured bind host is added so `--host mybox.local` still answers.
1538
+ allowedHosts: [
1539
+ ...(opts.allowedHost || []),
1540
+ ...(opts.host ? [opts.host] : []),
1541
+ ...(process.env.MDSS_HOST ? [process.env.MDSS_HOST] : []),
1542
+ ],
1543
+ // CORS stays OFF unless explicitly opted in (issue #120).
1544
+ corsOrigins: opts.corsOrigin,
1545
+ // Fairness gates (issue #119); 0 explicitly disables a gate.
1546
+ rateLimit: opts.rateLimit ?? envNonNegInt('MDSS_RATE_LIMIT'),
1547
+ maxConcurrency: opts.maxConcurrency ?? envNonNegInt('MDSS_MAX_CONCURRENCY'),
1065
1548
  log,
1066
1549
  });
1067
1550
 
@@ -1073,6 +1556,14 @@ async function cmdServe(opts) {
1073
1556
  (state.watching ? ' · watching for changes' : ''));
1074
1557
  log(` POST /search {"query": "...", "k": 6}`);
1075
1558
  log(` GET /health`);
1559
+ if (!isLoopbackHost(host) && !state.apiKey) {
1560
+ log('');
1561
+ log(' ⚠⚠⚠ UNSECURED NETWORK BIND — EXPLICIT OPT-IN (--allow-unsecured) ⚠⚠⚠');
1562
+ log(' ⚠⚠⚠ The ENTIRE knowledge base is readable by anyone who can ⚠⚠⚠');
1563
+ log(' ⚠⚠⚠ reach this port. Put a reverse proxy with TLS + auth in ⚠⚠⚠');
1564
+ log(' ⚠⚠⚠ front, or set an API key (--api-key / --api-key-file). ⚠⚠⚠');
1565
+ log('');
1566
+ }
1076
1567
  log(` Ctrl+C to stop.`);
1077
1568
  });
1078
1569
 
@@ -1098,6 +1589,8 @@ async function cmdMcp(opts) {
1098
1589
  const { startMcpServer } = await import('../dist/mcp.js');
1099
1590
  await startMcpServer({
1100
1591
  db, indexDir, cacheDir,
1592
+ // NOTE: external embedders (--embedder) are not yet wired into the MCP
1593
+ // stdio server; it keeps the local transformers.js path (issue #124).
1101
1594
  modelName: opts.model || DEFAULT_MODEL,
1102
1595
  ignore: opts.ignore,
1103
1596
  offline: resolveOffline(opts),
@@ -1105,6 +1598,121 @@ async function cmdMcp(opts) {
1105
1598
  });
1106
1599
  }
1107
1600
 
1601
+ // ---------------------------------------------------------------------------
1602
+ // First-run model download progress (issue #108): the embedding model (~280 MB)
1603
+ // and the reranker (~280 MB) stream in silently otherwise, which reads as a
1604
+ // hang on slow links. TTY → one aggregated bar; --json → throttled JSON events
1605
+ // on STDERR so stdout stays machine-readable; warm cache emits nothing at all.
1606
+ // ---------------------------------------------------------------------------
1607
+
1608
+ const DOWNLOAD_COMMANDS = new Set(['index', 'search', 'ask', 'chat', 'serve']);
1609
+
1610
+ function formatMb(bytes) {
1611
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
1612
+ return bytes >= 1024 * 1024 * 1024
1613
+ ? `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
1614
+ : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1615
+ }
1616
+
1617
+ function renderDownloadBar(snap) {
1618
+ const barWidth = 10;
1619
+ const filled = snap.percent === null ? 0 : Math.min(barWidth, Math.max(0, Math.round((snap.percent / 100) * barWidth)));
1620
+ const bar = '[' + '#'.repeat(filled) + '-'.repeat(barWidth - filled) + ']';
1621
+ const pct = snap.percent === null ? '…' : `${Math.floor(snap.percent)}%`;
1622
+ const size = `${formatMb(snap.loadedBytes)}${snap.totalBytes > 0 ? `/${formatMb(snap.totalBytes)}` : ''}`;
1623
+ const speed = snap.mbps !== null ? `${snap.mbps.toFixed(1)} MB/s` : '—';
1624
+ const eta = snap.etaSec !== null && Number.isFinite(snap.etaSec) ? formatDuration(snap.etaSec) : '—';
1625
+ process.stderr.write(`\r${bar} ${pct} │ ${size} │ ${speed} │ ETA ${eta}`);
1626
+ if (snap.complete) process.stderr.write('\n');
1627
+ }
1628
+
1629
+ /** Install the progress listener; returns a detach fn for the caller's finally. */
1630
+ function installDownloadProgress(opts) {
1631
+ const agg = new DownloadProgressAggregator();
1632
+ setDownloadProgressListener((e) => {
1633
+ agg.update(e);
1634
+ // JSON consumers poll slower than eyes; throttle accordingly.
1635
+ if (!agg.shouldRender(Date.now(), opts.json ? 1000 : 400)) return;
1636
+ const snap = agg.snapshot();
1637
+ if (!opts.json && process.stderr.isTTY) {
1638
+ renderDownloadBar(snap);
1639
+ } else if (opts.json) {
1640
+ process.stderr.write(JSON.stringify({
1641
+ type: snap.complete ? 'download-complete' : 'download-progress',
1642
+ percent: snap.percent === null ? null : Math.round(snap.percent * 10) / 10,
1643
+ loadedBytes: snap.loadedBytes,
1644
+ totalBytes: snap.totalBytes > 0 ? snap.totalBytes : null,
1645
+ mbps: snap.mbps === null ? null : Math.round(snap.mbps * 100) / 100,
1646
+ etaSec: snap.etaSec === null || !Number.isFinite(snap.etaSec) ? null : Math.round(snap.etaSec),
1647
+ doneFiles: snap.doneFiles,
1648
+ activeFiles: snap.activeFiles,
1649
+ }) + '\n');
1650
+ }
1651
+ });
1652
+ return () => setDownloadProgressListener(null);
1653
+ }
1654
+
1655
+ async function cmdCompletions(opts) {
1656
+ const shell = (opts._.shift() || '').toLowerCase();
1657
+ if (!COMPLETION_SHELLS.includes(shell)) {
1658
+ die(`unknown shell "${shell || '(none)'}" for completions — use one of: ${COMPLETION_SHELLS.join('|')}`);
1659
+ }
1660
+ process.stdout.write(generateCompletion(shell));
1661
+ }
1662
+
1663
+ async function cmdRelated(opts) {
1664
+ const db = resolveDb(opts);
1665
+ const indexDir = resolveIndexDir(opts, db);
1666
+ const vectorsPath = path.join(indexDir, 'vectors.json');
1667
+ if (!fs.existsSync(vectorsPath)) {
1668
+ die(`No index at ${vectorsPath}. Run \`mdss index --db ${db}\` first.`);
1669
+ }
1670
+ const target = opts._.shift();
1671
+ if (!target) {
1672
+ die('Missing note path/title/alias to find relations for. e.g. `mdss related --db . "Architecture"`');
1673
+ }
1674
+
1675
+ const loaded = loadIndex(indexDir);
1676
+ const k = Number.isInteger(opts.k) && opts.k > 0 ? opts.k : 10;
1677
+ const direction = opts.direction || 'both';
1678
+ const semantic = !opts.noSemantic;
1679
+
1680
+ let r;
1681
+ try {
1682
+ r = findRelatedNotes({
1683
+ loaded,
1684
+ target,
1685
+ k,
1686
+ direction,
1687
+ semantic,
1688
+ });
1689
+ } catch (e) {
1690
+ die(e.message);
1691
+ }
1692
+
1693
+ if (opts.json) {
1694
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
1695
+ return;
1696
+ }
1697
+
1698
+ if (r.results.length === 0) {
1699
+ process.stdout.write(`No related notes found for "${target}" (${r.resolvedFile}).\n`);
1700
+ if (opts.noSemantic) {
1701
+ process.stdout.write('Hint: Try running without `--no-semantic` to discover conceptually related notes without explicit links.\n');
1702
+ }
1703
+ return;
1704
+ }
1705
+
1706
+ process.stdout.write(`Related notes for "${target}" (${r.resolvedFile}):\n\n`);
1707
+ r.results.forEach((hit, idx) => {
1708
+ const scoreStr = hit.score.toFixed(2);
1709
+ process.stdout.write(` ${idx + 1}. \x1b[1m${hit.file}\x1b[0m — score: ${scoreStr} [${hit.reason}]\n`);
1710
+ if (hit.title && hit.title !== hit.file) {
1711
+ process.stdout.write(` title: "${hit.title}"\n`);
1712
+ }
1713
+ });
1714
+ }
1715
+
1108
1716
  async function main() {
1109
1717
  const argv = process.argv.slice(2);
1110
1718
  const opts = parseArgs(argv);
@@ -1120,19 +1728,30 @@ async function main() {
1120
1728
 
1121
1729
  if (opts.version) { process.stdout.write(`${VERSION}\n`); return; }
1122
1730
  if (opts.help || !cmd) { process.stdout.write(HELP); return; }
1123
- switch (cmd) {
1124
- case 'index': return cmdIndex(opts);
1125
- case 'stats': return cmdStats(opts);
1126
- case 'check':
1127
- case 'doctor': return cmdCheck(opts);
1128
- case 'export': return cmdExport(opts);
1129
- case 'search': return cmdSearch(opts);
1130
- case 'ask': return cmdAsk(opts);
1131
- case 'tui': return cmdTui(opts);
1132
- case 'serve': return cmdServe(opts);
1133
- case 'mcp': return cmdMcp(opts);
1134
- case 'models': return cmdModels();
1135
- default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
1731
+ // Model downloads stream through this listener only for commands that may
1732
+ // load a model in-process (issue #108). `mcp` is excluded — its stderr is an
1733
+ // IDE log; `tui` draws its own full-screen UI.
1734
+ const detachProgress = DOWNLOAD_COMMANDS.has(cmd) ? installDownloadProgress(opts) : null;
1735
+ try {
1736
+ switch (cmd) {
1737
+ case 'index': return await cmdIndex(opts);
1738
+ case 'stats': return await cmdStats(opts);
1739
+ case 'check':
1740
+ case 'doctor': return await cmdCheck(opts);
1741
+ case 'export': return await cmdExport(opts);
1742
+ case 'search': return await cmdSearch(opts);
1743
+ case 'related': return await cmdRelated(opts);
1744
+ case 'ask': return await cmdAsk(opts);
1745
+ case 'chat': return await cmdChat(opts);
1746
+ case 'tui': return await cmdTui(opts);
1747
+ case 'serve': return await cmdServe(opts);
1748
+ case 'mcp': return await cmdMcp(opts);
1749
+ case 'models': return await cmdModels();
1750
+ case 'completions': return await cmdCompletions(opts);
1751
+ default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
1752
+ }
1753
+ } finally {
1754
+ detachProgress?.();
1136
1755
  }
1137
1756
  }
1138
1757
 
@@ -1169,6 +1788,15 @@ export {
1169
1788
  parseArgs, nextInt, nextFloat, nextValue,
1170
1789
  resolveDb, resolveIndexDir, resolveCache, resolveOffline,
1171
1790
  findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
1791
+ CONFIG_NETWORK_KEYS, CONFIG_EXEC_KEYS, getConfigKeyTrustTier,
1172
1792
  checkHealth, cmdExport,
1173
1793
  die, main, HELP, VERSION,
1174
1794
  };
1795
+
1796
+
1797
+
1798
+
1799
+
1800
+
1801
+
1802
+