claude-mem-lite 3.20.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.20.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.20.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-update.mjs CHANGED
@@ -489,6 +489,51 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
489
489
  // below skips the 'node_modules' switchable path (existsSync guard), leaving
490
490
  // the target's node_modules untouched. Dependency bumps still flow through the
491
491
  // GitHub-tarball path (downloadAndInstall), which keeps skipNpmInstall=false.
492
+ // Undo a (partial or complete) file swap: delete the freshly-installed files, then
493
+ // rename each backup back into place. Shared by the error path and the MED-5
494
+ // post-install smoke gate so there is ONE rollback implementation.
495
+ function rollbackInstall(installed, backedUp, backupDir, targetDir) {
496
+ for (const relPath of installed.reverse()) {
497
+ try { rmSync(join(targetDir, relPath), { recursive: true, force: true }); } catch { /* best-effort */ }
498
+ }
499
+ for (const relPath of backedUp.reverse()) {
500
+ const backupPath = join(backupDir, relPath);
501
+ const targetPath = join(targetDir, relPath);
502
+ try {
503
+ if (existsSync(backupPath)) {
504
+ mkdirSync(dirname(targetPath), { recursive: true });
505
+ renameSync(backupPath, targetPath);
506
+ }
507
+ } catch (restoreErr) {
508
+ debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
509
+ }
510
+ }
511
+ }
512
+
513
+ // MED-5 post-install health gate: load-test the freshly-switched code in a SEPARATE
514
+ // process before the backup is discarded. A clean file swap can still yield an
515
+ // install that won't boot — a syntax error, an unresolved import from a half-applied
516
+ // mixed-version swap (renameSync loop hard-killed mid-way), or a native ABI mismatch.
517
+ // `cli.mjs help` boots the CLI entry, which dynamically imports the mem-cli module
518
+ // graph (maintain-core / search-engine / scoring / db-backup / schema / …) and exits
519
+ // 0 without opening the DB or a server. hook.mjs and server.mjs auto-execute on
520
+ // import (so they can't be import-smoked) — they get a `node --check` syntax pass.
521
+ // execSync (not execFileSync) so the unit-test child_process mock intercepts it.
522
+ function smokeInstalledRelease(targetDir) {
523
+ const q = (s) => JSON.stringify(s);
524
+ try {
525
+ execSync(`${q(process.execPath)} ${q(join(targetDir, 'cli.mjs'))} help`, { timeout: 20000, stdio: 'ignore' });
526
+ for (const entry of ['hook.mjs', 'server.mjs']) {
527
+ const p = join(targetDir, entry);
528
+ if (existsSync(p)) execSync(`${q(process.execPath)} --check ${q(p)}`, { timeout: 10000, stdio: 'ignore' });
529
+ }
530
+ return true;
531
+ } catch (e) {
532
+ debugLog('WARN', 'hook-update', `post-install smoke failed (rolling back): ${e.message}`);
533
+ return false;
534
+ }
535
+ }
536
+
492
537
  export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR, opts = {}) {
493
538
  // Cross-process lock: concurrent SessionStart self-heals / auto-updates must
494
539
  // not interleave the rename loop below (→ mixed-version install). A live peer
@@ -541,6 +586,16 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
541
586
  installed.push(relPath);
542
587
  }
543
588
 
589
+ // MED-5: before discarding the rollback backup, prove the switched code boots.
590
+ // If it can't, restore the backup and report failure — the running (old) version
591
+ // keeps working rather than leaving a broken install with no way back.
592
+ if (!opts.skipSmoke && !smokeInstalledRelease(targetDir)) {
593
+ rollbackInstall(installed, backedUp, backupDir, targetDir);
594
+ rmSync(stagingDir, { recursive: true, force: true });
595
+ rmSync(backupDir, { recursive: true, force: true });
596
+ return false;
597
+ }
598
+
544
599
  rmSync(stagingDir, { recursive: true, force: true });
545
600
  rmSync(backupDir, { recursive: true, force: true });
