claude-mem-lite 2.98.0 → 2.99.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": "2.99.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",
@@ -69,6 +69,8 @@
69
69
  "lib/save-observation.mjs",
70
70
  "lib/observation-write.mjs",
71
71
  "lib/recall-core.mjs",
72
+ "lib/timeline-core.mjs",
73
+ "lib/search-core.mjs",
72
74
  "lib/compress-core.mjs",
73
75
  "lib/maintain-core.mjs",
74
76
  "lib/dedup-constants.mjs",