claude-mem-lite 3.21.0 → 3.23.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.23.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.23.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/bash-utils.mjs CHANGED
@@ -13,6 +13,16 @@ const CMD_WRAPPERS = new Set(['sudo', 'doas', 'env', 'time', 'command', 'nice',
13
13
  // git read subcommands whose output contains commit/log/match text, not failures.
14
14
  const GIT_READ_SUBCMDS = new Set(['grep', 'log', 'show', 'diff', 'blame', 'ls-files', 'cat-file', 'whatchanged', 'shortlog', 'reflog', 'status']);
15
15
 
16
+ // Hard failure fingerprints — a real crash / thrown exception / non-zero-exit marker,
17
+ // as opposed to output that merely CONTAINS the word "error" (search results, log
18
+ // scans, prose). Deliberately strong/narrow: a JS stack frame (`\n at fn (…)`),
19
+ // panic/traceback/segfault, ENOENT/command-not-found, AssertionError, or a *named*
20
+ // error class (TypeError:/ReferenceError:/…). Generic `Error:`/`exception` are
21
+ // intentionally excluded — they appear too often in benign search/log output. Gates
22
+ // the bugfix-shape save-nudge (lib/cite-back-hint.mjs) so `node cli.mjs search "error"`
23
+ // + an edit in the same episode no longer looks like an unsaved fix.
24
+ const HARD_ERROR_RE = /\bERR!|\bpanic\b|traceback|segfault|core dumped|\benoent\b|command not found|assertion\s?error|\n\s+at\s+\S|(?:type|reference|range|syntax|eval|uri)error:/i;
25
+
16
26
  // True when the command's PRIMARY operation (left of the first pipe, past any
17
27
  // env-assignments / wrapper like `sudo`/`env`/`time`) is a read/search — including
18
28
  // `git grep`/`git log`. Anchoring on the primary command (not "search verb appears
@@ -69,6 +79,10 @@ export function detectBashSignificance(input, response) {
69
79
  const hasHardErrorSignal = hasGreenTestSummary
70
80
  && /\bERR!|panic|traceback|enoent|command not found|exception|AssertionError|TypeError:|SyntaxError:/i.test(response);
71
81
  const isError = looksLikeError && !(hasGreenTestSummary && !hasHardErrorSignal);
82
+ // Strict subset of isError: a genuine failure fingerprint, not just the word "error"
83
+ // in benign output. Consumers that must avoid false positives (the bugfix-shape
84
+ // save-nudge) gate on this instead of isError.
85
+ const isHardError = isError && HARD_ERROR_RE.test(response);
72
86
  // Match actual test runner invocations, not commands that merely reference "test" as a keyword
73
87
  const isTest = /\b(npm\s+test|npm\s+run\s+test|yarn\s+test|pnpm\s+test|pnpm\s+run\s+test|bun\s+test|go\s+test|cargo\s+test)\b/i.test(cmd)
74
88
  || /\b(jest|pytest|vitest|mocha|cypress|playwright)\b/i.test(cmd);
@@ -85,7 +99,7 @@ export function detectBashSignificance(input, response) {
85
99
  || /\bgh\s+release\s+(?:create|edit|upload|delete)\b/i.test(cmd)
86
100
  || /\btwine\s+upload\b/i.test(cmd);
87
101
  return {
88
- isError, isTest, isBuild, isGit, isDeploy,
102
+ isError, isHardError, isTest, isBuild, isGit, isDeploy,
89
103
  isSignificant: isError || isTest || isBuild || isGit || isDeploy,
90
104
  };
91
105
  }
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-llm.mjs CHANGED
@@ -803,9 +803,16 @@ ${actionList}`;
803
803
  // retry's entire purpose (a recovered bugfix lesson would silently drop
804
804
  // out of --importance 2 searches and the working tier). Gate the cap on
805
805
  // the *effective* low-signal state, not the pre-retry flag.
806
+ // v3.23: cap the FILE-PATH heuristic's contribution at 2. computeRuleImportance
807
+ // returns 3 for any entry touching schema./migration/prisma/.env/.key paths; via the
808
+ // Math.max below that force-promoted ordinary has-lesson episodes to "critical" imp=3
809
+ // even when Haiku judged them 1-2 (audit: auto imp=3 = 34.8%, e.g. a thin-lesson edit
810
+ // to schema.mjs). Haiku's OWN importance can still reach 3 (genuine judgment); only the
811
+ // path heuristic is capped. The isLessonLowSignal branch still floors no-lesson
812
+ // non-decision autos at ≤1; manual mem_save uses a different path and is unaffected.
806
813
  importance: isLessonLowSignal && !retryRecovered && parsed.type !== 'decision'
807
814
  ? Math.min(ruleImportance, 1)
808
- : Math.max(ruleImportance, clampImportance(parsed.importance)),
815
+ : Math.max(Math.min(ruleImportance, 2), clampImportance(parsed.importance)),
809
816
  lessonLearned,
810
817
  searchAliases,
811
818
  };
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';
@@ -46,7 +47,7 @@ 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, hardDeleteCandidateCount, purgeStale } from './lib/maintain-core.mjs';
50
+ import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren } from './lib/maintain-core.mjs';
50
51
  import { snapshotDb } from './lib/db-backup.mjs';
51
52
  import {
52
53
  extractCitationsFromTranscript,
@@ -285,6 +286,9 @@ async function handlePostToolUse() {
285
286
  files,
286
287
  ts: Date.now(),
287
288
  isError: bashSig?.isError || false,
289
+ // isHardError gates the bugfix-shape save-nudge (lib/cite-back-hint.mjs): a real
290
+ // failure fingerprint, not just "error" appearing in search/log output.
291
+ isHardError: bashSig?.isHardError || false,
288
292
  isSignificant: EDIT_TOOLS.has(tool_name) ||
289
293
  bashSig?.isSignificant || false,
290
294
  bashSig: bashSig || null,
@@ -696,16 +700,26 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
696
700
  WHERE status = 'active' AND started_at_epoch < ?
697
701
  `).run(staleSessionCutoff);
698
702
 
699
- // Auto-compress: mark old low-importance observations as compressed (30+ days, importance=1)
703
+ // Auto-compress: mark old low-importance observations as compressed (30+ days, importance<=1)
700
704
  // Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
701
705
  // v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant
702
706
  // via hook-memory injection, even if user never explicitly fetched). Same
703
707
  // protection applied symmetrically in auto-maintain decay/mark-idle below.
708
+ // `<= 1` (was `= 1`): citation-decay floors importance at 0 (added v2.73.2, after this
709
+ // predicate was written) and the LLM low-signal filter saves at imp=0 — those rows are
710
+ // STRICTLY lower value than imp=1 yet escaped GC, accumulating to ~40% of a mature DB
711
+ // (immortal: hidden from injection by the imp>=1 floor, but visible as explicit-search noise).
704
712
  const compressed = db.prepare(`
705
713
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
706
714
  WHERE COALESCE(compressed_into, 0) = 0
707
- AND importance = 1
715
+ AND COALESCE(importance, 1) <= 1
708
716
  AND COALESCE(injection_count, 0) = 0
717
+ -- v3.23: never auto-hide a row that carries a real lesson. compression folds
718
+ -- sources into a title-only summary (lesson lost), and COMPRESSED_AUTO hides the
719
+ -- row from search entirely. A low-importance obs whose lesson_learned is the
720
+ -- distilled value must stay findable (audit: 62 lessons buried this way). The 7d
721
+ -- noise block below already excludes lessons; this 30d block had drifted.
722
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
709
723
  AND created_at_epoch < ?
710
724
  AND project = ?
711
725
  `).run(autoCompressAge, project);
@@ -722,7 +736,7 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
722
736
  const noiseCompressed = db.prepare(`
723
737
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
724
738
  WHERE COALESCE(compressed_into, 0) = 0
725
- AND importance = 1
739
+ AND COALESCE(importance, 1) <= 1
726
740
  AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
727
741
  AND (facts IS NULL OR facts = '' OR facts = '[]')
728
742
  AND (
@@ -774,6 +788,12 @@ function runSessionStartAutoMaintain(db) {
774
788
  const cleaned = cleanupBroken(db, mctx);
775
789
  if (cleaned > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned} broken observations`);
776
790
 
791
+ // Self-heal legacy orphans: children whose compression keeper was hard-deleted
792
+ // before recoverChildrenOf existed are hidden + queue-less (unreachable). Resurface
793
+ // them so normal decay/GC handles them on merit. Non-destructive (un-hide only).
794
+ const orphansRecovered = recoverOrphanedChildren(db, mctx);
795
+ if (orphansRecovered > 0) debugLog('DEBUG', 'auto-maintain', `recovered ${orphansRecovered} orphaned compression children`);
796
+
777
797
  const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
778
798
  if (decayed > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed} stale observations`);
779
799
  if (idleMarked > 0) debugLog('DEBUG', 'auto-maintain', `marked ${idleMarked} idle as pending-purge`);
@@ -848,6 +868,20 @@ function runSessionStartAutoMaintain(db) {
848
868
  if (swept > 0) debugLog('DEBUG', 'auto-maintain', `swept ${swept} orphan ep-flush/pending file(s)`);
849
869
  } catch (e) { debugCatch(e, 'auto-maintain-orphan-sweep'); }
850
870
 
871
+ // GC expired session_handoffs: the consume-DELETE (handleSessionStart) only removes
872
+ // the single handoff a continuation reads back; an 'exit'/'compact' that is never
873
+ // resumed (and every superseded 'clear') lingers forever — read paths filter by
874
+ // expiry but nothing reaped the rows. Delete past-expiry rows with a +1d margin so a
875
+ // still-readable handoff is never raced away. 'clear' 6h+1d, 'exit'/other 7d+1d.
876
+ try {
877
+ const gc = db.prepare(`
878
+ DELETE FROM session_handoffs
879
+ WHERE (type = 'clear' AND created_at_epoch < ?)
880
+ OR (type != 'clear' AND created_at_epoch < ?)
881
+ `).run(Date.now() - HANDOFF_EXPIRY_CLEAR - 86400000, Date.now() - HANDOFF_EXPIRY_EXIT - 86400000);
882
+ if (gc.changes > 0) debugLog('DEBUG', 'auto-maintain', `gc'd ${gc.changes} expired session_handoffs`);
883
+ } catch (e) { debugCatch(e, 'auto-maintain-handoff-gc'); }
884
+
851
885
  // Mark maintenance as done (24h gate) — even though compression runs in background
852
886
  writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
853
887
  // Weekly summary grouping runs in background to avoid blocking SessionStart
@@ -68,11 +68,17 @@ export function buildCiteBackHint(episode, cooldown) {
68
68
  // the lib so all save-prompt hints share one home + the same wording rules.
69
69
  //
70
70
  // Detection (mirrors pre-v2.83 hook.mjs:194 heuristic):
71
- // • has at least one entry with isError=true
71
+ // • has at least one entry with a HARD failure fingerprint (isHardError) — NOT just
72
+ // isError. `node cli.mjs search "error"`, `npx vitest` green output, or any command
73
+ // whose output merely MENTIONS "error"/"failed" sets isError=true but is not a fix;
74
+ // gating on isError nagged on non-fixes (audit: fired on a read-only session + a
75
+ // scratch write). isHardError requires a real crash/exception/stack (bash-utils.mjs).
72
76
  // • has at least one entry using an edit tool
73
77
  // • entries.length >= 3 (rules out single-typo fixes that don't need a lesson)
74
78
  // Returns null when any condition fails or when no edited files are recoverable
75
79
  // from the entry list (defensive — episodes flushed mid-tool can have empties).
80
+ // Legacy fallback: entries written before isHardError existed lack the field, so fall
81
+ // back to isError for them rather than silently regress the nudge to never-fire.
76
82
  const MIN_BUGFIX_ENTRIES = 3;
77
83
  const MAX_DISPLAY_FILES = 3;
78
84
 
@@ -86,7 +92,7 @@ export function buildUnsavedBugfixHint(episode) {
86
92
  const editedFiles = new Set();
87
93
  for (const e of entries) {
88
94
  if (!e) continue;
89
- if (e.isError) hasError = true;
95
+ if (e.isHardError !== undefined ? e.isHardError : e.isError) hasError = true;
90
96
  if (EDIT_TOOLS.has(e.tool)) {
91
97
  hasEdit = true;
92
98
  for (const f of e.files || []) editedFiles.add(f);
@@ -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,8 +38,12 @@ 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
43
+ -- v3.23: exclude rows carrying a real lesson — folding them into a title-only
44
+ -- weekly summary discards the lesson (the distilled value of a lessons store).
45
+ -- Mirrors the hook auto-compress lesson guard so neither path buries a lesson.
46
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
40
47
  AND created_at_epoch < ?
41
48
  ${compressedFilter}
42
49
  ${projectFilter}
@@ -98,6 +98,25 @@ export function recoverChildrenOf(db, ids) {
98
98
  ).run(...ids, ...ids).changes;
99
99
  }
100
100
 
101
+ // Resurface children orphaned by a keeper hard-deleted BEFORE recoverChildrenOf existed
102
+ // (legacy data). recoverChildrenOf only fires at delete time for the keepers being deleted
103
+ // in that call; rows whose keeper vanished in a past release are missed forever. A child
104
+ // with compressed_into = <positive keeperId> whose keeper row no longer exists is hidden
105
+ // from every COALESCE(compressed_into,0)=0 view AND sits in no maintenance queue (not
106
+ // COMPRESSED_AUTO, not COMPRESSED_PENDING_PURGE), so nothing ever resurfaces or GCs it —
107
+ // it leaks its full narrative out of reach. Setting compressed_into = NULL makes it live
108
+ // again; normal decay/GC then handles it on merit. `compressed_into > 0` excludes the
109
+ // negative sentinels (intentional states, not orphans). NON-DESTRUCTIVE: only un-hides
110
+ // rows, never deletes — safe to run unconditionally, no snapshot needed.
111
+ export function recoverOrphanedChildren(db, { projectFilter = '', baseParams = [] } = {}) {
112
+ return db.prepare(`
113
+ UPDATE observations SET compressed_into = NULL
114
+ WHERE compressed_into > 0
115
+ AND NOT EXISTS (SELECT 1 FROM observations k WHERE k.id = observations.compressed_into)
116
+ ${projectFilter}
117
+ `).run(...baseParams).changes;
118
+ }
119
+
101
120
  export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP }) {
102
121
  const doomed = db.prepare(`
103
122
  SELECT id FROM observations
@@ -112,30 +131,43 @@ export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP })
112
131
  }
113
132
 
114
133
  /**
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).
134
+ * Decay importance of old, never-accessed, NEVER-INJECTED observations and mark the
135
+ * importance-1 idle ones as pending-purge. injection_count>0 is protected as first-class
136
+ * engagement alongside access_count (unified across all three paths).
137
+ *
138
+ * MARK-IDLE RUNS BEFORE DECAY (audit MED-1): if decay ran first, an imp-2 row would be
139
+ * decayed 2→1 and then re-selected by the same call's mark-idle pass → hidden as
140
+ * pending-purge in ONE pass, collapsing the per-tier grace cycle and over-marking vs what
141
+ * `maintain scan` (stale = imp-1 only) forecasts. Marking first means each call only marks
142
+ * rows that were ALREADY imp-1; a freshly-decayed imp-2→1 row waits for the next call,
143
+ * so importance tiers each buy a grace cycle (imp3→2→1→pending across runs) and the scan
144
+ * forecast matches what decay actually marks.
118
145
  */
119
146
  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)
147
+ const idleMarked = db.prepare(`
148
+ UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
122
149
  WHERE id IN (
123
150
  SELECT id FROM observations
124
151
  WHERE COALESCE(compressed_into, 0) = 0
125
- AND COALESCE(importance, 1) > 1
152
+ AND COALESCE(importance, 1) = 1
126
153
  AND COALESCE(access_count, 0) = 0
127
154
  AND COALESCE(injection_count, 0) = 0
155
+ -- v3.23: never mark a lesson-bearing row idle→pending-purge. A lesson is the
156
+ -- distilled value of a lessons store; auto-GC must not silently purge it (parity
157
+ -- with the compress lesson guards in hook.mjs + compress-core.mjs). Truly stale
158
+ -- lessons are removed by explicit delete, not background decay.
159
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
128
160
  AND created_at_epoch < ?
129
161
  ${projectFilter} LIMIT ${opCap}
130
162
  )
131
163
  `).run(staleAge, ...baseParams).changes;
132
164
 
133
- const idleMarked = db.prepare(`
134
- UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
165
+ const decayed = db.prepare(`
166
+ UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
135
167
  WHERE id IN (
136
168
  SELECT id FROM observations
137
169
  WHERE COALESCE(compressed_into, 0) = 0
138
- AND COALESCE(importance, 1) = 1
170
+ AND COALESCE(importance, 1) > 1
139
171
  AND COALESCE(access_count, 0) = 0
140
172
  AND COALESCE(injection_count, 0) = 0
141
173
  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
@@ -18,6 +18,7 @@ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from '
18
18
  import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
19
19
  import {
20
20
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
21
+ recoverOrphanedChildren,
21
22
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
22
23
  recoverChildrenOf, hardDeleteCandidateCount,
23
24
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
@@ -324,7 +325,10 @@ function cmdRecent(db, args) {
324
325
  // accepted silently; the positional path must reject garbage like the --limit flag does.
325
326
  const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= RECENT_MAX;
326
327
  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`);
328
+ // Name the ACTUAL fallback: a present --limit overrides the positional below, so
329
+ // claiming "default 10" when `recent abc --limit 5` returns 5 misled the user.
330
+ const fallbackLabel = flags.limit !== undefined ? '--limit' : 'default 10';
331
+ process.stderr.write(`[mem] Invalid count "${rawArg}" (must be an integer between 1 and ${RECENT_MAX}); using ${fallbackLabel}\n`);
328
332
  }
329
333
  // Positional [N] wins for backward-compat; --limit is sibling-parity alias
330
334
  // (search/recall/browse/stats all accept --limit). Pre-2.69 `recent --limit N`
@@ -1093,9 +1097,17 @@ async function cmdStats(db, args) {
1093
1097
  `SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`
1094
1098
  ).get(...baseParams);
1095
1099
  const thirtyDaysAgo = now - 30 * 86400000;
1100
+ // v3.23 noise-gauge de-blinding: the prior `importance = 1` predicate was
1101
+ // structurally blind to imp=0 — decay's floor + the LLM low-signal filter push
1102
+ // dormant rows to 0, which on a real store is ~half the live corpus. The gauge
1103
+ // therefore reported "0.0% noise" while the store was dormant-heavy. `<= 1` makes
1104
+ // imp=0 visible; injection_count=0 mirrors decay's NEVER-INJECTED guard so an
1105
+ // injected-but-decayed row (pinned noise, tracked separately) is not miscounted
1106
+ // as "never used".
1096
1107
  const lowVal = db.prepare(`
1097
1108
  SELECT COUNT(*) as c FROM observations
1098
- WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
1109
+ WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
1110
+ AND COALESCE(injection_count,0) = 0
1099
1111
  AND COALESCE(compressed_into, 0) = 0
1100
1112
  AND created_at_epoch < ? ${projectFilter}
1101
1113
  `).get(thirtyDaysAgo, ...baseParams);
@@ -1194,7 +1206,7 @@ async function cmdStats(db, args) {
1194
1206
  out('Data Health:');
1195
1207
  out(` Est. tokens: ${tokenEst.t ?? 0}`);
1196
1208
  out(` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`);
1197
- out(` Low-value (imp=1, never accessed, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`);
1209
+ out(` Low-value (imp1, never used, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`);
1198
1210
  out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
1199
1211
  out(` Compressed: ${compressedCount.c}`);
1200
1212
  out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
@@ -1423,6 +1435,12 @@ function cmdDelete(db, args) {
1423
1435
  return;
1424
1436
  }
1425
1437
 
1438
+ // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
1439
+ // matching the maintain purge/cleanup hard-delete paths (audit MED-2). Best-effort
1440
+ // (never throws, skips :memory:); rows here are confirmed + non-empty, so the delete
1441
+ // always removes something worth backing up. Must run OUTSIDE the transaction (VACUUM).
1442
+ snapshotDb(db, { tag: 'pre-delete' });
1443
+
1426
1444
  // Transaction: clean up related_ids references + delete (aligned with MCP mem_delete)
1427
1445
  const deletedIds = new Set(ids);
1428
1446
  const deleteTx = db.transaction(() => {
@@ -1552,6 +1570,12 @@ function cmdUpdate(db, args) {
1552
1570
 
1553
1571
  function cmdExport(db, args) {
1554
1572
  const { flags } = parseArgs(args);
1573
+ // Guard value-less string flags. Bare `--to` parsed to boolean `true`, and
1574
+ // `new Date(true).getTime()` is 1 (NOT NaN), so the isNaN guard below missed it and
1575
+ // the filter became `created_at_epoch <= 1` → an EMPTY export with exit 0. A backup
1576
+ // script (`export --to "$END" > backup.json`) with an unset `$END` would silently
1577
+ // write an empty backup and report success. Reject like cmdSearch does.
1578
+ if (rejectBareStringFlags(flags, ['project', 'type', 'from', 'to'])) return;
1555
1579
  const wheres = [];
1556
1580
  const params = [];
1557
1581
  // --include-compressed: include compressed observations (aligned with MCP mem_export)
@@ -1743,8 +1767,10 @@ function cmdCompress(db, args) {
1743
1767
  // got the 30-day cutoff without knowing their input was discarded.
1744
1768
  let ageDays = 30;
1745
1769
  if (flags['age-days'] !== undefined) {
1746
- const parsed = parseInt(flags['age-days'], 10);
1747
- if (!Number.isFinite(parsed) || parsed < 1) {
1770
+ // isNumericToken (not bare parseInt) so "1e5"→1 and "30x"→30 are rejected rather than
1771
+ // silently mis-parsed into a far-too-broad cutoff — parity with recent/search/maintain.
1772
+ const parsed = Number(flags['age-days']);
1773
+ if (!isNumericToken(flags['age-days']) || !Number.isInteger(parsed) || parsed < 1) {
1748
1774
  fail(`[mem] Invalid --age-days "${flags['age-days']}". Must be a positive integer.`);
1749
1775
  return;
1750
1776
  }
@@ -1800,6 +1826,11 @@ function cmdMaintain(db, args) {
1800
1826
  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
1827
  return;
1802
1828
  }
1829
+ // Guard value-less string flags before any `.split()` / resolveProject runs. A bare
1830
+ // `--merge-ids` parsed to boolean `true`, and `true.split(',')` (line ~1911) crashed
1831
+ // with a raw stack trace — the one string-flag path that lacked this #8470 guard, and
1832
+ // the exact form the `scan` output suggests copy-pasting (`--merge-ids <pairs>`).
1833
+ if (rejectBareStringFlags(flags, ['ops', 'project', 'merge-ids', 'retain-days'])) return;
1803
1834
 
1804
1835
  const project = flags.project ? resolveProject(db, flags.project) : null;
1805
1836
  const projectFilter = project ? 'AND project = ?' : '';
@@ -1868,18 +1899,64 @@ function cmdMaintain(db, args) {
1868
1899
  // T2-P1-B: surface the OP_CAP hit so users know to re-run, matching MCP mem_maintain.
1869
1900
  const capHint = (changes) => (changes >= OP_CAP ? ' (cap reached, re-run for more)' : '');
1870
1901
 
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');
1902
+ // Parse + validate --retain-days BEFORE the transaction so an invalid value rejects the
1903
+ // whole command atomically. The old code validated inside db.transaction() with a bare
1904
+ // `return`, so an earlier op (cleanup hard-delete / decay / boost) had already mutated and
1905
+ // the transaction COMMITTED despite the exit-1 error (audit MED-2 atomicity).
1906
+ let retainDays = 30;
1907
+ if (ops.includes('purge_stale') && flags['retain-days'] !== undefined) {
1908
+ // Number + isInteger (not parseInt) so "7.5"/"30x" are rejected rather than silently
1909
+ // truncated — parity with the mem_maintain zod .int().min(7).max(365) bound.
1910
+ const parsed = Number(flags['retain-days']);
1911
+ if (!Number.isInteger(parsed) || parsed < 7 || parsed > 365) {
1912
+ fail(`[mem] --retain-days must be an integer in [7, 365] (got "${flags['retain-days']}")`);
1913
+ return;
1914
+ }
1915
+ retainDays = parsed;
1916
+ }
1917
+ const retainCutoff = Date.now() - retainDays * 86400000;
1918
+ // purge_stale is the only DELETE here — require --confirm so a mis-typed run can't wipe rows.
1919
+ const confirmed = flags.confirm === true || flags.confirm === 'true';
1920
+
1921
+ // Snapshot the DB before the irreversible cleanup/purge hard-deletes — only when rows will
1922
+ // actually be removed, and OUTSIDE the transaction below (VACUUM cannot run inside one).
1923
+ // Best-effort; snapshotDb never throws.
1924
+ const willPurge = ops.includes('purge_stale') && confirmed;
1875
1925
  if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: willPurge }) > 0) {
1876
1926
  snapshotDb(db, { tag: 'pre-maintain' });
1877
1927
  }
1878
1928
 
1879
1929
  db.transaction(() => {
1930
+ // PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766). Running decay BEFORE
1931
+ // purge in one transaction marked a stale row pending-purge AND deleted it in the SAME call
1932
+ // (zero grace), and the pre-txn snapshot guard counts only PRE-EXISTING pending rows so it
1933
+ // skipped the backup → permanent, unrecoverable loss of notable imp-2/3 memories (audit
1934
+ // HIGH-1). Purging first deletes only rows a PRIOR run marked (which the guard saw + backed
1935
+ // up); rows decay marks below wait for the next maintain run, regaining the grace cycle.
1936
+ if (ops.includes('purge_stale')) {
1937
+ if (!confirmed) {
1938
+ const previewRow = purgeStalePreview(db, mctx, retainCutoff);
1939
+ const pushLines = [`purge_stale preview (no --confirm):`,
1940
+ ` Candidates (pending-purge, older than ${retainDays}d): ${previewRow.candidates}`];
1941
+ if (previewRow.candidates > 0) {
1942
+ pushLines.push(` Oldest: ${new Date(previewRow.oldest).toISOString().slice(0, 10)}`);
1943
+ pushLines.push(` Newest: ${new Date(previewRow.newest).toISOString().slice(0, 10)}`);
1944
+ }
1945
+ pushLines.push(` To delete, re-run with --confirm.`);
1946
+ results.push(pushLines.join('\n'));
1947
+ } else {
1948
+ const purged = purgeStale(db, mctx, retainCutoff);
1949
+ results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)${capHint(purged)}`);
1950
+ }
1951
+ }
1952
+
1880
1953
  if (ops.includes('cleanup')) {
1881
1954
  const deleted = cleanupBroken(db, mctx);
1882
1955
  results.push(`Cleaned up ${deleted} broken observations${capHint(deleted)}`);
1956
+ // Self-heal legacy orphans (keeper hard-deleted pre-recoverChildrenOf): resurface
1957
+ // unreachable children. Non-destructive — un-hide only, no delete.
1958
+ const orphans = recoverOrphanedChildren(db, mctx);
1959
+ if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
1883
1960
  }
1884
1961
 
1885
1962
  if (ops.includes('decay')) {
@@ -1926,43 +2003,6 @@ function cmdMaintain(db, args) {
1926
2003
  results.push('Warning: --merge-ids provided but "dedup" not in operations — merge-ids ignored');
1927
2004
  }
1928
2005
 
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
2006
  })();
1967
2007
 
1968
2008
  // 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.23.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
@@ -17,6 +17,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
17
17
  import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './lib/search-core.mjs';
18
18
  import {
19
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
20
+ recoverOrphanedChildren,
20
21
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
21
22
  recoverChildrenOf, hardDeleteCandidateCount,
22
23
  OP_CAP, STALE_AGE_MS,
@@ -617,6 +618,11 @@ server.registerTool(
617
618
  return { content: [{ type: 'text', text: lines.join('\n') }] };
618
619
  }
619
620
 
621
+ // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
622
+ // matching the CLI delete + maintain purge/cleanup paths (audit MED-2). Best-effort
623
+ // (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM).
624
+ snapshotDb(db, { tag: 'pre-delete' });
625
+
620
626
  // Wrap cleanup + deletion in a transaction for consistency
621
627
  const deletedIds = new Set(args.ids);
622
628
  const deleteTx = db.transaction(() => {
@@ -858,9 +864,14 @@ server.registerTool(
858
864
  `).get(...baseParams);
859
865
 
860
866
  const thirtyDaysAgo = Date.now() - 30 * 86400000;
867
+ // v3.23 noise-gauge de-blinding (mirrors mem-cli cmdStats): `<= 1` makes the
868
+ // imp=0 dormant population visible (decay floor + LLM low-signal filter push
869
+ // ~half the live corpus to 0); injection_count=0 mirrors decay's NEVER-INJECTED
870
+ // guard so injected-but-decayed pinned noise isn't miscounted as "never used".
861
871
  const lowVal = db.prepare(`
862
872
  SELECT COUNT(*) as c FROM observations
863
- WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
873
+ WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
874
+ AND COALESCE(injection_count,0) = 0
864
875
  AND COALESCE(compressed_into, 0) = 0
865
876
  AND created_at_epoch < ? ${projectFilter}
866
877
  `).get(thirtyDaysAgo, ...baseParams);
@@ -910,7 +921,7 @@ server.registerTool(
910
921
  'Data Health:',
911
922
  ` Est. tokens: ${tokenEst.t ?? 0}`,
912
923
  ` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`,
913
- ` Low-value (imp=1, never accessed, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`,
924
+ ` Low-value (imp1, never used, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`,
914
925
  ` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`,
915
926
  ` Compressed: ${compressedCount.c}`,
916
927
  ...((noiseRatio > 0.6 || lowSignalRatio > 0.3) ? [' ⚠️ High noise ratio — consider running mem_compress / maintain'] : []),
@@ -1088,9 +1099,26 @@ server.registerTool(
1088
1099
  }
1089
1100
 
1090
1101
  db.transaction(() => {
1102
+ // PURGE FIRST — matches the auto-maintain hook order (hook.mjs:766) and the CLI
1103
+ // cmdMaintain. Running decay BEFORE purge in one transaction marked a stale row
1104
+ // pending-purge AND deleted it in the SAME call (zero grace), while the pre-txn
1105
+ // snapshot guard counts only PRE-EXISTING pending rows so it skipped the backup →
1106
+ // permanent loss of notable imp-2/3 memories (audit HIGH-1). Purging first deletes
1107
+ // only rows a PRIOR run marked (backed up); rows decay marks below wait one cycle.
1108
+ if (ops.includes('purge_stale')) {
1109
+ const retainDays = args.retain_days ?? 30;
1110
+ const retainCutoff = Date.now() - retainDays * 86400000;
1111
+ const purged = purgeStale(db, mctx, retainCutoff);
1112
+ results.push(`Purged ${purged} stale observations (retained last ${retainDays} days)` + (purged >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1113
+ }
1114
+
1091
1115
  if (ops.includes('cleanup')) {
1092
1116
  const deleted = cleanupBroken(db, mctx);
1093
1117
  results.push(`Cleaned up ${deleted} broken observations` + (deleted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1118
+ // Self-heal legacy orphans (keeper hard-deleted pre-recoverChildrenOf):
1119
+ // resurface unreachable children. Non-destructive — un-hide only, no delete.
1120
+ const orphans = recoverOrphanedChildren(db, mctx);
1121
+ if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
1094
1122
  }
1095
1123
 
1096
1124
  if (ops.includes('decay')) {
@@ -1118,13 +1146,6 @@ server.registerTool(
1118
1146
  if (!ops.includes('dedup') && args.merge_ids) {
1119
1147
  results.push('Warning: merge_ids provided but "dedup" not in operations — merge_ids ignored');
1120
1148
  }
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
1149
  })();
1129
1150
 
1130
1151
  // 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