md-semantic-search 1.0.3 → 2.0.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 +132 -16
- package/RESEARCH.md +207 -3
- package/bin/cli.mjs +449 -13
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +11 -1
- package/dist/core.d.ts +42 -4
- package/dist/core.js +208 -14
- package/dist/federation.d.ts +16 -1
- package/dist/federation.js +33 -16
- package/dist/filter.d.ts +7 -0
- package/dist/filter.js +13 -0
- package/dist/hnsw.d.ts +96 -0
- package/dist/hnsw.js +326 -0
- package/dist/index-format.js +8 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/indexer.d.ts +18 -1
- package/dist/indexer.js +92 -18
- package/dist/ivf.d.ts +14 -7
- package/dist/ivf.js +126 -18
- package/dist/lexical.d.ts +10 -0
- package/dist/lexical.js +98 -15
- package/dist/markdown-parser.d.ts +62 -0
- package/dist/markdown-parser.js +227 -30
- package/dist/mcp.d.ts +229 -7
- package/dist/mcp.js +743 -44
- package/dist/models.d.ts +8 -2
- package/dist/models.js +84 -32
- package/dist/providers.d.ts +56 -0
- package/dist/providers.js +305 -0
- package/dist/quantization.d.ts +12 -0
- package/dist/quantization.js +54 -0
- package/dist/rag.d.ts +132 -7
- package/dist/rag.js +314 -38
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +51 -6
- package/dist/rerank.js +69 -10
- package/dist/search.d.ts +49 -3
- package/dist/search.js +387 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +574 -58
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +132 -15
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +225 -5
- package/dist/wikilinks.d.ts +31 -0
- package/dist/wikilinks.js +185 -1
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +11 -1
- package/src/core.ts +249 -13
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/index-format.ts +7 -1
- package/src/index.ts +7 -2
- package/src/indexer.ts +112 -19
- package/src/ivf.ts +157 -20
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +820 -47
- package/src/models.ts +97 -35
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +451 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +121 -14
- package/src/search.ts +424 -82
- package/src/serve.ts +589 -66
- package/src/tui.ts +142 -16
- package/src/watcher.ts +21 -10
- package/src/webui.ts +225 -5
- package/src/wikilinks.ts +220 -1
package/bin/cli.mjs
CHANGED
|
@@ -16,11 +16,12 @@ 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
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
23
|
import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath, setDownloadProgressListener } from '../dist/core.js';
|
|
24
|
+
import { findRelatedNotes } from '../dist/wikilinks.js';
|
|
24
25
|
import { DownloadProgressAggregator } from '../dist/download-progress.js';
|
|
25
26
|
import { generateCompletion, COMPLETION_SHELLS } from '../dist/completions.js';
|
|
26
27
|
import { openHit, resolveOpenCommand } from '../dist/open.js';
|
|
@@ -93,6 +94,7 @@ function parseArgs(argv) {
|
|
|
93
94
|
else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
|
|
94
95
|
else if (a === '--rate-limit') opts.rateLimit = nextNonNegInt(argv, ++i, a);
|
|
95
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);
|
|
96
98
|
else if (a === '--since') opts.since = nextValue(argv, ++i, a);
|
|
97
99
|
else if (a === '--path') opts.path.push(nextValue(argv, ++i, a));
|
|
98
100
|
else if (a === '--ignore') opts.ignore.push(nextValue(argv, ++i, a));
|
|
@@ -100,7 +102,16 @@ function parseArgs(argv) {
|
|
|
100
102
|
const v = nextValue(argv, ++i, a);
|
|
101
103
|
opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
|
|
102
104
|
}
|
|
103
|
-
else if (a === '--ann')
|
|
105
|
+
else if (a === '--ann') {
|
|
106
|
+
// issue #201: optional engine choice — bare --ann keeps the default
|
|
107
|
+
const next = argv[i + 1];
|
|
108
|
+
if (next === 'ivf' || next === 'hnsw') {
|
|
109
|
+
opts.ann = next;
|
|
110
|
+
i++;
|
|
111
|
+
} else {
|
|
112
|
+
opts.ann = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
104
115
|
else if (a === '--nprobe') opts.nprobe = nextInt(argv, ++i, a);
|
|
105
116
|
else if (a === '--project') opts.project = nextValue(argv, ++i, a);
|
|
106
117
|
else if (a === '--type') opts.type = nextValue(argv, ++i, a);
|
|
@@ -108,13 +119,31 @@ function parseArgs(argv) {
|
|
|
108
119
|
else if (a === '--canonical') opts.canonical = true;
|
|
109
120
|
else if (a === '--graph-boost') opts.graphBoost = nextFloat(argv, ++i, a);
|
|
110
121
|
else if (a === '--filter') opts.filter = nextValue(argv, ++i, a);
|
|
122
|
+
else if (a === '--direction') opts.direction = nextValue(argv, ++i, a);
|
|
123
|
+
else if (a === '--no-semantic') opts.noSemantic = true;
|
|
111
124
|
else if (a === '--quantize') opts.quantize = nextValue(argv, ++i, a);
|
|
125
|
+
else if (a === '--max-chunk-tokens') {
|
|
126
|
+
// issue #202: 0 explicitly restores the legacy char budget
|
|
127
|
+
opts.maxChunkTokens = nextNonNegInt(argv, ++i, a);
|
|
128
|
+
}
|
|
129
|
+
else if (a === '--multi-query') {
|
|
130
|
+
// issue #203: N total query variants (>=2 activates; LLM required)
|
|
131
|
+
opts.multiQuery = nextInt(argv, ++i, a);
|
|
132
|
+
if (opts.multiQuery > 5) die('--multi-query must be <= 5');
|
|
133
|
+
}
|
|
134
|
+
else if (a === '--rerank-model') opts.rerankModel = nextValue(argv, ++i, a);
|
|
112
135
|
else if (a === '--rag') opts.rag = true;
|
|
113
136
|
else if (a === '--auto-tag') opts.autoTag = true;
|
|
114
137
|
else if (a === '--auto-summarize') opts.autoSummarize = true;
|
|
115
138
|
else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
|
|
116
139
|
else if (a === '--llm-model') opts.llmModel = nextValue(argv, ++i, a);
|
|
140
|
+
else if (a === '--llm-provider') {
|
|
141
|
+
opts.llmProvider = nextValue(argv, ++i, a);
|
|
142
|
+
if (!['openai', 'ollama'].includes(opts.llmProvider)) die(`unknown --llm-provider "${opts.llmProvider}" - use openai|ollama`);
|
|
143
|
+
}
|
|
117
144
|
else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
|
|
145
|
+
else if (a === '--session') opts.session = nextValue(argv, ++i, a);
|
|
146
|
+
else if (a === '--resume') opts.resume = true;
|
|
118
147
|
else if (a === '--open') {
|
|
119
148
|
// `--open` opens the top hit; `--open N` opens the Nth hit (issue #110).
|
|
120
149
|
const next = argv[i + 1];
|
|
@@ -202,9 +231,13 @@ const KNOWN_CONFIG_KEYS = new Set([
|
|
|
202
231
|
'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
|
|
203
232
|
'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
|
|
204
233
|
'watchDebug', 'watch-debug',
|
|
205
|
-
'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
|
|
234
|
+
'format', 'noVectors', 'no-vectors', 'output', 'workers', 'dimensions', 'dim', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
|
|
235
|
+
'maxChunkTokens', 'max-chunk-tokens',
|
|
236
|
+
'multiQuery', 'multi-query', 'rerankModel', 'rerank-model',
|
|
206
237
|
'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
|
|
207
238
|
'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt',
|
|
239
|
+
'llmProvider', 'llm-provider',
|
|
240
|
+
'session', 'resume',
|
|
208
241
|
'completions', 'open', 'dry-run', 'fix', 'yes', 'no-ui', 'noUi',
|
|
209
242
|
'noQueryCache', 'no-query-cache',
|
|
210
243
|
'mcp',
|
|
@@ -213,6 +246,27 @@ const KNOWN_CONFIG_KEYS = new Set([
|
|
|
213
246
|
'expand', 'expandPassages', 'expand-passages', 'llm-endpoint', 'llmEndpoint'
|
|
214
247
|
]);
|
|
215
248
|
|
|
249
|
+
const CONFIG_NETWORK_KEYS = new Set([
|
|
250
|
+
'port', 'host', 'apiKey', 'api-key', 'apiKeyFile', 'api-key-file',
|
|
251
|
+
'allowUnsecured', 'allow-unsecured', 'allowedHost', 'allowed-host',
|
|
252
|
+
'allowedHosts', 'allowed-hosts', 'corsOrigin', 'cors-origin',
|
|
253
|
+
'rateLimit', 'rate-limit', 'maxConcurrency', 'max-concurrency',
|
|
254
|
+
'healthPublic', 'health-public', 'mcp', 'llmEndpoint', 'llm-endpoint',
|
|
255
|
+
'llmModel', 'llm-model', 'llmProvider', 'llm-provider', 'embedder', 'embedder-model', 'embedder-base-url',
|
|
256
|
+
'embedder-key-file', 'embedderModel', 'embedderBaseUrl', 'embedderKeyFile'
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
const CONFIG_EXEC_KEYS = new Set([
|
|
260
|
+
// No execution surfaces permitted in local repo config
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
function getConfigKeyTrustTier(key) {
|
|
264
|
+
if (CONFIG_EXEC_KEYS.has(key)) return 'exec';
|
|
265
|
+
if (CONFIG_NETWORK_KEYS.has(key)) return 'network';
|
|
266
|
+
if (KNOWN_CONFIG_KEYS.has(key)) return 'local-safe';
|
|
267
|
+
return 'unknown';
|
|
268
|
+
}
|
|
269
|
+
|
|
216
270
|
function findConfigFile(explicitPath) {
|
|
217
271
|
if (explicitPath) {
|
|
218
272
|
const abs = path.resolve(explicitPath);
|
|
@@ -252,22 +306,26 @@ function findConfigFile(explicitPath) {
|
|
|
252
306
|
}
|
|
253
307
|
|
|
254
308
|
function loadConfigFile(configPath) {
|
|
255
|
-
if (!configPath) return { config: {}, path: null, unknownKeys: [], error: null };
|
|
309
|
+
if (!configPath) return { config: {}, path: null, unknownKeys: [], trustTiers: {}, error: null };
|
|
256
310
|
try {
|
|
257
311
|
const raw = fs.readFileSync(configPath, 'utf8');
|
|
258
312
|
const parsed = JSON.parse(raw);
|
|
259
313
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
260
|
-
return { config: {}, path: configPath, unknownKeys: [], error: `Config at ${configPath} must be a JSON object` };
|
|
314
|
+
return { config: {}, path: configPath, unknownKeys: [], trustTiers: {}, error: `Config at ${configPath} must be a JSON object` };
|
|
261
315
|
}
|
|
262
316
|
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_CONFIG_KEYS.has(k));
|
|
317
|
+
const trustTiers = {};
|
|
318
|
+
for (const k of Object.keys(parsed)) {
|
|
319
|
+
trustTiers[k] = getConfigKeyTrustTier(k);
|
|
320
|
+
}
|
|
263
321
|
const normalized = {};
|
|
264
322
|
for (const [k, v] of Object.entries(parsed)) {
|
|
265
323
|
const camel = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
266
324
|
normalized[camel] = v;
|
|
267
325
|
}
|
|
268
|
-
return { config: normalized, path: configPath, unknownKeys, error: null };
|
|
326
|
+
return { config: normalized, path: configPath, unknownKeys, trustTiers, error: null };
|
|
269
327
|
} catch (e) {
|
|
270
|
-
return { config: {}, path: configPath, unknownKeys: [], error: `Failed to parse config at ${configPath}: ${e.message}` };
|
|
328
|
+
return { config: {}, path: configPath, unknownKeys: [], trustTiers: {}, error: `Failed to parse config at ${configPath}: ${e.message}` };
|
|
271
329
|
}
|
|
272
330
|
}
|
|
273
331
|
|
|
@@ -328,6 +386,9 @@ Usage:
|
|
|
328
386
|
mdss check --db <dir> [--json] Diagnose index/db/model cache (alias: doctor)
|
|
329
387
|
mdss export --db <dir> [options] Export index to JSONL / CSV / Parquet
|
|
330
388
|
mdss search --db <dir> [options] "query" Search by meaning
|
|
389
|
+
mdss related --db <dir> [options] <note> Find related notes via graph links and similarity
|
|
390
|
+
mdss ask --db <dir> [options] "question" Grounded QA synthesis with citations
|
|
391
|
+
mdss chat --db <dir> [options] Multi-turn persistent chat with citation manifest
|
|
331
392
|
mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
|
|
332
393
|
mdss mcp --db <dir> [--list-tools] Start MCP server over stdio for LLM agents / IDEs
|
|
333
394
|
mdss models List available models
|
|
@@ -339,7 +400,13 @@ Options:
|
|
|
339
400
|
--index-dir <dir> Where to store the index (default: <db>/.mdss).
|
|
340
401
|
--cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
|
|
341
402
|
--model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
|
|
403
|
+
--dimensions <n> Truncate vector dimensions for Matryoshka/MRL models (alias: --dim).
|
|
342
404
|
--workers <n> Number of parallel batch workers for indexing (default: 1).
|
|
405
|
+
--max-chunk-tokens <n> index/serve: chunk budget in TOKENS (issue #202) —
|
|
406
|
+
default min(model maxTokens, 512), so Cyrillic notes are
|
|
407
|
+
chunked by the same real budget as English ones instead
|
|
408
|
+
of an English-calibrated char count; 0 restores the
|
|
409
|
+
legacy char budget (1400).
|
|
343
410
|
--format <fmt> Export format: jsonl (default), csv, parquet (export).
|
|
344
411
|
--no-vectors Omit vector embeddings from export (export).
|
|
345
412
|
--output <file> Output file path (export, default: stdout).
|
|
@@ -350,9 +417,21 @@ Options:
|
|
|
350
417
|
--json Machine-readable output (index, stats, search).
|
|
351
418
|
--semantic Pure vector ranking, skip lexical/RRF fusion (search).
|
|
352
419
|
--rerank Re-rank candidates with a cross-encoder (search; ~280MB model).
|
|
420
|
+
--rerank-model <m> Reranker registry alias (issue #203): "bge-reranker-base"
|
|
421
|
+
(default) or "gte-reranker-modernbert" (144MB, 8k ctx,
|
|
422
|
+
Apache-2.0); also accepts id[@revision].
|
|
423
|
+
--multi-query <n> Multi-query expansion (issue #203): N total query
|
|
424
|
+
variants (original + N-1 LLM paraphrases, one extra
|
|
425
|
+
embedding each) fused by union-RRF — recall lift on
|
|
426
|
+
heavy paraphrases. Needs --llm-endpoint; silently falls
|
|
427
|
+
back to the single query without one. 0/1 = off.
|
|
353
428
|
--graph-boost <n> Boost weight for graph PageRank / Obsidian wikilinks (search, default: 0).
|
|
354
429
|
--filter <expr> Rich boolean filter across frontmatter tags and properties (search).
|
|
355
|
-
--ann
|
|
430
|
+
--ann [ivf|hnsw] Approximate nearest neighbor search (search/index).
|
|
431
|
+
Engines (issue #201): "ivf" (k-means clusters, the
|
|
432
|
+
historical default) or "hnsw" (graph index — no
|
|
433
|
+
training phase, incremental inserts, hnsw.json
|
|
434
|
+
sidecar). Bare --ann prefers an existing sidecar.
|
|
356
435
|
--nprobe <n> Number of centroids to probe for ANN (search, default: 8).
|
|
357
436
|
--port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
|
|
358
437
|
--host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
|
|
@@ -408,6 +487,13 @@ Options:
|
|
|
408
487
|
ONE embed total; issue #125) or "hyde" (LLM-generated
|
|
409
488
|
passage via --llm-endpoint; degrades silently without it).
|
|
410
489
|
--expand-passages <n> prf feedback pool size (default 3, max 20).
|
|
490
|
+
--llm-endpoint <u> OpenAI-compatible chat endpoint for ask/chat/serve /ask
|
|
491
|
+
and --expand hyde: POST {base}/chat/completions
|
|
492
|
+
(llama.cpp, LM Studio, vLLM, Ollama at .../v1).
|
|
493
|
+
--llm-provider <p> Chat wire format for --llm-endpoint: "openai" (default,
|
|
494
|
+
SSE streaming) or "ollama" (native /api/generate,
|
|
495
|
+
NDJSON streaming) (issue #199).
|
|
496
|
+
--llm-model <m> Provider-side chat model for --llm-endpoint.
|
|
411
497
|
--open [N] search: open the top (or Nth) hit in your editor at its
|
|
412
498
|
startLine — MDSS_EDITOR/VISUAL/EDITOR, then VS Code
|
|
413
499
|
--goto, then the GUI opener (issue #110). With --json on
|
|
@@ -426,6 +512,9 @@ Examples:
|
|
|
426
512
|
mdss search --db ./docs "incident runbook" --path "docs/**" --since 2026-01-01
|
|
427
513
|
mdss serve --db ./docs --port 8747 --watch
|
|
428
514
|
curl -X POST localhost:8747/search -d '{"query":"rotate api token","k":5}'
|
|
515
|
+
mdss ask --db ./docs "how do tokens expire?" \\
|
|
516
|
+
--llm-endpoint http://127.0.0.1:8080/v1 --llm-model qwen2.5
|
|
517
|
+
mdss chat --db ./docs --llm-endpoint http://127.0.0.1:11434/v1 --resume
|
|
429
518
|
`;
|
|
430
519
|
|
|
431
520
|
function formatDuration(sec) {
|
|
@@ -456,6 +545,9 @@ async function resolveEmbedderOverrides(opts) {
|
|
|
456
545
|
baseUrl: opts.embedderBaseUrl,
|
|
457
546
|
keyFile: opts.embedderKeyFile,
|
|
458
547
|
});
|
|
548
|
+
const egressUrl = opts.embedderBaseUrl ||
|
|
549
|
+
(opts.embedder === 'ollama' ? process.env.OLLAMA_HOST || 'http://127.0.0.1:11434' : 'https://api.openai.com/v1');
|
|
550
|
+
warnDataEgress(`--embedder ${opts.embedder}`, egressUrl);
|
|
459
551
|
const descriptor = await emb.descriptor();
|
|
460
552
|
logErr(`embedder: ${emb.id}/${emb.model} (dim ${descriptor.dim} via ${opts.embedderBaseUrl || 'default endpoint'})`);
|
|
461
553
|
return { modelName: descriptor, embedFn: emb.embedFn };
|
|
@@ -465,6 +557,36 @@ function logErr(s) {
|
|
|
465
557
|
process.stderr.write(s + '\n');
|
|
466
558
|
}
|
|
467
559
|
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// Data egress notice (issue #183): two opt-in paths send PRIVATE note content
|
|
562
|
+
// to non-loopback destinations — external embedders and the RAG LLM bridge.
|
|
563
|
+
// The default local pipeline never transmits anything; the notice fires once
|
|
564
|
+
// per process before the first send and can be silenced via env.
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
export function isLoopbackEndpoint(url) {
|
|
568
|
+
try {
|
|
569
|
+
const u = new URL(url);
|
|
570
|
+
const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
571
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1';
|
|
572
|
+
} catch {
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let egressWarned = false;
|
|
578
|
+
|
|
579
|
+
export function warnDataEgress(kind, destination, write = (m) => process.stderr.write(m + '\n')) {
|
|
580
|
+
if (egressWarned || process.env.MDSS_NO_EGRESS_WARNING === '1') return;
|
|
581
|
+
if (isLoopbackEndpoint(destination)) return; // local Ollama/llama.cpp — nothing leaves the machine
|
|
582
|
+
egressWarned = true;
|
|
583
|
+
write(
|
|
584
|
+
`⚠ data egress: ${kind} sends NOTE CONTENT to ${destination} (an external service).\n` +
|
|
585
|
+
` Everything stays local with --offline, a loopback endpoint, or the default local embedder.\n` +
|
|
586
|
+
` Set MDSS_NO_EGRESS_WARNING=1 to silence this notice. See SECURITY.md "Data egress".`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
468
590
|
async function cmdIndex(opts) {
|
|
469
591
|
const db = resolveDb(opts);
|
|
470
592
|
const indexDir = resolveIndexDir(opts, db);
|
|
@@ -499,12 +621,15 @@ async function cmdIndex(opts) {
|
|
|
499
621
|
r = await buildIndex({
|
|
500
622
|
db, indexDir, cacheDir,
|
|
501
623
|
modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
|
|
624
|
+
dimensions: opts.dimensions,
|
|
502
625
|
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
503
626
|
ignore: opts.ignore,
|
|
504
627
|
offline: resolveOffline(opts),
|
|
505
628
|
workers: opts.workers,
|
|
506
629
|
ann: opts.ann,
|
|
507
630
|
quantize: opts.quantize,
|
|
631
|
+
// issue #202: token-budget chunking — default min(adapter.maxTokens, 512)
|
|
632
|
+
...(opts.maxChunkTokens !== undefined ? { maxChunkTokens: opts.maxChunkTokens } : {}),
|
|
508
633
|
log: s => {
|
|
509
634
|
if (process.stderr.isTTY && !opts.json && /^\s+\d+\/\d+$/.test(s)) return;
|
|
510
635
|
process.stderr.write(s + '\n');
|
|
@@ -775,6 +900,7 @@ function checkHealth({ db, indexDir, cacheDir, requireOffline = false, config =
|
|
|
775
900
|
path: config.path,
|
|
776
901
|
valid: !config.error && (!config.unknownKeys || config.unknownKeys.length === 0),
|
|
777
902
|
unknownKeys: config.unknownKeys || [],
|
|
903
|
+
trustTiers: config.trustTiers || {},
|
|
778
904
|
error: config.error || (config.unknownKeys && config.unknownKeys.length > 0 ? `unknown config keys: ${config.unknownKeys.join(', ')}` : null),
|
|
779
905
|
};
|
|
780
906
|
if (!report.config.valid) {
|
|
@@ -1088,11 +1214,42 @@ async function cmdTui(opts) {
|
|
|
1088
1214
|
semanticOnly: !!opts.semantic,
|
|
1089
1215
|
offline: resolveOffline(opts),
|
|
1090
1216
|
path: opts.path.length > 0 ? opts.path : undefined,
|
|
1217
|
+
since: opts.since || undefined,
|
|
1218
|
+
filter: opts.filter || undefined,
|
|
1219
|
+
tag: opts.tag.length > 0 ? opts.tag : undefined,
|
|
1220
|
+
project: opts.project || undefined,
|
|
1221
|
+
type: opts.type || undefined,
|
|
1222
|
+
status: opts.status || undefined,
|
|
1223
|
+
graphBoost: typeof opts.graphBoost === 'number' ? opts.graphBoost : undefined,
|
|
1091
1224
|
rerank: !!opts.rerank,
|
|
1225
|
+
...(opts.multiQuery ? { multiQuery: opts.multiQuery, llmEndpoint: opts.llmEndpoint, ...(opts.llmModel ? { llmModel: opts.llmModel } : {}) } : {}),
|
|
1226
|
+
...(opts.rerankModel ? { rerankModel: opts.rerankModel } : {}),
|
|
1092
1227
|
rag: !!opts.rag,
|
|
1228
|
+
// issue #199: the RAG panel streams a real LLM answer when an endpoint
|
|
1229
|
+
// is configured; without one it stays on the extractive fallback.
|
|
1230
|
+
llmEndpoint: opts.llmEndpoint,
|
|
1231
|
+
llmModel: opts.llmModel,
|
|
1232
|
+
llmProvider: opts.llmProvider,
|
|
1233
|
+
systemPrompt: opts.systemPrompt,
|
|
1093
1234
|
});
|
|
1094
1235
|
}
|
|
1095
1236
|
|
|
1237
|
+
/**
|
|
1238
|
+
* issue #199: finish the answer render after synthesis. When LLM deltas
|
|
1239
|
+
* already went to the terminal, only trailing whitespace is added; when the
|
|
1240
|
+
* stream died mid-way and synthesis fell back to extractive, the fallback
|
|
1241
|
+
* answer is printed under an explicit notice so the two texts can't blur.
|
|
1242
|
+
*/
|
|
1243
|
+
function finishAnswerRender(streamedChars, result, end = '\n\n') {
|
|
1244
|
+
if (streamedChars === 0) {
|
|
1245
|
+
process.stdout.write(`\n${result.answer}${end}`);
|
|
1246
|
+
} else if (result.mode === 'endpoint') {
|
|
1247
|
+
process.stdout.write(end);
|
|
1248
|
+
} else {
|
|
1249
|
+
process.stdout.write(`\n\n[llm stream interrupted — extractive fallback:]\n\n${result.answer}${end}`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1096
1253
|
async function cmdAsk(opts) {
|
|
1097
1254
|
const query = opts._.join(' ').trim();
|
|
1098
1255
|
if (!query) die('missing question to ask. Usage: mdss ask [--db <dir>] "question"');
|
|
@@ -1101,15 +1258,25 @@ async function cmdAsk(opts) {
|
|
|
1101
1258
|
const indexDir = resolveIndexDir(opts, db);
|
|
1102
1259
|
const cacheDir = resolveCache(opts);
|
|
1103
1260
|
const k = opts.k || 5;
|
|
1261
|
+
const offline = resolveOffline(opts);
|
|
1262
|
+
const embedderOverrides = await resolveEmbedderOverrides(opts);
|
|
1104
1263
|
|
|
1264
|
+
if (opts.llmEndpoint) warnDataEgress('--llm-endpoint', opts.llmEndpoint);
|
|
1105
1265
|
const { askQuestion } = await import('../dist/rag.js');
|
|
1266
|
+
let streamedChars = 0;
|
|
1106
1267
|
const result = await askQuestion({
|
|
1107
1268
|
query,
|
|
1108
1269
|
indexDir,
|
|
1109
1270
|
cacheDir,
|
|
1110
1271
|
k,
|
|
1272
|
+
offline,
|
|
1273
|
+
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
1111
1274
|
llmEndpoint: opts.llmEndpoint,
|
|
1112
1275
|
llmModel: opts.llmModel,
|
|
1276
|
+
llmProvider: opts.llmProvider,
|
|
1277
|
+
// issue #199: incremental render — first token lands immediately. JSON
|
|
1278
|
+
// mode stays a single machine-readable write.
|
|
1279
|
+
...(opts.json ? {} : { onDelta: (d) => { streamedChars += d.length; process.stdout.write(d); } }),
|
|
1113
1280
|
systemPrompt: opts.systemPrompt,
|
|
1114
1281
|
});
|
|
1115
1282
|
|
|
@@ -1118,7 +1285,7 @@ async function cmdAsk(opts) {
|
|
|
1118
1285
|
return;
|
|
1119
1286
|
}
|
|
1120
1287
|
|
|
1121
|
-
|
|
1288
|
+
finishAnswerRender(streamedChars, result);
|
|
1122
1289
|
if (result.citations && result.citations.length > 0) {
|
|
1123
1290
|
process.stdout.write('Sources:\n');
|
|
1124
1291
|
for (const c of result.citations) {
|
|
@@ -1129,18 +1296,183 @@ async function cmdAsk(opts) {
|
|
|
1129
1296
|
}
|
|
1130
1297
|
}
|
|
1131
1298
|
|
|
1299
|
+
async function cmdChat(opts) {
|
|
1300
|
+
const db = resolveDb(opts);
|
|
1301
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
1302
|
+
const cacheDir = resolveCache(opts);
|
|
1303
|
+
const k = opts.k || 5;
|
|
1304
|
+
const offline = resolveOffline(opts);
|
|
1305
|
+
const embedderOverrides = await resolveEmbedderOverrides(opts);
|
|
1306
|
+
|
|
1307
|
+
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
1308
|
+
if (!fs.existsSync(vectorsPath)) {
|
|
1309
|
+
die(`No index at ${vectorsPath}. Run \`mdss index --db ${db}\` first.`);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
if (opts.llmEndpoint) warnDataEgress('--llm-endpoint', opts.llmEndpoint);
|
|
1313
|
+
const { chatTurn, loadChatSession, listChatSessions } = await import('../dist/rag.js');
|
|
1314
|
+
const loaded = loadIndex(indexDir);
|
|
1315
|
+
|
|
1316
|
+
let sessionId = opts.session;
|
|
1317
|
+
if (!sessionId && opts.resume) {
|
|
1318
|
+
const existing = listChatSessions(indexDir);
|
|
1319
|
+
if (existing.length > 0) {
|
|
1320
|
+
sessionId = existing[0].id;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
if (!sessionId) {
|
|
1324
|
+
sessionId = crypto.randomUUID().slice(0, 8);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const session = loadChatSession(indexDir, sessionId, (msg) => {
|
|
1328
|
+
if (!opts.json) process.stderr.write(`${msg}\n`);
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
const query = opts._.join(' ').trim();
|
|
1332
|
+
// If one-shot query provided as argument:
|
|
1333
|
+
if (query) {
|
|
1334
|
+
let streamedChars = 0;
|
|
1335
|
+
const res = await chatTurn({
|
|
1336
|
+
session,
|
|
1337
|
+
query,
|
|
1338
|
+
loaded,
|
|
1339
|
+
indexDir,
|
|
1340
|
+
cacheDir,
|
|
1341
|
+
k,
|
|
1342
|
+
offline,
|
|
1343
|
+
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
1344
|
+
llmEndpoint: opts.llmEndpoint,
|
|
1345
|
+
llmModel: opts.llmModel,
|
|
1346
|
+
llmProvider: opts.llmProvider,
|
|
1347
|
+
...(opts.json ? {} : { onDelta: (d) => { streamedChars += d.length; process.stdout.write(d); } }),
|
|
1348
|
+
systemPrompt: opts.systemPrompt,
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
if (opts.json) {
|
|
1352
|
+
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
finishAnswerRender(streamedChars, res);
|
|
1357
|
+
if (res.citations && res.citations.length > 0) {
|
|
1358
|
+
process.stdout.write('Sources:\n');
|
|
1359
|
+
for (const c of res.citations) {
|
|
1360
|
+
const heading = c.heading ? ` › ${c.heading}` : '';
|
|
1361
|
+
process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
|
|
1362
|
+
}
|
|
1363
|
+
process.stdout.write('\n');
|
|
1364
|
+
}
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Interactive REPL mode:
|
|
1369
|
+
const readline = await import('node:readline');
|
|
1370
|
+
const rl = readline.createInterface({
|
|
1371
|
+
input: process.stdin,
|
|
1372
|
+
output: process.stdout,
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
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`;
|
|
1376
|
+
process.stdout.write(banner);
|
|
1377
|
+
|
|
1378
|
+
const promptUser = () => {
|
|
1379
|
+
rl.question('\n\x1b[1mmdss chat>\x1b[0m ', async (input) => {
|
|
1380
|
+
const line = (input || '').trim();
|
|
1381
|
+
if (!line) {
|
|
1382
|
+
promptUser();
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
if (line === '/exit' || line === '/quit' || line === ':q') {
|
|
1387
|
+
rl.close();
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (line === '/help') {
|
|
1392
|
+
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');
|
|
1393
|
+
promptUser();
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
if (line === '/session') {
|
|
1398
|
+
process.stdout.write(`Session ID: ${session.id}\nCreated: ${session.createdAt}\nTurns: ${session.turns.length}\n`);
|
|
1399
|
+
promptUser();
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
if (line === '/sources') {
|
|
1404
|
+
const lastTurn = session.turns[session.turns.length - 1];
|
|
1405
|
+
if (!lastTurn || !lastTurn.manifest || lastTurn.manifest.length === 0) {
|
|
1406
|
+
process.stdout.write('No sources available yet.\n');
|
|
1407
|
+
} else {
|
|
1408
|
+
process.stdout.write('Sources from last turn:\n');
|
|
1409
|
+
for (const m of lastTurn.manifest) {
|
|
1410
|
+
const loc = (m.startLine && m.endLine) ? `:L${m.startLine}-L${m.endLine}` : '';
|
|
1411
|
+
const heading = m.heading ? ` › ${m.heading}` : '';
|
|
1412
|
+
process.stdout.write(` - ${m.file}${heading}${loc} [score: ${m.score.toFixed(3)}]\n`);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
promptUser();
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
try {
|
|
1420
|
+
let streamedChars = 0;
|
|
1421
|
+
const res = await chatTurn({
|
|
1422
|
+
session,
|
|
1423
|
+
query: line,
|
|
1424
|
+
loaded,
|
|
1425
|
+
indexDir,
|
|
1426
|
+
cacheDir,
|
|
1427
|
+
k,
|
|
1428
|
+
offline,
|
|
1429
|
+
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
1430
|
+
llmEndpoint: opts.llmEndpoint,
|
|
1431
|
+
llmModel: opts.llmModel,
|
|
1432
|
+
llmProvider: opts.llmProvider,
|
|
1433
|
+
// issue #199: REPL deltas stream as they arrive; session/history
|
|
1434
|
+
// persistence still happens on the complete answer only.
|
|
1435
|
+
onDelta: (d) => { streamedChars += d.length; process.stdout.write(d); },
|
|
1436
|
+
systemPrompt: opts.systemPrompt,
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
finishAnswerRender(streamedChars, res, '\n');
|
|
1440
|
+
if (res.citations && res.citations.length > 0) {
|
|
1441
|
+
process.stdout.write('\nSources:\n');
|
|
1442
|
+
for (const c of res.citations) {
|
|
1443
|
+
const heading = c.heading ? ` › ${c.heading}` : '';
|
|
1444
|
+
process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
} catch (e) {
|
|
1448
|
+
process.stdout.write(`\nError: ${e.message}\n`);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
promptUser();
|
|
1452
|
+
});
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
promptUser();
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1132
1458
|
async function cmdSearch(opts) {
|
|
1133
1459
|
if (opts.interactive) return cmdTui(opts);
|
|
1134
1460
|
const cacheDir = resolveCache(opts);
|
|
1135
1461
|
const query = opts._.join(' ').trim();
|
|
1136
1462
|
if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
|
|
1137
1463
|
|
|
1464
|
+
// issue #203: multi-query paraphrases and HyDE both send note-derived text
|
|
1465
|
+
// through the LLM endpoint — same one-time disclosure as ask/chat
|
|
1466
|
+
if ((opts.multiQuery || opts.expand === 'hyde') && opts.llmEndpoint) {
|
|
1467
|
+
warnDataEgress('--llm-endpoint', opts.llmEndpoint);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1138
1470
|
const embedderOverrides = await resolveEmbedderOverrides(opts);
|
|
1139
1471
|
let results;
|
|
1140
1472
|
const isFederated = Array.isArray(opts.vaults) && opts.vaults.length > 0;
|
|
1141
1473
|
|
|
1142
1474
|
if (isFederated) {
|
|
1143
|
-
|
|
1475
|
+
const fed = await searchFederated({
|
|
1144
1476
|
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
1145
1477
|
vaults: opts.vaults,
|
|
1146
1478
|
cacheDir,
|
|
@@ -1155,6 +1487,9 @@ async function cmdSearch(opts) {
|
|
|
1155
1487
|
path: opts.path.length > 0 ? opts.path : undefined,
|
|
1156
1488
|
since: opts.since,
|
|
1157
1489
|
rerank: !!opts.rerank,
|
|
1490
|
+
// issue #203: multi-query expansion + reranker registry alias
|
|
1491
|
+
...(opts.multiQuery ? { multiQuery: opts.multiQuery, llmEndpoint: opts.llmEndpoint, ...(opts.llmModel ? { llmModel: opts.llmModel } : {}) } : {}),
|
|
1492
|
+
...(opts.rerankModel ? { rerankModel: opts.rerankModel } : {}),
|
|
1158
1493
|
tag: opts.tag,
|
|
1159
1494
|
project: opts.project,
|
|
1160
1495
|
type: opts.type,
|
|
@@ -1167,6 +1502,9 @@ async function cmdSearch(opts) {
|
|
|
1167
1502
|
graphBoost: opts.graphBoost,
|
|
1168
1503
|
filter: opts.filter,
|
|
1169
1504
|
});
|
|
1505
|
+
// issue #189: a broken vault must not look like "no results"
|
|
1506
|
+
for (const ve of fed.vaultErrors) process.stderr.write(`warning: ${ve}\n`);
|
|
1507
|
+
results = fed.hits;
|
|
1170
1508
|
} else {
|
|
1171
1509
|
const db = resolveDb(opts);
|
|
1172
1510
|
const indexDir = resolveIndexDir(opts, db);
|
|
@@ -1185,6 +1523,9 @@ async function cmdSearch(opts) {
|
|
|
1185
1523
|
path: opts.path.length > 0 ? opts.path : undefined,
|
|
1186
1524
|
since: opts.since,
|
|
1187
1525
|
rerank: !!opts.rerank,
|
|
1526
|
+
// issue #203: multi-query expansion + reranker registry alias
|
|
1527
|
+
...(opts.multiQuery ? { multiQuery: opts.multiQuery, llmEndpoint: opts.llmEndpoint, ...(opts.llmModel ? { llmModel: opts.llmModel } : {}) } : {}),
|
|
1528
|
+
...(opts.rerankModel ? { rerankModel: opts.rerankModel } : {}),
|
|
1188
1529
|
tag: opts.tag,
|
|
1189
1530
|
project: opts.project,
|
|
1190
1531
|
type: opts.type,
|
|
@@ -1196,6 +1537,7 @@ async function cmdSearch(opts) {
|
|
|
1196
1537
|
nprobe: opts.nprobe,
|
|
1197
1538
|
graphBoost: opts.graphBoost,
|
|
1198
1539
|
filter: opts.filter,
|
|
1540
|
+
dimensions: opts.dimensions,
|
|
1199
1541
|
});
|
|
1200
1542
|
}
|
|
1201
1543
|
|
|
@@ -1289,9 +1631,10 @@ function cmdModels() {
|
|
|
1289
1631
|
const pooling = m.pooling ?? 'mean';
|
|
1290
1632
|
const normalized = m.normalize !== false ? 'L2' : 'raw';
|
|
1291
1633
|
const rev = m.revision ? ` · rev ${m.revision.slice(0, 8)}` : '';
|
|
1634
|
+
const mrl = m.dimensions ? ` · MRL: ${m.dimensions.join(', ')}` : '';
|
|
1292
1635
|
process.stdout.write(
|
|
1293
1636
|
` ${alias}${star}\n` +
|
|
1294
|
-
` ${m.id} · dim ${m.dim}${rev}\n` +
|
|
1637
|
+
` ${m.id} · dim ${m.dim}${mrl}${rev}\n` +
|
|
1295
1638
|
` ${pooling} · ${normalized} · maxTokens ${m.maxTokens ?? '—'} ` +
|
|
1296
1639
|
(m.family ? `· family ${m.family}\n` : '\n') +
|
|
1297
1640
|
` ${m.note}\n\n`,
|
|
@@ -1327,12 +1670,23 @@ async function cmdServe(opts) {
|
|
|
1327
1670
|
// Refuse to serve the whole knowledge base to the network unauthenticated (issue #121).
|
|
1328
1671
|
validateBindSecurity({ host, hasApiKey: !!apiKey, allowUnsecured: !!opts.allowUnsecured });
|
|
1329
1672
|
|
|
1673
|
+
// POST /ask egress (issue #199): the daemon forwards queries to the chat
|
|
1674
|
+
// endpoint — same disclosure gate as the CLI ask/chat paths.
|
|
1675
|
+
if (opts.llmEndpoint) warnDataEgress('--llm-endpoint', opts.llmEndpoint);
|
|
1676
|
+
|
|
1330
1677
|
const { server, state, close } = await createServe({
|
|
1331
1678
|
db, indexDir, cacheDir,
|
|
1332
1679
|
modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
|
|
1333
1680
|
ignore: opts.ignore,
|
|
1334
1681
|
offline: resolveOffline(opts),
|
|
1335
1682
|
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
1683
|
+
// issue #202: token-budget chunking for serve's initial + watch rebuilds.
|
|
1684
|
+
...(opts.maxChunkTokens !== undefined ? { maxChunkTokens: opts.maxChunkTokens } : {}),
|
|
1685
|
+
// issue #199: chat endpoint for POST /ask SSE synthesis.
|
|
1686
|
+
...(opts.llmEndpoint ? { llmEndpoint: opts.llmEndpoint } : {}),
|
|
1687
|
+
...(opts.llmModel ? { llmModel: opts.llmModel } : {}),
|
|
1688
|
+
...(opts.llmProvider ? { llmProvider: opts.llmProvider } : {}),
|
|
1689
|
+
...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}),
|
|
1336
1690
|
watch: !!opts.watch,
|
|
1337
1691
|
watchInterval: opts.watchInterval,
|
|
1338
1692
|
watchDelay: opts.watchDelay,
|
|
@@ -1357,6 +1711,25 @@ async function cmdServe(opts) {
|
|
|
1357
1711
|
log,
|
|
1358
1712
|
});
|
|
1359
1713
|
|
|
1714
|
+
// issue #186: the most common serve failure (EADDRINUSE) used to surface as
|
|
1715
|
+
// a raw stack trace — name the cause and the fix instead.
|
|
1716
|
+
server.on('error', (err) => {
|
|
1717
|
+
if (err?.code === 'EADDRINUSE') {
|
|
1718
|
+
process.stderr.write(
|
|
1719
|
+
`mdss serve: port ${port} is already in use — another daemon (or program) is listening on it.\n` +
|
|
1720
|
+
` Fix: pick another port, e.g. mdss serve --port ${port + 1}\n`
|
|
1721
|
+
);
|
|
1722
|
+
} else if (err?.code === 'EACCES') {
|
|
1723
|
+
process.stderr.write(
|
|
1724
|
+
`mdss serve: no permission to bind ${host}:${port}.\n` +
|
|
1725
|
+
` Fix: use an unprivileged port (1024+), e.g. mdss serve --port 8747\n`
|
|
1726
|
+
);
|
|
1727
|
+
} else {
|
|
1728
|
+
process.stderr.write(`mdss serve: ${err?.message || err}\n`);
|
|
1729
|
+
}
|
|
1730
|
+
process.exit(1);
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1360
1733
|
server.listen(port, host, () => {
|
|
1361
1734
|
const idx = state.loaded.index;
|
|
1362
1735
|
log(`mdss serve listening on http://${host}:${port}` +
|
|
@@ -1364,6 +1737,7 @@ async function cmdServe(opts) {
|
|
|
1364
1737
|
log(` index: ${idx.chunks.length} chunks · model ${state.loaded.model.id}` +
|
|
1365
1738
|
(state.watching ? ' · watching for changes' : ''));
|
|
1366
1739
|
log(` POST /search {"query": "...", "k": 6}`);
|
|
1740
|
+
if (state.llmEndpoint) log(` POST /ask {"query": "...", "k": 5} — SSE answer streams via ${state.llmEndpoint}`);
|
|
1367
1741
|
log(` GET /health`);
|
|
1368
1742
|
if (!isLoopbackHost(host) && !state.apiKey) {
|
|
1369
1743
|
log('');
|
|
@@ -1378,11 +1752,17 @@ async function cmdServe(opts) {
|
|
|
1378
1752
|
|
|
1379
1753
|
const stop = async (sig) => {
|
|
1380
1754
|
log(`\n${sig} — shutting down.`);
|
|
1381
|
-
|
|
1755
|
+
try {
|
|
1756
|
+
await close();
|
|
1757
|
+
} catch {
|
|
1758
|
+
// issue #186: a second signal (or a listen error) can make close()
|
|
1759
|
+
// reject with ERR_SERVER_NOT_RUNNING — always exit, never hang
|
|
1760
|
+
}
|
|
1382
1761
|
process.exit(0);
|
|
1383
1762
|
};
|
|
1384
1763
|
process.on('SIGINT', () => stop('SIGINT'));
|
|
1385
1764
|
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
1765
|
+
if (process.platform === 'win32') process.on('SIGBREAK', () => stop('SIGBREAK'));
|
|
1386
1766
|
}
|
|
1387
1767
|
|
|
1388
1768
|
async function cmdMcp(opts) {
|
|
@@ -1414,7 +1794,7 @@ async function cmdMcp(opts) {
|
|
|
1414
1794
|
// on STDERR so stdout stays machine-readable; warm cache emits nothing at all.
|
|
1415
1795
|
// ---------------------------------------------------------------------------
|
|
1416
1796
|
|
|
1417
|
-
const DOWNLOAD_COMMANDS = new Set(['index', 'search', 'ask', 'serve']);
|
|
1797
|
+
const DOWNLOAD_COMMANDS = new Set(['index', 'search', 'ask', 'chat', 'serve']);
|
|
1418
1798
|
|
|
1419
1799
|
function formatMb(bytes) {
|
|
1420
1800
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
|
|
@@ -1469,6 +1849,59 @@ async function cmdCompletions(opts) {
|
|
|
1469
1849
|
process.stdout.write(generateCompletion(shell));
|
|
1470
1850
|
}
|
|
1471
1851
|
|
|
1852
|
+
async function cmdRelated(opts) {
|
|
1853
|
+
const db = resolveDb(opts);
|
|
1854
|
+
const indexDir = resolveIndexDir(opts, db);
|
|
1855
|
+
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
1856
|
+
if (!fs.existsSync(vectorsPath)) {
|
|
1857
|
+
die(`No index at ${vectorsPath}. Run \`mdss index --db ${db}\` first.`);
|
|
1858
|
+
}
|
|
1859
|
+
const target = opts._.shift();
|
|
1860
|
+
if (!target) {
|
|
1861
|
+
die('Missing note path/title/alias to find relations for. e.g. `mdss related --db . "Architecture"`');
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
const loaded = loadIndex(indexDir);
|
|
1865
|
+
const k = Number.isInteger(opts.k) && opts.k > 0 ? opts.k : 10;
|
|
1866
|
+
const direction = opts.direction || 'both';
|
|
1867
|
+
const semantic = !opts.noSemantic;
|
|
1868
|
+
|
|
1869
|
+
let r;
|
|
1870
|
+
try {
|
|
1871
|
+
r = findRelatedNotes({
|
|
1872
|
+
loaded,
|
|
1873
|
+
target,
|
|
1874
|
+
k,
|
|
1875
|
+
direction,
|
|
1876
|
+
semantic,
|
|
1877
|
+
});
|
|
1878
|
+
} catch (e) {
|
|
1879
|
+
die(e.message);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
if (opts.json) {
|
|
1883
|
+
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
if (r.results.length === 0) {
|
|
1888
|
+
process.stdout.write(`No related notes found for "${target}" (${r.resolvedFile}).\n`);
|
|
1889
|
+
if (opts.noSemantic) {
|
|
1890
|
+
process.stdout.write('Hint: Try running without `--no-semantic` to discover conceptually related notes without explicit links.\n');
|
|
1891
|
+
}
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
process.stdout.write(`Related notes for "${target}" (${r.resolvedFile}):\n\n`);
|
|
1896
|
+
r.results.forEach((hit, idx) => {
|
|
1897
|
+
const scoreStr = hit.score.toFixed(2);
|
|
1898
|
+
process.stdout.write(` ${idx + 1}. \x1b[1m${hit.file}\x1b[0m — score: ${scoreStr} [${hit.reason}]\n`);
|
|
1899
|
+
if (hit.title && hit.title !== hit.file) {
|
|
1900
|
+
process.stdout.write(` title: "${hit.title}"\n`);
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1472
1905
|
async function main() {
|
|
1473
1906
|
const argv = process.argv.slice(2);
|
|
1474
1907
|
const opts = parseArgs(argv);
|
|
@@ -1496,7 +1929,9 @@ async function main() {
|
|
|
1496
1929
|
case 'doctor': return await cmdCheck(opts);
|
|
1497
1930
|
case 'export': return await cmdExport(opts);
|
|
1498
1931
|
case 'search': return await cmdSearch(opts);
|
|
1932
|
+
case 'related': return await cmdRelated(opts);
|
|
1499
1933
|
case 'ask': return await cmdAsk(opts);
|
|
1934
|
+
case 'chat': return await cmdChat(opts);
|
|
1500
1935
|
case 'tui': return await cmdTui(opts);
|
|
1501
1936
|
case 'serve': return await cmdServe(opts);
|
|
1502
1937
|
case 'mcp': return await cmdMcp(opts);
|
|
@@ -1542,6 +1977,7 @@ export {
|
|
|
1542
1977
|
parseArgs, nextInt, nextFloat, nextValue,
|
|
1543
1978
|
resolveDb, resolveIndexDir, resolveCache, resolveOffline,
|
|
1544
1979
|
findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
|
|
1980
|
+
CONFIG_NETWORK_KEYS, CONFIG_EXEC_KEYS, getConfigKeyTrustTier,
|
|
1545
1981
|
checkHealth, cmdExport,
|
|
1546
1982
|
die, main, HELP, VERSION,
|
|
1547
1983
|
};
|