claude-mem-lite 2.98.0 → 3.0.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
@@ -4,14 +4,12 @@
4
4
 
5
5
  import { homedir } from 'os';
6
6
  import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, scrubSecrets, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS } from './utils.mjs';
8
- import { cjkPrecisionOk } from './nlp.mjs';
9
- import { extractCjkLikePatterns } from './nlp.mjs';
7
+ import { truncate, typeIcon, inferProject, scrubSecrets } from './utils.mjs';
10
8
  import { resolveProject } from './project-utils.mjs';
11
- import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
9
+ import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
12
10
  import { _resetVocabCache } from './tfidf.mjs';
13
11
  import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
14
- import { searchObservationsHybrid, findFtsAnchor, countSearchTotal } from './search-engine.mjs';
12
+ import { searchObservationsHybrid, countSearchTotal } from './search-engine.mjs';
15
13
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
16
14
  import { searchResources } from './registry-retriever.mjs';
17
15
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
@@ -37,6 +35,8 @@ import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatP
37
35
  import { saveObservation } from './lib/save-observation.mjs';
38
36
  import { rebuildObservationDerived } from './lib/observation-write.mjs';
39
37
  import { recallByFile } from './lib/recall-core.mjs';
38
+ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
39
+ import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
40
40
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
41
41
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
42
42
  import {
@@ -63,11 +63,9 @@ function cmdSearch(db, args) {
63
63
  }
64
64
  const source = flags.source || null; // observations|sessions|prompts (null = all)
65
65
  const project = flags.project ? resolveProject(db, flags.project) : null;
66
- const dateFrom = flags.from ? new Date(flags.from).getTime() : null;
67
- let dateTo = flags.to ? new Date(flags.to).getTime() : null;
68
- if (dateTo && flags.to && /^\d{4}-\d{2}-\d{2}$/.test(flags.to)) dateTo += 86400000 - 1;
69
- if (flags.from && isNaN(dateFrom)) { fail(`[mem] Invalid --from date: "${flags.from}". Use YYYY-MM-DD or ISO 8601.`); return; }
70
- if (flags.to && isNaN(dateTo)) { fail(`[mem] Invalid --to date: "${flags.to}". Use YYYY-MM-DD or ISO 8601.`); return; }
66
+ const bounds = parseDateBounds(flags.from, flags.to);
67
+ if (!bounds.ok) { fail(`[mem] Invalid --${bounds.bad} date: "${bounds.value}". Use YYYY-MM-DD or ISO 8601.`); return; }
68
+ const { epochFrom: dateFrom, epochTo: dateTo } = bounds;
71
69
  // Inverted range silently returns 0 rows; warn so users see the cause, don't error
72
70
  // (a deliberate "search for nothing in this window" is not malformed input).
73
71
  if (dateFrom !== null && dateTo !== null && dateFrom > dateTo) {
@@ -106,8 +104,7 @@ function cmdSearch(db, args) {
106
104
  return;
107
105
  }
108
106
 
109
- let ftsQuery = sanitizeFtsQuery(query);
110
- if (ftsQuery && useOr) ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
107
+ const ftsQuery = buildSearchFtsQuery(query, { or: useOr });
111
108
  if (!ftsQuery) {
112
109
  fail(`[mem] No valid search terms in "${query}"`);
113
110
  return;
@@ -126,17 +123,12 @@ function cmdSearch(db, args) {
126
123
  const effectiveSource = source || ((type || tier || minImportance || branch) ? 'observations' : null);
127
124
 
128
125
  // Cross-source mode: each source needs more candidates than the final limit
129
- // so the post-merge sort has room to pick the best from each (paired-path with
130
- // server.mjs:377 — without this, obs gets systematically squeezed out by sessions).
126
+ // so the post-merge sort has room to pick the best from each (shared sizing
127
+ // with mem_search — without this, obs gets systematically squeezed out by
128
+ // sessions). Over-fetch from offset 0; --offset applies ONCE at the final
129
+ // slice below (see computePerSourceWindow for the #8217/#8638 rationale).
131
130
  const isCrossSourceMode = !effectiveSource;
132
- // Over-fetch from offset 0 and apply --offset ONCE at the final slice (below) in
133
- // ALL modes — mirrors server.mjs. Pushing OFFSET into the obs hybrid path was
134
- // unreliable: its AND→OR fallback / vector / concept-cooccurrence stages re-add
135
- // rows the SQL OFFSET already skipped, so engine-side paging dropped (or
136
- // duplicated) rows on the --type/--tier/--importance/--branch path (a page that
137
- // MCP returned came back empty).
138
- const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
139
- const perSourceOffset = 0;
131
+ const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
140
132
 
141
133
  const results = [];
142
134
  // Tracks whether AND returned 0 and OR recovered non-empty. Mirrors server.mjs
@@ -168,107 +160,31 @@ function cmdSearch(db, args) {
168
160
 
169
161
  // Tier post-filter — applied to ALL obs results from the engine.
170
162
  if (tier) {
171
- const obsInResults = results.filter(r => r._source === 'obs');
172
- if (obsInResults.length > 0) {
173
- const obsIds = obsInResults.map(r => r.id);
174
- const ph = obsIds.map(() => '?').join(',');
175
- const fullRows = db.prepare(
176
- `SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${ph})`
177
- ).all(...obsIds);
178
- const rowMap = new Map(fullRows.map(r => [r.id, r]));
179
- const tierCtx = { now: Date.now(), currentProject: project || inferProject(), currentSessionId: '' };
180
- const allowedIds = new Set();
181
- for (const [id, full] of rowMap) {
182
- if (computeTier(full, tierCtx) === tier) allowedIds.add(id);
183
- }
184
- for (let i = results.length - 1; i >= 0; i--) {
185
- if (results[i]._source === 'obs' && !allowedIds.has(results[i].id)) results.splice(i, 1);
186
- }
187
- }
163
+ const filtered = applyTierFilter(db, results, { tier, sourceKey: '_source', currentProject: project || inferProject() });
164
+ results.length = 0;
165
+ results.push(...filtered);
188
166
  }
189
167
  }
190
168
 
191
- // Search sessions (aligned with MCP mem_search)
169
+ // Search sessions (shared engine with MCP mem_search — lib/search-core.mjs)
192
170
  if (!effectiveSource || effectiveSource === 'sessions') {
193
- const now = Date.now();
194
- const sessionProjectBoost = project ? null : inferProject();
195
- const sessWheres = ['session_summaries_fts MATCH ?'];
196
- const sessParams = [now, sessionProjectBoost, sessionProjectBoost, ftsQuery];
197
- if (project) { sessWheres.push('s.project = ?'); sessParams.push(project); }
198
- if (dateFrom) { sessWheres.push('s.created_at_epoch >= ?'); sessParams.push(dateFrom); }
199
- if (dateTo) { sessWheres.push('s.created_at_epoch <= ?'); sessParams.push(dateTo); }
200
- sessParams.push(perSourceLimit, perSourceOffset);
201
171
  try {
202
- const sessRows = db.prepare(`
203
- SELECT s.id, s.request, s.completed, s.project, s.created_at, s.created_at_epoch,
204
- ${SESS_BM25}
205
- * (1.0 + EXP(-0.693 * (? - s.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
206
- * (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
207
- FROM session_summaries_fts
208
- JOIN session_summaries s ON session_summaries_fts.rowid = s.id
209
- WHERE ${sessWheres.join(' AND ')}
210
- ORDER BY score
211
- LIMIT ? OFFSET ?
212
- `).all(...sessParams);
172
+ const sessRows = searchSessionsFts(db, {
173
+ ftsQuery, project, projectBoost: project ? null : inferProject(),
174
+ epochFrom: dateFrom, epochTo: dateTo, perSourceLimit, perSourceOffset,
175
+ });
213
176
  for (const r of sessRows) results.push({ ...r, _source: 'session' });
214
177
  } catch { /* session FTS may not exist in older DBs */ }
215
178
  }
216
179
 
217
- // Search prompts (aligned with MCP mem_search)
180
+ // Search prompts (shared engine incl. CJK precision gate + LIKE fallback)
218
181
  if (!effectiveSource || effectiveSource === 'prompts') {
219
- const promptWheres = ['user_prompts_fts MATCH ?', "p.prompt_text NOT LIKE '<task-notification>%'"];
220
- const promptParams = [ftsQuery];
221
- if (project) { promptWheres.push('s.project = ?'); promptParams.push(project); }
222
- if (dateFrom) { promptWheres.push('p.created_at_epoch >= ?'); promptParams.push(dateFrom); }
223
- if (dateTo) { promptWheres.push('p.created_at_epoch <= ?'); promptParams.push(dateTo); }
224
- promptParams.push(perSourceLimit, perSourceOffset);
225
182
  try {
226
- const promptRows = db.prepare(`
227
- SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch,
228
- bm25(user_prompts_fts, 1) as score
229
- FROM user_prompts_fts
230
- JOIN user_prompts p ON user_prompts_fts.rowid = p.id
231
- JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
232
- WHERE ${promptWheres.join(' AND ')}
233
- ORDER BY score
234
- LIMIT ? OFFSET ?
235
- `).all(...promptParams);
236
- // CJK precision filter (read-path parity with server.mjs): unicode61
237
- // degrades bigram queries to single-char AND, letting common-char
238
- // Chinese prose leak through. Drop rows that miss < 20% of query
239
- // bigrams/keywords as contiguous substrings.
240
- const keptPromptRows = promptRows.filter(r => cjkPrecisionOk(query, r.prompt_text));
241
- for (const r of keptPromptRows) results.push({ ...r, _source: 'prompt' });
242
- // CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
243
- if (keptPromptRows.length === 0) {
244
- const cjkPatterns = extractCjkLikePatterns(query);
245
- if (cjkPatterns.length > 0) {
246
- const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
247
- const likeParams = cjkPatterns.map(p => `%${p}%`);
248
- if (project) likeParams.push(project);
249
- if (dateFrom) likeParams.push(dateFrom);
250
- if (dateTo) likeParams.push(dateTo);
251
- likeParams.push(perSourceLimit, perSourceOffset);
252
- const fallbackRows = db.prepare(`
253
- SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
254
- FROM user_prompts p
255
- JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
256
- WHERE (${likeConds.join(' OR ')})
257
- AND p.prompt_text NOT LIKE '<task-notification>%'
258
- ${project ? 'AND s.project = ?' : ''}
259
- ${dateFrom ? 'AND p.created_at_epoch >= ?' : ''}
260
- ${dateTo ? 'AND p.created_at_epoch <= ?' : ''}
261
- ORDER BY p.created_at_epoch DESC
262
- LIMIT ? OFFSET ?
263
- `).all(...likeParams);
264
- // CJK precision filter applies here too: the LIKE fallback is just
265
- // OR'd substring bigrams; without the precision gate it re-admits
266
- // the same common-char noise the FTS path dropped (this was the
267
- // actual leak source — FTS returned 0, fallback filled 20).
268
- const keptFallback = fallbackRows.filter(r => cjkPrecisionOk(query, r.prompt_text));
269
- for (const r of keptFallback) results.push({ ...r, _source: 'prompt', score: 0 });
270
- }
271
- }
183
+ const promptRows = searchPromptsFts(db, {
184
+ query, ftsQuery, project,
185
+ epochFrom: dateFrom, epochTo: dateTo, perSourceLimit, perSourceOffset,
186
+ });
187
+ for (const r of promptRows) results.push({ ...r, _source: 'prompt' });
272
188
  } catch { /* prompt FTS may not exist in older DBs */ }
273
189
  }
274
190
 
@@ -281,18 +197,11 @@ function cmdSearch(db, args) {
281
197
  return;
282
198
  }
283
199
 
284
- // Cross-source score normalization (paired-path with server.mjs:428).
200
+ // Cross-source score normalization (shared with mem_search).
285
201
  // ftsQuery gate prevents normalization when scores are all 0 (no-FTS path).
286
202
  const isCrossSource = isCrossSourceMode;
287
203
  if (isCrossSource && results.length > 0 && ftsQuery) {
288
- for (const src of ['obs', 'session', 'prompt']) {
289
- const srcResults = results.filter(r => r._source === src && r.score !== null && r.score !== undefined);
290
- if (srcResults.length < 2) continue;
291
- const maxAbs = Math.max(...srcResults.map(r => Math.abs(r.score)));
292
- if (maxAbs > 0) {
293
- for (const r of srcResults) r.score = r.score / maxAbs;
294
- }
295
- }
204
+ normalizeCrossSourceScores(results, '_source');
296
205
  results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
297
206
  }
298
207
 
@@ -306,13 +215,8 @@ function cmdSearch(db, args) {
306
215
  if (isCrossSource) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
307
216
  }
308
217
 
309
- // Apply user-requested sort (after relevance scoring)
310
- if (sort === 'time') {
311
- results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
312
- } else if (sort === 'importance') {
313
- results.sort((a, b) => (b.importance ?? 1) - (a.importance ?? 1) || (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
314
- }
315
- // else 'relevance' keeps BM25 score order (already sorted)
218
+ // Apply user-requested sort (after relevance scoring; shared with mem_search)
219
+ applyUserSort(results, sort);
316
220
 
317
221
  // Trim to limit with offset. The engine always received perSourceOffset=0 and
318
222
  // over-fetched (see above), so the merged+reranked `results` start at row 0 and
@@ -326,7 +230,7 @@ function cmdSearch(db, args) {
326
230
  const trueTotal = countSearchTotal(db, {
327
231
  effectiveSource,
328
232
  ftsQuery,
329
- obsFtsQuery: orFallbackFired ? (relaxFtsQueryToOr(ftsQuery) || ftsQuery) : ftsQuery,
233
+ obsFtsQuery: effectiveObsFtsQuery(ftsQuery, orFallbackFired),
330
234
  args: { project: project || null, obs_type: type || null, importance: minImportance || null, branch: branch || null },
331
235
  project: project || null,
332
236
  epochFrom: dateFrom,
@@ -713,113 +617,39 @@ function cmdTimeline(db, args) {
713
617
  });
714
618
 
715
619
  // Parse --anchor, accepting P#/S#/# prefix so callers can paste search-result IDs verbatim.
716
- // For prompt/session anchors, resolve to the nearest-in-time observation so timeline semantics
717
- // (before/after observations) still apply.
620
+ // Resolution ladder (prompt/session nearest obs, compressed re-anchor, bare-int
621
+ // fallback) is shared with MCP mem_timeline via lib/timeline-core.mjs.
718
622
  let anchorId = null;
719
623
  let anchorNote = null; // hint line for output when anchor was resolved via conversion
720
624
  if (flags.anchor !== undefined && flags.anchor !== true) {
721
- const parsed = parseIdToken(flags.anchor);
722
- if (!parsed) {
723
- fail(`[mem] Invalid --anchor "${flags.anchor}". Expected N, #N, P#N, or S#N.`);
625
+ const resolved = resolveAnchorToken(db, flags.anchor, { project });
626
+ if (!resolved.ok) {
627
+ fail(formatAnchorError(resolved.error, 'cli'));
724
628
  return;
725
629
  }
726
- if (parsed.source === 'prompt') {
727
- const row = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
728
- if (!row) { fail(`[mem] Prompt P#${parsed.id} not found`); return; }
729
- const proj = project;
730
- const nearest = db.prepare(`
731
- SELECT id FROM observations
732
- WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
733
- ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
734
- `).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
735
- if (!nearest) { fail(`[mem] No observations near P#${parsed.id}`); return; }
736
- anchorId = nearest.id;
737
- anchorNote = `(anchored to #${nearest.id}, closest obs to P#${parsed.id})`;
738
- } else if (parsed.source === 'session') {
739
- const row = db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
740
- if (!row) { fail(`[mem] Session S#${parsed.id} not found`); return; }
741
- const proj = project;
742
- const nearest = db.prepare(`
743
- SELECT id FROM observations
744
- WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
745
- ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
746
- `).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
747
- if (!nearest) { fail(`[mem] No observations near S#${parsed.id}`); return; }
748
- anchorId = nearest.id;
749
- anchorNote = `(anchored to #${nearest.id}, closest obs to S#${parsed.id})`;
750
- } else {
751
- // Bare integer (no prefix): try observation first. Fall back to user_prompts
752
- // then session_summaries so pasted P#/S# IDs still work when the prefix is
753
- // omitted — matches the prefix-aware routing used by search/probe.
754
- const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
755
- if (obsRow) {
756
- const ci = obsRow.compressed_into;
757
- if (ci && ci > 0) {
758
- // Compressed into a live parent: re-anchor so the window doesn't silently
759
- // straddle a dead record. Negative sentinels (-1 dropped, -2 pending purge)
760
- // have no canonical parent — surface an explicit error instead.
761
- anchorId = ci;
762
- anchorNote = `(anchored to #${ci}, #${parsed.id} was compressed into it)`;
763
- } else if (ci && ci < 0) {
764
- fail(`[mem] Observation #${parsed.id} was compressed and pruned; no canonical anchor available`);
765
- return;
766
- } else {
767
- anchorId = parsed.id;
768
- }
769
- } else {
770
- const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
771
- const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
772
- const hit = promptRow ? { row: promptRow, prefix: 'P', name: 'prompt' }
773
- : sessionRow ? { row: sessionRow, prefix: 'S', name: 'session' }
774
- : null;
775
- if (!hit) {
776
- fail(`[mem] Observation, prompt, or session with id ${parsed.id} not found`);
777
- return;
778
- }
779
- const proj = project;
780
- const nearest = db.prepare(`
781
- SELECT id FROM observations
782
- WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
783
- ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
784
- `).get(...(proj ? [proj, hit.row.created_at_epoch] : [hit.row.created_at_epoch]));
785
- if (!nearest) { fail(`[mem] No observations near ${hit.prefix}#${parsed.id} (${hit.name})`); return; }
786
- anchorId = nearest.id;
787
- anchorNote = `(anchored to #${nearest.id}, closest obs to ${hit.prefix}#${parsed.id})`;
788
- }
789
- }
630
+ anchorId = resolved.anchorId;
631
+ anchorNote = resolved.anchorNote;
790
632
  }
791
633
 
792
634
  // Support query-based anchor: `timeline --query "search terms"` or positional.
793
- // Routes through shared findFtsAnchor (paired-path with MCP mem_timeline)
794
- // so AND→OR fallback semantics match `search` without this, queries like
795
- // "ep-flush leak" miss rows whose title is "ep-flush ... leaked" that
796
- // search would otherwise find via OR relaxation.
635
+ // Shared with MCP so AND→OR fallback semantics match `search` — without this,
636
+ // queries like "ep-flush leak" miss rows whose title is "ep-flush ... leaked"
637
+ // that search would otherwise find via OR relaxation.
797
638
  const queryStr = flags.query || positional.join(' ');
798
639
  if ((!anchorId || isNaN(anchorId)) && queryStr) {
799
- const ftsQuery = sanitizeFtsQuery(queryStr);
800
- const found = findFtsAnchor(db, { ftsQuery, project: project ?? null });
640
+ const found = resolveQueryAnchor(db, queryStr, { project: project ?? null });
801
641
  if (found) {
802
- anchorId = found.id;
803
- if (found.relaxed && !anchorNote) {
804
- anchorNote = `(query "${queryStr}" relaxed AND→OR — no row matched all terms)`;
805
- }
642
+ anchorId = found.anchorId;
643
+ if (found.anchorNote && !anchorNote) anchorNote = found.anchorNote;
806
644
  }
807
645
  }
808
646
 
809
- // No anchor: show most recent observations (aligned with MCP mem_timeline fallback)
647
+ // No anchor: show most recent observations (shared fallback with MCP mem_timeline)
810
648
  if (!anchorId || isNaN(anchorId)) {
811
649
  if (queryStr) {
812
650
  process.stderr.write(`[mem] No anchor found for "${queryStr}", showing recent timeline\n`);
813
651
  }
814
- const compressedFilter = 'COALESCE(compressed_into, 0) = 0';
815
- const projectFilter = project ? `WHERE ${compressedFilter} AND project = ?` : `WHERE ${compressedFilter}`;
816
- const fallbackParams = project ? [project, before + after + 1] : [before + after + 1];
817
- const rows = db.prepare(`
818
- SELECT id, type, title, subtitle, created_at, created_at_epoch
819
- FROM observations ${projectFilter}
820
- ORDER BY created_at_epoch DESC
821
- LIMIT ?
822
- `).all(...fallbackParams);
652
+ const rows = fetchRecentTimeline(db, { project, limit: before + after + 1 });
823
653
 
824
654
  if (jsonOutput) {
825
655
  out(JSON.stringify({
@@ -847,58 +677,25 @@ function cmdTimeline(db, args) {
847
677
  return;
848
678
  }
849
679
 
850
- // Update access_count for anchor (aligned with MCP mem_timeline)
851
- try {
852
- db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
853
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
854
-
855
- // Get anchor epoch
856
- const anchorRow = db.prepare('SELECT created_at_epoch, project FROM observations WHERE id = ?').get(anchorId);
857
- if (!anchorRow) {
680
+ // Window fetch (access-count bump + project auto-scope) shared with MCP.
681
+ const win = fetchTimelineWindow(db, anchorId, { before, after, project });
682
+ if (!win) {
858
683
  fail(`[mem] Observation #${anchorId} not found`);
859
684
  return;
860
685
  }
861
-
862
- // Auto-scope to anchor's project when --project not explicitly given: users asking
863
- // "what happened around #N" expect same-project context, not cross-project time-bleed.
864
- const effectiveProject = project || anchorRow.project;
865
- const projectFilter = effectiveProject ? 'AND project = ?' : '';
866
- const baseParams = effectiveProject ? [effectiveProject] : [];
867
-
868
- // Before anchor
869
- const beforeRows = db.prepare(`
870
- SELECT id, type, title, subtitle, created_at, created_at_epoch
871
- FROM observations
872
- WHERE created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
873
- ORDER BY created_at_epoch DESC
874
- LIMIT ?
875
- `).all(anchorRow.created_at_epoch, ...baseParams, before);
876
-
877
- // After anchor
878
- const afterRows = db.prepare(`
879
- SELECT id, type, title, subtitle, created_at, created_at_epoch
880
- FROM observations
881
- WHERE created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
882
- ORDER BY created_at_epoch ASC
883
- LIMIT ?
884
- `).all(anchorRow.created_at_epoch, ...baseParams, after);
885
-
886
- // Anchor itself
887
- const anchor = db.prepare(
888
- 'SELECT id, type, title, subtitle, created_at, created_at_epoch FROM observations WHERE id = ?'
889
- ).get(anchorId);
686
+ const { anchor, beforeRows, afterRows } = win;
890
687
 
891
688
  if (jsonOutput) {
892
689
  out(JSON.stringify({
893
690
  anchor: toRow(anchor),
894
691
  anchor_note: anchorNote,
895
- before: beforeRows.reverse().map(toRow),
692
+ before: beforeRows.map(toRow),
896
693
  after: afterRows.map(toRow),
897
694
  }));
898
695
  return;
899
696
  }
900
697
 
901
- const all = [...beforeRows.reverse(), anchor, ...afterRows];
698
+ const all = [...beforeRows, anchor, ...afterRows];
902
699
 
903
700
  out(`[mem] Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:`);
904
701
  for (const r of all) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.98.0",
3
+ "version": "3.0.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",
@@ -63,12 +63,16 @@
63
63
  "lib/id-routing.mjs",
64
64
  "lib/err-sampler.mjs",
65
65
  "lib/hook-telemetry.mjs",
66
+ "lib/file-intel.mjs",
67
+ "lib/reread-guard.mjs",
66
68
  "lib/metrics.mjs",
67
69
  "lib/binding-probe.mjs",
68
70
  "lib/mem-override.mjs",
69
71
  "lib/save-observation.mjs",
70
72
  "lib/observation-write.mjs",
71
73
  "lib/recall-core.mjs",
74
+ "lib/timeline-core.mjs",
75
+ "lib/search-core.mjs",
72
76
  "lib/compress-core.mjs",
73
77
  "lib/maintain-core.mjs",
74
78
  "lib/dedup-constants.mjs",
@@ -138,7 +142,7 @@
138
142
  "zod": "^4.3.6"
139
143
  },
140
144
  "overrides": {
141
- "hono": ">=4.12.21",
145
+ "hono": ">=4.12.26",
142
146
  "fast-uri": ">=3.1.2",
143
147
  "ip-address": ">=10.1.1"
144
148
  },
@@ -10,6 +10,8 @@ import { homedir } from 'os';
10
10
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
11
11
  import { recordHookError } from '../lib/hook-telemetry.mjs';
12
12
  import { citeFactorClause } from '../scoring-sql.mjs';
13
+ import { fileIntelFor } from '../lib/file-intel.mjs';
14
+ import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
13
15
 
14
16
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
15
17
  // whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
@@ -39,6 +41,23 @@ const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
39
41
  || process.env.CLAUDE_MEM_SALIENCE === '0';
40
42
  const ACK_DIRECTIVE = "apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
41
43
  const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
44
+ // Feature ① (file intelligence): on the first Read of a file each session, inject
45
+ // its approximate token size + a one-line summary so the agent can decide to read
46
+ // fully, slice, or grep. Read-only (Edit/Write already commit to the file). Default
47
+ // ON; CLAUDE_MEM_FILE_INTEL=0 disables. Files below the token floor stay silent so
48
+ // small reads carry no noise. Env names mirror schema.mjs CLAUDE_MEM_* convention (#8447).
49
+ const FILE_INTEL_OFF = ['0', 'off', 'false', 'no'].includes(
50
+ String(process.env.CLAUDE_MEM_FILE_INTEL || '').toLowerCase());
51
+ const FILE_INTEL_MIN_TOKENS = Math.max(1,
52
+ parseInt(process.env.CLAUDE_MEM_FILE_INTEL_MIN_TOKENS, 10) || 800);
53
+ // Feature ② (repeated-read guard): when the agent does a FULL re-read of a file
54
+ // it already read this session and the file is unchanged (mtime), nudge it to
55
+ // reuse context instead of re-slurping. Read-only; only fires above the floor and
56
+ // never on offset/limit paging. Default ON; CLAUDE_MEM_REREAD_GUARD=0 disables.
57
+ const REREAD_GUARD_OFF = ['0', 'off', 'false', 'no'].includes(
58
+ String(process.env.CLAUDE_MEM_REREAD_GUARD || '').toLowerCase());
59
+ const REREAD_MIN_TOKENS = Math.max(1,
60
+ parseInt(process.env.CLAUDE_MEM_REREAD_MIN_TOKENS, 10) || 600);
42
61
  // Stale-cooldown GC moved to hook.mjs::handleSessionStart — running it on every
43
62
  // Edit cost 15-30 disk stats per call. SessionStart fires once at session boot,
44
63
  // which is enough to keep RUNTIME_DIR from growing unbounded.
@@ -153,11 +172,17 @@ try {
153
172
  let filePath;
154
173
  let sessionId;
155
174
  let toolName;
175
+ // isFullRead: a Read with no offset/limit reads the whole file. The reread
176
+ // guard only flags full-vs-full re-reads, so paging never trips it.
177
+ let isFullRead = true;
156
178
  try {
157
179
  const event = JSON.parse(input);
158
180
  filePath = event.tool_input?.file_path;
159
181
  sessionId = event.session_id || null;
160
182
  toolName = event.tool_name || null;
183
+ const off = event.tool_input?.offset;
184
+ const lim = event.tool_input?.limit;
185
+ isFullRead = (off === undefined || off === null) && (lim === undefined || lim === null);
161
186
  } catch (e) {
162
187
  recordHookError('pre-recall:json', e, RUNTIME_DIR, { inputLen: input.length });
163
188
  process.exit(0);
@@ -218,6 +243,22 @@ try {
218
243
  }));
219
244
  cooldown[filePath] = { ...entry, mode: 'edit' };
220
245
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
246
+ } else if (isRead && !REREAD_GUARD_OFF && typeof entry === 'object' && entry.reread) {
247
+ // ② repeated-read guard: a full re-read of an unchanged, sizable file —
248
+ // nudge to reuse what's already in context. Read-only; never throws.
249
+ const meta = readFileMeta(filePath);
250
+ if (shouldWarnReread(entry.reread, meta ? meta.mtimeMs : null, isFullRead, REREAD_MIN_TOKENS)) {
251
+ process.stdout.write(JSON.stringify({
252
+ suppressOutput: true,
253
+ hookSpecificOutput: {
254
+ hookEventName: 'PreToolUse',
255
+ additionalContext: [
256
+ '[mem] PreToolUse recall — system-injected context, continue your planned action:',
257
+ buildRereadWarning(basename(filePath), entry.reread.tokens),
258
+ ].join('\n'),
259
+ },
260
+ }));
261
+ }
221
262
  }
222
263
  process.exit(0); // already recalled this file in-session
223
264
  }
@@ -340,6 +381,13 @@ try {
340
381
  // v2.31 T2: emit JSON with hookSpecificOutput.additionalContext so the message
341
382
  // reliably renders across CC variants (sdscc drops plain-text stdout from PreToolUse).
342
383
  // suppressOutput:true hides it from transcript mode per CC hook docs.
384
+ // Feature ①: file intelligence (size + summary) for the first Read of this
385
+ // file this session. Read-only; opt out via CLAUDE_MEM_FILE_INTEL=0. Never
386
+ // throws — fileIntelFor returns null on unreadable/below-threshold files.
387
+ let fileIntelLine = null;
388
+ if (isRead && !FILE_INTEL_OFF) {
389
+ try { fileIntelLine = fileIntelFor(filePath, { minTokens: FILE_INTEL_MIN_TOKENS }); } catch {}
390
+ }
343
391
  const lines = [];
344
392
  // v2.34.6: Read mode uses 120-char truncation (Edit mode keeps the 240-char
345
393
  // cap from R3-UX). Rationale: Read is a one-shot nudge with 1 lesson max;
@@ -347,11 +395,20 @@ try {
347
395
  // carries the actionable "Fix:" guidance — short enough per-lesson at 240,
348
396
  // but the total payload is bounded by the 3-row limit and the cooldown.
349
397
  const LESSON_MAX = isRead ? 120 : 240;
350
- if (allRows.length > 0) {
398
+ // Feature (file-intel): null on Edit/Write and on below-threshold or
399
+ // unreadable files. When present (first Read of a sizable file this session),
400
+ // it leads the injection, above any lessons.
401
+ const hasLessons = allRows.length > 0;
402
+ const showFraming = hasLessons || Boolean(fileIntelLine)
403
+ || (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1');
404
+ if (showFraming) {
351
405
  // Framing line mirrors #7758 handoff-injection fix: without an explicit
352
406
  // "system-injected, continue" disclaimer, observed turn-end after Edit+reminder
353
407
  // when the model misreads passive lesson context as a closing note.
354
408
  lines.push(`[mem] PreToolUse recall — system-injected context, continue your planned action:`);
409
+ }
410
+ if (fileIntelLine) lines.push(fileIntelLine);
411
+ if (hasLessons) {
355
412
  lines.push(`[mem] Lessons for ${fname}:`);
356
413
  for (const r of allRows) {
357
414
  if (r.lesson_learned) {
@@ -386,7 +443,7 @@ try {
386
443
  //
387
444
  // Read never emitted this (passive). The cooldown write below still runs on
388
445
  // every branch, so Read→Edit dedup + cite-back lessonId tracking are intact.
389
- lines.push(`[mem] PreToolUse recall system-injected context, continue your planned action:`);
446
+ // (Framing line already pushed above via showFraming.)
390
447
  lines.push(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`);
391
448
  }
392
449
 
@@ -408,7 +465,16 @@ try {
408
465
  // v2.98: mode records WHERE the injection happened so the Read→Edit ack
409
466
  // nudge can distinguish "lessons seen passively at Read" from "already
410
467
  // surfaced at an action point".
411
- cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id), mode: isRead ? 'read' : 'edit' };
468
+ // repeated-read guard: record file metadata on the first Read so a later
469
+ // full re-read of the unchanged file can be flagged. Read-only, session-scoped;
470
+ // one stat + bounded read, first-read only.
471
+ const rereadMeta = (isRead && !REREAD_GUARD_OFF && isSessionScoped) ? readFileMeta(filePath) : null;
472
+ cooldown[filePath] = {
473
+ ts: now,
474
+ lessonIds: allRows.map(r => r.id),
475
+ mode: isRead ? 'read' : 'edit',
476
+ ...(rereadMeta ? { reread: { mtimeMs: rereadMeta.mtimeMs, tokens: rereadMeta.tokens, full: isFullRead } } : {}),
477
+ };
412
478
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
413
479
  // A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
414
480
  // file so the next UPS prompt skips them too. Always write, even on