claude-mem-lite 3.20.0 → 3.21.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.20.0",
13
+ "version": "3.21.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.20.0",
3
+ "version": "3.21.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/hook-update.mjs CHANGED
@@ -489,6 +489,51 @@ export async function verifyReleaseAuthenticity(extractedDir, assets, publicKey
489
489
  // below skips the 'node_modules' switchable path (existsSync guard), leaving
490
490
  // the target's node_modules untouched. Dependency bumps still flow through the
491
491
  // GitHub-tarball path (downloadAndInstall), which keeps skipNpmInstall=false.
492
+ // Undo a (partial or complete) file swap: delete the freshly-installed files, then
493
+ // rename each backup back into place. Shared by the error path and the MED-5
494
+ // post-install smoke gate so there is ONE rollback implementation.
495
+ function rollbackInstall(installed, backedUp, backupDir, targetDir) {
496
+ for (const relPath of installed.reverse()) {
497
+ try { rmSync(join(targetDir, relPath), { recursive: true, force: true }); } catch { /* best-effort */ }
498
+ }
499
+ for (const relPath of backedUp.reverse()) {
500
+ const backupPath = join(backupDir, relPath);
501
+ const targetPath = join(targetDir, relPath);
502
+ try {
503
+ if (existsSync(backupPath)) {
504
+ mkdirSync(dirname(targetPath), { recursive: true });
505
+ renameSync(backupPath, targetPath);
506
+ }
507
+ } catch (restoreErr) {
508
+ debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
509
+ }
510
+ }
511
+ }
512
+
513
+ // MED-5 post-install health gate: load-test the freshly-switched code in a SEPARATE
514
+ // process before the backup is discarded. A clean file swap can still yield an
515
+ // install that won't boot — a syntax error, an unresolved import from a half-applied
516
+ // mixed-version swap (renameSync loop hard-killed mid-way), or a native ABI mismatch.
517
+ // `cli.mjs help` boots the CLI entry, which dynamically imports the mem-cli module
518
+ // graph (maintain-core / search-engine / scoring / db-backup / schema / …) and exits
519
+ // 0 without opening the DB or a server. hook.mjs and server.mjs auto-execute on
520
+ // import (so they can't be import-smoked) — they get a `node --check` syntax pass.
521
+ // execSync (not execFileSync) so the unit-test child_process mock intercepts it.
522
+ function smokeInstalledRelease(targetDir) {
523
+ const q = (s) => JSON.stringify(s);
524
+ try {
525
+ execSync(`${q(process.execPath)} ${q(join(targetDir, 'cli.mjs'))} help`, { timeout: 20000, stdio: 'ignore' });
526
+ for (const entry of ['hook.mjs', 'server.mjs']) {
527
+ const p = join(targetDir, entry);
528
+ if (existsSync(p)) execSync(`${q(process.execPath)} --check ${q(p)}`, { timeout: 10000, stdio: 'ignore' });
529
+ }
530
+ return true;
531
+ } catch (e) {
532
+ debugLog('WARN', 'hook-update', `post-install smoke failed (rolling back): ${e.message}`);
533
+ return false;
534
+ }
535
+ }
536
+
492
537
  export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR, opts = {}) {
493
538
  // Cross-process lock: concurrent SessionStart self-heals / auto-updates must
494
539
  // not interleave the rename loop below (→ mixed-version install). A live peer
@@ -541,6 +586,16 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
541
586
  installed.push(relPath);
542
587
  }
543
588
 
589
+ // MED-5: before discarding the rollback backup, prove the switched code boots.
590
+ // If it can't, restore the backup and report failure — the running (old) version
591
+ // keeps working rather than leaving a broken install with no way back.
592
+ if (!opts.skipSmoke && !smokeInstalledRelease(targetDir)) {
593
+ rollbackInstall(installed, backedUp, backupDir, targetDir);
594
+ rmSync(stagingDir, { recursive: true, force: true });
595
+ rmSync(backupDir, { recursive: true, force: true });
596
+ return false;
597
+ }
598
+
544
599
  rmSync(stagingDir, { recursive: true, force: true });
545
600
  rmSync(backupDir, { recursive: true, force: true });
546
601
 
