claude-mem-lite 2.54.0 → 2.58.2

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.
package/hook.mjs CHANGED
@@ -25,7 +25,7 @@ import { homedir } from 'os';
25
25
  import {
26
26
  truncate, inferProject, detectBashSignificance,
27
27
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
28
- makeEntryDesc, scrubSecrets, EDIT_TOOLS, debugCatch, debugLog,
28
+ makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
29
29
  COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, isoWeekKey, OBS_BM25,
30
30
  computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity,
31
31
  } from './utils.mjs';
@@ -639,10 +639,14 @@ async function handleSessionStart() {
639
639
 
640
640
  // Auto-compress: mark old low-importance observations as compressed (30+ days, importance=1)
641
641
  // Lightweight: only marks rows, doesn't create summaries (full compression via mem_compress)
642
+ // v2.56.0 #4: protect injection_count > 0 obs (proven contextually relevant
643
+ // via hook-memory injection, even if user never explicitly fetched). Same
644
+ // protection applied symmetrically in auto-maintain decay/mark-idle below.
642
645
  const compressed = db.prepare(`
643
646
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
644
647
  WHERE COALESCE(compressed_into, 0) = 0
645
648
  AND importance = 1
649
+ AND COALESCE(injection_count, 0) = 0
646
650
  AND created_at_epoch < ?
647
651
  AND project = ?
648
652
  `).run(autoCompressAge, project);
@@ -708,6 +712,11 @@ async function handleSessionStart() {
708
712
  if (cleaned.changes > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned.changes} broken observations`);
709
713
 
710
714
  // Decay: reduce importance of old, never-accessed observations
715
+ // v2.56.0 #4: injection_count is a separate engagement signal —
716
+ // hook-memory.mjs bumps it when the obs is auto-injected into Claude's
717
+ // context. Pre-v2.56 only checked access_count, so an obs auto-injected
718
+ // 8x (proven contextually relevant) still got decayed/marked. Adding
719
+ // `injection_count = 0` treats injection as first-class engagement.
711
720
  const decayed = db.prepare(`
712
721
  UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
713
722
  WHERE id IN (
@@ -715,13 +724,15 @@ async function handleSessionStart() {
715
724
  WHERE COALESCE(compressed_into, 0) = 0
716
725
  AND COALESCE(importance, 1) > 1
717
726
  AND COALESCE(access_count, 0) = 0
727
+ AND COALESCE(injection_count, 0) = 0
718
728
  AND created_at_epoch < ?
719
729
  LIMIT ${OP_CAP}
720
730
  )
721
731
  `).run(STALE_AGE);
722
732
  if (decayed.changes > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed.changes} stale observations`);
723
733
 
724
- // Mark idle: importance=1, never-accessed, old → pending-purge (will be purged next cycle)
734
+ // Mark idle: importance=1, never-accessed, never-injected, old → pending-purge
735
+ // (will be purged next cycle). v2.56.0 #4: injection_count protects.
725
736
  const idleMarked = db.prepare(`
726
737
  UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
727
738
  WHERE id IN (
@@ -729,6 +740,7 @@ async function handleSessionStart() {
729
740
  WHERE COALESCE(compressed_into, 0) = 0
730
741
  AND COALESCE(importance, 1) = 1
731
742
  AND COALESCE(access_count, 0) = 0
743
+ AND COALESCE(injection_count, 0) = 0
732
744
  AND created_at_epoch < ?
733
745
  LIMIT ${OP_CAP}
734
746
  )
@@ -1020,11 +1032,21 @@ async function handleUserPrompt() {
1020
1032
  let hookData;
1021
1033
  try { hookData = JSON.parse(raw.text); } catch { return; }
1022
1034
 
1023
- const promptText = hookData.prompt || hookData.user_prompt;
1024
- if (!promptText || typeof promptText !== 'string') return;
1025
-
1026
- // Skip internal Claude Code protocol messages — not real user input
1027
- if (promptText.startsWith('<task-notification>')) return;
1035
+ const rawPrompt = hookData.prompt || hookData.user_prompt;
1036
+ if (!rawPrompt || typeof rawPrompt !== 'string') return;
1037
+
1038
+ // Skip internal Claude Code protocol messages — not real user input.
1039
+ // Check on raw text BEFORE stripPrivate (the marker is a literal sentinel,
1040
+ // wrapping it in <private> would never make sense, but order matters: a
1041
+ // future <task-notification> with embedded <private> blocks should still
1042
+ // be classified as protocol first.)
1043
+ if (rawPrompt.startsWith('<task-notification>')) return;
1044
+
1045
+ // Strip user-marked <private>...</private> blocks at the input boundary so
1046
+ // every downstream consumer (user_prompts INSERT, FTS query, continuation
1047
+ // detection, semantic-memory injection) sees the redacted text — single
1048
+ // source of truth for the privacy primitive.
1049
+ const promptText = stripPrivate(rawPrompt);
1028
1050
 
1029
1051
  const sessionId = getSessionId();
1030
1052
  const db = openDb();
package/install.mjs CHANGED
@@ -29,23 +29,13 @@ import { createRequire } from 'module';
29
29
 
30
30
  import { RESOURCE_METADATA } from './install-metadata.mjs';
31
31
  import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
32
- import { SOURCE_FILES } from './source-files.mjs';
32
+ import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
33
33
 
34
- /**
35
- * Hook scripts that non-dev install must copy into ~/.claude-mem-lite/scripts/
36
- * to keep settings.json hook commands resolvable. Single source of truth so
37
- * adding a new PreToolUse/PostToolUse hook script can't drift from the install
38
- * copy block (which previously hand-listed only 3 of these and silently
39
- * dropped pre-tool-recall.js + pre-skill-bridge.js — every fresh install left
40
- * settings.json pointing at non-existent files).
41
- */
42
- export const HOOK_SCRIPT_FILES = [
43
- 'post-tool-use.sh',
44
- 'user-prompt-search.js',
45
- 'prompt-search-utils.mjs',
46
- 'pre-tool-recall.js',
47
- 'pre-skill-bridge.js',
48
- ];
34
+ // Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
35
+ // and any external consumers still import HOOK_SCRIPT_FILES from install.mjs.
36
+ // The constant itself moved to source-files.mjs in v2.55 so hook-update.mjs
37
+ // can share it without a static cycle.
38
+ export { HOOK_SCRIPT_FILES };
49
39
 
50
40
  export function copyHookScripts(srcDir, destDir) {
51
41
  for (const name of HOOK_SCRIPT_FILES) {
@@ -349,12 +339,10 @@ async function install() {
349
339
  if (existsSync(join(PROJECT_DIR, 'registry'))) {
350
340
  symlinkSync(join(PROJECT_DIR, 'registry'), regLink);
351
341
  }
352
- // Symlink commands/ directory
353
- const cmdLink = join(DATA_DIR, 'commands');
354
- if (existsSync(cmdLink)) try { rmSync(cmdLink, { recursive: true, force: true }); } catch {}
355
- if (existsSync(join(PROJECT_DIR, 'commands'))) {
356
- symlinkSync(join(PROJECT_DIR, 'commands'), cmdLink);
357
- }
342
+ // commands/ is intentionally NOT linked: Claude Code reads slash commands
343
+ // from the plugin cache (~/.claude/plugins/cache/<mp>/<plugin>/<ver>/commands/)
344
+ // or user-level ~/.claude/commands/, never from ~/.claude-mem-lite/commands/.
345
+ // Pre-v2.55 maintained a symlink/copy here that had no consumers.
358
346
  ok('Symlinks created in ~/.claude-mem-lite/ → dev dir');
359
347
  } else {
360
348
  log('Installing to ~/.claude-mem-lite/...');
@@ -375,15 +363,7 @@ async function install() {
375
363
  copyHookScripts(join(PROJECT_DIR, 'scripts'), scriptsDir);
376
364
  // Ensure bash script is executable
377
365
  try { execFileSync('chmod', ['+x', join(scriptsDir, 'post-tool-use.sh')], { stdio: 'pipe' }); } catch {}
378
- // Copy commands directory
379
- const commandsDir = join(DATA_DIR, 'commands');
380
- if (!existsSync(commandsDir)) mkdirSync(commandsDir, { recursive: true });
381
- const commandsSrc = join(PROJECT_DIR, 'commands');
382
- if (existsSync(commandsSrc)) {
383
- for (const f of readdirSync(commandsSrc).filter(f => f.endsWith('.md'))) {
384
- copyFileSync(join(commandsSrc, f), join(commandsDir, f));
385
- }
386
- }
366
+ // commands/ is intentionally NOT copied — see dev-mode branch above.
387
367
  // Copy registry manifest
388
368
  const registryDir = join(DATA_DIR, 'registry');
389
369
  if (!existsSync(registryDir)) mkdirSync(registryDir, { recursive: true });
@@ -1614,9 +1594,10 @@ function syncVersions() {
1614
1594
  const marketJson = JSON.parse(readFileSync(marketJsonPath, 'utf8'));
1615
1595
  const plugin = marketJson.plugins?.[0];
1616
1596
  if (plugin && plugin.version !== version) {
1597
+ const prev = plugin.version;
1617
1598
  plugin.version = version;
1618
1599
  writeFileSync(marketJsonPath, JSON.stringify(marketJson, null, 2) + '\n');
1619
- ok(`marketplace.json: ${plugin.version} → ${version}`);
1600
+ ok(`marketplace.json: ${prev} → ${version}`);
1620
1601
  } else if (plugin) {
1621
1602
  ok(`marketplace.json: already ${version}`);
1622
1603
  }
@@ -1624,6 +1605,27 @@ function syncVersions() {
1624
1605
  warn('marketplace.json not found');
1625
1606
  }
1626
1607
 
1608
+ // Sync CLAUDE.md `**Version**: x.y.z` line — install-e2e asserts this
1609
+ // matches package.json so omitting it here would break CI on every release.
1610
+ const claudeMdPath = join(PROJECT_DIR, 'CLAUDE.md');
1611
+ if (existsSync(claudeMdPath)) {
1612
+ const orig = readFileSync(claudeMdPath, 'utf8');
1613
+ const versionLine = /^- \*\*Version\*\*: .+$/m;
1614
+ if (versionLine.test(orig)) {
1615
+ const patched = orig.replace(versionLine, `- **Version**: ${version}`);
1616
+ if (patched !== orig) {
1617
+ writeFileSync(claudeMdPath, patched);
1618
+ ok(`CLAUDE.md: → ${version}`);
1619
+ } else {
1620
+ ok(`CLAUDE.md: already ${version}`);
1621
+ }
1622
+ } else {
1623
+ warn('CLAUDE.md: `**Version**:` line not found — skipped');
1624
+ }
1625
+ } else {
1626
+ warn('CLAUDE.md not found');
1627
+ }
1628
+
1627
1629
  console.log('');
1628
1630
  }
1629
1631
 
@@ -147,6 +147,44 @@ export function capNoiseImportance(obs) {
147
147
  return original > 1 ? 1 : original;
148
148
  }
149
149
 
150
+ /**
151
+ * v2.56.0 #1: paired-gate DROP for type=change + null/short lesson + low importance.
152
+ *
153
+ * Pairs with capNoiseImportance (DEMOTE) per #8152's paired-gate model. The
154
+ * existing isNoiseObservation gate is title-pattern keyed (LOW_SIGNAL regex);
155
+ * Haiku-titled `change` obs with substantive-looking titles but no extractable
156
+ * lesson slip through it. This gate is type+lesson keyed and catches them.
157
+ *
158
+ * Empirical baseline (CLAUDE.md, projects--mem): type=change has 16.5% hit-rate
159
+ * vs decision 72.7%. type=change is 67% of recent 30d obs, and Haiku writes
160
+ * lesson_learned=null/'none' for ~70% of curated observations (per
161
+ * hook-llm.mjs:639 lowSignalLesson set). When *all three* hold — change type +
162
+ * no lesson + Haiku didn't flag importance>=2 — the obs is by definition
163
+ * low-yield and adds noise to the corpus.
164
+ *
165
+ * Scope: ONLY type='change'. bugfix/decision get a lesson-retry pass already
166
+ * (hook-llm.mjs:648); feature/refactor/discovery aren't dominated by null
167
+ * lessons in the same way.
168
+ *
169
+ * Opt-out: env `CLAUDE_MEM_KEEP_LOW_SIGNAL=1` disables (parity with
170
+ * isNoiseObservation).
171
+ *
172
+ * @param {object} obs { type, lessonLearned|lesson_learned, importance }
173
+ * @param {object} [env=process.env] Environment (injected for testability)
174
+ * @returns {boolean} true = drop, caller should skip insert
175
+ */
176
+ export function isLowYieldChangeObs(obs, env = process.env) {
177
+ if (env && env.CLAUDE_MEM_KEEP_LOW_SIGNAL === '1') return false;
178
+ if (!obs || obs.type !== 'change') return false;
179
+ if ((obs.importance ?? 1) >= 2) return false;
180
+ const lesson = obs.lessonLearned ?? obs.lesson_learned;
181
+ const trimmed = (typeof lesson === 'string') ? lesson.trim() : '';
182
+ if (!trimmed) return true; // null / undefined / whitespace
183
+ if (trimmed.toLowerCase() === 'none') return true; // Haiku default
184
+ if (trimmed.length < 12) return true; // "ok" / "fixed it" / "works"
185
+ return false;
186
+ }
187
+
150
188
  export function isNoiseObservation(obs, env = process.env) {
151
189
  if (env && env.CLAUDE_MEM_KEEP_LOW_SIGNAL === '1') return false;
152
190
  const title = (obs && obs.title) || '';
@@ -0,0 +1,36 @@
1
+ // claude-mem-lite: Strip <private>...</private> blocks from user-supplied text
2
+ // before any persistence or downstream processing.
3
+ //
4
+ // Use case: user wraps sensitive content (test fixtures, internal IDs, draft
5
+ // secrets that scrubSecrets misses) in <private>X</private> to opt out of
6
+ // memory capture. Replaces each well-formed pair with [redacted] to preserve
7
+ // surrounding grammar and FTS bigram boundaries.
8
+ //
9
+ // Mirrors thedotmack/claude-mem v13's <private> primitive (referenced in
10
+ // observation #8252 follow-up scope) — same syntax for cross-tool familiarity.
11
+ //
12
+ // Intentionally does NOT strip:
13
+ // - Open-without-close (`<private>...` with no `</private>`): user may still
14
+ // be typing; aggressive strip-to-EOL would surprise. Caller can chain a
15
+ // length cap (`promptText.slice(0, 10000)`) after this for safety.
16
+ // - Stray `</private>` with no opener: same reasoning, leave intact.
17
+ // Both gaps are documented for callers to layer additional guards if needed.
18
+ //
19
+ // Case-insensitive on the tag (`<PRIVATE>`, `<Private>` all work) since users
20
+ // type by hand. Non-greedy match handles multiple blocks correctly.
21
+
22
+ const PRIVATE_BLOCK_RE = /<private>([\s\S]*?)<\/private>/gi;
23
+ const REDACTION_MARKER = '[redacted]';
24
+
25
+ /**
26
+ * Replace each well-formed <private>...</private> block with [redacted].
27
+ * Returns input unchanged if no closed block is present.
28
+ *
29
+ * @param {unknown} text Input string (non-string passes through)
30
+ * @returns {string|unknown} Stripped text, or input unchanged if not a string
31
+ */
32
+ export function stripPrivate(text) {
33
+ if (typeof text !== 'string') return text;
34
+ if (!text.includes('<')) return text; // fast path — most prompts have no tags
35
+ return text.replace(PRIVATE_BLOCK_RE, REDACTION_MARKER);
36
+ }
package/mem-cli.mjs CHANGED
@@ -905,6 +905,43 @@ async function cmdStats(db, args) {
905
905
  await renderQualityReport(db, { project, days });
906
906
  return;
907
907
  }
908
+ // v2.57.x B2: --retry shows the lesson_retry_stats aggregate. Answers
909
+ // "is the bugfix/decision retry path (1 extra Haiku call per attempt)
910
+ // paying off?". If recovered/attempts < 0.10 over a long window, the
911
+ // path is dead weight and should be deleted.
912
+ const retry = flags.retry === true || flags.retry === 'true';
913
+ if (retry) {
914
+ const { readRetryStats } = await import('./hook-llm.mjs');
915
+ const rows = readRetryStats(db, days);
916
+ const totalAttempts = rows.reduce((a, r) => a + r.attempts, 0);
917
+ const totalRecovered = rows.reduce((a, r) => a + r.recovered, 0);
918
+ const recoveryRate = totalAttempts > 0 ? totalRecovered / totalAttempts : 0;
919
+ if (flags.json === true || flags.json === 'true') {
920
+ out(JSON.stringify({
921
+ days, total_attempts: totalAttempts, total_recovered: totalRecovered,
922
+ recovery_rate: Number(recoveryRate.toFixed(4)),
923
+ per_day: rows,
924
+ }, null, 2));
925
+ return;
926
+ }
927
+ out(`[mem] lesson-retry stats — last ${days}d (UTC date buckets)`);
928
+ out(` attempts: ${totalAttempts}`);
929
+ out(` recovered: ${totalRecovered}`);
930
+ out(` rate: ${(recoveryRate * 100).toFixed(1)}% ${totalAttempts === 0 ? '(no data — retry path may be unused this window)' : ''}`);
931
+ if (totalAttempts >= 50 && recoveryRate < 0.10) {
932
+ out(' ⚠ recovery rate <10% over ≥50 attempts — retry path likely dead weight, consider deleting');
933
+ } else if (totalAttempts >= 50 && recoveryRate >= 0.30) {
934
+ out(' ✓ recovery rate ≥30% — retry path actively saving lessons');
935
+ }
936
+ if (rows.length > 0) {
937
+ out('\n date attempts recovered rate');
938
+ for (const r of rows.slice(0, 14)) {
939
+ const rate = r.attempts > 0 ? (r.recovered / r.attempts * 100).toFixed(1) + '%' : '—';
940
+ out(` ${r.date_bucket} ${String(r.attempts).padStart(8)} ${String(r.recovered).padStart(9)} ${rate.padStart(5)}`);
941
+ }
942
+ }
943
+ return;
944
+ }
908
945
 
909
946
  const projectFilter = project ? 'AND project = ?' : '';
910
947
  const baseParams = project ? [project] : [];
@@ -1566,6 +1603,9 @@ function cmdMaintain(db, args) {
1566
1603
  }
1567
1604
 
1568
1605
  if (ops.includes('decay')) {
1606
+ // v2.56.0 #4: parity with hook.mjs auto-maintain — injection_count > 0
1607
+ // protects from decay/mark-idle, treating hook injection as first-class
1608
+ // engagement alongside access_count.
1569
1609
  const decayed = db.prepare(`
1570
1610
  UPDATE observations SET importance = MAX(1, COALESCE(importance, 1) - 1)
1571
1611
  WHERE id IN (
@@ -1573,12 +1613,13 @@ function cmdMaintain(db, args) {
1573
1613
  WHERE COALESCE(compressed_into, 0) = 0
1574
1614
  AND COALESCE(importance, 1) > 1
1575
1615
  AND COALESCE(access_count, 0) = 0
1616
+ AND COALESCE(injection_count, 0) = 0
1576
1617
  AND created_at_epoch < ?
1577
1618
  ${projectFilter} LIMIT ${OP_CAP}
1578
1619
  )
1579
1620
  `).run(staleAge, ...baseParams);
1580
1621
 
1581
- // Mark importance=1, never-accessed, old observations as pending-purge (aligned with MCP)
1622
+ // Mark importance=1, never-accessed, never-injected, old pending-purge.
1582
1623
  const idleMarked = db.prepare(`
1583
1624
  UPDATE observations SET compressed_into = ${COMPRESSED_PENDING_PURGE}
1584
1625
  WHERE id IN (
@@ -1586,6 +1627,7 @@ function cmdMaintain(db, args) {
1586
1627
  WHERE COALESCE(compressed_into, 0) = 0
1587
1628
  AND COALESCE(importance, 1) = 1
1588
1629
  AND COALESCE(access_count, 0) = 0
1630
+ AND COALESCE(injection_count, 0) = 0
1589
1631
  AND created_at_epoch < ?
1590
1632
  ${projectFilter} LIMIT ${OP_CAP}
1591
1633
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.54.0",
3
+ "version": "2.58.2",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "scripts": {
17
17
  "lint": "eslint .",
18
+ "dead-code": "knip",
18
19
  "test": "vitest run",
19
20
  "test:smoke": "vitest run tests/smoke.test.mjs",
20
21
  "test:coverage": "vitest run --coverage",
@@ -51,6 +52,7 @@
51
52
  "lib/doctor-drift.mjs",
52
53
  "lib/stats-quality.mjs",
53
54
  "lib/low-signal-patterns.mjs",
55
+ "lib/private-strip.mjs",
54
56
  "lib/citation-tracker.mjs",
55
57
  "lib/summary-extractor.mjs",
56
58
  "lib/id-routing.mjs",
@@ -117,13 +119,16 @@
117
119
  "zod": "^4.3.6"
118
120
  },
119
121
  "overrides": {
120
- "hono": ">=4.12.14"
122
+ "hono": ">=4.12.16",
123
+ "fast-uri": ">=3.1.2",
124
+ "ip-address": ">=10.1.1"
121
125
  },
122
126
  "devDependencies": {
123
127
  "@eslint/js": "^10.0.1",
124
128
  "@vitest/coverage-v8": "^4.0.18",
125
129
  "eslint": "^10.0.0",
126
130
  "fast-check": "^4.5.3",
131
+ "knip": "^6.12.1",
127
132
  "vitest": "^4.0.18"
128
133
  }
129
134
  }
package/schema.mjs CHANGED
@@ -26,7 +26,21 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
26
26
  // 2839/6429 (44%) orphaned rows (historic deletes during FK-OFF migrations)
27
27
  // and 3282/6429 (51%) stale-vocab rows (rebuildVocabulary never pruned old
28
28
  // versions before v2.47). Idempotent one-shot DELETE on ensureDb.
29
- export const CURRENT_SCHEMA_VERSION = 28;
29
+ //
30
+ // v29 (v2.57.x): (1) sdk_sessions_id_invariant trigger guarding the v2.33.1
31
+ // mix pattern (memory_session_id and content_session_id must not be the same
32
+ // non-null value — they're different ID schemes). (2) lesson_retry_stats
33
+ // aggregate table tracking how often hook-llm.mjs retry path actually
34
+ // recovers a lesson (vs being a wasted Haiku call). Both purely additive.
35
+ //
36
+ // v30 (v2.57.x patch): trigger body fix — UUID-shape gate so test fixtures
37
+ // using short literal IDs ('sess-1') don't trigger. Initial v29 trigger
38
+ // fired on any equal non-null pair, breaking 60+ test scaffolds that write
39
+ // the same literal to both columns by helper convention. v30 forces
40
+ // DROP+CREATE so DBs that picked up the strict v29 trigger get the UUID-
41
+ // gated body. Required because `CREATE TRIGGER IF NOT EXISTS` is a no-op
42
+ // when the trigger already exists, even with a different body.
43
+ export const CURRENT_SCHEMA_VERSION = 30;
30
44
 
31
45
  const CORE_SCHEMA = `
32
46
  CREATE TABLE IF NOT EXISTS sdk_sessions (
@@ -471,6 +485,62 @@ export function initSchema(db) {
471
485
  }
472
486
  } catch { /* non-critical — normalization can retry on next open */ }
473
487
 
488
+ // ─── v29 (v2.57.x): session-id mix invariant + lesson-retry stats ─────────
489
+ //
490
+ // (B1) sdk_sessions_id_mix_check trigger — guards the v2.33.1 bug pattern
491
+ // where memory_session_id and content_session_id were silently the same
492
+ // value because a caller passed the wrong ID type. The two columns hold
493
+ // *different* ID schemes (mem-internal `hook-<project>-<hash>` vs Claude
494
+ // Code UUID); they should never be equal non-null in production.
495
+ //
496
+ // Trigger fires only when both values look like CC UUIDs (length 36 +
497
+ // hyphenated 8-4-4-4-12 LIKE pattern). This is the v2.33.1 fingerprint —
498
+ // a CC UUID accidentally written into BOTH columns. Test fixtures use
499
+ // short literal strings ('sess-1') for which neither column holds a UUID,
500
+ // so the trigger correctly bypasses them; the audit function below reports
501
+ // any mix regardless for diagnostic completeness.
502
+ //
503
+ // DROP+CREATE pattern (not IF NOT EXISTS) so v29 DBs that captured the
504
+ // initial strict trigger body get the UUID-gated v30 body on next init.
505
+ // Cheap — triggers are metadata-only DDL; this runs once per schema
506
+ // version bump (gated by the fast-path schema_version check above).
507
+ db.exec(`
508
+ DROP TRIGGER IF EXISTS sdk_sessions_id_mix_check_ai;
509
+ DROP TRIGGER IF EXISTS sdk_sessions_id_mix_check_au;
510
+ CREATE TRIGGER sdk_sessions_id_mix_check_ai
511
+ BEFORE INSERT ON sdk_sessions
512
+ WHEN NEW.memory_session_id IS NOT NULL
513
+ AND NEW.memory_session_id = NEW.content_session_id
514
+ AND length(NEW.memory_session_id) = 36
515
+ AND NEW.memory_session_id LIKE '________-____-____-____-____________'
516
+ BEGIN
517
+ SELECT RAISE(ABORT, 'sdk_sessions invariant: memory_session_id and content_session_id must not hold the same UUID value (v2.33.1 mix pattern)');
518
+ END;
519
+ CREATE TRIGGER sdk_sessions_id_mix_check_au
520
+ BEFORE UPDATE ON sdk_sessions
521
+ WHEN NEW.memory_session_id IS NOT NULL
522
+ AND NEW.memory_session_id = NEW.content_session_id
523
+ AND length(NEW.memory_session_id) = 36
524
+ AND NEW.memory_session_id LIKE '________-____-____-____-____________'
525
+ BEGIN
526
+ SELECT RAISE(ABORT, 'sdk_sessions invariant: memory_session_id and content_session_id must not hold the same UUID value (v2.33.1 mix pattern)');
527
+ END;
528
+ `);
529
+
530
+ // (B2) lesson_retry_stats — daily aggregate of hook-llm.mjs retry path
531
+ // outcomes. attempts = times the bugfix/decision retry prompt was issued;
532
+ // recovered = times the retry actually returned a non-low-signal lesson.
533
+ // Used by `claude-mem-lite stats --retry` to answer "is the extra Haiku
534
+ // call paying off?" — if recovered/attempts < 0.1 over a long window,
535
+ // delete the retry path and save one LLM call per bugfix/decision.
536
+ db.exec(`
537
+ CREATE TABLE IF NOT EXISTS lesson_retry_stats (
538
+ date_bucket TEXT PRIMARY KEY,
539
+ attempts INTEGER NOT NULL DEFAULT 0,
540
+ recovered INTEGER NOT NULL DEFAULT 0
541
+ )
542
+ `);
543
+
474
544
  // Record schema version for fast-path on subsequent calls
475
545
  db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
476
546
  db.transaction(() => {
@@ -481,6 +551,67 @@ export function initSchema(db) {
481
551
  return db;
482
552
  }
483
553
 
554
+ // ─── Session-consistency audit (B1) ─────────────────────────────────────────
555
+ //
556
+ // Used by `claude-mem-lite doctor --session-audit` to surface dangling state
557
+ // that the schema invariant trigger only catches at insert/update time. The
558
+ // trigger is a forward-protection; this function detects historical drift.
559
+ //
560
+ // Returns shape: {
561
+ // id_mix_uuid_shape: rows where both columns hold the same UUID-shaped value
562
+ // (the v2.33.1 production fingerprint — alarming),
563
+ // id_mix_other: rows where both columns equal but NOT UUID-shaped
564
+ // (typically test-fixture scaffold convention — informational),
565
+ // missing_mem_id: sdk_sessions rows where memory_session_id IS NULL after grace,
566
+ // orphan_obs: observations.memory_session_id values not in sdk_sessions,
567
+ // healthy: true when id_mix_uuid_shape + missing_mem_id + orphan_obs == 0;
568
+ // id_mix_other does NOT drive healthy=false, mirroring the
569
+ // trigger's UUID-shape gate so doctor doesn't misfire on DBs
570
+ // contaminated with test-fixture-style literal IDs.
571
+ // }
572
+ //
573
+ // Post-review fix (Important #5): split id_mix to avoid false-positive doctor
574
+ // failures on DBs that contain test fixtures or any 'sess-1'-style literal
575
+ // equality. The trigger only fires for UUID-shaped equality (the actual bug
576
+ // fingerprint); the audit now mirrors that policy for the exit-code-driving
577
+ // metric while still surfacing the broader count for diagnostic transparency.
578
+ export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
579
+ const cutoff = Date.now() - graceMinutes * 60_000;
580
+ // UUID-shape gate mirrors the v30 trigger — same length=36 + LIKE pattern.
581
+ const UUID_LIKE = '________-____-____-____-____________';
582
+ const idMixUuidShape = db.prepare(`
583
+ SELECT COUNT(*) AS c FROM sdk_sessions
584
+ WHERE memory_session_id IS NOT NULL
585
+ AND memory_session_id = content_session_id
586
+ AND length(memory_session_id) = 36
587
+ AND memory_session_id LIKE ?
588
+ `).get(UUID_LIKE).c;
589
+ const idMixOther = db.prepare(`
590
+ SELECT COUNT(*) AS c FROM sdk_sessions
591
+ WHERE memory_session_id IS NOT NULL
592
+ AND memory_session_id = content_session_id
593
+ AND NOT (length(memory_session_id) = 36 AND memory_session_id LIKE ?)
594
+ `).get(UUID_LIKE).c;
595
+ const missingMemId = db.prepare(`
596
+ SELECT COUNT(*) AS c FROM sdk_sessions
597
+ WHERE memory_session_id IS NULL
598
+ AND started_at_epoch < ?
599
+ `).get(cutoff).c;
600
+ const orphanObs = db.prepare(`
601
+ SELECT COUNT(*) AS c FROM observations o
602
+ WHERE NOT EXISTS (
603
+ SELECT 1 FROM sdk_sessions s WHERE s.memory_session_id = o.memory_session_id
604
+ )
605
+ `).get().c;
606
+ return {
607
+ id_mix_uuid_shape: idMixUuidShape,
608
+ id_mix_other: idMixOther,
609
+ missing_mem_id: missingMemId,
610
+ orphan_obs: orphanObs,
611
+ healthy: idMixUuidShape === 0 && missingMemId === 0 && orphanObs === 0,
612
+ };
613
+ }
614
+
484
615
  /**
485
616
  * Ensure DB directory, database file, and all tables exist.
486
617
  * Safe to call from any process (hook or server). Idempotent.
package/scripts/setup.sh CHANGED
@@ -26,6 +26,7 @@ fi
26
26
  log_ok() { echo -e "${GREEN}✓${NC} $*" >&2; }
27
27
  log_info() { echo -e "${BLUE}ℹ${NC} $*" >&2; }
28
28
  log_warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; }
29
+ # shellcheck disable=SC2317 # kept for API symmetry with log_ok/log_info/log_warn
29
30
  log_err() { echo -e "${RED}✗${NC} $*" >&2; }
30
31
 
31
32
  # 1. Migrate unhidden dir (~/claude-mem-lite/ → ~/.claude-mem-lite/)
@@ -71,8 +72,9 @@ mkdir -p "$DATA_DIR/runtime"
71
72
  if [[ ! -d "$ROOT/node_modules/better-sqlite3" ]]; then
72
73
  # Fast path: symlink from data dir (instant, no network needed)
73
74
  if [[ -d "$DATA_DIR/node_modules/better-sqlite3" ]]; then
74
- ln -sfn "$DATA_DIR/node_modules" "$ROOT/node_modules" 2>/dev/null && \
75
- log_ok "Dependencies linked from $DATA_DIR" || true
75
+ if ln -sfn "$DATA_DIR/node_modules" "$ROOT/node_modules" 2>/dev/null; then
76
+ log_ok "Dependencies linked from $DATA_DIR"
77
+ fi
76
78
  fi
77
79
  # Slow path: npm install (first-time only, ~10-20s for native addon)
78
80
  if [[ ! -d "$ROOT/node_modules/better-sqlite3" ]]; then
@@ -122,11 +124,15 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
122
124
  CACHE_DIR="$HOME/.claude/plugins/cache/sdsrss/claude-mem-lite"
123
125
  if [[ -d "$CACHE_DIR" ]]; then
124
126
  # List version dirs sorted by semver descending, skip top 3
125
- # Use while-read instead of mapfile for bash 3.2 (macOS) compatibility
127
+ # Use glob + while-read for bash 3.2 (macOS) compatibility (no mapfile, no `ls | grep`)
126
128
  OLD_VERS=()
129
+ shopt -s nullglob
130
+ _all_dirs=("$CACHE_DIR"/[0-9]*)
131
+ shopt -u nullglob
127
132
  while IFS= read -r ver; do
128
133
  [[ -n "$ver" ]] && OLD_VERS+=("$ver")
129
- done < <(ls -1 "$CACHE_DIR" | grep -E '^[0-9]+\.' | sort -t. -k1,1nr -k2,2nr -k3,3nr | tail -n +4)
134
+ done < <(for _d in "${_all_dirs[@]}"; do [[ -d "$_d" ]] && echo "${_d##*/}"; done | sort -t. -k1,1nr -k2,2nr -k3,3nr | tail -n +4)
135
+ unset _all_dirs _d
130
136
  if [[ ${#OLD_VERS[@]} -gt 0 ]]; then
131
137
  for ver in "${OLD_VERS[@]}"; do
132
138
  rm -rf "${CACHE_DIR:?}/$ver" 2>/dev/null || true
@@ -136,5 +142,53 @@ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" ]]; then
136
142
  fi
137
143
  fi
138
144
 
145
+ # 9. Residue detection (plugin mode only): warn once if legacy direct-install
146
+ # hooks remain in ~/.claude/settings.json. A user who installed via global
147
+ # `claude-mem-lite install` and later switched to the marketplace plugin
148
+ # will run every hook twice (direct settings.json hooks AND plugin hooks)
149
+ # until they run `claude-mem-lite uninstall` to clear the settings.json
150
+ # entries. /plugin uninstall does not touch settings.json.
151
+ RESIDUE_MARKER="$DATA_DIR/runtime/.residue-warned-v2.55"
152
+ if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
153
+ SETTINGS="$HOME/.claude/settings.json"
154
+ if [[ -f "$SETTINGS" ]]; then
155
+ SETTINGS_PATH="$SETTINGS" node -e '
156
+ const fs = require("fs");
157
+ try {
158
+ const raw = fs.readFileSync(process.env.SETTINGS_PATH, "utf8");
159
+ const data = JSON.parse(raw);
160
+ const hooks = data.hooks || {};
161
+ const events = Object.keys(hooks);
162
+ const found = [];
163
+ for (const ev of events) {
164
+ const list = Array.isArray(hooks[ev]) ? hooks[ev] : [];
165
+ for (const entry of list) {
166
+ const inner = Array.isArray(entry?.hooks) ? entry.hooks : [];
167
+ for (const h of inner) {
168
+ const cmd = String(h?.command || "");
169
+ if (cmd.includes(".claude-mem-lite/") || cmd.includes("claude-mem-lite/scripts") || cmd.includes("claude-mem-lite/hook.mjs")) {
170
+ found.push(ev);
171
+ break;
172
+ }
173
+ }
174
+ }
175
+ }
176
+ if (found.length) {
177
+ process.stderr.write("\n");
178
+ process.stderr.write("\x1b[33m⚠\x1b[0m Legacy direct-install hooks detected in " + process.env.SETTINGS_PATH + "\n");
179
+ process.stderr.write(" Events with stale entries: " + [...new Set(found)].join(", ") + "\n");
180
+ process.stderr.write(" These will fire alongside plugin hooks (each tool call runs twice).\n");
181
+ process.stderr.write(" Fix: run \x1b[1mclaude-mem-lite uninstall\x1b[0m to clear settings.json,\n");
182
+ process.stderr.write(" then keep using the plugin install. (One-time warning.)\n\n");
183
+ process.exit(2);
184
+ }
185
+ } catch {}
186
+ ' || true
187
+ fi
188
+ # Mark the warning as shown regardless of result — silence is fine if no
189
+ # residue, and the warning above is one-shot per data-dir.
190
+ touch "$RESIDUE_MARKER"
191
+ fi
192
+
139
193
  log_ok "claude-mem-lite ready"
140
194
  exit 0