546
601
 
@@ -580,25 +635,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
580
635
  return true;
581
636
  } catch (err) {
582
637
  debugCatch(err, 'installExtractedRelease');
583
-
584
- for (const relPath of installed.reverse()) {
585
- try { rmSync(join(targetDir, relPath), { recursive: true, force: true }); } catch {}
586
- }
587
- for (const relPath of backedUp.reverse()) {
588
- const backupPath = join(backupDir, relPath);
589
- const targetPath = join(targetDir, relPath);
590
- try {
591
- if (existsSync(backupPath)) {
592
- mkdirSync(dirname(targetPath), { recursive: true });
593
- renameSync(backupPath, targetPath);
594
- }
595
- } catch (restoreErr) {
596
- debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
597
- }
598
- }
599
-
600
- try { rmSync(stagingDir, { recursive: true, force: true }); } catch {}
601
- try { rmSync(backupDir, { recursive: true, force: true }); } catch {}
638
+ rollbackInstall(installed, backedUp, backupDir, targetDir);
639
+ try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* best-effort */ }
640
+ try { rmSync(backupDir, { recursive: true, force: true }); } catch { /* best-effort */ }
602
641
  return false;
603
642
  } finally {
604
643
  release();
package/hook.mjs CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  truncate, inferProject, detectBashSignificance,
27
27
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
28
28
  makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
29
- COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
29
+ COMPRESSED_AUTO, OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
30
30
  } from './utils.mjs';
31
31
  import {
32
32
  readEpisodeRaw, episodeFile,
@@ -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';
@@ -46,7 +47,8 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
46
47
  import { scrubRecord } from './lib/scrub-record.mjs';
47
48
  import { formatHookError } from './lib/native-binding-hint.mjs';
48
49
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
49
- import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds } from './lib/maintain-core.mjs';
50
+ import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale } from './lib/maintain-core.mjs';
51
+ import { snapshotDb } from './lib/db-backup.mjs';
50
52
  import {
51
53
  extractCitationsFromTranscript,
52
54
  extractAllInjected,
@@ -695,15 +697,19 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
695
697
  WHERE status = 'active' AND started_at_epoch < ?
696
698
  `).run(staleSessionCutoff);
697
699
 
698
- // 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)
699
701
  // Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
700
702
  // v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant
701
703
  // via hook-memory injection, even if user never explicitly fetched). Same
702
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).
703
709
  const compressed = db.prepare(`
704
710
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
705
711
  WHERE COALESCE(compressed_into, 0) = 0
706
- AND importance = 1
712
+ AND COALESCE(importance, 1) <= 1
707
713
  AND COALESCE(injection_count, 0) = 0
708
714
  AND created_at_epoch < ?
709
715
  AND project = ?
@@ -721,7 +727,7 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
721
727
  const noiseCompressed = db.prepare(`
722
728
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
723
729
  WHERE COALESCE(compressed_into, 0) = 0
724
- AND importance = 1
730
+ AND COALESCE(importance, 1) <= 1
725
731
  AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
726
732
  AND (facts IS NULL OR facts = '' OR facts = '[]')
727
733
  AND (
@@ -749,21 +755,27 @@ function runSessionStartAutoMaintain(db) {
749
755
  try {
750
756
  const STALE_AGE = Date.now() - 30 * 86400000;
751
757
  const OP_CAP = 500;
752
-
753
- // Purge FIRST: delete pending-purge entries. Schema has no marked_at_epoch, so we
754
- // anchor retention on created_at_epoch instead: 30d marking gate + 7d grace = 37d.
755
- // Older cutoffs (e.g. 7d) were always redundant with the 30d marking filter and
756
- // made purge effectively immediate on the next maintenance cycle — fix for T4-P1-A.
757
- const purged = db.prepare(`
758
- DELETE FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE}
759
- AND created_at_epoch < ?
760
- `).run(Date.now() - 37 * 86400000);
761
- if (purged.changes > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged.changes} stale observations`);
762
-
763
- // cleanup / decay+mark-idle / boost via maintain-core (shared with CLI + MCP).
764
- // injection_count>0 protection lives in decayAndMarkIdle. Whole-DB, cap 500.
758
+ // Shared maintenance context (whole-DB, cap 500) — used by every maintain-core
759
+ // op below AND the MED-2 snapshot guard. injection_count>0 protection lives in
760
+ // decayAndMarkIdle.
765
761
  const mctx = { projectFilter: '', baseParams: [], staleAge: STALE_AGE, opCap: OP_CAP };
