md-semantic-search 1.0.2 → 1.0.3
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/README.md +154 -6
- package/bin/cli.mjs +403 -22
- package/dist/completions.d.ts +25 -0
- package/dist/completions.js +233 -0
- package/dist/core.d.ts +5 -0
- package/dist/core.js +17 -0
- package/dist/download-progress.d.ts +52 -0
- package/dist/download-progress.js +112 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +6 -1
- package/dist/open.d.ts +43 -0
- package/dist/open.js +84 -0
- package/dist/providers.d.ts +21 -0
- package/dist/providers.js +104 -0
- package/dist/query-cache-disk.d.ts +14 -0
- package/dist/query-cache-disk.js +175 -0
- package/dist/repair.d.ts +36 -0
- package/dist/repair.js +208 -0
- package/dist/rerank.js +3 -2
- package/dist/search.d.ts +34 -0
- package/dist/search.js +172 -5
- package/dist/serve.d.ts +126 -0
- package/dist/serve.js +579 -45
- package/dist/watcher.d.ts +16 -0
- package/dist/watcher.js +41 -0
- package/dist/webui.d.ts +13 -0
- package/dist/webui.js +231 -0
- package/package.json +1 -1
- package/src/completions.ts +250 -0
- package/src/core.ts +26 -0
- package/src/download-progress.ts +146 -0
- package/src/index.ts +4 -0
- package/src/mcp.ts +10 -3
- package/src/open.ts +119 -0
- package/src/providers.ts +134 -0
- package/src/query-cache-disk.ts +191 -0
- package/src/repair.ts +245 -0
- package/src/rerank.ts +3 -2
- package/src/search.ts +192 -6
- package/src/serve.ts +635 -42
- package/src/watcher.ts +46 -0
- package/src/webui.ts +234 -0
package/bin/cli.mjs
CHANGED
|
@@ -18,9 +18,14 @@ import crypto from 'node:crypto';
|
|
|
18
18
|
import { buildIndex } from '../dist/indexer.js';
|
|
19
19
|
import { 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 { DownloadProgressAggregator } from '../dist/download-progress.js';
|
|
25
|
+
import { generateCompletion, COMPLETION_SHELLS } from '../dist/completions.js';
|
|
26
|
+
import { openHit, resolveOpenCommand } from '../dist/open.js';
|
|
27
|
+
import { planRepairs, applyRepairs } from '../dist/repair.js';
|
|
28
|
+
import { resolveExternalEmbedder } from '../dist/providers.js';
|
|
24
29
|
import { inspectIndexSchema, validateCurrentChunk, validateIndexEnvelope, validateNumericVector } from '../dist/index-format.js';
|
|
25
30
|
|
|
26
31
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -49,6 +54,7 @@ function parseArgs(argv) {
|
|
|
49
54
|
else if (a === '--semantic') opts.semantic = true;
|
|
50
55
|
else if (a === '--offline') opts.offline = true;
|
|
51
56
|
else if (a === '--watch') opts.watch = true;
|
|
57
|
+
else if (a === '--watch-debug') opts.watchDebug = true;
|
|
52
58
|
else if (a === '--rerank') opts.rerank = true;
|
|
53
59
|
else if (a === '--explain') opts.explain = true;
|
|
54
60
|
else if (a === '--interactive' || a === '-i') opts.interactive = true;
|
|
@@ -72,9 +78,21 @@ function parseArgs(argv) {
|
|
|
72
78
|
else if (a === '--port') opts.port = nextInt(argv, ++i, a);
|
|
73
79
|
else if (a === '--host') opts.host = nextValue(argv, ++i, a);
|
|
74
80
|
else if (a === '--api-key') opts.apiKey = nextValue(argv, ++i, a);
|
|
81
|
+
else if (a === '--api-key-file') opts.apiKeyFile = nextValue(argv, ++i, a);
|
|
82
|
+
else if (a === '--allow-unsecured') opts.allowUnsecured = true;
|
|
83
|
+
else if (a === '--allowed-host') {
|
|
84
|
+
const v = nextValue(argv, ++i, a);
|
|
85
|
+
opts.allowedHost = opts.allowedHost ? [...opts.allowedHost, v] : [v];
|
|
86
|
+
}
|
|
87
|
+
else if (a === '--cors-origin') {
|
|
88
|
+
const v = nextValue(argv, ++i, a);
|
|
89
|
+
opts.corsOrigin = opts.corsOrigin ? [...opts.corsOrigin, v] : [v];
|
|
90
|
+
}
|
|
75
91
|
else if (a === '--health-public') opts.healthPublic = true;
|
|
76
92
|
else if (a === '--watch-delay') opts.watchDelay = nextInt(argv, ++i, a);
|
|
77
93
|
else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
|
|
94
|
+
else if (a === '--rate-limit') opts.rateLimit = nextNonNegInt(argv, ++i, a);
|
|
95
|
+
else if (a === '--max-concurrency') opts.maxConcurrency = nextNonNegInt(argv, ++i, a);
|
|
78
96
|
else if (a === '--since') opts.since = nextValue(argv, ++i, a);
|
|
79
97
|
else if (a === '--path') opts.path.push(nextValue(argv, ++i, a));
|
|
80
98
|
else if (a === '--ignore') opts.ignore.push(nextValue(argv, ++i, a));
|
|
@@ -97,6 +115,37 @@ function parseArgs(argv) {
|
|
|
97
115
|
else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
|
|
98
116
|
else if (a === '--llm-model') opts.llmModel = nextValue(argv, ++i, a);
|
|
99
117
|
else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
|
|
118
|
+
else if (a === '--open') {
|
|
119
|
+
// `--open` opens the top hit; `--open N` opens the Nth hit (issue #110).
|
|
120
|
+
const next = argv[i + 1];
|
|
121
|
+
if (next !== undefined && /^\d+$/.test(next)) {
|
|
122
|
+
opts.openRank = Number.parseInt(next, 10);
|
|
123
|
+
i++;
|
|
124
|
+
} else {
|
|
125
|
+
opts.open = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (a === '--fix') opts.fix = true;
|
|
129
|
+
else if (a === '--dry-run') opts.dryRun = true;
|
|
130
|
+
else if (a === '--no-ui') opts.noUi = true;
|
|
131
|
+
else if (a === '--no-query-cache') opts.noQueryCache = true;
|
|
132
|
+
else if (a === '--mcp') opts.mcp = true;
|
|
133
|
+
else if (a === '--recency') {
|
|
134
|
+
opts.recency = nextFloat(argv, ++i, a);
|
|
135
|
+
if (!(opts.recency > 0)) die(`--recency must be a positive half-life in days, got "${opts.recency}"`);
|
|
136
|
+
}
|
|
137
|
+
else if (a === '--embedder') opts.embedder = nextValue(argv, ++i, a);
|
|
138
|
+
else if (a === '--embedder-model') opts.embedderModel = nextValue(argv, ++i, a);
|
|
139
|
+
else if (a === '--embedder-base-url') opts.embedderBaseUrl = nextValue(argv, ++i, a);
|
|
140
|
+
else if (a === '--embedder-key-file') opts.embedderKeyFile = nextValue(argv, ++i, a);
|
|
141
|
+
else if (a === '--expand') {
|
|
142
|
+
opts.expand = nextValue(argv, ++i, a);
|
|
143
|
+
if (!['prf', 'hyde'].includes(opts.expand)) die(`unknown --expand "${opts.expand}" - use prf|hyde`);
|
|
144
|
+
}
|
|
145
|
+
else if (a === '--expand-passages') {
|
|
146
|
+
opts.expandPassages = nextInt(argv, ++i, a);
|
|
147
|
+
if (opts.expandPassages > 20) die(`--expand-passages must be <= 20`);
|
|
148
|
+
}
|
|
100
149
|
else if (a.startsWith('-')) die(`unknown option: ${a}. Try \`mdss --help\`.`);
|
|
101
150
|
else opts._.push(a);
|
|
102
151
|
}
|
|
@@ -126,15 +175,42 @@ function nextFloat(argv, i, flag) {
|
|
|
126
175
|
return f;
|
|
127
176
|
}
|
|
128
177
|
|
|
178
|
+
/** Like nextInt but allows 0 (used by serve limit flags where 0 = disabled). */
|
|
179
|
+
function nextNonNegInt(argv, i, flag) {
|
|
180
|
+
const v = nextValue(argv, i, flag);
|
|
181
|
+
const n = Number.parseInt(v, 10);
|
|
182
|
+
if (!Number.isInteger(n) || n < 0) die(`${flag} must be a non-negative integer (0 disables), got "${v}"`);
|
|
183
|
+
return n;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Non-negative integer from an env var; undefined when unset/invalid. */
|
|
187
|
+
function envNonNegInt(name) {
|
|
188
|
+
const raw = process.env[name];
|
|
189
|
+
if (!raw) return undefined;
|
|
190
|
+
const n = Number.parseInt(raw, 10);
|
|
191
|
+
return Number.isInteger(n) && n >= 0 ? n : undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
129
194
|
const KNOWN_CONFIG_KEYS = new Set([
|
|
130
195
|
'db', 'model', 'indexDir', 'index-dir', 'cacheDir', 'cache-dir',
|
|
131
196
|
'ignore', 'path', 'k', 'maxPerFile', 'max-per-file', 'maxPerDoc', 'max-per-doc',
|
|
132
197
|
'targetTokens', 'target-tokens', 'port', 'host', 'apiKey', 'api-key',
|
|
198
|
+
'apiKeyFile', 'api-key-file', 'allowUnsecured', 'allow-unsecured',
|
|
199
|
+
'allowedHost', 'allowed-host', 'allowedHosts', 'allowed-hosts',
|
|
200
|
+
'corsOrigin', 'cors-origin',
|
|
201
|
+
'rateLimit', 'rate-limit', 'maxConcurrency', 'max-concurrency',
|
|
133
202
|
'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
|
|
134
203
|
'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
|
|
204
|
+
'watchDebug', 'watch-debug',
|
|
135
205
|
'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
|
|
136
206
|
'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
|
|
137
|
-
'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt'
|
|
207
|
+
'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt',
|
|
208
|
+
'completions', 'open', 'dry-run', 'fix', 'yes', 'no-ui', 'noUi',
|
|
209
|
+
'noQueryCache', 'no-query-cache',
|
|
210
|
+
'mcp',
|
|
211
|
+
'embedder', 'embedder-model', 'embedder-base-url', 'embedder-key-file',
|
|
212
|
+
'embedderModel', 'embedderBaseUrl', 'embedderKeyFile',
|
|
213
|
+
'expand', 'expandPassages', 'expand-passages', 'llm-endpoint', 'llmEndpoint'
|
|
138
214
|
]);
|
|
139
215
|
|
|
140
216
|
function findConfigFile(explicitPath) {
|
|
@@ -255,6 +331,7 @@ Usage:
|
|
|
255
331
|
mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
|
|
256
332
|
mdss mcp --db <dir> [--list-tools] Start MCP server over stdio for LLM agents / IDEs
|
|
257
333
|
mdss models List available models
|
|
334
|
+
mdss completions <bash|zsh|fish|powershell> Print a self-contained shell completion script
|
|
258
335
|
|
|
259
336
|
Options:
|
|
260
337
|
--config <file> Path to config file (default: .mdssrc.json / mdss.config.json).
|
|
@@ -280,11 +357,64 @@ Options:
|
|
|
280
357
|
--port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
|
|
281
358
|
--host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
|
|
282
359
|
only; use 0.0.0.0 to expose on the LAN, env MDSS_HOST).
|
|
360
|
+
--api-key <key> Require Bearer auth on every request (env MDSS_API_KEY).
|
|
361
|
+
--api-key-file <p> Read the API key from a file instead of a literal value —
|
|
362
|
+
avoids shell-history/CI-log leaks (env MDSS_API_KEY_FILE;
|
|
363
|
+
trailing newline trimmed, POSIX chmod-checked).
|
|
364
|
+
--allow-unsecured Explicit opt-in to serve non-loopback WITHOUT auth — a loud
|
|
365
|
+
banner is printed; without it (and without an api key) a
|
|
366
|
+
non-loopback bind refuses to start (issue #121).
|
|
367
|
+
--allowed-host <h> Extra Host header value to accept (repeatable) — requests
|
|
368
|
+
naming any other Host get 403 (DNS-rebinding guard, issue #120).
|
|
369
|
+
--cors-origin <o> Allow cross-origin browser access from this exact origin
|
|
370
|
+
(repeatable); CORS is off unless set, and only exact
|
|
371
|
+
matches are reflected with Vary: Origin (issue #120).
|
|
372
|
+
--rate-limit <n> serve: max /search requests per minute per client, burst
|
|
373
|
+
10 → 429 + Retry-After above it; 0 disables (default 60,
|
|
374
|
+
env MDSS_RATE_LIMIT; issue #119).
|
|
375
|
+
--max-concurrency <n> serve: max concurrent in-flight searches, queue cap
|
|
376
|
+
2× → 503 when full; 0 disables (default = CPU count,
|
|
377
|
+
env MDSS_MAX_CONCURRENCY; issue #119).
|
|
283
378
|
--watch serve: re-index incrementally on file changes (mtime poll).
|
|
379
|
+
--no-ui serve: disable the built-in web UI at the root path
|
|
380
|
+
(issue #111) - the JSON API help is served there instead.
|
|
381
|
+
--mcp serve: also mount Streamable HTTP MCP at /mcp
|
|
382
|
+
(issue #123) - same auth/host/rate-limit gates as
|
|
383
|
+
/search; stdio via the mcp command remains default.
|
|
384
|
+
--watch-debug serve --watch: verbose trace of polls, FS-error
|
|
385
|
+
classifications and retries (issue #116).
|
|
284
386
|
--watch-interval <ms> serve --watch: poll every N ms (default 3000).
|
|
285
387
|
--watch-delay <ms> serve --watch: quiet-period debounce before a burst of
|
|
286
388
|
saves triggers ONE re-index (default 1000; issue #42).
|
|
287
389
|
--offline Never download the model; require a cached one (env MDSS_OFFLINE=1).
|
|
390
|
+
--no-query-cache search: skip the persistent query-embedding cache
|
|
391
|
+
(<cacheDir>/query-cache.json, issue #114); in-memory
|
|
392
|
+
caching stays on.
|
|
393
|
+
--recency <days> search: time-decay half-life — fresher passages get
|
|
394
|
+
0.5^(age/halfLife) boost post-fusion (frontmatter
|
|
395
|
+
created/updated, then file mtime; issue #127).
|
|
396
|
+
--embedder <name> Use an EXTERNAL embedding provider instead of local
|
|
397
|
+
transformers.js: "ollama" or "openai" (issue #124).
|
|
398
|
+
Local stays the zero-config default and the only
|
|
399
|
+
fully-offline path.
|
|
400
|
+
--embedder-model <m> Provider-side model (e.g. nomic-embed-text).
|
|
401
|
+
--embedder-base-url <u> Endpoint (default: OLLAMA_HOST /
|
|
402
|
+
http://127.0.0.1:11434 for ollama,
|
|
403
|
+
https://api.openai.com/v1 for openai).
|
|
404
|
+
--embedder-key-file <p> Bearer key file for openai-compatible endpoints
|
|
405
|
+
(or OPENAI_API_KEY env).
|
|
406
|
+
--expand <mode> search: query expansion - "prf" (offline pseudo-
|
|
407
|
+
relevance feedback: top passages feed salient terms,
|
|
408
|
+
ONE embed total; issue #125) or "hyde" (LLM-generated
|
|
409
|
+
passage via --llm-endpoint; degrades silently without it).
|
|
410
|
+
--expand-passages <n> prf feedback pool size (default 3, max 20).
|
|
411
|
+
--open [N] search: open the top (or Nth) hit in your editor at its
|
|
412
|
+
startLine — MDSS_EDITOR/VISUAL/EDITOR, then VS Code
|
|
413
|
+
--goto, then the GUI opener (issue #110). With --json on
|
|
414
|
+
a non-TTY prints what WOULD be opened instead of launching.
|
|
415
|
+
--fix check: apply safe auto-repairs (stale lock, broken
|
|
416
|
+
vectors.bin sidecar, stale ivf.json); never touches notes.
|
|
417
|
+
--dry-run check --fix: print planned repairs without mutating.
|
|
288
418
|
--version Print the version and exit.
|
|
289
419
|
-h, --help Show this help.
|
|
290
420
|
|
|
@@ -306,6 +436,35 @@ function formatDuration(sec) {
|
|
|
306
436
|
return `${h}:${String(m).padStart(2, '0')}:${String(remainingS).padStart(2, '0')}`;
|
|
307
437
|
}
|
|
308
438
|
|
|
439
|
+
/**
|
|
440
|
+
* External embedder wiring (issue #124): when --embedder is set, build the
|
|
441
|
+
* synthetic adapter + embedFn and pass BOTH into the pipeline. Provider
|
|
442
|
+
* mismatch across rebuilds surfaces through the existing adapter-fingerprint
|
|
443
|
+
* invalidation, with the index `model` field naming the provider explicitly.
|
|
444
|
+
*/
|
|
445
|
+
async function resolveEmbedderOverrides(opts) {
|
|
446
|
+
if (!opts.embedder) return {};
|
|
447
|
+
if (!['ollama', 'openai'].includes(opts.embedder)) {
|
|
448
|
+
die(`unknown --embedder "${opts.embedder}" - use "ollama" or "openai"`);
|
|
449
|
+
}
|
|
450
|
+
if (!opts.embedderModel) {
|
|
451
|
+
die(`--embedder ${opts.embedder} requires --embedder-model <name>`);
|
|
452
|
+
}
|
|
453
|
+
const emb = resolveExternalEmbedder({
|
|
454
|
+
embedder: opts.embedder,
|
|
455
|
+
model: opts.embedderModel,
|
|
456
|
+
baseUrl: opts.embedderBaseUrl,
|
|
457
|
+
keyFile: opts.embedderKeyFile,
|
|
458
|
+
});
|
|
459
|
+
const descriptor = await emb.descriptor();
|
|
460
|
+
logErr(`embedder: ${emb.id}/${emb.model} (dim ${descriptor.dim} via ${opts.embedderBaseUrl || 'default endpoint'})`);
|
|
461
|
+
return { modelName: descriptor, embedFn: emb.embedFn };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function logErr(s) {
|
|
465
|
+
process.stderr.write(s + '\n');
|
|
466
|
+
}
|
|
467
|
+
|
|
309
468
|
async function cmdIndex(opts) {
|
|
310
469
|
const db = resolveDb(opts);
|
|
311
470
|
const indexDir = resolveIndexDir(opts, db);
|
|
@@ -333,11 +492,14 @@ async function cmdIndex(opts) {
|
|
|
333
492
|
}
|
|
334
493
|
};
|
|
335
494
|
|
|
495
|
+
const embedderOverrides = await resolveEmbedderOverrides(opts);
|
|
496
|
+
|
|
336
497
|
let r;
|
|
337
498
|
try {
|
|
338
499
|
r = await buildIndex({
|
|
339
500
|
db, indexDir, cacheDir,
|
|
340
|
-
modelName: opts.model || DEFAULT_MODEL,
|
|
501
|
+
modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
|
|
502
|
+
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
341
503
|
ignore: opts.ignore,
|
|
342
504
|
offline: resolveOffline(opts),
|
|
343
505
|
workers: opts.workers,
|
|
@@ -777,7 +939,7 @@ const modelId = index.model ?? null;
|
|
|
777
939
|
* embedding model or touching the network. Exit code 0 when healthy, 1 with a
|
|
778
940
|
* summary when not; `--json` for scripting.
|
|
779
941
|
*/
|
|
780
|
-
function cmdCheck(opts) {
|
|
942
|
+
async function cmdCheck(opts) {
|
|
781
943
|
const db = resolveDb(opts);
|
|
782
944
|
const indexDir = resolveIndexDir(opts, db);
|
|
783
945
|
const cacheDir = resolveCache(opts);
|
|
@@ -787,6 +949,46 @@ function cmdCheck(opts) {
|
|
|
787
949
|
config: opts._config,
|
|
788
950
|
});
|
|
789
951
|
|
|
952
|
+
// --fix (issue #117): plan safe repairs, optionally apply them. Never
|
|
953
|
+
// touches source Markdown; --dry-run prints the plan without mutating.
|
|
954
|
+
if (opts.fix) {
|
|
955
|
+
const plan = planRepairs(indexDir, { db: fs.existsSync(db) ? db : null });
|
|
956
|
+
const result = await applyRepairs(indexDir, plan, {
|
|
957
|
+
dryRun: !!opts.dryRun,
|
|
958
|
+
db: fs.existsSync(db) ? db : null,
|
|
959
|
+
cacheDir,
|
|
960
|
+
modelName: opts.model || undefined,
|
|
961
|
+
log: (s) => process.stderr.write(s + '\n'),
|
|
962
|
+
});
|
|
963
|
+
report.fix = {
|
|
964
|
+
dryRun: !!opts.dryRun,
|
|
965
|
+
planned: plan.actions,
|
|
966
|
+
performed: result.performed,
|
|
967
|
+
skipped: plan.skipped,
|
|
968
|
+
failed: result.failed,
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
if (opts.json) {
|
|
972
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
973
|
+
process.exitCode = result.failed.length > 0 ? 1 : 0;
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
let out = '';
|
|
978
|
+
for (const a of (opts.dryRun ? plan.actions : result.performed)) {
|
|
979
|
+
out += ` ${opts.dryRun ? 'would' : 'fixed'} ${a.action}: ${a.detail}\n`;
|
|
980
|
+
}
|
|
981
|
+
for (const s of plan.skipped) out += ` skip ${s.action}: ${s.detail}\n`;
|
|
982
|
+
for (const f of result.failed) out += ` FAIL ${f.action}: ${f.error}\n`;
|
|
983
|
+
if (plan.actions.length === 0 && plan.skipped.length === 0) out += ' ok nothing to repair\n';
|
|
984
|
+
process.stdout.write(out);
|
|
985
|
+
process.exitCode = result.failed.length > 0 ? 1 : 0;
|
|
986
|
+
if (!opts.dryRun && result.performed.length > 0) {
|
|
987
|
+
process.stdout.write('check: repairs applied — run `mdss check` to verify (issue #117)\n');
|
|
988
|
+
}
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
790
992
|
if (opts.json) {
|
|
791
993
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
792
994
|
process.exitCode = report.healthy ? 0 : 1;
|
|
@@ -933,17 +1135,23 @@ async function cmdSearch(opts) {
|
|
|
933
1135
|
const query = opts._.join(' ').trim();
|
|
934
1136
|
if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
|
|
935
1137
|
|
|
1138
|
+
const embedderOverrides = await resolveEmbedderOverrides(opts);
|
|
936
1139
|
let results;
|
|
937
1140
|
const isFederated = Array.isArray(opts.vaults) && opts.vaults.length > 0;
|
|
938
1141
|
|
|
939
1142
|
if (isFederated) {
|
|
940
1143
|
results = await searchFederated({
|
|
1144
|
+
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
941
1145
|
vaults: opts.vaults,
|
|
942
1146
|
cacheDir,
|
|
943
1147
|
query,
|
|
944
1148
|
k: opts.k || 6,
|
|
945
1149
|
semanticOnly: !!opts.semantic,
|
|
946
1150
|
offline: resolveOffline(opts),
|
|
1151
|
+
queryDiskCache: !opts.noQueryCache,
|
|
1152
|
+
recency: opts.recency,
|
|
1153
|
+
expand: opts.expand,
|
|
1154
|
+
expandPassages: opts.expandPassages,
|
|
947
1155
|
path: opts.path.length > 0 ? opts.path : undefined,
|
|
948
1156
|
since: opts.since,
|
|
949
1157
|
rerank: !!opts.rerank,
|
|
@@ -964,10 +1172,16 @@ async function cmdSearch(opts) {
|
|
|
964
1172
|
const indexDir = resolveIndexDir(opts, db);
|
|
965
1173
|
|
|
966
1174
|
results = await search({
|
|
1175
|
+
...(embedderOverrides.modelName ? { model: embedderOverrides.modelName } : {}),
|
|
1176
|
+
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
967
1177
|
indexDir, cacheDir, query,
|
|
968
1178
|
k: opts.k || 6,
|
|
969
1179
|
semanticOnly: !!opts.semantic,
|
|
970
1180
|
offline: resolveOffline(opts),
|
|
1181
|
+
queryDiskCache: !opts.noQueryCache,
|
|
1182
|
+
recency: opts.recency,
|
|
1183
|
+
expand: opts.expand,
|
|
1184
|
+
expandPassages: opts.expandPassages,
|
|
971
1185
|
path: opts.path.length > 0 ? opts.path : undefined,
|
|
972
1186
|
since: opts.since,
|
|
973
1187
|
rerank: !!opts.rerank,
|
|
@@ -988,6 +1202,12 @@ async function cmdSearch(opts) {
|
|
|
988
1202
|
if (opts.json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }
|
|
989
1203
|
if (results.length === 0) { process.stdout.write('No matches.\n'); return; }
|
|
990
1204
|
|
|
1205
|
+
// --open (issue #110): jump into the editor at the hit's exact line.
|
|
1206
|
+
if (opts.open || opts.openRank) {
|
|
1207
|
+
await openSearchHit(opts, results);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
991
1211
|
process.stdout.write(`\nTop ${results.length} for: "${query}"\n\n`);
|
|
992
1212
|
|
|
993
1213
|
// Group by file (issue #13): each file becomes a header with its hits below.
|
|
@@ -1016,9 +1236,50 @@ async function cmdSearch(opts) {
|
|
|
1016
1236
|
}
|
|
1017
1237
|
}
|
|
1018
1238
|
|
|
1239
|
+
/**
|
|
1240
|
+
* --open (issue #110): resolve the Nth hit to an absolute path and jump to it
|
|
1241
|
+
* in the user's editor at startLine. With --json on a non-TTY nothing is
|
|
1242
|
+
* launched — the intent is printed as a JSON line on stderr instead, keeping
|
|
1243
|
+
* stdout machine-readable.
|
|
1244
|
+
*/
|
|
1245
|
+
async function openSearchHit(opts, results) {
|
|
1246
|
+
const rank = opts.openRank || 1;
|
|
1247
|
+
if (!Number.isInteger(rank) || rank < 1) die(`--open N must be a positive hit number, got ${rank}`);
|
|
1248
|
+
const hit = results[rank - 1];
|
|
1249
|
+
if (!hit) {
|
|
1250
|
+
die(`--open ${rank}: only ${results.length} hit(s) available`);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Resolve relative hit paths against the right base (db or the matched vault).
|
|
1254
|
+
let base = null;
|
|
1255
|
+
if (hit.vault && Array.isArray(opts.vaults)) {
|
|
1256
|
+
base = opts.vaults.find((v) => v === hit.vault || path.basename(v) === hit.vault) || null;
|
|
1257
|
+
}
|
|
1258
|
+
if (!base) base = resolveDb(opts);
|
|
1259
|
+
const absFile = path.isAbsolute(hit.file) ? hit.file : path.join(base, hit.file);
|
|
1260
|
+
const target = { file: absFile, line: hit.startLine };
|
|
1261
|
+
const envEditor = process.env.MDSS_EDITOR || process.env.VISUAL || process.env.EDITOR || undefined;
|
|
1262
|
+
|
|
1263
|
+
// --json on a non-TTY: print what WOULD be launched, launch nothing (issue #110).
|
|
1264
|
+
if (opts.json && !process.stdout.isTTY) {
|
|
1265
|
+
const would = resolveOpenCommand(target, { editor: envEditor, platform: process.platform });
|
|
1266
|
+
if (!would) die(`could not resolve an editor for ${absFile} — set $MDSS_EDITOR/$VISUAL/$EDITOR`);
|
|
1267
|
+
process.stderr.write(JSON.stringify({
|
|
1268
|
+
type: 'open-intent',
|
|
1269
|
+
file: absFile,
|
|
1270
|
+
line: hit.startLine ?? null,
|
|
1271
|
+
via: would.via,
|
|
1272
|
+
}) + '\n');
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const resolved = openHit(target, { editor: envEditor });
|
|
1277
|
+
if (!resolved) die(`could not resolve an editor for ${absFile} — set $MDSS_EDITOR/$VISUAL/$EDITOR`);
|
|
1278
|
+
process.stderr.write(`→ opened ${absFile}${hit.startLine ? `:${hit.startLine}` : ''} via ${resolved.via}\n`);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1019
1281
|
/** Escape a string for use inside a RegExp. */
|
|
1020
|
-
function escRe(s) {
|
|
1021
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1282
|
+
function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1022
1283
|
}
|
|
1023
1284
|
|
|
1024
1285
|
function cmdModels() {
|
|
@@ -1052,16 +1313,47 @@ async function cmdServe(opts) {
|
|
|
1052
1313
|
const host = opts.host || process.env.MDSS_HOST || DEFAULT_HOST;
|
|
1053
1314
|
const log = s => process.stderr.write(s + '\n');
|
|
1054
1315
|
|
|
1316
|
+
const embedderOverrides = await resolveEmbedderOverrides(opts);
|
|
1317
|
+
|
|
1318
|
+
// API key precedence (issue #121): literal flag/env first, then key file —
|
|
1319
|
+
// the file form avoids shell-history and CI-log leaks of the literal value.
|
|
1320
|
+
let apiKey = opts.apiKey || process.env.MDSS_API_KEY || undefined;
|
|
1321
|
+
const keyFile = opts.apiKeyFile || process.env.MDSS_API_KEY_FILE;
|
|
1322
|
+
if (!apiKey && keyFile) {
|
|
1323
|
+
apiKey = loadApiKeyFile(keyFile, log);
|
|
1324
|
+
log(`api-key-file: key loaded from ${keyFile}`);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Refuse to serve the whole knowledge base to the network unauthenticated (issue #121).
|
|
1328
|
+
validateBindSecurity({ host, hasApiKey: !!apiKey, allowUnsecured: !!opts.allowUnsecured });
|
|
1329
|
+
|
|
1055
1330
|
const { server, state, close } = await createServe({
|
|
1056
1331
|
db, indexDir, cacheDir,
|
|
1057
|
-
modelName: opts.model || DEFAULT_MODEL,
|
|
1332
|
+
modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
|
|
1058
1333
|
ignore: opts.ignore,
|
|
1059
1334
|
offline: resolveOffline(opts),
|
|
1335
|
+
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
1060
1336
|
watch: !!opts.watch,
|
|
1061
1337
|
watchInterval: opts.watchInterval,
|
|
1062
1338
|
watchDelay: opts.watchDelay,
|
|
1063
|
-
|
|
1339
|
+
watchDebug: !!opts.watchDebug,
|
|
1340
|
+
apiKey,
|
|
1064
1341
|
healthPublic: !!opts.healthPublic || process.env.MDSS_HEALTH_PUBLIC === 'true',
|
|
1342
|
+
// Web UI on by default at `/` (issue #111); --no-ui restores pure JSON.
|
|
1343
|
+
ui: !opts.noUi,
|
|
1344
|
+
mcp: !!opts.mcp,
|
|
1345
|
+
// DNS-rebinding allowlist (issue #120): loopback names are always allowed;
|
|
1346
|
+
// the configured bind host is added so `--host mybox.local` still answers.
|
|
1347
|
+
allowedHosts: [
|
|
1348
|
+
...(opts.allowedHost || []),
|
|
1349
|
+
...(opts.host ? [opts.host] : []),
|
|
1350
|
+
...(process.env.MDSS_HOST ? [process.env.MDSS_HOST] : []),
|
|
1351
|
+
],
|
|
1352
|
+
// CORS stays OFF unless explicitly opted in (issue #120).
|
|
1353
|
+
corsOrigins: opts.corsOrigin,
|
|
1354
|
+
// Fairness gates (issue #119); 0 explicitly disables a gate.
|
|
1355
|
+
rateLimit: opts.rateLimit ?? envNonNegInt('MDSS_RATE_LIMIT'),
|
|
1356
|
+
maxConcurrency: opts.maxConcurrency ?? envNonNegInt('MDSS_MAX_CONCURRENCY'),
|
|
1065
1357
|
log,
|
|
1066
1358
|
});
|
|
1067
1359
|
|
|
@@ -1073,6 +1365,14 @@ async function cmdServe(opts) {
|
|
|
1073
1365
|
(state.watching ? ' · watching for changes' : ''));
|
|
1074
1366
|
log(` POST /search {"query": "...", "k": 6}`);
|
|
1075
1367
|
log(` GET /health`);
|
|
1368
|
+
if (!isLoopbackHost(host) && !state.apiKey) {
|
|
1369
|
+
log('');
|
|
1370
|
+
log(' ⚠⚠⚠ UNSECURED NETWORK BIND — EXPLICIT OPT-IN (--allow-unsecured) ⚠⚠⚠');
|
|
1371
|
+
log(' ⚠⚠⚠ The ENTIRE knowledge base is readable by anyone who can ⚠⚠⚠');
|
|
1372
|
+
log(' ⚠⚠⚠ reach this port. Put a reverse proxy with TLS + auth in ⚠⚠⚠');
|
|
1373
|
+
log(' ⚠⚠⚠ front, or set an API key (--api-key / --api-key-file). ⚠⚠⚠');
|
|
1374
|
+
log('');
|
|
1375
|
+
}
|
|
1076
1376
|
log(` Ctrl+C to stop.`);
|
|
1077
1377
|
});
|
|
1078
1378
|
|
|
@@ -1098,6 +1398,8 @@ async function cmdMcp(opts) {
|
|
|
1098
1398
|
const { startMcpServer } = await import('../dist/mcp.js');
|
|
1099
1399
|
await startMcpServer({
|
|
1100
1400
|
db, indexDir, cacheDir,
|
|
1401
|
+
// NOTE: external embedders (--embedder) are not yet wired into the MCP
|
|
1402
|
+
// stdio server; it keeps the local transformers.js path (issue #124).
|
|
1101
1403
|
modelName: opts.model || DEFAULT_MODEL,
|
|
1102
1404
|
ignore: opts.ignore,
|
|
1103
1405
|
offline: resolveOffline(opts),
|
|
@@ -1105,6 +1407,68 @@ async function cmdMcp(opts) {
|
|
|
1105
1407
|
});
|
|
1106
1408
|
}
|
|
1107
1409
|
|
|
1410
|
+
// ---------------------------------------------------------------------------
|
|
1411
|
+
// First-run model download progress (issue #108): the embedding model (~280 MB)
|
|
1412
|
+
// and the reranker (~280 MB) stream in silently otherwise, which reads as a
|
|
1413
|
+
// hang on slow links. TTY → one aggregated bar; --json → throttled JSON events
|
|
1414
|
+
// on STDERR so stdout stays machine-readable; warm cache emits nothing at all.
|
|
1415
|
+
// ---------------------------------------------------------------------------
|
|
1416
|
+
|
|
1417
|
+
const DOWNLOAD_COMMANDS = new Set(['index', 'search', 'ask', 'serve']);
|
|
1418
|
+
|
|
1419
|
+
function formatMb(bytes) {
|
|
1420
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
|
|
1421
|
+
return bytes >= 1024 * 1024 * 1024
|
|
1422
|
+
? `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
1423
|
+
: `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function renderDownloadBar(snap) {
|
|
1427
|
+
const barWidth = 10;
|
|
1428
|
+
const filled = snap.percent === null ? 0 : Math.min(barWidth, Math.max(0, Math.round((snap.percent / 100) * barWidth)));
|
|
1429
|
+
const bar = '[' + '#'.repeat(filled) + '-'.repeat(barWidth - filled) + ']';
|
|
1430
|
+
const pct = snap.percent === null ? '…' : `${Math.floor(snap.percent)}%`;
|
|
1431
|
+
const size = `${formatMb(snap.loadedBytes)}${snap.totalBytes > 0 ? `/${formatMb(snap.totalBytes)}` : ''}`;
|
|
1432
|
+
const speed = snap.mbps !== null ? `${snap.mbps.toFixed(1)} MB/s` : '—';
|
|
1433
|
+
const eta = snap.etaSec !== null && Number.isFinite(snap.etaSec) ? formatDuration(snap.etaSec) : '—';
|
|
1434
|
+
process.stderr.write(`\r${bar} ${pct} │ ${size} │ ${speed} │ ETA ${eta}`);
|
|
1435
|
+
if (snap.complete) process.stderr.write('\n');
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** Install the progress listener; returns a detach fn for the caller's finally. */
|
|
1439
|
+
function installDownloadProgress(opts) {
|
|
1440
|
+
const agg = new DownloadProgressAggregator();
|
|
1441
|
+
setDownloadProgressListener((e) => {
|
|
1442
|
+
agg.update(e);
|
|
1443
|
+
// JSON consumers poll slower than eyes; throttle accordingly.
|
|
1444
|
+
if (!agg.shouldRender(Date.now(), opts.json ? 1000 : 400)) return;
|
|
1445
|
+
const snap = agg.snapshot();
|
|
1446
|
+
if (!opts.json && process.stderr.isTTY) {
|
|
1447
|
+
renderDownloadBar(snap);
|
|
1448
|
+
} else if (opts.json) {
|
|
1449
|
+
process.stderr.write(JSON.stringify({
|
|
1450
|
+
type: snap.complete ? 'download-complete' : 'download-progress',
|
|
1451
|
+
percent: snap.percent === null ? null : Math.round(snap.percent * 10) / 10,
|
|
1452
|
+
loadedBytes: snap.loadedBytes,
|
|
1453
|
+
totalBytes: snap.totalBytes > 0 ? snap.totalBytes : null,
|
|
1454
|
+
mbps: snap.mbps === null ? null : Math.round(snap.mbps * 100) / 100,
|
|
1455
|
+
etaSec: snap.etaSec === null || !Number.isFinite(snap.etaSec) ? null : Math.round(snap.etaSec),
|
|
1456
|
+
doneFiles: snap.doneFiles,
|
|
1457
|
+
activeFiles: snap.activeFiles,
|
|
1458
|
+
}) + '\n');
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
return () => setDownloadProgressListener(null);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async function cmdCompletions(opts) {
|
|
1465
|
+
const shell = (opts._.shift() || '').toLowerCase();
|
|
1466
|
+
if (!COMPLETION_SHELLS.includes(shell)) {
|
|
1467
|
+
die(`unknown shell "${shell || '(none)'}" for completions — use one of: ${COMPLETION_SHELLS.join('|')}`);
|
|
1468
|
+
}
|
|
1469
|
+
process.stdout.write(generateCompletion(shell));
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1108
1472
|
async function main() {
|
|
1109
1473
|
const argv = process.argv.slice(2);
|
|
1110
1474
|
const opts = parseArgs(argv);
|
|
@@ -1120,19 +1484,28 @@ async function main() {
|
|
|
1120
1484
|
|
|
1121
1485
|
if (opts.version) { process.stdout.write(`${VERSION}\n`); return; }
|
|
1122
1486
|
if (opts.help || !cmd) { process.stdout.write(HELP); return; }
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1487
|
+
// Model downloads stream through this listener only for commands that may
|
|
1488
|
+
// load a model in-process (issue #108). `mcp` is excluded — its stderr is an
|
|
1489
|
+
// IDE log; `tui` draws its own full-screen UI.
|
|
1490
|
+
const detachProgress = DOWNLOAD_COMMANDS.has(cmd) ? installDownloadProgress(opts) : null;
|
|
1491
|
+
try {
|
|
1492
|
+
switch (cmd) {
|
|
1493
|
+
case 'index': return await cmdIndex(opts);
|
|
1494
|
+
case 'stats': return await cmdStats(opts);
|
|
1495
|
+
case 'check':
|
|
1496
|
+
case 'doctor': return await cmdCheck(opts);
|
|
1497
|
+
case 'export': return await cmdExport(opts);
|
|
1498
|
+
case 'search': return await cmdSearch(opts);
|
|
1499
|
+
case 'ask': return await cmdAsk(opts);
|
|
1500
|
+
case 'tui': return await cmdTui(opts);
|
|
1501
|
+
case 'serve': return await cmdServe(opts);
|
|
1502
|
+
case 'mcp': return await cmdMcp(opts);
|
|
1503
|
+
case 'models': return await cmdModels();
|
|
1504
|
+
case 'completions': return await cmdCompletions(opts);
|
|
1505
|
+
default: die(`unknown command: ${cmd}. Try \`mdss --help\`.`);
|
|
1506
|
+
}
|
|
1507
|
+
} finally {
|
|
1508
|
+
detachProgress?.();
|
|
1136
1509
|
}
|
|
1137
1510
|
}
|
|
1138
1511
|
|
|
@@ -1172,3 +1545,11 @@ export {
|
|
|
1172
1545
|
checkHealth, cmdExport,
|
|
1173
1546
|
die, main, HELP, VERSION,
|
|
1174
1547
|
};
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell completions generator (issue #107).
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth: the FLAGS table below drives every shell script AND
|
|
5
|
+
* the drift-guard test (test/completions.test.mjs) that diffs it against the
|
|
6
|
+
* flags actually parsed in bin/cli.mjs. Adding a flag in parseArgs without
|
|
7
|
+
* updating this table fails the test — and vice versa.
|
|
8
|
+
*
|
|
9
|
+
* Static generation only: no runtime dependency, `mdss completions <shell>`
|
|
10
|
+
* prints a self-contained script to stdout.
|
|
11
|
+
*/
|
|
12
|
+
export type FlagKind = 'flag' | 'value' | 'file' | 'dir' | 'number';
|
|
13
|
+
export interface FlagSpec {
|
|
14
|
+
/** Primary long form, e.g. "--index-dir". */
|
|
15
|
+
name: string;
|
|
16
|
+
alias?: string;
|
|
17
|
+
kind: FlagKind;
|
|
18
|
+
}
|
|
19
|
+
/** Every option bin/cli.mjs parseArgs understands (issue #107 drift guard). */
|
|
20
|
+
export declare const FLAGS: FlagSpec[];
|
|
21
|
+
export declare const COMMANDS: string[];
|
|
22
|
+
export declare const COMPLETION_SHELLS: readonly ['bash', 'zsh', 'fish', 'powershell'];
|
|
23
|
+
export type CompletionShell = (typeof COMPLETION_SHELLS)[number];
|
|
24
|
+
/** Generate a self-contained completion script; throws on an unknown shell. */
|
|
25
|
+
export declare function generateCompletion(shell: string): string;
|