claude-mem-lite 3.62.0 → 3.64.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.62.0",
13
+ "version": "3.64.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.62.0",
3
+ "version": "3.64.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/README.md CHANGED
@@ -529,6 +529,32 @@ Notes:
529
529
  - Direct install / npx mode keeps auto-update enabled and uses staged replacement with rollback on install failure.
530
530
  - If you disabled the plugin but still have old mem hooks in `~/.claude/settings.json`, run `node install.mjs cleanup-hooks`.
531
531
 
532
+ #### Trust model per install path
533
+
534
+ The three install paths do **not** carry the same supply-chain guarantees — pick the one that matches your threat model:
535
+
536
+ | Path | Update mechanism | Ed25519 release-signature verification |
537
+ |------|------------------|----------------------------------------|
538
+ | npm / npx / git-clone direct install | auto-update from GitHub Releases | **Yes** — every runtime file (140 entries incl. hook scripts, MCP launcher, plugin declaration files) is hash-pinned in a signed manifest; verification is fail-closed |
539
+ | `/plugin install` (marketplace) | manual `/plugin marketplace update` + reinstall | **No** — Claude Code installs from a git clone of the marketplace repo; the plugin's own signature chain is not consulted on this path. You are trusting GitHub + the repo's branch protection, not the release signing key |
540
+
541
+ **Rollback recipe (plugin path).** If an update misbehaves, pin the marketplace clone to the previous release tag and reinstall from it:
542
+
543
+ ```bash
544
+ # 1. Find the local marketplace clone
545
+ ls ~/.claude/plugins/marketplaces/ # e.g. sdsrss
546
+
547
+ # 2. Pin it to the previous good tag (tags mirror npm versions, e.g. v3.62.0)
548
+ cd ~/.claude/plugins/marketplaces/sdsrss
549
+ git fetch --tags && git checkout v3.62.0
550
+
551
+ # 3. Reinstall from the pinned clone — inside Claude Code:
552
+ # /plugin install claude-mem-lite@sdsrss
553
+ # 4. To leave the pin later: git checkout main, then the normal update flow.
554
+ ```
555
+
556
+ Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node install.mjs doctor` before trusting search results.
557
+
532
558
  ### doctor
533
559
 
534
560
  Checks Node.js version, dependencies, server/hook files, database integrity, FTS5 indexes, and stale processes.
package/format-utils.mjs CHANGED
@@ -123,6 +123,27 @@ export function neutralizeSkillDelimiters(s) {
123
123
  return defangToFixpoint(s, SKILL_BLOCK_RE);
124
124
  }
125
125
 
126
+ // <skill-bridge> is the wrapper scripts/pre-skill-bridge.js puts around a managed
127
+ // skill body it injects as PreToolUse additionalContext. The body comes from a
128
+ // third-party repo (tools/adopt import) — an untrusted boundary — so a literal
129
+ // `</skill-bridge>` inside it would close the wrapper early and spill the rest of
130
+ // the payload (e.g. a forged <system-reminder>) as undelimited context (audit
131
+ // 2026-08-14 M-4). Not in CONTEXT_DELIMITER_RE for the same reason <skill-loaded>
132
+ // isn't: the bridge's OWN wrapper must stay live, so the defang is applied per
133
+ // call site to the untrusted body only.
134
+ const SKILL_BRIDGE_RE = /<\/?skill-bridge(?:\s[^>]*)?>/gi;
135
+
136
+ /**
137
+ * Defang a literal `<skill-bridge>` opener/closer in untrusted text that is about
138
+ * to be wrapped in a real skill-bridge block. Same fixpoint treatment as the
139
+ * classes above. Never apply to the wrapper itself.
140
+ * @param {string} s Input string (any type; coerced)
141
+ * @returns {string} Text with skill-bridge delimiters defanged
142
+ */
143
+ export function neutralizeSkillBridgeDelimiters(s) {
144
+ return defangToFixpoint(s, SKILL_BRIDGE_RE);
145
+ }
146
+
126
147
  /**
127
148
  * Render the PostToolUse error-recall hint block (hook.mjs::triggerErrorRecall).
128
149
  * The single most-relevant hit (rows[0]) that carries a lesson_learned gets its
package/hook-memory.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters, basenameAnySep } from './utils.mjs';
5
5
  import { citeFactorJs } from './scoring-sql.mjs';
6
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
6
7
  import { recordMetric } from './lib/metrics.mjs';
7
8
  import { DB_DIR } from './schema.mjs';
8
9
  import { extractIdents } from './lib/lesson-idents.mjs';
@@ -218,8 +219,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
218
219
  AND o.project = ?
219
220
  AND o.importance >= 1
220
221
  AND o.created_at_epoch > ?
221
- AND COALESCE(o.compressed_into, 0) = 0
222
- AND o.superseded_at IS NULL
222
+ AND ${liveObsFilterSql('o')}
223
223
  AND ${notLowSignalTitleClause('o')}
224
224
  ORDER BY ${OBS_BM25}
225
225
  LIMIT 10
@@ -266,8 +266,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
266
266
  AND o.type IN ('decision', 'discovery')
267
267
  AND o.importance >= 2
268
268
  AND o.created_at_epoch > ?
269
- AND COALESCE(o.compressed_into, 0) = 0
270
- AND o.superseded_at IS NULL
269
+ AND ${liveObsFilterSql('o')}
271
270
  AND ${notLowSignalTitleClause('o')}
272
271
  ORDER BY ${OBS_BM25}
273
272
  LIMIT 5
@@ -316,7 +315,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
316
315
  // Adaptive threshold: scales with corpus size to filter noise.
317
316
  // Each result must individually exceed the threshold (not just the top one).
318
317
  const obsCount = db.prepare(
319
- 'SELECT COUNT(*) as c FROM observations WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL',
318
+ `SELECT COUNT(*) as c FROM observations WHERE project = ? AND ${liveObsFilterSql('')}`,
320
319
  ).get(project)?.c || 0;
321
320
  const { TINY, SMALL, MEDIUM, LARGE } = BM25_THRESHOLD;
322
321
  const threshold = obsCount < 5 ? TINY : obsCount < 100 ? SMALL : obsCount < 500 ? MEDIUM : LARGE;
@@ -389,8 +388,7 @@ export function recallForFile(db, filePath, project) {
389
388
  JOIN observation_files of2 ON of2.obs_id = o.id
390
389
  WHERE o.project = ?
391
390
  AND o.importance >= 2
392
- AND COALESCE(o.compressed_into, 0) = 0
393
- AND o.superseded_at IS NULL
391
+ AND ${liveObsFilterSql('o')}
394
392
  AND o.created_at_epoch > ?
395
393
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
396
394
  ORDER BY o.created_at_epoch DESC
@@ -431,8 +429,7 @@ export function rankImperativeCandidates(db, userPrompt, project, excludeIds = [
431
429
  SELECT id, title, lesson_learned, importance
432
430
  FROM observations
433
431
  WHERE project = ?
434
- AND COALESCE(compressed_into, 0) = 0
435
- AND superseded_at IS NULL
432
+ AND ${liveObsFilterSql('')}
436
433
  AND COALESCE(importance, 1) >= 2
437
434
  AND lesson_learned IS NOT NULL
438
435
  AND TRIM(lesson_learned) != ''
package/hook.mjs CHANGED
@@ -68,6 +68,8 @@ import { formatTaskImperative } from './lib/task-imperative.mjs';
68
68
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
69
69
  import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
70
70
  import { detectMemOverride } from './lib/mem-override.mjs';
71
+ import { injectedIdsFileName } from './lib/injected-ids.mjs';
72
+ import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
71
73
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
72
74
  import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
73
75
  import { handleLLMOptimize } from './hook-optimize.mjs';
@@ -491,11 +493,12 @@ function triggerErrorRecall(db, toolInput, response) {
491
493
  -- only for symmetry: the block's own footer is a mem_get(ids=...) pointer, and a
492
494
  -- COMPRESSED_PENDING_PURGE row is queued for deletion by maintain purge_stale,
493
495
  -- so that pointer would resolve to nothing.
494
- AND COALESCE(o.compressed_into, 0) = 0
495
- AND o.superseded_at IS NULL
496
+ AND ${liveObsFilterSql('o')}
496
497
  AND ${notLowSignalTitleClause('o')}
498
+ -- Decay via the shared core (P2-11): the M-1 MAX(0,…) age clamp lives there.
499
+ -- Fixed 14d half-life (error recency matters more than obs type here).
497
500
  ORDER BY ${OBS_BM25}
498
- * (1.0 + EXP(-0.693 * (? - o.created_at_epoch) / 1209600000.0))
501
+ * ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: '1209600000.0' })}
499
502
  LIMIT 3
500
503
  `).all(ftsQuery, project, nowR);