766
762
 
763
+ // MED-2: snapshot the DB before the irreversible purge/cleanup hard-deletes
764
+ // below, but only when rows will actually be removed (cheap COUNT). Must run
765
+ // here, outside any transaction — VACUUM cannot run inside one. Best-effort:
766
+ // snapshotDb never throws, so a backup failure cannot block auto-maintain.
767
+ if (hardDeleteCandidateCount(db, mctx, { cleanup: true, purge: true }) > 0) {
768
+ snapshotDb(db, { tag: 'pre-maintain' });
769
+ }
770
+
771
+ // Purge FIRST via the SHARED purgeStale — was an inline DELETE that skipped
772
+ // recoverChildrenOf, so purging a keeper that had absorbed dups orphaned its
773
+ // children (compressed_into dangling at a deleted id). purgeStale recovers them
774
+ // first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
775
+ // created_at_epoch: 30d marking gate + 7d grace = 37d.
776
+ const purged = purgeStale(db, mctx, Date.now() - 37 * 86400000);
777
+ if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
778
+
767
779
  const cleaned = cleanupBroken(db, mctx);
768
780
  if (cleaned > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned} broken observations`);
769
781
 
@@ -841,6 +853,20 @@ function runSessionStartAutoMaintain(db) {
841
853
  if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
842
854
  } catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
843
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
+
844
870
  // Mark maintenance as done (24h gate) — even though compression runs in background
845
871
  writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
846
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}
@@ -0,0 +1,55 @@
1
+ // lib/db-backup.mjs — point-in-time DB snapshot before irreversible maintenance.
2
+ //
3
+ // VACUUM INTO produces a consistent, compact copy of the database (WAL-safe —
4
+ // unlike copyFileSync, which would miss un-checkpointed WAL frames). Best-effort:
5
+ // a failure logs a WARN and returns null so it NEVER blocks the maintenance it
6
+ // guards (a backup that aborts a disk-full purge would be worse than no backup).
7
+ //
8
+ // MUST be called OUTSIDE any transaction — VACUUM cannot run inside one, which is
9
+ // why the maintenance entry points snapshot before opening their db.transaction().
10
+ import { readdirSync, unlinkSync } from 'fs';
11
+ import { dirname, basename, join } from 'path';
12
+ import { debugLog } from '../utils.mjs';
13
+
14
+ // Monotonic per-process suffix so two snapshots in the same millisecond (same pid)
15
+ // still get unique filenames (VACUUM INTO fails if the target already exists).
16
+ let _seq = 0;
17
+
18
+ /**
19
+ * Snapshot `db` to `<db-path>.<tag>-<ts>.bak` via VACUUM INTO, then prune to the
20
+ * newest `retain` snapshots. No-op (returns null) for :memory: DBs (tests) and on
21
+ * any error (logged). Returns the snapshot path on success.
22
+ * @returns {string|null}
23
+ */
24
+ export function snapshotDb(db, { tag = 'pre-maintain', retain = 3 } = {}) {
25
+ try {
26
+ const dbPath = db && db.name;
27
+ if (!dbPath || dbPath === ':memory:') return null; // in-memory — nothing to snapshot
28
+ const stamp = `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${_seq++}`;
29
+ const out = `${dbPath}.${tag}-${stamp}.bak`;
30
+ // Path is internal (db.name from our own config), but escape single quotes
31
+ // defensively since VACUUM INTO takes a string literal, not a bound param.
32
+ db.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`);
33
+ pruneSnapshots(dbPath, tag, retain);
34
+ return out;
35
+ } catch (e) {
36
+ try { debugLog('WARN', 'db-backup', `snapshot skipped (proceeding without): ${e.message}`); } catch { /* ignore */ }
37
+ return null;
38
+ }
39
+ }
40
+
41
+ // Keep the newest `retain` `<base>.<tag>-*.bak` files, unlink the rest. The ISO
42
+ // timestamp embedded in the name makes a lexical sort chronological.
43
+ function pruneSnapshots(dbPath, tag, retain) {
44
+ try {
45
+ const dir = dirname(dbPath);
46
+ const prefix = `${basename(dbPath)}.${tag}-`;
47
+ const names = readdirSync(dir)
48
+ .filter(n => n.startsWith(prefix) && n.endsWith('.bak'))
49
+ .sort()
50
+ .reverse(); // newest first
51
+ for (const n of names.slice(retain)) {
52
+ try { unlinkSync(join(dir, n)); } catch { /* per-entry best-effort */ }
53
+ }
54
+ } catch { /* best-effort — dir may be unreadable */ }
55
+ }
@@ -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 < ?
@@ -238,6 +246,31 @@ export function mergeDuplicates(db, groups) {
238
246
  return merged;
239
247
  }
