claude-mem-lite 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ import { sanitizeFtsQuery, OBS_BM25 } from '../utils.mjs';
16
16
 
17
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
18
  const SERVER_PATH = join(__dirname, '..', 'server.mjs');
19
- const SERVER_INTERNALS_PATH = join(__dirname, '..', 'server-internals.mjs');
19
+ const SEARCH_SCORING_PATH = join(__dirname, '..', 'search-scoring.mjs');
20
20
  const BENCHMARK_VERSION = '1';
21
21
 
22
22
  function extractStringArrayBody(body) {
@@ -40,7 +40,7 @@ function extractStringArrayBody(body) {
40
40
  * 1. template literal in server.mjs: instructions: `...`
41
41
  * 2. array-join in server.mjs: instructions: [ '...', '...' ].join('\n')
42
42
  * 3. (v2.31.3+) builder call in server.mjs referencing INSTRUCTIONS_BASE +
43
- * INSTRUCTIONS_VERBOSE arrays in server-internals.mjs. Measured at the
43
+ * INSTRUCTIONS_VERBOSE arrays in search-scoring.mjs. Measured at the
44
44
  * verbose form — this is the cost-per-turn baseline the benchmark tracks.
45
45
  * Returns '' if no shape matches (caller treats byte count as 0).
46
46
  */
@@ -56,10 +56,10 @@ function readMcpInstructions() {
56
56
  if (arr) return extractStringArrayBody(arr[1]).join('\n');
57
57
 
58
58
  // Form 3: buildServerInstructions() — reconstruct verbose form from
59
- // server-internals.mjs INSTRUCTIONS_BASE + INSTRUCTIONS_VERBOSE arrays.
59
+ // search-scoring.mjs INSTRUCTIONS_BASE + INSTRUCTIONS_VERBOSE arrays.
60
60
  if (/instructions:\s*buildServerInstructions\(/.test(src)) {
61
61
  let internals;
62
- try { internals = readFileSync(SERVER_INTERNALS_PATH, 'utf8'); } catch { return ''; }
62
+ try { internals = readFileSync(SEARCH_SCORING_PATH, 'utf8'); } catch { return ''; }
63
63
  const base = internals.match(/INSTRUCTIONS_BASE\s*=\s*\[([\s\S]*?)\];/);
64
64
  const verbose = internals.match(/INSTRUCTIONS_VERBOSE\s*=\s*\[([\s\S]*?)\];/);
65
65
  const parts = [];
@@ -0,0 +1,106 @@
1
+ // lib/release-digest.mjs — shared release-signing core (P1 supply-chain hardening).
2
+ //
3
+ // One source of truth for BOTH sides of the auto-update authenticity check so the
4
+ // CI signer and the runtime verifier can never drift:
5
+ // * scripts/sign-release.mjs (CI) — builds the manifest, serializes it, signs
6
+ // the EXACT serialized bytes with an Ed25519 private key (a GitHub secret),
7
+ // and uploads release-manifest.json (+ .sig) as GitHub Release assets.
8
+ // * hook-update.mjs (client) — downloads those assets, verifies the signature
9
+ // over the downloaded manifest bytes with an EMBEDDED public key, then checks
10
+ // each extracted file's sha256 against the signed manifest.
11
+ //
12
+ // Why content hashes, not a tarball-byte hash: GitHub's on-the-fly git-archive
13
+ // tarballs are not guaranteed byte-stable over time, but the FILE CONTENTS at a
14
+ // tag are (they are the git blobs). Hashing the extracted files sidesteps the
15
+ // archive-byte-stability problem entirely.
16
+ //
17
+ // Why verify over the downloaded file bytes (not a re-serialization): the client
18
+ // verifies the signature against the manifest bytes exactly as downloaded and
19
+ // only THEN parses it — so there is no canonical-JSON drift between signer and
20
+ // verifier. serializeManifest() exists so CI writes precisely what it signs.
21
+ //
22
+ // Pure Node built-ins (node:crypto Ed25519) — zero dependencies, no `gh`/cosign
23
+ // needed on the user's machine, works in the silent SessionStart hook.
24
+
25
+ import { createHash, verify as cryptoVerify } from 'node:crypto';
26
+ import { readFileSync, existsSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+
29
+ const PACKAGE_NAME = 'claude-mem-lite';
30
+
31
+ export function sha256Hex(buf) {
32
+ return createHash('sha256').update(buf).digest('hex');
33
+ }
34
+
35
+ export function sha256File(absPath) {
36
+ return sha256Hex(readFileSync(absPath));
37
+ }
38
+
39
+ /**
40
+ * Build a release manifest: { name, version, algo, files: { <relPath>: <sha256> } }.
41
+ * Only files that exist under rootDir are listed; keys are sorted so the
42
+ * serialization is deterministic regardless of fileList order.
43
+ *
44
+ * @param {string} rootDir Extracted release root (or CI checkout root)
45
+ * @param {string[]} fileList Relative paths to hash (typically SOURCE_FILES)
46
+ * @param {string} version Release version (for the manifest body)
47
+ */
48
+ export function buildReleaseManifest(rootDir, fileList, version) {
49
+ const files = {};
50
+ for (const rel of [...fileList].sort()) {
51
+ const abs = join(rootDir, rel);
52
+ if (existsSync(abs)) files[rel] = sha256File(abs);
53
+ }
54
+ return { name: PACKAGE_NAME, version, algo: 'sha256', files };
55
+ }
56
+
57
+ /**
58
+ * Deterministic byte serialization. CI writes EXACTLY this to
59
+ * release-manifest.json and signs these bytes; the client verifies the signature
60
+ * against the downloaded file bytes (it does not re-serialize), so signer and
61
+ * verifier cannot disagree on canonical form.
62
+ */
63
+ export function serializeManifest(manifest) {
64
+ return JSON.stringify(manifest, null, 2) + '\n';
65
+ }
66
+
67
+ /**
68
+ * Verify every file the manifest lists matches its signed sha256 on disk.
69
+ * Returns { ok, mismatches: string[], missing: string[] }.
70
+ * A content change → mismatches; a manifest-listed file absent → missing.
71
+ * Both are failures (ok=false).
72
+ */
73
+ export function verifyReleaseFiles(rootDir, manifest) {
74
+ const mismatches = [];
75
+ const missing = [];
76
+ const files = (manifest && manifest.files) || {};
77
+ for (const [rel, expected] of Object.entries(files)) {
78
+ const abs = join(rootDir, rel);
79
+ if (!existsSync(abs)) { missing.push(rel); continue; }
80
+ if (sha256File(abs) !== expected) mismatches.push(rel);
81
+ }
82
+ return { ok: mismatches.length === 0 && missing.length === 0, mismatches, missing };
83
+ }
84
+
85
+ /**
86
+ * Verify an Ed25519 signature over the manifest bytes. Never throws — a malformed
87
+ * key/signature/algorithm returns false so callers can treat "can't verify" the
88
+ * same as "invalid" without a try/catch at every call site.
89
+ *
90
+ * @param {Buffer|string} manifestBytes The EXACT manifest bytes that were signed
91
+ * @param {string} signatureB64 Base64-encoded Ed25519 signature
92
+ * @param {string} publicKeyPem SPKI PEM public key
93
+ * @returns {boolean}
94
+ */
95
+ export function verifyManifestSignature(manifestBytes, signatureB64, publicKeyPem) {
96
+ try {
97
+ if (!publicKeyPem || !signatureB64) return false;
98
+ const data = Buffer.isBuffer(manifestBytes) ? manifestBytes : Buffer.from(manifestBytes);
99
+ const sig = Buffer.from(signatureB64, 'base64');
100
+ if (sig.length === 0) return false;
101
+ // `null` algorithm = Ed25519 (the key type carries the algorithm in Node).
102
+ return cryptoVerify(null, data, publicKeyPem, sig);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
@@ -1,26 +1,33 @@
1
- // Shared cross-source search core (query build / source queries / scoring
2
- // normalization / sort / pagination math).
1
+ // Shared cross-source search core for cmdSearch (CLI) and mem_search (MCP).
3
2
  //
4
- // Single source of truth for cmdSearch (CLI) and mem_search (MCP). The
5
- // observation path already converged in search-engine.mjs (#8198/#8212); the
6
- // sessions/prompts FTS queries, CJK precision + LIKE fallback, cross-source
7
- // score normalization, user-sort, over-fetch sizing, and date-bound parsing
8
- // were still copy-pasted and synced by "paired-path" comments — the drift
9
- // class compress-core (ARCH-1), recall-core, and timeline-core were extracted
10
- // to close. Call sites keep what legitimately differs: flag/schema parsing,
11
- // result-row dialect (CLI `_source`+raw columns vs MCP `source`+mapped
12
- // fields), error-message wording, and output rendering.
3
+ // coreRunSearchPipeline (below) is the SINGLE orchestration body deep /
4
+ // auto-escalation per-source query (obs hybrid + sessions + prompts)
5
+ // cross-source normalize+sort context re-rank + supersede tier filter →
6
+ // user sort count+paginate. The two ~180-line bodies that cmdSearch and
7
+ // runSearchPipeline used to keep hand-synced via "paired-path" comments are
8
+ // gone (audit P1-2); each surface is now a thin adapter that parses/validates
9
+ // in and renders out. Cross-surface equivalence is enforced by
10
+ // tests/search-parity.test.mjs, not by comments.
13
11
  //
14
- // Behavioral asymmetries that are PRESERVED, not converged (documented so a
15
- // future "fix" is a deliberate contract change, not an accident):
16
- // CLI forces source=observations when --type/--tier/--importance/--branch
17
- // is set; MCP only forces it for obs_type.
12
+ // Surfaces legitimately differ on a few policy points; each is an explicit opt
13
+ // on coreRunSearchPipeline (obsTypeFallback / crossSourceEpochSortNoFts /
14
+ // rerankPolicy / recentListingNoFts / tolerateMissingFts / tierPosition …) so
15
+ // behavior is strictly preserved and any future convergence is a deliberate
16
+ // change, not an accident. Notable preserved asymmetries:
17
+ // • CLI forces source=observations for --type/--tier/--importance/--branch;
18
+ // MCP only forces it for obs_type. (effectiveSource is computed per-adapter.)
18
19
  // • CLI warns on inverted --from/--to ranges; MCP does not.
19
- // • CLI wraps session/prompt FTS in try/catch for pre-FTS legacy DBs.
20
+ // • CLI tolerates missing session/prompt FTS (pre-FTS legacy DBs); MCP does not.
21
+ // • MCP lists-recent-by-type on a 0-match obs_type query; CLI does not (#8217).
22
+ //
23
+ // Result rows use one canonical `source` key; session/prompt rows carry dual
24
+ // keys (date=created_at, text=prompt_text, session=content_session_id) so each
25
+ // surface's renderer reads its own field names off a single row shape.
20
26
 
21
27
  import { sanitizeFtsQuery, relaxFtsQueryToOr, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS } from '../utils.mjs';
22
28
  import { cjkPrecisionOk, extractCjkLikePatterns } from '../nlp.mjs';
23
29
  import { computeTier } from '../tier.mjs';
30
+ import { countSearchTotal, attachBodyTokens } from '../search-engine.mjs';
24
31
 
25
32
  /** Sanitize a user query to FTS5 syntax; optionally force OR semantics. */
26
33
  export function buildSearchFtsQuery(query, { or = false } = {}) {
@@ -198,3 +205,252 @@ export function applyTierFilter(db, results, { tier, sourceKey, currentProject }
198
205
  return full && computeTier(full, tierCtx) === tier;
199
206
  });
200
207
  }
208
+
209
+ /**
210
+ * Finalize a merged, scored result set into one page: compute the TRUE
211
+ * (limit/offset-invariant) population, slice the requested page, and attach the
212
+ * ~Nt fetch-cost hint. Single source of truth for the count+paginate+enrich tail
213
+ * of cmdSearch (CLI) and runSearchPipeline (MCP) — exactly where #8635 drifted
214
+ * (the over-fetch cap leaked into the reported total on BOTH sides independently).
215
+ *
216
+ * `total`: every source over-fetched from offset 0 (computePerSourceWindow), so
217
+ * results.length is the over-fetched candidate pool, NOT the population —
218
+ * countSearchTotal re-derives the real MATCH+filter count. Clamp to
219
+ * >= results.length so vector/concept-augmented obs rows are never undercounted
220
+ * (#8217/#8638). For deep (explicit or auto-escalated) the population IS the fused
221
+ * variant set already in `results` (deepSearch is obs-only, capped at
222
+ * perSourceLimit); countSearchTotal would instead count the ORIGINAL query's FTS
223
+ * matches — wrong, and ~0 on the vocabulary-mismatch queries deep exists for (F1).
224
+ *
225
+ * Pagination always slices: single-source results can exceed SQL LIMIT via
226
+ * expansion (concept co-occurrence / PRF / vector), and `offset` is applied
227
+ * exactly ONCE here (the per-source SQL always saw offset 0).
228
+ *
229
+ * @returns {{ total: number, page: object[] }}
230
+ */
231
+ export function finalizeSearchPage(db, results, {
232
+ isDeep, offset, limit, effectiveSource, ftsQuery, orFallbackFired,
233
+ project = null, obsType = null, importance = null, branch = null,
234
+ epochFrom = null, epochTo = null, includeNoise = false,
235
+ }) {
236
+ const total = isDeep
237
+ ? results.length
238
+ : Math.max(countSearchTotal(db, {
239
+ effectiveSource: effectiveSource || null,
240
+ ftsQuery,
241
+ obsFtsQuery: effectiveObsFtsQuery(ftsQuery, orFallbackFired),
242
+ args: { project: project || null, obs_type: obsType || null, importance: importance || null, branch: branch || null },
243
+ project: project || null,
244
+ epochFrom, epochTo, includeNoise,
245
+ }), results.length);
246
+ const page = results.slice(offset, offset + limit);
247
+ attachBodyTokens(db, page);
248
+ return { total, page };
249
+ }
250
+
251
+ /**
252
+ * Unified cross-source search orchestrator — single source of truth for the CLI
253
+ * (cmdSearch) and MCP (mem_search) search bodies: deep / auto-escalation →
254
+ * per-source query (obs hybrid + sessions + prompts) → cross-source
255
+ * normalize+sort → context re-rank + supersede → tier post-filter → user sort →
256
+ * count+paginate. The two surfaces legitimately differ on a handful of policy
257
+ * points; each is a named opt so a future "fix" is a deliberate contract change,
258
+ * not an accident (see the per-opt comments).
259
+ *
260
+ * The core does NO flag/schema parsing, NO stdout/stderr, NO formatting — adapters
261
+ * validate+parse on the way in and render on the way out. Session/prompt rows
262
+ * carry dual keys (`date`=created_at, `text`=prompt_text, `session`=content_session_id)
263
+ * so both renderers read their own field names off one canonical row.
264
+ *
265
+ * #8743: `db` comes ONLY from `ctx.db` — there is no module-global fallback, so a
266
+ * per-source leg can never silently query the wrong database.
267
+ *
268
+ * @returns {Promise<{ page:object[], total:number, preFinalizeCount:number, isDeep:boolean,
269
+ * escalated:boolean, escalatedObsCount:number, variants:object[]|null,
270
+ * reranked:boolean, orFallbackFired:boolean, effectiveSource:string|null, ftsQuery:string }>}
271
+ */
272
+ export async function coreRunSearchPipeline(ctx, opts) {
273
+ const {
274
+ db, currentProject = null, env = process.env,
275
+ searchObservationsHybrid, deepSearch, shouldEscalateToDeep,
276
+ autoDeepLlmReady, reRankWithContext, markSuperseded,
277
+ llm = null, rerankLlm = undefined,
278
+ } = ctx;
279
+ const {
280
+ query, ftsQuery, effectiveSource = null, deepMode = 'normal', rerank = false,
281
+ limit, offset, project = null, obsType = null, importance = null, branch = null,
282
+ includeNoise = false, epochFrom = null, epochTo = null, sort = 'relevance', tier = null,
283
+ // ── surface policy (strict behavior-preservation; the two surfaces differ) ──
284
+ obsTypeFallback = false, // A5: list-recent-by-type when 0 matches — MCP true, CLI false (#8217 removed it from CLI)
285
+ crossSourceEpochSortNoFts = false, // A3: epoch-sort the cross-source set when no ftsQuery — MCP true, CLI false
286
+ rerankPolicy = 'mcp', // A4: re-rank/supersede gate + re-sort condition — 'mcp' | 'cli'
287
+ rerankProject = null, // reRankWithContext project — MCP currentProject, CLI project||inferProject()
288
+ recentListingNoFts = false, // session/prompt recent-listing when no ftsQuery (explicit --source) — MCP true, CLI false
289
+ tolerateMissingFts = false, // wrap session/prompt FTS in try/catch for pre-FTS legacy DBs — CLI true, MCP false
290
+ tierPosition = 'late', // tier filter vs re-rank ordering — MCP 'late' (after re-rank), CLI 'early' (in obs block)
291
+ tierProject = null, // applyTierFilter project — MCP project||currentProject, CLI project||inferProject()
292
+ } = opts;
293
+
294
+ const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
295
+ const isCrossSource = !effectiveSource;
296
+ const results = [];
297
+ let orFallbackFired = false;
298
+ let deepVariants = null;
299
+ let deepReranked = false;
300
+ let isDeep = deepMode === 'deep';
301
+ let escalated = false;
302
+ let escalatedObsCount = 0;
303
+
304
+ const obsCtx = {
305
+ db, ftsQuery,
306
+ args: { project, obs_type: obsType, importance, branch, include_noise: includeNoise },
307
+ epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit,
308
+ orFallbackFired: false,
309
+ };
310
+
311
+ const runDeep = async ({ auto = false } = {}) => {
312
+ const ds = await deepSearch(db, {
313
+ query, project, type: obsType, importance, branch, includeNoise,
314
+ epochFrom, epochTo, limit: perSourceLimit, currentProject,
315
+ }, llm ? { llm, rerank: rerank && !auto, rerankLlm } : { auto, rerank: rerank && !auto, rerankLlm });
316
+ deepVariants = ds.variants;
317
+ deepReranked = ds.reranked;
318
+ return ds.results;
319
+ };
320
+
321
+ // ── Observations (hybrid engine; deep / auto-escalation; obs rows already carry source:'obs') ──
322
+ if (!effectiveSource || effectiveSource === 'observations') {
323
+ if (deepMode === 'deep') {
324
+ results.push(...await runDeep());
325
+ } else {
326
+ results.push(...searchObservationsHybrid(db, obsCtx));
327
+ if (obsCtx.orFallbackFired) orFallbackFired = true;
328
+ const obsCountBefore = results.filter((r) => r.source === 'obs').length;
329
+ if (deepMode === 'auto' && autoDeepLlmReady(env, llm) &&
330
+ shouldEscalateToDeep(results.filter((r) => r.source === 'obs'), obsCtx, { db, project })) {
331
+ const deepRows = await runDeep({ auto: true });
332
+ results.length = 0;
333
+ results.push(...deepRows);
334
+ isDeep = true;
335
+ escalated = true;
336
+ escalatedObsCount = obsCountBefore;
337
+ }
338
+ }
339
+ }
340
+
341
+ // ── Tier post-filter, CLI position: obs-only (tier forces observations), before re-rank ──
342
+ if (tier && tierPosition === 'early') {
343
+ const filtered = applyTierFilter(db, results, { tier, sourceKey: 'source', currentProject: tierProject });
344
+ results.length = 0; results.push(...filtered);
345
+ }
346
+
347
+ // ── Sessions (FTS via shared helper; optional recent-listing when no ftsQuery) ──
348
+ if ((!effectiveSource || effectiveSource === 'sessions') && !isDeep) {
349
+ const pushSessions = () => {
350
+ if (ftsQuery) {
351
+ const rows = searchSessionsFts(db, { ftsQuery, project, projectBoost: project ? null : currentProject, epochFrom, epochTo, perSourceLimit, perSourceOffset });
352
+ for (const r of rows) results.push({ ...r, source: 'session', date: r.created_at });
353
+ } else if (recentListingNoFts && effectiveSource === 'sessions') {
354
+ const params = []; const wheres = [];
355
+ if (project) { wheres.push('project = ?'); params.push(project); }
356
+ if (epochFrom !== null) { wheres.push('created_at_epoch >= ?'); params.push(epochFrom); }
357
+ if (epochTo !== null) { wheres.push('created_at_epoch <= ?'); params.push(epochTo); }
358
+ const where = wheres.length ? `WHERE ${wheres.join(' AND ')}` : '';
359
+ params.push(perSourceLimit, perSourceOffset);
360
+ const rows = db.prepare(`
361
+ SELECT id, request, completed, project, created_at, created_at_epoch
362
+ FROM session_summaries ${where}
363
+ ORDER BY created_at_epoch DESC
364
+ LIMIT ? OFFSET ?
365
+ `).all(...params);
366
+ for (const r of rows) results.push({ ...r, source: 'session', date: r.created_at });
367
+ }
368
+ };
369
+ if (tolerateMissingFts) { try { pushSessions(); } catch { /* session FTS may not exist in older DBs */ } } else pushSessions();
370
+ }
371
+
372
+ // ── Prompts (FTS via shared helper incl. CJK gate; optional recent-listing) ──
373
+ if ((!effectiveSource || effectiveSource === 'prompts') && !isDeep) {
374
+ const pushPrompts = () => {
375
+ if (ftsQuery) {
376
+ const rows = searchPromptsFts(db, { query, ftsQuery, project, epochFrom, epochTo, perSourceLimit, perSourceOffset });
377
+ for (const r of rows) results.push({ ...r, source: 'prompt', date: r.created_at, text: r.prompt_text, session: r.content_session_id });
378
+ } else if (recentListingNoFts && effectiveSource === 'prompts') {
379
+ const params = []; const wheres = [];
380
+ if (project) { wheres.push('s.project = ?'); params.push(project); }
381
+ if (epochFrom !== null) { wheres.push('p.created_at_epoch >= ?'); params.push(epochFrom); }
382
+ if (epochTo !== null) { wheres.push('p.created_at_epoch <= ?'); params.push(epochTo); }
383
+ const where = wheres.length ? `WHERE ${wheres.join(' AND ')}` : '';
384
+ params.push(perSourceLimit, perSourceOffset);
385
+ const rows = db.prepare(`
386
+ SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
387
+ FROM user_prompts p
388
+ JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
389
+ ${where}
390
+ ORDER BY p.created_at_epoch DESC
391
+ LIMIT ? OFFSET ?
392
+ `).all(...params);
393
+ for (const r of rows) results.push({ ...r, source: 'prompt', date: r.created_at, text: r.prompt_text, session: r.content_session_id });
394
+ }
395
+ };
396
+ if (tolerateMissingFts) { try { pushPrompts(); } catch { /* prompt FTS may not exist in older DBs */ } } else pushPrompts();
397
+ }
398
+
399
+ // ── Type-list fallback (MCP): obs_type set + 0 matches → list recent of that type ──
400
+ if (obsTypeFallback && results.length === 0 && obsType) {
401
+ const typeWheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL', 'type = ?'];
402
+ const typeParams = [obsType];
403
+ if (project) { typeWheres.push('project = ?'); typeParams.push(project); }
404
+ if (epochFrom !== null) { typeWheres.push('created_at_epoch >= ?'); typeParams.push(epochFrom); }
405
+ if (epochTo !== null) { typeWheres.push('created_at_epoch <= ?'); typeParams.push(epochTo); }
406
+ if (importance) { typeWheres.push('COALESCE(importance, 1) >= ?'); typeParams.push(importance); }
407
+ typeParams.push(limit);
408
+ const typeRows = db.prepare(`
409
+ SELECT id, type, title, subtitle, project, created_at, importance, files_modified
410
+ FROM observations WHERE ${typeWheres.join(' AND ')}
411
+ ORDER BY created_at_epoch DESC LIMIT ?
412
+ `).all(...typeParams);
413
+ for (const r of typeRows) results.push({ source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle, project: r.project, date: r.created_at, importance: r.importance, files_modified: r.files_modified, score: 0, snippet: '' });
414
+ }
415
+
416
+ // ── Cross-source normalize + sort ──
417
+ if (isCrossSource && results.length > 0 && ftsQuery) normalizeCrossSourceScores(results, 'source');
418
+ if (isCrossSource && results.length > 0) {
419
+ if (ftsQuery) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
420
+ else if (crossSourceEpochSortNoFts) results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
421
+ }
422
+
423
+ // ── Context re-rank + superseded marking (markSuperseded is pure stale-tagging) ──
424
+ const hasObs = results.some((r) => r.source === 'obs');
425
+ const rerankGate = rerankPolicy === 'mcp' ? ((ftsQuery || isDeep) && hasObs) : hasObs;
426
+ if (rerankGate) {
427
+ const obsResults = results.filter((r) => r.source === 'obs');
428
+ const doReRank = rerankPolicy === 'mcp' ? (ftsQuery && !deepReranked) : !deepReranked;
429
+ if (doReRank) reRankWithContext(db, obsResults, rerankProject);
430
+ markSuperseded(obsResults);
431
+ const doReSort = rerankPolicy === 'mcp' ? (ftsQuery && !deepReranked) : isCrossSource;
432
+ if (doReSort) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
433
+ }
434
+
435
+ // ── Tier post-filter, MCP position: after re-rank, on the merged set ──
436
+ if (tier && tierPosition === 'late') {
437
+ const filtered = applyTierFilter(db, results, { tier, sourceKey: 'source', currentProject: tierProject });
438
+ results.length = 0; results.push(...filtered);
439
+ }
440
+
441
+ // ── User-requested sort (after relevance scoring) ──
442
+ applyUserSort(results, sort);
443
+
444
+ // ── Count + paginate + ~Nt enrich. preFinalizeCount lets the CLI adapter
445
+ // distinguish "nothing matched" from "this page is empty" (its two messages). ──
446
+ const preFinalizeCount = results.length;
447
+ const { total, page } = finalizeSearchPage(db, results, {
448
+ isDeep, offset, limit, effectiveSource, ftsQuery, orFallbackFired,
449
+ project, obsType, importance, branch, epochFrom, epochTo, includeNoise,
450
+ });
451
+
452
+ return {
453
+ page, total, preFinalizeCount, isDeep, escalated, escalatedObsCount,
454
+ variants: deepVariants, reranked: deepReranked, orFallbackFired, effectiveSource, ftsQuery,
455
+ };
456
+ }