501
504
 
@@ -853,16 +856,21 @@ function buildCiteRecallNudge(project) {
853
856
  return libBuildCiteRecallNudge(project, RUNTIME_DIR);
854
857
  }
855
858
 
856
- // GC pre-recall cooldown files older than 24h. Pulled out of pre-tool-recall.js
857
- // (where it ran on every Edit, costing 15-30 disk stats per call on long-lived
858
- // projects) and consolidated here once per SessionStart is enough to keep
859
- // RUNTIME_DIR from growing unbounded across stale sessions.
859
+ // GC stale per-session runtime files older than 24h: pre-recall cooldowns AND
860
+ // (D#120) the per-session injected-ids markers both grow one file per session.
861
+ // Pulled out of pre-tool-recall.js (where it ran on every Edit, costing 15-30
862
+ // disk stats per call on long-lived projects) and consolidated here — once per
863
+ // SessionStart is enough to keep RUNTIME_DIR from growing unbounded.
860
864
  const PRE_RECALL_COOLDOWN_STALE_MS = 24 * 60 * 60 * 1000;
861
865
  function gcStalePreRecallCooldowns() {
862
866
  try {
863
867
  const now = Date.now();
864
868
  for (const name of readdirSync(RUNTIME_DIR)) {
865
- if (!name.startsWith('pre-recall-cooldown-') || !name.endsWith('.json')) continue;
869
+ // D#120: the injected-ids marker is also per-session now same growth
870
+ // shape as the cooldown files, same 24h GC (dedup window is 5 min).
871
+ const isCooldown = name.startsWith('pre-recall-cooldown-') && name.endsWith('.json');
872
+ const isInjectedMarker = name.startsWith('.claude-mem-injected-');
873
+ if (!isCooldown && !isInjectedMarker) continue;
866
874
  try {
867
875
  const p = join(RUNTIME_DIR, name);
868
876
  const st = statSync(p);
@@ -1017,20 +1025,29 @@ function runSessionStartAutoMaintain(db) {
1017
1025
 
1018
1026
  // Auto-dedup (exact): merge identical-title observations within 1h.
1019
1027
  // Catches rapid duplicate writes (same hook firing twice, race conditions).
1028
+ // BOTH join sides must be live (audit 2026-08-14 H-1): without the
1029
+ // superseded_at filters (which the fuzzy channel below always had), a row the
1030
+ // fuzzy pass had tombstoned could come back as `a` (a.id < b.id) and tombstone
1031
+ // the LIVE keeper `b` — both copies gone from every read path. Worse, a user
1032
+ // correction saved with supersedes=[#A] (A.superseded_by = B's NUMERIC id) has
1033
+ // the same title as A, so the pair (A, B) tombstoned the correction B itself
1034
+ // and the string 'auto-dedup' write clobbered numeric supersession chains that
1035
+ // citation-tracker decay hand-off and timeline re-anchoring both follow. The
1036
+ // UPDATE repeats the guard so a concurrent writer can't re-stamp a chain.
1020
1037
  const dupPairs = db.prepare(`
1021
1038
  SELECT a.id as keep_id, b.id as remove_id
1022
1039
  FROM observations a
1023
1040
  JOIN observations b ON a.title = b.title AND a.project = b.project
1024
1041
  AND a.id < b.id
1025
1042
  AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
1026
- AND COALESCE(a.compressed_into, 0) = 0
1027
- AND COALESCE(b.compressed_into, 0) = 0
1043
+ AND ${liveObsFilterSql('a')}
1044
+ AND ${liveObsFilterSql('b')}
1028
1045
  LIMIT 20
1029
1046
  `).all();
1030
1047
  if (dupPairs.length > 0) {
1031
1048
  const removeIds = dupPairs.map(p => p.remove_id);
1032
1049
  const ph = removeIds.map(() => '?').join(',');
1033
- db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph})`).run(Date.now(), ...removeIds);
1050
+ db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph}) AND superseded_at IS NULL`).run(Date.now(), ...removeIds);
1034
1051
  debugLog('DEBUG', 'auto-maintain', `auto-deduped ${dupPairs.length} near-identical observations`);
1035
1052
  }
1036
1053
 
@@ -1047,8 +1064,7 @@ function runSessionStartAutoMaintain(db) {
1047
1064
  const recent = db.prepare(`
1048
1065
  SELECT id, title, importance, created_at_epoch, narrative, text
1049
1066
  FROM observations
1050
- WHERE COALESCE(compressed_into, 0) = 0
1051
- AND superseded_at IS NULL
1067
+ WHERE ${liveObsFilterSql('')}
1052
1068
  AND created_at_epoch > ?
1053
1069
  AND title IS NOT NULL AND title != ''
1054
1070
  ORDER BY created_at_epoch DESC LIMIT ${SCAN_LIMIT}
@@ -1646,11 +1662,17 @@ async function handleUserPrompt() {
1646
1662
 
1647
1663
  // Read IDs already injected by user-prompt-search.js to avoid duplicate injection
1648
1664
  try {
1649
- const injectedFile = join(RUNTIME_DIR, `.claude-mem-injected-${project}`);
1665
+ // D#120: the marker file is session-keyed (no ccSessionId → legacy
1666
+ // project-keyed name), so a concurrent session's write can no longer
1667
+ // replace this session's payload between the UPS write and this read.
1668
+ const injectedFile = join(RUNTIME_DIR, injectedIdsFileName(project, ccSessionId));
1650
1669
  const raw = readFileSync(injectedFile, 'utf8');
1651
- const { ids, ts } = JSON.parse(raw);
1652
- // Only use if written within last 10 seconds (same prompt cycle)
1653
- if (ts && Date.now() - ts < 10000 && Array.isArray(ids)) {
1670
+ const { ids, ts, session } = JSON.parse(raw);
1671
+ // Only use if written within last 10 seconds (same prompt cycle) AND by this
1672
+ // CC session (M-6 payload gate, still load-bearing for legacy files).
1673
+ // Legacy payloads without `session` keep the old time-window-only behavior.
1674
+ if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
1675
+ && !(session && ccSessionId && session !== ccSessionId)) {
1654
1676
  for (const id of ids) { keyContextIds.push(id); pathAInjectedIds.push(id); }
1655
1677
  }
1656
1678
  } catch { /* file may not exist — that's fine */ }
@@ -1871,6 +1893,11 @@ try {
1871
1893
  // lib/native-binding-hint.mjs.
1872
1894
  const line = formatHookError(err, event, { runtimeDir: RUNTIME_DIR });
1873
1895
  if (line) console.error(line);
1896
+ // stderr alone is invisible to `stats` self-observation — only the native-binding
1897
+ // family was persisted, so a non-binding fatal (schema drift, bad stdin shape)
1898
+ // could kill every dispatch-routed surface while the hook-errors log read zero
1899
+ // (audit 2026-08-14 M-5; same blindness one layer up from the v3.60 outage).
1900
+ recordHookError(`hook:${event}`, err, RUNTIME_DIR);
1874
1901
  }
1875
1902
 
1876
1903
  process.exit(0);
package/install.mjs CHANGED
@@ -1561,6 +1561,27 @@ async function doctor() {
1561
1561
  ok(`Native DB binding: loadable on Node ${process.version}`);
1562
1562
  }
1563
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
+
1564
1585
  // Plugin/hook lifecycle state
1565
1586
  const settings = readSettings();
1566
1587
  const hasHooks = hasMemHooksConfigured(settings);
@@ -0,0 +1,67 @@
1
+ // lib/browse-core.mjs — shared data collection for the CLI `browse` / MCP
2
+ // `mem_browse` twin (P2-12, audit 2026-08-14). The tier count + row queries were
3
+ // duplicated and had already drifted (the CLI SELECT carried `importance`, the
4
+ // MCP one had dropped it). Collection lives here with the superset column shape;
5
+ // each face keeps its own rendering (text dashboard vs --json vs MCP text).
6
+
7
+ import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
8
+ import { liveObsFilterSql } from './inject-search-core.mjs';
9
+
10
+ export const BROWSE_TIERS = ['working', 'active', 'archive'];
11
+ export const BROWSE_TIER_LABELS = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
12
+
13
+ /** Newest active memory_session_id for the project ('' when none) — the tier
14
+ * classifier's "current session" input, needed identically by both faces. */
15
+ export function getActiveMemorySessionId(db, project) {
16
+ const row = db.prepare(
17
+ "SELECT memory_session_id FROM sdk_sessions WHERE project = ? AND status = 'active' ORDER BY started_at_epoch DESC LIMIT 1"
18
+ ).get(project);
19
+ return row?.memory_session_id ?? '';
20
+ }
21
+
22
+ /**
23
+ * Collect per-tier counts + rows for the memory dashboard.
24
+ * Archive keeps its count but skips row fetch in the unfiltered view (both faces'
25
+ * documented behavior — the archive tail is reachable via `browse --tier archive`).
26
+ * @returns {{showTiers: string[], tierData: object, tierCounts: object, grandTotal: number}}
27
+ */
28
+ export function collectBrowseTiers(db, { project, tierFilter, limit, now, currentSessionId }) {
29
+ const ctx = { now, currentProject: project, currentSessionId };
30
+ const params = tierSqlParams(ctx);
31
+ const showTiers = tierFilter ? [tierFilter] : BROWSE_TIERS;
32
+
33
+ const tierData = {};
34
+ const tierCounts = {};
35
+ let grandTotal = 0;
36
+
37
+ for (const tier of showTiers) {
38
+ const countRow = db.prepare(`
39
+ SELECT COUNT(*) as c FROM (
40
+ SELECT ${TIER_CASE_SQL} as tier FROM observations
41
+ WHERE project = ? AND ${liveObsFilterSql('')}
42
+ ) WHERE tier = ?
43
+ `).get(...params, project, tier);
44
+ const count = countRow?.c ?? 0;
45
+ tierCounts[tier] = count;
46
+ grandTotal += count;
47
+
48
+ const skipRows = tier === 'archive' && !tierFilter;
49
+ if (count === 0 || skipRows) {
50
+ tierData[tier] = { count, rows: [] };
51
+ continue;
52
+ }
53
+
54
+ const rows = db.prepare(`
55
+ SELECT * FROM (
56
+ SELECT id, type, title, importance, created_at, created_at_epoch, ${TIER_CASE_SQL} as tier
57
+ FROM observations
58
+ WHERE project = ? AND ${liveObsFilterSql('')}
59
+ ) WHERE tier = ?
60
+ ORDER BY created_at_epoch DESC
61
+ LIMIT ?
62
+ `).all(...params, project, tier, limit);
63
+ tierData[tier] = { count, rows };
64
+ }
65
+
66
+ return { showTiers, tierData, tierCounts, grandTotal };
67
+ }
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 */ }
@@ -7,7 +7,7 @@
7
7
  // merged/compressed-child recovery, and the delete transaction.
8
8
  import { snapshotDb } from './db-backup.mjs';
9
9
  import { recoverChildrenOf } from './maintain-core.mjs';
10
- import { debugCatch } from '../utils.mjs';
10
+ import { debugCatch, truncate } from '../utils.mjs';
11
11
 
12
12
  /**
13
13
  * Hard-delete the given observation ids with full orchestration. The CALLER owns
@@ -70,3 +70,20 @@ export function deleteObservations(db, ids, { snapshotTag = 'pre-delete' } = {})
70
70
  const result = deleteTx();
71
71
  return { deleted: result.changes, recoveredChildren: result.recovered, snapshotPath };
72
72
  }
73
+
74
+ /**
75
+ * Shared delete-preview body (P2-12): fetch the doomed rows and format the
76
+ * per-row lines both faces print between their own header ("Preview: N …")
77
+ * and footer (--confirm vs confirm=true remedy). The SELECT and the row shape
78
+ * were duplicated in mem-cli.mjs + server.mjs and are the exact place a
79
+ * preview/execute drift would hide.
80
+ * @param {import('better-sqlite3').Database} db
81
+ * @param {number[]} ids
82
+ * @returns {{rows: Array<{id:number,type:string,title:string,project:string}>, lines: string[]}}
83
+ */
84
+ export function previewDeleteRows(db, ids) {
85
+ const ph = ids.map(() => '?').join(',');
86
+ const rows = db.prepare(`SELECT id, type, title, project FROM observations WHERE id IN (${ph})`).all(...ids);
87
+ const lines = rows.map((r) => ` #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`);
88
+ return { rows, lines };
89
+ }
@@ -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
  ];
@@ -0,0 +1,33 @@
1
+ // lib/get-core.mjs — shared core for the CLI `get` / MCP `mem_get` twin (P2-12,
2
+ // audit 2026-08-14). The 23-element OBS_FIELDS array was duplicated verbatim in
3
+ // mem-cli.mjs and server.mjs (the 16-vs-24-column export data-loss incident's
4
+ // precursor shape), and the session detail field sets had ALREADY diverged
5
+ // (MCP 13 fields vs CLI 6 — a `remaining_items` FTS hit was a dead end in the
6
+ // CLI detail view). Field sets + the access-bump fetch live here; each face
7
+ // keeps its own header/label rendering conventions.
8
+
9
+ import { autoBoostIfNeeded } from '../search-scoring.mjs';
10
+
11
+ /** Every observation column `get --fields` accepts, in render order. */
12
+ export const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
13
+
14
+ /** Session-summary detail render set — the FULL set (both faces). The CLI's old
15
+ * 6-field subset made notes/remaining_items/files_* searchable-but-unrenderable. */
16
+ export const SESSION_DETAIL_FIELDS = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'remaining_items', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
17
+
18
+ /**
19
+ * Fetch observation detail rows: bump access_count/last_accessed_at (reading a
20
+ * detail IS an access signal — feeds noisePenalty's ratio guard), run the
21
+ * auto-boost heuristic, and return rows oldest-first.
22
+ * @param {import('better-sqlite3').Database} db
23
+ * @param {number[]} ids
24
+ * @returns {object[]} full observation rows (SELECT *), created order
25
+ */
26
+ export function fetchObsDetail(db, ids) {
27
+ const ph = ids.map(() => '?').join(',');
28
+ try {
29
+ db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...ids);
30
+ autoBoostIfNeeded(db, ids);
31
+ } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
32
+ return db.prepare(`SELECT * FROM observations WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...ids);
33
+ }
@@ -0,0 +1,84 @@
1
+ // lib/inject-search-core.mjs — the injection-side shared core (P2-11, audit
2
+ // 2026-08-14). Shared home for the three SQL atoms that kept drifting across
3
+ // hand-copied twins on the INJECTION-side retrieval surfaces (the five consumer
4
+ // files the ledger test enforces — NOT yet the whole read surface: recall-core /
5
+ // recent-core / timeline-core / hook-context / hook-handoff / hook-optimize /
6
+ // deep-search / stats and the sessions/events decay arms in lib/search-core
7
+ // still inline their own copies; extending them is the deferred second cut):
8
+ //
9
+ // * live-row filter — the compressed+superseded pair whose omission was
10
+ // the superseded-invariant's recurring reopening
11
+ // (7th occurrence fixed in v3.63 H-1, write-side)
12
+ // * clamped recency decay — the MAX(0,…) age clamp (audit M-1: a far-future
13
+ // created_at from restore/import-jsonl EXP-overflowed
14
+ // and pinned that row #1; the fix reached
15
+ // search-engine but not the UPS/error-recall twins)
16
+ // * injection relevance — the full multiplicative chain incl. cite/noise
17
+ // behavior factors (audit M-3: wired on every auto
18
+ // surface, missing from the explicit-surface score)
19
+ //
20
+ // Consumers: scripts/user-prompt-search.js, scripts/pre-tool-recall.js,
21
+ // hook-memory.mjs, hook.mjs (error-recall), search-engine.mjs. Each surface keeps
22
+ // its own deliberate pipeline composition (BM25-sort + JS scoring vs SQL full
23
+ // chain vs file-keyed sort — see #8786: per-surface asymmetries stay explicit);
24
+ // only the ATOMS are shared. tests/inject-search-core.test.mjs holds the ledger:
25
+ // the five consumer files must compose these builders, never re-inline copies.
26
+ //
27
+ // Lives under lib/ (not scripts/) so hook.mjs can statically import it without
28
+ // colliding with the installExtractedRelease scripts-dir rename (same constraint
29
+ // as lib/mem-override.mjs). Dependency-light by design: pre-tool-recall's
30
+ // standalone fast-path (#8447) already imports scoring-sql.mjs.
31
+
32
+ import {
33
+ OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE,
34
+ noisePenaltyClause, citeFactorClause,
35
+ } from '../scoring-sql.mjs';
36
+
37
+ /**
38
+ * Live-row filter: rows a model-facing retrieval surface may return. Excludes
39
+ * compression tombstones (positive keeper ids AND -2 pending-purge) and
40
+ * superseded rows (a retracted lesson must never outrank its correction).
41
+ * @param {string} [alias='o'] table alias; '' for unqualified single-table queries
42
+ * @returns {string} SQL boolean expression (no leading AND)
43
+ */
44
+ export function liveObsFilterSql(alias = 'o') {
45
+ const a = alias ? `${alias}.` : '';
46
+ return `COALESCE(${a}compressed_into, 0) = 0 AND ${a}superseded_at IS NULL`;
47
+ }
48
+
49
+ /**
50
+ * Clamped recency-decay factor: (1 + EXP(-ln2 · age / halfLife)), age >= 0.
51
+ * The MAX(0,…) clamp is the M-1 fix — restore/import-jsonl accept arbitrary
52
+ * epochs, and an unclamped future timestamp makes the exponent large-positive →
53
+ * EXP overflows to +Infinity → that row sorts #1 for every query (and its score
54
+ * serializes as null). A future row reads as age 0 = max finite recency instead.
55
+ * Binds one `?` placeholder: the caller passes `now` (epoch ms) at that position.
56
+ * @param {object} opts
57
+ * @param {string} opts.tsExpr - SQL expression for the row's reference timestamp
58
+ * (e.g. 'o.created_at_epoch', or the created/last-accessed MAX search-engine uses)
59
+ * @param {string} [opts.halfLifeSql=TYPE_DECAY_CASE] - SQL expression for the
60
+ * half-life in ms (constant or per-type CASE; TYPE_DECAY_CASE assumes alias 'o')
61
+ * @returns {string} SQL numeric expression (parenthesized)
62
+ */
63
+ export function recencyDecaySql({ tsExpr, halfLifeSql = TYPE_DECAY_CASE }) {
64
+ return `(1.0 + EXP(-0.693 * MAX(0, ? - ${tsExpr}) / ${halfLifeSql}))`;
65
+ }
66
+
67
+ /**
68
+ * Injection relevance: the full multiplicative chain the prompt-time injection
69
+ * surface (UPS searchByFts) ranks by — BM25 × clamped type-decay × type-quality
70
+ * × importance × noise penalty × cite factor. Alias is fixed to 'o' because
71
+ * TYPE_DECAY_CASE / TYPE_QUALITY_CASE bake that alias in.
72
+ * Binds one `?` placeholder (the decay `now`), first in parameter order.
73
+ * @param {string} [alias='o'] must be 'o'
74
+ * @returns {string} SQL numeric expression
75
+ */
76
+ export function injectionRelevanceSql(alias = 'o') {
77
+ if (alias !== 'o') throw new Error('injectionRelevanceSql: alias must be "o" (TYPE_*_CASE bake it in)');
78
+ return `${OBS_BM25}
79
+ * ${recencyDecaySql({ tsExpr: 'o.created_at_epoch' })}
80
+ * ${TYPE_QUALITY_CASE}
81
+ * (0.5 + 0.5 * COALESCE(o.importance, 1))
82
+ * ${noisePenaltyClause('o')}
83
+ * ${citeFactorClause('o')}`;
84
+ }