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
@@ -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));
@@ -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' },
@@ -54,6 +58,7 @@ export const FLAGS = [
54
58
  { name: '--index-dir', kind: 'dir' },
55
59
  { name: '--cache-dir', kind: 'dir' },
56
60
  { name: '--model', kind: 'value' },
61
+ { name: '--dimensions', alias: '--dim', kind: 'number' },
57
62
  { name: '--k', kind: 'number' },
58
63
  { name: '--max-per-file', kind: 'number' },
59
64
  { name: '--max-per-doc', kind: 'number' },
@@ -79,13 +84,18 @@ export const FLAGS = [
79
84
  { name: '--graph-boost', kind: 'number' },
80
85
  { name: '--filter', kind: 'value' },
81
86
  { name: '--quantize', kind: 'value' },
87
+ { name: '--direction', kind: 'value' },
88
+ { name: '--no-semantic', kind: 'flag' },
89
+ { name: '--session', kind: 'value' },
90
+ { name: '--resume', kind: 'flag' },
82
91
  { name: '--llm-endpoint', kind: 'value' },
83
92
  { name: '--llm-model', kind: 'value' },
93
+ { name: '--llm-provider', kind: 'value' },
84
94
  { name: '--system-prompt', kind: 'value' },
85
95
  ];
86
96
  export const COMMANDS = [
87
97
  'index', 'stats', 'check', 'doctor', 'export', 'search',
88
- 'ask', 'tui', 'serve', 'mcp', 'models', 'completions',
98
+ 'ask', 'chat', 'tui', 'serve', 'mcp', 'models', 'completions', 'related',
89
99
  ];
90
100
  export const COMPLETION_SHELLS = ['bash', 'zsh', 'fish', 'powershell'];
91
101
  const DIR_FLAGS = [...new Set(FLAGS.filter((f) => f.kind === 'dir').flatMap((f) => [f.name, ...(f.alias && !f.alias.startsWith('--') ? [f.alias] : [])]))];
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): LockAcquireResult;
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[];
@@ -48,6 +58,7 @@ export declare function prepareEmbeddingRequest(model: ModelDescriptor, texts: s
48
58
  normalize: boolean;
49
59
  };
50
60
  };
