@agfpd/iapeer-memory-core 0.1.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.
package/src/search.ts ADDED
@@ -0,0 +1,806 @@
1
+ /**
2
+ * Full search pipeline:
3
+ * 1. BM25 (FTS5)
4
+ * 2. Vector search (cosine similarity)
5
+ * 3. RRF fusion
6
+ * 4. Frontmatter boosting
7
+ * 5. Cross-encoder rerank (TEI)
8
+ * 6. Graph expand (1 hop)
9
+ * 7. Backlink boost
10
+ *
11
+ * Graceful degradation: works with BM25-only if GPU services are down.
12
+ */
13
+
14
+ import type { CoreConfig } from "./config.js";
15
+ import type { CoreDb, SearchRow } from "./db.js";
16
+ import { getRelatedPaths, searchDocuments, getDocumentMeta, getChunkTexts } from "./db.js";
17
+ import { embedQuery, cosineSimilarity } from "./embedding.js";
18
+ import type { EmbeddingStatus } from "./embedding.js";
19
+ import { rerank } from "./reranker.js";
20
+ import type { RerankerStatus } from "./reranker.js";
21
+ import { escapeFtsQuery, queryTokens } from "./utils.js";
22
+ import { agentMemoryFolderMarker, statusGroup } from "./taxonomy.js";
23
+
24
+ /**
25
+ * Per-step pipeline status, прокидывается наверх в response vault_search,
26
+ * чтобы агент-вызыватель видел в каком режиме отработал поиск (полный
27
+ * пайплайн / BM25-only из-за деградации эмбеддингов / без reranker).
28
+ *
29
+ * Расширяемо: новый компонент = новое поле. Текущие значения:
30
+ * - `bm25`: всегда "ok" (FTS5 локальная, не сетевая — отвалиться может только
31
+ * вместе с самим MCP-процессом).
32
+ * - `embedding`: "disabled" если endpoint не настроен в env; иначе один из
33
+ * EmbeddingStatus.
34
+ * - `reranker`: "disabled" если endpoint не настроен; "skipped" если fused
35
+ * содержит ≤1 элемент (rerank бессмыслен); иначе RerankerStatus.
36
+ * - `graph`: всегда "ok" (1-hop expand чисто на SQLite).
37
+ */
38
+ export type PipelineStatus = {
39
+ bm25: "ok";
40
+ embedding: EmbeddingStatus | "disabled" | "skipped";
41
+ reranker: RerankerStatus | "disabled" | "skipped";
42
+ graph: "ok";
43
+ // Echo `config.callerAgent` + `forCuration` — диагностика проброса.
44
+ // null caller_agent при env-misconfig → foreign-операт penalty не работает.
45
+ caller_agent: string | null;
46
+ for_curation: boolean;
47
+ };
48
+
49
+ // Max chars of body text in a result snippet. Long enough to give the agent
50
+ // enough context to decide whether to vault_read, short enough to stay out of
51
+ // the response-size budget.
52
+ const SNIPPET_WINDOW_CHARS = 240;
53
+ // Chars of lead-in context kept before the first matched term, so the snippet
54
+ // reads as a sentence fragment, not a word cut mid-air.
55
+ const SNIPPET_LEAD_CHARS = 40;
56
+ // Honest marker for a note that has frontmatter/links but zero body prose
57
+ // (a stub "План"/"Фаза" the author hasn't filled in yet). The tool
58
+ // contract promises a non-empty snippet; the old code met that by echoing
59
+ // the title as if it were content, which is exactly the lie this fix kills.
60
+ // A stub marker tells the agent "don't vault_read, there's nothing here"
61
+ // without faking relevance.
62
+ const EMPTY_NOTE_SNIPPET = "(пустой каркас — в заметке нет содержимого)";
63
+ // How many top-degree related notes to surface per result. Was unbounded —
64
+ // hub notes (e.g. План MergeMind) inflated payload to ~10KB on a 6-result
65
+ // search. Agents can call vault_graph for the full neighborhood.
66
+ const RELATED_LIMIT = 3;
67
+
68
+ // --- Frontmatter status categories & ranking coefficients ---
69
+ //
70
+ // Source of truth: docs/01-vault-layout.md → «Boost'ы при поиске по `status`».
71
+ // Three life-cycle groups, one boost per group regardless of `type`. The
72
+ // groups (per-locale status tokens) live in the taxonomy preset and the
73
+ // coefficients in `config.ranking` (ADR-002) — no local constants here.
74
+ // Foreign agent-memory penalty (×0.7 default): `06_Agent_Memory/<other>/`
75
+ // ranks below own memory at equal semantics; details — docs/03-operatives.md.
76
+
77
+ export type VaultSearchResult = {
78
+ title: string;
79
+ path: string;
80
+ type: string | null;
81
+ status: string | null;
82
+ score: number;
83
+ snippet: string;
84
+ related: Array<{ path: string; title: string; direction: string }>;
85
+ };
86
+
87
+ export type VaultSearchOutput = {
88
+ results: VaultSearchResult[];
89
+ pipeline: PipelineStatus;
90
+ };
91
+
92
+ export async function runVaultSearch(params: {
93
+ db: CoreDb;
94
+ config: CoreConfig;
95
+ query: string;
96
+ forCuration?: boolean;
97
+ }): Promise<VaultSearchOutput> {
98
+ const { db, config, query } = params;
99
+ const forCuration = params.forCuration ?? false;
100
+ const maxResults = config.search.maxResults;
101
+ const candidateLimit = maxResults * 4; // Fetch more for fusion/reranking
102
+
103
+ // Default initialisation учитывает наличие config:
104
+ // - endpoint не настроен в env → "disabled" (terminal, шаг никогда не
105
+ // запустится в этой сессии).
106
+ // - endpoint настроен, но шаг ещё не выполнен / пропущен → "skipped"
107
+ // (бывает при early-return, либо при reranker'е с ≤1 кандидатом).
108
+ // После реального вызова это значение перезаписывается фактическим
109
+ // EmbeddingStatus / RerankerStatus от модуля-клиента.
110
+ const pipeline: PipelineStatus = {
111
+ bm25: "ok",
112
+ embedding: config.embedding ? "skipped" : "disabled",
113
+ reranker: config.reranker ? "skipped" : "disabled",
114
+ graph: "ok",
115
+ caller_agent: config.callerAgent,
116
+ for_curation: forCuration,
117
+ };
118
+
119
+ const t0 = Date.now();
120
+
121
+ // --- Step 1: BM25 ---
122
+ const ftsQuery = escapeFtsQuery(query);
123
+ const bm25Results = ftsQuery
124
+ ? searchDocuments(db, { query: ftsQuery, limit: candidateLimit })
125
+ : [];
126
+ const t1 = Date.now();
127
+
128
+ // --- Step 2: Vector search ---
129
+ let vectorResults: Array<{ path: string; score: number }> = [];
130
+ if (config.embedding) {
131
+ const queryResult = await embedQuery(query, config.embedding);
132
+ pipeline.embedding = queryResult.status;
133
+ if (queryResult.vector) {
134
+ vectorResults = vectorSearch(db, queryResult.vector, candidateLimit);
135
+ }
136
+ }
137
+ const t2 = Date.now();
138
+
139
+ // --- Step 3: RRF fusion ---
140
+ let fused: Array<{ path: string; score: number; snippet: string }>;
141
+
142
+ if (bm25Results.length > 0 && vectorResults.length > 0) {
143
+ fused = rrfFusion(bm25Results, vectorResults, config.search.rrfK ?? 60);
144
+ } else if (bm25Results.length > 0) {
145
+ // BM25 only — normalize negative FTS5 ranks to positive scores
146
+ fused = bm25Results.map((r) => ({
147
+ path: r.path,
148
+ score: -r.score, // FTS5 rank is negative, more negative = better
149
+ snippet: r.snippet,
150
+ }));
151
+ } else if (vectorResults.length > 0) {
152
+ fused = vectorResults.map((r) => ({
153
+ path: r.path,
154
+ score: r.score,
155
+ snippet: "",
156
+ }));
157
+ } else {
158
+ return { results: [], pipeline };
159
+ }
160
+
161
+ // --- Step 4: Frontmatter status boost (active/stale/pending) ---
162
+ // FOREIGN_OPERATIVE_PENALTY вынесен ниже — после rerank'а, иначе
163
+ // нормализация `item.score / maxScore` в applyReranker размывает множитель
164
+ // ×0.7 в относительный ноль (доказано репро с моком reranker'а: 0.93 vs
165
+ // 0.93 для оперативки и канона при тождественном rerankScore). До rerank
166
+ // здесь остаётся только семантический буст по `status` — он осмысленно
167
+ // нормализуется в пуле кандидатов (актуальное > устаревшего внутри
168
+ // ранжирования reranker'а), не зависит от author.
169
+ fused = applyStatusBoost(db, fused, config);
170
+
171
+ // --- Step 5: Cross-encoder rerank ---
172
+ // pipeline.reranker уже инициализирован "disabled" если endpoint не задан,
173
+ // либо "skipped" если задан но шаг не запустился (например fused ≤1).
174
+ // Перезаписываем только когда реально дёрнули endpoint.
175
+ if (config.reranker && fused.length > 1) {
176
+ const rerankResult = await applyReranker(db, query, fused, config);
177
+ fused = rerankResult.items;
178
+ pipeline.reranker = rerankResult.status;
179
+ }
180
+
181
+ // --- Step 5.5: Foreign-operative penalty (после rerank) ---
182
+ // Множитель ×0.7 на чужую оперативку применяется ПОСЛЕ rerank'а — на
183
+ // финальный score, без последующей нормализации. forCuration пропускает
184
+ // этот шаг (Индекс при построении `## Связи` должен видеть intra-папку
185
+ // на равных с каноном).
186
+ if (!forCuration && config.callerAgent) {
187
+ fused = applyForeignOperativePenalty(db, fused, config.callerAgent, config);
188
+ }
189
+
190
+ const t3 = Date.now();
191
+ if (process.env.IAPEER_MEMORY_DEBUG) {
192
+ // stdout is reserved for MCP JSON-RPC — diagnostics must go to stderr,
193
+ // otherwise debug output corrupts the wire protocol and Claude Code deadlocks.
194
+ process.stderr.write(`[mergemind search] BM25=${t1-t0}ms, Vector=${t2-t1}ms, Rerank=${t3-t2}ms, BM25=${bm25Results.length} VEC=${vectorResults.length}\n`);
195
+ }
196
+
197
+ // Sort by score descending (higher = better)
198
+ fused.sort((a, b) => b.score - a.score);
199
+
200
+ // Take top results before graph expand
201
+ const topFused = fused.slice(0, maxResults);
202
+
203
+ // --- Step 6: Graph expand (1 hop) ---
204
+ const expanded = graphExpand(db, topFused, maxResults, config);
205
+
206
+ // --- Step 7: Backlink boost ---
207
+ applyBacklinkBoost(db, expanded, config);
208
+
209
+ // Final sort
210
+ expanded.sort((a, b) => b.score - a.score);
211
+
212
+ // Display normalisation — DELIBERATE DEVIATION from the reference
213
+ // (sanctioned by PM, 09.06): raw pipeline scores at BM25-only magnitudes
214
+ // (RRF over FTS5 rank) round to 0.000 for every result, and BM25-only is
215
+ // the zero-config default of the public product — "0.0 on everything"
216
+ // reads as a broken search. Normalise by the max BEFORE rounding: the
217
+ // displayed score is relative relevance, top result = 1.0. Ordering is
218
+ // computed on RAW scores above and is unchanged by construction (a
219
+ // positive constant divisor is order-preserving).
220
+ const maxScore = expanded.length > 0 ? expanded[0].score : 0;
221
+
222
+ // Build result objects
223
+ const results: VaultSearchResult[] = expanded.slice(0, maxResults).map((item) => {
224
+ const meta = getDocumentMeta(db, item.path);
225
+ const related = getRelatedTopByDegree(db, item.path, RELATED_LIMIT);
226
+
227
+ // Snippet is built uniformly here from the note's own chunk texts, not
228
+ // taken from the BM25 path's SQL `snippet()` column. Reason: every result
229
+ // arriving via the vector-only / RRF / graph-expand branch carried an
230
+ // empty snippet, and the old fallback returned chunk[0] verbatim —
231
+ // which for the canonical note shape ("# Title" as the first body line)
232
+ // is the degenerate "<Title>\n\n# <Title>" prefix chunk parser.chunkText
233
+ // emits. Result: the snippet collapsed to a title echo and told the
234
+ // reader nothing about *why* the note matched. buildSnippet instead
235
+ // finds the body window around the actual query terms (or, for a pure
236
+ // semantic hit with no lexical overlap, the first real prose), so the
237
+ // snippet is what an agent uses to judge relevance without a vault_read.
238
+ const snippet = buildSnippet(
239
+ query,
240
+ getChunkTexts(db, item.path),
241
+ meta?.title ?? "",
242
+ );
243
+
244
+ return {
245
+ title: meta?.title ?? item.path,
246
+ path: item.path,
247
+ type: meta?.type ?? null,
248
+ status: meta?.status ?? null,
249
+ score: maxScore > 0 ? Math.round((item.score / maxScore) * 1000) / 1000 : 0,
250
+ snippet,
251
+ related,
252
+ } as VaultSearchResult;
253
+ });
254
+
255
+ return { results, pipeline };
256
+ }
257
+
258
+ // --- Vector search ---
259
+ //
260
+ // Hot path: `vec_chunks` virtual table from sqlite-vec, MATCH+ORDER BY runs
261
+ // the KNN inside the SQL engine. Per-query JS allocation: one Float32Array
262
+ // for the bound query vector plus K (chunk-level, before dedup-by-path)
263
+ // result rows = a few KB. Replaces the legacy brute-force path that pulled
264
+ // every chunk's BLOB into JS heap on every search (~25 MB/query at the
265
+ // current vault size, the root cause of the 04:45 panic).
266
+ //
267
+ // Fallback: when `db.vecAvailable` is false (no non-stripped libsqlite3 on
268
+ // the host, sqlite-vec didn't load), drop to the brute-force scan. It still
269
+ // produces correct results, just with the heap cost the vec path was
270
+ // introduced to eliminate. Kept so the plugin works on installs that don't
271
+ // have homebrew sqlite (or equivalent) yet.
272
+ //
273
+ // Score convention. sqlite-vec returns `distance` (cosine distance = 1 - sim,
274
+ // because we created the table with `distance_metric=cosine`). The rest of
275
+ // the pipeline ranks higher-is-better, so we surface `score = 1 - distance`
276
+ // — identical to what the legacy `cosineSimilarity` returned to ~1e-8.
277
+
278
+ function vectorSearch(
279
+ db: CoreDb,
280
+ queryVec: Float32Array,
281
+ limit: number,
282
+ ): Array<{ path: string; score: number }> {
283
+ if (db.vecAvailable) {
284
+ return vectorSearchVec(db, queryVec, limit);
285
+ }
286
+ return vectorSearchBruteForce(db, queryVec, limit);
287
+ }
288
+
289
+ function vectorSearchVec(
290
+ db: CoreDb,
291
+ queryVec: Float32Array,
292
+ limit: number,
293
+ ): Array<{ path: string; score: number }> {
294
+ // KNN at the chunk level. Several chunks of the same doc can rank, so we
295
+ // ask for limit*4 chunks (same expansion factor used in BM25 fetch upstream)
296
+ // and dedup by path keeping the best chunk-level score per doc.
297
+ //
298
+ // sqlite-vec requires the K constraint inline in the WHERE clause via the
299
+ // pseudo-column `k`, NOT as a bound `LIMIT ?` — without it the query fails
300
+ // with "A LIMIT or 'k = ?' constraint is required on vec0 knn queries".
301
+ // `k = ?` binds cleanly and is the documented hot path.
302
+ const chunkLimit = limit * 4;
303
+ const blob = new Uint8Array(queryVec.buffer, queryVec.byteOffset, queryVec.byteLength);
304
+ const rows = db
305
+ .prepare(
306
+ `SELECT c.doc_path AS path, v.distance AS distance
307
+ FROM vec_chunks v
308
+ JOIN chunks c ON c.id = v.rowid
309
+ WHERE v.embedding MATCH ? AND k = ?
310
+ ORDER BY v.distance`,
311
+ )
312
+ .all(blob, chunkLimit) as Array<{ path: string; distance: number }>;
313
+
314
+ const scored = new Map<string, number>();
315
+ for (const row of rows) {
316
+ const sim = 1 - row.distance;
317
+ const existing = scored.get(row.path) ?? -Infinity;
318
+ if (sim > existing) scored.set(row.path, sim);
319
+ }
320
+
321
+ return [...scored.entries()]
322
+ .sort((a, b) => b[1] - a[1])
323
+ .slice(0, limit)
324
+ .map(([path, score]) => ({ path, score }));
325
+ }
326
+
327
+ function vectorSearchBruteForce(
328
+ db: CoreDb,
329
+ queryVec: Float32Array,
330
+ limit: number,
331
+ ): Array<{ path: string; score: number }> {
332
+ const rows = db
333
+ .prepare(
334
+ `SELECT c.doc_path as path, c.embedding as embedding
335
+ FROM chunks c
336
+ WHERE c.embedding IS NOT NULL`,
337
+ )
338
+ .all() as Array<{ path: string; embedding: Buffer }>;
339
+
340
+ const scored = new Map<string, number>();
341
+ for (const row of rows) {
342
+ const vec = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4);
343
+ const sim = cosineSimilarity(queryVec, vec);
344
+ const existing = scored.get(row.path) ?? -Infinity;
345
+ if (sim > existing) scored.set(row.path, sim);
346
+ }
347
+
348
+ return [...scored.entries()]
349
+ .sort((a, b) => b[1] - a[1])
350
+ .slice(0, limit)
351
+ .map(([path, score]) => ({ path, score }));
352
+ }
353
+
354
+ // --- RRF (Reciprocal Rank Fusion) ---
355
+
356
+ function rrfFusion(
357
+ bm25: SearchRow[],
358
+ vector: Array<{ path: string; score: number }>,
359
+ k: number,
360
+ ): Array<{ path: string; score: number; snippet: string }> {
361
+ const scores = new Map<string, { score: number; snippet: string }>();
362
+
363
+ // BM25 results are sorted by rank ASC (more negative = better match)
364
+ bm25.forEach((r, idx) => {
365
+ const rrfScore = 1 / (k + idx + 1);
366
+ const existing = scores.get(r.path);
367
+ scores.set(r.path, {
368
+ score: (existing?.score ?? 0) + rrfScore,
369
+ snippet: existing?.snippet || r.snippet,
370
+ });
371
+ });
372
+
373
+ // Vector results are sorted by cosine similarity DESC
374
+ vector.forEach((r, idx) => {
375
+ const rrfScore = 1 / (k + idx + 1);
376
+ const existing = scores.get(r.path);
377
+ scores.set(r.path, {
378
+ score: (existing?.score ?? 0) + rrfScore,
379
+ snippet: existing?.snippet ?? "",
380
+ });
381
+ });
382
+
383
+ return [...scores.entries()]
384
+ .map(([path, { score, snippet }]) => ({ path, score, snippet }))
385
+ .sort((a, b) => b.score - a.score);
386
+ }
387
+
388
+ // --- Status boost (active/stale/pending) ---
389
+ //
390
+ // Применяется ДО rerank'а: семантическое свойство заметки, осмысленно
391
+ // нормализуется reranker'ом в пуле кандидатов.
392
+ //
393
+ // Поле needs_review — авто-флаг «Индекс ещё не подтвердил», ставится
394
+ // hook/watcher на любую правку. Не применяет penalty в поиске: активно
395
+ // правящиеся заметки иначе зависают в полузабытом состоянии до
396
+ // permanent-tick'а (до 6 часов) — инверсия ранжирования. Флаг работает
397
+ // как маркер для Индекса/монитора, не как сигнал ранжирования.
398
+ function applyStatusBoost(
399
+ db: CoreDb,
400
+ items: Array<{ path: string; score: number; snippet: string }>,
401
+ config: CoreConfig,
402
+ ): Array<{ path: string; score: number; snippet: string }> {
403
+ return items.map((item) => {
404
+ const meta = getDocumentMeta(db, item.path);
405
+ if (!meta) return item;
406
+
407
+ const status = meta.status?.toLowerCase();
408
+ let multiplier = 1.0;
409
+ const group = status ? statusGroup(config.taxonomy, status) : null;
410
+ if (group === "active") {
411
+ multiplier = config.ranking.activeBoost;
412
+ } else if (group === "stale") {
413
+ multiplier = config.ranking.stalePenalty;
414
+ } else if (group === "pending") {
415
+ multiplier = config.ranking.pendingPenalty;
416
+ }
417
+ return { ...item, score: item.score * multiplier };
418
+ });
419
+ }
420
+
421
+ // --- Foreign-operative penalty ---
422
+ //
423
+ // Применяется ПОСЛЕ rerank'а: множитель ×0.7 размывался бы нормализацией
424
+ // `item.score / maxScore` внутри applyReranker (проверено репро — penalty в
425
+ // проде не имел эффекта пока стоял до rerank'а). Здесь же — финальный
426
+ // score, без последующего нормализующего шага кроме сортировки.
427
+ //
428
+ // `callerAgent` берётся из env `IAPEER_MEMORY_AGENT_NAME (fallback; первичен identity MCP-подключения, ADR-012)`. Если не задан
429
+ // (локальный CC без connect) — caller выше пропускает вызов, penalty не
430
+ // применяется, вся оперативка видна как есть. Параметр author лежит в
431
+ // frontmatter заметки (load-bearing — равен имени подпапки
432
+ // `06_Оперативка_агентов/<имя>/`).
433
+ function applyForeignOperativePenalty(
434
+ db: CoreDb,
435
+ items: Array<{ path: string; score: number; snippet: string }>,
436
+ callerAgent: string,
437
+ config: CoreConfig,
438
+ ): Array<{ path: string; score: number; snippet: string }> {
439
+ const marker = agentMemoryFolderMarker(config.taxonomy);
440
+ return items.map((item) => {
441
+ if (!item.path.includes(marker)) return item;
442
+ const meta = getDocumentMeta(db, item.path);
443
+ if (!meta) return item;
444
+ const author = meta.frontmatter?.author as string | undefined;
445
+ if (!author || author === callerAgent) return item;
446
+ return { ...item, score: item.score * config.ranking.foreignAgentMemoryPenalty };
447
+ });
448
+ }
449
+
450
+ // --- Cross-encoder reranker ---
451
+
452
+ async function applyReranker(
453
+ db: CoreDb,
454
+ query: string,
455
+ items: Array<{ path: string; score: number; snippet: string }>,
456
+ config: CoreConfig,
457
+ ): Promise<{
458
+ items: Array<{ path: string; score: number; snippet: string }>;
459
+ status: RerankerStatus;
460
+ }> {
461
+ // Caller проверяет наличие reranker до вызова — этот guard просто
462
+ // защищает от опечатки и возвращает все исходные элементы со статусом
463
+ // "ok" (sentinel — фактического сетевого вызова не было).
464
+ if (!config.reranker) return { items, status: "ok" };
465
+
466
+ const topK = Math.min(items.length, config.reranker.topK);
467
+ const candidates = items.slice(0, topK);
468
+
469
+ // Build texts for reranking: use snippets or titles
470
+ const texts = candidates.map((item) => {
471
+ const meta = getDocumentMeta(db, item.path);
472
+ const title = meta?.title ?? item.path;
473
+ const snippet = item.snippet || "";
474
+ return `${title}\n${snippet}`.trim();
475
+ });
476
+
477
+ const rerankResp = await rerank(query, texts, config.reranker);
478
+ if (!rerankResp.items) {
479
+ // Reranker недоступен (timeout / error / circuit-open) — оставляем
480
+ // RRF-ранжирование, статус прокидываем наверх для pipeline.reranker.
481
+ return { items, status: rerankResp.status };
482
+ }
483
+ const ranked = rerankResp.items;
484
+
485
+ // Merge reranker scores with existing scores
486
+ const rerankerWeight = config.reranker.weight ?? 0.7;
487
+ const fusionWeight = 1 - rerankerWeight;
488
+
489
+ // Normalize existing scores to [0, 1]
490
+ const maxScore = Math.max(...candidates.map((c) => c.score), 0.001);
491
+
492
+ const reranked = candidates.map((item, idx) => {
493
+ const rerankResult = ranked.find((r) => r.index === idx);
494
+ const rerankScore = rerankResult?.score ?? 0;
495
+ const normalizedFusion = item.score / maxScore;
496
+ return {
497
+ ...item,
498
+ score: fusionWeight * normalizedFusion + rerankerWeight * rerankScore,
499
+ };
500
+ });
501
+
502
+ // Add remaining items (not reranked) with reduced scores
503
+ const remaining = items.slice(topK).map((item) => ({
504
+ ...item,
505
+ score: item.score * 0.3, // Penalize items that didn't make it to reranking
506
+ }));
507
+
508
+ return { items: [...reranked, ...remaining], status: "ok" };
509
+ }
510
+
511
+ // --- Graph expand ---
512
+
513
+ function graphExpand(
514
+ db: CoreDb,
515
+ items: Array<{ path: string; score: number; snippet: string }>,
516
+ maxResults: number,
517
+ config: CoreConfig,
518
+ ): Array<{ path: string; score: number; snippet: string }> {
519
+ const existing = new Set(items.map((i) => i.path));
520
+ const expanded = [...items];
521
+
522
+ for (const item of items) {
523
+ if (expanded.length >= maxResults * 2) break;
524
+ const neighbors = getRelatedPaths(db, item.path, 3);
525
+ for (const neighborPath of neighbors) {
526
+ if (existing.has(neighborPath)) continue;
527
+ // Only include neighbors that exist in the index
528
+ const meta = getDocumentMeta(db, neighborPath);
529
+ if (!meta) continue;
530
+ existing.add(neighborPath);
531
+ expanded.push({
532
+ path: neighborPath,
533
+ score: item.score * config.ranking.graphExpandPenalty,
534
+ snippet: "",
535
+ });
536
+ }
537
+ }
538
+
539
+ return expanded;
540
+ }
541
+
542
+ // --- Backlink boost ---
543
+
544
+ function applyBacklinkBoost(
545
+ db: CoreDb,
546
+ items: Array<{ path: string; score: number; snippet: string }>,
547
+ config: CoreConfig,
548
+ ): void {
549
+ for (const item of items) {
550
+ const backlinkCount = (
551
+ db
552
+ .prepare("SELECT COUNT(*) as n FROM edges WHERE target_path = ?")
553
+ .get(item.path) as { n: number }
554
+ ).n;
555
+ if (backlinkCount >= config.ranking.backlinkHubThreshold) {
556
+ item.score *= config.ranking.backlinkHubBoost;
557
+ }
558
+ }
559
+ }
560
+
561
+ // --- Related paths with metadata ---
562
+
563
+ /**
564
+ * Return up to `limit` related notes (incoming + outgoing wikilink neighbors)
565
+ * sorted by their own total degree in the edges graph — i.e. the most
566
+ * "hub-like" neighbors first. Hub neighbors are usually more useful to surface
567
+ * than leaf siblings.
568
+ *
569
+ * Single SQL pass via UNION + COUNT subqueries instead of N+1 getDocumentMeta
570
+ * calls.
571
+ */
572
+ function getRelatedTopByDegree(
573
+ db: CoreDb,
574
+ docPath: string,
575
+ limit: number,
576
+ ): Array<{ path: string; title: string; direction: string }> {
577
+ const rows = db
578
+ .prepare(
579
+ `
580
+ WITH neighbors AS (
581
+ SELECT target_path AS path, 'outgoing' AS direction FROM edges WHERE source_path = ?
582
+ UNION
583
+ SELECT source_path AS path, 'incoming' AS direction FROM edges WHERE target_path = ?
584
+ )
585
+ SELECT
586
+ n.path AS path,
587
+ n.direction AS direction,
588
+ d.title AS title,
589
+ (
590
+ (SELECT COUNT(*) FROM edges WHERE source_path = n.path) +
591
+ (SELECT COUNT(*) FROM edges WHERE target_path = n.path)
592
+ ) AS degree
593
+ FROM neighbors n
594
+ JOIN documents d ON d.path = n.path
595
+ ORDER BY degree DESC, n.path ASC
596
+ LIMIT ?
597
+ `,
598
+ )
599
+ .all(docPath, docPath, limit) as Array<{
600
+ path: string;
601
+ direction: string;
602
+ title: string;
603
+ degree: number;
604
+ }>;
605
+
606
+ // Dedup by path (UNION already does, but a note can have edges in both
607
+ // directions and SQL UNION keeps both rows — we keep the first/highest-degree).
608
+ const seen = new Set<string>();
609
+ const result: Array<{ path: string; title: string; direction: string }> = [];
610
+ for (const r of rows) {
611
+ if (seen.has(r.path)) continue;
612
+ seen.add(r.path);
613
+ result.push({ path: r.path, title: r.title, direction: r.direction });
614
+ }
615
+ return result;
616
+ }
617
+
618
+ // --- Query-aware snippet builder ---
619
+ //
620
+ // Exported for unit tests. Given a query, a note's ordered chunk texts and its
621
+ // title, returns a body fragment that shows *why* the note is relevant: the
622
+ // window around the densest cluster of query terms, with those terms wrapped
623
+ // in [brackets]. Falls back to the first real prose when the match is purely
624
+ // semantic (vector hit, zero lexical overlap). Never returns the title echo.
625
+
626
+ /**
627
+ * Drop leading lines that are pure structure, not content: the bare title
628
+ * (parser.chunkText prepends it to chunk[0]) and any markdown headings
629
+ * ("# Title" — the canonical first body line of every vault note). Returns
630
+ * the first prose onward. A chunk that is *only* title+heading collapses to
631
+ * "" and is skipped by the caller — that is exactly the degenerate
632
+ * "<Title>\n\n# <Title>" chunk[0] this whole fix exists to stop surfacing.
633
+ */
634
+ export function stripLeadingTitleEcho(text: string, title: string): string {
635
+ const lines = text.split("\n");
636
+ const t = title.trim().toLowerCase();
637
+ let i = 0;
638
+ while (i < lines.length) {
639
+ const line = lines[i].trim();
640
+ if (line === "") {
641
+ i++;
642
+ continue;
643
+ }
644
+ const isHeading = /^#{1,6}\s/.test(line);
645
+ const isBareTitle = t.length > 0 && line.toLowerCase() === t;
646
+ if (isHeading || isBareTitle) {
647
+ i++;
648
+ continue;
649
+ }
650
+ break;
651
+ }
652
+ return lines.slice(i).join("\n").trim();
653
+ }
654
+
655
+ /**
656
+ * Best window over `text` for `terms`: the offset of the matched term that
657
+ * starts the SNIPPET_WINDOW_CHARS span covering the most *distinct* terms
658
+ * (tie-break: most total hits, then earliest). null = no term occurs at all.
659
+ */
660
+ function bestWindow(
661
+ text: string,
662
+ terms: string[],
663
+ ): { start: number; distinct: number; hits: number } | null {
664
+ const lower = text.toLowerCase();
665
+ const matches: Array<{ idx: number; term: string }> = [];
666
+ for (const term of terms) {
667
+ let from = 0;
668
+ for (;;) {
669
+ const idx = lower.indexOf(term, from);
670
+ if (idx === -1) break;
671
+ matches.push({ idx, term });
672
+ from = idx + term.length;
673
+ }
674
+ }
675
+ if (matches.length === 0) return null;
676
+ matches.sort((a, b) => a.idx - b.idx);
677
+
678
+ let best: { start: number; distinct: number; hits: number } | null = null;
679
+ for (let i = 0; i < matches.length; i++) {
680
+ const winEnd = matches[i].idx + SNIPPET_WINDOW_CHARS;
681
+ const seenTerms = new Set<string>();
682
+ let hits = 0;
683
+ for (let j = i; j < matches.length && matches[j].idx < winEnd; j++) {
684
+ seenTerms.add(matches[j].term);
685
+ hits++;
686
+ }
687
+ const distinct = seenTerms.size;
688
+ if (
689
+ !best ||
690
+ distinct > best.distinct ||
691
+ (distinct === best.distinct && hits > best.hits)
692
+ ) {
693
+ best = { start: matches[i].idx, distinct, hits };
694
+ }
695
+ }
696
+ return best;
697
+ }
698
+
699
+ /** Wrap every occurrence of any term in `[ ]`, preserving original casing.
700
+ * Single left-to-right scan (longest term wins) — no nested/overlap wraps. */
701
+ function highlightTerms(text: string, terms: string[]): string {
702
+ const lower = text.toLowerCase();
703
+ let out = "";
704
+ let i = 0;
705
+ while (i < text.length) {
706
+ let matched = "";
707
+ for (const term of terms) {
708
+ if (term.length > matched.length && lower.startsWith(term, i)) {
709
+ matched = term;
710
+ }
711
+ }
712
+ if (matched) {
713
+ out += "[" + text.slice(i, i + matched.length) + "]";
714
+ i += matched.length;
715
+ } else {
716
+ out += text[i];
717
+ i += 1;
718
+ }
719
+ }
720
+ return out;
721
+ }
722
+
723
+ /** Cut the window around `matchStart`, snap to word boundaries, ellipsize the
724
+ * cut edges, then highlight the query terms inside it. */
725
+ function extractWindow(
726
+ text: string,
727
+ matchStart: number,
728
+ terms: string[],
729
+ ): string {
730
+ let start = Math.max(0, matchStart - SNIPPET_LEAD_CHARS);
731
+ if (start > 0) {
732
+ const sp = text.indexOf(" ", start);
733
+ if (sp !== -1 && sp - start < 30) start = sp + 1;
734
+ }
735
+ let end = Math.min(text.length, start + SNIPPET_WINDOW_CHARS);
736
+ const truncatedRight = end < text.length;
737
+ if (truncatedRight) {
738
+ // Inside the chunk — snap back to a word boundary so we don't cut a word.
739
+ const sp = text.lastIndexOf(" ", end);
740
+ if (sp > start) end = sp;
741
+ }
742
+ let raw = text.slice(start, end).trim();
743
+
744
+ // `text` is one chunk — a ~500-char cut of the note — so even when the
745
+ // window reaches the chunk's end it is usually mid-sentence, not the
746
+ // note's end. Trailing "…" unless we stopped on sentence punctuation.
747
+ const endsClean = /[.!?…»"”)\]]$/.test(raw);
748
+ if (!truncatedRight && !endsClean) {
749
+ raw = raw.replace(/\s*\S*$/, ""); // drop the dangling partial word
750
+ }
751
+
752
+ let slice = highlightTerms(raw.trim(), terms);
753
+ if (start > 0) slice = "…" + slice;
754
+ if (truncatedRight || !endsClean) slice = slice + "…";
755
+ return slice;
756
+ }
757
+
758
+ export function buildSnippet(
759
+ query: string,
760
+ chunks: string[],
761
+ title: string,
762
+ ): string {
763
+ const terms = [
764
+ ...new Set(
765
+ queryTokens(query)
766
+ .map((t) => t.toLowerCase())
767
+ .filter((t) => t.length >= 2),
768
+ ),
769
+ ];
770
+
771
+ const cleaned = chunks
772
+ .map((c, idx) => ({
773
+ idx,
774
+ text: stripLeadingTitleEcho(c, title).replace(/\s+/g, " ").trim(),
775
+ }))
776
+ .filter((c) => c.text.length > 0);
777
+ if (cleaned.length === 0) return EMPTY_NOTE_SNIPPET;
778
+
779
+ if (terms.length > 0) {
780
+ let pick:
781
+ | { idx: number; text: string; w: ReturnType<typeof bestWindow> }
782
+ | null = null;
783
+ for (const c of cleaned) {
784
+ const w = bestWindow(c.text, terms);
785
+ if (!w) continue;
786
+ if (
787
+ !pick ||
788
+ w.distinct > pick.w!.distinct ||
789
+ (w.distinct === pick.w!.distinct && w.hits > pick.w!.hits)
790
+ // earlier chunk wins on a full tie: it iterates first, so the
791
+ // strict `>` comparisons above already keep the first-seen best.
792
+ ) {
793
+ pick = { idx: c.idx, text: c.text, w };
794
+ }
795
+ }
796
+ if (pick && pick.w) {
797
+ return extractWindow(pick.text, pick.w.start, terms);
798
+ }
799
+ }
800
+
801
+ // No lexical overlap (pure semantic / graph-expanded hit) — head of the
802
+ // first real prose chunk. Still query-honest: there is nothing to centre on.
803
+ const head = cleaned[0].text;
804
+ if (head.length <= SNIPPET_WINDOW_CHARS) return head;
805
+ return head.slice(0, SNIPPET_WINDOW_CHARS).replace(/\s*\S*$/, "") + "…";
806
+ }