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.
- package/README.md +198 -8
- package/RESEARCH.md +25 -3
- package/bin/cli.mjs +657 -29
- package/dist/completions.d.ts +25 -0
- package/dist/completions.js +238 -0
- package/dist/core.d.ts +31 -0
- package/dist/core.js +121 -1
- package/dist/download-progress.d.ts +52 -0
- package/dist/download-progress.js +112 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +5 -4
- package/dist/indexer.d.ts +1 -0
- package/dist/indexer.js +5 -5
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/mcp.d.ts +143 -4
- package/dist/mcp.js +499 -23
- package/dist/models.d.ts +1 -1
- package/dist/models.js +52 -31
- 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/rag.d.ts +72 -7
- package/dist/rag.js +191 -13
- package/dist/repair.d.ts +36 -0
- package/dist/repair.js +208 -0
- package/dist/rerank.d.ts +20 -4
- package/dist/rerank.js +14 -4
- package/dist/search.d.ts +35 -0
- package/dist/search.js +175 -5
- package/dist/serve.d.ts +126 -0
- package/dist/serve.js +676 -46
- package/dist/tui.d.ts +8 -0
- package/dist/tui.js +80 -13
- package/dist/watcher.d.ts +16 -0
- package/dist/watcher.js +41 -0
- package/dist/webui.d.ts +13 -0
- package/dist/webui.js +252 -0
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +1 -1
- package/src/completions.ts +255 -0
- package/src/core.ts +160 -1
- package/src/download-progress.ts +146 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +11 -2
- package/src/indexer.ts +8 -4
- package/src/ivf.ts +157 -20
- package/src/mcp.ts +514 -23
- package/src/models.ts +58 -33
- package/src/open.ts +119 -0
- package/src/providers.ts +134 -0
- package/src/query-cache-disk.ts +191 -0
- package/src/rag.ts +279 -17
- package/src/repair.ts +245 -0
- package/src/rerank.ts +43 -8
- package/src/search.ts +199 -6
- package/src/serve.ts +726 -43
- package/src/tui.ts +86 -14
- package/src/watcher.ts +46 -0
- package/src/webui.ts +255 -0
- 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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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
|
+
|