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.
@@ -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 = [];
@@ -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
+ }
package/mem-cli.mjs CHANGED
@@ -8,8 +8,8 @@ import { truncate, typeIcon, inferProject, scrubSecrets } from './utils.mjs';
8
8
  import { resolveProject } from './project-utils.mjs';
9
9
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
10
10
  import { _resetVocabCache } from './tfidf.mjs';
11
- import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
12
- import { searchObservationsHybrid, countSearchTotal, attachBodyTokens } from './search-engine.mjs';
11
+ import { autoBoostIfNeeded, reRankWithContext, markSuperseded } 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 { ensureRegistryDb, upsertResource } from './registry.mjs';
15
15
  import { searchResources } from './registry-retriever.mjs';
@@ -37,7 +37,7 @@ import { saveObservation } from './lib/save-observation.mjs';
37
37
  import { rebuildObservationDerived } from './lib/observation-write.mjs';
38
38
  import { recallByFile } from './lib/recall-core.mjs';
39
39
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
40
- import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
40
+ import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './lib/search-core.mjs';
41
41
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
42
42
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
43
43
  import { computeCitationFunnelTrend } from './lib/citation-tracker.mjs';
@@ -156,116 +156,51 @@ async function cmdSearch(db, args, { llm } = {}) {
156
156
  ? 'observations'
157
157
  : (source || ((type || tier || minImportance || branch) ? 'observations' : null));
158
158
 
159
- // Cross-source mode: each source needs more candidates than the final limit
160
- // so the post-merge sort has room to pick the best from each (shared sizing
161
- // with mem_search without this, obs gets systematically squeezed out by
162
- // sessions). Over-fetch from offset 0; --offset applies ONCE at the final
163
- // slice below (see computePerSourceWindow for the #8217/#8638 rationale).
164
- const isCrossSourceMode = !effectiveSource;
165
- const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
166
-
167
- const results = [];
168
- // Tracks whether AND returned 0 and OR recovered non-empty. Mirrors server.mjs
169
- // ctx.orFallbackFired so the header can surface a "(relaxed AND→OR)" hint.
170
- let orFallbackFired = false;
171
-
172
- let deepVariants = null;
173
- let isReranked = false;
174
- let isDeep = deepMode === 'deep';
175
-
176
- // Search observations shared engine with server.mjs (#8198/#8212 paired-path fix)
177
- if (!effectiveSource || effectiveSource === 'observations') {
178
- const obsCtx = {
179
- ftsQuery,
180
- args: {
181
- project: project || null,
182
- obs_type: type || null,
183
- importance: minImportance || null,
184
- branch: branch || null,
185
- include_noise: includeNoise,
186
- },
187
- epochFrom: dateFrom,
188
- epochTo: dateTo,
189
- perSourceLimit,
190
- perSourceOffset,
191
- currentProject: project ? null : inferProject(),
192
- limit,
193
- orFallbackFired: false,
194
- };
195
-
196
- const runDeep = async ({ auto = false } = {}) => {
197
- const ds = await deepSearch(db, {
198
- query,
199
- project: project || null,
200
- type: type || null,
201
- importance: minImportance || null,
202
- branch: branch || null,
203
- includeNoise,
204
- epochFrom: dateFrom,
205
- epochTo: dateTo,
206
- limit: perSourceLimit,
207
- currentProject: project ? null : inferProject(),
208
- }, llm ? { llm, rerank: rerank && !auto } : { auto, rerank: rerank && !auto });
209
- deepVariants = ds.variants;
210
- isReranked = ds.reranked;
211
- if (deepVariants.length > 1) {
212
- process.stderr.write(`[mem] Deep search: rewrote into ${deepVariants.length} query variants, RRF-fused\n`);
213
- } else {
214
- process.stderr.write('[mem] Deep search: rewrite returned no usable variants; used original query only\n');
215
- }
216
- if (rerank && !auto) {
217
- process.stderr.write(ds.reranked
218
- ? '[mem] Deep search: LLM-reranked the fused top-20\n'
219
- : '[mem] Deep search: rerank produced no usable order; kept fused order\n');
220
- }
221
- return ds.results;
222
- };
223
-
224
- let obsResults;
225
- if (deepMode === 'deep') {
226
- obsResults = await runDeep();
227
- } else {
228
- obsResults = searchObservationsHybrid(db, obsCtx);
229
- if (obsCtx.orFallbackFired) orFallbackFired = true;
230
- if (deepMode === 'auto' && autoDeepLlmReady(process.env, llm) && shouldEscalateToDeep(obsResults, obsCtx, { db, project: project || null })) {
231
- process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${obsResults.length} hits)\n`);
232
- obsResults = await runDeep({ auto: true });
233
- isDeep = true;
234
- }
235
- }
236
- for (const r of obsResults) results.push({ ...r, _source: 'obs', score: r.score ?? 0 });
237
-
238
- // Tier post-filter — applied to ALL obs results from the engine.
239
- if (tier) {
240
- const filtered = applyTierFilter(db, results, { tier, sourceKey: '_source', currentProject: project || inferProject() });
241
- results.length = 0;
242
- results.push(...filtered);
243
- }
244
- }
245
-
246
- // Search sessions (shared engine with MCP mem_search — lib/search-core.mjs)
247
- if ((!effectiveSource || effectiveSource === 'sessions') && !isDeep) {
248
- try {
249
- const sessRows = searchSessionsFts(db, {
250
- ftsQuery, project, projectBoost: project ? null : inferProject(),
251
- epochFrom: dateFrom, epochTo: dateTo, perSourceLimit, perSourceOffset,
252
- });
253
- for (const r of sessRows) results.push({ ...r, _source: 'session' });
254
- } catch { /* session FTS may not exist in older DBs */ }
255
- }
256
-
257
- // Search prompts (shared engine incl. CJK precision gate + LIKE fallback)
258
- if ((!effectiveSource || effectiveSource === 'prompts') && !isDeep) {
259
- try {
260
- const promptRows = searchPromptsFts(db, {
261
- query, ftsQuery, project,
262
- epochFrom: dateFrom, epochTo: dateTo, perSourceLimit, perSourceOffset,
263
- });
264
- for (const r of promptRows) results.push({ ...r, _source: 'prompt' });
265
- } catch { /* prompt FTS may not exist in older DBs */ }
266
- }
267
-
268
- if (results.length === 0) {
159
+ const res = await coreRunSearchPipeline(
160
+ {
161
+ db, currentProject: project ? null : inferProject(), env: process.env,
162
+ searchObservationsHybrid, deepSearch, shouldEscalateToDeep, autoDeepLlmReady,
163
+ reRankWithContext, markSuperseded, llm,
164
+ },
165
+ {
166
+ query, ftsQuery, effectiveSource, deepMode, rerank,
167
+ limit, offset, project: project || null, obsType: type, importance: minImportance,
168
+ branch, includeNoise, epochFrom: dateFrom, epochTo: dateTo, sort, tier,
169
+ // ── CLI surface policy ──
170
+ obsTypeFallback: false, // #8217 removed list-by-type fallback from the CLI
171
+ crossSourceEpochSortNoFts: false, // CLI never reaches cross-source with empty ftsQuery (fails earlier)
172
+ rerankPolicy: 'cli', // re-rank/supersede on any obs; re-sort gated on cross-source
173
+ rerankProject: project || inferProject(),
174
+ recentListingNoFts: false,
175
+ tolerateMissingFts: true, // pre-FTS legacy DBs: swallow session/prompt FTS errors
176
+ tierPosition: 'early', // tier filter inside the obs block (before sessions/prompts)
177
+ tierProject: project || inferProject(),
178
+ }
179
+ );
180
+ const isDeep = res.isDeep;
181
+ const orFallbackFired = res.orFallbackFired;
182
+ const deepVariants = res.variants;
183
+ const paged = res.page;
184
+ const total = res.total;
185
+
186
+ // Deep / escalation observability on stderr — reconstructed from core signals.
187
+ // The CLI emitted these inline in runDeep; same strings, same order (escalation →
188
+ // variants → rerank). rerank is only ever true on explicit --deep (never auto).
189
+ if (res.escalated) process.stderr.write(`[mem] auto-escalated to deep search (weak results: ${res.escalatedObsCount} hits)\n`);
190
+ if (isDeep && deepVariants) {
191
+ process.stderr.write(deepVariants.length > 1
192
+ ? `[mem] Deep search: rewrote into ${deepVariants.length} query variants, RRF-fused\n`
193
+ : '[mem] Deep search: rewrite returned no usable variants; used original query only\n');
194
+ }
195
+ if (rerank) {
196
+ process.stderr.write(res.reranked
197
+ ? '[mem] Deep search: LLM-reranked the fused top-20\n'
198
+ : '[mem] Deep search: rerank produced no usable order; kept fused order\n');
199
+ }
200
+
201
+ // "nothing matched" (no offset) vs "this page is empty" (with offset) — the two
202
+ // CLI messages. preFinalizeCount is the pre-pagination population (post-tier).
203
+ if (res.preFinalizeCount === 0) {
269
204
  if (jsonOutput) {
270
205
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: isDeep, variants: isDeep ? deepVariants : undefined, results: [] }));
271
206
  } else {
@@ -274,60 +209,6 @@ async function cmdSearch(db, args, { llm } = {}) {
274
209
  return;
275
210
  }
276
211
 
277
- // Cross-source score normalization (shared with mem_search).
278
- // ftsQuery gate prevents normalization when scores are all 0 (no-FTS path).
279
- const isCrossSource = isCrossSourceMode;
280
- if (isCrossSource && results.length > 0 && ftsQuery) {
281
- normalizeCrossSourceScores(results, '_source');
282
- results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
283
- }
284
-
285
- // Context re-ranking + superseded marking (aligned with MCP mem_search)
286
- const obsResults = results.filter(r => r._source === 'obs');
287
- if (obsResults.length > 0) {
288
- // reRankWithContext/markSuperseded expect source='obs' — alias _source for compatibility
289
- for (const r of obsResults) r.source = 'obs';
290
- // Explicit LLM rerank order is final — skip file-context re-rank when reranked
291
- // (paired-path with mem_search; markSuperseded still runs for stale-tagging).
292
- if (!isReranked) reRankWithContext(db, obsResults, project || inferProject());
293
- markSuperseded(obsResults);
294
- if (isCrossSource) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
295
- }
296
-
297
- // Apply user-requested sort (after relevance scoring; shared with mem_search)
298
- applyUserSort(results, sort);
299
-
300
- // Trim to limit with offset. The engine always received perSourceOffset=0 and
301
- // over-fetched (see above), so the merged+reranked `results` start at row 0 and
302
- // the offset is applied exactly ONCE here — for every mode.
303
- //
304
- // `total` must be the TRUE population, independent of --limit/--offset (else the
305
- // over-fetched candidate count grew with the page and broke the "N of M" /
306
- // pagination contract). countSearchTotal mirrors each source's MATCH+filters;
307
- // clamp to >= results.length so it never understates the rows actually shown
308
- // (vector/concept augmentation can add obs rows beyond the FTS count).
309
- // For --deep the population is the fused variant result set: deepSearch already
310
- // returned all fused rows (capped at perSourceLimit) and they are the only rows
311
- // in `results` (deep is obs-only). countSearchTotal would instead count the
312
- // ORIGINAL query's FTS matches — wrong, and ~0 on the vocabulary-mismatch
313
- // queries deep exists for, which falsely shrinks the "N of M" total (F1).
314
- const total = isDeep
315
- ? results.length
316
- : Math.max(countSearchTotal(db, {
317
- effectiveSource,
318
- ftsQuery,
319
- obsFtsQuery: effectiveObsFtsQuery(ftsQuery, orFallbackFired),
320
- args: { project: project || null, obs_type: type || null, importance: minImportance || null, branch: branch || null },
321
- project: project || null,
322
- epochFrom: dateFrom,
323
- epochTo: dateTo,
324
- includeNoise,
325
- }), results.length);
326
- const paged = results.slice(offset, offset + limit);
327
- // Enrich the final page with the ~Nt fetch-cost hint (paired with MCP mem_search; #8654 both
328
- // source keys handled). Batch-fetches heavy obs fields by id — no-op on an empty page.
329
- attachBodyTokens(db, paged);
330
-
331
212
  if (paged.length === 0) {
332
213
  if (jsonOutput) {
333
214
  out(JSON.stringify({ query, total, returned: 0, offset, limit, deep: isDeep, variants: isDeep ? deepVariants : undefined, results: [] }));
@@ -337,24 +218,24 @@ async function cmdSearch(db, args, { llm } = {}) {
337
218
  return;
338
219
  }
339
220
 
340
- // paired-path with server.mjs formatSearchOutput (#8198): "N of M" total when paged < total.
221
+ // "N of M" total when paged < total (paired-path with server.mjs formatSearchOutput, #8198).
341
222
  const showTime = sort === 'time';
342
- const hasMixed = paged.some(r => r._source === 'session' || r._source === 'prompt');
223
+ const hasMixed = paged.some(r => r.source === 'session' || r.source === 'prompt');
343
224
  // Suppressed when --or was explicit — user already asked for OR, no "fallback" there.
344
225
  const fallbackHint = orFallbackFired && !useOr ? ' (relaxed AND→OR)' : '';
345
226
 
346
227
  if (jsonOutput) {
347
228
  const items = paged.map(r => {
348
229
  const base = {
349
- source: r._source,
230
+ source: r.source,
350
231
  id: r.id,
351
232
  created_at: r.created_at,
352
233
  score: r.score ?? null,
353
234
  };
354
- if (r._source === 'session') {
235
+ if (r.source === 'session') {
355
236
  return { ...base, request: r.request || null, completed: r.completed || null, project: r.project || null };
356
237
  }
357
- if (r._source === 'prompt') {
238
+ if (r.source === 'prompt') {
358
239
  return { ...base, prompt_text: r.prompt_text || null };
359
240
  }
360
241
  return {
@@ -392,10 +273,10 @@ async function cmdSearch(db, args, { llm } = {}) {
392
273
  const tok = r => (r.bodyTokens ? ` ~${r.bodyTokens}t` : '');
393
274
  for (const r of paged) {
394
275
  const timeStr = showTime && r.created_at_epoch ? ` (${relativeTime(r.created_at_epoch)})` : '';
395
- if (r._source === 'session') {
276
+ if (r.source === 'session') {
396
277
  const date = fmtDateShort(r.created_at);
397
278
  out(`S#${r.id} 📋 ${date}${timeStr} ${truncate(r.request || r.completed || '(no summary)', 80)}${tok(r)}`);
398
- } else if (r._source === 'prompt') {
279
+ } else if (r.source === 'prompt') {
399
280
  const date = fmtDateShort(r.created_at);
400
281
  out(`P#${r.id} 💬 ${date}${timeStr} ${truncate(r.prompt_text || '(empty)', 80)}${tok(r)}`);
401
282
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -28,7 +28,7 @@
28
28
  "cli-path.mjs",
29
29
  "mem-cli.mjs",
30
30
  "server.mjs",
31
- "server-internals.mjs",
31
+ "search-scoring.mjs",
32
32
  "search-engine.mjs",
33
33
  "deep-search.mjs",
34
34
  "rerank.mjs",