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/src/core.ts
CHANGED
|
@@ -78,7 +78,10 @@ export type LockAcquireResult =
|
|
|
78
78
|
| { acquired: true; lockPath: string }
|
|
79
79
|
| { acquired: false; reason: string; pid: number | null; heldSince: string | null };
|
|
80
80
|
|
|
81
|
-
export function acquireIndexLock(
|
|
81
|
+
export function acquireIndexLock(
|
|
82
|
+
indexDir: string,
|
|
83
|
+
_testHooks?: { onClassified?: () => void },
|
|
84
|
+
): LockAcquireResult {
|
|
82
85
|
const lockPath = path.join(indexDir, LOCK_FILENAME);
|
|
83
86
|
const payload = JSON.stringify({ pid: process.pid, since: new Date().toISOString() }) + '\n';
|
|
84
87
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -104,6 +107,24 @@ export function acquireIndexLock(indexDir: string): LockAcquireResult {
|
|
|
104
107
|
const dead = pid !== null && !pidAlive(pid);
|
|
105
108
|
const staleMs = stat ? Date.now() - stat.mtimeMs : 0;
|
|
106
109
|
if ((garbage || dead || staleMs > LOCK_STALE_MS) && stat) {
|
|
110
|
+
// issue #188: between the stat/read above and the unlink below another
|
|
111
|
+
// writer may reclaim the lock and write a fresh, live one. Re-read and
|
|
112
|
+
// unlink only when the owner identity (pid + since) still matches what
|
|
113
|
+
// was classified — never unlink a lock we did not classify.
|
|
114
|
+
_testHooks?.onClassified?.();
|
|
115
|
+
if (!fs.existsSync(lockPath)) continue;
|
|
116
|
+
const current = readLock(lockPath);
|
|
117
|
+
const sameOwner = garbage
|
|
118
|
+
? current === null
|
|
119
|
+
: current !== null && current.pid === pid && current.since === heldSince;
|
|
120
|
+
if (!sameOwner) {
|
|
121
|
+
return {
|
|
122
|
+
acquired: false,
|
|
123
|
+
reason: 'lock changed while reclaiming — another writer got here first',
|
|
124
|
+
pid: current?.pid ?? pid,
|
|
125
|
+
heldSince: current?.since ?? heldSince,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
107
128
|
try {
|
|
108
129
|
fs.unlinkSync(lockPath);
|
|
109
130
|
} catch {
|
|
@@ -166,14 +187,18 @@ export function resolveSessionOptions(model?: ModelDescriptor): Record<string, u
|
|
|
166
187
|
const intra = envIntra ? parseInt(envIntra, 10) : undefined;
|
|
167
188
|
const inter = envInter ? parseInt(envInter, 10) : undefined;
|
|
168
189
|
const opts = model?.sessionOptions || {};
|
|
169
|
-
|
|
190
|
+
// issue #194: ONNX wasm sessions default to a single intra-op thread — the
|
|
191
|
+
// build/query sweep leaves most of the CPU idle. Default to the physical
|
|
192
|
+
// core count (measured 1.2x on a 4c/8t laptop; hyperthreads add contention,
|
|
193
|
+
// not throughput), still overridable via MDSS_INTRA_OP_THREADS.
|
|
194
|
+
const defaultIntra = Math.max(1, Math.min(4, os.cpus().length >> 1));
|
|
195
|
+
const intraOpNumThreads = Number.isSafeInteger(intra) && intra && intra > 0
|
|
196
|
+
? intra
|
|
197
|
+
: (opts.intraOpNumThreads as number | undefined) ?? defaultIntra;
|
|
170
198
|
const interOpNumThreads = Number.isSafeInteger(inter) && inter && inter > 0 ? inter : (opts.interOpNumThreads as number | undefined);
|
|
171
199
|
|
|
172
|
-
if (intraOpNumThreads === undefined && interOpNumThreads === undefined) {
|
|
173
|
-
return undefined;
|
|
174
|
-
}
|
|
175
200
|
const result: Record<string, unknown> = {};
|
|
176
|
-
|
|
201
|
+
result.intraOpNumThreads = intraOpNumThreads;
|
|
177
202
|
if (interOpNumThreads !== undefined) result.interOpNumThreads = interOpNumThreads;
|
|
178
203
|
return result;
|
|
179
204
|
}
|
|
@@ -188,6 +213,9 @@ export function getExtractorCacheKey(model: ModelDescriptor, cacheDir: string, o
|
|
|
188
213
|
|
|
189
214
|
export function isNetworkError(err: any): boolean {
|
|
190
215
|
if (!err) return false;
|
|
216
|
+
// issue #184: outbound fetch timeouts abort with TimeoutError/AbortError —
|
|
217
|
+
// they must classify as network errors so retries and fallbacks engage
|
|
218
|
+
if (err.name === 'TimeoutError' || err.name === 'AbortError') return true;
|
|
191
219
|
const code = err.code || err.cause?.code;
|
|
192
220
|
if (code && ['ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH', 'FETCH_ERROR'].includes(code)) {
|
|
193
221
|
return true;
|
|
@@ -201,12 +229,27 @@ export function isNetworkError(err: any): boolean {
|
|
|
201
229
|
msg.includes('etimedout') ||
|
|
202
230
|
msg.includes('enotfound') ||
|
|
203
231
|
msg.includes('network error') ||
|
|
232
|
+
msg.includes('aborted due to timeout') ||
|
|
204
233
|
msg.includes('503 service unavailable') ||
|
|
205
234
|
msg.includes('502 bad gateway') ||
|
|
206
235
|
msg.includes('504 gateway timeout')
|
|
207
236
|
);
|
|
208
237
|
}
|
|
209
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Outbound fetch timeout (issue #184): undici's default ~300s lets one hung
|
|
241
|
+
* embedder/LLM endpoint hold a serve concurrency slot or stall `mdss index`
|
|
242
|
+
* for minutes. All outbound calls carry `AbortSignal.timeout(fetchTimeoutMs())`;
|
|
243
|
+
* override with `MDSS_FETCH_TIMEOUT_MS` (0/negative falls back to the default).
|
|
244
|
+
*/
|
|
245
|
+
export const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
246
|
+
|
|
247
|
+
export function fetchTimeoutMs(envValue?: string): number {
|
|
248
|
+
const raw = envValue ?? process.env.MDSS_FETCH_TIMEOUT_MS;
|
|
249
|
+
const n = Number(raw);
|
|
250
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_FETCH_TIMEOUT_MS;
|
|
251
|
+
}
|
|
252
|
+
|
|
210
253
|
export async function retryWithBackoff<T>(
|
|
211
254
|
fn: () => Promise<T>,
|
|
212
255
|
opts: { maxRetries?: number; delays?: number[]; log?: (msg: string) => void } = {}
|
|
@@ -257,6 +300,44 @@ export async function getExtractor(
|
|
|
257
300
|
const key = getExtractorCacheKey(model, cacheDir, offline);
|
|
258
301
|
if (_extractors.has(key)) return _extractors.get(key);
|
|
259
302
|
|
|
303
|
+
// issue #200: adapters whose ONNX graph emits a pre-pooled
|
|
304
|
+
// `sentence_embedding` (EmbeddingGemma family) bypass the feature-extraction
|
|
305
|
+
// pipeline — it can only pool last_hidden_state/logits/token_embeddings and
|
|
306
|
+
// would crash on such graphs. The shim keeps the pipeline call signature so
|
|
307
|
+
// embed() is unchanged.
|
|
308
|
+
if (model.pooledOutput === true) {
|
|
309
|
+
const loadPooledModel = async () => {
|
|
310
|
+
const { AutoModel, AutoTokenizer, env } = await import('@huggingface/transformers');
|
|
311
|
+
if (cacheDir) env.cacheDir = cacheDir;
|
|
312
|
+
env.allowRemoteModels = !offline;
|
|
313
|
+
const sessionOptions = resolveSessionOptions(model);
|
|
314
|
+
const modelOpts: any = {
|
|
315
|
+
revision,
|
|
316
|
+
dtype: model.dtype || 'q8',
|
|
317
|
+
progress_callback: emitDownloadEvent,
|
|
318
|
+
...(sessionOptions ? { session_options: sessionOptions } : {}),
|
|
319
|
+
};
|
|
320
|
+
const tokenizer = await AutoTokenizer.from_pretrained(source, { revision });
|
|
321
|
+
const onnxModel = await AutoModel.from_pretrained(source, modelOpts);
|
|
322
|
+
const normalize = model.normalize !== false;
|
|
323
|
+
const ext = async (input: string | string[]) => {
|
|
324
|
+
const texts = Array.isArray(input) ? input : [input];
|
|
325
|
+
const modelInputs = await tokenizer(texts, { padding: true, truncation: true });
|
|
326
|
+
const outputs = await onnxModel(modelInputs);
|
|
327
|
+
let result = outputs.sentence_embedding;
|
|
328
|
+
if (!result) {
|
|
329
|
+
throw new Error(`model "${model.id}" declared pooledOutput but produced no sentence_embedding output`);
|
|
330
|
+
}
|
|
331
|
+
if (normalize) result = result.normalize(2, -1);
|
|
332
|
+
return { tolist: () => result.tolist() };
|
|
333
|
+
};
|
|
334
|
+
_extractors.set(key, ext);
|
|
335
|
+
return ext;
|
|
336
|
+
};
|
|
337
|
+
if (offline) return await loadPooledModel();
|
|
338
|
+
return await retryWithBackoff(loadPooledModel, retryOpts);
|
|
339
|
+
}
|
|
340
|
+
|
|
260
341
|
const loadPipeline = async () => {
|
|
261
342
|
const { pipeline, env } = await import('@huggingface/transformers');
|
|
262
343
|
if (cacheDir) env.cacheDir = cacheDir;
|
|
@@ -300,6 +381,30 @@ export function prepareEmbeddingRequest(
|
|
|
300
381
|
};
|
|
301
382
|
}
|
|
302
383
|
|
|
384
|
+
export function truncateAndNormalizeVector(
|
|
385
|
+
vec: number[] | Float32Array,
|
|
386
|
+
targetDim: number,
|
|
387
|
+
normalize: boolean = true
|
|
388
|
+
): number[] {
|
|
389
|
+
const len = Math.min(vec.length, targetDim);
|
|
390
|
+
const out = new Array<number>(len);
|
|
391
|
+
let norm = 0;
|
|
392
|
+
for (let i = 0; i < len; i++) {
|
|
393
|
+
const val = vec[i];
|
|
394
|
+
out[i] = val;
|
|
395
|
+
norm += val * val;
|
|
396
|
+
}
|
|
397
|
+
if (normalize) {
|
|
398
|
+
norm = Math.sqrt(norm);
|
|
399
|
+
if (norm > 0) {
|
|
400
|
+
for (let i = 0; i < len; i++) {
|
|
401
|
+
out[i] /= norm;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
303
408
|
export async function embed(
|
|
304
409
|
texts: string[],
|
|
305
410
|
kind: 'query' | 'passage',
|
|
@@ -311,7 +416,13 @@ export async function embed(
|
|
|
311
416
|
const ext = await getExtractor(model, cacheDir, offline, retryOpts);
|
|
312
417
|
const { input, options } = prepareEmbeddingRequest(model, texts, kind);
|
|
313
418
|
const out = await ext(input, options);
|
|
314
|
-
|
|
419
|
+
const list: number[][] = out.tolist();
|
|
420
|
+
const targetDim = model.dim;
|
|
421
|
+
if (targetDim && list.length > 0 && list[0].length > targetDim) {
|
|
422
|
+
const doNorm = model.normalize !== false;
|
|
423
|
+
return list.map((vec) => truncateAndNormalizeVector(vec, targetDim, doNorm));
|
|
424
|
+
}
|
|
425
|
+
return list;
|
|
315
426
|
}
|
|
316
427
|
|
|
317
428
|
export function cosine(
|
|
@@ -429,14 +540,20 @@ export const SCHEMA_MIGRATIONS: Record<number, (index: any) => void> = {
|
|
|
429
540
|
3: migrateToSchemaV3,
|
|
430
541
|
};
|
|
431
542
|
|
|
432
|
-
export function walkMarkdown(dir: string, ignore: string[] = []): string[] {
|
|
543
|
+
export function walkMarkdown(dir: string, ignore: string[] = [], errors?: string[]): string[] {
|
|
433
544
|
const out: string[] = [];
|
|
434
545
|
const ignoreRe = ignore.map(globToRegExp);
|
|
435
546
|
const walk = (cur: string) => {
|
|
436
547
|
let entries: fs.Dirent[];
|
|
437
548
|
try {
|
|
438
549
|
entries = fs.readdirSync(cur, { withFileTypes: true });
|
|
439
|
-
} catch {
|
|
550
|
+
} catch (e: any) {
|
|
551
|
+
// issue #189: an unreadable directory (permissions, OneDrive eviction)
|
|
552
|
+
// used to silently shrink the corpus; surface it when a collector is
|
|
553
|
+
// given, always at least as a stderr warning
|
|
554
|
+
const msg = `walkMarkdown: cannot read directory ${cur}: ${e?.message || e}`;
|
|
555
|
+
if (errors) errors.push(msg);
|
|
556
|
+
process.stderr.write(`warning: ${msg}\n`);
|
|
440
557
|
return;
|
|
441
558
|
}
|
|
442
559
|
for (const e of entries) {
|
|
@@ -542,8 +659,17 @@ export function extractTitle(frontmatter: string, body: string, relPath: string)
|
|
|
542
659
|
|
|
543
660
|
const DEFAULT_MAX_CHUNK = 1400;
|
|
544
661
|
|
|
545
|
-
export function chunkMarkdown(
|
|
546
|
-
|
|
662
|
+
export function chunkMarkdown(
|
|
663
|
+
body: string,
|
|
664
|
+
maxChunk: number = DEFAULT_MAX_CHUNK,
|
|
665
|
+
maxChunkTokens?: number
|
|
666
|
+
): StructuralChunk[] {
|
|
667
|
+
// issue #202: a token budget (maxChunkTokens) takes precedence over the
|
|
668
|
+
// legacy char budget; without it the pre-#202 char behavior is unchanged
|
|
669
|
+
return chunkMarkdownStructural(
|
|
670
|
+
body,
|
|
671
|
+
maxChunkTokens && maxChunkTokens > 0 ? { maxChunkTokens } : maxChunk
|
|
672
|
+
);
|
|
547
673
|
}
|
|
548
674
|
|
|
549
675
|
export interface ParsedChunkFile {
|
|
@@ -557,14 +683,21 @@ export interface ParsedChunkFile {
|
|
|
557
683
|
meta: DocumentMetadata;
|
|
558
684
|
}
|
|
559
685
|
|
|
560
|
-
export function parseFile(
|
|
686
|
+
export function parseFile(
|
|
687
|
+
absPath: string,
|
|
688
|
+
dbDir: string,
|
|
689
|
+
maxChunk?: number,
|
|
690
|
+
raw?: string,
|
|
691
|
+
/** issue #202: token budget overrides the char budget when set. */
|
|
692
|
+
maxChunkTokens?: number
|
|
693
|
+
): ParsedChunkFile[] {
|
|
561
694
|
const content = raw ?? fs.readFileSync(absPath, 'utf8');
|
|
562
695
|
const { frontmatter, body } = splitFrontmatter(content);
|
|
563
696
|
const meta = parseFrontmatter(frontmatter);
|
|
564
697
|
const rel = path.relative(dbDir, absPath).split(path.sep).join('/');
|
|
565
698
|
const title = meta.title || extractTitle(frontmatter, body, rel);
|
|
566
699
|
const frontmatterLineCount = frontmatter ? frontmatter.split('\n').length + 2 : 0;
|
|
567
|
-
return chunkMarkdown(body, maxChunk).map((c) => {
|
|
700
|
+
return chunkMarkdown(body, maxChunk, maxChunkTokens).map((c) => {
|
|
568
701
|
const heading = c.heading || title;
|
|
569
702
|
return {
|
|
570
703
|
file: rel,
|
|
@@ -578,3 +711,106 @@ export function parseFile(absPath: string, dbDir: string, maxChunk?: number, raw
|
|
|
578
711
|
};
|
|
579
712
|
});
|
|
580
713
|
}
|
|
714
|
+
|
|
715
|
+
export interface GetDocLinesOptions {
|
|
716
|
+
file: string;
|
|
717
|
+
fromLine?: number;
|
|
718
|
+
maxLines?: number;
|
|
719
|
+
dbDir?: string;
|
|
720
|
+
chunks?: Array<{ file: string; text: string; startLine?: number; endLine?: number }>;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export interface GetDocLinesResult {
|
|
724
|
+
file: string;
|
|
725
|
+
fromLine: number;
|
|
726
|
+
toLine: number;
|
|
727
|
+
lineCount: number;
|
|
728
|
+
totalLines: number;
|
|
729
|
+
text: string;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Line-range retrieval helper (issue #140).
|
|
734
|
+
* Retrieves an exact line span from disk or fallback chunk data with EOF clamping.
|
|
735
|
+
*/
|
|
736
|
+
export function getDocLines(opts: GetDocLinesOptions): GetDocLinesResult {
|
|
737
|
+
const { file, fromLine = 1, maxLines, dbDir, chunks } = opts;
|
|
738
|
+
if (!file || typeof file !== 'string') {
|
|
739
|
+
throw new Error('getDocLines: "file" parameter is required');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const normalizedFile = file.split(/[/\\]/).join('/');
|
|
743
|
+
let rawContent: string | null = null;
|
|
744
|
+
|
|
745
|
+
if (dbDir) {
|
|
746
|
+
const candidatePath = path.resolve(dbDir, file);
|
|
747
|
+
try {
|
|
748
|
+
assertSafePath(candidatePath, [dbDir]);
|
|
749
|
+
if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile()) {
|
|
750
|
+
rawContent = fs.readFileSync(candidatePath, 'utf8');
|
|
751
|
+
}
|
|
752
|
+
} catch (e: any) {
|
|
753
|
+
if (e.message?.includes('path traversal guard')) throw e;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (rawContent === null && Array.isArray(chunks)) {
|
|
758
|
+
const matchingChunks = chunks.filter(
|
|
759
|
+
(c) => c.file === file || c.file === normalizedFile || c.file.split(/[/\\]/).join('/') === normalizedFile
|
|
760
|
+
);
|
|
761
|
+
if (matchingChunks.length > 0) {
|
|
762
|
+
matchingChunks.sort((a, b) => (a.startLine || 0) - (b.startLine || 0));
|
|
763
|
+
const maxEnd = Math.max(...matchingChunks.map((c) => c.endLine || c.startLine || 1));
|
|
764
|
+
const lineMap = new Array(maxEnd).fill('');
|
|
765
|
+
for (const ch of matchingChunks) {
|
|
766
|
+
const start = (ch.startLine || 1) - 1;
|
|
767
|
+
const chunkLines = (ch.text || '').split(/\r?\n/);
|
|
768
|
+
for (let i = 0; i < chunkLines.length; i++) {
|
|
769
|
+
if (start + i < lineMap.length) {
|
|
770
|
+
lineMap[start + i] = chunkLines[i];
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
rawContent = lineMap.join('\n');
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (rawContent === null) {
|
|
779
|
+
throw new Error(`File not found: ${file}`);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const lines = rawContent.split(/\r?\n/);
|
|
783
|
+
const totalLines = lines.length;
|
|
784
|
+
|
|
785
|
+
let from = typeof fromLine === 'number' && !isNaN(fromLine) ? Math.floor(fromLine) : 1;
|
|
786
|
+
if (from < 1) from = 1;
|
|
787
|
+
|
|
788
|
+
let selected: string[];
|
|
789
|
+
let to: number;
|
|
790
|
+
|
|
791
|
+
if (totalLines === 0) {
|
|
792
|
+
from = 1;
|
|
793
|
+
to = 0;
|
|
794
|
+
selected = [];
|
|
795
|
+
} else if (from > totalLines) {
|
|
796
|
+
from = totalLines;
|
|
797
|
+
to = totalLines;
|
|
798
|
+
selected = [lines[totalLines - 1]];
|
|
799
|
+
} else {
|
|
800
|
+
const count = typeof maxLines === 'number' && !isNaN(maxLines) && maxLines > 0
|
|
801
|
+
? Math.min(Math.floor(maxLines), totalLines - from + 1)
|
|
802
|
+
: totalLines - from + 1;
|
|
803
|
+
to = from + count - 1;
|
|
804
|
+
selected = lines.slice(from - 1, to);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
file: normalizedFile,
|
|
809
|
+
fromLine: from,
|
|
810
|
+
toLine: to,
|
|
811
|
+
lineCount: selected.length,
|
|
812
|
+
totalLines,
|
|
813
|
+
text: selected.join('\n'),
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
package/src/federation.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import { loadIndex, searchIndex, LoadedIndex, SearchOptions, SearchResultHit } from './search.js';
|
|
11
|
+
import { assertSafePath } from './core.js';
|
|
11
12
|
|
|
12
13
|
export interface VaultConfig {
|
|
13
14
|
name?: string;
|
|
@@ -18,6 +19,13 @@ export interface VaultConfig {
|
|
|
18
19
|
export interface FederatedSearchOptions extends Omit<SearchOptions, 'loaded' | 'cacheDir'> {
|
|
19
20
|
vaults: Array<string | VaultConfig>;
|
|
20
21
|
cacheDir?: string;
|
|
22
|
+
/**
|
|
23
|
+
* issue #179: when set, every vault path (and explicit indexDir) must resolve
|
|
24
|
+
* inside one of these roots or a traversal-guard error is thrown. Callers
|
|
25
|
+
* exposing federation to remote clients (MCP) must pin this to their trust
|
|
26
|
+
* domain; direct CLI use stays guarded only by MDSS_ROOT_GUARD.
|
|
27
|
+
*/
|
|
28
|
+
allowedRoots?: string[];
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
export interface FederatedSearchResultHit extends SearchResultHit {
|
|
@@ -25,21 +33,32 @@ export interface FederatedSearchResultHit extends SearchResultHit {
|
|
|
25
33
|
vaultPath: string;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
export
|
|
29
|
-
|
|
36
|
+
export interface FederatedSearchResult {
|
|
37
|
+
hits: FederatedSearchResultHit[];
|
|
38
|
+
/**
|
|
39
|
+
* issue #189: per-vault load/query failures surfaced to the caller — a
|
|
40
|
+
* broken vault must not silently look like "0 results".
|
|
41
|
+
*/
|
|
42
|
+
vaultErrors: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function searchFederated(opts: FederatedSearchOptions): Promise<FederatedSearchResult> {
|
|
46
|
+
const { vaults, k = 10, cacheDir = '', allowedRoots, ...searchOpts } = opts;
|
|
30
47
|
if (!vaults || vaults.length === 0) {
|
|
31
48
|
throw new Error('Federated search requires at least one vault specified');
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
const normalizedVaults: VaultConfig[] = vaults.map((v) => {
|
|
35
52
|
if (typeof v === 'string') {
|
|
36
|
-
const absPath = path.resolve(v);
|
|
53
|
+
const absPath = assertSafePath(path.resolve(v), allowedRoots);
|
|
37
54
|
const name = path.basename(absPath);
|
|
38
55
|
return { name, path: absPath };
|
|
39
56
|
}
|
|
40
|
-
const absPath = path.resolve(v.path);
|
|
57
|
+
const absPath = assertSafePath(path.resolve(v.path), allowedRoots);
|
|
41
58
|
const name = v.name || path.basename(absPath);
|
|
42
|
-
|
|
59
|
+
// indexDir is loaded from disk too — guard it with the same roots (#179)
|
|
60
|
+
const indexDir = v.indexDir ? assertSafePath(path.resolve(v.indexDir), allowedRoots) : undefined;
|
|
61
|
+
return { name, path: absPath, indexDir };
|
|
43
62
|
});
|
|
44
63
|
|
|
45
64
|
// Load and query each vault concurrently
|
|
@@ -60,7 +79,10 @@ export async function searchFederated(opts: FederatedSearchOptions): Promise<Fed
|
|
|
60
79
|
try {
|
|
61
80
|
// Query vault index with a slightly larger pool to ensure healthy global fusion
|
|
62
81
|
const poolK = Math.max(k * 2, 20);
|
|
63
|
-
|
|
82
|
+
// issue #179: never invent a per-vault cache location — a falsy cacheDir
|
|
83
|
+
// disables the disk layer (searchIndex semantics), so query embeddings
|
|
84
|
+
// are only ever written to a cache dir the caller explicitly owns.
|
|
85
|
+
const resolvedCache = cacheDir;
|
|
64
86
|
const hits = await searchIndex({
|
|
65
87
|
...searchOpts,
|
|
66
88
|
cacheDir: resolvedCache,
|
|
@@ -74,14 +96,23 @@ export async function searchFederated(opts: FederatedSearchOptions): Promise<Fed
|
|
|
74
96
|
})
|
|
75
97
|
);
|
|
76
98
|
|
|
99
|
+
// issue #189: surface per-vault failures — a broken vault must not look
|
|
100
|
+
// like a vault with simply no matching notes
|
|
101
|
+
const vaultErrors = perVaultResults
|
|
102
|
+
.filter((r): r is { vault: VaultConfig; hits: SearchResultHit[]; error: string } => r.error !== null)
|
|
103
|
+
.map((r) => `vault "${r.vault.name || path.basename(r.vault.path)}" (${r.vault.path}): ${r.error}`);
|
|
104
|
+
|
|
77
105
|
// If single vault, return directly with attribution
|
|
78
106
|
if (normalizedVaults.length === 1) {
|
|
79
107
|
const { vault, hits } = perVaultResults[0];
|
|
80
|
-
return
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
return {
|
|
109
|
+
hits: hits.slice(0, k).map((h) => ({
|
|
110
|
+
...h,
|
|
111
|
+
vault: vault.name || path.basename(vault.path),
|
|
112
|
+
vaultPath: vault.path,
|
|
113
|
+
})),
|
|
114
|
+
vaultErrors,
|
|
115
|
+
};
|
|
85
116
|
}
|
|
86
117
|
|
|
87
118
|
// Multi-vault reciprocal rank fusion (RRF)
|
|
@@ -109,10 +140,13 @@ export async function searchFederated(opts: FederatedSearchOptions): Promise<Fed
|
|
|
109
140
|
})
|
|
110
141
|
.slice(0, k);
|
|
111
142
|
|
|
112
|
-
return
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
143
|
+
return {
|
|
144
|
+
hits: fused.map(({ hit, vault, rrfSum }) => ({
|
|
145
|
+
...hit,
|
|
146
|
+
score: rrfSum,
|
|
147
|
+
vault: vault.name || path.basename(vault.path),
|
|
148
|
+
vaultPath: vault.path,
|
|
149
|
+
})),
|
|
150
|
+
vaultErrors,
|
|
151
|
+
};
|
|
118
152
|
}
|
package/src/filter.ts
CHANGED
|
@@ -97,6 +97,14 @@ export function tokenizeFilter(expr: string): string[] {
|
|
|
97
97
|
return tokens;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* issue #180: parseOr/parseAnd/parseNot/parsePrimary are mutually recursive
|
|
102
|
+
* (~4 stack frames per nesting level) — an unbounded `(((((…` from a 64 KiB
|
|
103
|
+
* request body would exhaust the call stack. 64 levels is far beyond any
|
|
104
|
+
* legitimate filter and keeps the parser fail-closed with a clear error.
|
|
105
|
+
*/
|
|
106
|
+
export const MAX_FILTER_DEPTH = 64;
|
|
107
|
+
|
|
100
108
|
export function parseFilter(expr: string): FilterNode {
|
|
101
109
|
const tokens = tokenizeFilter(expr);
|
|
102
110
|
if (tokens.length === 0) {
|
|
@@ -104,6 +112,7 @@ export function parseFilter(expr: string): FilterNode {
|
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
let pos = 0;
|
|
115
|
+
let depth = 0;
|
|
107
116
|
|
|
108
117
|
function peek(): string | undefined {
|
|
109
118
|
return tokens[pos];
|
|
@@ -167,12 +176,17 @@ export function parseFilter(expr: string): FilterNode {
|
|
|
167
176
|
}
|
|
168
177
|
|
|
169
178
|
if (token === '(') {
|
|
179
|
+
depth += 1;
|
|
180
|
+
if (depth > MAX_FILTER_DEPTH) {
|
|
181
|
+
throw new Error(`Filter expression too deeply nested (max ${MAX_FILTER_DEPTH} levels of parentheses)`);
|
|
182
|
+
}
|
|
170
183
|
consume(); // (
|
|
171
184
|
const node = parseExpression();
|
|
172
185
|
if (peek() !== ')') {
|
|
173
186
|
throw new Error('Expected closing parenthesis ")" in filter expression');
|
|
174
187
|
}
|
|
175
188
|
consume(); // )
|
|
189
|
+
depth -= 1;
|
|
176
190
|
return node;
|
|
177
191
|
}
|
|
178
192
|
|