claude-mem-lite 2.96.0 → 2.98.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.96.0",
13
+ "version": "2.98.0",
14
14
  "source": "./",
15
15
  "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)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.96.0",
3
+ "version": "2.98.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
  "author": {
6
6
  "name": "sdsrss"
package/cli/common.mjs CHANGED
@@ -98,6 +98,24 @@ export function fmtDateShort(iso) {
98
98
  return iso.slice(0, 10);
99
99
  }
100
100
 
101
+ // Integer epoch-ms time fields on the observations table that `get`/`mem_get`
102
+ // render. Shared by the CLI (mem-cli.mjs) and the MCP server (server.mjs) so the
103
+ // two `get` paths can't drift — pre-2.97 the MCP path printed bare ms
104
+ // (`last_accessed_at: 1781024049720`) while the CLI showed `<ms> (<relative>)`,
105
+ // because the formatter lived only in mem-cli.mjs.
106
+ export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
107
+
108
+ // Pure formatter — null/undefined/non-time pass through; integer time fields
109
+ // render as `<raw> (<relative>)` so callers get both an audit value and a
110
+ // human/LLM-scannable hint, mirroring `recent`/`timeline`/`recall`.
111
+ export function formatObsFieldValue(field, val) {
112
+ if (val === null || val === undefined) return val;
113
+ if (OBS_TIME_FIELDS.includes(field) && typeof val === 'number') {
114
+ return `${val} (${relativeTime(val)})`;
115
+ }
116
+ return val;
117
+ }
118
+
101
119
  // ─── ID Token Parsing ────────────────────────────────────────────────────────
102
120
  // Re-exported from lib/id-routing.mjs so CLI and MCP (server.mjs) share a single
103
121
  // parser — parity per #8050. Keep this re-export for back-compat with the
package/hook-llm.mjs CHANGED
@@ -601,7 +601,12 @@ export async function handleLLMEpisode() {
601
601
  await sleep(delayMs);
602
602
  }
603
603
 
604
- const fileList = episode.files.map(f => basename(f)).join(', ') || '(multiple)';
604
+ // `episode.files` is normally a [] from createEpisode, but a malformed or
605
+ // older-format tmp file can omit it — `.map()` on undefined would throw here,
606
+ // before any cleanup, leaking the tmp file (which is then retried and crashes
607
+ // forever). Guard defensively, mirroring buildImmediateObservation's `|| []`.
608
+ const episodeFiles = Array.isArray(episode.files) ? episode.files : [];
609
+ const fileList = episodeFiles.map(f => basename(f)).join(', ') || '(multiple)';
605
610
 
606
611
  // Defense-in-depth (cso F#4): split static instructions (system) from
607
612
  // per-call data (user). Episode descriptions and file paths come from tool
@@ -622,7 +627,7 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
622
627
  JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","search_aliases":["alt query 1","alt query 2"]}
623
628
  ${SHARED_OBS_SCHEMA_TAIL}`;
624
629
  const user = `Tool: ${e.tool}
625
- File: ${episode.files.join(', ') || 'unknown'}
630
+ File: ${episodeFiles.join(', ') || 'unknown'}
626
631
  Action: ${e.desc}
627
632
  Error: ${e.isError ? 'yes' : 'no'}`;
628
633
  prompt = { system, user };
@@ -743,7 +748,7 @@ ${actionList}`;
743
748
  narrative: truncate(parsed.narrative || '', 500),
744
749
  concepts: Array.isArray(parsed.concepts) ? parsed.concepts.slice(0, 10) : [],
745
750
  facts: Array.isArray(parsed.facts) ? parsed.facts.slice(0, 10) : [],
746
- files: episode.files,
751
+ files: episodeFiles,
747
752
  filesRead: episode.filesRead || [],
748
753
  // v2.33.1: when lesson is low-signal, don't trust Haiku's importance
749
754
  // inflation. v2.54.0: extended from {change, discovery} to all types
@@ -752,7 +757,12 @@ ${actionList}`;
752
757
  // imp=2-3 even when lesson is null after retry. Keep `decision` exempt:
753
758
  // it's rare (39 obs / 94.9% hit-rate) and the retry path already gave
754
759
  // it a second chance; a no-lesson decision is still a worthwhile signal.
755
- importance: isLessonLowSignal && parsed.type !== 'decision'
760
+ // `!retryRecovered`: when the P3 retry recovered a substantive lesson,
761
+ // the obs is no longer low-signal — capping it to 1 would negate the
762
+ // retry's entire purpose (a recovered bugfix lesson would silently drop
763
+ // out of --importance 2 searches and the working tier). Gate the cap on
764
+ // the *effective* low-signal state, not the pre-retry flag.
765
+ importance: isLessonLowSignal && !retryRecovered && parsed.type !== 'decision'
756
766
  ? Math.min(ruleImportance, 1)
757
767
  : Math.max(ruleImportance, clampImportance(parsed.importance)),
758
768
  lessonLearned,
package/hook-update.mjs CHANGED
@@ -155,8 +155,22 @@ function isPluginMode() {
155
155
  // ── Dev Mode Detection ─────────────────────────────────────
156
156
  function isDevMode() {
157
157
  try {
158
- const serverPath = join(INSTALL_DIR, 'server.mjs');
159
- return existsSync(serverPath) && lstatSync(serverPath).isSymbolicLink();
158
+ // A dev checkout always carries a .git dir. This catches a whole-directory
159
+ // symlink (~/.claude-mem-lite -> /repo): lstat on server.mjs there follows the
160
+ // intermediate symlink and sees a regular file, so the per-file probe below
161
+ // would return false and auto-update would clobber the working tree.
162
+ if (existsSync(join(INSTALL_DIR, '.git'))) return true;
163
+ // Standard `install --dev` symlinks individual source files; checking several
164
+ // core files (not just server.mjs) survives the drift case where one file was
165
+ // replaced by a plain copy while the install is still symlink-provisioned —
166
+ // mirrors lib/doctor-drift.mjs's "any symlink ⇒ dev" detection. A copy-based
167
+ // real install (install.mjs non-dev) has no symlinks and no .git, so this
168
+ // cannot false-positive into never auto-updating.
169
+ for (const f of ['server.mjs', 'hook.mjs', 'cli.mjs', 'mem-cli.mjs']) {
170
+ const p = join(INSTALL_DIR, f);
171
+ if (existsSync(p) && lstatSync(p).isSymbolicLink()) return true;
172
+ }
173
+ return false;
160
174
  } catch { return false; }
161
175
  }
162
176
 
@@ -9,7 +9,7 @@
9
9
  // + vector writes in one db.transaction so a failure can't leave a partial row).
10
10
 
11
11
  import { getVocabulary, computeVector } from '../tfidf.mjs';
12
- import { debugCatch } from '../utils.mjs';
12
+ import { debugCatch, cjkBigrams } from '../utils.mjs';
13
13
 
14
14
  // Canonical column order — must mirror the observations schema (schema.mjs).
15
15
  const OBS_COLUMNS = [
@@ -65,3 +65,20 @@ export function insertObservationVector(db, obsId, vecText) {
65
65
  .run(obsId, Buffer.from(vec.buffer), vocab.version, Date.now());
66
66
  } catch (e) { debugCatch(e, 'insertObservationVector'); }
67
67
  }
68
+
69
+ /**
70
+ * Rebuild an observation's derived columns after a field UPDATE: the FTS `text`
71
+ * column (incl. CJK bigrams + search_aliases, matching the ingest paths) and the
72
+ * TF-IDF vector. cmdUpdate (CLI) and mem_update (MCP) previously hand-copied this
73
+ * block — the same drift class #8614/#8639 closed for compress/maintain. Caller
74
+ * owns the transaction (vector write is internally non-critical).
75
+ */
76
+ export function rebuildObservationDerived(db, obsId) {
77
+ const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(obsId);
78
+ if (!row) return;
79
+ const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
80
+ const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
81
+ const textField = bigrams ? base + ' ' + bigrams : base;
82
+ db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, obsId);
83
+ insertObservationVector(db, obsId, textField);
84
+ }
@@ -0,0 +1,43 @@
1
+ // Single source of truth for file-keyed recall: the observation_files junction
2
+ // query, LIKE-wildcard escaping, noise filtering, and the access-count bump.
3
+ // cmdRecall (mem-cli.mjs) and mem_recall (server.mjs) previously hand-copied all
4
+ // four — the drift class that produced the mem_get formatter drift (#8678) and
5
+ // the maintain hand-sync drift (#8614). Renderers stay per-surface; the data
6
+ // contract lives here.
7
+
8
+ import { basename } from 'path';
9
+ import { notLowSignalTitleClause } from '../utils.mjs';
10
+
11
+ /**
12
+ * Recall observations linked to a file (basename or full path). Returns
13
+ * { filename, rows } where rows carry the column superset both surfaces render.
14
+ * Side effect: bumps access_count / last_accessed_at on every returned row —
15
+ * recall IS engagement, and the tier/decay system feeds on these counters.
16
+ */
17
+ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}) {
18
+ const filename = basename(file);
19
+ const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
20
+ const likePattern = `%${escaped}`;
21
+ const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
22
+ const rows = db.prepare(`
23
+ SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
24
+ o.created_at, o.created_at_epoch, o.project
25
+ FROM observations o
26
+ JOIN observation_files of2 ON of2.obs_id = o.id
27
+ WHERE COALESCE(o.compressed_into, 0) = 0
28
+ AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
29
+ ${noiseClause}
30
+ ORDER BY o.created_at_epoch DESC
31
+ LIMIT ?
32
+ `).all(filename, likePattern, limit);
33
+
34
+ if (rows.length > 0) {
35
+ const ph = rows.map(() => '?').join(',');
36
+ try {
37
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`)
38
+ .run(Date.now(), ...rows.map(r => r.id));
39
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
40
+ }
41
+
42
+ return { filename, rows };
43
+ }
package/mem-cli.mjs CHANGED
@@ -4,12 +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';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, scrubSecrets, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS } from './utils.mjs';
8
8
  import { cjkPrecisionOk } from './nlp.mjs';
