claude-mem-lite 3.61.1 → 3.63.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.
package/install.mjs CHANGED
@@ -679,6 +679,24 @@ const memPostToolUse = {
679
679
  }]
680
680
  };
681
681
 
682
+ // Component 2 of the bind-salience forcing function: after an Edit/Write, flag an
683
+ // identifier the file's own lesson named that the edit just removed (component 1 is the
684
+ // pre-edit directive from scripts/pre-tool-recall.js, which also records the identifiers
685
+ // this one checks). Shipped, signed and tested since it was written, but registered in
686
+ // NEITHER registry — so `CLAUDE_MEM_SALIENCE=bind` delivered half the mechanism and
687
+ // nothing said so (audit B6, 2026-08-14). Matched on the edit tools only, NOT Read: there
688
+ // is no post-edit state to compare after a read. Inert (returns before touching stdin)
689
+ // unless CLAUDE_MEM_SALIENCE=bind, so the default chain pays one short-circuit spawn per
690
+ // edit and emits nothing.
691
+ const memPostToolRecall = {
692
+ matcher: 'Edit|Write|NotebookEdit',
693
+ hooks: [{
694
+ type: 'command',
695
+ command: nodeHook('scripts/post-tool-recall.js'),
696
+ timeout: 3
697
+ }]
698
+ };
699
+
682
700
  const memSessionStart = {
683
701
  matcher: 'startup|clear|compact',
684
702
  hooks: [{
@@ -697,6 +715,21 @@ const memStop = {
697
715
  }]
698
716
  };
699
717
 
718
+ // Fires immediately BEFORE auto-compaction, re-emitting <claude-mem-context> so the
719
+ // summarizer that rewrites the transcript still has memory in scope (SessionStart's
720
+ // compact matcher fires AFTER, when the context is already gone). Parity with
721
+ // hooks/hooks.json: omitting it here made every settings.json install lose exactly the
722
+ // block that exists to survive compaction, invisibly — doctor only ever asked "are ANY
723
+ // mem hooks present", never "which events" (audit B3, 2026-08-14).
724
+ const memPreCompact = {
725
+ matcher: '*',
726
+ hooks: [{
727
+ type: 'command',
728
+ command: nodeHook('hook.mjs', 'pre-compact'),
729
+ timeout: 5
730
+ }]
731
+ };
732
+
700
733
  const memUserPrompt = {
701
734
  matcher: '*',
702
735
  hooks: [
@@ -754,9 +787,14 @@ const memPreAgentInject = {
754
787
 
755
788
  // Filter out existing mem hooks, then append fresh ones
756
789
  // PreToolUse has three separate matchers, so we register all three
790
+ // Event set MUST stay equal to hooks/hooks.json's (minus scripts/setup.sh, which
791
+ // bootstraps the plugin cache and has no settings.json counterpart) —
792
+ // tests/audit-silent-20260814.test.mjs diffs a real `install --dev` run's
793
+ // settings.json against the shipped manifest and reds on any new divergence.
757
794
  const hookConfigs = {
758
795
  PreToolUse: [memPreToolRecall, memPreSkillBridge, memPreAgentInject],
759
- PostToolUse: [memPostToolUse],
796
+ PostToolUse: [memPostToolUse, memPostToolRecall],
797
+ PreCompact: [memPreCompact],
760
798
  SessionStart: [memSessionStart],
761
799
  Stop: [memStop],
762
800
  UserPromptSubmit: [memUserPrompt],
@@ -768,7 +806,10 @@ for (const [event, configs] of Object.entries(hookConfigs)) {
768
806
  }
769
807
 
770
808
  writeSettings(settings);
771
- ok('Hooks configured (PreToolUse, PostToolUse, SessionStart, Stop, UserPromptSubmit)');
809
+ // Derived from the map, not a parallel literal: the pre-B3 line said five events and
810
+ // kept saying five after the map changed, which is how a missing registration reads as
811
+ // a successful one.
812
+ ok(`Hooks configured (${Object.keys(hookConfigs).join(', ')})`);
772
813
  }
773
814
 
774
815
  function backupLegacyClaudeMemData() {
@@ -1520,6 +1561,27 @@ async function doctor() {
1520
1561
  ok(`Native DB binding: loadable on Node ${process.version}`);
1521
1562
  }
1522
1563
 
1564
+ // Disk footprint (audit 2026-08-14 M-9): a "lite" data dir had grown to 653MB
1565
+ // against a 59MB DB — 360MB of it orphaned per-tag .bak snapshots — with no
1566
+ // check anywhere. Cheap probes only (DB file + .bak aggregate, no tree walk).
1567
+ // The budget itself is enforced by lib/db-backup on every new snapshot; this
1568
+ // check surfaces stores that predate the budget or exceed it between snapshots.
1569
+ try {
1570
+ const { listSnapshots, backupBudgetBytes } = await import('./lib/db-backup.mjs');
1571
+ const dbFile = join(MEM_DATA_DIR, 'claude-mem-lite.db');
1572
+ const dbBytes = existsSync(dbFile) ? statSync(dbFile).size : 0;
1573
+ const snaps = listSnapshots(dbFile);
1574
+ const backupBytes = snaps.reduce((s, x) => s + x.size, 0);
1575
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1);
1576
+ // Warn threshold = the REAL eviction budget (pre-release review 2026-08-16) —
1577
+ // warning below it promised an eviction enforceBackupBudget would never do.
1578
+ if (backupBytes > backupBudgetBytes()) {
1579
+ dwarn(`Disk footprint: ${snaps.length} backup snapshot(s) hold ${mb(backupBytes)}MB, over the ${mb(backupBudgetBytes())}MB budget (CLAUDE_MEM_BACKUP_BUDGET_MB) — the next maintain/save snapshot evicts oldest snapshots past the 7d undo grace`);
1580
+ } else {
1581
+ ok(`Disk footprint: DB ${mb(dbBytes)}MB, ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB (budget ${mb(backupBudgetBytes())}MB)`);
1582
+ }
1583
+ } catch { /* footprint check is informational — never block doctor */ }
1584
+
1523
1585
  // Plugin/hook lifecycle state
1524
1586
  const settings = readSettings();
1525
1587
  const hasHooks = hasMemHooksConfigured(settings);
package/lib/db-backup.mjs CHANGED
@@ -7,10 +7,92 @@
7
7
  //
8
8
  // MUST be called OUTSIDE any transaction — VACUUM cannot run inside one, which is
9
9
  // why the maintenance entry points snapshot before opening their db.transaction().
10
- import { readdirSync, unlinkSync } from 'fs';
10
+ import { readdirSync, unlinkSync, statSync } from 'fs';
11
11
  import { dirname, basename, join } from 'path';
12
12
  import { debugLog } from '../utils.mjs';
13
13
 
14
+ // M-9 (audit 2026-08-14): per-tag retention alone let one-shot tags live forever —
15
+ // each tag kept its newest 3, but a tag written once (pre-backfill-v3390 and four
16
+ // siblings) never got a second snapshot to age it out, so 9 orphaned .bak files held
17
+ // 360MB against a 59MB live DB. A TOTAL byte budget across ALL tags bounds the
18
+ // footprint of a system whose name promises "lite". Oldest-first eviction, newest
19
+ // snapshot always survives (the most recent safety net must outlive any budget).
20
+ const DEFAULT_BACKUP_BUDGET_BYTES = 256 * 1024 * 1024;
21
+
22
+ /** Effective budget (env-tunable). Exported so stats/doctor derive their
23
+ * footprint-warning thresholds from the SAME number eviction acts on —
24
+ * pre-release review 2026-08-16: a hardcoded `3× DB` hint fired ~2.7× below
25
+ * the real budget and promised an eviction that would never happen. */
26
+ export function backupBudgetBytes() {
27
+ const mb = Number(process.env.CLAUDE_MEM_BACKUP_BUDGET_MB);
28
+ return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : DEFAULT_BACKUP_BUDGET_BYTES;
29
+ }
30
+
31
+ // Eviction grace: snapshots younger than this are never budget-evicted. This is
32
+ // what protects a fresh `pre-delete` undo pre-image (deleteObservations reports
33
+ // its path as the recovery route) from being unlinked by same-day maintain churn
34
+ // (pre-release review 2026-08-16). Deliberately age-based, NOT newest-per-tag:
35
+ // a per-tag exemption would make every one-shot tag's only snapshot immortal —
36
+ // exactly the 360MB orphan shape M-9 exists to evict. Mirrors the purge grace
37
+ // convention (7d) used by stale-observation retention.
38
+ export const BACKUP_EVICTION_GRACE_MS = 7 * 86400000;
39
+
40
+ // Canonical snapshot shape `<base>.<tag>-<ISO stamp>-<pid>-<seq>.bak` (what
41
+ // snapshotDb writes). Budget eviction deletes ONLY names matching this — a
42
+ // user's hand-made `cp db db.before-upgrade.bak` must never be auto-unlinked.
43
+ const SNAPSHOT_STAMP_RE = /-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d+-\d+\.bak$/;
44
+
45
+ /** All `<db>.<tag>-<ts>.bak` snapshots for `dbPath`, any tag, with size + mtime. */
46
+ export function listSnapshots(dbPath) {
47
+ try {
48
+ const dir = dirname(dbPath);
49
+ const prefix = `${basename(dbPath)}.`;
50
+ const out = [];
51
+ for (const n of readdirSync(dir)) {
52
+ if (!n.startsWith(prefix) || !n.endsWith('.bak')) continue;
53
+ const full = join(dir, n);
54
+ try {
55
+ const st = statSync(full);
56
+ out.push({ path: full, size: st.size, mtimeMs: st.mtimeMs });
57
+ } catch { /* raced away */ }
58
+ }
59
+ return out;
60
+ } catch { return []; }
61
+ }
62
+
63
+ /**
64
+ * Evict oldest snapshots (across ALL tags) until the total is within the byte
65
+ * budget. Never evicted: the single newest snapshot (even if it alone exceeds
66
+ * the budget), anything younger than BACKUP_EVICTION_GRACE_MS, and any file not
67
+ * matching the canonical snapshot name shape. The total counts every .bak
68
+ * (exempt files included), so a burst of young snapshots can hold the dir above
69
+ * budget for up to the grace window — bounded by write rate, and the price of
70
+ * never deleting an undo pre-image someone may still want.
71
+ * Returns the number of files removed. Best-effort, never throws.
72
+ */
73
+ export function enforceBackupBudget(dbPath, { budgetBytes = backupBudgetBytes(), nowMs = Date.now() } = {}) {
74
+ try {
75
+ const snaps = listSnapshots(dbPath).sort((a, b) => b.mtimeMs - a.mtimeMs); // newest first
76
+ let total = snaps.reduce((s, x) => s + x.size, 0);
77
+ let removed = 0;
78
+ const graceCutoff = nowMs - BACKUP_EVICTION_GRACE_MS;
79
+ // Walk oldest-first; stop before touching the newest (index 0).
80
+ for (let i = snaps.length - 1; i >= 1 && total > budgetBytes; i--) {
81
+ if (!SNAPSHOT_STAMP_RE.test(snaps[i].path)) continue; // not ours — never delete
82
+ if (snaps[i].mtimeMs > graceCutoff) continue; // inside undo grace
83
+ try {
84
+ unlinkSync(snaps[i].path);
85
+ total -= snaps[i].size;
86
+ removed++;
87
+ } catch { /* per-entry best-effort */ }
88
+ }
89
+ if (removed > 0) {
90
+ try { debugLog('DEBUG', 'db-backup', `budget eviction removed ${removed} snapshot(s)`); } catch { /* ignore */ }
91
+ }
92
+ return removed;
93
+ } catch { return 0; }
94
+ }
95
+
14
96
  // Monotonic per-process suffix so two snapshots in the same millisecond (same pid)
15
97
  // still get unique filenames (VACUUM INTO fails if the target already exists).
16
98
  let _seq = 0;
@@ -31,6 +113,7 @@ export function snapshotDb(db, { tag = 'pre-maintain', retain = 3 } = {}) {
31
113
  // defensively since VACUUM INTO takes a string literal, not a bound param.
32
114
  db.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`);
33
115
  pruneSnapshots(dbPath, tag, retain);
116
+ enforceBackupBudget(dbPath);
34
117
  return out;
35
118
  } catch (e) {
36
119
  try { debugLog('WARN', 'db-backup', `snapshot skipped (proceeding without): ${e.message}`); } catch { /* ignore */ }
@@ -18,10 +18,16 @@
18
18
  // exported: `related_ids` (holds observation ids, stale/dangling after restore remaps ids)
19
19
  // and `discovery_tokens` (a derived retrieval metric, rebuilt by the live system; exporting
20
20
  // it would freeze a stale value into backups).
21
+ // `compressed_into` (audit 2026-08-14 M-8): only ever non-null in an export taken
22
+ // with --include-compressed / include_compressed — the default WHERE excludes those
23
+ // rows. Without the column, a compressed member round-tripped through restore as a
24
+ // LIVE row and turned up in search results next to the weekly-summary keeper that
25
+ // already absorbed it. Restore does not remap it (the keeper's id is meaningless in
26
+ // the target store); it REJECTS marked rows instead — see cmdRestore.
21
27
  export const EXPORT_COLUMNS = [
22
28
  'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
23
29
  'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
24
- 'scope',
30
+ 'scope', 'compressed_into',
25
31
  'importance', 'branch', 'access_count', 'cited_count', 'uncited_streak', 'injection_count',
26
32
  'decay_seen_count', 'last_accessed_at', 'created_at', 'created_at_epoch',
27
33
  ];
@@ -13,6 +13,13 @@ import { notLowSignalTitleClause } from '../utils.mjs';
13
13
  * { filename, rows } where rows carry the column superset both surfaces render.
14
14
  * Side effect: bumps access_count / last_accessed_at on every returned row —
15
15
  * recall IS engagement, and the tier/decay system feeds on these counters.
16
+ *
17
+ * `superseded_at IS NULL` is load-bearing twice over (audit B2, 2026-08-14): recall was
18
+ * the ONE retrieval path missing it, so a lesson a later save explicitly retracted
19
+ * (`--supersedes N`) was still served to an agent about to edit that very file — and the
20
+ * access-count bump below runs over exactly these rows, so the tombstone was ALSO pushed
21
+ * back up the decay/tier system on every read. `includeNoise` is about LOW_SIGNAL titles
22
+ * and must not reach this clause: nobody asks for retracted content.
16
23
  */
17
24
  export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}) {
18
25
  const filename = basename(file);
@@ -25,6 +32,7 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
25
32
  FROM observations o
26
33
  JOIN observation_files of2 ON of2.obs_id = o.id
27
34
  WHERE COALESCE(o.compressed_into, 0) = 0
35
+ AND o.superseded_at IS NULL
28
36
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
29
37
  ${noiseClause}
30
38
  ORDER BY o.created_at_epoch DESC
package/mem-cli.mjs CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  hardDeleteCandidateCount,
23
23
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
24
24
  } from './lib/maintain-core.mjs';
25
- import { snapshotDb } from './lib/db-backup.mjs';
25
+ import { snapshotDb, listSnapshots, backupBudgetBytes } from './lib/db-backup.mjs';
26
26
  import { deleteObservations } from './lib/delete-core.mjs';
27
27
  import { OBS_TYPE_SET } from './lib/obs-types.mjs';
28
28
  import { computeStatsFeed } from './lib/stats-core.mjs';
@@ -36,14 +36,14 @@ import { aggregateProjectCiteRecall } from './lib/citation-tracker.mjs';
36
36
  import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
37
37
  import { join, sep, dirname } from 'path';
38
38
  import { spawnSync } from 'child_process';
39
- import { readFileSync, existsSync, readdirSync } from 'fs';
39
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
40
40
 
41
41
  // v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
42
42
  // router + remaining-command bodies during the incremental split. Future work:
43
43
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
44
44
  import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
45
45
  import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
46
- import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
46
+ import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
47
47
  import { saveObservation } from './lib/save-observation.mjs';
48
48
  import { rebuildObservationDerived, normalizeScope, insertObservationVector } from './lib/observation-write.mjs';
49
49
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
@@ -533,7 +533,7 @@ function renderObsRows(db, ids, requestedFields) {
533
533
  const formatted = formatObsFieldValue(f, val);
534
534
  const maxLen = f === 'narrative' ? 1000 : f === 'lesson_learned' ? 500 : f === 'text' ? 500 : 200;
535
535
  const display = typeof formatted === 'string' && formatted.length > maxLen ? formatted.slice(0, maxLen) + '…' : formatted;
536
- lines.push(`${f}: ${display}`);
536
+ lines.push(`${obsFieldLabel(f)}: ${display}`);
537
537
  }
538
538
  parts.push(lines.join('\n'));
539
539
  }
@@ -1200,6 +1200,15 @@ async function cmdStats(db, args) {
1200
1200
  // failure mode that left code-graph's matcher bug undetected for 10 sessions.
1201
1201
  const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
1202
1202
 
1203
+ // M-9 (audit 2026-08-14): disk footprint — a "lite" store had accumulated 360MB of
1204
+ // pre-maintain snapshots against a 59MB DB with nothing reporting it. Cheap probes
1205
+ // only (DB file + .bak aggregate), no recursive tree walk.
1206
+ let dbBytes = 0;
1207
+ try { dbBytes = statSync(join(DB_DIR, 'claude-mem-lite.db')).size; } catch { /* fresh */ }
1208
+ const snaps = listSnapshots(join(DB_DIR, 'claude-mem-lite.db'));
1209
+ const backupBytes = snaps.reduce((s, x) => s + x.size, 0);
1210
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1);
1211
+
1203
1212
  if (jsonOutput) {
1204
1213
  out(JSON.stringify({
1205
1214
  project,
@@ -1226,6 +1235,9 @@ async function cmdStats(db, args) {
1226
1235
  compressed: compressedCount.c,
1227
1236
  superseded_only: supersededOnlyCount.c,
1228
1237
  hook_errors_24h: hookErrors24h,
1238
+ db_bytes: dbBytes,
1239
+ backup_count: snaps.length,
1240
+ backup_bytes: backupBytes,
1229
1241
  },
1230
1242
  tier_distribution: {
1231
1243
  working: tierMap.working ?? 0,
@@ -1265,6 +1277,9 @@ async function cmdStats(db, args) {
1265
1277
  out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
1266
1278
  out(` Compressed: ${compressedCount.c}`);
1267
1279
  out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
1280
+ // Hint threshold = the REAL eviction budget (pre-release review 2026-08-16: a
1281
+ // hardcoded 3×-DB heuristic promised an eviction that fires only past the budget).
1282
+ out(` Disk: DB ${mb(dbBytes)}MB | ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB${backupBytes > backupBudgetBytes() ? ` ← over the ${mb(backupBudgetBytes())}MB backup budget; next maintain/save snapshot evicts oldest (>7d old)` : ''}`);
1268
1283
  // Tier-1 firing counters for ① file-intel + ② reread-guard (recorded by
1269
1284
  // pre-tool-recall.js via lib/metrics.mjs; CLAUDE_MEM_METRICS=1 to enable).
1270
1285
  const featAgg = aggregateMetrics(DB_DIR, 7);
@@ -1327,7 +1342,13 @@ function cmdContext(db, args) {
1327
1342
  }
1328
1343
  out(JSON.stringify(result, null, 2));
1329
1344
  } else {
1330
- out(`<claude-mem-context>\n${block}\n</claude-mem-context>`);
1345
+ // outVerbatim: `context` is the one CLI command that must EMIT a real
1346
+ // <claude-mem-context> wrapper — it prints the same block the SessionStart hook
1347
+ // injects, so `out`'s defang would strip the delimiters this command exists to
1348
+ // produce (the CLI twin of why <skill-loaded> is excluded from CONTEXT_DELIMITER_RE).
1349
+ // The untrusted half is already neutralized one layer up: buildSessionContextLines
1350
+ // defangs every row it renders, so only the trusted wrapper is written raw here.
1351
+ outVerbatim(`<claude-mem-context>\n${block}\n</claude-mem-context>`);
1331
1352
  }
1332
1353
  }
1333
1354
 
@@ -1714,15 +1735,20 @@ function cmdExport(db, args) {
1714
1735
  // jsonl → 0 lines (valid empty file)
1715
1736
  // The friendly note goes to stderr so it doesn't poison stdout for callers
1716
1737
  // piping to a parser.
1717
- if (format === 'json') out('[]');
1738
+ if (format === 'json') outVerbatim('[]');
1718
1739
  process.stderr.write('[mem] No observations found matching criteria\n');
1719
1740
  return;
1720
1741
  }
1721
1742
 
1743
+ // outVerbatim, NOT out: `out` neutralizes structural context delimiters (cli/common.mjs)
1744
+ // because CLI stdout is model context — but this stream is a BACKUP that `restore` reads
1745
+ // back, so defanging it would silently rewrite any row whose text legitimately contains
1746
+ // `<system-reminder>`/`</claude-mem-context>` and persist the rewrite on restore. Mirrors
1747
+ // safeHandler(mem_export, { verbatim: true }) on the MCP side (audit 2026-08-14 A1).
1722
1748
  if (format === 'jsonl') {
1723
- for (const r of rows) out(JSON.stringify(r));
1749
+ for (const r of rows) outVerbatim(JSON.stringify(r));
1724
1750
  } else {
1725
- out(JSON.stringify(rows, null, 2));
1751
+ outVerbatim(JSON.stringify(rows, null, 2));
1726
1752
  }
1727
1753
 
1728
1754
  if (limitGiven && rows.length >= limit) {
@@ -1788,9 +1814,15 @@ function cmdRestore(db, argv) {
1788
1814
  decay_seen_count = ?, last_accessed_at = ?
1789
1815
  WHERE id = ?`);
1790
1816
 
1791
- let restored = 0, skipped = 0, malformed = 0;
1817
+ let restored = 0, skipped = 0, malformed = 0, tombstoned = 0;
1792
1818
  for (const r of rows) {
1793
1819
  if (!r || typeof r !== 'object' || !r.type || !r.title) { malformed++; continue; }
1820
+ // M-8 (audit 2026-08-14): a row exported with --include-compressed carries its
1821
+ // compressed_into tombstone. Restoring it as a live row resurrects a member its
1822
+ // weekly-summary keeper already absorbed (duplicate search hits, and the marker's
1823
+ // target id is meaningless in this store). Reject rather than remap; the content
1824
+ // lives on in the keeper.
1825
+ if (r.compressed_into) { tombstoned++; continue; }
1794
1826
  const project = projOverride || r.project || inferProject();
1795
1827
  const createdEpoch = Number.isFinite(Number(r.created_at_epoch)) ? Number(r.created_at_epoch) : Date.now();
1796
1828
  // Durable exact-dup guard — saveObservation's 5-min Jaccard window can't catch a
@@ -1857,7 +1889,8 @@ function cmdRestore(db, argv) {
1857
1889
  // ones that parsed.
1858
1890
  const totalMalformed = malformed + parseFailures;
1859
1891
  const totalLines = rows.length + parseFailures;
1860
- out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
1892
+ const tombstoneNote = tombstoned > 0 ? `, ${tombstoned} compressed member(s) rejected (already absorbed by their summary keeper)` : '';
1893
+ out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped${tombstoneNote}, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
1861
1894
  // Name the lossiness where the user meets it. Export omits related_ids and drops
1862
1895
  // superseded rows, and restore re-inserts under fresh AUTOINCREMENT ids — so no
1863
1896
  // cross-link can survive the round-trip. That is a deliberate format tradeoff (stored
@@ -1971,7 +2004,7 @@ function cmdMaintain(db, args) {
1971
2004
  out(` Broken (no title/narrative): ${stats.broken}`);
1972
2005
  out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
1973
2006
  out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
1974
- out(` Pending purge: ${stats.pendingPurge} (compressed originals awaiting cleanup)`);
2007
+ out(formatPendingPurgeLine(stats.pendingPurge));
1975
2008
  if (duplicates.length > 0) {
1976
2009
  const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
1977
2010
  const manualReview = duplicates.filter(d => parseFloat(d.similarity) < AUTO_MERGE_THRESHOLD);
@@ -3119,8 +3152,12 @@ async function cmdOptimize(db, args) {
3119
3152
  if (project) out(` Project filter: ${project}`);
3120
3153
  out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}`);
3121
3154
  out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
3122
- out(` Cluster-merge: ${preview.clusterMerge} clusters`);
3123
- out(` Smart-compress: ${preview.smartCompress} clusters`);
3155
+ // "candidates" matches the MCP wording (server.mjs mem_optimize preview) AND the
3156
+ // Re-enrich line just above, which already read that way on both surfaces. The two
3157
+ // surfaces render one optimizePreview() result — tests/audit-findings-20260814.test.mjs
3158
+ // drives both and compares the label lists, so the drift cannot reopen.
3159
+ out(` Cluster-merge candidates: ${preview.clusterMerge} clusters`);
3160
+ out(` Smart-compress candidates: ${preview.smartCompress} clusters`);
3124
3161
  out(` Total: ${preview.total} items`);
3125
3162
  if (verbose) {
3126
3163
  out('');
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.61.1",
3
+ "version": "3.63.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.61.1",
9
+ "version": "3.63.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.61.1",
3
+ "version": "3.63.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",
@@ -16,6 +16,7 @@
16
16
  import { existsSync, readFileSync } from 'fs';
17
17
  import { basename, join } from 'path';
18
18
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
19
+ import { recordHookError } from '../lib/hook-telemetry.mjs';
19
20
 
20
21
  const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
21
22
 
@@ -45,7 +46,13 @@ async function main() {
45
46
  const cdPath = cooldownPathFor(sessionId);
46
47
  if (!existsSync(cdPath)) return;
47
48
  let entry;
48
- try { entry = JSON.parse(readFileSync(cdPath, 'utf8'))[filePath]; } catch { return; }
49
+ try { entry = JSON.parse(readFileSync(cdPath, 'utf8'))[filePath]; } catch (e) {
50
+ // A corrupt cooldown file (torn concurrent write — audit 2026-08-14 M-6) turns
51
+ // the bind-salience check into a zero-trace no-op; record it so `stats` can see
52
+ // the surface die instead of silently reading zero errors (M-5).
53
+ recordHookError('post-recall:cooldown-parse', e, RUNTIME_DIR, { file: basename(cdPath) });
54
+ return;
55
+ }
49
56
  const idents = entry && entry.lessonIdents;
50
57
  if (!idents || typeof idents !== 'object') return;
51
58
 
@@ -77,4 +84,7 @@ async function main() {
77
84
  // stream emits 'error' — without the (now-removed) forced exit, an unhandled one would
78
85
  // surface as a non-zero exit + stack. A hook must never fail loud on a dropped pipe.
79
86
  process.stdout.on('error', () => {});
80
- main().catch(() => {});
87
+ // Record what slips past main()'s early returns before the mandatory swallow —
88
+ // this script had zero telemetry (audit 2026-08-14 M-5). Recorder never throws;
89
+ // the outer catch keeps the exit code 0 regardless.
90
+ main().catch((e) => { try { recordHookError('post-recall:main', e, RUNTIME_DIR); } catch { /* never */ } });
@@ -15,6 +15,23 @@
15
15
  const ENABLED = process.env.CLAUDE_MEM_SUBAGENT_INJECT === 'on'
16
16
  || process.env.CLAUDE_MEM_SUBAGENT_INJECT === '1';
17
17
 
18
+ // Telemetry via DYNAMIC import so the default-off fast path stays import-free
19
+ // (the file's stated contract). Only ever reached on the enabled path's failure
20
+ // branches — this script had zero recordHookError coverage, so a dead DB or
21
+ // schema drift silently disabled subagent injection with no trace (audit
22
+ // 2026-08-14 M-5). Swallows everything: telemetry must never break a dispatch.
23
+ async function recordFailure(scope, err, ctx) {
24
+ try {
25
+ const [{ recordHookError }, { resolveDataDir }, { join }] = await Promise.all([
26
+ import('../lib/hook-telemetry.mjs'),
27
+ import('../lib/resolve-data-dir.mjs'),
28
+ import('path'),
29
+ ]);
30
+ const dataDir = resolveDataDir(process.env.CLAUDE_MEM_DIR);
31
+ recordHookError(scope, err, process.env.CLAUDE_MEM_RUNTIME_DIR || join(dataDir, 'runtime'), ctx);
32
+ } catch { /* never */ }
33
+ }
34
+
18
35
  function readStdin() {
19
36
  return new Promise((resolve) => {
20
37
  let data = '';
@@ -51,7 +68,7 @@ async function main() {
51
68
  const { buildSubagentInjection } = await import('../hook-memory.mjs');
52
69
 
53
70
  let db;
54
- try { db = ensureDb(); } catch { return; }
71
+ try { db = ensureDb(); } catch (e) { await recordFailure('agent-inject:db-open', e); return; }
55
72
  try {
56
73
  const updatedInput = buildSubagentInjection(db, hook.tool_input, inferProject());
57
74
  if (updatedInput) {
@@ -59,7 +76,7 @@ async function main() {
59
76
  hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput },
60
77
  }));
61
78
  }
62
- } catch { /* never break a dispatch */ } finally {
79
+ } catch (e) { await recordFailure('agent-inject:query', e); /* never break a dispatch */ } finally {
63
80
  try { db.close(); } catch { /* */ }
64
81
  }
65
82
  }
@@ -69,5 +86,6 @@ async function main() {
69
86
  // which FLUSHES stdout. The emitted updatedInput echoes the whole prompt back, so the
70
87
  // payload can exceed the ~64KB pipe buffer; a forced process.exit() would drop that
71
88
  // pending async write and truncate the JSON (the gotcha every sibling hook avoids).
72
- // Swallow any rejection so the exit code can never go non-zero.
73
- main().catch(() => {});
89
+ // Swallow any rejection so the exit code can never go non-zero — but record it
90
+ // first (recordFailure itself swallows everything, including its own failures).
91
+ main().catch((e) => recordFailure('agent-inject:main', e));
@@ -8,6 +8,9 @@ import { join, resolve, sep } from 'path';
8
8
  import { homedir } from 'os';
9
9
  import { recordHookError } from '../lib/hook-telemetry.mjs';
10
10
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
11
+ // format-utils.mjs is import-free — pulling three defang helpers keeps this script
12
+ // inside its "lightweight standalone" budget (no heavy transitive deps).
13
+ import { neutralizeContextDelimiters, neutralizeSkillDelimiters, neutralizeSkillBridgeDelimiters } from '../format-utils.mjs';
11
14
 
12
15
  // CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
13
16
  const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
@@ -88,12 +91,22 @@ try {
88
91
  // console.log() form would render on stock CC but no-op on those variants.
89
92
  // Token budget: ~4 chars per token, 4000 token limit = 16000 chars.
90
93
  const portablePath = resolvedPath.startsWith(homedir()) ? '~' + resolvedPath.slice(homedir().length) : resolvedPath;
94
+ // Defang the untrusted skill body + name before wrapping (audit 2026-08-14 M-4):
95
+ // registry rows come from third-party repos, and this was the one AUTO injection
96
+ // surface of that data with zero neutralization — a body carrying a literal
97
+ // `</skill-bridge>` + forged <system-reminder> (or a `<skill-loaded>` execute
98
+ // block) escaped the wrapper verbatim. Name additionally drops quote/bracket
99
+ // chars: it lands in an ATTRIBUTE position, where `"` breaks out of the wrapper
100
+ // tag itself. Applied to the truncated summary too (a cut can't be trusted to
101
+ // land mid-tag).
102
+ const defang = (s) => neutralizeSkillBridgeDelimiters(neutralizeSkillDelimiters(neutralizeContextDelimiters(s)));
103
+ const safeName = String(row.name).replace(/["'<>]/g, '');
91
104
  let additionalContext;
92
105
  if (content.length > 16000) {
93
- const summary = content.slice(0, 800);
94
- additionalContext = `<skill-bridge name="${row.name}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${portablePath}") to load full content.`;
106
+ const summary = defang(content.slice(0, 800));
107
+ additionalContext = `<skill-bridge name="${safeName}" source="managed" truncated="true">\n${summary}\n...\n</skill-bridge>\n\nSkill content truncated. Read("${portablePath}") to load full content.`;
95
108
  } else {
96
- additionalContext = `<skill-bridge name="${row.name}" source="managed">\n${content}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
109
+ additionalContext = `<skill-bridge name="${safeName}" source="managed">\n${defang(content)}\n</skill-bridge>\n\nThis skill was loaded from the managed registry. Follow the instructions above.`;
97
110
  }
98
111
  process.stdout.write(JSON.stringify({
99
112
  suppressOutput: true,
@@ -4,9 +4,10 @@
4
4
  // and the pure-data lib/low-signal-patterns.mjs (zero runtime deps, ~1ms overhead).
5
5
  // Safety: readonly DB, exit 0 always, 3s timeout
6
6
 
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
+ import { existsSync, readFileSync, mkdirSync } from 'fs';
8
8
  import { basename, join } from 'path';
9
9
  import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
10
+ import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
10
11
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
11
12
  import { recordHookError } from '../lib/hook-telemetry.mjs';
12
13
  import { citeFactorClause } from '../scoring-sql.mjs';
@@ -159,17 +160,24 @@ function crossHookInjectedFile(project) {
159
160
  return join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
160
161
  }
161
162
 
162
- function readCrossHookInjected(project) {
163
+ // M-6 (audit 2026-08-14): the marker file is keyed by PROJECT, so two concurrent
164
+ // CC sessions in the same project shared one suppression state — session A's
165
+ // injections silently deduped session B's, and B inherited A's count cap. Both
166
+ // read and merge now carry the CC session id: a payload written by a DIFFERENT
167
+ // session is ignored (read) / replaced (merge), mirroring the v3.35.2 episode
168
+ // session-key fix. Legacy payloads without `session` keep the old behavior.
169
+ function readCrossHookInjected(project, sessionId) {
163
170
  try {
164
171
  const raw = readFileSync(crossHookInjectedFile(project), 'utf8');
165
- const { ids, ts } = JSON.parse(raw);
172
+ const { ids, ts, session } = JSON.parse(raw);
173
+ if (session && sessionId && session !== sessionId) return new Set();
166
174
  if (!ts || Date.now() - ts > CROSS_HOOK_DEDUP_MS) return new Set();
167
175
  if (!Array.isArray(ids)) return new Set();
168
176
  return new Set(ids.map(String));
169
177
  } catch { return new Set(); }
170
178
  }
171
179
 
172
- function mergeCrossHookInjected(project, newIds) {
180
+ function mergeCrossHookInjected(project, newIds, sessionId) {
173
181
  if (!newIds || newIds.length === 0) return;
174
182
  try {
175
183
  mkdirSync(RUNTIME_DIR, { recursive: true });
@@ -178,8 +186,10 @@ function mergeCrossHookInjected(project, newIds) {
178
186
  try {
179
187
  const raw = readFileSync(file, 'utf8');
180
188
  const parsed = JSON.parse(raw);
181
- // Within the staleness window: union. Outside: replace (fresh session).
182
- if (parsed.ts && Date.now() - parsed.ts < CROSS_HOOK_DEDUP_MS) {
189
+ // Within the staleness window AND same session (or legacy): union.
190
+ // Outside / other session: replace.
191
+ if (parsed.ts && Date.now() - parsed.ts < CROSS_HOOK_DEDUP_MS
192
+ && !(parsed.session && sessionId && parsed.session !== sessionId)) {
183
193
  prev = parsed;
184
194
  }
185
195
  } catch { /* fresh file */ }
@@ -187,10 +197,13 @@ function mergeCrossHookInjected(project, newIds) {
187
197
  ...(Array.isArray(prev.ids) ? prev.ids.map(String) : []),
188
198
  ...newIds.map(String),
189
199
  ])];
190
- writeFileSync(file, JSON.stringify({
200
+ // Atomic (tmp+rename, M-6): a plain write torn by a concurrent hook left the
201
+ // shared marker as invalid JSON, silently disabling cross-hook dedup.
202
+ atomicWriteFileSync(file, JSON.stringify({
191
203
  ids,
192
204
  ts: Date.now(),
193
205
  count: (prev.count || 0) + 1,
206
+ ...(sessionId ? { session: sessionId } : {}),
194
207
  }));
195
208
  } catch { /* silent — dedup is best-effort */ }
196
209
  }
@@ -209,7 +222,10 @@ function writeCooldown(cooldownPath, data, isSessionScoped) {
209
222
  if (ts && now - ts < STALE_MS) cleaned[k] = v;
210
223
  }
211
224
  }
212
- writeFileSync(cooldownPath, JSON.stringify(cleaned));
225
+ // Atomic (tmp+rename, M-6): the cooldown carries lessonIdents that the
226
+ // PostToolUse bind-salience check reads back — a torn write turned that
227
+ // check into a zero-trace no-op.
228
+ atomicWriteFileSync(cooldownPath, JSON.stringify(cleaned));
213
229
  } catch { /* silent */ }
214
230
  }
215
231
 
@@ -474,7 +490,7 @@ try {
474
490
  // P1 (D#78): tag each row's source table — events share the numeric id
475
491
  // space with observations, and the Stop-side edge attribution must never
476
492
  // feed an event id into observation_files updates.
477
- const crossHookSeen = readCrossHookInjected(project);
493
+ const crossHookSeen = readCrossHookInjected(project, sessionId);
478
494
  const sourcedRows = [
479
495
  ...rows.map(r => ({ ...r, src: 'obs' })),
480
496
  ...eventRows.map(r => ({ ...r, src: 'evt' })),
@@ -638,7 +654,7 @@ try {
638
654
  // file so the next UPS prompt skips them too. Always write, even on
639
655
  // empty allRows, so the file's ts stays fresh for the no-op case where
640
656
  // we'd otherwise drift outside the dedup window.
641
- mergeCrossHookInjected(project, allRows.map(r => r.id));
657
+ mergeCrossHookInjected(project, allRows.map(r => r.id), sessionId);
642
658
  } catch (e) {
643
659
  // Silent failure — never block editing, but record for self-observation.
644
660
  recordHookError('pre-recall:query', e, RUNTIME_DIR, { filePath });