240
248
 
249
+ /**
250
+ * Count rows a destructive maintenance run would hard-DELETE: pending-purge rows
251
+ * (any age — a cheap proxy for "purge has something to remove", deliberately not
252
+ * age-filtered so the guard never under-counts) and/or broken empty-content rows
253
+ * (cleanupBroken's doomed set). Used by the maintenance entry points to decide
254
+ * whether to VACUUM-snapshot the DB first (audit MED-2) — over-counting only costs
255
+ * one extra bounded backup; under-counting would skip the safety net.
256
+ */
257
+ export function hardDeleteCandidateCount(db, { projectFilter, baseParams }, { cleanup = false, purge = false } = {}) {
258
+ let n = 0;
259
+ if (purge) {
260
+ n += db.prepare(
261
+ `SELECT COUNT(*) AS c FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} ${projectFilter}`
262
+ ).get(...baseParams).c;
263
+ }
264
+ if (cleanup) {
265
+ n += db.prepare(
266
+ `SELECT COUNT(*) AS c FROM observations
267
+ WHERE COALESCE(compressed_into, 0) = 0
268
+ AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '') ${projectFilter}`
269
+ ).get(...baseParams).c;
270
+ }
271
+ return n;
272
+ }
273
+
241
274
  /** Preview pending-purge candidates older than the retain cutoff (no deletion). */
242
275
  export function purgeStalePreview(db, { projectFilter, baseParams }, retainCutoff) {
243
276
  return db.prepare(`
@@ -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
@@ -19,9 +19,10 @@ import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
19
19
  import {
20
20
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
21
21
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
22
- recoverChildrenOf,
22
+ recoverChildrenOf, hardDeleteCandidateCount,
23
23
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
24
24
  } from './lib/maintain-core.mjs';
25
+ import { snapshotDb } from './lib/db-backup.mjs';
25
26
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
26
27
  import { buildSessionContextLines } from './hook-context.mjs';
27
28
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
@@ -323,7 +324,10 @@ function cmdRecent(db, args) {
323
324
  // accepted silently; the positional path must reject garbage like the --limit flag does.
324
325
  const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= RECENT_MAX;
325
326
  if (rawArg !== undefined && !isValid) {
326
- 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`);
327
331
  }
328
332
  // Positional [N] wins for backward-compat; --limit is sibling-parity alias
329
333
  // (search/recall/browse/stats all accept --limit). Pre-2.69 `recent --limit N`
@@ -1422,6 +1426,12 @@ function cmdDelete(db, args) {
1422
1426
  return;
1423
1427
  }
1424
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
+
1425
1435
  // Transaction: clean up related_ids references + delete (aligned with MCP mem_delete)
