claude-mem-lite 3.7.0 → 3.7.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/schema.mjs CHANGED
@@ -894,7 +894,13 @@ export function ensureDb() {
894
894
  db.pragma('foreign_keys = OFF'); // Enabled after dedup migration
895
895
 
896
896
  try {
897
- return initSchema(db);
897
+ const ready = initSchema(db);
898
+ // P1-5: sentinel-gated data cleanups must run on EVERY open (schema.mjs:766).
899
+ // They were extracted out of initSchema into runDeferredCleanups but never
900
+ // wired into a production opener — without this call they ran nowhere but
901
+ // tests, silently halting orphan/normalize hygiene. Best-effort: never throws.
902
+ runDeferredCleanups(ready);
903
+ return ready;
898
904
  } catch (e) {
899
905
  try { db.close(); } catch {}
900
906
  throw e;
package/search-engine.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  relaxFtsQueryToOr, debugLog, debugCatch, estimateTokens,
13
13
  } from './utils.mjs';
14
14
  import { getVocabulary, computeVector, vectorSearch, rrfMerge } from './tfidf.mjs';
15
- import { extractPRFTerms, expandQueryByConcepts } from './server-internals.mjs';
15
+ import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
16
16
 
17
17
  // Scoring expressions — full adds project boost + access bonus; simple is for
18
18
  // expansion paths where boost would over-amplify already-loose matches.
@@ -1,5 +1,9 @@
1
- // claude-mem-lite server internal functions
2
- // Extracted from server.mjs for testability (server.mjs has top-level side effects)
1
+ // claude-mem-lite shared search-scoring / ranking helpers: re-ranking, supersede
2
+ // marking, PRF term extraction, concept-expansion plus the MCP instructions
3
+ // builder and idle-cleanup/access-boost side helpers. Used by the MCP server,
4
+ // the CLI (mem-cli), and search-engine; originally extracted from server.mjs for
5
+ // testability (server.mjs has top-level side effects), hence the former
6
+ // "server-internals" name — renamed in audit P3 since it is not server-only.
3
7
 
4
8
  import { debugCatch, COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25 } from './utils.mjs';
5
9
  import { BASE_STOP_WORDS } from './stop-words.mjs';
package/server.mjs CHANGED
@@ -8,12 +8,12 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { truncate, typeIcon, inferProject, scrubSecrets, fmtDate, debugLog, debugCatch, isPathConfined } from './utils.mjs';
9
9
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
10
10
  import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
11
- import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
12
- import { searchObservationsHybrid, countSearchTotal, attachBodyTokens } from './search-engine.mjs';
11
+ import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
12
+ import { searchObservationsHybrid } from './search-engine.mjs';
13
13
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
14
14
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
15
15
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
16
- import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
16
+ import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './lib/search-core.mjs';
17
17
  import {
18
18
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
19
19
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
@@ -177,91 +177,9 @@ function safeHandler(fn) {
177
177
  // Observation-search core (FTS query/params builders, hybrid pipeline) lives in
178
178
  // search-engine.mjs so mem-cli.mjs gets the identical implementation.
179
179
 
180
- // Thin wrapper around the shared engine keeps the existing call sites
181
- // (searchObservations(ctx)) without ferrying `db` through every layer.
182
- // ctx.db is set by runSearchPipeline when an injected db is present (e.g. tests);
183
- // falls back to the module-level db for the normal MCP handler path.
184
- function searchObservations(ctx) {
185
- return searchObservationsHybrid(ctx.db ?? db, ctx);
186
- }
187
-
188
- function searchSessions(ctx) {
189
- const _db = ctx.db ?? db;
190
- const { ftsQuery, searchType, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject } = ctx;
191
- const results = [];
192
-
193
- if (ftsQuery) {
194
- const rows = searchSessionsFts(_db, {
195
- ftsQuery, project: args.project ?? null,
196
- projectBoost: args.project ? null : currentProject,
197
- epochFrom, epochTo, perSourceLimit, perSourceOffset,
198
- });
199
- for (const r of rows) {
200
- results.push({ source: 'session', id: r.id, request: r.request, completed: r.completed, project: r.project, date: r.created_at, created_at_epoch: r.created_at_epoch, score: r.score });
201
- }
202
- } else if (!searchType) {
203
- // Skip sessions in unfiltered no-query mode (too noisy)
204
- } else {
205
- const params = [];
206
- const wheres = [];
207
- if (args.project) { wheres.push('project = ?'); params.push(args.project); }
208
- if (epochFrom !== null) { wheres.push('created_at_epoch >= ?'); params.push(epochFrom); }
209
- if (epochTo !== null) { wheres.push('created_at_epoch <= ?'); params.push(epochTo); }
210
- const where = wheres.length ? `WHERE ${wheres.join(' AND ')}` : '';
211
- params.push(perSourceLimit, perSourceOffset);
212
- const rows = _db.prepare(`
213
- SELECT id, request, completed, project, created_at, created_at_epoch
214
- FROM session_summaries ${where}
215
- ORDER BY created_at_epoch DESC
216
- LIMIT ? OFFSET ?
217
- `).all(...params);
218
- for (const r of rows) {
219
- results.push({ source: 'session', id: r.id, request: r.request, completed: r.completed, project: r.project, date: r.created_at, created_at_epoch: r.created_at_epoch });
220
- }
221
- }
222
-
223
- return results;
224
- }
225
-
226
- function searchPrompts(ctx) {
227
- const _db = ctx.db ?? db;
228
- const { ftsQuery, searchType, args, epochFrom, epochTo, perSourceLimit, perSourceOffset } = ctx;
229
- const results = [];
230
-
231
- if (ftsQuery) {
232
- // CJK precision gate + LIKE fallback live in the shared core (see
233
- // lib/search-core.mjs for the leak rationale).
234
- const rows = searchPromptsFts(_db, {
235
- query: args.query, ftsQuery, project: args.project ?? null,
236
- epochFrom, epochTo, perSourceLimit, perSourceOffset,
237
- });
238
- for (const r of rows) {
239
- results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, created_at_epoch: r.created_at_epoch, score: r.score });
240
- }
241
- } else if (searchType === 'prompts') {
242
- const params = [];
243
- const wheres = [];
244
- if (args.project) { wheres.push('s.project = ?'); params.push(args.project); }
245
- if (epochFrom !== null) { wheres.push('p.created_at_epoch >= ?'); params.push(epochFrom); }
246
- if (epochTo !== null) { wheres.push('p.created_at_epoch <= ?'); params.push(epochTo); }
247
- const where = wheres.length ? `WHERE ${wheres.join(' AND ')}` : '';
248
- params.push(perSourceLimit, perSourceOffset);
249
- const rows = _db.prepare(`
250
- SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
251
- FROM user_prompts p
252
- JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
253
- ${where}
254
- ORDER BY p.created_at_epoch DESC
255
- LIMIT ? OFFSET ?
256
- `).all(...params);
257
- for (const r of rows) {
258
- results.push({ source: 'prompt', id: r.id, text: r.prompt_text, session: r.content_session_id, date: r.created_at, created_at_epoch: r.created_at_epoch });
259
- }
260
- }
261
-
262
- return results;
263
- }
264
-
180
+ // searchObservations / searchSessions / searchPrompts were consolidated into the
181
+ // shared coreRunSearchPipeline (lib/search-core.mjs). This surface is now a thin
182
+ // adapter (runSearchPipeline below); only output formatting stays local.
265
183
  function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFallbackFired = false, isDeepSearch = false) {
266
184
  if (paginatedResults.length === 0) {
267
185
  const hint = [];
@@ -339,213 +257,74 @@ export async function handleSearchForTest(db, args, { llm, rerankLlm } = {}) {
339
257
  }
340
258
 
341
259
  async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
342
- if (args.project) args = { ...args, project: resolveProject(args.project) };
343
- const limit = args.limit ?? 20;
344
- const offset = args.offset ?? 0;
345
- // args.or (Batch A CLI↔MCP alignment): force OR from start, matching
346
- // CLI `search --or`. The default path still does AND with OR-fallback
347
- // inside searchObservations when AND returns 0.
348
- const ftsQuery = buildSearchFtsQuery(args.query, { or: args.or });
349
- const searchType = args.type;
350
- const currentProject = inferProject();
351
-
352
- // Over-fetch from offset 0 for EVERY mode, then apply `offset` exactly once
353
- // at the merge slice below — shared sizing with the CLI (see
354
- // computePerSourceWindow for the #8217 double-offset rationale).
355
- const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
356
-
357
- // Parse date bounds to epoch (with validation; date-only date_to extends
358
- // to end-of-day 23:59:59.999Z — shared with CLI --from/--to)
359
- const bounds = parseDateBounds(args.date_from, args.date_to);
360
- if (!bounds.ok) throw new Error(`Invalid date_${bounds.bad}: "${bounds.value}" (use ISO 8601 or YYYY-MM-DD)`);
361
- const { epochFrom, epochTo } = bounds;
362
-
363
- // Resolve tri-state deep mode. MCP defaults to 'auto' (escalate on weak results)
364
- // unless explicitly overridden via args.deep or CLAUDE_MEM_AUTO_DEEP env flag.
365
- const deepMode = resolveDeepMode(args.deep, { surface: 'mcp' });
366
- // Opt-in LLM rerank (D#43): explicit-deep only — never on AUTO escalation — so
367
- // no default search behaviour changes. Parity with CLI `search --deep --rerank`.
368
- const rerank = args.rerank === true && deepMode === 'deep';
369
-
370
- // Early return when query was provided but sanitized to nothing (all FTS5
371
- // keywords/special chars). Skipped for deep/auto — deep's LLM rewrite may
372
- // still produce searchable variants from a query the FTS sanitizer rejects,
373
- // and auto could escalate similarly.
374
- if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance && deepMode === 'normal') {
375
- return { ...formatSearchOutput([], args, ftsQuery, 0), escalated: false, results: [], total: 0, variants: null };
376
- }
377
-
378
- // When obs_type is specified, implicitly restrict to observations only.
379
- // deep mode is observations-only too (deepSearch fuses hybrid-obs lists).
380
- const effectiveType = deepMode === 'deep' ? 'observations' : (searchType || (args.obs_type ? 'observations' : undefined));
381
- const isCrossSource = !effectiveType;
382
- const ctx = { db, ftsQuery, searchType: effectiveType, args, epochFrom, epochTo, perSourceLimit, perSourceOffset, currentProject, limit };
383
- const results = [];
384
- let deepVariants = null;
385
- let deepReranked = false;
386
- let isDeep = deepMode === 'deep';
387
- let escalated = false;
388
- let escalatedObsCount = 0;
389
-
390
- // Helper: run deepSearch and load results into the shared `results` array.
391
- const runDeepInto = async ({ auto = false } = {}) => {
392
- const { results: deepRows, variants, reranked } = await deepSearch(db, {
393
- query: args.query,
394
- project: args.project || null,
395
- type: args.obs_type || null,
396
- importance: args.importance || null,
397
- branch: args.branch || null,
398
- includeNoise: args.include_noise === true,
399
- epochFrom, epochTo,
400
- limit: perSourceLimit,
401
- currentProject,
402
- }, llm ? { llm, rerank: rerank && !auto, rerankLlm } : { auto, rerank: rerank && !auto, rerankLlm });
403
- // Safe to reset: sessions/prompts are pushed AFTER the obs block, so nothing is lost here.
404
- results.length = 0;
405
- results.push(...deepRows);
406
- deepVariants = variants;
407
- deepReranked = reranked;
408
- };
409
-
410
- if (!effectiveType || effectiveType === 'observations') {
411
- if (deepMode === 'deep') {
412
- // Opt-in LLM multi-query/HyDE deep search: rewrite → per-variant hybrid
413
- // search → RRF fusion, collapsing to the single query (== baseline) when
414
- // the rewrite yields nothing (deep-search.mjs). Over-fetch perSourceLimit
415
- // so the pagination slice below has room.
416
- await runDeepInto();
417
- } else {
418
- results.push(...searchObservations(ctx));
419
- // Auto-escalate: if normal search is weak (too few results or OR fallback
420
- // fired — a vocabulary-mismatch symptom), escalate to deep. ctx is mutated
421
- // by searchObservations to set ctx.orFallbackFired when the AND→OR relaxation
422
- // fires, so we read it here after the call.
423
- // results is already obs-only here (sessions/prompts pushed below), but the
424
- // filter makes the invariant explicit and robust to future reordering.
425
- const obsCountBeforeEscalation = results.length;
426
- if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(results.filter(r => r.source === 'obs'), ctx, { db, project: args.project || null })) {
427
- await runDeepInto({ auto: true });
428
- isDeep = true;
429
- escalated = true;
430
- escalatedObsCount = obsCountBeforeEscalation;
431
- }
432
- }
433
- }
434
- // Sessions and prompts are excluded when deep (obs-only invariant, #8735).
435
- if ((!effectiveType || effectiveType === 'sessions') && !isDeep) results.push(...searchSessions(ctx));
436
- if ((!effectiveType || effectiveType === 'prompts') && !isDeep) results.push(...searchPrompts(ctx));
437
-
438
- // Type-list fallback: when obs_type is specified and FTS finds nothing,
439
- // list recent observations of that type (user likely wants to browse by type)
440
- if (results.length === 0 && args.obs_type) {
441
- const typeWheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL', 'type = ?'];
442
- const typeParams = [args.obs_type];
443
- if (args.project) { typeWheres.push('project = ?'); typeParams.push(args.project); }
444
- if (epochFrom !== null) { typeWheres.push('created_at_epoch >= ?'); typeParams.push(epochFrom); }
445
- if (epochTo !== null) { typeWheres.push('created_at_epoch <= ?'); typeParams.push(epochTo); }
446
- if (args.importance) { typeWheres.push('COALESCE(importance, 1) >= ?'); typeParams.push(args.importance); }
447
- typeParams.push(limit);
448
- const typeRows = db.prepare(`
449
- SELECT id, type, title, subtitle, project, created_at, importance, files_modified
450
- FROM observations WHERE ${typeWheres.join(' AND ')}
451
- ORDER BY created_at_epoch DESC LIMIT ?
452
- `).all(...typeParams);
453
- for (const r of typeRows) {
454
- 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: '' });
455
- }
456
- }
457
-
458
- // Cross-source score normalization (shared with CLI — lib/search-core.mjs):
459
- // normalize each source to [-1, 0] before merging so observations (BM25 can
460
- // reach -40) don't systematically outrank sessions (-6) and prompts (-1).
461
- if (isCrossSource && results.length > 0 && ftsQuery) {
462
- normalizeCrossSourceScores(results, 'source');
463
- }
464
-
465
- // Global sort (cross-source)
466
- if (isCrossSource && results.length > 0) {
467
- if (ftsQuery) {
468
- results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
469
- } else {
470
- results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
471
- }
472
- }
473
-
474
- // Re-rank observations by file context overlap and mark superseded.
475
- // markSuperseded is pure correctness (stale-tag) and must run for deep results
476
- // too, including the case where the ORIGINAL query sanitized to an empty
477
- // ftsQuery but the rewrite still returned rows (F2). reRankWithContext + the
478
- // re-sort are FTS-rank operations; deep rows are already RRF-ranked, so on the
479
- // empty-ftsQuery deep path we tag-but-don't-reorder (keep RRF order).
480
- if ((ftsQuery || isDeep) && results.some(r => r.source === 'obs')) {
481
- const obsResults = results.filter(r => r.source === 'obs');
482
- // When the deep candidates were explicitly LLM-reranked, that order is final:
483
- // skip the file-context re-rank + re-sort (they would perturb the rerank order
484
- // via score multiplication / score-sort). markSuperseded is pure stale-tagging
485
- // and still runs. (D#43 — parity with the CLI deep path, which keeps array order.)
486
- if (ftsQuery && !deepReranked) reRankWithContext(db, obsResults, currentProject);
487
- markSuperseded(obsResults);
488
- if (ftsQuery && !deepReranked) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
489
- }
490
-
491
- // Tier post-filter: batch-lookup full rows and classify (shared with CLI).
492
- // Classification uses the explicitly-requested project, not the CWD-inferred
493
- // one — see applyTierFilter for the cross-project rationale.
494
- if (args.tier) {
495
- const filtered = applyTierFilter(db, results, { tier: args.tier, sourceKey: 'source', currentProject: args.project || currentProject });
496
- results.length = 0;
497
- results.push(...filtered);
498
- }
260
+ if (args.project) args = { ...args, project: resolveProject(args.project) };
261
+ const limit = args.limit ?? 20;
262
+ const offset = args.offset ?? 0;
263
+ // args.or: force OR from the start (CLI `search --or` parity). The default path
264
+ // still does AND with the engine's OR-fallback when AND returns 0.
265
+ const ftsQuery = buildSearchFtsQuery(args.query, { or: args.or });
266
+ const currentProject = inferProject();
267
+
268
+ const bounds = parseDateBounds(args.date_from, args.date_to);
269
+ if (!bounds.ok) throw new Error(`Invalid date_${bounds.bad}: "${bounds.value}" (use ISO 8601 or YYYY-MM-DD)`);
270
+ const { epochFrom, epochTo } = bounds;
271
+
272
+ // MCP defaults to 'auto' (escalate on weak results) unless overridden by
273
+ // args.deep or CLAUDE_MEM_AUTO_DEEP. Rerank is explicit-deep only (D#43).
274
+ const deepMode = resolveDeepMode(args.deep, { surface: 'mcp' });
275
+ const rerank = args.rerank === true && deepMode === 'deep';
276
+
277
+ // Early return when query was provided but sanitized to nothing (all FTS5
278
+ // keywords/special chars). Skipped for deep/auto (the LLM rewrite may still
279
+ // produce variants) and for filter-only listings (date/obs_type/importance).
280
+ if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance && deepMode === 'normal') {
281
+ return { ...formatSearchOutput([], args, ftsQuery, 0), escalated: false, results: [], total: 0, variants: null };
282
+ }
499
283
 
