@oomkapwn/enquire-mcp 3.5.13 → 3.6.0-rc.1

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.
@@ -0,0 +1,891 @@
1
+ import * as path from "node:path";
2
+ import { findBestMatch, intersectionSize, jaccard, ngrams, stripMd } from "./meta.js";
3
+ import { resolveTarget } from "./write.js";
4
+ export async function searchText(vault, args) {
5
+ await vault.ensureExists();
6
+ const limit = args.limit ?? 25;
7
+ const mode = args.mode ?? "all";
8
+ const q = args.query;
9
+ if (!q.trim())
10
+ throw new Error("query must not be empty");
11
+ // Tokenize on whitespace for "all" / "any". Phrase mode keeps the raw query.
12
+ const tokens = mode === "phrase" ? [q] : q.trim().split(/\s+/);
13
+ const lowerTokens = tokens.map((t) => t.toLowerCase());
14
+ const entries = await vault.listMarkdown(args.folder);
15
+ // Parallel file reads — was sequential, slow on large vaults. Chunk to
16
+ // bound concurrency (avoid blowing the open-fd limit on huge vaults).
17
+ const CHUNK = 16;
18
+ const matches = [];
19
+ for (let i = 0; i < entries.length; i += CHUNK) {
20
+ const chunk = entries.slice(i, i + CHUNK);
21
+ const results = await Promise.all(chunk.map(async (e) => {
22
+ const { content } = await vault.readNote(e.absPath, e.mtimeMs);
23
+ const lower = content.toLowerCase();
24
+ let totalScore = 0;
25
+ let firstHit = -1;
26
+ let firstHitLen = 0;
27
+ const matched = [];
28
+ for (let t = 0; t < lowerTokens.length; t++) {
29
+ const lowerT = lowerTokens[t];
30
+ if (lowerT === undefined || lowerT === "")
31
+ continue;
32
+ let tokenScore = 0;
33
+ let from = 0;
34
+ while (true) {
35
+ const idx = lower.indexOf(lowerT, from);
36
+ if (idx === -1)
37
+ break;
38
+ tokenScore += 1;
39
+ if (firstHit === -1 || idx < firstHit) {
40
+ firstHit = idx;
41
+ firstHitLen = lowerT.length;
42
+ }
43
+ from = idx + lowerT.length;
44
+ }
45
+ if (tokenScore > 0) {
46
+ totalScore += tokenScore;
47
+ matched.push(tokens[t] ?? lowerT);
48
+ }
49
+ }
50
+ // Mode policy: "all" requires every token to match; "any" requires at
51
+ // least one; "phrase" requires the raw query (single token).
52
+ if (mode === "all" && matched.length !== lowerTokens.filter(Boolean).length)
53
+ return null;
54
+ if (totalScore === 0)
55
+ return null;
56
+ const { snippet, line } = sliceSnippet(content, firstHit, firstHitLen);
57
+ const hit = {
58
+ path: e.relPath,
59
+ snippet,
60
+ score: totalScore,
61
+ line,
62
+ matched_terms: matched
63
+ };
64
+ return hit;
65
+ }));
66
+ for (const r of results)
67
+ if (r)
68
+ matches.push(r);
69
+ }
70
+ matches.sort((a, b) => b.score - a.score);
71
+ return {
72
+ query: q,
73
+ mode,
74
+ scanned_notes: entries.length,
75
+ matches: matches.slice(0, limit)
76
+ };
77
+ }
78
+ export async function findSimilar(vault, args) {
79
+ await vault.ensureExists();
80
+ const limit = args.limit ?? 10;
81
+ const minScore = args.min_score ?? 0.05;
82
+ const target = await resolveTarget(vault, args);
83
+ const entries = await vault.listMarkdown();
84
+ const metas = new Map();
85
+ for (const e of entries) {
86
+ const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
87
+ const tags = new Set(parsed.tags.map((t) => t.toLowerCase()));
88
+ const title3grams = ngrams(stripMd(e.basename).toLowerCase(), 3);
89
+ const outbound = new Set();
90
+ for (const link of parsed.wikilinks) {
91
+ const m = findBestMatch(entries, link.target, e.relPath);
92
+ if (m)
93
+ outbound.add(m.relPath);
94
+ }
95
+ metas.set(e.relPath, { entry: e, tags, title3grams, outbound });
96
+ }
97
+ const targetMeta = metas.get(target.relPath);
98
+ if (!targetMeta) {
99
+ // The target was found by resolveTarget but may have been excluded from
100
+ // listMarkdown by --exclude-glob. Treat as zero results rather than crash.
101
+ return [];
102
+ }
103
+ // For co-backlink: build "who links to X?" for everyone we care about
104
+ // (target + all candidates). Single pass over outbound sets.
105
+ const inboundFor = new Map();
106
+ for (const [from, m] of metas) {
107
+ for (const to of m.outbound) {
108
+ const set = inboundFor.get(to) ?? new Set();
109
+ set.add(from);
110
+ inboundFor.set(to, set);
111
+ }
112
+ }
113
+ const targetInbound = inboundFor.get(target.relPath) ?? new Set();
114
+ const out = [];
115
+ for (const [relPath, m] of metas) {
116
+ if (relPath === target.relPath)
117
+ continue;
118
+ const tagJ = jaccard(targetMeta.tags, m.tags);
119
+ const titleJ = jaccard(targetMeta.title3grams, m.title3grams);
120
+ const candInbound = inboundFor.get(relPath) ?? new Set();
121
+ // shared_outbound: how much of A's outbound is also in B's
122
+ const sharedOut = targetMeta.outbound.size === 0 ? 0 : intersectionSize(targetMeta.outbound, m.outbound) / targetMeta.outbound.size;
123
+ // co_backlink: how many notes link to both target and candidate, over union
124
+ const coBack = jaccard(targetInbound, candInbound);
125
+ const score = 3.0 * tagJ + 1.5 * titleJ + 2.0 * sharedOut + 2.0 * coBack;
126
+ if (score < minScore)
127
+ continue;
128
+ const shared = [];
129
+ for (const t of targetMeta.tags)
130
+ if (m.tags.has(t))
131
+ shared.push(t);
132
+ shared.sort();
133
+ out.push({
134
+ path: m.entry.relPath,
135
+ title: stripMd(m.entry.basename),
136
+ score: Math.round(score * 10000) / 10000,
137
+ signals: {
138
+ tag_jaccard: Math.round(tagJ * 10000) / 10000,
139
+ title_3gram: Math.round(titleJ * 10000) / 10000,
140
+ shared_outbound: Math.round(sharedOut * 10000) / 10000,
141
+ co_backlink: Math.round(coBack * 10000) / 10000
142
+ },
143
+ shared_tags: shared,
144
+ mtime: new Date(m.entry.mtimeMs).toISOString()
145
+ });
146
+ }
147
+ out.sort((a, b) => b.score - a.score);
148
+ return out.slice(0, limit);
149
+ }
150
+ const tfidfCache = new WeakMap();
151
+ const STOP_WORDS = new Set([
152
+ "a",
153
+ "an",
154
+ "and",
155
+ "are",
156
+ "as",
157
+ "at",
158
+ "be",
159
+ "but",
160
+ "by",
161
+ "for",
162
+ "from",
163
+ "has",
164
+ "have",
165
+ "if",
166
+ "in",
167
+ "is",
168
+ "it",
169
+ "its",
170
+ "of",
171
+ "on",
172
+ "or",
173
+ "that",
174
+ "the",
175
+ "this",
176
+ "to",
177
+ "was",
178
+ "were",
179
+ "will",
180
+ "with",
181
+ "i",
182
+ "you",
183
+ "we",
184
+ "they",
185
+ "he",
186
+ "she",
187
+ "not",
188
+ "no",
189
+ "do",
190
+ "does",
191
+ "did",
192
+ "had",
193
+ "been",
194
+ "being",
195
+ "so",
196
+ "than",
197
+ "then",
198
+ "there",
199
+ "their",
200
+ "them",
201
+ "these",
202
+ "those",
203
+ "what",
204
+ "when",
205
+ "where",
206
+ "which",
207
+ "who",
208
+ "why",
209
+ "how"
210
+ ]);
211
+ // v2.1.0: detect Chinese / Japanese / Thai / Khmer / Lao via script ranges.
212
+ // These languages don't use spaces between words, so the Unicode-regex
213
+ // tokenizer falls back to character-level (or huge multi-word tokens),
214
+ // which tanks BM25 + TF-IDF precision. Intl.Segmenter (Node 16+ ICU)
215
+ // gives word-break per language. Detection is per-document, branching the
216
+ // tokenizer.
217
+ const CJK_OR_THAI_RANGES = /[぀-ヿ㐀-䶿一-鿿가-힯฀-๿ༀ-࿿ក-៿]/;
218
+ export function tokenizeForTfidf(text) {
219
+ // v1.11.1: Unicode-aware tokenizer. The previous ASCII-only regex
220
+ // (`/[a-z0-9][a-z0-9_-]*/g`) silently dropped Cyrillic, Greek, CJK,
221
+ // Hebrew, Arabic, and any non-Latin content from the TF-IDF index.
222
+ // `\p{L}` matches any Unicode letter; `\p{N}` matches any Unicode number.
223
+ //
224
+ // v2.1.0: when the text contains CJK / Thai / Khmer / Lao chars (no-
225
+ // whitespace scripts), use Intl.Segmenter for proper word-break first,
226
+ // then run the Unicode regex per-segment. This produces real word tokens
227
+ // instead of "認可サーバーがアクセストークン" as a single 12-char token
228
+ // that the length filter would drop.
229
+ const lower = text.toLowerCase();
230
+ const out = [];
231
+ if (CJK_OR_THAI_RANGES.test(lower) && typeof Intl !== "undefined" && typeof Intl.Segmenter !== "undefined") {
232
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
233
+ for (const seg of segmenter.segment(lower)) {
234
+ if (!seg.isWordLike)
235
+ continue;
236
+ const t = seg.segment;
237
+ if (t.length < 1)
238
+ continue;
239
+ if (t.length > 40)
240
+ continue;
241
+ if (STOP_WORDS.has(t))
242
+ continue;
243
+ out.push(t);
244
+ }
245
+ return out;
246
+ }
247
+ for (const m of lower.matchAll(/[\p{L}\p{N}][\p{L}\p{N}_-]*/gu)) {
248
+ const t = m[0];
249
+ if (t.length < 2)
250
+ continue;
251
+ if (t.length > 40)
252
+ continue;
253
+ if (STOP_WORDS.has(t))
254
+ continue;
255
+ out.push(t);
256
+ }
257
+ return out;
258
+ }
259
+ export async function buildTfidfIndex(vault) {
260
+ const entries = await vault.listMarkdown();
261
+ const cached = tfidfCache.get(vault);
262
+ if (cached &&
263
+ cached.entriesRef.length === entries.length &&
264
+ cached.entriesRef.every((e, i) => entries[i]?.relPath === e.relPath && entries[i]?.mtimeMs === e.mtimeMs)) {
265
+ return cached;
266
+ }
267
+ const rawDocs = [];
268
+ const docFreq = new Map();
269
+ for (const e of entries) {
270
+ const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
271
+ const tokens = tokenizeForTfidf(parsed.body);
272
+ const tf = new Map();
273
+ for (const t of tokens)
274
+ tf.set(t, (tf.get(t) ?? 0) + 1);
275
+ rawDocs.push({ entry: e, tf });
276
+ for (const t of tf.keys())
277
+ docFreq.set(t, (docFreq.get(t) ?? 0) + 1);
278
+ }
279
+ // Smoothed IDF: ln(1 + N / (1 + df)). Smoothing keeps every-doc terms
280
+ // non-zero and tames inflation on small vaults.
281
+ const N = rawDocs.length || 1;
282
+ const idf = new Map();
283
+ for (const [term, df] of docFreq) {
284
+ idf.set(term, Math.log(1 + N / (1 + df)));
285
+ }
286
+ const docs = [];
287
+ for (const r of rawDocs) {
288
+ const weights = new Map();
289
+ let normSq = 0;
290
+ for (const [term, count] of r.tf) {
291
+ const w = (1 + Math.log(count)) * (idf.get(term) ?? 0);
292
+ if (w === 0)
293
+ continue;
294
+ weights.set(term, w);
295
+ normSq += w * w;
296
+ }
297
+ const norm = Math.sqrt(normSq);
298
+ if (norm > 0) {
299
+ for (const [t, w] of weights)
300
+ weights.set(t, w / norm);
301
+ }
302
+ docs.push({
303
+ relPath: r.entry.relPath,
304
+ basename: r.entry.basename,
305
+ mtimeMs: r.entry.mtimeMs,
306
+ weights
307
+ });
308
+ }
309
+ const result = { docs, idf, entriesRef: entries };
310
+ tfidfCache.set(vault, result);
311
+ return result;
312
+ }
313
+ export async function semanticSearch(vault, args) {
314
+ await vault.ensureExists();
315
+ const limit = args.limit ?? 10;
316
+ const minScore = args.min_score ?? 0.05;
317
+ if (!args.query.trim())
318
+ throw new Error("query must not be empty");
319
+ const { docs, idf } = await buildTfidfIndex(vault);
320
+ // Vectorize query: same tokenization, IDF from the corpus, L2 normalize.
321
+ const qTokens = tokenizeForTfidf(args.query);
322
+ const qTf = new Map();
323
+ for (const t of qTokens)
324
+ qTf.set(t, (qTf.get(t) ?? 0) + 1);
325
+ const qWeights = new Map();
326
+ let qNormSq = 0;
327
+ for (const [t, count] of qTf) {
328
+ const w = (1 + Math.log(count)) * (idf.get(t) ?? 0);
329
+ if (w === 0)
330
+ continue;
331
+ qWeights.set(t, w);
332
+ qNormSq += w * w;
333
+ }
334
+ const qNorm = Math.sqrt(qNormSq);
335
+ if (qNorm > 0) {
336
+ for (const [t, w] of qWeights)
337
+ qWeights.set(t, w / qNorm);
338
+ }
339
+ // Cosine = Σ q[t]·d[t] over shared terms (both vectors are L2-normed).
340
+ const folderPrefix = args.folder ? `${args.folder.replace(/\/+$/, "")}/` : null;
341
+ const scored = [];
342
+ for (const doc of docs) {
343
+ if (folderPrefix && !doc.relPath.startsWith(folderPrefix) && doc.relPath !== args.folder)
344
+ continue;
345
+ let s = 0;
346
+ const matched = [];
347
+ for (const [t, qw] of qWeights) {
348
+ const dw = doc.weights.get(t);
349
+ if (dw !== undefined) {
350
+ s += qw * dw;
351
+ matched.push(t);
352
+ }
353
+ }
354
+ if (s < minScore)
355
+ continue;
356
+ scored.push({ doc, score: s, matchedTerms: matched });
357
+ }
358
+ scored.sort((a, b) => b.score - a.score);
359
+ const matches = [];
360
+ for (const { doc, score, matchedTerms } of scored.slice(0, limit)) {
361
+ matchedTerms.sort((a, b) => (idf.get(b) ?? 0) - (idf.get(a) ?? 0));
362
+ // v1.8.1 fix: snippet was being built from `content` (full file with
363
+ // frontmatter), so a matched term that lived in the YAML block could leak
364
+ // YAML keys/values into the response. Use `parsed.body` instead — TF-IDF
365
+ // is built from body too, so the indexOf below is guaranteed to land if
366
+ // the term contributed to the cosine score.
367
+ const { parsed } = await vault.readNote(vault.resolveInside(doc.relPath), doc.mtimeMs);
368
+ const body = parsed.body;
369
+ let snippetText = "";
370
+ for (const t of matchedTerms) {
371
+ const idx = body.toLowerCase().indexOf(t);
372
+ if (idx >= 0) {
373
+ const { snippet } = sliceSnippet(body, idx, t.length);
374
+ snippetText = snippet;
375
+ break;
376
+ }
377
+ }
378
+ matches.push({
379
+ path: doc.relPath,
380
+ title: stripMd(doc.basename),
381
+ score: Math.round(score * 10000) / 10000,
382
+ snippet: snippetText,
383
+ matched_terms: matchedTerms.slice(0, 8),
384
+ mtime: new Date(doc.mtimeMs).toISOString()
385
+ });
386
+ }
387
+ return { query: args.query, total_docs: docs.length, method: "tfidf-cosine", matches };
388
+ }
389
+ /**
390
+ * v3.1.0 — pick the text that should be embedded for an embeddings-search
391
+ * call. HyDE-augmented retrieval prefers the agent-supplied
392
+ * `hypothetical_answer` (Gao et al 2023); falls back to the raw query
393
+ * when that's absent / empty / whitespace-only.
394
+ *
395
+ * Pure helper so we can unit-test the decision in isolation (the real
396
+ * `embeddingsSearch` function loads the @huggingface/transformers
397
+ * embedder, which is out of scope for unit tests).
398
+ */
399
+ export function pickEmbedTextForHyde(args) {
400
+ const ha = args.hypothetical_answer?.trim() ?? "";
401
+ if (ha.length > 0)
402
+ return { text: ha, usedHyde: true };
403
+ return { text: args.query, usedHyde: false };
404
+ }
405
+ export async function embeddingsSearch(vault, args, embedFile, hnsw) {
406
+ await vault.ensureExists();
407
+ if (!args.query.trim())
408
+ throw new Error("query must not be empty");
409
+ // v3.1.0 — pick the actual text to embed. HyDE prefers the
410
+ // hypothetical answer when present; otherwise fall back to the query.
411
+ const { text: embedText, usedHyde } = pickEmbedTextForHyde(args);
412
+ const limit = args.limit ?? 10;
413
+ const minScore = args.min_score ?? 0.3;
414
+ // Lazy-load embed-db + embeddings only when the tool is actually called.
415
+ const [{ EmbedDb }, { loadEmbedder, resolveModel }] = await Promise.all([
416
+ import("../embed-db.js"),
417
+ import("../embeddings.js")
418
+ ]);
419
+ // Verify the embed db exists before doing anything heavy. This separates
420
+ // "user hasn't built the index yet" from "model failed to load".
421
+ const fsMod = await import("node:fs");
422
+ if (!fsMod.existsSync(embedFile)) {
423
+ throw new Error(`Embedding index not found at ${embedFile}. ` +
424
+ `Run: enquire-mcp build-embeddings --vault ${vault.root} ` +
425
+ `(first-time setup also needs: enquire-mcp install-model multilingual)`);
426
+ }
427
+ const model = resolveModel(args.model);
428
+ const db = new EmbedDb({
429
+ file: embedFile,
430
+ vaultRoot: vault.root,
431
+ modelAlias: model.alias,
432
+ dim: model.dim
433
+ });
434
+ await db.open();
435
+ try {
436
+ const total = db.totalChunks();
437
+ if (total === 0) {
438
+ return { query: args.query, method: "embeddings-cosine", model: model.alias, total_chunks: 0, matches: [] };
439
+ }
440
+ const embedder = await loadEmbedder(args.model);
441
+ const [qVec] = await embedder.embed([embedText]);
442
+ if (!qVec)
443
+ throw new Error("Embedder returned no vectors for the query");
444
+ // v2.0.0-beta.2 P0 fix: filter excluded paths from the embedding-index
445
+ // hits BEFORE returning. The persistent .embed.db is built once and may
446
+ // contain entries for paths now excluded by --exclude-glob / --read-paths
447
+ // (added between build-embeddings and serve, or between two serve runs).
448
+ // Pre-fix, those entries leaked through `text_preview` and `rel_path`,
449
+ // bypassing the privacy contract — same shape as the writeNote bug.
450
+ // We over-fetch by 2× to keep top-K stable when many hits get filtered.
451
+ const overFetch = limit * 2;
452
+ let rawHits;
453
+ if (hnsw) {
454
+ // v2.13.0 — HNSW path. Sub-10ms top-K at any scale. We over-fetch
455
+ // slightly more (3×) than brute-force because HNSW can occasionally
456
+ // miss a true nearest neighbor; the privacy filter then pares down.
457
+ const k = Math.min(Math.max(overFetch * 2, 30), Math.max(hnsw.rowByLabel.size, 1));
458
+ const result = hnsw.index.searchKnn(qVec, k, hnsw.ef !== undefined ? { ef: hnsw.ef } : undefined);
459
+ const { hnswResultsToHits } = await import("../hnsw.js");
460
+ rawHits = hnswResultsToHits(result, hnsw.rowByLabel);
461
+ // HNSW returns scores in [-1, 1] like brute-force cosine. Apply the
462
+ // same min_score floor + folder filter brute-force does.
463
+ if (args.folder) {
464
+ const prefix = `${args.folder.replace(/\/+$/, "")}/`;
465
+ rawHits = rawHits.filter((h) => h.rel_path.startsWith(prefix));
466
+ }
467
+ rawHits = rawHits.filter((h) => h.score >= minScore);
468
+ }
469
+ else {
470
+ rawHits = db.search(qVec, overFetch, { folder: args.folder, minScore });
471
+ }
472
+ const hits = rawHits.filter((h) => !vault.isExcluded(h.rel_path)).slice(0, limit);
473
+ const matches = hits.map((h) => ({
474
+ path: h.rel_path,
475
+ title: stripMd(path.basename(h.rel_path)),
476
+ score: Math.round(h.score * 10000) / 10000,
477
+ snippet: h.text_preview.slice(0, 240),
478
+ chunk_index: h.chunk_index,
479
+ line_start: h.line_start,
480
+ line_end: h.line_end,
481
+ kind: h.kind
482
+ }));
483
+ return {
484
+ query: args.query,
485
+ method: "embeddings-cosine",
486
+ model: model.alias,
487
+ total_chunks: total,
488
+ matches,
489
+ ...(usedHyde ? { hyde: true } : {})
490
+ };
491
+ }
492
+ finally {
493
+ db.close();
494
+ }
495
+ }
496
+ export async function searchHybrid(vault, args, ctx) {
497
+ await vault.ensureExists();
498
+ if (!args.query.trim())
499
+ throw new Error("query must not be empty");
500
+ const limit = args.limit ?? 10;
501
+ const minSignals = args.min_signals ?? 1;
502
+ const granularity = args.granularity ?? "note";
503
+ // Fan-out per-ranker top-K. Bigger than user's `limit` so RRF has room
504
+ // to surface a doc that's mid-rank in one signal but top in another.
505
+ const fanOutK = Math.max(50, limit * 5);
506
+ const [{ reciprocalRankFusion, RRF_K }, { existsSync }] = await Promise.all([import("../rrf.js"), import("node:fs")]);
507
+ // v2.0.0-beta.2 P1 fix: collect per-signal errors for response-side observability.
508
+ const signalErrors = {};
509
+ const signalsUsed = [];
510
+ // ─── BM25 (FTS5) ────────────────────────────────────────────────────────
511
+ // Note-level: collapse multi-chunk hits to the best rank per note.
512
+ let bm25Ranked = [];
513
+ if (ctx.ftsIndex) {
514
+ try {
515
+ // v2.0.0-beta.2 P0 fix: filter excluded paths from FTS5 hits BEFORE
516
+ // chunk-collapse + RRF. The .fts5.db can contain entries from when the
517
+ // index was built without exclusion flags (or with different flags).
518
+ // Pre-fix, BM25 search returned excluded chunks via the hybrid pipeline.
519
+ const rawFtsHits = ctx.ftsIndex.search(args.query, { limit: fanOutK, folder: args.folder });
520
+ const ftsHits = rawFtsHits.filter((h) => !vault.isExcluded(h.rel_path));
521
+ // v2.2.0: granularity branch.
522
+ // "note" → collapse multi-chunk hits per note (best-rank wins),
523
+ // RRF fuses on path key.
524
+ // "block" → keep each chunk distinct, RRF fuses on `path#chunk_index`.
525
+ if (granularity === "block") {
526
+ bm25Ranked = ftsHits.map((h, i) => ({
527
+ id: `${h.rel_path}#${h.chunk_index}`,
528
+ rank: i + 1,
529
+ score: h.score,
530
+ snippet: h.snippet,
531
+ chunk_index: h.chunk_index,
532
+ line_start: h.line_start,
533
+ line_end: h.line_end,
534
+ kind: h.kind
535
+ }));
536
+ }
537
+ else {
538
+ const bestPerNote = new Map();
539
+ ftsHits.forEach((h, i) => {
540
+ const existing = bestPerNote.get(h.rel_path);
541
+ if (!existing || i < existing.rank) {
542
+ bestPerNote.set(h.rel_path, {
543
+ score: h.score,
544
+ rank: i + 1,
545
+ snippet: h.snippet,
546
+ chunk_index: h.chunk_index,
547
+ line_start: h.line_start,
548
+ line_end: h.line_end,
549
+ kind: h.kind
550
+ });
551
+ }
552
+ });
553
+ bm25Ranked = Array.from(bestPerNote.entries()).map(([id, b]) => ({
554
+ id,
555
+ rank: b.rank,
556
+ score: b.score,
557
+ snippet: b.snippet,
558
+ chunk_index: b.chunk_index,
559
+ line_start: b.line_start,
560
+ line_end: b.line_end,
561
+ kind: b.kind
562
+ }));
563
+ // Re-sort to ensure 1-based ranks are consecutive after dedup.
564
+ bm25Ranked.sort((a, b) => a.rank - b.rank);
565
+ for (let i = 0; i < bm25Ranked.length; i++) {
566
+ const hit = bm25Ranked[i];
567
+ if (hit)
568
+ hit.rank = i + 1;
569
+ }
570
+ }
571
+ if (bm25Ranked.length > 0)
572
+ signalsUsed.push("bm25");
573
+ }
574
+ catch (err) {
575
+ const msg = err instanceof Error ? err.message : String(err);
576
+ signalErrors.bm25 = msg;
577
+ process.stderr.write(`obsidian_search: BM25 ranker failed — ${msg}\n`);
578
+ }
579
+ }
580
+ // ─── TF-IDF ─────────────────────────────────────────────────────────────
581
+ // Always available (in-memory, no native deps).
582
+ let tfidfRanked = [];
583
+ try {
584
+ const tfidf = await semanticSearch(vault, {
585
+ query: args.query,
586
+ folder: args.folder,
587
+ limit: fanOutK,
588
+ min_score: 0.05
589
+ });
590
+ tfidfRanked = tfidf.matches.map((m, i) => ({
591
+ id: m.path,
592
+ rank: i + 1,
593
+ score: m.score,
594
+ snippet: m.snippet
595
+ }));
596
+ if (tfidfRanked.length > 0)
597
+ signalsUsed.push("tfidf");
598
+ }
599
+ catch (err) {
600
+ const msg = err instanceof Error ? err.message : String(err);
601
+ signalErrors.tfidf = msg;
602
+ process.stderr.write(`obsidian_search: TF-IDF ranker failed — ${msg}\n`);
603
+ }
604
+ // ─── ML embeddings (if .embed.db exists) ────────────────────────────────
605
+ let embedRanked = [];
606
+ if (existsSync(ctx.embedFile)) {
607
+ try {
608
+ // v2.0.0-beta.1 P1 fix: pass `min_score: 0` to fan-out the embeddings
609
+ // ranker uniformly with BM25 (no floor) and TF-IDF (0.05 floor). The
610
+ // user-facing precision filter happens AFTER fusion via `min_signals`,
611
+ // not before — pre-fix, embeddings used the standalone tool's 0.3
612
+ // default which silently shrank the embedding-side candidate pool and
613
+ // starved RRF of cross-signal evidence.
614
+ const embed = await embeddingsSearch(vault, { query: args.query, folder: args.folder, limit: fanOutK, model: args.embedding_model, min_score: 0 }, ctx.embedFile, ctx.hnsw);
615
+ // v2.2.0: granularity branch — same shape as BM25 above.
616
+ if (granularity === "block") {
617
+ embedRanked = embed.matches.map((m, i) => ({
618
+ id: `${m.path}#${m.chunk_index ?? 0}`,
619
+ rank: i + 1,
620
+ score: m.score,
621
+ snippet: m.snippet,
622
+ chunk_index: m.chunk_index,
623
+ line_start: m.line_start,
624
+ line_end: m.line_end,
625
+ kind: m.kind
626
+ }));
627
+ }
628
+ else {
629
+ const bestPerNote = new Map();
630
+ embed.matches.forEach((m, i) => {
631
+ const existing = bestPerNote.get(m.path);
632
+ if (!existing || i < existing.rank) {
633
+ bestPerNote.set(m.path, {
634
+ score: m.score,
635
+ rank: i + 1,
636
+ snippet: m.snippet,
637
+ chunk_index: m.chunk_index,
638
+ line_start: m.line_start,
639
+ line_end: m.line_end,
640
+ kind: m.kind
641
+ });
642
+ }
643
+ });
644
+ embedRanked = Array.from(bestPerNote.entries()).map(([id, b]) => ({
645
+ id,
646
+ rank: b.rank,
647
+ score: b.score,
648
+ snippet: b.snippet,
649
+ chunk_index: b.chunk_index,
650
+ line_start: b.line_start,
651
+ line_end: b.line_end,
652
+ kind: b.kind
653
+ }));
654
+ embedRanked.sort((a, b) => a.rank - b.rank);
655
+ for (let i = 0; i < embedRanked.length; i++) {
656
+ const hit = embedRanked[i];
657
+ if (hit)
658
+ hit.rank = i + 1;
659
+ }
660
+ }
661
+ if (embedRanked.length > 0)
662
+ signalsUsed.push("embeddings");
663
+ }
664
+ catch (err) {
665
+ const msg = err instanceof Error ? err.message : String(err);
666
+ signalErrors.embeddings = msg;
667
+ process.stderr.write(`obsidian_search: embeddings ranker failed — ${msg}\n`);
668
+ }
669
+ }
670
+ // ─── RRF fusion ─────────────────────────────────────────────────────────
671
+ const fused = reciprocalRankFusion({
672
+ bm25: bm25Ranked.map((h) => ({ id: h.id, rank: h.rank, score: h.score })),
673
+ tfidf: tfidfRanked.map((h) => ({ id: h.id, rank: h.rank, score: h.score })),
674
+ embeddings: embedRanked.map((h) => ({ id: h.id, rank: h.rank, score: h.score }))
675
+ }, { topK: Math.max(limit * 4, 30) } // overshoot — graph boost may rerank
676
+ );
677
+ // ─── v2.3.0: Wikilink graph-boost ───────────────────────────────────────
678
+ // Re-rank top-K by counting how many *other* top-K hits link to each one.
679
+ // Equivalent to a 1-step personalised PageRank seeded by the fused top-K.
680
+ // Boost is small (α=0.005) — enough to break ties but won't override
681
+ // strong single-ranker signals. Requires no new index — uses already-
682
+ // cached parsed wikilinks per note.
683
+ // This is the "only enquire-mcp does this" feature: generic vector stores
684
+ // can't do this without an Obsidian-aware layer; Smart Connections doesn't
685
+ // do it either. Wikilinks ARE the differentiating Obsidian primitive.
686
+ const graphBoost = args.graph_boost !== false; // default ON
687
+ if (graphBoost && fused.length > 1) {
688
+ const candidatePaths = new Set();
689
+ for (const f of fused) {
690
+ candidatePaths.add(f.id.includes("#") ? (f.id.split("#")[0] ?? f.id) : f.id);
691
+ }
692
+ const outLinks = new Map();
693
+ for (const candidatePath of candidatePaths) {
694
+ try {
695
+ const note = await vault.readNote(vault.resolveInside(candidatePath));
696
+ const targets = new Set();
697
+ for (const wl of note.parsed.wikilinks) {
698
+ if (!wl.target)
699
+ continue;
700
+ // Wikilinks can be by basename ("Foo") or relative path ("Sub/Foo").
701
+ // Normalize both forms so the membership test catches either.
702
+ targets.add(wl.target);
703
+ targets.add(stripMd(wl.target));
704
+ }
705
+ outLinks.set(candidatePath, targets);
706
+ }
707
+ catch {
708
+ // skip unreadable notes
709
+ }
710
+ }
711
+ const ALPHA = 0.005;
712
+ for (const f of fused) {
713
+ const fPath = f.id.includes("#") ? (f.id.split("#")[0] ?? f.id) : f.id;
714
+ const fBasename = stripMd(path.basename(fPath));
715
+ let inDegree = 0;
716
+ for (const [otherPath, targets] of outLinks) {
717
+ if (otherPath === fPath)
718
+ continue;
719
+ if (targets.has(fPath) || targets.has(stripMd(fPath)) || targets.has(fBasename)) {
720
+ inDegree += 1;
721
+ }
722
+ }
723
+ if (inDegree > 0)
724
+ f.score += ALPHA * inDegree;
725
+ }
726
+ fused.sort((a, b) => b.score - a.score);
727
+ }
728
+ // Build snippet/chunk lookup tables for attaching the best evidence per
729
+ // note in the final response.
730
+ const bm25Map = new Map(bm25Ranked.map((h) => [h.id, h]));
731
+ const tfidfMap = new Map(tfidfRanked.map((h) => [h.id, h]));
732
+ const embedMap = new Map(embedRanked.map((h) => [h.id, h]));
733
+ // ─── v2.9.0: Cross-encoder reranking (post-RRF, post-graph-boost) ────────
734
+ // Take the top-N fused candidates, score each (query, snippet) pair with a
735
+ // BGE-style cross-encoder, and re-sort. Cross-encoder is far more accurate
736
+ // than bi-encoder cosine for relevance ranking — it sees query+document
737
+ // interaction directly. ~30-50ms per query overhead on M1 CPU at N=50.
738
+ //
739
+ // Failures are caught and surfaced as `signal_errors.reranker` so a model
740
+ // load problem doesn't poison the whole search response. The fused order
741
+ // (RRF + graph-boost) is preserved if reranking fails.
742
+ let rerankerScores = null;
743
+ if ((ctx.reranker || ctx.rerankerOverride) && fused.length > 0) {
744
+ const topN = ctx.reranker?.topN ?? 50;
745
+ const rerankBatch = fused.slice(0, topN);
746
+ try {
747
+ // Prefer the test-injected reranker when present; otherwise lazy-load.
748
+ let reranker;
749
+ if (ctx.rerankerOverride) {
750
+ reranker = ctx.rerankerOverride;
751
+ }
752
+ else {
753
+ const { loadReranker } = await import("../embeddings.js");
754
+ reranker = await loadReranker(ctx.reranker?.alias);
755
+ }
756
+ // For each candidate, find the best snippet (BM25 > embeddings > TF-IDF)
757
+ // and pair it with the query. Empty-snippet candidates go to the bottom
758
+ // by getting a -Infinity score (sort below scored candidates).
759
+ const passages = rerankBatch.map((f) => {
760
+ const bm = bm25Map.get(f.id);
761
+ const emb = embedMap.get(f.id);
762
+ const tf = tfidfMap.get(f.id);
763
+ const snippet = bm?.snippet ?? emb?.snippet ?? tf?.snippet ?? "";
764
+ // Strip FTS5 «…» highlight markers — they're cosmetic and the
765
+ // reranker should see clean prose. Limit to ~600 chars to stay
766
+ // safely under the model's 512-token budget (rough char/token ratio
767
+ // varies by language; 600 chars ≈ 200 tokens for English / Cyrillic
768
+ // per the multilingual model's tokenizer, well under 512).
769
+ return snippet.replace(/[«»]/g, "").slice(0, 600);
770
+ });
771
+ const scores = await reranker.score(args.query, passages);
772
+ rerankerScores = new Map();
773
+ for (let i = 0; i < rerankBatch.length; i++) {
774
+ const f = rerankBatch[i];
775
+ const s = scores[i];
776
+ if (f && typeof s === "number")
777
+ rerankerScores.set(f.id, s);
778
+ }
779
+ // Sort the top-N by reranker score; everything below top-N keeps RRF
780
+ // order. We do this by re-ordering fused[0..topN] in place.
781
+ const reordered = [...rerankBatch].sort((a, b) => {
782
+ const sa = rerankerScores?.get(a.id) ?? -Infinity;
783
+ const sb = rerankerScores?.get(b.id) ?? -Infinity;
784
+ return sb - sa;
785
+ });
786
+ for (let i = 0; i < reordered.length; i++) {
787
+ fused[i] = reordered[i];
788
+ }
789
+ }
790
+ catch (err) {
791
+ const msg = err instanceof Error ? err.message : String(err);
792
+ // Add to signalErrors so it surfaces in the response. Reranker is not
793
+ // a "signal" per se but the existing dict is the right home.
794
+ signalErrors.reranker = msg;
795
+ process.stderr.write(`obsidian_search: reranker failed — ${msg}\n`);
796
+ }
797
+ }
798
+ const matches = [];
799
+ for (const f of fused) {
800
+ const numSignals = Object.keys(f.per_signal).length;
801
+ if (numSignals < minSignals)
802
+ continue;
803
+ // Snippet preference: BM25 > embeddings > TF-IDF (BM25 snippets bracket
804
+ // the matched terms with «…», highest signal-to-noise).
805
+ const bm = bm25Map.get(f.id);
806
+ const emb = embedMap.get(f.id);
807
+ const tf = tfidfMap.get(f.id);
808
+ const bestEvidence = bm ?? emb ?? tf;
809
+ // Build per_signal as a Partial — only include keys that actually
810
+ // contributed. Setting `key: undefined` keeps the key visible in
811
+ // Object.keys() and JSON.stringify, which leaks "this signal exists
812
+ // but didn't match" instead of "this signal wasn't even running".
813
+ const perSignal = {};
814
+ if (f.per_signal.bm25)
815
+ perSignal.bm25 = { rank: f.per_signal.bm25.rank, score: f.per_signal.bm25.score };
816
+ if (f.per_signal.tfidf)
817
+ perSignal.tfidf = { rank: f.per_signal.tfidf.rank, score: f.per_signal.tfidf.score };
818
+ if (f.per_signal.embeddings) {
819
+ perSignal.embeddings = { rank: f.per_signal.embeddings.rank, score: f.per_signal.embeddings.score };
820
+ }
821
+ // v2.2.0: when granularity is "block", f.id is "path#chunk_index" — split
822
+ // back into path + chunk_index for the response. When "note", f.id is
823
+ // just the path.
824
+ let pathPart = f.id;
825
+ let chunkFromId;
826
+ if (granularity === "block") {
827
+ const hashIdx = f.id.lastIndexOf("#");
828
+ if (hashIdx > 0) {
829
+ pathPart = f.id.slice(0, hashIdx);
830
+ const parsed = Number.parseInt(f.id.slice(hashIdx + 1), 10);
831
+ if (Number.isInteger(parsed) && parsed >= 0)
832
+ chunkFromId = parsed;
833
+ }
834
+ }
835
+ // v2.8.0: derive content-source kind. BM25 / embeddings hits carry it
836
+ // explicitly; TF-IDF doesn't (it only runs over markdown). Either
837
+ // ranker reporting "pdf" wins; otherwise fall back to "md".
838
+ const kind = bm?.kind === "pdf" || emb?.kind === "pdf" ? "pdf" : "md";
839
+ // For PDFs, the title is best derived from the filename without
840
+ // `.md`-stripping (PDFs don't have that extension); use the .pdf-stripped
841
+ // form so titles read naturally in agent output.
842
+ const baseName = path.basename(pathPart);
843
+ const title = kind === "pdf" ? baseName.replace(/\.pdf$/i, "") : stripMd(baseName);
844
+ const rerankerScore = rerankerScores?.get(f.id);
845
+ matches.push({
846
+ path: pathPart,
847
+ title,
848
+ score: Math.round(f.score * 100000) / 100000,
849
+ snippet: bestEvidence?.snippet ?? "",
850
+ chunk_index: chunkFromId ?? bm?.chunk_index ?? emb?.chunk_index,
851
+ line_start: bm?.line_start ?? emb?.line_start,
852
+ line_end: bm?.line_end ?? emb?.line_end,
853
+ kind,
854
+ per_signal: perSignal,
855
+ ...(typeof rerankerScore === "number" && Number.isFinite(rerankerScore)
856
+ ? { reranker_score: Math.round(rerankerScore * 100000) / 100000 }
857
+ : {})
858
+ });
859
+ if (matches.length >= limit)
860
+ break;
861
+ }
862
+ // v2.0.0-beta.2 P1 fix: surface signal_errors only when at least one
863
+ // ranker actually failed. Omit the key when all signals ran cleanly so
864
+ // happy-path responses stay narrow.
865
+ const response = {
866
+ query: args.query,
867
+ method: "rrf",
868
+ k: RRF_K,
869
+ signals_used: signalsUsed,
870
+ total_candidates: fused.length,
871
+ matches
872
+ };
873
+ if (Object.keys(signalErrors).length > 0) {
874
+ response.signal_errors = signalErrors;
875
+ }
876
+ return response;
877
+ }
878
+ export function sliceSnippet(text, idx, qLen) {
879
+ if (idx < 0)
880
+ return { snippet: "", line: 0 };
881
+ const before = Math.max(0, idx - 60);
882
+ const after = Math.min(text.length, idx + qLen + 60);
883
+ let snippet = text.slice(before, after).replace(/\s+/g, " ").trim();
884
+ if (before > 0)
885
+ snippet = `…${snippet}`;
886
+ if (after < text.length)
887
+ snippet = `${snippet}…`;
888
+ const line = text.slice(0, idx).split("\n").length;
889
+ return { snippet, line };
890
+ }
891
+ //# sourceMappingURL=search.js.map