1426
1436
  const deletedIds = new Set(ids);
1427
1437
  const deleteTx = db.transaction(() => {
@@ -1551,6 +1561,12 @@ function cmdUpdate(db, args) {
1551
1561
 
1552
1562
  function cmdExport(db, args) {
1553
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;
1554
1570
  const wheres = [];
1555
1571
  const params = [];
1556
1572
  // --include-compressed: include compressed observations (aligned with MCP mem_export)
@@ -1742,8 +1758,10 @@ function cmdCompress(db, args) {
1742
1758
  // got the 30-day cutoff without knowing their input was discarded.
1743
1759
  let ageDays = 30;
1744
1760
  if (flags['age-days'] !== undefined) {
1745
- const parsed = parseInt(flags['age-days'], 10);
1746
- 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) {
1747
1765
  fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be a positive integer.`);
1748
1766
  return;
1749
1767
  }
@@ -1799,6 +1817,11 @@ function cmdMaintain(db, args) {
1799
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.");
1800
1818
  return;
1801
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;
1802
1825
 
1803
1826
  const project = flags.project ? resolveProject(db, flags.project) : null;
1804
1827
  const projectFilter = project ? 'AND project = ?' : '';
@@ -1867,7 +1890,57 @@ function cmdMaintain(db, args) {
1867
1890
  // T2-P1-B: surface the OP_CAP hit so users know to re-run, matching MCP mem_maintain.
1868
1891
  const capHint = (changes) => (changes >= OP_CAP ? ' (cap reached, re-run for more)' : '');
1869
1892
 
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;
1916
+ if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: willPurge }) > 0) {
1917
+ snapshotDb(db, { tag: 'pre-maintain' });
1918
+ }
1919
+
1870
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
+
1871
1944
  if (ops.includes('cleanup')) {
1872
1945
  const deleted = cleanupBroken(db, mctx);
1873
1946
  results.push(`Cleaned up ${deleted} broken observations${capHint(deleted)}`);
@@ -1917,43 +1990,6 @@ function cmdMaintain(db, args) {
1917
1990
  results.push('Warning: --merge-ids provided but "dedup" not in operations — merge-ids ignored');
1918
1991
  }
1919
1992
 
1920
- if (ops.includes('purge_stale')) {
1921
- // --retain-days: default 30 when absent; reject negative / 0 / NaN / out-of-range.
1922
- // A negative value made retainCutoff a FUTURE timestamp → purged the entire
1923
- // pending-purge backlog regardless of age; 0/garbage silently became 30 and
1924
- // masked typos. Parity with the mem_maintain MCP zod bound [7, 365].
1925
- let retainDays = 30;
1926
- if (flags['retain-days'] !== undefined) {
1927
- // Number + isInteger (not parseInt) so "7.5"/"30x" are rejected rather than
1928
- // silently truncated to 7/30 — parity with the mem_maintain zod .int() bound.
1929
- const parsed = Number(flags['retain-days']);
1930
- if (!Number.isInteger(parsed) || parsed < 7 || parsed > 365) {
1931
- // fail() only sets exitCode + writes stderr; it does NOT throw, so we
1932
- // MUST return or execution falls through to the DELETE with the bad value.
1933
- fail(`[mem] --retain-days must be an integer in [7, 365] (got "${flags['retain-days']}")`);
1934
- return;
1935
- }
1936
- retainDays = parsed;
1937
- }
1938
- const retainCutoff = Date.now() - retainDays * 86400000;
1939
- // T2-P0-A (CLI parity): purge_stale is the only DELETE in this code path — require
1940
- // --confirm so a mis-typed `maintain execute --ops purge_stale` can't wipe rows silently.
1941
- const confirmed = flags.confirm === true || flags.confirm === 'true';
1942
- if (!confirmed) {
1943
- const previewRow = purgeStalePreview(db, mctx, retainCutoff);
1944
- const pushLines = [`purge_stale preview (no --confirm):`,
1945
- ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
1946
- if (previewRow.candidates > 0) {
1947
- pushLines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
1948
- pushLines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
1949
- }
1950
- pushLines.push(` To delete, re-run with --confirm.`);
1951
- results.push(pushLines.join('\n'));
1952
- } else {
1953
- const purged = purgeStale(db, mctx, retainCutoff);
1954
- results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)${capHint(purged)}`);
1955
- }
1956
- }
1957
1993
  })();
1958
1994
 
1959
1995
  // FTS optimize
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.20.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",
@@ -84,6 +84,7 @@
84
84
  "lib/search-core.mjs",
85
85
  "lib/rrf.mjs",
86
86
  "lib/compress-core.mjs",
87
+ "lib/db-backup.mjs",
87
88
  "lib/maintain-core.mjs",
88
89
  "lib/dedup-constants.mjs",
89
90
  "lib/deferred-work.mjs",
@@ -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
@@ -18,9 +18,10 @@ import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './l
18
18
  import {
19
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
20
20
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
21
- recoverChildrenOf,
21
+ recoverChildrenOf, hardDeleteCandidateCount,
22
22
  OP_CAP, STALE_AGE_MS,
23
23
  } from './lib/maintain-core.mjs';
24
+ import { snapshotDb } from './lib/db-backup.mjs';
24
25
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
25
26
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
26
27
  import { formatObsFieldValue } from './cli/common.mjs';
@@ -616,6 +617,11 @@ server.registerTool(
616
617
  return { content: [{ type: 'text', text: lines.join('\n') }] };
617
618
  }
618
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
+
619
625
  // Wrap cleanup + deletion in a transaction for consistency
620
626
  const deletedIds = new Set(args.ids);
621
627
  const deleteTx = db.transaction(() => {
@@ -1078,7 +1084,28 @@ server.registerTool(
1078
1084
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1079
1085
  }
1080
1086
 
1087
+ // MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
1088
+ // only when rows will actually be removed, and OUTSIDE the transaction below
1089
+ // (VACUUM cannot run inside one). purge_stale is already confirmed by here (the
1090
+ // preview branch returned above otherwise). Best-effort; snapshotDb never throws.
1091
+ if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') }) > 0) {
1092
+ snapshotDb(db, { tag: 'pre-maintain' });
1093
+ }
1094
+
1081
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
+
1082
1109
  if (ops.includes('cleanup')) {
1083
1110
  const deleted = cleanupBroken(db, mctx);
1084
1111
  results.push(`Cleaned up ${deleted} broken observations` + (deleted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
@@ -1109,13 +1136,6 @@ server.registerTool(
1109
1136
  if (!ops.includes('dedup') && args.merge_ids) {
1110
1137
  results.push('Warning: merge_ids provided but "dedup" not in operations — merge_ids ignored');
1111
1138
  }
1112
-
1113
- if (ops.includes('purge_stale')) {
1114
- const retainDays = args.retain_days ?? 30;
1115
- const retainCutoff = Date.now() - retainDays * 86400000;
1116
- const purged = purgeStale(db, mctx, retainCutoff);
1117
- results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1118
- }
1119
1139
  })();
1120
1140
 
1121
1141
  // FTS5 optimize (outside transaction)
package/source-files.mjs CHANGED
@@ -131,6 +131,10 @@ export const SOURCE_FILES = [
131
131
  // Statically imported by mem-cli.mjs (cmdMaintain), server.mjs (mem_maintain),
132
132
  // and hook.mjs (handleAutoMaintain) — missing it would break maintain on auto-update.
133
133
  'lib/maintain-core.mjs',
134
+ // Pre-maintenance VACUUM INTO snapshot (MED-2). Statically imported by mem-cli.mjs,
135
+ // server.mjs, and hook.mjs before their destructive purge/cleanup — missing it
136
+ // would crash maintain on auto-update with an unresolved import.
137
+ 'lib/db-backup.mjs',
134
138
  // P10 dedup/merge threshold constants — single source of truth for the Jaccard
135
139
  // dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
136
140
  // hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;
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