claude-mem-lite 2.97.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, cjkBigrams, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause } 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';
12
- import { getVocabulary, computeVector, _resetVocabCache } from './tfidf.mjs';
9
+ import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
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';
@@ -27,7 +25,7 @@ import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
27
25
  import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
28
26
  import { auditMemdir, memdirPath } from './memdir.mjs';
29
27
  import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
30
- import { basename, join, sep } from 'path';
28
+ import { join, sep } from 'path';
31
29
  import { readFileSync, existsSync, readdirSync } from 'fs';
32
30
 
33
31
  // v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
@@ -35,6 +33,10 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
35
33
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
36
34
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
37
35
  import { saveObservation } from './lib/save-observation.mjs';
36
+ import { rebuildObservationDerived } from './lib/observation-write.mjs';
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';
38
40
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
39
41
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
40
42
  import {
@@ -61,11 +63,9 @@ function cmdSearch(db, args) {
61
63
  }
62
64
  const source = flags.source || null; // observations|sessions|prompts (null = all)
63
65
  const project = flags.project ? resolveProject(db, flags.project) : null;
64
- const dateFrom = flags.from ? new Date(flags.from).getTime() : null;
65
- let dateTo = flags.to ? new Date(flags.to).getTime() : null;
66
- if (dateTo && flags.to && /^\d{4}-\d{2}-\d{2}$/.test(flags.to)) dateTo += 86400000 - 1;
67
- if (flags.from && isNaN(dateFrom)) { fail(`[mem] Invalid --from date: "${flags.from}". Use YYYY-MM-DD or ISO 8601.`); return; }
68
- 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;
69
69
  // Inverted range silently returns 0 rows; warn so users see the cause, don't error
70
70
  // (a deliberate "search for nothing in this window" is not malformed input).
71
71
  if (dateFrom !== null && dateTo !== null && dateFrom > dateTo) {
@@ -104,8 +104,7 @@ function cmdSearch(db, args) {
104
104
  return;
105
105
  }
106
106
 
107
- let ftsQuery = sanitizeFtsQuery(query);
108
- if (ftsQuery && useOr) ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
107
+ const ftsQuery = buildSearchFtsQuery(query, { or: useOr });
109
108
  if (!ftsQuery) {
110
109
  fail(`[mem] No valid search terms in "${query}"`);
111
110
  return;
@@ -124,17 +123,12 @@ function cmdSearch(db, args) {
124
123
  const effectiveSource = source || ((type || tier || minImportance || branch) ? 'observations' : null);
125
124
 
126
125
  // Cross-source mode: each source needs more candidates than the final limit
127
- // so the post-merge sort has room to pick the best from each (paired-path with
128
- // 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).
129
130
  const isCrossSourceMode = !effectiveSource;
130
- // Over-fetch from offset 0 and apply --offset ONCE at the final slice (below) in
131
- // ALL modes — mirrors server.mjs. Pushing OFFSET into the obs hybrid path was
132
- // unreliable: its AND→OR fallback / vector / concept-cooccurrence stages re-add
133
- // rows the SQL OFFSET already skipped, so engine-side paging dropped (or
134
- // duplicated) rows on the --type/--tier/--importance/--branch path (a page that
135
- // MCP returned came back empty).
136
- const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
137
- const perSourceOffset = 0;
131
+ const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
138
132
 
139
133
  const results = [];
140
134
  // Tracks whether AND returned 0 and OR recovered non-empty. Mirrors server.mjs
@@ -166,107 +160,31 @@ function cmdSearch(db, args) {
166
160
 
167
161
  // Tier post-filter — applied to ALL obs results from the engine.
168
162
  if (tier) {
169
- const obsInResults = results.filter(r => r._source === 'obs');
170
- if (obsInResults.length > 0) {
171
- const obsIds = obsInResults.map(r => r.id);
172
- const ph = obsIds.map(() => '?').join(',');
173
- const fullRows = db.prepare(
174
- `SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${ph})`
175
- ).all(...obsIds);
176
- const rowMap = new Map(fullRows.map(r => [r.id, r]));
177
- const tierCtx = { now: Date.now(), currentProject: project || inferProject(), currentSessionId: '' };
178
- const allowedIds = new Set();
179
- for (const [id, full] of rowMap) {
180
- if (computeTier(full, tierCtx) === tier) allowedIds.add(id);
181
- }
182
- for (let i = results.length - 1; i >= 0; i--) {
183
- if (results[i]._source === 'obs' && !allowedIds.has(results[i].id)) results.splice(i, 1);
184
- }
185
- }
163
+ const filtered = applyTierFilter(db, results, { tier, sourceKey: '_source', currentProject: project || inferProject() });
164
+ results.length = 0;
165
+ results.push(...filtered);
186
166
  }
187
167
  }
188
168
 
189
- // Search sessions (aligned with MCP mem_search)
169
+ // Search sessions (shared engine with MCP mem_search — lib/search-core.mjs)
190
170
  if (!effectiveSource || effectiveSource === 'sessions') {
191
- const now = Date.now();
192
- const sessionProjectBoost = project ? null : inferProject();
193
- const sessWheres = ['session_summaries_fts MATCH ?'];
194
- const sessParams = [now, sessionProjectBoost, sessionProjectBoost, ftsQuery];
195
- if (project) { sessWheres.push('s.project = ?'); sessParams.push(project); }
196
- if (dateFrom) { sessWheres.push('s.created_at_epoch >= ?'); sessParams.push(dateFrom); }
197
- if (dateTo) { sessWheres.push('s.created_at_epoch <= ?'); sessParams.push(dateTo); }
198
- sessParams.push(perSourceLimit, perSourceOffset);
199
171
  try {
200
- const sessRows = db.prepare(`
201
- SELECT s.id, s.request, s.completed, s.project, s.created_at, s.created_at_epoch,
202
- ${SESS_BM25}
203
- * (1.0 + EXP(-0.693 * (? - s.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
204
- * (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
205
- FROM session_summaries_fts
206
- JOIN session_summaries s ON session_summaries_fts.rowid = s.id
207
- WHERE ${sessWheres.join(' AND ')}
208
- ORDER BY score
209
- LIMIT ? OFFSET ?
210
- `).all(...sessParams);
172
+ const sessRows = searchSessionsFts(db, {
173
+ ftsQuery, project, projectBoost: project ? null : inferProject(),
174
+ epochFrom: dateFrom, epochTo: dateTo, perSourceLimit, perSourceOffset,
175
+ });
211
176
  for (const r of sessRows) results.push({ ...r, _source: 'session' });
212
177
  } catch { /* session FTS may not exist in older DBs */ }
213
178
  }
214
179
 
215
- // Search prompts (aligned with MCP mem_search)
180
+ // Search prompts (shared engine incl. CJK precision gate + LIKE fallback)
216
181
  if (!effectiveSource || effectiveSource === 'prompts') {
217
- const promptWheres = ['user_prompts_fts MATCH ?', "p.prompt_text NOT LIKE '<task-notification>%'"];
218
- const promptParams = [ftsQuery];
219
- if (project) { promptWheres.push('s.project = ?'); promptParams.push(project); }
220
- if (dateFrom) { promptWheres.push('p.created_at_epoch >= ?'); promptParams.push(dateFrom); }
221
- if (dateTo) { promptWheres.push('p.created_at_epoch <= ?'); promptParams.push(dateTo); }
222
- promptParams.push(perSourceLimit, perSourceOffset);
223
182
  try {
224
- const promptRows = db.prepare(`
225
- SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch,
226
- bm25(user_prompts_fts, 1) as score
227
- FROM user_prompts_fts
228
- JOIN user_prompts p ON user_prompts_fts.rowid = p.id
229
- JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
230
- WHERE ${promptWheres.join(' AND ')}
231
- ORDER BY score
232
- LIMIT ? OFFSET ?
233
- `).all(...promptParams);
234
- // CJK precision filter (read-path parity with server.mjs): unicode61
235
- // degrades bigram queries to single-char AND, letting common-char
236
- // Chinese prose leak through. Drop rows that miss < 20% of query
237
- // bigrams/keywords as contiguous substrings.
238
- const keptPromptRows = promptRows.filter(r => cjkPrecisionOk(query, r.prompt_text));
239
- for (const r of keptPromptRows) results.push({ ...r, _source: 'prompt' });
240
- // CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
241
- if (keptPromptRows.length === 0) {
242
- const cjkPatterns = extractCjkLikePatterns(query);
243
- if (cjkPatterns.length > 0) {
244
- const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
245
- const likeParams = cjkPatterns.map(p => `%${p}%`);
246
- if (project) likeParams.push(project);
247
- if (dateFrom) likeParams.push(dateFrom);
248
- if (dateTo) likeParams.push(dateTo);
249
- likeParams.push(perSourceLimit, perSourceOffset);
250
- const fallbackRows = db.prepare(`
251
- SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
252
- FROM user_prompts p
253
- JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
254
- WHERE (${likeConds.join(' OR ')})
255
- AND p.prompt_text NOT LIKE '<task-notification>%'
256
- ${project ? 'AND s.project = ?' : ''}
257
- ${dateFrom ? 'AND p.created_at_epoch >= ?' : ''}
258
- ${dateTo ? 'AND p.created_at_epoch <= ?' : ''}
259
- ORDER BY p.created_at_epoch DESC
260
- LIMIT ? OFFSET ?
261
- `).all(...likeParams);
262
- // CJK precision filter applies here too: the LIKE fallback is just
263
- // OR'd substring bigrams; without the precision gate it re-admits
264
- // the same common-char noise the FTS path dropped (this was the
265
- // actual leak source — FTS returned 0, fallback filled 20).
266
- const keptFallback = fallbackRows.filter(r => cjkPrecisionOk(query, r.prompt_text));
267
- for (const r of keptFallback) results.push({ ...r, _source: 'prompt', score: 0 });
268
- }
269
- }
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' });
270
188
  } catch { /* prompt FTS may not exist in older DBs */ }
271
189
  }
272
190
 
@@ -279,18 +197,11 @@ function cmdSearch(db, args) {
279
197
  return;
280
198
  }
281
199
 
282
- // Cross-source score normalization (paired-path with server.mjs:428).
200
+ // Cross-source score normalization (shared with mem_search).
283
201
  // ftsQuery gate prevents normalization when scores are all 0 (no-FTS path).
284
202
  const isCrossSource = isCrossSourceMode;
285
203
  if (isCrossSource && results.length > 0 && ftsQuery) {
286
- for (const src of ['obs', 'session', 'prompt']) {
287
- const srcResults = results.filter(r => r._source === src && r.score !== null && r.score !== undefined);
288
- if (srcResults.length < 2) continue;
289
- const maxAbs = Math.max(...srcResults.map(r => Math.abs(r.score)));
290
- if (maxAbs > 0) {
291
- for (const r of srcResults) r.score = r.score / maxAbs;
292
- }
293
- }
204
+ normalizeCrossSourceScores(results, '_source');
294
205
  results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
295
206
  }
296
207
 
@@ -304,13 +215,8 @@ function cmdSearch(db, args) {
304
215
  if (isCrossSource) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
305
216
  }
306
217
 
307
- // Apply user-requested sort (after relevance scoring)
308
- if (sort === 'time') {
309
- results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
310
- } else if (sort === 'importance') {
311
- results.sort((a, b) => (b.importance ?? 1) - (a.importance ?? 1) || (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
312
- }
313
- // else 'relevance' keeps BM25 score order (already sorted)
218
+ // Apply user-requested sort (after relevance scoring; shared with mem_search)
219
+ applyUserSort(results, sort);
314
220
 
315
221
  // Trim to limit with offset. The engine always received perSourceOffset=0 and
316
222
  // over-fetched (see above), so the merged+reranked `results` start at row 0 and
@@ -324,7 +230,7 @@ function cmdSearch(db, args) {
324
230
  const trueTotal = countSearchTotal(db, {
325
231
  effectiveSource,
326
232
  ftsQuery,
327
- obsFtsQuery: orFallbackFired ? (relaxFtsQueryToOr(ftsQuery) || ftsQuery) : ftsQuery,
233
+ obsFtsQuery: effectiveObsFtsQuery(ftsQuery, orFallbackFired),
328
234
  args: { project: project || null, obs_type: type || null, importance: minImportance || null, branch: branch || null },
329
235
  project: project || null,
330
236
  epochFrom: dateFrom,
@@ -493,35 +399,12 @@ function cmdRecall(db, args) {
493
399
  return;
494
400
  }
495
401
 
496
- const filename = basename(file);
497
402
  const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
498
403
  const includeNoise = flags['include-noise'] === true || flags['include-noise'] === 'true';
499
404
  const jsonOutput = flags.json === true || flags.json === 'true';
500
405
 
501
- // Search via observation_files junction table for indexed filename lookups
502
- const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
503
- const likePattern = `%${escaped}`;
504
- const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
505
- const rows = db.prepare(`
506
- SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
507
- o.created_at, o.created_at_epoch, o.project
508
- FROM observations o
509
- JOIN observation_files of2 ON of2.obs_id = o.id
510
- WHERE COALESCE(o.compressed_into, 0) = 0
511
- AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
512
- ${noiseClause}
513
- ORDER BY o.created_at_epoch DESC
514
- LIMIT ?
515
- `).all(filename, likePattern, limit);
516
-
517
- if (rows.length > 0) {
518
- // Update access_count for recalled observations (aligned with MCP mem_recall)
519
- const recalledIds = rows.map(r => r.id);
520
- const recallPh = recalledIds.map(() => '?').join(',');
521
- try {
522
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
523
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
524
- }
406
+ // Shared core with MCP mem_recall: query + escaping + access bump (lib/recall-core.mjs)
407
+ const { filename, rows } = recallByFile(db, file, { limit, includeNoise });
525
408
 
526
409
  if (jsonOutput) {
527
410
  out(JSON.stringify({
@@ -734,113 +617,39 @@ function cmdTimeline(db, args) {
734
617
  });
735
618
 
736
619
  // Parse --anchor, accepting P#/S#/# prefix so callers can paste search-result IDs verbatim.
737
- // For prompt/session anchors, resolve to the nearest-in-time observation so timeline semantics
738
- // (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.
739
622
  let anchorId = null;
740
623
  let anchorNote = null; // hint line for output when anchor was resolved via conversion
741
624
  if (flags.anchor !== undefined && flags.anchor !== true) {
742
- const parsed = parseIdToken(flags.anchor);
743
- if (!parsed) {
744
- 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'));
745
628
  return;
746
629
  }
747
- if (parsed.source === 'prompt') {
748
- const row = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
749
- if (!row) { fail(`[mem] Prompt P#${parsed.id} not found`); return; }
750
- const proj = project;
751
- const nearest = db.prepare(`
752
- SELECT id FROM observations
753
- WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
754
- ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
755
- `).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
756
- if (!nearest) { fail(`[mem] No observations near P#${parsed.id}`); return; }
757
- anchorId = nearest.id;
758
- anchorNote = `(anchored to #${nearest.id}, closest obs to P#${parsed.id})`;
759
- } else if (parsed.source === 'session') {
760
- const row = db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
761
- if (!row) { fail(`[mem] Session S#${parsed.id} not found`); return; }
762
- const proj = project;
763
- const nearest = db.prepare(`
764
- SELECT id FROM observations
765
- WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
766
- ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
767
- `).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
768
- if (!nearest) { fail(`[mem] No observations near S#${parsed.id}`); return; }
769
- anchorId = nearest.id;
770
- anchorNote = `(anchored to #${nearest.id}, closest obs to S#${parsed.id})`;
771
- } else {
772
- // Bare integer (no prefix): try observation first. Fall back to user_prompts
773
- // then session_summaries so pasted P#/S# IDs still work when the prefix is
774
- // omitted — matches the prefix-aware routing used by search/probe.
775
- const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
776
- if (obsRow) {
777
- const ci = obsRow.compressed_into;
778
- if (ci && ci > 0) {
779
- // Compressed into a live parent: re-anchor so the window doesn't silently
780
- // straddle a dead record. Negative sentinels (-1 dropped, -2 pending purge)
781
- // have no canonical parent — surface an explicit error instead.
782
- anchorId = ci;
783
- anchorNote = `(anchored to #${ci}, #${parsed.id} was compressed into it)`;
784
- } else if (ci && ci < 0) {
785
- fail(`[mem] Observation #${parsed.id} was compressed and pruned; no canonical anchor available`);
786
- return;
787
- } else {
788
- anchorId = parsed.id;
789
- }
790
- } else {
791
- const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
792
- const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
793
- const hit = promptRow ? { row: promptRow, prefix: 'P', name: 'prompt' }
794
- : sessionRow ? { row: sessionRow, prefix: 'S', name: 'session' }
795
- : null;
796
- if (!hit) {
797
- fail(`[mem] Observation, prompt, or session with id ${parsed.id} not found`);
798
- return;
799
- }
800
- const proj = project;
801
- const nearest = db.prepare(`
802
- SELECT id FROM observations
803
- WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
804
- ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
805
- `).get(...(proj ? [proj, hit.row.created_at_epoch] : [hit.row.created_at_epoch]));
806
- if (!nearest) { fail(`[mem] No observations near ${hit.prefix}#${parsed.id} (${hit.name})`); return; }
807
- anchorId = nearest.id;
808
- anchorNote = `(anchored to #${nearest.id}, closest obs to ${hit.prefix}#${parsed.id})`;
809
- }
810
- }
630
+ anchorId = resolved.anchorId;
631
+ anchorNote = resolved.anchorNote;
811
632
  }
812
633
 
813
634
  // Support query-based anchor: `timeline --query "search terms"` or positional.
814
- // Routes through shared findFtsAnchor (paired-path with MCP mem_timeline)
815
- // so AND→OR fallback semantics match `search` without this, queries like
816
- // "ep-flush leak" miss rows whose title is "ep-flush ... leaked" that
817
- // 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.
818
638
  const queryStr = flags.query || positional.join(' ');
819
639
  if ((!anchorId || isNaN(anchorId)) && queryStr) {
820
- const ftsQuery = sanitizeFtsQuery(queryStr);
821
- const found = findFtsAnchor(db, { ftsQuery, project: project ?? null });
640
+ const found = resolveQueryAnchor(db, queryStr, { project: project ?? null });
822
641
  if (found) {
823
- anchorId = found.id;
824
- if (found.relaxed && !anchorNote) {
825
- anchorNote = `(query "${queryStr}" relaxed AND→OR — no row matched all terms)`;
826
- }
642
+ anchorId = found.anchorId;
643
+ if (found.anchorNote && !anchorNote) anchorNote = found.anchorNote;
827
644
  }
828
645
  }
829
646
 
830
- // 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)
831
648
  if (!anchorId || isNaN(anchorId)) {
832
649
  if (queryStr) {
833
650
  process.stderr.write(`[mem] No anchor found for "${queryStr}", showing recent timeline\n`);
834
651
  }
835
- const compressedFilter = 'COALESCE(compressed_into, 0) = 0';
836
- const projectFilter = project ? `WHERE ${compressedFilter} AND project = ?` : `WHERE ${compressedFilter}`;
837
- const fallbackParams = project ? [project, before + after + 1] : [before + after + 1];
838
- const rows = db.prepare(`
839
- SELECT id, type, title, subtitle, created_at, created_at_epoch
840
- FROM observations ${projectFilter}
841
- ORDER BY created_at_epoch DESC
842
- LIMIT ?
843
- `).all(...fallbackParams);
652
+ const rows = fetchRecentTimeline(db, { project, limit: before + after + 1 });
844
653
 
845
654
  if (jsonOutput) {
846
655
  out(JSON.stringify({
@@ -868,58 +677,25 @@ function cmdTimeline(db, args) {
868
677
  return;
869
678
  }
870
679
 
871
- // Update access_count for anchor (aligned with MCP mem_timeline)
872
- try {
873
- db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
874
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
875
-
876
- // Get anchor epoch
877
- const anchorRow = db.prepare('SELECT created_at_epoch, project FROM observations WHERE id = ?').get(anchorId);
878
- 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) {
879
683
  fail(`[mem] Observation #${anchorId} not found`);
880
684
  return;
881
685
  }
882
-
883
- // Auto-scope to anchor's project when --project not explicitly given: users asking
884
- // "what happened around #N" expect same-project context, not cross-project time-bleed.
885
- const effectiveProject = project || anchorRow.project;
886
- const projectFilter = effectiveProject ? 'AND project = ?' : '';
887
- const baseParams = effectiveProject ? [effectiveProject] : [];
888
-
889
- // Before anchor
890
- const beforeRows = db.prepare(`
891
- SELECT id, type, title, subtitle, created_at, created_at_epoch
892
- FROM observations
893
- WHERE created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
894
- ORDER BY created_at_epoch DESC
895
- LIMIT ?
896
- `).all(anchorRow.created_at_epoch, ...baseParams, before);
897
-
898
- // After anchor
899
- const afterRows = db.prepare(`
900
- SELECT id, type, title, subtitle, created_at, created_at_epoch
901
- FROM observations
902
- WHERE created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
903
- ORDER BY created_at_epoch ASC
904
- LIMIT ?
905
- `).all(anchorRow.created_at_epoch, ...baseParams, after);
906
-
907
- // Anchor itself
908
- const anchor = db.prepare(
909
- 'SELECT id, type, title, subtitle, created_at, created_at_epoch FROM observations WHERE id = ?'
910
- ).get(anchorId);
686
+ const { anchor, beforeRows, afterRows } = win;
911
687
 
912
688
  if (jsonOutput) {
913
689
  out(JSON.stringify({
914
690
  anchor: toRow(anchor),
915
691
  anchor_note: anchorNote,
916
- before: beforeRows.reverse().map(toRow),
692
+ before: beforeRows.map(toRow),
917
693
  after: afterRows.map(toRow),
918
694
  }));
919
695
  return;
920
696
  }
921
697
 
922
- const all = [...beforeRows.reverse(), anchor, ...afterRows];
698
+ const all = [...beforeRows, anchor, ...afterRows];
923
699
 
924
700
  out(`[mem] Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:`);
925
701
  for (const r of all) {
@@ -1708,28 +1484,11 @@ function cmdUpdate(db, args) {
1708
1484
 
1709
1485
  params.push(id);
1710
1486
 
1711
- // Atomic: update fields + rebuild FTS text + re-vectorize (aligned with MCP mem_update)
1487
+ // Atomic: update fields + rebuild derived columns (FTS text + vector) via the
1488
+ // shared core — single source with MCP mem_update (lib/observation-write.mjs).
1712
1489
  db.transaction(() => {
1713
1490
  db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1714
-
1715
- // Rebuild FTS text field
1716
- const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(id);
1717
- const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
1718
- const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
1719
- const textField = bigrams ? base + ' ' + bigrams : base;
1720
- db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, id);
1721
-
1722
- // Re-vectorize (non-critical — catch to avoid rollback)
1723
- try {
1724
- const vocab = getVocabulary(db);
1725
- if (vocab) {
1726
- const vec = computeVector(textField, vocab);
1727
- if (vec) {
1728
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
1729
- .run(id, Buffer.from(vec.buffer), vocab.version, Date.now());
1730
- }
1731
- }
1732
- } catch { /* non-critical */ }
1491
+ rebuildObservationDerived(db, id);
1733
1492
  })();
1734
1493
 
1735
1494
  out(`[mem] Updated #${id}: ${updates.map(u => u.split(' =')[0]).join(', ')}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.97.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",
@@ -68,6 +68,9 @@
68
68
  "lib/mem-override.mjs",
69
69
  "lib/save-observation.mjs",
70
70
  "lib/observation-write.mjs",
71
+ "lib/recall-core.mjs",
72
+ "lib/timeline-core.mjs",
73
+ "lib/search-core.mjs",
71
74
  "lib/compress-core.mjs",
72
75
  "lib/maintain-core.mjs",
73
76
  "lib/dedup-constants.mjs",
@@ -29,6 +29,15 @@ const CROSS_HOOK_DEDUP_MS = 5 * 60 * 1000;
29
29
  // legacy global path so env-less test harnesses still behave.
30
30
  const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
31
31
  const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes (used only for legacy fallback)
32
+ // v2.98 salience forcing-function (#8651: verified injection only moved
33
+ // bug-reintroduction 100%→50% — the agent sees lessons and ignores them; the
34
+ // bottleneck is ACTING). Default ON: Edit/Write lesson blocks end with an ack
35
+ // directive, and Read→Edit re-surfaces the Read-time lesson IDs as a one-line
36
+ // ack nudge at the actual action point. CLAUDE_MEM_SALIENCE=legacy (or 0)
37
+ // restores the pre-v2.98 passive behavior.
38
+ const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
39
+ || process.env.CLAUDE_MEM_SALIENCE === '0';
40
+ 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.";
32
41
  const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
33
42
  // Stale-cooldown GC moved to hook.mjs::handleSessionStart — running it on every
34
43
  // Edit cost 15-30 disk stats per call. SessionStart fires once at session boot,
@@ -183,7 +192,35 @@ try {
183
192
  const cooldown = readCooldown(cooldownPath);
184
193
  const now = Date.now();
185
194
  if (isSessionScoped) {
186
- if (cooldown[filePath]) process.exit(0); // already recalled this file in-session
195
+ const entry = cooldown[filePath];
196
+ if (entry) {
197
+ // v2.98 salience: the old full-dedup meant a Read-injected lesson left the
198
+ // actual Edit with ZERO context at the action point — the most likely spot
199
+ // for #8651's "saw it, ignored it". When this Edit/Write follows a
200
+ // Read-mode injection that surfaced lessons, emit a one-line ack nudge
201
+ // naming the IDs (no lesson bodies — token cost stays minimal), then mark
202
+ // the entry handled so the next Edit is silent again. Entries without a
203
+ // mode field (pre-v2.98) are treated as already handled.
204
+ const seenIds = (typeof entry === 'object' && Array.isArray(entry.lessonIds))
205
+ ? entry.lessonIds : [];
206
+ const wasReadMode = typeof entry === 'object' && entry.mode === 'read';
207
+ if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
208
+ const idList = seenIds.map(id => `#${id}`).join(', ');
209
+ process.stdout.write(JSON.stringify({
210
+ suppressOutput: true,
211
+ hookSpecificOutput: {
212
+ hookEventName: 'PreToolUse',
213
+ additionalContext: [
214
+ '[mem] PreToolUse recall — system-injected context, continue your planned action:',
215
+ `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACK_DIRECTIVE}`,
216
+ ].join('\n'),
217
+ },
218
+ }));
219
+ cooldown[filePath] = { ...entry, mode: 'edit' };
220
+ writeCooldown(cooldownPath, cooldown, isSessionScoped);
221
+ }
222
+ process.exit(0); // already recalled this file in-session
223
+ }
187
224
  } else {
188
225
  const ts = entryTimestamp(cooldown[filePath]);
189
226
  if (ts && (now - ts) < COOLDOWN_MS) process.exit(0);
@@ -329,6 +366,14 @@ try {
329
366
  lines.push(` #${r.id} [${r.type}] ${title}`);
330
367
  }
331
368
  }
369
+ // v2.98 salience: Edit/Write is the action point — close the block with an
370
+ // explicit ack directive instead of leaving the lessons as passive FYI
371
+ // (#8651: passive framing was ignored ~half the time even when on-topic).
372
+ // Read keeps the quiet form; its forcing-function fires at the later Edit
373
+ // via the Read→Edit ack nudge above.
374
+ if (!isRead && !SALIENCE_LEGACY) {
375
+ lines.push(`[mem] ⚠ Before this edit: ${ACK_DIRECTIVE}`);
376
+ }
332
377
  } else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
333
378
  // R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
334
379
  // of the cross-project audit: this "no prior lessons, remember to /lesson"
@@ -360,7 +405,10 @@ try {
360
405
  // v2.81: record the emitted lesson IDs so flushEpisode (hook.mjs) can
361
406
  // build the PostToolUse cite-back hint when the user actually edits the
362
407
  // file. Empty array on no-lesson branches keeps the schema uniform.
363
- cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id) };
408
+ // v2.98: mode records WHERE the injection happened so the Read→Edit ack
409
+ // nudge can distinguish "lessons seen passively at Read" from "already
410
+ // surfaced at an action point".
411
+ cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id), mode: isRead ? 'read' : 'edit' };
364
412
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
365
413
  // A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
366
414
  // file so the next UPS prompt skips them too. Always write, even on