9
9
  import { extractCjkLikePatterns } from './nlp.mjs';
10
10
  import { resolveProject } from './project-utils.mjs';
11
11
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
12
- import { getVocabulary, computeVector, _resetVocabCache } from './tfidf.mjs';
12
+ import { _resetVocabCache } from './tfidf.mjs';
13
13
  import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
14
14
  import { searchObservationsHybrid, findFtsAnchor, countSearchTotal } from './search-engine.mjs';
15
15
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
@@ -27,14 +27,16 @@ import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
27
27
  import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
28
28
  import { auditMemdir, memdirPath } from './memdir.mjs';
29
29
  import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
30
- import { basename, join, sep } from 'path';
30
+ import { join, sep } from 'path';
31
31
  import { readFileSync, existsSync, readdirSync } from 'fs';
32
32
 
33
33
  // v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
34
34
  // router + remaining-command bodies during the incremental split. Future work:
35
35
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
36
- import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags } from './cli/common.mjs';
36
+ import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
37
37
  import { saveObservation } from './lib/save-observation.mjs';
38
+ import { rebuildObservationDerived } from './lib/observation-write.mjs';
39
+ import { recallByFile } from './lib/recall-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 {
@@ -493,35 +495,12 @@ function cmdRecall(db, args) {
493
495
  return;
494
496
  }