500
- // Apply user-requested sort (after relevance scoring; shared with CLI)
501
- applyUserSort(results, args.sort || 'relevance');
502
-
503
- // `total` must be the TRUE population, invariant to limit/offset. In cross-source
504
- // mode results is over-fetched (perSourceLimit scales with limit+offset), so
505
- // results.length is NOT the population — count the real MATCH set instead. Clamp
506
- // to >= results.length so vector/concept-augmented obs rows are never undercounted.
507
- // (paired-path with mem-cli.mjs via shared countSearchTotal — #8217)
508
- // For deep (explicit or auto-escalated), the population is the fused variant set
509
- // already in `results` (deep is obs-only, returned by deepSearch capped at
510
- // perSourceLimit). countSearchTotal would count the ORIGINAL query's FTS matches
511
- // instead — wrong, and ~0 on the vocabulary-mismatch queries deep exists for (F1).
512
- const totalBeforePagination = isDeep
513
- ? results.length
514
- : Math.max(countSearchTotal(db, {
515
- effectiveSource: effectiveType || null,
516
- ftsQuery,
517
- obsFtsQuery: effectiveObsFtsQuery(ftsQuery, ctx.orFallbackFired === true),
518
- args: { project: args.project || null, obs_type: args.obs_type || null, importance: args.importance || null, branch: args.branch || null },
519
- project: args.project || null,
520
- epochFrom, epochTo,
521
- includeNoise: args.include_noise === true,
522
- }), results.length);
523
- // Always apply pagination — single-source results can exceed SQL LIMIT due to expansion (concept co-occurrence, PRF, vector search)
524
- const paginatedResults = (offset > 0 || results.length > limit) ? results.slice(offset, offset + limit) : results;
525
- // Enrich the FINAL page with a fetch-cost estimate (~Nt) so the agent budgets before mem_get.
526
- // Uses the same db threaded through the pipeline (#8743) — batch-fetches heavy obs fields by id.
527
- attachBodyTokens(db, paginatedResults);
528
-
529
- // Observability: announce auto-escalation on stderr (parity with CLI deep note).
530
- if (escalated) process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${escalatedObsCount} hits)\n`);
531
-
532
- const output = formatSearchOutput(paginatedResults, args, ftsQuery, totalBeforePagination, ctx.orFallbackFired === true, isDeep);
533
- // Surface the rewrite to the calling agent (CLI prints this to stderr + JSON;
534
- // MCP had no signal at all — F13). Tells the agent whether deep actually
535
- // reformulated the query or collapsed to the single-query baseline.
536
- if (isDeep && deepVariants && output.content?.[0]?.type === 'text') {
537
- output.content[0].text += deepVariants.length > 1
538
- ? `\n\n[deep search: rewrote into ${deepVariants.length} variants — ${deepVariants.slice(1).map(v => JSON.stringify(v)).join(', ')}]`
539
- : '\n\n[deep search: rewrite produced no usable variants; searched the original query only (== baseline)]';
540
- }
541
- // Discoverability signal for the opt-in rerank (D#43): tell the calling agent the
542
- // candidates were LLM-reranked — parity with the CLI stderr note.
543
- if (deepReranked && output.content?.[0]?.type === 'text') {
544
- output.content[0].text += '\n\n[deep search: LLM-reranked the top candidates by relevance]';
545
- }
284
+ // obs_type ⇒ observations-only; deep is observations-only too (deepSearch fuses
285
+ // hybrid-obs lists). args.type is the source filter (observations|sessions|prompts).
286
+ const effectiveType = deepMode === 'deep' ? 'observations' : (args.type || (args.obs_type ? 'observations' : undefined));
287
+
288
+ const r = await coreRunSearchPipeline(
289
+ {
290
+ db, currentProject, env: process.env,
291
+ searchObservationsHybrid, deepSearch, shouldEscalateToDeep, autoDeepLlmReady,
292
+ reRankWithContext, markSuperseded, llm, rerankLlm,
293
+ },
294
+ {
295
+ query: args.query, ftsQuery, effectiveSource: effectiveType, deepMode, rerank,
296
+ limit, offset, project: args.project ?? null, obsType: args.obs_type ?? null,
297
+ importance: args.importance ?? null, branch: args.branch ?? null,
298
+ includeNoise: args.include_noise === true, epochFrom, epochTo,
299
+ sort: args.sort || 'relevance', tier: args.tier ?? null,
300
+ // ── MCP surface policy ──
301
+ obsTypeFallback: true, // list-recent-by-type when 0 matches
302
+ crossSourceEpochSortNoFts: true, // epoch-sort cross-source with no ftsQuery
303
+ rerankPolicy: 'mcp', // (ftsQuery||isDeep) gate; re-rank/re-sort on ftsQuery&&!reranked
304
+ rerankProject: currentProject,
305
+ recentListingNoFts: true, // recent-listing for explicit --source with no ftsQuery
306
+ tolerateMissingFts: false,
307
+ tierPosition: 'late', // tier filter after re-rank
308
+ tierProject: args.project || currentProject,
309
+ }
310
+ );
311
+
312
+ // Observability: announce auto-escalation on stderr (parity with CLI deep note).
313
+ if (r.escalated) process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${r.escalatedObsCount} hits)\n`);
314
+
315
+ const output = formatSearchOutput(r.page, args, ftsQuery, r.total, r.orFallbackFired, r.isDeep);
316
+ // Surface the rewrite to the calling agent (F13) + the rerank signal (D#43).
317
+ if (r.isDeep && r.variants && output.content?.[0]?.type === 'text') {
318
+ output.content[0].text += r.variants.length > 1
319
+ ? `\n\n[deep search: rewrote into ${r.variants.length} variants ${r.variants.slice(1).map(v => JSON.stringify(v)).join(', ')}]`
320
+ : '\n\n[deep search: rewrite produced no usable variants; searched the original query only (== baseline)]';
321
+ }
322
+ if (r.reranked && output.content?.[0]?.type === 'text') {
323
+ output.content[0].text += '\n\n[deep search: LLM-reranked the top candidates by relevance]';
324
+ }
546
325
 
