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.
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.8.0",
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",
@@ -73,6 +73,7 @@
73
73
  "lib/binding-probe.mjs",
74
74
  "lib/proc-lock.mjs",
75
75
  "lib/atomic-write.mjs",
76
+ "lib/release-digest.mjs",
76
77
  "lib/mem-override.mjs",
77
78
  "lib/save-observation.mjs",
78
79
  "lib/observation-write.mjs",
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/scripts/setup.sh CHANGED
@@ -86,6 +86,7 @@ mark_deps_broken() {
86
86
  # having to re-derive them. Delegate JSON serialization to node so embedded
87
87
  # quotes / shell metachars in $ROOT or $reason can't produce an invalid file
88
88
  # (bash `printf '"..%s.."'` cannot escape arbitrary strings safely; v2.79.1 fix).
89
+ # shellcheck disable=SC2016 # node script single-quoted on purpose; vars passed via env (MARK_*), not shell expansion
89
90
  MARK_REASON="$reason" MARK_ROOT="$ROOT" MARK_FLAG="$DEPS_FLAG" node -e '
90
91
  const fs = require("fs");
91
92
  const reason = process.env.MARK_REASON || "unknown";
@@ -141,6 +142,7 @@ fi
141
142
  # versions; same shape as the .deps-broken self-heal pattern.
142
143
  MCP_MIGRATION="$DATA_DIR/runtime/.mcp-dedup-v2.78"
143
144
  if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$MCP_MIGRATION" ]]; then
145
+ # shellcheck disable=SC2016 # node script single-quoted on purpose; CLAUDE_JSON passed via env, not shell expansion
144
146
  CLAUDE_JSON="$HOME/.claude.json" node -e '
145
147
  const fs = require("fs");
146
148
  let changed = false;
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';