@@ -580,25 +635,9 @@ export async function installExtractedRelease(sourceDir, targetDir = INSTALL_DIR
580
635
  return true;
581
636
  } catch (err) {
582
637
  debugCatch(err, 'installExtractedRelease');
583
-
584
- for (const relPath of installed.reverse()) {
585
- try { rmSync(join(targetDir, relPath), { recursive: true, force: true }); } catch {}
586
- }
587
- for (const relPath of backedUp.reverse()) {
588
- const backupPath = join(backupDir, relPath);
589
- const targetPath = join(targetDir, relPath);
590
- try {
591
- if (existsSync(backupPath)) {
592
- mkdirSync(dirname(targetPath), { recursive: true });
593
- renameSync(backupPath, targetPath);
594
- }
595
- } catch (restoreErr) {
596
- debugCatch(restoreErr, `installExtractedRelease-restore-${relPath}`);
597
- }
598
- }
599
-
600
- try { rmSync(stagingDir, { recursive: true, force: true }); } catch {}
601
- try { rmSync(backupDir, { recursive: true, force: true }); } catch {}
638
+ rollbackInstall(installed, backedUp, backupDir, targetDir);
639
+ try { rmSync(stagingDir, { recursive: true, force: true }); } catch { /* best-effort */ }
640
+ try { rmSync(backupDir, { recursive: true, force: true }); } catch { /* best-effort */ }
602
641
  return false;
603
642
  } finally {
604
643
  release();
package/hook.mjs CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  truncate, inferProject, detectBashSignificance,
27
27
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
28
28
  makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
29
- COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
29
+ COMPRESSED_AUTO, OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
30
30
  } from './utils.mjs';