61
+ export declare function truncateAndNormalizeVector(vec: number[] | Float32Array, targetDim: number, normalize?: boolean): number[];
51
62
  export declare function embed(texts: string[], kind: 'query' | 'passage', model: ModelDescriptor, cacheDir: string, offline?: boolean, retryOpts?: {
52
63
  maxRetries?: number;
53
64
  delays?: number[];
@@ -67,7 +78,7 @@ export declare function migrateToSchemaV2(index: {
67
78
  }): void;
68
79
  export declare function migrateToSchemaV3(): void;
69
80
  export declare const SCHEMA_MIGRATIONS: Record<number, (index: any) => void>;
70
- export declare function walkMarkdown(dir: string, ignore?: string[]): string[];
81
+ export declare function walkMarkdown(dir: string, ignore?: string[], errors?: string[]): string[];
71
82
  export declare function assertSafePath(targetPath: string, allowedRoots?: string[]): string;
72
83
  export declare function validateGlob(glob: string): string;
73
84
  export declare function globToRegExp(glob: string): RegExp;
@@ -76,7 +87,7 @@ export declare function splitFrontmatter(raw: string): {
76
87
  body: string;
77
88
  };
78
89
  export declare function extractTitle(frontmatter: string, body: string, relPath: string): string;
79
- export declare function chunkMarkdown(body: string, maxChunk?: number): StructuralChunk[];
90
+ export declare function chunkMarkdown(body: string, maxChunk?: number, maxChunkTokens?: number): StructuralChunk[];
80
91
  export interface ParsedChunkFile {
81
92
  file: string;
82
93
  title: string;
@@ -87,4 +98,31 @@ export interface ParsedChunkFile {
87
98
  endLine: number;
88
99
  meta: DocumentMetadata;
89
100
  }
90
- export declare function parseFile(absPath: string, dbDir: string, maxChunk?: number, raw?: string): ParsedChunkFile[];
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[];
104
+ export interface GetDocLinesOptions {
105
+ file: string;
106
+ fromLine?: number;
107
+ maxLines?: number;
108
+ dbDir?: string;
109
+ chunks?: Array<{
110
+ file: string;
111
+ text: string;
112
+ startLine?: number;
113
+ endLine?: number;
114
+ }>;
115
+ }
116
+ export interface GetDocLinesResult {
117
+ file: string;
118
+ fromLine: number;
119
+ toLine: number;
120
+ lineCount: number;
121
+ totalLines: number;
122
+ text: string;
123
+ }
124
+ /**
125
+ * Line-range retrieval helper (issue #140).
126
+ * Retrieves an exact line span from disk or fallback chunk data with EOF clamping.
127
+ */
128
+ export declare function getDocLines(opts: GetDocLinesOptions): GetDocLinesResult;
package/dist/core.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import os from 'node:os';
8
9
  import { resolveModel } from './models.js';
9
10
  import { chunkMarkdownStructural, PARSER_VERSION } from './markdown-parser.js';
10
11
  import { parseFrontmatter } from './frontmatter.js';
@@ -58,7 +59,7 @@ function readLock(lockPath) {
58
59
  return null;
59
60
  }
60
61
  }
61
- export function acquireIndexLock(indexDir) {
62
+ export function acquireIndexLock(indexDir, _testHooks) {
62
63
  const lockPath = path.join(indexDir, LOCK_FILENAME);
63
64
  const payload = JSON.stringify({ pid: process.pid, since: new Date().toISOString() }) + '\n';
64
65
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -86,6 +87,25 @@ export function acquireIndexLock(indexDir) {
86
87
  const dead = pid !== null && !pidAlive(pid);
87
88
  const staleMs = stat ? Date.now() - stat.mtimeMs : 0;
88
89
  if ((garbage || dead || staleMs > LOCK_STALE_MS) && stat) {
90
+ // issue #188: between the stat/read above and the unlink below another
91
+ // writer may reclaim the lock and write a fresh, live one. Re-read and
92
+ // unlink only when the owner identity (pid + since) still matches what
93
+ // was classified — never unlink a lock we did not classify.
94
+ _testHooks?.onClassified?.();
95
+ if (!fs.existsSync(lockPath))
96
+ continue;
97
+ const current = readLock(lockPath);
98
+ const sameOwner = garbage
99
+ ? current === null
100
+ : current !== null && current.pid === pid && current.since === heldSince;
101
+ if (!sameOwner) {
102
+ return {
103
+ acquired: false,
104
+ reason: 'lock changed while reclaiming — another writer got here first',
105
+ pid: current?.pid ?? pid,
106
+ heldSince: current?.since ?? heldSince,
107
+ };
108
+ }
89
109
  try {
90
110
  fs.unlinkSync(lockPath);
91
111
  }
@@ -148,14 +168,17 @@ export function resolveSessionOptions(model) {
148
168
  const intra = envIntra ? parseInt(envIntra, 10) : undefined;
149
169
  const inter = envInter ? parseInt(envInter, 10) : undefined;
150
170
  const opts = model?.sessionOptions || {};
151
- const intraOpNumThreads = Number.isSafeInteger(intra) && intra && intra > 0 ? intra : opts.intraOpNumThreads;
171
+ // issue #194: ONNX wasm sessions default to a single intra-op thread — the
172
+ // build/query sweep leaves most of the CPU idle. Default to the physical
173
+ // core count (measured 1.2x on a 4c/8t laptop; hyperthreads add contention,
174
+ // not throughput), still overridable via MDSS_INTRA_OP_THREADS.
175
+ const defaultIntra = Math.max(1, Math.min(4, os.cpus().length >> 1));
176
+ const intraOpNumThreads = Number.isSafeInteger(intra) && intra && intra > 0
177
+ ? intra
178
+ : opts.intraOpNumThreads ?? defaultIntra;
152
179
  const interOpNumThreads = Number.isSafeInteger(inter) && inter && inter > 0 ? inter : opts.interOpNumThreads;
153
- if (intraOpNumThreads === undefined && interOpNumThreads === undefined) {
154
- return undefined;
155
- }
156
180
  const result = {};
157
- if (intraOpNumThreads !== undefined)
158
- result.intraOpNumThreads = intraOpNumThreads;
181
+ result.intraOpNumThreads = intraOpNumThreads;
159
182
  if (interOpNumThreads !== undefined)
160
183
  result.interOpNumThreads = interOpNumThreads;
161
184
  return result;
@@ -170,6 +193,10 @@ export function getExtractorCacheKey(model, cacheDir, offline) {
170
193
  export function isNetworkError(err) {
171
194
  if (!err)
172
195
  return false;
196
+ // issue #184: outbound fetch timeouts abort with TimeoutError/AbortError —
197
+ // they must classify as network errors so retries and fallbacks engage
198
+ if (err.name === 'TimeoutError' || err.name === 'AbortError')
199
+ return true;
173
200
  const code = err.code || err.cause?.code;
174
201
  if (code && ['ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH', 'FETCH_ERROR'].includes(code)) {
175
202
  return true;
@@ -184,10 +211,23 @@ export function isNetworkError(err) {
184
211
  msg.includes('etimedout') ||
185
212
  msg.includes('enotfound') ||
186
213
  msg.includes('network error') ||
214
+ msg.includes('aborted due to timeout') ||
187
215
  msg.includes('503 service unavailable') ||
188
216
  msg.includes('502 bad gateway') ||
189
217
  msg.includes('504 gateway timeout'));
190
218
  }
219
+ /**
220
+ * Outbound fetch timeout (issue #184): undici's default ~300s lets one hung
221
+ * embedder/LLM endpoint hold a serve concurrency slot or stall `mdss index`
222
+ * for minutes. All outbound calls carry `AbortSignal.timeout(fetchTimeoutMs())`;
223
+ * override with `MDSS_FETCH_TIMEOUT_MS` (0/negative falls back to the default).
224
+ */
225
+ export const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
226
+ export function fetchTimeoutMs(envValue) {
227
+ const raw = envValue ?? process.env.MDSS_FETCH_TIMEOUT_MS;
228
+ const n = Number(raw);
229
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_FETCH_TIMEOUT_MS;
230
+ }
191
231
  export async function retryWithBackoff(fn, opts = {}) {
192
232
  const maxRetries = Number.isInteger(opts.maxRetries) && opts.maxRetries >= 0 ? opts.maxRetries : 3;
193
233
  const delays = opts.delays || [1000, 4000, 16000];
@@ -224,6 +264,46 @@ export async function getExtractor(model, cacheDir, offline = false, retryOpts =
224
264
  const key = getExtractorCacheKey(model, cacheDir, offline);
225
265
  if (_extractors.has(key))
226
266
  return _extractors.get(key);
267
+ // issue #200: adapters whose ONNX graph emits a pre-pooled
268
+ // `sentence_embedding` (EmbeddingGemma family) bypass the feature-extraction
269
+ // pipeline — it can only pool last_hidden_state/logits/token_embeddings and
270
+ // would crash on such graphs. The shim keeps the pipeline call signature so
271
+ // embed() is unchanged.
272
+ if (model.pooledOutput === true) {
273
+ const loadPooledModel = async () => {
274
+ const { AutoModel, AutoTokenizer, env } = await import('@huggingface/transformers');
275
+ if (cacheDir)
276
+ env.cacheDir = cacheDir;
277
+ env.allowRemoteModels = !offline;
278
+ const sessionOptions = resolveSessionOptions(model);
279
+ const modelOpts = {
280
+ revision,
281
+ dtype: model.dtype || 'q8',
282
+ progress_callback: emitDownloadEvent,
283
+ ...(sessionOptions ? { session_options: sessionOptions } : {}),
284
+ };
285
+ const tokenizer = await AutoTokenizer.from_pretrained(source, { revision });
286
+ const onnxModel = await AutoModel.from_pretrained(source, modelOpts);
287
+ const normalize = model.normalize !== false;
288
+ const ext = async (input) => {
289
+ const texts = Array.isArray(input) ? input : [input];
290
+ const modelInputs = await tokenizer(texts, { padding: true, truncation: true });
291
+ const outputs = await onnxModel(modelInputs);
292
+ let result = outputs.sentence_embedding;
293
+ if (!result) {
294
+ throw new Error(`model "${model.id}" declared pooledOutput but produced no sentence_embedding output`);
295
+ }
296
+ if (normalize)
297
+ result = result.normalize(2, -1);
298
+ return { tolist: () => result.tolist() };
299
+ };
300
+ _extractors.set(key, ext);
301
+ return ext;
302
+ };
303
+ if (offline)
304
+ return await loadPooledModel();
305
+ return await retryWithBackoff(loadPooledModel, retryOpts);
306
+ }
227
307
  const loadPipeline = async () => {
228
308
  const { pipeline, env } = await import('@huggingface/transformers');
229
309
  if (cacheDir)
@@ -258,11 +338,36 @@ export function prepareEmbeddingRequest(model, texts, kind) {
258
338
  options: { pooling: model.pooling ?? 'mean', normalize: model.normalize !== false },
259
339
  };
260
340
  }
341
+ export function truncateAndNormalizeVector(vec, targetDim, normalize = true) {
342
+ const len = Math.min(vec.length, targetDim);
343
+ const out = new Array(len);
344
+ let norm = 0;
345
+ for (let i = 0; i < len; i++) {
346
+ const val = vec[i];
347
+ out[i] = val;
348
+ norm += val * val;
349
+ }
350
+ if (normalize) {
351
+ norm = Math.sqrt(norm);
352
+ if (norm > 0) {
353
+ for (let i = 0; i < len; i++) {
354
+ out[i] /= norm;
355
+ }
356
+ }
357
+ }
358
+ return out;
359
+ }
261
360
  export async function embed(texts, kind, model, cacheDir, offline = false, retryOpts = {}) {
262
361
  const ext = await getExtractor(model, cacheDir, offline, retryOpts);
263
362
  const { input, options } = prepareEmbeddingRequest(model, texts, kind);
264
363
  const out = await ext(input, options);
265
- return out.tolist();
364
+ const list = out.tolist();
365
+ const targetDim = model.dim;
366
+ if (targetDim && list.length > 0 && list[0].length > targetDim) {
367
+ const doNorm = model.normalize !== false;
368
+ return list.map((vec) => truncateAndNormalizeVector(vec, targetDim, doNorm));
369
+ }
370
+ return list;
266
371
  }
267
372
  export function cosine(a, b, bOffset = 0) {
268
373
  if (b instanceof Int8Array) {
@@ -355,7 +460,7 @@ export const SCHEMA_MIGRATIONS = {
355
460
  2: migrateToSchemaV2,
356
461
  3: migrateToSchemaV3,
357
462
  };
358
- export function walkMarkdown(dir, ignore = []) {
463
+ export function walkMarkdown(dir, ignore = [], errors) {
359
464
  const out = [];
360
465
  const ignoreRe = ignore.map(globToRegExp);
361
466
  const walk = (cur) => {
@@ -363,7 +468,14 @@ export function walkMarkdown(dir, ignore = []) {
363
468
  try {
364
469
  entries = fs.readdirSync(cur, { withFileTypes: true });
365
470
  }
366
- catch {
471
+ catch (e) {
472
+ // issue #189: an unreadable directory (permissions, OneDrive eviction)
473
+ // used to silently shrink the corpus; surface it when a collector is
474
+ // given, always at least as a stderr warning
475
+ const msg = `walkMarkdown: cannot read directory ${cur}: ${e?.message || e}`;
476
+ if (errors)
477
+ errors.push(msg);
478
+ process.stderr.write(`warning: ${msg}\n`);
367
479
  return;
368
480
  }
369
481
  for (const e of entries) {
@@ -465,17 +577,21 @@ export function extractTitle(frontmatter, body, relPath) {
465
577
  return path.basename(relPath).replace(/\.(md|markdown)$/i, '');
466
578
  }
467
579
  const DEFAULT_MAX_CHUNK = 1400;
468
- export function chunkMarkdown(body, maxChunk = DEFAULT_MAX_CHUNK) {
469
- return chunkMarkdownStructural(body, maxChunk);
580
+ export function chunkMarkdown(body, maxChunk = DEFAULT_MAX_CHUNK, maxChunkTokens) {
581
+ // issue #202: a token budget (maxChunkTokens) takes precedence over the
582
+ // legacy char budget; without it the pre-#202 char behavior is unchanged
583
+ return chunkMarkdownStructural(body, maxChunkTokens && maxChunkTokens > 0 ? { maxChunkTokens } : maxChunk);
470
584
  }
471
- export function parseFile(absPath, dbDir, maxChunk, raw) {
585
+ export function parseFile(absPath, dbDir, maxChunk, raw,
586
+ /** issue #202: token budget overrides the char budget when set. */
587
+ maxChunkTokens) {
472
588
  const content = raw ?? fs.readFileSync(absPath, 'utf8');
473
589
  const { frontmatter, body } = splitFrontmatter(content);
474
590
  const meta = parseFrontmatter(frontmatter);
475
591
  const rel = path.relative(dbDir, absPath).split(path.sep).join('/');
476
592
  const title = meta.title || extractTitle(frontmatter, body, rel);
477
593
  const frontmatterLineCount = frontmatter ? frontmatter.split('\n').length + 2 : 0;
478
- return chunkMarkdown(body, maxChunk).map((c) => {
594
+ return chunkMarkdown(body, maxChunk, maxChunkTokens).map((c) => {
479
595
  const heading = c.heading || title;
480
596
  return {
481
597
  file: rel,
@@ -489,3 +605,81 @@ export function parseFile(absPath, dbDir, maxChunk, raw) {
489
605
  };
490
606
  });
491
607
  }
608
+ /**
609
+ * Line-range retrieval helper (issue #140).
610
+ * Retrieves an exact line span from disk or fallback chunk data with EOF clamping.
611
+ */
612
+ export function getDocLines(opts) {
613
+ const { file, fromLine = 1, maxLines, dbDir, chunks } = opts;
614
+ if (!file || typeof file !== 'string') {
615
+ throw new Error('getDocLines: "file" parameter is required');
616
+ }
617
+ const normalizedFile = file.split(/[/\\]/).join('/');
618
+ let rawContent = null;
619
+ if (dbDir) {
620
+ const candidatePath = path.resolve(dbDir, file);
621
+ try {
622
+ assertSafePath(candidatePath, [dbDir]);
623
+ if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile()) {
624
+ rawContent = fs.readFileSync(candidatePath, 'utf8');
625
+ }
626
+ }
627
+ catch (e) {
628
+ if (e.message?.includes('path traversal guard'))
629
+ throw e;
630
+ }
631
+ }
632
+ if (rawContent === null && Array.isArray(chunks)) {
633
+ const matchingChunks = chunks.filter((c) => c.file === file || c.file === normalizedFile || c.file.split(/[/\\]/).join('/') === normalizedFile);
634
+ if (matchingChunks.length > 0) {
635
+ matchingChunks.sort((a, b) => (a.startLine || 0) - (b.startLine || 0));
636
+ const maxEnd = Math.max(...matchingChunks.map((c) => c.endLine || c.startLine || 1));
637
+ const lineMap = new Array(maxEnd).fill('');
638
+ for (const ch of matchingChunks) {
639
+ const start = (ch.startLine || 1) - 1;
640
+ const chunkLines = (ch.text || '').split(/\r?\n/);
641
+ for (let i = 0; i < chunkLines.length; i++) {
642
+ if (start + i < lineMap.length) {
643
+ lineMap[start + i] = chunkLines[i];
644
+ }
645
+ }
646
+ }
647
+ rawContent = lineMap.join('\n');
648
+ }
649
+ }
650
+ if (rawContent === null) {
651
+ throw new Error(`File not found: ${file}`);
652
+ }
653
+ const lines = rawContent.split(/\r?\n/);
654
+ const totalLines = lines.length;
655
+ let from = typeof fromLine === 'number' && !isNaN(fromLine) ? Math.floor(fromLine) : 1;
656
+ if (from < 1)
657
+ from = 1;
658
+ let selected;
659
+ let to;
660
+ if (totalLines === 0) {
661
+ from = 1;
662
+ to = 0;
663
+ selected = [];
664
+ }
665
+ else if (from > totalLines) {
666
+ from = totalLines;
667
+ to = totalLines;
668
+ selected = [lines[totalLines - 1]];
669
+ }
670
+ else {
671
+ const count = typeof maxLines === 'number' && !isNaN(maxLines) && maxLines > 0
672
+ ? Math.min(Math.floor(maxLines), totalLines - from + 1)
673
+ : totalLines - from + 1;
674
+ to = from + count - 1;
675
+ selected = lines.slice(from - 1, to);
676
+ }
677
+ return {
678
+ file: normalizedFile,
679
+ fromLine: from,
680
+ toLine: to,
681
+ lineCount: selected.length,
682
+ totalLines,
683
+ text: selected.join('\n'),
684
+ };
685
+ }
@@ -7,9 +7,24 @@ export interface VaultConfig {
7
7
  export interface FederatedSearchOptions extends Omit<SearchOptions, 'loaded' | 'cacheDir'> {
8
8
  vaults: Array<string | VaultConfig>;
9
9
  cacheDir?: string;
10
+ /**
11
+ * issue #179: when set, every vault path (and explicit indexDir) must resolve
12
+ * inside one of these roots or a traversal-guard error is thrown. Callers
13
+ * exposing federation to remote clients (MCP) must pin this to their trust
14
+ * domain; direct CLI use stays guarded only by MDSS_ROOT_GUARD.
15
+ */
16
+ allowedRoots?: string[];
10
17
  }
11
18
  export interface FederatedSearchResultHit extends SearchResultHit {
12
19
  vault: string;
13
20
  vaultPath: string;
14
21
  }
15
- export declare function searchFederated(opts: FederatedSearchOptions): Promise<FederatedSearchResultHit[]>;
22
+ export interface FederatedSearchResult {
23
+ hits: FederatedSearchResultHit[];
24
+ /**
25
+ * issue #189: per-vault load/query failures surfaced to the caller — a
26
+ * broken vault must not silently look like "0 results".
27
+ */
28
+ vaultErrors: string[];
29
+ }
30
+ export declare function searchFederated(opts: FederatedSearchOptions): Promise<FederatedSearchResult>;
@@ -8,20 +8,23 @@
8
8
  import path from 'node:path';
9
9
  import fs from 'node:fs';
10
10
  import { loadIndex, searchIndex } from './search.js';
11
+ import { assertSafePath } from './core.js';
11
12
  export async function searchFederated(opts) {
12
- const { vaults, k = 10, cacheDir = '', ...searchOpts } = opts;
13
+ const { vaults, k = 10, cacheDir = '', allowedRoots, ...searchOpts } = opts;
13
14
  if (!vaults || vaults.length === 0) {
14
15
  throw new Error('Federated search requires at least one vault specified');
15
16
  }
16
17
  const normalizedVaults = vaults.map((v) => {
17
18
  if (typeof v === 'string') {
18
- const absPath = path.resolve(v);
19
+ const absPath = assertSafePath(path.resolve(v), allowedRoots);
19
20
  const name = path.basename(absPath);
20
21
  return { name, path: absPath };
21
22
  }
22
- const absPath = path.resolve(v.path);
23
+ const absPath = assertSafePath(path.resolve(v.path), allowedRoots);
23
24
  const name = v.name || path.basename(absPath);
24
- return { name, path: absPath, indexDir: v.indexDir };
25
+ // indexDir is loaded from disk too — guard it with the same roots (#179)
26
+ const indexDir = v.indexDir ? assertSafePath(path.resolve(v.indexDir), allowedRoots) : undefined;
27
+ return { name, path: absPath, indexDir };
25
28
  });
26
29
  // Load and query each vault concurrently
27
30
  const perVaultResults = await Promise.all(normalizedVaults.map(async (vault) => {
@@ -39,7 +42,10 @@ export async function searchFederated(opts) {
39
42
  try {
40
43
  // Query vault index with a slightly larger pool to ensure healthy global fusion
41
44
  const poolK = Math.max(k * 2, 20);
42
- const resolvedCache = cacheDir || path.join(vault.path, '.cache');
45
+ // issue #179: never invent a per-vault cache location — a falsy cacheDir
46
+ // disables the disk layer (searchIndex semantics), so query embeddings
47
+ // are only ever written to a cache dir the caller explicitly owns.
48
+ const resolvedCache = cacheDir;
43
49
  const hits = await searchIndex({
44
50
  ...searchOpts,
45
51
  cacheDir: resolvedCache,
@@ -52,14 +58,22 @@ export async function searchFederated(opts) {
52
58
  return { vault, hits: [], error: err.message };
53
59
  }
54
60
  }));
61
+ // issue #189: surface per-vault failures — a broken vault must not look
62
+ // like a vault with simply no matching notes
63
+ const vaultErrors = perVaultResults
64
+ .filter((r) => r.error !== null)
65
+ .map((r) => `vault "${r.vault.name || path.basename(r.vault.path)}" (${r.vault.path}): ${r.error}`);
55
66
  // If single vault, return directly with attribution
56
67
  if (normalizedVaults.length === 1) {
57
68
  const { vault, hits } = perVaultResults[0];
58
- return hits.slice(0, k).map((h) => ({
59
- ...h,
60
- vault: vault.name || path.basename(vault.path),
61
- vaultPath: vault.path,
62
- }));
69
+ return {
70
+ hits: hits.slice(0, k).map((h) => ({
71
+ ...h,
72
+ vault: vault.name || path.basename(vault.path),
73
+ vaultPath: vault.path,
74
+ })),
75
+ vaultErrors,
76
+ };
63
77
  }
64
78
  // Multi-vault reciprocal rank fusion (RRF)
65
79
  // Collect all hits across vaults
@@ -85,10 +99,13 @@ export async function searchFederated(opts) {
85
99
  return b.hit.score - a.hit.score;
86
100
  })
87
101
  .slice(0, k);
88
- return fused.map(({ hit, vault, rrfSum }) => ({
89
- ...hit,
90
- score: rrfSum,
91
- vault: vault.name || path.basename(vault.path),
92
- vaultPath: vault.path,
93
- }));
102
+ return {
103
+ hits: fused.map(({ hit, vault, rrfSum }) => ({
104
+ ...hit,
105
+ score: rrfSum,
106
+ vault: vault.name || path.basename(vault.path),
107
+ vaultPath: vault.path,
108
+ })),
109
+ vaultErrors,
110
+ };
94
111
  }
package/dist/filter.d.ts CHANGED
@@ -22,6 +22,13 @@ export type FilterNode = {
22
22
  value: string | number | boolean;
23
23
  };
24
24
  export declare function tokenizeFilter(expr: string): string[];
25
+ /**
26
+ * issue #180: parseOr/parseAnd/parseNot/parsePrimary are mutually recursive
27
+ * (~4 stack frames per nesting level) — an unbounded `(((((…` from a 64 KiB
28
+ * request body would exhaust the call stack. 64 levels is far beyond any
29
+ * legitimate filter and keeps the parser fail-closed with a clear error.
30
+ */
31
+ export declare const MAX_FILTER_DEPTH = 64;
25
32
  export declare function parseFilter(expr: string): FilterNode;
26
33
  export declare function evaluateFilter(node: FilterNode | string, meta?: DocumentMetadata, doc?: {
27
34
  file?: string;
package/dist/filter.js CHANGED
@@ -72,12 +72,20 @@ export function tokenizeFilter(expr) {
72
72
  }
73
73
  return tokens;
74
74
  }
75
+ /**
76
+ * issue #180: parseOr/parseAnd/parseNot/parsePrimary are mutually recursive
77
+ * (~4 stack frames per nesting level) — an unbounded `(((((…` from a 64 KiB
78
+ * request body would exhaust the call stack. 64 levels is far beyond any
79
+ * legitimate filter and keeps the parser fail-closed with a clear error.
80
+ */
81
+ export const MAX_FILTER_DEPTH = 64;
75
82
  export function parseFilter(expr) {
76
83
  const tokens = tokenizeFilter(expr);
77
84
  if (tokens.length === 0) {
78
85
  throw new Error('Empty filter expression');
79
86
  }
80
87
  let pos = 0;
88
+ let depth = 0;
81
89
  function peek() {
82
90
  return tokens[pos];
83
91
  }
@@ -131,12 +139,17 @@ export function parseFilter(expr) {
131
139
  throw new Error(`Unexpected end of filter expression at position ${pos}`);
132
140
  }
133
141
  if (token === '(') {
142
+ depth += 1;
143
+ if (depth > MAX_FILTER_DEPTH) {
144
+ throw new Error(`Filter expression too deeply nested (max ${MAX_FILTER_DEPTH} levels of parentheses)`);
145
+ }
134
146
  consume(); // (
135
147
  const node = parseExpression();
136
148
  if (peek() !== ')') {
137
149
  throw new Error('Expected closing parenthesis ")" in filter expression');
138
150
  }
139
151
  consume(); // )
152
+ depth -= 1;
140
153
  return node;
141
154
  }
142
155
  // Comparison term: <field> <op> <value> or <field>:<value>