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.
Files changed (75) hide show
  1. package/README.md +132 -16
  2. package/RESEARCH.md +207 -3
  3. package/bin/cli.mjs +449 -13
  4. package/dist/binary-format.js +9 -0
  5. package/dist/completions.js +11 -1
  6. package/dist/core.d.ts +42 -4
  7. package/dist/core.js +208 -14
  8. package/dist/federation.d.ts +16 -1
  9. package/dist/federation.js +33 -16
  10. package/dist/filter.d.ts +7 -0
  11. package/dist/filter.js +13 -0
  12. package/dist/hnsw.d.ts +96 -0
  13. package/dist/hnsw.js +326 -0
  14. package/dist/index-format.js +8 -1
  15. package/dist/index.d.ts +4 -4
  16. package/dist/index.js +4 -4
  17. package/dist/indexer.d.ts +18 -1
  18. package/dist/indexer.js +92 -18
  19. package/dist/ivf.d.ts +14 -7
  20. package/dist/ivf.js +126 -18
  21. package/dist/lexical.d.ts +10 -0
  22. package/dist/lexical.js +98 -15
  23. package/dist/markdown-parser.d.ts +62 -0
  24. package/dist/markdown-parser.js +227 -30
  25. package/dist/mcp.d.ts +229 -7
  26. package/dist/mcp.js +743 -44
  27. package/dist/models.d.ts +8 -2
  28. package/dist/models.js +84 -32
  29. package/dist/providers.d.ts +56 -0
  30. package/dist/providers.js +305 -0
  31. package/dist/quantization.d.ts +12 -0
  32. package/dist/quantization.js +54 -0
  33. package/dist/rag.d.ts +132 -7
  34. package/dist/rag.js +314 -38
  35. package/dist/repair.d.ts +1 -1
  36. package/dist/repair.js +53 -2
  37. package/dist/rerank.d.ts +51 -6
  38. package/dist/rerank.js +69 -10
  39. package/dist/search.d.ts +49 -3
  40. package/dist/search.js +387 -78
  41. package/dist/serve.d.ts +35 -0
  42. package/dist/serve.js +574 -58
  43. package/dist/tui.d.ts +14 -0
  44. package/dist/tui.js +132 -15
  45. package/dist/watcher.js +23 -12
  46. package/dist/webui.d.ts +3 -3
  47. package/dist/webui.js +225 -5
  48. package/dist/wikilinks.d.ts +31 -0
  49. package/dist/wikilinks.js +185 -1
  50. package/package.json +3 -3
  51. package/src/binary-format.ts +13 -0
  52. package/src/completions.ts +11 -1
  53. package/src/core.ts +249 -13
  54. package/src/federation.ts +51 -17
  55. package/src/filter.ts +14 -0
  56. package/src/hnsw.ts +414 -0
  57. package/src/index-format.ts +7 -1
  58. package/src/index.ts +7 -2
  59. package/src/indexer.ts +112 -19
  60. package/src/ivf.ts +157 -20
  61. package/src/lexical.ts +91 -13
  62. package/src/markdown-parser.ts +279 -37
  63. package/src/mcp.ts +820 -47
  64. package/src/models.ts +97 -35
  65. package/src/providers.ts +345 -0
  66. package/src/quantization.ts +61 -0
  67. package/src/rag.ts +451 -34
  68. package/src/repair.ts +51 -2
  69. package/src/rerank.ts +121 -14
  70. package/src/search.ts +424 -82
  71. package/src/serve.ts +589 -66
  72. package/src/tui.ts +142 -16
  73. package/src/watcher.ts +21 -10
  74. package/src/webui.ts +225 -5
  75. 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(indexDir: string): LockAcquireResult {
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
- const intraOpNumThreads = Number.isSafeInteger(intra) && intra && intra > 0 ? intra : (opts.intraOpNumThreads as number | undefined);
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
- if (intraOpNumThreads !== undefined) result.intraOpNumThreads = intraOpNumThreads;
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
- return out.tolist();
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(body: string, maxChunk: number = DEFAULT_MAX_CHUNK): StructuralChunk[] {
546
- return chunkMarkdownStructural(body, maxChunk);
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(absPath: string, dbDir: string, maxChunk?: number, raw?: string): ParsedChunkFile[] {
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 async function searchFederated(opts: FederatedSearchOptions): Promise<FederatedSearchResultHit[]> {
29
- const { vaults, k = 10, cacheDir = '', ...searchOpts } = opts;
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
- return { name, path: absPath, indexDir: v.indexDir };
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
- const resolvedCache = cacheDir || path.join(vault.path, '.cache');
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 hits.slice(0, k).map((h) => ({
81
- ...h,
82
- vault: vault.name || path.basename(vault.path),
83
- vaultPath: vault.path,
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 fused.map(({ hit, vault, rrfSum }) => ({
113
- ...hit,
114
- score: rrfSum,
115
- vault: vault.name || path.basename(vault.path),
116
- vaultPath: vault.path,
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