claude-mem-lite 3.47.0 → 3.48.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.47.0",
13
+ "version": "3.48.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.47.0",
3
+ "version": "3.48.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-context.mjs CHANGED
@@ -8,8 +8,9 @@ import {
8
8
  debugLog, debugCatch, neutralizeContextDelimiters,
9
9
  DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
10
10
  } from './utils.mjs';
11
- import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet } from './hook-shared.mjs';
11
+ import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks } from './hook-shared.mjs';
12
12
  import { extractUnfinishedSummary } from './hook-handoff.mjs';
13
+ import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
13
14
 
14
15
  /**
15
16
  * Infer the project directory from environment variables or cwd.
@@ -390,6 +391,24 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
390
391
  }
391
392
  }
392
393
 
394
+ // HIGH-1 (full audit 2026-07-16): surface recent high-importance events — the
395
+ // canonical store for promoted bugfix/decision/lesson memories that
396
+ // persistHaikuSummary upgrade-deletes out of observations. Without this section
397
+ // SessionStart never shows them. E# prefix keeps citation extractors (bare-`#`
398
+ // anchored) from reading an event id as an observation id. Gated on isQuietHooks()
399
+ // ONLY (explicit low-noise opt-out), NOT effectiveQuiet: unlike Key Context, events
400
+ // never appear in the obs-only Recent table and are absent from the MEMORY.md
401
+ // sentinel, so an adopted project (the default) would otherwise have zero
402
+ // SessionStart surface for them. Never throws (recentInjectableEvents catches).
403
+ if (!isQuietHooks()) {
404
+ const keyEvents = recentInjectableEvents(db, { project, limit: 5 });
405
+ if (keyEvents.length > 0) {
406
+ summaryLines.push('### Key Events');
407
+ for (const e of keyEvents) summaryLines.push(`- ${renderInjectableEvent(e)}`);
408
+ summaryLines.push('');
409
+ }
410
+ }
411
+
393
412
  // 5. Working state from latest /clear handoff.
394
413
  // Session scoping: when currentCcSessionId is provided, restrict to this session's
395
414
  // own clear handoff so parallel sessions don't see each other's Working State block.
package/hook.mjs CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attribution.mjs';
62
62
  import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
63
63
  import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
64
+ import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
64
65
  import { formatTaskImperative } from './lib/task-imperative.mjs';
65
66
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
66
67
  import { gcOldMetricShards } from './lib/metrics.mjs';
@@ -87,7 +88,7 @@ import { getVocabulary } from './tfidf.mjs';
87
88
  // Prevent recursive hooks from background claude -p calls
88
89
  // Background workers (llm-episode, llm-summary) are exempt — they're ours
89
90
  const event = process.argv[2];
90
- const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize']);
91
+ const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize', 'auto-maintain']);
91
92
 
92
93
  // Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
93
94
  // install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
@@ -420,7 +421,18 @@ function triggerErrorRecall(db, toolInput, response) {
420
421
  `).all(ftsQuery, project, nowR);
421
422
 
422
423
  const out = formatErrorRecallHints(rows);
423
- if (out) process.stdout.write(out);
424
+ if (out) {
425
+ // MED-3 (full audit 2026-07-16): emit via the JSON envelope (trailing '\n'),
426
+ // NOT raw stdout. A raw multi-line write corrupts a co-emitted episode-flush
427
+ // receipt (both write to PostToolUse stdout in the same call → `<text>{json}`)
428
+ // and is silently dropped on CC variants that ignore plain-text PostToolUse
429
+ // stdout. This is the same suppressOutput+additionalContext channel
430
+ // flushEpisode uses; two separate JSON lines each parse independently.
431
+ process.stdout.write(JSON.stringify({
432
+ suppressOutput: true,
433
+ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: out },
434
+ }) + '\n');
435
+ }
424
436
  } catch (e) { debugCatch(e, 'triggerErrorRecall'); }
425
437
  }
426
438
 
@@ -975,6 +987,29 @@ function runSessionStartAutoMaintain(db) {
975
987
  }
976
988
  }
977
989
 
990
+ // SessionStart boot path (MED-4): cheap 24h gate pre-check only. When maintenance is
991
+ // due, the heavy pass (VACUUM-INTO snapshot + purge/cleanup/decay/dedup) is handed to
992
+ // a detached `auto-maintain` worker via spawnBackground so it never blocks interactive
993
+ // session start. The worker re-checks the same gate (idempotent) before doing the work.
994
+ function scheduleSessionStartAutoMaintain() {
995
+ const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
996
+ try {
997
+ const last = JSON.parse(readFileSync(maintainFile, 'utf8'));
998
+ if (Date.now() - last.epoch < 24 * 3600000) return; // not due — no spawn
999
+ } catch { /* no gate file → due */ }
1000
+ if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain');
1001
+ }
1002
+
1003
+ // Detached `auto-maintain` worker entry: opens its own DB and runs the maintenance
1004
+ // pass off the interactive boot path. runSessionStartAutoMaintain still owns the 24h
1005
+ // gate + the compress/optimize spawns at its tail.
1006
+ function handleAutoMaintain() {
1007
+ const db = openDb();
1008
+ if (!db) return;
1009
+ try { runSessionStartAutoMaintain(db); }
1010
+ finally { try { db.close(); } catch { /* ignore */ } }
1011
+ }
1012
+
978
1013
  function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, ccSessionId, episodeSnapshot, now }) {
979
1014
  // Shared clear handoff reference — queried once, used by fast summary + working state
980
1015
  let prevClearHandoff = null;
@@ -1285,7 +1320,7 @@ async function handleSessionStart() {
1285
1320
 
1286
1321
  runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now });
