md-semantic-search 1.1.0 → 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 +88 -14
- package/RESEARCH.md +182 -0
- package/bin/cli.mjs +197 -8
- package/dist/binary-format.js +9 -0
- package/dist/completions.js +5 -0
- package/dist/core.d.ts +16 -4
- package/dist/core.js +105 -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/indexer.d.ts +17 -1
- package/dist/indexer.js +91 -17
- 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 +86 -3
- package/dist/mcp.js +259 -31
- package/dist/models.d.ts +7 -1
- package/dist/models.js +32 -1
- 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 +60 -0
- package/dist/rag.js +133 -35
- package/dist/repair.d.ts +1 -1
- package/dist/repair.js +53 -2
- package/dist/rerank.d.ts +32 -3
- package/dist/rerank.js +58 -8
- package/dist/search.d.ts +48 -3
- package/dist/search.js +384 -78
- package/dist/serve.d.ts +35 -0
- package/dist/serve.js +478 -58
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +52 -2
- package/dist/watcher.js +23 -12
- package/dist/webui.d.ts +3 -3
- package/dist/webui.js +201 -2
- package/package.json +3 -3
- package/src/binary-format.ts +13 -0
- package/src/completions.ts +5 -0
- package/src/core.ts +115 -12
- package/src/federation.ts +51 -17
- package/src/filter.ts +14 -0
- package/src/hnsw.ts +414 -0
- package/src/indexer.ts +107 -18
- package/src/lexical.ts +91 -13
- package/src/markdown-parser.ts +279 -37
- package/src/mcp.ts +323 -34
- package/src/models.ts +39 -2
- package/src/providers.ts +345 -0
- package/src/quantization.ts +61 -0
- package/src/rag.ts +189 -34
- package/src/repair.ts +51 -2
- package/src/rerank.ts +81 -8
- package/src/search.ts +417 -82
- package/src/serve.ts +499 -66
- package/src/tui.ts +56 -2
- package/src/watcher.ts +21 -10
- package/src/webui.ts +201 -2
package/bin/cli.mjs
CHANGED
|
@@ -102,7 +102,16 @@ function parseArgs(argv) {
|
|
|
102
102
|
const v = nextValue(argv, ++i, a);
|
|
103
103
|
opts.tag = opts.tag ? (Array.isArray(opts.tag) ? [...opts.tag, v] : [opts.tag, v]) : v;
|
|
104
104
|
}
|
|
105
|
-
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
|
+
}
|
|
106
115
|
else if (a === '--nprobe') opts.nprobe = nextInt(argv, ++i, a);
|
|
107
116
|
else if (a === '--project') opts.project = nextValue(argv, ++i, a);
|
|
108
117
|
else if (a === '--type') opts.type = nextValue(argv, ++i, a);
|
|
@@ -113,11 +122,25 @@ function parseArgs(argv) {
|
|
|
113
122
|
else if (a === '--direction') opts.direction = nextValue(argv, ++i, a);
|
|
114
123
|
else if (a === '--no-semantic') opts.noSemantic = true;
|
|
115
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);
|
|
116
135
|
else if (a === '--rag') opts.rag = true;
|
|
117
136
|
else if (a === '--auto-tag') opts.autoTag = true;
|
|
118
137
|
else if (a === '--auto-summarize') opts.autoSummarize = true;
|
|
119
138
|
else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
|
|
120
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
|
+
}
|
|
121
144
|
else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
|
|
122
145
|
else if (a === '--session') opts.session = nextValue(argv, ++i, a);
|
|
123
146
|
else if (a === '--resume') opts.resume = true;
|
|
@@ -209,8 +232,11 @@ const KNOWN_CONFIG_KEYS = new Set([
|
|
|
209
232
|
'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
|
|
210
233
|
'watchDebug', 'watch-debug',
|
|
211
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',
|
|
212
237
|
'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
|
|
213
238
|
'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt',
|
|
239
|
+
'llmProvider', 'llm-provider',
|
|
214
240
|
'session', 'resume',
|
|
215
241
|
'completions', 'open', 'dry-run', 'fix', 'yes', 'no-ui', 'noUi',
|
|
216
242
|
'noQueryCache', 'no-query-cache',
|
|
@@ -226,7 +252,7 @@ const CONFIG_NETWORK_KEYS = new Set([
|
|
|
226
252
|
'allowedHosts', 'allowed-hosts', 'corsOrigin', 'cors-origin',
|
|
227
253
|
'rateLimit', 'rate-limit', 'maxConcurrency', 'max-concurrency',
|
|
228
254
|
'healthPublic', 'health-public', 'mcp', 'llmEndpoint', 'llm-endpoint',
|
|
229
|
-
'llmModel', 'llm-model', 'embedder', 'embedder-model', 'embedder-base-url',
|
|
255
|
+
'llmModel', 'llm-model', 'llmProvider', 'llm-provider', 'embedder', 'embedder-model', 'embedder-base-url',
|
|
230
256
|
'embedder-key-file', 'embedderModel', 'embedderBaseUrl', 'embedderKeyFile'
|
|
231
257
|
]);
|
|
232
258
|
|
|
@@ -376,6 +402,11 @@ Options:
|
|
|
376
402
|
--model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
|
|
377
403
|
--dimensions <n> Truncate vector dimensions for Matryoshka/MRL models (alias: --dim).
|
|
378
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).
|
|
379
410
|
--format <fmt> Export format: jsonl (default), csv, parquet (export).
|
|
380
411
|
--no-vectors Omit vector embeddings from export (export).
|
|
381
412
|
--output <file> Output file path (export, default: stdout).
|
|
@@ -386,9 +417,21 @@ Options:
|
|
|
386
417
|
--json Machine-readable output (index, stats, search).
|
|
387
418
|
--semantic Pure vector ranking, skip lexical/RRF fusion (search).
|
|
388
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.
|
|
389
428
|
--graph-boost <n> Boost weight for graph PageRank / Obsidian wikilinks (search, default: 0).
|
|
390
429
|
--filter <expr> Rich boolean filter across frontmatter tags and properties (search).
|
|
391
|
-
--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.
|
|
392
435
|
--nprobe <n> Number of centroids to probe for ANN (search, default: 8).
|
|
393
436
|
--port <n> HTTP port for serve (default: ${DEFAULT_PORT}).
|
|
394
437
|
--host <ip> Bind address for serve (default: ${DEFAULT_HOST} — loopback
|
|
@@ -444,6 +487,13 @@ Options:
|
|
|
444
487
|
ONE embed total; issue #125) or "hyde" (LLM-generated
|
|
445
488
|
passage via --llm-endpoint; degrades silently without it).
|
|
446
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.
|
|
447
497
|
--open [N] search: open the top (or Nth) hit in your editor at its
|
|
448
498
|
startLine — MDSS_EDITOR/VISUAL/EDITOR, then VS Code
|
|
449
499
|
--goto, then the GUI opener (issue #110). With --json on
|
|
@@ -462,6 +512,9 @@ Examples:
|
|
|
462
512
|
mdss search --db ./docs "incident runbook" --path "docs/**" --since 2026-01-01
|
|
463
513
|
mdss serve --db ./docs --port 8747 --watch
|
|
464
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
|
|
465
518
|
`;
|
|
466
519
|
|
|
467
520
|
function formatDuration(sec) {
|
|
@@ -492,6 +545,9 @@ async function resolveEmbedderOverrides(opts) {
|
|
|
492
545
|
baseUrl: opts.embedderBaseUrl,
|
|
493
546
|
keyFile: opts.embedderKeyFile,
|
|
494
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);
|
|
495
551
|
const descriptor = await emb.descriptor();
|
|
496
552
|
logErr(`embedder: ${emb.id}/${emb.model} (dim ${descriptor.dim} via ${opts.embedderBaseUrl || 'default endpoint'})`);
|
|
497
553
|
return { modelName: descriptor, embedFn: emb.embedFn };
|
|
@@ -501,6 +557,36 @@ function logErr(s) {
|
|
|
501
557
|
process.stderr.write(s + '\n');
|
|
502
558
|
}
|
|
503
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
|
+
|
|
504
590
|
async function cmdIndex(opts) {
|
|
505
591
|
const db = resolveDb(opts);
|
|
506
592
|
const indexDir = resolveIndexDir(opts, db);
|
|
@@ -542,6 +628,8 @@ async function cmdIndex(opts) {
|
|
|
542
628
|
workers: opts.workers,
|
|
543
629
|
ann: opts.ann,
|
|
544
630
|
quantize: opts.quantize,
|
|
631
|
+
// issue #202: token-budget chunking — default min(adapter.maxTokens, 512)
|
|
632
|
+
...(opts.maxChunkTokens !== undefined ? { maxChunkTokens: opts.maxChunkTokens } : {}),
|
|
545
633
|
log: s => {
|
|
546
634
|
if (process.stderr.isTTY && !opts.json && /^\s+\d+\/\d+$/.test(s)) return;
|
|
547
635
|
process.stderr.write(s + '\n');
|
|
@@ -1134,10 +1222,34 @@ async function cmdTui(opts) {
|
|
|
1134
1222
|
status: opts.status || undefined,
|
|
1135
1223
|
graphBoost: typeof opts.graphBoost === 'number' ? opts.graphBoost : undefined,
|
|
1136
1224
|
rerank: !!opts.rerank,
|
|
1225
|
+
...(opts.multiQuery ? { multiQuery: opts.multiQuery, llmEndpoint: opts.llmEndpoint, ...(opts.llmModel ? { llmModel: opts.llmModel } : {}) } : {}),
|
|
1226
|
+
...(opts.rerankModel ? { rerankModel: opts.rerankModel } : {}),
|
|
1137
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,
|
|
1138
1234
|
});
|
|
1139
1235
|
}
|
|
1140
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
|
+
|
|
1141
1253
|
async function cmdAsk(opts) {
|
|
1142
1254
|
const query = opts._.join(' ').trim();
|
|
1143
1255
|
if (!query) die('missing question to ask. Usage: mdss ask [--db <dir>] "question"');
|
|
@@ -1146,15 +1258,25 @@ async function cmdAsk(opts) {
|
|
|
1146
1258
|
const indexDir = resolveIndexDir(opts, db);
|
|
1147
1259
|
const cacheDir = resolveCache(opts);
|
|
1148
1260
|
const k = opts.k || 5;
|
|
1261
|
+
const offline = resolveOffline(opts);
|
|
1262
|
+
const embedderOverrides = await resolveEmbedderOverrides(opts);
|
|
1149
1263
|
|
|
1264
|
+
if (opts.llmEndpoint) warnDataEgress('--llm-endpoint', opts.llmEndpoint);
|
|
1150
1265
|
const { askQuestion } = await import('../dist/rag.js');
|
|
1266
|
+
let streamedChars = 0;
|
|
1151
1267
|
const result = await askQuestion({
|
|
1152
1268
|
query,
|
|
1153
1269
|
indexDir,
|
|
1154
1270
|
cacheDir,
|
|
1155
1271
|
k,
|
|
1272
|
+
offline,
|
|
1273
|
+
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
1156
1274
|
llmEndpoint: opts.llmEndpoint,
|
|
1157
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); } }),
|
|
1158
1280
|
systemPrompt: opts.systemPrompt,
|
|
1159
1281
|
});
|
|
1160
1282
|
|
|
@@ -1163,7 +1285,7 @@ async function cmdAsk(opts) {
|
|
|
1163
1285
|
return;
|
|
1164
1286
|
}
|
|
1165
1287
|
|
|
1166
|
-
|
|
1288
|
+
finishAnswerRender(streamedChars, result);
|
|
1167
1289
|
if (result.citations && result.citations.length > 0) {
|
|
1168
1290
|
process.stdout.write('Sources:\n');
|
|
1169
1291
|
for (const c of result.citations) {
|
|
@@ -1179,12 +1301,15 @@ async function cmdChat(opts) {
|
|
|
1179
1301
|
const indexDir = resolveIndexDir(opts, db);
|
|
1180
1302
|
const cacheDir = resolveCache(opts);
|
|
1181
1303
|
const k = opts.k || 5;
|
|
1304
|
+
const offline = resolveOffline(opts);
|
|
1305
|
+
const embedderOverrides = await resolveEmbedderOverrides(opts);
|
|
1182
1306
|
|
|
1183
1307
|
const vectorsPath = path.join(indexDir, 'vectors.json');
|
|
1184
1308
|
if (!fs.existsSync(vectorsPath)) {
|
|
1185
1309
|
die(`No index at ${vectorsPath}. Run \`mdss index --db ${db}\` first.`);
|
|
1186
1310
|
}
|
|
1187
1311
|
|
|
1312
|
+
if (opts.llmEndpoint) warnDataEgress('--llm-endpoint', opts.llmEndpoint);
|
|
1188
1313
|
const { chatTurn, loadChatSession, listChatSessions } = await import('../dist/rag.js');
|
|
1189
1314
|
const loaded = loadIndex(indexDir);
|
|
1190
1315
|
|
|
@@ -1206,6 +1331,7 @@ async function cmdChat(opts) {
|
|
|
1206
1331
|
const query = opts._.join(' ').trim();
|
|
1207
1332
|
// If one-shot query provided as argument:
|
|
1208
1333
|
if (query) {
|
|
1334
|
+
let streamedChars = 0;
|
|
1209
1335
|
const res = await chatTurn({
|
|
1210
1336
|
session,
|
|
1211
1337
|
query,
|
|
@@ -1213,8 +1339,12 @@ async function cmdChat(opts) {
|
|
|
1213
1339
|
indexDir,
|
|
1214
1340
|
cacheDir,
|
|
1215
1341
|
k,
|
|
1342
|
+
offline,
|
|
1343
|
+
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
1216
1344
|
llmEndpoint: opts.llmEndpoint,
|
|
1217
1345
|
llmModel: opts.llmModel,
|
|
1346
|
+
llmProvider: opts.llmProvider,
|
|
1347
|
+
...(opts.json ? {} : { onDelta: (d) => { streamedChars += d.length; process.stdout.write(d); } }),
|
|
1218
1348
|
systemPrompt: opts.systemPrompt,
|
|
1219
1349
|
});
|
|
1220
1350
|
|
|
@@ -1223,7 +1353,7 @@ async function cmdChat(opts) {
|
|
|
1223
1353
|
return;
|
|
1224
1354
|
}
|
|
1225
1355
|
|
|
1226
|
-
|
|
1356
|
+
finishAnswerRender(streamedChars, res);
|
|
1227
1357
|
if (res.citations && res.citations.length > 0) {
|
|
1228
1358
|
process.stdout.write('Sources:\n');
|
|
1229
1359
|
for (const c of res.citations) {
|
|
@@ -1287,6 +1417,7 @@ async function cmdChat(opts) {
|
|
|
1287
1417
|
}
|
|
1288
1418
|
|
|
1289
1419
|
try {
|
|
1420
|
+
let streamedChars = 0;
|
|
1290
1421
|
const res = await chatTurn({
|
|
1291
1422
|
session,
|
|
1292
1423
|
query: line,
|
|
@@ -1294,12 +1425,18 @@ async function cmdChat(opts) {
|
|
|
1294
1425
|
indexDir,
|
|
1295
1426
|
cacheDir,
|
|
1296
1427
|
k,
|
|
1428
|
+
offline,
|
|
1429
|
+
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
1297
1430
|
llmEndpoint: opts.llmEndpoint,
|
|
1298
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); },
|
|
1299
1436
|
systemPrompt: opts.systemPrompt,
|
|
1300
1437
|
});
|
|
1301
1438
|
|
|
1302
|
-
|
|
1439
|
+
finishAnswerRender(streamedChars, res, '\n');
|
|
1303
1440
|
if (res.citations && res.citations.length > 0) {
|
|
1304
1441
|
process.stdout.write('\nSources:\n');
|
|
1305
1442
|
for (const c of res.citations) {
|
|
@@ -1324,12 +1461,18 @@ async function cmdSearch(opts) {
|
|
|
1324
1461
|
const query = opts._.join(' ').trim();
|
|
1325
1462
|
if (!query) die('Missing query text. e.g. mdss search --db ./docs "your question"');
|
|
1326
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
|
+
|
|
1327
1470
|
const embedderOverrides = await resolveEmbedderOverrides(opts);
|
|
1328
1471
|
let results;
|
|
1329
1472
|
const isFederated = Array.isArray(opts.vaults) && opts.vaults.length > 0;
|
|
1330
1473
|
|
|
1331
1474
|
if (isFederated) {
|
|
1332
|
-
|
|
1475
|
+
const fed = await searchFederated({
|
|
1333
1476
|
...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
|
|
1334
1477
|
vaults: opts.vaults,
|
|
1335
1478
|
cacheDir,
|
|
@@ -1344,6 +1487,9 @@ async function cmdSearch(opts) {
|
|
|
1344
1487
|
path: opts.path.length > 0 ? opts.path : undefined,
|
|
1345
1488
|
since: opts.since,
|
|
1346
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 } : {}),
|
|
1347
1493
|
tag: opts.tag,
|
|
1348
1494
|
project: opts.project,
|
|
1349
1495
|
type: opts.type,
|
|
@@ -1356,6 +1502,9 @@ async function cmdSearch(opts) {
|
|
|
1356
1502
|
graphBoost: opts.graphBoost,
|
|
1357
1503
|
filter: opts.filter,
|
|
1358
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;
|
|
1359
1508
|
} else {
|
|
1360
1509
|
const db = resolveDb(opts);
|
|
1361
1510
|
const indexDir = resolveIndexDir(opts, db);
|
|
@@ -1374,6 +1523,9 @@ async function cmdSearch(opts) {
|
|
|
1374
1523
|
path: opts.path.length > 0 ? opts.path : undefined,
|
|
1375
1524
|
since: opts.since,
|
|
1376
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 } : {}),
|
|
1377
1529
|
tag: opts.tag,
|
|
1378
1530
|
project: opts.project,
|
|
1379
1531
|
type: opts.type,
|
|
@@ -1518,12 +1670,23 @@ async function cmdServe(opts) {
|
|
|
1518
1670
|
// Refuse to serve the whole knowledge base to the network unauthenticated (issue #121).
|
|
1519
1671
|
validateBindSecurity({ host, hasApiKey: !!apiKey, allowUnsecured: !!opts.allowUnsecured });
|
|
1520
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
|
+
|
|
1521
1677
|
const { server, state, close } = await createServe({
|
|
1522
1678
|
db, indexDir, cacheDir,
|
|
1523
1679
|
modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
|
|
1524
1680
|
ignore: opts.ignore,
|
|
1525
1681
|
offline: resolveOffline(opts),
|
|
1526
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 } : {}),
|
|
1527
1690
|
watch: !!opts.watch,
|
|
1528
1691
|
watchInterval: opts.watchInterval,
|
|
1529
1692
|
watchDelay: opts.watchDelay,
|
|
@@ -1548,6 +1711,25 @@ async function cmdServe(opts) {
|
|
|
1548
1711
|
log,
|
|
1549
1712
|
});
|
|
1550
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
|
+
|
|
1551
1733
|
server.listen(port, host, () => {
|
|
1552
1734
|
const idx = state.loaded.index;
|
|
1553
1735
|
log(`mdss serve listening on http://${host}:${port}` +
|
|
@@ -1555,6 +1737,7 @@ async function cmdServe(opts) {
|
|
|
1555
1737
|
log(` index: ${idx.chunks.length} chunks · model ${state.loaded.model.id}` +
|
|
1556
1738
|
(state.watching ? ' · watching for changes' : ''));
|
|
1557
1739
|
log(` POST /search {"query": "...", "k": 6}`);
|
|
1740
|
+
if (state.llmEndpoint) log(` POST /ask {"query": "...", "k": 5} — SSE answer streams via ${state.llmEndpoint}`);
|
|
1558
1741
|
log(` GET /health`);
|
|
1559
1742
|
if (!isLoopbackHost(host) && !state.apiKey) {
|
|
1560
1743
|
log('');
|
|
@@ -1569,11 +1752,17 @@ async function cmdServe(opts) {
|
|
|
1569
1752
|
|
|
1570
1753
|
const stop = async (sig) => {
|
|
1571
1754
|
log(`\n${sig} — shutting down.`);
|
|
1572
|
-
|
|
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
|
+
}
|
|
1573
1761
|
process.exit(0);
|
|
1574
1762
|
};
|
|
1575
1763
|
process.on('SIGINT', () => stop('SIGINT'));
|
|
1576
1764
|
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
1765
|
+
if (process.platform === 'win32') process.on('SIGBREAK', () => stop('SIGBREAK'));
|
|
1577
1766
|
}
|
|
1578
1767
|
|
|
1579
1768
|
async function cmdMcp(opts) {
|
package/dist/binary-format.js
CHANGED
|
@@ -116,7 +116,16 @@ export function readBinaryHeader(buffer) {
|
|
|
116
116
|
throw new Error(`Invalid binary index magic: expected "${BINARY_MAGIC}", got "${magic}"`);
|
|
117
117
|
}
|
|
118
118
|
const version = buffer.readUInt32LE(8);
|
|
119
|
+
// issue #189: version was parsed but never checked — a future-format file
|
|
120
|
+
// would silently decode as garbage vectors
|
|
121
|
+
if (version !== BINARY_VERSION) {
|
|
122
|
+
throw new Error(`Invalid binary index: unsupported version ${version} (expected ${BINARY_VERSION}) — run \`mdss index\` to rebuild`);
|
|
123
|
+
}
|
|
119
124
|
const flags = buffer.readUInt32LE(12);
|
|
125
|
+
// issue #189: unknown flag bits silently decoded as fp32 garbage
|
|
126
|
+
if (flags !== BINARY_FLAG_FP32 && flags !== BINARY_FLAG_INT8) {
|
|
127
|
+
throw new Error(`Invalid binary index: unknown flags 0x${flags.toString(16)} (expected 0 or 1) — run \`mdss index\` to rebuild`);
|
|
128
|
+
}
|
|
120
129
|
const dim = buffer.readUInt32LE(16);
|
|
121
130
|
const chunkCount = buffer.readUInt32LE(20);
|
|
122
131
|
const vectorsOffset = Number(buffer.readBigUInt64LE(24));
|
package/dist/completions.js
CHANGED
|
@@ -32,9 +32,12 @@ export const FLAGS = [
|
|
|
32
32
|
{ name: '--embedder-key-file', kind: 'file' },
|
|
33
33
|
{ name: '--expand', kind: 'value' },
|
|
34
34
|
{ name: '--expand-passages', kind: 'number' },
|
|
35
|
+
{ name: '--multi-query', kind: 'number' },
|
|
36
|
+
{ name: '--rerank-model', kind: 'value' },
|
|
35
37
|
{ name: '--health-public', kind: 'flag' },
|
|
36
38
|
{ name: '--allow-unsecured', kind: 'flag' },
|
|
37
39
|
{ name: '--ann', kind: 'flag' },
|
|
40
|
+
{ name: '--ann', kind: 'value' },
|
|
38
41
|
{ name: '--canonical', kind: 'flag' },
|
|
39
42
|
{ name: '--rag', kind: 'flag' },
|
|
40
43
|
{ name: '--auto-tag', kind: 'flag' },
|
|
@@ -46,6 +49,7 @@ export const FLAGS = [
|
|
|
46
49
|
{ name: '--dry-run', kind: 'flag' },
|
|
47
50
|
// values
|
|
48
51
|
{ name: '--workers', kind: 'number' },
|
|
52
|
+
{ name: '--max-chunk-tokens', kind: 'number' },
|
|
49
53
|
{ name: '--config', kind: 'file' },
|
|
50
54
|
{ name: '--format', kind: 'value' },
|
|
51
55
|
{ name: '--output', alias: '-o', kind: 'file' },
|
|
@@ -86,6 +90,7 @@ export const FLAGS = [
|
|
|
86
90
|
{ name: '--resume', kind: 'flag' },
|
|
87
91
|
{ name: '--llm-endpoint', kind: 'value' },
|
|
88
92
|
{ name: '--llm-model', kind: 'value' },
|
|
93
|
+
{ name: '--llm-provider', kind: 'value' },
|
|
89
94
|
{ name: '--system-prompt', kind: 'value' },
|
|
90
95
|
];
|
|
91
96
|
export const COMMANDS = [
|
package/dist/core.d.ts
CHANGED
|
@@ -23,7 +23,9 @@ export type LockAcquireResult = {
|
|
|
23
23
|
pid: number | null;
|
|
24
24
|
heldSince: string | null;
|
|
25
25
|
};
|
|
26
|
-
export declare function acquireIndexLock(indexDir: string
|
|
26
|
+
export declare function acquireIndexLock(indexDir: string, _testHooks?: {
|
|
27
|
+
onClassified?: () => void;
|
|
28
|
+
}): LockAcquireResult;
|
|
27
29
|
export declare function releaseIndexLock(indexDir: string): void;
|
|
28
30
|
export declare function withIndexLock<T>(indexDir: string, fn: () => Promise<T>): Promise<T>;
|
|
29
31
|
export type ModelDescriptor = ModelAdapter;
|
|
@@ -31,6 +33,14 @@ export declare function getPipelineModelSource(model: ModelDescriptor, cacheDir:
|
|
|
31
33
|
export declare function resolveSessionOptions(model?: ModelDescriptor): Record<string, unknown> | undefined;
|
|
32
34
|
export declare function getExtractorCacheKey(model: ModelDescriptor, cacheDir: string, offline?: boolean): string;
|
|
33
35
|
export declare function isNetworkError(err: any): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Outbound fetch timeout (issue #184): undici's default ~300s lets one hung
|
|
38
|
+
* embedder/LLM endpoint hold a serve concurrency slot or stall `mdss index`
|
|
39
|
+
* for minutes. All outbound calls carry `AbortSignal.timeout(fetchTimeoutMs())`;
|
|
40
|
+
* override with `MDSS_FETCH_TIMEOUT_MS` (0/negative falls back to the default).
|
|
41
|
+
*/
|
|
42
|
+
export declare const DEFAULT_FETCH_TIMEOUT_MS = 30000;
|
|
43
|
+
export declare function fetchTimeoutMs(envValue?: string): number;
|
|
34
44
|
export declare function retryWithBackoff<T>(fn: () => Promise<T>, opts?: {
|
|
35
45
|
maxRetries?: number;
|
|
36
46
|
delays?: number[];
|
|
@@ -68,7 +78,7 @@ export declare function migrateToSchemaV2(index: {
|
|
|
68
78
|
}): void;
|
|
69
79
|
export declare function migrateToSchemaV3(): void;
|
|
70
80
|
export declare const SCHEMA_MIGRATIONS: Record<number, (index: any) => void>;
|
|
71
|
-
export declare function walkMarkdown(dir: string, ignore?: string[]): string[];
|
|
81
|
+
export declare function walkMarkdown(dir: string, ignore?: string[], errors?: string[]): string[];
|
|
72
82
|
export declare function assertSafePath(targetPath: string, allowedRoots?: string[]): string;
|
|
73
83
|
export declare function validateGlob(glob: string): string;
|
|
74
84
|
export declare function globToRegExp(glob: string): RegExp;
|
|
@@ -77,7 +87,7 @@ export declare function splitFrontmatter(raw: string): {
|
|
|
77
87
|
body: string;
|
|
78
88
|
};
|
|
79
89
|
export declare function extractTitle(frontmatter: string, body: string, relPath: string): string;
|
|
80
|
-
export declare function chunkMarkdown(body: string, maxChunk?: number): StructuralChunk[];
|
|
90
|
+
export declare function chunkMarkdown(body: string, maxChunk?: number, maxChunkTokens?: number): StructuralChunk[];
|
|
81
91
|
export interface ParsedChunkFile {
|
|
82
92
|
file: string;
|
|
83
93
|
title: string;
|
|
@@ -88,7 +98,9 @@ export interface ParsedChunkFile {
|
|
|
88
98
|
endLine: number;
|
|
89
99
|
meta: DocumentMetadata;
|
|
90
100
|
}
|
|
91
|
-
export declare function parseFile(absPath: string, dbDir: string, maxChunk?: number, raw?: string
|
|
101
|
+
export declare function parseFile(absPath: string, dbDir: string, maxChunk?: number, raw?: string,
|
|
102
|
+
/** issue #202: token budget overrides the char budget when set. */
|
|
103
|
+
maxChunkTokens?: number): ParsedChunkFile[];
|
|
92
104
|
export interface GetDocLinesOptions {
|
|
93
105
|
file: string;
|
|
94
106
|
fromLine?: number;
|