31
31
  import {
32
32
  readEpisodeRaw, episodeFile,
@@ -46,7 +46,8 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
46
46
  import { scrubRecord } from './lib/scrub-record.mjs';
47
47
  import { formatHookError } from './lib/native-binding-hint.mjs';
48
48
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
49
- import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds } from './lib/maintain-core.mjs';
49
+ import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale } from './lib/maintain-core.mjs';
50
+ import { snapshotDb } from './lib/db-backup.mjs';
50
51
  import {
51
52
  extractCitationsFromTranscript,
52
53
  extractAllInjected,
@@ -749,21 +750,27 @@ function runSessionStartAutoMaintain(db) {
749
750
  try {
750
751
  const STALE_AGE = Date.now() - 30 * 86400000;
751
752
  const OP_CAP = 500;
752
-
753
- // Purge FIRST: delete pending-purge entries. Schema has no marked_at_epoch, so we
754
- // anchor retention on created_at_epoch instead: 30d marking gate + 7d grace = 37d.
755
- // Older cutoffs (e.g. 7d) were always redundant with the 30d marking filter and
756
- // made purge effectively immediate on the next maintenance cycle — fix for T4-P1-A.
757
- const purged = db.prepare(`
758
- DELETE FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE}
759
- AND created_at_epoch < ?
760
- `).run(Date.now() - 37 * 86400000);
761
- if (purged.changes > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged.changes} stale observations`);
762
-
763
- // cleanup / decay+mark-idle / boost via maintain-core (shared with CLI + MCP).
764
- // injection_count>0 protection lives in decayAndMarkIdle. Whole-DB, cap 500.
753
+ // Shared maintenance context (whole-DB, cap 500) — used by every maintain-core
754
+ // op below AND the MED-2 snapshot guard. injection_count>0 protection lives in
755
+ // decayAndMarkIdle.
765
756
  const mctx = { projectFilter: '', baseParams: [], staleAge: STALE_AGE, opCap: OP_CAP };
766
757
 
758
+ // MED-2: snapshot the DB before the irreversible purge/cleanup hard-deletes
759
+ // below, but only when rows will actually be removed (cheap COUNT). Must run
760
+ // here, outside any transaction — VACUUM cannot run inside one. Best-effort:
761
+ // snapshotDb never throws, so a backup failure cannot block auto-maintain.
762
+ if (hardDeleteCandidateCount(db, mctx, { cleanup: true, purge: true }) > 0) {
763
+ snapshotDb(db, { tag: 'pre-maintain' });
764
+ }
765
+
766
+ // Purge FIRST via the SHARED purgeStale — was an inline DELETE that skipped
767
+ // recoverChildrenOf, so purging a keeper that had absorbed dups orphaned its
768
+ // children (compressed_into dangling at a deleted id). purgeStale recovers them
769
+ // first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
770
+ // created_at_epoch: 30d marking gate + 7d grace = 37d.
771
+ const purged = purgeStale(db, mctx, Date.now() - 37 * 86400000);
772
+ if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
773
+
767
774
  const cleaned = cleanupBroken(db, mctx);
768
775
  if (cleaned > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned} broken observations`);
769
776
 
@@ -0,0 +1,55 @@
1
+ // lib/db-backup.mjs — point-in-time DB snapshot before irreversible maintenance.
2
+ //
3
+ // VACUUM INTO produces a consistent, compact copy of the database (WAL-safe —
4
+ // unlike copyFileSync, which would miss un-checkpointed WAL frames). Best-effort:
5
+ // a failure logs a WARN and returns null so it NEVER blocks the maintenance it
6
+ // guards (a backup that aborts a disk-full purge would be worse than no backup).
7
+ //
8
+ // MUST be called OUTSIDE any transaction — VACUUM cannot run inside one, which is
9
+ // why the maintenance entry points snapshot before opening their db.transaction().
10
+ import { readdirSync, unlinkSync } from 'fs';
11
+ import { dirname, basename, join } from 'path';
12
+ import { debugLog } from '../utils.mjs';
13
+
14
+ // Monotonic per-process suffix so two snapshots in the same millisecond (same pid)
15
+ // still get unique filenames (VACUUM INTO fails if the target already exists).
16
+ let _seq = 0;
17
+
18
+ /**
19
+ * Snapshot `db` to `<db-path>.<tag>-<ts>.bak` via VACUUM INTO, then prune to the
20
+ * newest `retain` snapshots. No-op (returns null) for :memory: DBs (tests) and on
21
+ * any error (logged). Returns the snapshot path on success.
22
+ * @returns {string|null}
23
+ */
24
+ export function snapshotDb(db, { tag = 'pre-maintain', retain = 3 } = {}) {
25
+ try {
26
+ const dbPath = db && db.name;
27
+ if (!dbPath || dbPath === ':memory:') return null; // in-memory — nothing to snapshot
28
+ const stamp = `${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${_seq++}`;
29
+ const out = `${dbPath}.${tag}-${stamp}.bak`;
30
+ // Path is internal (db.name from our own config), but escape single quotes
31
+ // defensively since VACUUM INTO takes a string literal, not a bound param.
32
+ db.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`);
33
+ pruneSnapshots(dbPath, tag, retain);
34
+ return out;
35
+ } catch (e) {
36
+ try { debugLog('WARN', 'db-backup', `snapshot skipped (proceeding without): ${e.message}`); } catch { /* ignore */ }
37
+ return null;
38
+ }
39
+ }
40
+
41
+ // Keep the newest `retain` `<base>.<tag>-*.bak` files, unlink the rest. The ISO
42
+ // timestamp embedded in the name makes a lexical sort chronological.
43
+ function pruneSnapshots(dbPath, tag, retain) {
44
+ try {
45
+ const dir = dirname(dbPath);
46
+ const prefix = `${basename(dbPath)}.${tag}-`;
47
+ const names = readdirSync(dir)
48
+ .filter(n => n.startsWith(prefix) && n.endsWith('.bak'))
49
+ .sort()
50
+ .reverse(); // newest first
51
+ for (const n of names.slice(retain)) {
52
+ try { unlinkSync(join(dir, n)); } catch { /* per-entry best-effort */ }
53
+ }
54
+ } catch { /* best-effort — dir may be unreadable */ }
55
+ }
@@ -238,6 +238,31 @@ export function mergeDuplicates(db, groups) {
238
238
  return merged;
239
239
  }
240
240
 
241
+ /**
242
+ * Count rows a destructive maintenance run would hard-DELETE: pending-purge rows
243
+ * (any age — a cheap proxy for "purge has something to remove", deliberately not
244
+ * age-filtered so the guard never under-counts) and/or broken empty-content rows
245
+ * (cleanupBroken's doomed set). Used by the maintenance entry points to decide
246
+ * whether to VACUUM-snapshot the DB first (audit MED-2) — over-counting only costs
247
+ * one extra bounded backup; under-counting would skip the safety net.
248
+ */
249
+ export function hardDeleteCandidateCount(db, { projectFilter, baseParams }, { cleanup = false, purge = false } = {}) {
250
+ let n = 0;
251
+ if (purge) {
252
+ n += db.prepare(
253
+ `SELECT COUNT(*) AS c FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} ${projectFilter}`
254
+ ).get(...baseParams).c;
255
+ }
256
+ if (cleanup) {
257
+ n += db.prepare(
258
+ `SELECT COUNT(*) AS c FROM observations
259
+ WHERE COALESCE(compressed_into, 0) = 0
260
+ AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '') ${projectFilter}`
261
+ ).get(...baseParams).c;
262
+ }
263
+ return n;
264
+ }
265
+
241
266
  /** Preview pending-purge candidates older than the retain cutoff (no deletion). */
242
267
  export function purgeStalePreview(db, { projectFilter, baseParams }, retainCutoff) {
243
268
  return db.prepare(`
package/mem-cli.mjs CHANGED
@@ -19,9 +19,10 @@ import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
19
19
  import {
20
20
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
21
21
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
22
- recoverChildrenOf,
22
+ recoverChildrenOf, 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
26
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
26
27
  import { buildSessionContextLines } from './hook-context.mjs';
27
28
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
@@ -1867,6 +1868,14 @@ function cmdMaintain(db, args) {
1867
1868
  // T2-P1-B: surface the OP_CAP hit so users know to re-run, matching MCP mem_maintain.
1868
1869
  const capHint = (changes) => (changes >= OP_CAP ? ' (cap reached, re-run for more)' : '');
1869
1870
 
1871
+ // MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
1872
+ // only when rows will actually be removed, and OUTSIDE the transaction below
1873
+ // (VACUUM cannot run inside one). Best-effort; snapshotDb never throws.
1874
+ const willPurge = ops.includes('purge_stale') && (flags.confirm === true || flags.confirm === 'true');
1875
+ if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: willPurge }) > 0) {
1876
+ snapshotDb(db, { tag: 'pre-maintain' });
1877
+ }
1878
+
1870
1879
  db.transaction(() => {
1871
1880
  if (ops.includes('cleanup')) {
1872
1881
  const deleted = cleanupBroken(db, mctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.20.0",
3
+ "version": "3.21.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",
@@ -84,6 +84,7 @@
84
84
  "lib/search-core.mjs",
85
85
  "lib/rrf.mjs",
86
86
  "lib/compress-core.mjs",
87
+ "lib/db-backup.mjs",
87
88
  "lib/maintain-core.mjs",
88
89
  "lib/dedup-constants.mjs",
89
90
  "lib/deferred-work.mjs",
package/server.mjs CHANGED
@@ -18,9 +18,10 @@ import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './l
18
18
  import {
19
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
20
20
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
21
- recoverChildrenOf,
21
+ recoverChildrenOf, hardDeleteCandidateCount,
22
22
  OP_CAP, STALE_AGE_MS,
23
23
  } from './lib/maintain-core.mjs';
24
+ import { snapshotDb } from './lib/db-backup.mjs';
24
25
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
25
26
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
26
27
  import { formatObsFieldValue } from './cli/common.mjs';
@@ -1078,6 +1079,14 @@ server.registerTool(
1078
1079
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1079
1080
  }
1080
1081
 
1082
+ // MED-2: snapshot the DB before the irreversible cleanup/purge hard-deletes —
1083
+ // only when rows will actually be removed, and OUTSIDE the transaction below
1084
+ // (VACUUM cannot run inside one). purge_stale is already confirmed by here (the
1085
+ // preview branch returned above otherwise). Best-effort; snapshotDb never throws.
1086
+ if (hardDeleteCandidateCount(db, mctx, { cleanup: ops.includes('cleanup'), purge: ops.includes('purge_stale') }) > 0) {
1087
+ snapshotDb(db, { tag: 'pre-maintain' });
1088
+ }
1089
+
1081
1090
  db.transaction(() => {
1082
1091
  if (ops.includes('cleanup')) {
1083
1092
  const deleted = cleanupBroken(db, mctx);
package/source-files.mjs CHANGED
@@ -131,6 +131,10 @@ export const SOURCE_FILES = [
131
131
  // Statically imported by mem-cli.mjs (cmdMaintain), server.mjs (mem_maintain),
132
132
  // and hook.mjs (handleAutoMaintain) — missing it would break maintain on auto-update.
133
133
  'lib/maintain-core.mjs',
134
+ // Pre-maintenance VACUUM INTO snapshot (MED-2). Statically imported by mem-cli.mjs,
135
+ // server.mjs, and hook.mjs before their destructive purge/cleanup — missing it
136
+ // would crash maintain on auto-update with an unresolved import.
137
+ 'lib/db-backup.mjs',
134
138
  // P10 dedup/merge threshold constants — single source of truth for the Jaccard
135
139
  // dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
136
140
  // hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;