1287
1322
 
1288
- runSessionStartAutoMaintain(db);
1323
+ scheduleSessionStartAutoMaintain();
1289
1324
 
1290
1325
  // ── Non-transactional operations (side effects, background work) ──
1291
1326
 
@@ -1520,6 +1555,21 @@ async function handleUserPrompt() {
1520
1555
  lines.push('</memory-context>');
1521
1556
  process.stdout.write(lines.join('\n') + '\n');
1522
1557
  }
1558
+ // HIGH-1 (full audit 2026-07-16): surface FTS-matched events — the canonical
1559
+ // store for promoted bugfix/decision/lesson memories that persistHaikuSummary
1560
+ // upgrade-deletes out of observations. Without this leg they are unreachable at
1561
+ // prompt time. Separate E#-tagged block so it doesn't perturb observation
1562
+ // ranking and citation extractors (bare-`#` anchored) never read an event id as
1563
+ // an obs id. Nested try so an events failure can't suppress the imperative pick.
1564
+ try {
1565
+ const events = searchInjectableEvents(db, { prompt: promptText, project });
1566
+ if (events.length > 0) {
1567
+ const elines = ['<memory-context relevance="events">'];
1568
+ for (const e of events) elines.push(`- ${renderInjectableEvent(e)}`);
1569
+ elines.push('</memory-context>');
1570
+ process.stdout.write(elines.join('\n') + '\n');
1571
+ }
1572
+ } catch (e) { debugCatch(e, 'handleUserPrompt-events'); }
1523
1573
  if (imperativePick) {
1524
1574
  // Guard the write on a non-empty return — formatTaskImperative yields '' for a
1525
1575
  // lesson that strips to empty (e.g. "."), which would otherwise emit a bare line.
@@ -1639,6 +1689,7 @@ try {
1639
1689
  case 'llm-episode': await handleLLMEpisode(); break;
1640
1690
  case 'llm-summary': await handleLLMSummary(); break;
1641
1691
  case 'auto-compress': handleAutoCompress(); break;
1692
+ case 'auto-maintain': handleAutoMaintain(); break;
1642
1693
  case 'llm-optimize': await handleLLMOptimize(); break;
1643
1694
  // Detached update refresh spawned by handleSessionStart (audit P3d) — does the
1644
1695
  // GitHub fetch + (non-plugin) install off the SessionStart critical path,
@@ -707,7 +707,13 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
707
707
  }
708
708
  }
709
709
  });