495
497
 
496
- const filename = basename(file);
497
498
  const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
498
499
  const includeNoise = flags['include-noise'] === true || flags['include-noise'] === 'true';
499
500
  const jsonOutput = flags.json === true || flags.json === 'true';
500
501
 
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
- }
502
+ // Shared core with MCP mem_recall: query + escaping + access bump (lib/recall-core.mjs)
503
+ const { filename, rows } = recallByFile(db, file, { limit, includeNoise });
525
504
 
526
505
  if (jsonOutput) {
527
506
  out(JSON.stringify({
@@ -559,24 +538,12 @@ function cmdRecall(db, args) {
559
538
 
560
539
  const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
561
540
 
562
- // Integer-typed time-epoch fields on the observations table that the `get`
563
- // command renders. Callers expect raw ms (audit) AND a relative-time hint
564
- // (human-scan), so formatObsFieldValue emits both. Other epoch fields like
565
- // `created_at_epoch` / `optimized_at` / `last_injected_at` aren't in
566
- // OBS_FIELDS so they don't surface via `get`.
567
- export const OBS_TIME_FIELDS = ['superseded_at', 'last_accessed_at'];
568
-
569
- // Pure formatter — null/undefined/non-time pass through; time fields on
570
- // integer values render as `<raw> (<relative>)` mirroring the convention
571
- // already used by `recent` / `timeline` / `recall`. Pre-2.63.0 the get
572
- // path printed bare ms (e.g. `last_accessed_at: 1778357330957`).
573
- export function formatObsFieldValue(field, val) {
574
- if (val === null || val === undefined) return val;
575
- if (OBS_TIME_FIELDS.includes(field) && typeof val === 'number') {
576
- return `${val} (${relativeTime(val)})`;
577
- }
578
- return val;
579
- }
541
+ // Time-field formatting moved to cli/common.mjs so the CLI `get` and the MCP
542
+ // `mem_get` (server.mjs) share one source and can't drift (the drift bug:
543
+ // MCP printed bare ms while CLI showed `<ms> (<relative>)`). Imported at the
544
+ // top; re-exported here for back-compat with existing importers
545
+ // (tests/get-time-format.test.mjs).
546
+ export { OBS_TIME_FIELDS, formatObsFieldValue };
580
547
 
581
548
  function renderObsRows(db, ids, requestedFields) {
582
549
  const placeholders = ids.map(() => '?').join(',');
@@ -1720,28 +1687,11 @@ function cmdUpdate(db, args) {
1720
1687
 
1721
1688
  params.push(id);
1722
1689
 
1723
- // Atomic: update fields + rebuild FTS text + re-vectorize (aligned with MCP mem_update)
1690
+ // Atomic: update fields + rebuild derived columns (FTS text + vector) via the
1691
+ // shared core — single source with MCP mem_update (lib/observation-write.mjs).
1724
1692
  db.transaction(() => {
1725
1693
  db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1726
-
1727
- // Rebuild FTS text field
1728
- const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(id);
1729
- const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
1730
- const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
1731
- const textField = bigrams ? base + ' ' + bigrams : base;
1732
- db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, id);
1733
-
1734
- // Re-vectorize (non-critical — catch to avoid rollback)
1735
- try {
1736
- const vocab = getVocabulary(db);
1737
- if (vocab) {
1738
- const vec = computeVector(textField, vocab);
1739
- if (vec) {
1740
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
1741
- .run(id, Buffer.from(vec.buffer), vocab.version, Date.now());
1742
- }
1743
- }
1744
- } catch { /* non-critical */ }
1694
+ rebuildObservationDerived(db, id);
1745
1695
  })();
1746
1696
 
1747
1697
  out(`[mem] Updated #${id}: ${updates.map(u => u.split(' =')[0]).join(', ')}`);
@@ -1797,12 +1747,15 @@ function cmdExport(db, args) {
1797
1747
 
1798
1748
  // Full round-trippable column set so `restore` rebuilds observations faithfully —
1799
1749
  // content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
1800
- // Additive vs the pre-v2.90 13-col shape; existing `export | jq '.[].title'` consumers
1801
- // are unaffected. id + memory_session_id are informational (restore remaps id and
1802
- // buckets under a restore session).
1750
+ // `search_aliases` is an FTS5-indexed column (BM25 weight 5) dropping it on
1751
+ // export silently lost the LLM-generated alternate query terms on restore, so a
1752
+ // restored memory became unfindable by its aliases. Additive vs the pre-v2.90
1753
+ // 13-col shape; existing `export | jq '.[].title'` consumers are unaffected.
1754
+ // id + memory_session_id are informational (restore remaps id and buckets under
1755
+ // a restore session).
1803
1756
  const rows = db.prepare(`
1804
1757
  SELECT id, memory_session_id, project, type, title, subtitle, narrative, concepts, facts,
1805
- files_read, files_modified, lesson_learned, importance, branch,
1758
+ files_read, files_modified, lesson_learned, search_aliases, importance, branch,
1806
1759
  access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
1807
1760
  last_accessed_at, created_at, created_at_epoch
1808
1761
  FROM observations WHERE ${wheres.join(' AND ')}
@@ -1863,7 +1816,7 @@ function cmdRestore(db, argv) {
1863
1816
 
1864
1817
  const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
1865
1818
  const signalUpdate = db.prepare(`UPDATE observations SET
1866
- subtitle = ?, concepts = ?, facts = ?, files_read = ?, branch = COALESCE(?, branch),
1819
+ subtitle = ?, concepts = ?, facts = ?, search_aliases = ?, files_read = ?, branch = COALESCE(?, branch),
1867
1820
  access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
1868
1821
  decay_seen_count = ?, last_accessed_at = ?
1869
1822
  WHERE id = ?`);
@@ -1893,8 +1846,10 @@ function cmdRestore(db, argv) {
1893
1846
  });
1894
1847
  if (res.kind !== 'saved') { skipped++; continue; } // saveObservation Jaccard dedup
1895
1848
  // Re-apply the fields saveObservation zeros/derives so the backup is faithful.
1849
+ // search_aliases is its own FTS5 column, so this UPDATE re-syncs the index
1850
+ // (via the observations FTS triggers) and restored aliases stay searchable.
1896
1851
  signalUpdate.run(
1897
- r.subtitle || '', r.concepts || '', r.facts || '', r.files_read || '[]', r.branch ?? null,
1852
+ r.subtitle || '', r.concepts || '', r.facts || '', r.search_aliases ?? null, r.files_read || '[]', r.branch ?? null,
1898
1853
  num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
1899
1854
  num(r.decay_seen_count), r.last_accessed_at ?? null,
1900
1855
  res.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.96.0",
3
+ "version": "2.98.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,7 @@
68
68
  "lib/mem-override.mjs",
69
69
  "lib/save-observation.mjs",
70
70
  "lib/observation-write.mjs",
71
+ "lib/recall-core.mjs",
71
72
  "lib/compress-core.mjs",
72
73
  "lib/maintain-core.mjs",
73
74
  "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
package/server.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
- import { truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, scrubSecrets, cjkBigrams, fmtDate, debugLog, debugCatch, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause } from './utils.mjs';
8
+ import { truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, scrubSecrets, fmtDate, debugLog, debugCatch, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined } from './utils.mjs';
9
9
  import { extractCjkLikePatterns, cjkPrecisionOk } from './nlp.mjs';
10
10
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
11
11
  import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
@@ -20,6 +20,7 @@ import {
20
20
  } from './lib/maintain-core.mjs';
21
21
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
22
22
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
23
+ import { formatObsFieldValue } from './cli/common.mjs';
23
24
  import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
24
25
 
25
26
  // Lookup helper: all user-facing tool descriptions live in tool-schemas.mjs
@@ -31,18 +32,20 @@ function descriptionOf(name) {
31
32
  return d;
32
33
  }
33
34
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
34
- import { basename, join, sep } from 'path';
35
+ import { join, sep } from 'path';
35
36
  import { homedir } from 'os';
36
37
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
37
38
  import { searchResources } from './registry-retriever.mjs';
38
39
  import { probeOtherSources as probeIdSources, parseIdToken, bucketIdTokens } from './lib/id-routing.mjs';
39
40
  import { saveObservation } from './lib/save-observation.mjs';
41
+ import { rebuildObservationDerived } from './lib/observation-write.mjs';
42
+ import { recallByFile } from './lib/recall-core.mjs';
40
43
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
41
44
  import {
42
45
  insertDeferred, listOpenWithOrdinal, dropDeferred,
43
46
  resolveDeferredIds, closeDeferredItems,
44
47
  } from './lib/deferred-work.mjs';
45
- import { getVocabulary, _resetVocabCache, computeVector } from './tfidf.mjs';
48
+ import { _resetVocabCache } from './tfidf.mjs';
46
49
  import { createRequire } from 'module';
47
50
 
48
51
  const require = createRequire(import.meta.url);
@@ -801,8 +804,12 @@ server.registerTool(
801
804
  const val = row[f];
802
805
  if (val === null || val === undefined || val === '') continue;
803
806
  if (f === 'text' && row.narrative && typeof val === 'string' && val.startsWith(row.narrative)) continue;
807
+ // Shared formatter (cli/common.mjs) renders epoch-ms time fields as
808
+ // `<ms> (<relative>)` — parity with the CLI `get` path so an LLM reader
809
+ // gets a scannable hint instead of a bare millisecond integer.
810
+ const display = formatObsFieldValue(f, val);
804
811
  const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
805
- lines.push(`${f}: ${typeof val === 'string' && val.length > maxLen ? val.slice(0, maxLen) + '…' : val}`);
812
+ lines.push(`${f}: ${typeof display === 'string' && display.length > maxLen ? display.slice(0, maxLen) + '…' : display}`);
806
813
  }
807
814
  sections.push(lines.join('\n'));
808
815
  }
@@ -1814,28 +1821,11 @@ server.registerTool(
1814
1821
 
1815
1822
  params.push(args.id);
1816
1823
 
1817
- // Atomic: update fields + rebuild FTS text + re-vectorize
1824
+ // Atomic: update fields + rebuild derived columns (FTS text + vector) via the
1825
+ // shared core — single source with CLI cmdUpdate (lib/observation-write.mjs).
1818
1826
  db.transaction(() => {
1819
1827
  db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1820
-
1821
- // Rebuild FTS text field (must include CJK bigrams + search_aliases to match mem_save/hook-llm)
1822
- const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(args.id);
1823
- const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
1824
- const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
1825
- const textField = bigrams ? base + ' ' + bigrams : base;
1826
- db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, args.id);
1827
-
1828
- // Re-vectorize (non-critical — catch to avoid rollback)
1829
- try {
1830
- const vocab = getVocabulary(db);
1831
- if (vocab) {
1832
- const vec = computeVector(textField, vocab);
1833
- if (vec) {
1834
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
1835
- .run(args.id, Buffer.from(vec.buffer), vocab.version, Date.now());
1836
- }
1837
- }
1838
- } catch (e) { debugCatch(e, 'mem_update-vector'); }
1828
+ rebuildObservationDerived(db, args.id);
1839
1829
  })();
1840
1830
 
1841
1831
  return { content: [{ type: 'text', text: `Updated observation #${args.id}: ${updates.map(u => u.split(' =')[0]).join(', ')}` }] };
@@ -1901,35 +1891,16 @@ server.registerTool(
1901
1891
  inputSchema: memRecallSchema,
1902
1892
  },
1903
1893
  safeHandler(async (args) => {
1904
- const filename = basename(args.file);
1905
- const limit = args.limit ?? 10;
1906
- const includeNoise = args.include_noise === true;
1907
-
1908
- const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
1909
- const likePattern = `%${escaped}`;
1910
- const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
1911
- const rows = db.prepare(`
1912
- SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.created_at, o.project
1913
- FROM observations o
1914
- JOIN observation_files of2 ON of2.obs_id = o.id
1915
- WHERE COALESCE(o.compressed_into, 0) = 0
1916
- AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
1917
- ${noiseClause}
1918
- ORDER BY o.created_at_epoch DESC
1919
- LIMIT ?
1920
- `).all(filename, likePattern, limit);
1894
+ // Shared core with CLI cmdRecall: query + escaping + access bump (lib/recall-core.mjs)
1895
+ const { filename, rows } = recallByFile(db, args.file, {
1896
+ limit: args.limit ?? 10,
1897
+ includeNoise: args.include_noise === true,
1898
+ });
1921
1899
 
1922
1900
  if (rows.length === 0) {
1923
1901
  return { content: [{ type: 'text', text: `No history for "${filename}". This file hasn't been observed yet.` }] };
1924
1902
  }
1925
1903
 
1926
- // Update access_count for recalled observations
1927
- const recalledIds = rows.map(r => r.id);
1928
- const ph = recalledIds.map(() => '?').join(',');
1929
- try {
1930
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...recalledIds);
1931
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
1932
-
1933
1904
  const lines = [`History for ${filename} (${rows.length} observation${rows.length !== 1 ? 's' : ''}):\n`];
1934
1905
  for (const r of rows) {
1935
1906
  const lesson = r.lesson_learned ? `\n Lesson: ${truncate(r.lesson_learned, 100)}` : '';
package/source-files.mjs CHANGED
@@ -82,6 +82,7 @@ export const SOURCE_FILES = [
82
82
  // entry-point-reachable); missing it from the manifest would break ALL saves on
83
83
  // auto-update. Same single-source-of-truth pattern (see #8217).
84
84
  'lib/observation-write.mjs',
85
+ 'lib/recall-core.mjs',
85
86
  // Shared "compress old low-value observations into weekly summaries" core.
86
87
  // Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
87
88
  // and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as
package/tool-schemas.mjs CHANGED
@@ -218,11 +218,15 @@ export const memMaintainSchema = {
218
218
 
219
219
  export const memUpdateSchema = {
220
220
  id: coerceInt.pipe(z.number().int().positive()).describe('Observation ID to update'),
221
- title: z.string().optional().describe('New title'),
221
+ // CLI parity (cmdUpdate): empty/whitespace title would render as `(untitled)`
222
+ // in every listing — reject here like the CLI does, instead of persisting it.
223
+ title: z.string().refine(s => s.trim() !== '', 'title cannot be empty').optional().describe('New title'),
222
224
  narrative: z.string().optional().describe('New narrative/content'),
223
225
  type: OBS_TYPE_ENUM.optional().describe('New observation type'),
224
226
  importance: coerceInt.pipe(z.number().int().min(1).max(3)).optional().describe('New importance (1-3)'),
225
- lesson_learned: z.string().optional().describe('Add or update lesson learned'),
227
+ // 500-char cap mirrors memSaveSchema + cmdUpdate — update was the one path
228
+ // that let overlong lessons leak into the DB via MCP.
229
+ lesson_learned: z.string().max(500).optional().describe('Add or update lesson learned'),
226
230
  concepts: z.string().optional().describe('Space-separated concept tags'),
227
231
  };
228
232