claude-mem-lite 3.22.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.22.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.22.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/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.mjs CHANGED
@@ -47,7 +47,7 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
47
47
  import { scrubRecord } from './lib/scrub-record.mjs';
48
48
  import { formatHookError } from './lib/native-binding-hint.mjs';
49
49
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
50
- 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';
51
51
  import { snapshotDb } from './lib/db-backup.mjs';
52
52
  import {
53
53
  extractCitationsFromTranscript,
@@ -286,6 +286,9 @@ async function handlePostToolUse() {
286
286
  files,
287
287
  ts: Date.now(),
288
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,
289
292
  isSignificant: EDIT_TOOLS.has(tool_name) ||
290
293
  bashSig?.isSignificant || false,
291
294
  bashSig: bashSig || null,
@@ -711,6 +714,12 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
711
714
  WHERE COALESCE(compressed_into, 0) = 0
712
715
  AND COALESCE(importance, 1) <= 1
713
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')
714
723
  AND created_at_epoch < ?
715
724
  AND project = ?
716
725
  `).run(autoCompressAge, project);
@@ -779,6 +788,12 @@ function runSessionStartAutoMaintain(db) {
779
788
  const cleaned = cleanupBroken(db, mctx);
780
789
  if (cleaned > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned} broken observations`);
781
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
+
782
797
  const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
783
798
  if (decayed > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed} stale observations`);
784
799
  if (idleMarked > 0) debugLog('DEBUG', 'auto-maintain', `marked ${idleMarked} idle as pending-purge`);
@@ -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);
@@ -40,6 +40,10 @@ export function selectCompressionCandidates(db, { cutoff, project = null, includ
40
40
  FROM observations
41
41
  WHERE COALESCE(importance, 1) <= 1
42
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')
43
47
  AND created_at_epoch < ?
44
48
  ${compressedFilter}
45
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
@@ -133,6 +152,11 @@ export function decayAndMarkIdle(db, { projectFilter, baseParams, staleAge, opCa
133
152
  AND COALESCE(importance, 1) = 1
134
153
  AND COALESCE(access_count, 0) = 0
135
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')
136
160
  AND created_at_epoch < ?
137
161
  ${projectFilter} LIMIT ${opCap}
138
162
  )
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,
@@ -1096,9 +1097,17 @@ async function cmdStats(db, args) {
1096
1097
  `SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`
1097
1098
  ).get(...baseParams);
1098
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".
1099
1107
  const lowVal = db.prepare(`
1100
1108
  SELECT COUNT(*) as c FROM observations
1101
- 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
1102
1111
  AND COALESCE(compressed_into, 0) = 0
1103
1112
  AND created_at_epoch < ? ${projectFilter}
1104
1113
  `).get(thirtyDaysAgo, ...baseParams);
@@ -1197,7 +1206,7 @@ async function cmdStats(db, args) {
1197
1206
  out('Data Health:');
1198
1207
  out(` Est. tokens: ${tokenEst.t ?? 0}`);
1199
1208
  out(` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`);
1200
- 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)`);
1201
1210
  out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
1202
1211
  out(` Compressed: ${compressedCount.c}`);
1203
1212
  out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
@@ -1944,6 +1953,10 @@ function cmdMaintain(db, args) {
1944
1953
  if (ops.includes('cleanup')) {
1945
1954
  const deleted = cleanupBroken(db, mctx);
1946
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`);
1947
1960
  }
1948
1961
 
1949
1962
  if (ops.includes('decay')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.22.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",
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,
@@ -863,9 +864,14 @@ server.registerTool(
863
864
  `).get(...baseParams);
864
865
 
865
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".
866
871
  const lowVal = db.prepare(`
867
872
  SELECT COUNT(*) as c FROM observations
868
- 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
869
875
  AND COALESCE(compressed_into, 0) = 0
870
876
  AND created_at_epoch < ? ${projectFilter}
871
877
  `).get(thirtyDaysAgo, ...baseParams);
@@ -915,7 +921,7 @@ server.registerTool(
915
921
  'Data Health:',
916
922
  ` Est. tokens: ${tokenEst.t ?? 0}`,
917
923
  ` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`,
918
- ` 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)`,
919
925
  ` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`,
920
926
  ` Compressed: ${compressedCount.c}`,
921
927
  ...((noiseRatio > 0.6 || lowSignalRatio > 0.3) ? [' ⚠️ High noise ratio — consider running mem_compress / maintain'] : []),
@@ -1109,6 +1115,10 @@ server.registerTool(
1109
1115
  if (ops.includes('cleanup')) {
1110
1116
  const deleted = cleanupBroken(db, mctx);
1111
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`);
1112
1122
  }
1113
1123
 
1114
1124
  if (ops.includes('decay')) {