547
- // Return an object that exposes structured fields for tests + the MCP content blob.
548
- return { ...output, results: paginatedResults, total: totalBeforePagination, escalated, variants: deepVariants, reranked: deepReranked };
326
+ // Expose structured fields for tests + the MCP content blob.
327
+ return { ...output, results: r.page, total: r.total, escalated: r.escalated, variants: r.variants, reranked: r.reranked };
549
328
  }
550
329
 
551
330
  server.registerTool(
package/source-files.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  export const SOURCE_FILES = [
8
8
  // Entry points and top-level modules
9
- 'cli.mjs', 'cli-path.mjs', 'server.mjs', 'server-internals.mjs', 'search-engine.mjs', 'deep-search.mjs', 'rerank.mjs', 'tool-schemas.mjs',
9
+ 'cli.mjs', 'cli-path.mjs', 'server.mjs', 'search-scoring.mjs', 'search-engine.mjs', 'deep-search.mjs', 'rerank.mjs', 'tool-schemas.mjs',
10
10
  'hook.mjs', 'hook-shared.mjs', 'hook-llm.mjs', 'hook-memory.mjs', 'skip-tools.mjs',
11
11
  'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs', 'hook-handoff.mjs',
12
12
  'hook-update.mjs', 'hook-optimize.mjs', 'hook-precompact.mjs',