claude-mem-lite 3.21.0 → 3.22.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": "3.21.0",
13
+ "version": "3.22.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": "3.21.0",
3
+ "version": "3.22.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
@@ -21,7 +21,19 @@ export function parseArgs(argv) {
21
21
  while (i < argv.length) {
22
22
  const arg = argv[i];
23
23
  if (arg.startsWith('--')) {
24
- const key = arg.slice(2);
24
+ const body = arg.slice(2);
25
+ // `--key=value` (GNU long-option form). Split on the FIRST '=' so values that
26
+ // themselves contain '=' (e.g. `--from=2026-01-01`, a token with '=') stay intact.
27
+ // Without this, `--type=feature` parsed as a boolean flag literally named
28
+ // "type=feature"; the real `--type` stayed undefined and the default silently
29
+ // applied — a save landed in the wrong project / type with no error.
30
+ const eq = body.indexOf('=');
31
+ if (eq >= 0) {
32
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
33
+ i++;
34
+ continue;
35
+ }
36
+ const key = body;
25
37
  const next = argv[i + 1];
26
38
  if (next !== undefined && !next.startsWith('--') && (!next.startsWith('-') || /^-\d/.test(next))) {
27
39
  flags[key] = next;
package/format-utils.mjs CHANGED
@@ -24,6 +24,24 @@ export function truncate(str, max = 80) {
24
24
  return str.slice(0, end) + '\u2026';
25
25
  }
26
26
 
27
+ // The block delimiters claude-mem-lite wraps injected context in. Any user-derived text
28
+ // (observation title / lesson, handoff body) that contains one of these LITERALLY would
29
+ // prematurely open or close the block it lands in, and the model then reads the rest as
30
+ // undelimited context. Reachable by editing files that contain these tokens \u2014 e.g.
31
+ // developing claude-mem-lite itself, where source/observations carry the delimiter names.
32
+ const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff)>/gi;
33
+
34
+ /**
35
+ * Defang the literal context-block delimiter tags in user-derived text. Strips just the
36
+ * angle brackets, so `</claude-mem-context>` renders as `/claude-mem-context` \u2014 still
37
+ * readable, but no longer a structural delimiter. Complements `mdCell`'s pipe-escaping.
38
+ * @param {string} s Input string (any type; coerced)
39
+ * @returns {string} Text with delimiter tags defanged
40
+ */
41
+ export function neutralizeContextDelimiters(s) {
42
+ return String(s ?? '').replace(CONTEXT_DELIMITER_RE, (m) => m.slice(1, -1));
43
+ }
44
+
27
45
  /**
28
46
  * Render the PostToolUse error-recall hint block (hook.mjs::triggerErrorRecall).
29
47
  * The single most-relevant hit (rows[0]) that carries a lesson_learned gets its
package/hook-context.mjs CHANGED
@@ -5,7 +5,7 @@ import { basename, join } from 'path';
5
5
  import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
6
6
  import {
7
7
  estimateTokens, truncate, typeIcon, fmtTime, inferProject,
8
- debugLog, debugCatch,
8
+ debugLog, debugCatch, neutralizeContextDelimiters,
9
9
  DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
10
10
  } from './utils.mjs';
11
11
  import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet } from './hook-shared.mjs';
@@ -462,7 +462,11 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
462
462
  }
463
463
  }
464
464
 
465
- return [...summaryLines, ...handoffLines, ...deferredLines, ...obsLines].join('\n');
465
+ // Defang any literal block-delimiter tag carried in a title/lesson/summary so a row
466
+ // can't prematurely close the <claude-mem-context> block it's wrapped in (mdCell does
467
+ // the same for `|`). One source of truth: both the SessionStart hook and the CLI
468
+ // `context` command consume this return.
469
+ return neutralizeContextDelimiters([...summaryLines, ...handoffLines, ...deferredLines, ...obsLines].join('\n'));
466
470
  }
467
471
 
468
472
  /**
package/hook-handoff.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // Extracted for testability — hook.mjs has module-level side effects
3
3
 
4
4
  import { basename } from 'path';
5
- import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, scrubSecrets, LOW_SIGNAL_TITLE, EDIT_TOOLS, isMetaTriggerPrompt, notLowSignalTitleClause } from './utils.mjs';
5
+ import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, scrubSecrets, LOW_SIGNAL_TITLE, EDIT_TOOLS, isMetaTriggerPrompt, notLowSignalTitleClause, neutralizeContextDelimiters } from './utils.mjs';
6
6
  import { scrubRecord } from './lib/scrub-record.mjs';
7
7
  import {
8
8
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT, HANDOFF_ANCHOR_MAX_AGE,
@@ -418,11 +418,15 @@ function renderHandoffFromRow(handoff, db, project) {
418
418
  `<session-handoff source="${handoff.type}" age="${ageStr}" origin="hook-injected">`,
419
419
  ];
420
420
 
421
+ // Defang delimiter tags in the free-text fields ONLY — never the structural
422
+ // <session-handoff> tags in `lines`, or the block would lose its own framing. A
423
+ // user prompt or edit snippet carrying a literal </session-handoff> would otherwise
424
+ // close the block early and the rest would read as a real user message.
421
425
  if (handoff.working_on) {
422
- lines.push('## Working On', handoff.working_on, '');
426
+ lines.push('## Working On', neutralizeContextDelimiters(handoff.working_on), '');
423
427
  }
424
428
  if (handoff.completed) {
425
- lines.push('## Completed', ...handoff.completed.split('\n').map(l => `- ${l}`), '');
429
+ lines.push('## Completed', ...neutralizeContextDelimiters(handoff.completed).split('\n').map(l => `- ${l}`), '');
426
430
  }
427
431
  if (handoff.unfinished) {
428
432
  // Extract only the pending-work portion (before narrative history separator).
@@ -431,7 +435,7 @@ function renderHandoffFromRow(handoff, db, project) {
431
435
  // completeness claim the episode buffer can't support.
432
436
  const pending = extractUnfinishedSummary(handoff.unfinished);
433
437
  if (pending) {
434
- lines.push('## Recent activity', ...pending.split('; ').map(l => `- ${l}`), '');
438
+ lines.push('## Recent activity', ...neutralizeContextDelimiters(pending).split('; ').map(l => `- ${l}`), '');
435
439
  }
436
440
  }
437
441
  if (handoff.key_files) {
@@ -441,7 +445,7 @@ function renderHandoffFromRow(handoff, db, project) {
441
445
  } catch {}
442
446
  }
443
447
  if (handoff.key_decisions) {
444
- lines.push('## Key Decisions', ...handoff.key_decisions.split('\n').map(l => `- ${l}`), '');
448
+ lines.push('## Key Decisions', ...neutralizeContextDelimiters(handoff.key_decisions).split('\n').map(l => `- ${l}`), '');
445
449
  }
446
450
 
447
451
  lines.push('</session-handoff>');
package/hook-memory.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // claude-mem-lite — Semantic Memory Injection
2
2
  // Search past observations for relevant memories to inject as context at user-prompt time.
3
3
 
4
- import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords } from './utils.mjs';
4
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters } from './utils.mjs';
5
5
  import { citeFactorJs } from './scoring-sql.mjs';
6
6
  import { recordMetric } from './lib/metrics.mjs';
7
7
  import { DB_DIR } from './schema.mjs';
@@ -123,7 +123,10 @@ export function formatMemoryLine(obs) {
123
123
  && hasFilePaths(obs.files_modified)) {
124
124
  staleHint = ' [verify-before-use]';
125
125
  }
126
- return `- [${obs.type}] ${truncate(obs.title, 80)}${lessonTag} (#${obs.id})${staleHint}`;
126
+ // Defang any literal block-delimiter tag in title/lesson so it can't prematurely close
127
+ // the <memory-context> block this line is injected into (parity with hook-context's
128
+ // <claude-mem-context> defense).
129
+ return neutralizeContextDelimiters(`- [${obs.type}] ${truncate(obs.title, 80)}${lessonTag} (#${obs.id})${staleHint}`);
127
130
  }
128
131
 
129
132
  function hasFilePaths(filesModified) {
package/hook.mjs CHANGED
@@ -39,6 +39,7 @@ import { entry as preCompactEntry } from './hook-precompact.mjs';
39
39
  import {
40
40
  RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
41
41
  SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
42
+ HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
42
43
  sessionFile, getSessionId, createSessionId, openDb,
43
44
  spawnBackground, sweepOrphanEpisodeFiles,
44
45
  } from './hook-shared.mjs';
@@ -696,15 +697,19 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
696
697
  WHERE status = 'active' AND started_at_epoch < ?
697
698
  `).run(staleSessionCutoff);
698
699
 
699
- // Auto-compress: mark old low-importance observations as compressed (30+ days, importance=1)
700
+ // Auto-compress: mark old low-importance observations as compressed (30+ days, importance<=1)
700
701
  // Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
701
702
  // v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant
702
703
  // via hook-memory injection, even if user never explicitly fetched). Same
703
704
  // protection applied symmetrically in auto-maintain decay/mark-idle below.
705
+ // `<= 1` (was `= 1`): citation-decay floors importance at 0 (added v2.73.2, after this
706
+ // predicate was written) and the LLM low-signal filter saves at imp=0 — those rows are
707
+ // STRICTLY lower value than imp=1 yet escaped GC, accumulating to ~40% of a mature DB
708
+ // (immortal: hidden from injection by the imp>=1 floor, but visible as explicit-search noise).
704
709
  const compressed = db.prepare(`
705
710
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
706
711
  WHERE COALESCE(compressed_into, 0) = 0
707
- AND importance = 1
712
+ AND COALESCE(importance, 1) <= 1
708
713
  AND COALESCE(injection_count, 0) = 0
709
714
  AND created_at_epoch < ?
710
715
  AND project = ?
@@ -722,7 +727,7 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
722
727
  const noiseCompressed = db.prepare(`
723
728
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
724
729
  WHERE COALESCE(compressed_into, 0) = 0
725
- AND importance = 1
730
+ AND COALESCE(importance, 1) <= 1
726
731
  AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
727
732
  AND (facts IS NULL OR facts = '' OR facts = '[]')
728
733
  AND (
@@ -848,6 +853,20 @@ function runSessionStartAutoMaintain(db) {
848
853
  if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
849
854
  } catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
850
855
 
856
+ // GC expired session_handoffs: the consume-DELETE (handleSessionStart) only removes
857
+ // the single handoff a continuation reads back; an 'exit'/'compact' that is never
858
+ // resumed (and every superseded 'clear') lingers forever — read paths filter by
859
+ // expiry but nothing reaped the rows. Delete past-expiry rows with a +1d margin so a
860
+ // still-readable handoff is never raced away. 'clear' 6h+1d, 'exit'/other 7d+1d.
861
+ try {
862
+ const gc = db.prepare(`
863
+ DELETE FROM session_handoffs
864
+ WHERE (type = 'clear' AND created_at_epoch < ?)
865
+ OR (type != 'clear' AND created_at_epoch < ?)
866
+ `).run(Date.now() - HANDOFF_EXPIRY_CLEAR - 86400000, Date.now() - HANDOFF_EXPIRY_EXIT - 86400000);
867
+ if (gc.changes > 0) debugLog('DEBUG', 'auto-maintain', `gc'd ${gc.changes} expired session_handoffs`);
868
+ } catch (e) { debugCatch(e, 'auto-maintain-handoff-gc'); }
869
+
851
870
  // Mark maintenance as done (24h gate) — even though compression runs in background
852
871
  writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
853
872
  // Weekly summary grouping runs in background to avoid blocking SessionStart
@@ -22,9 +22,12 @@ import { getVocabulary, computeVector } from '../tfidf.mjs';
22
22
  import { scrubRecord } from './scrub-record.mjs';
23
23
 
24
24
  /**
25
- * Low-value compression candidates: importance=1, never accessed, older than
25
+ * Low-value compression candidates: importance<=1, never accessed, older than
26
26
  * `cutoff`, not already compressed. `includeAutoMarked` also folds in rows the
27
27
  * hook lightweight-marked as COMPRESSED_AUTO (the hook re-summarizes those).
28
+ * `<= 1` (was `= 1`): citation-decay floors importance at 0 and the LLM low-signal
29
+ * filter saves at imp=0; those rows are strictly lower value than imp=1 and must be
30
+ * GC-eligible too, or they accumulate forever (parity with hook.mjs auto-compress).
28
31
  */
29
32
  export function selectCompressionCandidates(db, { cutoff, project = null, includeAutoMarked = false }) {
30
33
  const compressedFilter = includeAutoMarked
@@ -35,7 +38,7 @@ export function selectCompressionCandidates(db, { cutoff, project = null, includ
35
38
  return db.prepare(`
36
39
  SELECT id, project, type, title, created_at, created_at_epoch
37
40
  FROM observations
38
- WHERE COALESCE(importance, 1) = 1
41
+ WHERE COALESCE(importance, 1) <= 1
39
42
  AND COALESCE(access_count, 0) = 0
40
43
  AND created_at_epoch < ?
41
44
  ${compressedFilter}
@@ -112,17 +112,25 @@ export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP })
112
112
  }
113
113
 
114
114
  /**
115
- * Decay importance of old, never-accessed, NEVER-INJECTED observations, then mark
116
- * the importance-1 idle ones as pending-purge. injection_count>0 is protected as
117
- * first-class engagement alongside access_count (unified across all three paths).
115
+ * Decay importance of old, never-accessed, NEVER-INJECTED observations and mark the
116
+ * importance-1 idle ones as pending-purge. injection_count>0 is protected as first-class
117
+ * engagement alongside access_count (unified across all three paths).
118
+ *
119
+ * MARK-IDLE RUNS BEFORE DECAY (audit MED-1): if decay ran first, an imp-2 row would be
120
+ * decayed 2→1 and then re-selected by the same call's mark-idle pass → hidden as
121
+ * pending-purge in ONE pass, collapsing the per-tier grace cycle and over-marking vs what
122
+ * `maintain scan` (stale = imp-1 only) forecasts. Marking first means each call only marks
123
+ * rows that were ALREADY imp-1; a freshly-decayed imp-2→1 row waits for the next call,
124
+ * so importance tiers each buy a grace cycle (imp3→2→1→pending across runs) and the scan
125
+ * forecast matches what decay actually marks.
118
126
  */
119
127
  export function decayAndMarkIdle(db, { projectFilter, baseParams, staleAge, opCap = OP_CAP }) {
120
- const decayed = db.prepare(`
121
- UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
128
+ const idleMarked = db.prepare(`
129
+ UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
122
130
  WHERE id IN (
123
131
  SELECT id FROM observations
124
132
  WHERE COALESCE(compressed_into, 0) = 0
125
- AND COALESCE(importance, 1) > 1
133
+ AND COALESCE(importance, 1) = 1
126
134
  AND COALESCE(access_count, 0) = 0
127
135
  AND COALESCE(injection_count, 0) = 0
128
136
  AND created_at_epoch < ?
@@ -130,12 +138,12 @@ export function decayAndMarkIdle(db, { projectFilter, baseParams, staleAge, opCa
130
138
  )
131
139
  `).run(staleAge, ...baseParams).changes;
132
140
 
133
- const idleMarked = db.prepare(`
134
- UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
141
+ const decayed = db.prepare(`
142
+ UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
135
143
  WHERE id IN (
136
144
  SELECT id FROM observations
137
145
  WHERE COALESCE(compressed_into, 0) = 0
138
- AND COALESCE(importance, 1) = 1
146
+ AND COALESCE(importance, 1) > 1
139
147
  AND COALESCE(access_count, 0) = 0
140
148
  AND COALESCE(injection_count, 0) = 0
141
149
  AND created_at_epoch < ?
@@ -102,7 +102,7 @@ export function searchSessionsFts(db, { ftsQuery, project = null, projectBoost =
102
102
  return db.prepare(`
103
103
  SELECT s.id, s.request, s.completed, s.project, s.created_at, s.created_at_epoch,
104
104
  ${SESS_BM25}
105
- * (1.0 + EXP(-0.693 * (? - s.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
105
+ * (1.0 + EXP(-0.693 * MAX(0, ? - s.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
106
106
  * (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
107
107
  FROM session_summaries_fts
108
108
  JOIN session_summaries s ON session_summaries_fts.rowid = s.id
package/mem-cli.mjs CHANGED
@@ -324,7 +324,10 @@ function cmdRecent(db, args) {
324
324
  // accepted silently; the positional path must reject garbage like the --limit flag does.
325
325
  const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= RECENT_MAX;
326
326
  if (rawArg !== undefined && !isValid) {
327
- process.stderr.write(`[mem] Invalid count "${rawArg}" (must be an integer between 1 and ${RECENT_MAX}); using default 10\n`);
327
+ // Name the ACTUAL fallback: a present --limit overrides the positional below, so
328
+ // claiming "default 10" when `recent abc --limit 5` returns 5 misled the user.
329
+ const fallbackLabel = flags.limit !== undefined ? '--limit' : 'default 10';
330
+ process.stderr.write(`[mem] Invalid count "${rawArg}" (must be an integer between 1 and ${RECENT_MAX}); using ${fallbackLabel}\n`);
328
331
  }
329
332
  // Positional [N] wins for backward-compat; --limit is sibling-parity alias
330
333
  // (search/recall/browse/stats all accept --limit). Pre-2.69 `recent --limit N`
@@ -1423,6 +1426,12 @@ function cmdDelete(db, args) {
1423
1426
  return;
1424
1427
  }
1425
1428
 
1429
+ // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
1430
+ // matching the maintain purge/cleanup hard-delete paths (audit MED-2). Best-effort
1431
+ // (never throws, skips :memory:); rows here are confirmed + non-empty, so the delete
1432
+ // always removes something worth backing up. Must run OUTSIDE the transaction (VACUUM).
1433
+ snapshotDb(db, { tag: 'pre-delete' });
1434
+
1426
1435
  // Transaction: clean up related_ids references + delete (aligned with MCP mem_delete)
1427
1436
  const deletedIds = new Set(ids);
1428
1437
  const deleteTx = db.transaction(() => {
@@ -1552,6 +1561,12 @@ function cmdUpdate(db, args) {
1552
1561
 
1553
1562
  function cmdExport(db, args) {
1554
1563
  const { flags } = parseArgs(args);
1564
+ // Guard value-less string flags. Bare `--to` parsed to boolean `true`, and
1565
+ // `new Date(true).getTime()` is 1 (NOT NaN), so the isNaN guard below missed it and
1566
+ // the filter became `created_at_epoch <= 1` → an EMPTY export with exit 0. A backup
1567
+ // script (`export --to "$END" > backup.json`) with an unset `$END` would silently
1568
+ // write an empty backup and report success. Reject like cmdSearch does.
1569
+ if (rejectBareStringFlags(flags, ['project', 'type', 'from', 'to'])) return;
1555
1570
  const wheres = [];
1556
1571
  const params = [];
1557
1572
  // --include-compressed: include compressed observations (aligned with MCP mem_export)
@@ -1743,8 +1758,10 @@ function cmdCompress(db, args) {
1743
1758
  // got the 30-day cutoff without knowing their input was discarded.
1744
1759
  let ageDays = 30;
1745
1760
  if (flags['age-days'] !== undefined) {
1746
- const parsed = parseInt(flags['age-days'], 10);
1747
- if (!Number.isFinite(parsed) || parsed < 1) {
1761
+ // isNumericToken (not bare parseInt) so "1e5"→1 and "30x"→30 are rejected rather than
1762
+ // silently mis-parsed into a far-too-broad cutoff — parity with recent/search/maintain.
1763
+ const parsed = Number(flags['age-days']);
1764
+ if (!isNumericToken(flags['age-days']) || !Number.isInteger(parsed) || parsed < 1) {
1748
1765
  fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be a positive integer.`);
1749
1766
  return;
1750
1767
  }
@@ -1800,6 +1817,11 @@ function cmdMaintain(db, args) {
1800
1817
  fail("[mem] Usage: claude-mem-lite maintain <scan|execute> [--ops cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum] [--project P] [--retain-days N] [--merge-ids keepId:removeId,...] — 'scan' previews, 'execute' applies.");
1801
1818
  return;
1802
1819
  }
1820
+ // Guard value-less string flags before any `.split()` / resolveProject runs. A bare
1821
+ // `--merge-ids` parsed to boolean `true`, and `true.split(',')` (line ~1911) crashed
1822
+ // with a raw stack trace — the one string-flag path that lacked this #8470 guard, and
1823
+ // the exact form the `scan` output suggests copy-pasting (`--merge-ids <pairs>`).
1824
+ if (rejectBareStringFlags(flags, ['ops', 'project', 'merge-ids', 'retain-days'])) return;
1803
1825
 
1804
1826
  const project = flags.project ? resolveProject(db, flags.project) : null;
1805
1827
  const projectFilter = project ? 'AND project = ?' : '';
@@ -1868,15 +1890,57 @@ function cmdMaintain(db, args) {
1868
1890
  // T2-P1-B: surface the OP_CAP hit so users know to re-run, matching MCP mem_maintain.
1869
1891
  const capHint = (changes) => (changes >= OP_CAP ? ' (cap reached, re-run for more)' : '');
1870
1892
 
1871
- // MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes
1872
- // only when rows will actually be removed, and OUTSIDE the transaction below
1873
- // (VACUUM cannot run inside one). Best-effort; snapshotDb never throws.
1874
- const willPurge = ops.includes('purge_stale') && (flags.confirm === true || flags.confirm === 'true');
1893
+ // Parse + validate --retain-days BEFORE the transaction so an invalid value rejects the
1894
+ // whole command atomically. The old code validated inside db.transaction() with a bare
1895
+ // `return`, so an earlier op (cleanup hard-delete / decay / boost) had already mutated and
1896
+ // the transaction COMMITTED despite the exit-1 error (audit MED-2 atomicity).
1897
+ let retainDays = 30;
1898
+ if (ops.includes('purge_stale') && flags['retain-days'] !== undefined) {
1899
+ // Number + isInteger (not parseInt) so "7.5"/"30x" are rejected rather than silently
1900
+ // truncated — parity with the mem_maintain zod .int().min(7).max(365) bound.
1901
+ const parsed = Number(flags['retain-days']);
1902
+ if (!Number.isInteger(parsed) || parsed < 7 || parsed > 365) {
1903
+ fail(`[mem] --retain-days must be an integer in [7, 365] (got "${flags['retain-days']}")`);
1904
+ return;
1905
+ }
1906
+ retainDays = parsed;
1907
+ }
1908
+ const retainCutoff = Date.now() - retainDays * 86400000;
1909
+ // purge_stale is the only DELETE here — require --confirm so a mis-typed run can't wipe rows.
1910
+ const confirmed = flags.confirm === true || flags.confirm === 'true';
1911
+
1912
+ // Snapshot the DB before the irreversible cleanup/purge hard-deletes — only when rows will
1913
+ // actually be removed, and OUTSIDE the transaction below (VACUUM cannot run inside one).
1914
+ // Best-effort; snapshotDb never throws.
1915
+ const willPurge = ops.includes('purge_stale') && confirmed;
1875
1916
  if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: willPurge }) > 0) {
1876
1917
  snapshotDb(db, { tag: 'pre-maintain' });
1877
1918
  }
1878
1919
 
1879
1920
  db.transaction(() => {
1921
+ // PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766). Running decay BEFORE
1922
+ // purge in one transaction marked a stale row pending-purge AND deleted it in the SAME call
1923
+ // (zero grace), and the pre-txn snapshot guard counts only PRE-EXISTING pending rows so it
1924
+ // skipped the backup → permanent, unrecoverable loss of notable imp-2/3 memories (audit
1925
+ // HIGH-1). Purging first deletes only rows a PRIOR run marked (which the guard saw + backed
1926
+ // up); rows decay marks below wait for the next maintain run, regaining the grace cycle.
1927
+ if (ops.includes('purge_stale')) {
1928
+ if (!confirmed) {
1929
+ const previewRow = purgeStalePreview(db, mctx, retainCutoff);
1930
+ const pushLines = [`purge_stale preview (no --confirm):`,
1931
+ ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
1932
+ if (previewRow.candidates > 0) {
1933
+ pushLines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
1934
+ pushLines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
1935
+ }
1936
+ pushLines.push(` To delete, re-run with --confirm.`);
1937
+ results.push(pushLines.join('\n'));
1938
+ } else {
1939
+ const purged = purgeStale(db, mctx, retainCutoff);
1940
+ results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)${capHint(purged)}`);
1941
+ }
1942
+ }
1943
+
1880
1944
  if (ops.includes('cleanup')) {
1881
1945
  const deleted = cleanupBroken(db, mctx);
1882
1946
  results.push(`Cleaned up ${deleted} broken observations${capHint(deleted)}`);
@@ -1926,43 +1990,6 @@ function cmdMaintain(db, args) {
1926
1990
  results.push('Warning: --merge-ids provided but "dedup" not in operations — merge-ids ignored');
1927
1991
  }
1928
1992
 
1929
- if (ops.includes('purge_stale')) {
1930
- // --retain-days: default 30 when absent; reject negative / 0 / NaN / out-of-range.
1931
- // A negative value made retainCutoff a FUTURE timestamp → purged the entire
1932
- // pending-purge backlog regardless of age; 0/garbage silently became 30 and
1933
- // masked typos. Parity with the mem_maintain MCP zod bound [7, 365].
1934
- let retainDays = 30;
1935
- if (flags['retain-days'] !== undefined) {
1936
- // Number + isInteger (not parseInt) so "7.5"/"30x" are rejected rather than
1937
- // silently truncated to 7/30 — parity with the mem_maintain zod .int() bound.
1938
- const parsed = Number(flags['retain-days']);
1939
- if (!Number.isInteger(parsed) || parsed < 7 || parsed > 365) {
1940
- // fail() only sets exitCode + writes stderr; it does NOT throw, so we
1941
- // MUST return or execution falls through to the DELETE with the bad value.
1942
- fail(`[mem] --retain-days must be an integer in [7, 365] (got "${flags['retain-days']}")`);
1943
- return;
1944
- }
1945
- retainDays = parsed;
1946
- }
1947
- const retainCutoff = Date.now() - retainDays * 86400000;
1948
- // T2-P0-A (CLI parity): purge_stale is the only DELETE in this code path — require
1949
- // --confirm so a mis-typed `maintain execute --ops purge_stale` can't wipe rows silently.
1950
- const confirmed = flags.confirm === true || flags.confirm === 'true';
1951
- if (!confirmed) {
1952
- const previewRow = purgeStalePreview(db, mctx, retainCutoff);
1953
- const pushLines = [`purge_stale preview (no --confirm):`,
1954
- ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
1955
- if (previewRow.candidates > 0) {
1956
- pushLines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
1957
- pushLines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
1958
- }
1959
- pushLines.push(` To delete, re-run with --confirm.`);
1960
- results.push(pushLines.join('\n'));
1961
- } else {
1962
- const purged = purgeStale(db, mctx, retainCutoff);
1963
- results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)${capHint(purged)}`);
1964
- }
1965
- }
1966
1993
  })();
1967
1994
 
1968
1995
  // FTS optimize
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.21.0",
3
+ "version": "3.22.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",
@@ -32,7 +32,11 @@ if [[ "$tool" == "Read" ]]; then
32
32
  # Sanitize project name to match utils.mjs inferProject()
33
33
  project="${project//[^a-zA-Z0-9_.-]/-}"
34
34
  project="${project:-unknown}"
35
- runtime_dir="$HOME/.claude-mem-lite/runtime"
35
+ # Honor CLAUDE_MEM_DIR relocation (mirrors schema.mjs DB_DIR → hook-shared RUNTIME_DIR).
36
+ # hook.mjs flushEpisode reads reads-<project>.txt from CLAUDE_MEM_DIR/runtime; if this
37
+ # bash fast-path wrote to $HOME unconditionally, a relocated install would drop all
38
+ # Read context from episodes AND grow an uncollected reads file in $HOME forever.
39
+ runtime_dir="${CLAUDE_MEM_DIR:-$HOME/.claude-mem-lite}/runtime"
36
40
  mkdir -p "$runtime_dir" 2>/dev/null
37
41
  # Use printf to avoid shell interpretation of special characters in file paths
38
42
  printf '%s\n' "$file_path" >> "${runtime_dir}/reads-${project}.txt"
@@ -491,6 +491,11 @@ async function main() {
491
491
 
492
492
  let hookData;
493
493
  try { hookData = JSON.parse(raw); } catch { return; }
494
+ // JSON.parse('null'/'42'/'"x"') succeeds with a non-object; dereferencing .prompt on
495
+ // it threw a raw TypeError → unhandled rejection → exit 1 (this was the lone hook
496
+ // script without an exit-0 safety net, violating the "hooks never exit non-zero"
497
+ // invariant — exit 2 on UserPromptSubmit would even block the user's prompt).
498
+ if (!hookData || typeof hookData !== 'object') return;
494
499
 
495
500
  const rawPrompt = hookData.prompt || hookData.user_prompt;
496
501
  if (!rawPrompt || typeof rawPrompt !== 'string') return;
@@ -724,4 +729,8 @@ async function main() {
724
729
  }
725
730
  }
726
731
 
727
- main();
732
+ // Swallow any rejection so the hook can never surface a non-zero exit (the invariant
733
+ // every sibling hook script upholds). Deliberately NOT `.finally(process.exit(0))` —
734
+ // this hook detaches a background `claude -p` search and a forced exit would kill it;
735
+ // letting the loop drain naturally exits 0 once the detached child is unref'd.
736
+ main().catch(() => {});
package/search-engine.mjs CHANGED
@@ -16,8 +16,13 @@ import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
16
16
 
17
17
  // Scoring expressions — full adds project boost + access bonus; simple is for
18
18
  // expansion paths where boost would over-amplify already-loose matches.
19
+ // `MAX(0, now - ts)` clamps the recency age to >= 0: a far-FUTURE created_at/last_accessed
20
+ // (reachable via restore/import-jsonl, which accept arbitrary epochs) otherwise made the
21
+ // exponent large-positive → EXP overflowed to +Infinity → score -Infinity → that row sorted
22
+ // #1 for any match AND JSON.stringify emitted `"score": null` (numeric-contract break). A
23
+ // future row now reads as age 0 = max (finite) recency, not Infinity.
19
24
  const FULL_SCORE = `${OBS_BM25}
20
- * (1.0 + EXP(-0.693 * (? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
25
+ * (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
21
26
  * ${TYPE_QUALITY_CASE}
22
27
  * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
23
28
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
@@ -25,7 +30,7 @@ const FULL_SCORE = `${OBS_BM25}
25
30
  * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
26
31
 
27
32
  const SIMPLE_SCORE = `${OBS_BM25}
28
- * (1.0 + EXP(-0.693 * (? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
33
+ * (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
29
34
  * ${TYPE_QUALITY_CASE}
30
35
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
31
36
  * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL))`;
@@ -320,7 +325,7 @@ export function findFtsAnchor(db, { ftsQuery, project = null, nowT = null, halfL
320
325
  AND (? IS NULL OR o.project = ?)
321
326
  AND COALESCE(o.compressed_into, 0) = 0
322
327
  ORDER BY ${OBS_BM25}
323
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / ${halfLifeMs}.0))
328
+ * (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${halfLifeMs}.0))
324
329
  LIMIT 1
325
330
  `;
326
331
  const stmt = db.prepare(sql);
package/server.mjs CHANGED
@@ -617,6 +617,11 @@ server.registerTool(
617
617
  return { content: [{ type: 'text', text: lines.join('\n') }] };
618
618
  }
619
619
 
620
+ // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
621
+ // matching the CLI delete + maintain purge/cleanup paths (audit MED-2). Best-effort
622
+ // (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM).
623
+ snapshotDb(db, { tag: 'pre-delete' });
624
+
620
625
  // Wrap cleanup + deletion in a transaction for consistency
621
626
  const deletedIds = new Set(args.ids);
622
627
  const deleteTx = db.transaction(() => {
@@ -1088,6 +1093,19 @@ server.registerTool(
1088
1093
  }
1089
1094
 
1090
1095
  db.transaction(() => {
1096
+ // PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766) and the CLI
1097
+ // cmdMaintain. Running decay BEFORE purge in one transaction marked a stale row
1098
+ // pending-purge AND deleted it in the SAME call (zero grace), while the pre-txn
1099
+ // snapshot guard counts only PRE-EXISTING pending rows so it skipped the backup →
1100
+ // permanent loss of notable imp-2/3 memories (audit HIGH-1). Purging first deletes
1101
+ // only rows a PRIOR run marked (backed up); rows decay marks below wait one cycle.
1102
+ if (ops.includes('purge_stale')) {
1103
+ const retainDays = args.retain_days ?? 30;
1104
+ const retainCutoff = Date.now() - retainDays * 86400000;
1105
+ const purged = purgeStale(db, mctx, retainCutoff);
1106
+ results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1107
+ }
1108
+
1091
1109
  if (ops.includes('cleanup')) {
1092
1110
  const deleted = cleanupBroken(db, mctx);
1093
1111
  results.push(`Cleaned up ${deleted} broken observations` + (deleted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
@@ -1118,13 +1136,6 @@ server.registerTool(
1118
1136
  if (!ops.includes('dedup') && args.merge_ids) {
1119
1137
  results.push('Warning: merge_ids provided but "dedup" not in operations — merge_ids ignored');
1120
1138
  }
1121
-
1122
- if (ops.includes('purge_stale')) {
1123
- const retainDays = args.retain_days ?? 30;
1124
- const retainCutoff = Date.now() - retainDays * 86400000;
1125
- const purged = purgeStale(db, mctx, retainCutoff);
1126
- results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1127
- }
1128
1139
  })();
1129
1140
 
1130
1141
  // FTS5 optimize (outside transaction)
package/utils.mjs CHANGED
@@ -14,7 +14,7 @@ export { cjkBigrams, extractCjkSynonymTokens, extractCjkKeywords, extractCjkLike
14
14
  export { resolveProject, _resetProjectCache } from './project-utils.mjs';
15
15
  export { scrubSecrets, SECRET_PATTERNS } from './secret-scrub.mjs';
16
16
  export { stripPrivate } from './lib/private-strip.mjs';
17
- export { truncate, typeIcon, fmtDate, fmtTime, isoWeekKey, formatErrorRecallHints } from './format-utils.mjs';
17
+ export { truncate, typeIcon, fmtDate, fmtTime, isoWeekKey, formatErrorRecallHints, neutralizeContextDelimiters } from './format-utils.mjs';
18
18
  export { computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from './hash-utils.mjs';
19
19
  export { detectBashSignificance, extractErrorKeywords, extractFilePaths, stripTestSuffix } from './bash-utils.mjs';
20
20