710
- try { txn(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
710
+ // IMMEDIATE (not the default DEFERRED): this txn reads each obs then writes it.
711
+ // Under concurrent same-project Stop hooks a DEFERRED txn pins a read snapshot
712
+ // first, and the second session's write then hits SQLITE_BUSY_SNAPSHOT — a
713
+ // snapshot-upgrade conflict busy_timeout cannot resolve — silently dropping the
714
+ // whole decay pass (promotes/demotes/streak + the paired funnel deltas). Taking
715
+ // the write lock up front lets busy_timeout actually serialize the two sessions.
716
+ try { txn.immediate(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
711
717
  return { promoted, demoted, touched };
712
718
  }
713
719
 
@@ -0,0 +1,72 @@
1
+ // lib/delete-core.mjs — shared hard-delete orchestration for the CLI `delete`
2
+ // command (mem-cli.mjs cmdDelete) and the MCP `mem_delete` tool (server.mjs).
3
+ // Both surfaces previously inlined a byte-for-byte-equivalent copy of this logic,
4
+ // kept in sync by "aligned with MCP mem_delete" / "matching the CLI delete" parity
5
+ // comments — the project's documented #1 drift risk. Consolidated here so there is
6
+ // one implementation: pre-delete snapshot, stale related_ids cleanup,
7
+ // merged/compressed-child recovery, and the delete transaction.
8
+ import { snapshotDb } from './db-backup.mjs';
9
+ import { recoverChildrenOf } from './maintain-core.mjs';
10
+ import { debugCatch } from '../utils.mjs';
11
+
12
+ /**
13
+ * Hard-delete the given observation ids with full orchestration. The CALLER owns
14
+ * the confirm/preview gating and fetches whatever rows it needs to render (existence
15
+ * check, preview list, missing-id note); this performs only the irreversible mutation
16
+ * once the caller has decided to proceed.
17
+ *
18
+ * Order (preserved from the pre-extraction inline paths):
19
+ * 1. snapshotDb OUTSIDE any transaction (VACUUM INTO cannot run inside one). Best-
20
+ * effort: no-op on :memory:, never throws.
21
+ * 2. In ONE transaction: strip the deleted ids out of other rows' related_ids
22
+ * (coarse LIKE pre-filter → JSON-parse precise filter), recover children
23
+ * merged/compressed INTO the doomed rows (compressed_into has no FK), then DELETE.
24
+ *
25
+ * @param {import('better-sqlite3').Database} db
26
+ * @param {number[]} ids observation ids to delete
27
+ * @param {{ snapshotTag?: string }} [opts]
28
+ * @returns {{ deleted: number, recoveredChildren: number, snapshotPath: string|null }}
29
+ */
30
+ export function deleteObservations(db, ids, { snapshotTag = 'pre-delete' } = {}) {
31
+ const placeholders = ids.map(() => '?').join(',');
32
+
33
+ // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
34
+ // matching the maintain purge/cleanup hard-delete paths (audit MED-2). Best-effort
35
+ // (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM INTO).
36
+ const snapshotPath = snapshotDb(db, { tag: snapshotTag });
37
+
38
+ const deletedIds = new Set(ids);
39
+ const deleteTx = db.transaction(() => {
40
+ // Clean up stale references in other observations' related_ids.
41
+ // LIKE %id% is a coarse pre-filter (false positives: %1% matches [10], [21]) — it only
42
+ // narrows which rows to fetch; the JSON parse + Set.has below is the precise filter.
43
+ // Acceptable because observation count per user is typically <10K.
44
+ const likeConditions = ids.map(() => `related_ids LIKE ?`).join(' OR ');
45
+ const likeParams = ids.map(id => `%${id}%`);
46
+ const referencing = db.prepare(`
47
+ SELECT id, related_ids FROM observations
48
+ WHERE related_ids IS NOT NULL AND related_ids != '[]' AND (${likeConditions})
49
+ `).all(...likeParams);
50
+ for (const r of referencing) {
51
+ let refIds;
52
+ try { refIds = JSON.parse(r.related_ids); } catch (e) { debugCatch(e, 'deleteRelatedIds'); continue; }
53
+ // Only rewrite a well-formed integer array; a malformed related_ids value is left
54
+ // untouched rather than reshaped by the filter (the stricter of the two originals).
55
+ if (!Array.isArray(refIds) || !refIds.every(id => Number.isInteger(id))) continue;
56
+ const filtered = refIds.filter(id => !deletedIds.has(id));
57
+ if (filtered.length !== refIds.length) {
58
+ db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
59
+ }
60
+ }
61
+ // Resurface rows merged/compressed INTO the doomed keepers before deleting, else they
62
+ // dangle behind a now-missing parent (compressed_into has no FK) — invisible to every
63
+ // COALESCE(compressed_into,0)=0 view and unrecoverable. Same guard the maintain
64
+ // hard-delete paths use (recoverChildrenOf).
65
+ const recovered = recoverChildrenOf(db, ids);
66
+ // Execute deletion (FTS5 cleanup handled by the observations_ad trigger).
67
+ const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
68
+ return { changes: deleted.changes, recovered };
69
+ });
70
+ const result = deleteTx();
71
+ return { deleted: result.changes, recoveredChildren: result.recovered, snapshotPath };
72
+ }
@@ -0,0 +1,91 @@
1
+ // lib/events-injection.mjs — surface `events` into the passive injection surfaces.
2
+ //
3
+ // HIGH-1 (full audit 2026-07-16): persistHaikuSummary upgrade-deletes every
4
+ // event-typed memory (bugfix/decision/lesson/discovery/…) out of `observations`
5
+ // and into the `events` table, so after Haiku enrichment the high-value history
6
+ // lives ONLY in events. v3.44 wired events into mem_search + PreToolUse recall,
7
+ // but the three PASSIVE injection surfaces (SessionStart context, UserPromptSubmit
8
+ // path A = user-prompt-search.js, path B = hook.mjs memory-context) still read only
9
+ // observations — so the promoted memories were unreachable at prompt/session time.
10
+ //
11
+ // id-space discipline: events share the numeric id space with observations but are a
12
+ // separate table, so injected events are rendered with an `E#` prefix. The
13
+ // citation-tracker injected-id extractors all anchor on a BARE `#`
14
+ // (FYI `/^#\d/`, memory-context `/\(#\d/`, error-recall row-anchored), so an `E#`
15
+ // row is never mis-attributed to an observation id (which would let citation decay
16
+ // mutate an unrelated observation sharing that id). Events carry no citation columns,
17
+ // so they inject as reference-only — reachability, not decay bookkeeping.
18
+
19
+ import { searchEventsFts } from './search-core.mjs';
20
+ import { neutralizeContextDelimiters } from '../format-utils.mjs';
21
+ import { sanitizeFtsQuery } from '../utils.mjs';
22
+
23
+ const DEFAULT_LIMIT = 3;
24
+ const DEFAULT_MIN_IMPORTANCE = 2;
25
+ const TITLE_MAX = 80;
26
+ const LESSON_MAX = 160;
27
+
28
+ function normalizeRow(r) {
29
+ return {
30
+ id: r.id,
31
+ type: r.event_type || r.type || 'event',
32
+ title: r.title || '',
33
+ lesson_learned: (r.body ?? r.lesson_learned) || null,
34
+ importance: r.importance,
35
+ created_at_epoch: r.created_at_epoch,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * FTS-matched events for a prompt (UserPromptSubmit surfaces). Superseded events are
41
+ * excluded by searchEventsFts; importance floor drops low-value rows. Never throws.
42
+ * @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
43
+ */
44
+ export function searchInjectableEvents(db, { prompt, ftsQuery, project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
45
+ if (!db || !project) return [];
46
+ const q = ftsQuery || (prompt ? sanitizeFtsQuery(prompt) : null);
47
+ if (!q) return [];
48
+ try {
49
+ const rows = searchEventsFts(db, {
50
+ ftsQuery: q, project, projectBoost: project,
51
+ importance: minImportance, perSourceLimit: limit,
52
+ });
53
+ return rows.map(normalizeRow);
54
+ } catch { return []; }
55
+ }
56
+
57
+ /**
58
+ * Recency + importance events (SessionStart context — no FTS query available).
59
+ * Excludes superseded events. Never throws.
60
+ * @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
61
+ */
62
+ export function recentInjectableEvents(db, { project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
63
+ if (!db || !project) return [];
64
+ try {
65
+ const rows = db.prepare(`
66
+ SELECT id, event_type, title, body, importance, created_at_epoch
67
+ FROM events
68
+ WHERE project = ?
69
+ AND superseded_at_epoch IS NULL
70
+ AND COALESCE(importance, 1) >= ?
71
+ ORDER BY created_at_epoch DESC
72
+ LIMIT ?
73
+ `).all(project, minImportance, limit);
74
+ return rows.map(normalizeRow);
75
+ } catch { return []; }
76
+ }
77
+
78
+ /**
79
+ * Render one injectable event as a defanged `E#<id> [<type>] <title> — <lesson>` line.
80
+ * The E# prefix keeps citation-tracker extractors (which anchor on a bare `#`) from
81
+ * mis-reading the event id as an observation id.
82
+ */
83
+ export function renderInjectableEvent(row) {
84
+ const title = neutralizeContextDelimiters((row.title || '').slice(0, TITLE_MAX));
85
+ const head = `E#${row.id} [${row.type}] ${title}`;
86
+ if (row.lesson_learned) {
87
+ const lesson = neutralizeContextDelimiters(row.lesson_learned.trim().slice(0, LESSON_MAX));
88
+ if (lesson) return `${head} — ${lesson}`;
89
+ }
90
+ return head;
91
+ }
@@ -245,17 +245,33 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
245
245
  `).all(...params);
246
246
  }
247
247
 
248
+ // A lone match has no within-source spread to normalize against. Clamp it to
249
+ // the neutral middle of the [-1, 0] band (MED-5): leaving its raw BM25 magnitude
250
+ // (a title hit can reach -8..-40) let one incidental single-source match — and
251
+ // `event` is now a common low-cardinality source — outrank an entire page of
252
+ // strongly-matched, normalized rows from another source. -0.5 is magnitude-
253
+ // independent, so it fixes both the dominating strong lone match and does not
254
+ // inflate a weak one to -1.0 (the reason single-row sources were previously
255
+ // skipped). Global-max normalization would NOT fix a lone match that IS the
256
+ // global max, so a fixed neutral value is used instead.
257
+ const NEUTRAL_SINGLE_MATCH_SCORE = -0.5;
258
+
248
259
  /**
249
260
  * Normalize each source's BM25 scores to [-1, 0] before cross-source merge.
250
261
  * Prevents observations (BM25 can reach -40) from systematically outranking
251
- * sessions (-6) and prompts (-1) regardless of relevance. Sources with a
252
- * single scored row are skipped normalizing would inflate a weak match to
253
- * -1.0. Mutates `results` in place; callers re-sort afterwards.
262
+ * sessions (-6) and prompts (-1) regardless of relevance. A source with a single
263
+ * scored row is clamped to the neutral mid (see NEUTRAL_SINGLE_MATCH_SCORE)
264
+ * it has no within-source spread to normalize against. Mutates `results` in
265
+ * place; callers re-sort afterwards.
254
266
  */
255
267
  export function normalizeCrossSourceScores(results, sourceKey) {
256
268
  for (const src of ['obs', 'session', 'prompt', 'event']) {
257
269
  const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined);
258
- if (srcResults.length < 2) continue;
270
+ if (srcResults.length === 0) continue;
271
+ if (srcResults.length === 1) {
272
+ srcResults[0].score = NEUTRAL_SINGLE_MATCH_SCORE;
273
+ continue;
274
+ }
259
275
  const maxAbs = Math.max(...srcResults.map((r) => Math.abs(r.score)));
260
276
  if (maxAbs > 0) {
261
277
  for (const r of srcResults) r.score = r.score / maxAbs;
package/mem-cli.mjs CHANGED
@@ -20,10 +20,11 @@ import {
20
20
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
21
21
  recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
22
22
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
23
- recoverChildrenOf, hardDeleteCandidateCount,
23
+ hardDeleteCandidateCount,
24
24
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
25
25
  } from './lib/maintain-core.mjs';
26
26
  import { snapshotDb } from './lib/db-backup.mjs';
27
+ import { deleteObservations } from './lib/delete-core.mjs';
27
28
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
28
29
  import { buildSessionContextLines } from './hook-context.mjs';
29
30
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
@@ -1508,43 +1509,13 @@ function cmdDelete(db, args) {
1508
1509
  return;
1509
1510
  }
1510
1511
 
1511
- // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
1512
- // matching the maintain purge/cleanup hard-delete paths (audit MED-2). Best-effort
1513
- // (never throws, skips :memory:); rows here are confirmed + non-empty, so the delete
1514
- // always removes something worth backing up. Must run OUTSIDE the transaction (VACUUM).
1515
- snapshotDb(db, { tag: 'pre-delete' });
1516
-
1517
- // Transaction: clean up related_ids references + delete (aligned with MCP mem_delete)
1518
- const deletedIds = new Set(ids);
1519
- const deleteTx = db.transaction(() => {
1520
- const likeConditions = ids.map(() => `related_ids LIKE ?`).join(' OR ');
1521
- const likeParams = ids.map(id => `%${id}%`);
1522
- const referencing = db.prepare(`
1523
- SELECT id, related_ids FROM observations
1524
- WHERE related_ids IS NOT NULL AND related_ids != '[]' AND (${likeConditions})
1525
- `).all(...likeParams);
1526
- for (const r of referencing) {
1527
- let refIds;
1528
- try { refIds = JSON.parse(r.related_ids); } catch { continue; }
1529
- if (!Array.isArray(refIds)) continue;
1530
- const filtered = refIds.filter(id => !deletedIds.has(id));
1531
- if (filtered.length !== refIds.length) {
1532
- db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
1533
- }
1534
- }
1535
- // Resurface any rows merged/compressed INTO the doomed keepers before deleting,
1536
- // else they dangle behind a missing parent (compressed_into has no FK) — invisible
1537
- // to every COALESCE(compressed_into,0)=0 view and unrecoverable. Same guard the
1538
- // maintain hard-delete paths use (recoverChildrenOf); the interactive delete path
1539
- // was missing it. Returned in the result so the user sees the recovery count.
1540
- const recovered = recoverChildrenOf(db, ids);
1541
- const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
1542
- return { changes: deleted.changes, recovered };
1543
- });
1544
- const result = deleteTx();
1512
+ // Full delete orchestration (snapshot + related_ids cleanup + child recovery + delete
1513
+ // transaction) lives in lib/delete-core.mjs single source of truth shared with the MCP
1514
+ // mem_delete path (was inlined here + kept in sync by parity comments, the #1 drift risk).
1515
+ const result = deleteObservations(db, ids);
1545
1516
  const missing = ids.filter(id => !rows.some(r => r.id === id));
1546
- const recoveredNote = result.recovered > 0 ? ` Recovered ${result.recovered} merged/compressed child observation(s) to live.` : '';
1547
- out(`[mem] Deleted ${result.changes} observation(s).${recoveredNote}${missing.length > 0 ? ` Note: ID(s) ${missing.join(', ')} not found.` : ''}`);
1517
+ const recoveredNote = result.recoveredChildren > 0 ? ` Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.` : '';
1518
+ out(`[mem] Deleted ${result.deleted} observation(s).${recoveredNote}${missing.length > 0 ? ` Note: ID(s) ${missing.join(', ')} not found.` : ''}`);
1548
1519
  }
1549
1520
 
1550
1521
  // ─── Update ──────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.47.0",
3
+ "version": "3.48.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",
@@ -90,6 +90,8 @@
90
90
  "lib/rrf.mjs",
91
91
  "lib/compress-core.mjs",
92
92
  "lib/db-backup.mjs",
93
+ "lib/delete-core.mjs",
94
+ "lib/events-injection.mjs",
93
95
  "lib/maintain-core.mjs",
94
96
  "lib/dedup-constants.mjs",
95
97
  "lib/deferred-work.mjs",
@@ -15,6 +15,7 @@ import { fileIntelFor } from '../lib/file-intel.mjs';
15
15
  import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
16
16
  import { recordMetric } from '../lib/metrics.mjs';
17
17
  import { presentIdents } from '../lib/lesson-idents.mjs';
18
+ import { neutralizeContextDelimiters } from '../format-utils.mjs';
18
19
 
19
20
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
20
21
  // whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
@@ -519,7 +520,14 @@ try {
519
520
  // when the model misreads passive lesson context as a closing note.
520
521
  lines.push(`[mem] PreToolUse recall — system-injected context, continue your planned action:`);
521
522
  }
522
- if (fileIntelLine) lines.push(fileIntelLine);
523
+ // MED-1 (full audit 2026-07-16): defang the injection-block delimiters in
524
+ // all DB/file-derived text before it enters additionalContext (which CC wraps
525
+ // in <system-reminder>). Stored text is raw — a lesson/title/summary carrying
526
+ // a literal </system-reminder> or forged <invoke ...> would break the wrapper
527
+ // and inject a privileged instruction. Mirrors formatErrorRecallHints, which
528
+ // already applies the same guard on the parallel error-recall surface. The
529
+ // `#id [type]` prefix is agent-generated (numeric id + type enum) — no defang.
530
+ if (fileIntelLine) lines.push(neutralizeContextDelimiters(fileIntelLine));
523
531
  if (hasLessons) {
524
532
  lines.push(`[mem] Lessons for ${fname}:`);
525
533
  for (const r of allRows) {
@@ -527,12 +535,12 @@ try {
527
535
  const lesson = r.lesson_learned.length > LESSON_MAX
528
536
  ? r.lesson_learned.slice(0, LESSON_MAX - 3) + '...'
529
537
  : r.lesson_learned;
530
- lines.push(` #${r.id} [${r.type}] ${lesson}`);
538
+ lines.push(` #${r.id} [${r.type}] ${neutralizeContextDelimiters(lesson)}`);
531
539
  } else {
532
540
  const title = (r.title || '').length > LESSON_MAX
533
541
  ? r.title.slice(0, LESSON_MAX - 3) + '...'
534
542
  : (r.title || '');
535
- lines.push(` #${r.id} [${r.type}] ${title}`);
543
+ lines.push(` #${r.id} [${r.type}] ${neutralizeContextDelimiters(title)}`);
536
544
  }
537
545
  }
538
546
  // v2.98 salience: Edit/Write is the action point — close the block with an
@@ -544,7 +552,7 @@ try {
544
552
  const changeText = [toolInput?.old_string, toolInput?.new_string, toolInput?.content]
545
553
  .filter(Boolean).join('\n');
546
554
  const bridged = await bridgeTopLesson(allRows, changeText);
547
- if (bridged) lines.push(`[mem] ⚠ #${bridged.id} → this edit must: ${bridged.check}. Confirm your new code satisfies it.`);
555
+ if (bridged) lines.push(`[mem] ⚠ #${bridged.id} → this edit must: ${neutralizeContextDelimiters(bridged.check)}. Confirm your new code satisfies it.`);
548
556
  else lines.push(`[mem] ⚠ Before this edit: ${ACTIVE_DIRECTIVE}`);
549
557
  }
550
558
  } else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
package/server.mjs CHANGED
@@ -19,10 +19,11 @@ import {
19
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
20
20
  recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
21
21
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
22
- recoverChildrenOf, hardDeleteCandidateCount,
22
+ hardDeleteCandidateCount,
23
23
  OP_CAP, STALE_AGE_MS,
24
24
  } from './lib/maintain-core.mjs';
25
25
  import { snapshotDb } from './lib/db-backup.mjs';
26
+ import { deleteObservations } from './lib/delete-core.mjs';
26
27
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
27
28
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
28
29
  import { formatObsFieldValue } from './cli/common.mjs';
@@ -680,49 +681,14 @@ server.registerTool(
680
681
  return { content: [{ type: 'text', text: lines.join('\n') }] };
681
682
  }
682
683
 
683
- // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
684
- // matching the CLI delete + maintain purge/cleanup paths (audit MED-2). Best-effort
685
- // (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM).
686
- snapshotDb(db, { tag: 'pre-delete' });
687
-
688
- // Wrap cleanup + deletion in a transaction for consistency
689
- const deletedIds = new Set(args.ids);
690
- const deleteTx = db.transaction(() => {
691
- // Clean up stale references in other observations' related_ids
692
- // Use LIKE filter to avoid O(N) full-table scan — only fetch rows that may reference deleted IDs.
693
- // NOTE: LIKE %id% has false positives (e.g. %1% matches [10], [21]). This is intentional —
694
- // the LIKE is a coarse pre-filter; the JSON parse + Set.has below is the precise filter.
695
- // Acceptable because observation count per user is typically <10K.
696
- const likeConditions = args.ids.map(() => `related_ids LIKE ?`).join(' OR ');
697
- const likeParams = args.ids.map(id => `%${id}%`);
698
- const referencing = db.prepare(`
699
- SELECT id, related_ids FROM observations
700
- WHERE related_ids IS NOT NULL AND related_ids != '[]'
701
- AND (${likeConditions})
702
- `).all(...likeParams);
703
- for (const r of referencing) {
704
- let ids;
705
- try { ids = JSON.parse(r.related_ids); } catch (e) { debugCatch(e, 'deleteRelatedIds'); continue; }
706
- if (!Array.isArray(ids) || !ids.every(id => Number.isInteger(id))) continue;
707
- const filtered = ids.filter(id => !deletedIds.has(id));
708
- if (filtered.length !== ids.length) {
709
- db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
710
- }
711
- }
712
- // Resurface rows merged/compressed INTO the doomed keepers before deleting, else
713
- // they dangle behind a now-missing parent (compressed_into has no FK) — invisible
714
- // to every COALESCE(compressed_into,0)=0 view and unrecoverable. Mirrors the CLI
715
- // delete path + the maintain hard-delete guard (recoverChildrenOf).
716
- const recovered = recoverChildrenOf(db, args.ids);
717
- // Execute deletion (FTS5 cleanup handled by observations_ad trigger)
718
- const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...args.ids);
719
- return { changes: deleted.changes, recovered };
720
- });
721
- const result = deleteTx();
684
+ // Full delete orchestration (snapshot + related_ids cleanup + child recovery +
685
+ // delete transaction) lives in lib/delete-core.mjs single source of truth shared
686
+ // with the CLI `delete` path (was inlined + kept in sync by parity comments).
687
+ const result = deleteObservations(db, args.ids);
722
688
 
723
689
  const missing = args.ids.filter(id => !rows.some(r => r.id === id));
724
- const msg = [`Deleted ${result.changes} observation(s).`];
725
- if (result.recovered > 0) msg.push(`Recovered ${result.recovered} merged/compressed child observation(s) to live.`);
690
+ const msg = [`Deleted ${result.deleted} observation(s).`];
691
+ if (result.recoveredChildren > 0) msg.push(`Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`);
726
692
  if (missing.length > 0) msg.push(`Note: ID(s) ${missing.join(', ')} not found.`);
727
693
  return { content: [{ type: 'text', text: msg.join(' ') }] };
728
694
  })
package/source-files.mjs CHANGED
@@ -152,6 +152,16 @@ export const SOURCE_FILES = [
152
152
  // server.mjs, and hook.mjs before their destructive purge/cleanup — missing it
153
153
  // would crash maintain on auto-update with an unresolved import.
154
154
  'lib/db-backup.mjs',
155
+ // HIGH-1 events-injection: surfaces the `events` canonical store into the passive
156
+ // injection surfaces. Statically imported by hook.mjs (UserPromptSubmit) and
157
+ // hook-context.mjs (SessionStart) — missing it from the manifest would break both
158
+ // hooks on auto-update.
159
+ 'lib/events-injection.mjs',
160
+ // Shared delete orchestration (snapshot + related_ids cleanup + child recovery
161
+ // + delete txn). Statically imported by server.mjs (mem_delete) and mem-cli.mjs
162
+ // (cmdDelete) — extracted to kill the byte-duplicated twin. Missing it from the
163
+ // manifest would leave both delete surfaces unsigned/broken on auto-update.
164
+ 'lib/delete-core.mjs',
155
165
  // P10 dedup/merge threshold constants — single source of truth for the Jaccard
156
166
  // dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
157
167
  // hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;