@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/package.json +32 -0
- package/src/config.ts +257 -0
- package/src/context-render.ts +185 -0
- package/src/db.ts +550 -0
- package/src/embedding.ts +174 -0
- package/src/fm-update.ts +352 -0
- package/src/frontmatter-fill.ts +529 -0
- package/src/graph.ts +427 -0
- package/src/http-client.ts +129 -0
- package/src/human-edit-detect.ts +213 -0
- package/src/index-render.ts +876 -0
- package/src/index.ts +65 -0
- package/src/indexer.ts +323 -0
- package/src/log.ts +27 -0
- package/src/mcp-tools.ts +468 -0
- package/src/memoryd.ts +680 -0
- package/src/migrate-auto-memory.ts +289 -0
- package/src/parser.ts +269 -0
- package/src/permanent-detect.ts +110 -0
- package/src/render-doctrine.ts +113 -0
- package/src/reranker.ts +162 -0
- package/src/search.ts +806 -0
- package/src/smart-hash.ts +85 -0
- package/src/sqlite-loader.ts +151 -0
- package/src/tags-mirror.ts +47 -0
- package/src/taxonomy.ts +385 -0
- package/src/utils.ts +69 -0
- package/tsconfig.json +24 -0
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
|
+
}
|