claude-mem-lite 3.47.0 → 3.49.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.
@@ -0,0 +1,113 @@
1
+ // lib/stats-core.mjs — shared primary stats feed for CLI `stats` and MCP `mem_stats`.
2
+ //
3
+ // Audit 2026-07-17 MED-4: these ~15 COUNT/GROUP-BY queries were hand-copied
4
+ // byte-equivalent between server.mjs (mem_stats) and mem-cli.mjs (cmdStats) — the
5
+ // same twin-drift class the `--quality` sub-report already closed via
6
+ // lib/stats-quality.mjs, and delete orchestration via lib/delete-core.mjs. One
7
+ // computation, two renderers: callers keep their own formatting (MCP text block /
8
+ // CLI console+JSON) plus any surface-only extras (CLI hookErrors24h).
9
+ import { inferProject } from '../utils.mjs';
10
+ import { buildNotLowSignalSql } from './low-signal-patterns.mjs';
11
+ import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
12
+ import { computeNoiseGauge } from './stats-quality.mjs';
13
+
14
+ /**
15
+ * Compute the primary stats feed. Row shapes are returned exactly as the twin
16
+ * blocks produced them ({c}/{t}/{v} single-row objects, arrays for distributions)
17
+ * so both call sites render with minimal diff.
18
+ */
19
+ export function computeStatsFeed(db, { project = null, days = 30, now = Date.now() } = {}) {
20
+ const cutoff = now - days * 86400000;
21
+ const projectFilter = project ? 'AND project = ?' : '';
22
+ const baseParams = project ? [project] : [];
23
+
24
+ // Total counts (session_summaries, not sdk_sessions — CLI↔MCP aligned)
25
+ const obsTotal = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
26
+ const sessTotal = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`).get(...baseParams);
27
+ const promptTotal = project
28
+ ? db.prepare('SELECT COUNT(*) as c FROM user_prompts p JOIN sdk_sessions s ON p.content_session_id = s.content_session_id WHERE s.project = ?').get(project)
29
+ : db.prepare('SELECT COUNT(*) as c FROM user_prompts').get();
30
+
31
+ // Recent counts
32
+ const obsRecent = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
33
+ const sessRecent = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
34
+
35
+ // Type distribution (recent)
36
+ const types = db.prepare(`
37
+ SELECT type, COUNT(*) as c FROM observations
38
+ WHERE created_at_epoch >= ? ${projectFilter}
39
+ GROUP BY type ORDER BY c DESC
40
+ `).all(cutoff, ...baseParams);
41
+
42
+ // Top projects (global view — skipped when filtering by single project)
43
+ const projects = project ? [] : db.prepare(`
44
+ SELECT project, COUNT(*) as c FROM observations
45
+ GROUP BY project ORDER BY c DESC LIMIT 20
46
+ `).all();
47
+
48
+ // Daily activity (last 7 days)
49
+ const daily = db.prepare(`
50
+ SELECT date(created_at) as day, COUNT(*) as c FROM observations
51
+ WHERE created_at_epoch >= ? ${projectFilter}
52
+ GROUP BY day ORDER BY day DESC LIMIT 7
53
+ `).all(now - 7 * 86400000, ...baseParams);
54
+
55
+ // Data health
56
+ const tokenEst = db.prepare(`
57
+ SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
58
+ FROM observations WHERE 1=1 ${projectFilter}
59
+ `).get(...baseParams);
60
+ const avgImp = db.prepare(`SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
61
+
62
+ const thirtyDaysAgo = now - 30 * 86400000;
63
+ // v3.23 noise-gauge de-blinding: `<= 1` makes the imp=0 dormant population visible
64
+ // (decay floor + LLM low-signal filter push ~half the live corpus to 0);
65
+ // injection_count=0 mirrors decay's NEVER-INJECTED guard so injected-but-decayed
66
+ // pinned noise isn't miscounted as "never used".
67
+ const lowVal = db.prepare(`
68
+ SELECT COUNT(*) as c FROM observations
69
+ WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
70
+ AND COALESCE(injection_count,0) = 0
71
+ AND COALESCE(compressed_into, 0) = 0
72
+ AND created_at_epoch < ? ${projectFilter}
73
+ `).get(thirtyDaysAgo, ...baseParams);
74
+ // Low-signal-title population (template / tool-log titles the read-side filter
75
+ // already excludes). The imp≤1 "Low-value" metric can't see these, so the gauge
76
+ // under-reports real noise without it. Same source as lib/low-signal-patterns.mjs.
77
+ const lowSignalTitle = db.prepare(`
78
+ SELECT COUNT(*) as c FROM observations
79
+ WHERE NOT ${buildNotLowSignalSql()}
80
+ AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
81
+ `).get(...baseParams);
82
+ // F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
83
+ // obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
84
+ const liveTotal = db.prepare(
85
+ `SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
86
+ ).get(...baseParams);
87
+ const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
88
+ const compressedCount = db.prepare(
89
+ `SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
90
+ ).get(...baseParams);
91
+ const supersededOnlyCount = db.prepare(
92
+ `SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
93
+ ).get(...baseParams);
94
+
95
+ // Tier distribution
96
+ const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
97
+ const tdParams = tierSqlParams(tierCtx);
98
+ const tierDist = db.prepare(`
99
+ SELECT tier, COUNT(*) as c FROM (
100
+ SELECT ${TIER_CASE_SQL} as tier FROM observations
101
+ WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
102
+ ) GROUP BY tier ORDER BY tier
103
+ `).all(...tdParams, ...baseParams);
104
+ const tierMap = Object.fromEntries(tierDist.map((r) => [r.tier, r.c]));
105
+
106
+ return {
107
+ obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
108
+ types, projects, daily,
109
+ tokenEst, avgImp, lowVal, lowSignalTitle, liveTotal,
110
+ noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount,
111
+ tierMap,
112
+ };
113
+ }
package/mem-cli.mjs CHANGED
@@ -15,15 +15,18 @@ import { ensureRegistryDb, upsertResource } from './registry.mjs';
15
15
  import { searchResources } from './registry-retriever.mjs';
16
16
  import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
17
17
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
18
- import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
19
18
  import {
20
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
21
20
  recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
22
21
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
23
- recoverChildrenOf, hardDeleteCandidateCount,
22
+ hardDeleteCandidateCount,
24
23
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
25
24
  } from './lib/maintain-core.mjs';
26
25
  import { snapshotDb } from './lib/db-backup.mjs';
26
+ import { deleteObservations } from './lib/delete-core.mjs';
27
+ import { OBS_TYPE_SET } from './lib/obs-types.mjs';
28
+ import { computeStatsFeed } from './lib/stats-core.mjs';
29
+ import { buildLessonNudge } from './lib/save-nudge.mjs';
27
30
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
28
31
  import { buildSessionContextLines } from './hook-context.mjs';
29
32
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
@@ -41,7 +44,6 @@ import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatP
41
44
  import { saveObservation } from './lib/save-observation.mjs';
42
45
  import { rebuildObservationDerived, normalizeScope } from './lib/observation-write.mjs';
43
46
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
44
- import { computeNoiseGauge } from './lib/stats-quality.mjs';
45
47
  import { recallByFile } from './lib/recall-core.mjs';
46
48
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
47
49
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
@@ -71,7 +73,7 @@ async function cmdSearch(db, args, { llm } = {}) {
71
73
 
72
74
  const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
73
75
  const type = flags.type || null;
74
- const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
76
+ const validObsTypes = OBS_TYPE_SET;
75
77
  if (type && !validObsTypes.has(type)) {
76
78
  fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
77
79
  return;
@@ -368,7 +370,7 @@ function cmdRecent(db, args) {
368
370
  // try this for "show recent bugfixes". Mirror cmdSearch's enum validation.
369
371
  const type = flags.type || null;
370
372
  if (type) {
371
- const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
373
+ const validObsTypes = OBS_TYPE_SET;
372
374
  if (!validObsTypes.has(type)) {
373
375
  fail(`[mem] Invalid --type "${type}". Valid: ${[...validObsTypes].join(', ')}`);
374
376
  return;
@@ -802,7 +804,7 @@ function cmdSave(db, args) {
802
804
  if (rejectBareStringFlags(flags, ['title', 'files', 'lesson', 'lesson-learned', 'project', 'type'])) return;
803
805
 
804
806
  const type = flags.type || 'discovery';
805
- const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
807
+ const validTypes = OBS_TYPE_SET;
806
808
  if (!validTypes.has(type)) {
807
809
  fail(`[mem] Invalid type "${type}". Valid: ${[...validTypes].join(', ')}`);
808
810
  return;
@@ -911,7 +913,7 @@ function cmdSave(db, args) {
911
913
  const supersededNote = result.supersededIds && result.supersededIds.length > 0
912
914
  ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
913
915
  : '';
914
- out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}`);
916
+ out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}${buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'cli' })}`);
915
917
  }
916
918
 
917
919
  // ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
@@ -1111,98 +1113,12 @@ async function cmdStats(db, args) {
1111
1113
  return;
1112
1114
  }
1113
1115
 
1114
- const projectFilter = project ? 'AND project = ?' : '';
1115
- const baseParams = project ? [project] : [];
1116
-
1117
1116
  const now = Date.now();
1118
- const cutoff = now - days * 86400000;
1119
-
1120
- // Total counts (aligned with MCP mem_stats: use session_summaries, not sdk_sessions)
1121
- const obsTotal = db.prepare(
1122
- `SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`
1123
- ).get(...baseParams);
1124
- const sessTotal = db.prepare(
1125
- `SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`
1126
- ).get(...baseParams);
1127
- const promptTotal = project
1128
- ? db.prepare('SELECT COUNT(*) as c FROM user_prompts p JOIN sdk_sessions s ON p.content_session_id = s.content_session_id WHERE s.project = ?').get(project)
1129
- : db.prepare('SELECT COUNT(*) as c FROM user_prompts').get();
1130
-
1131
- // Recent counts
1132
- const obsRecent = db.prepare(
1133
- `SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`
1134
- ).get(cutoff, ...baseParams);
1135
- const sessRecent = db.prepare(
1136
- `SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`
1137
- ).get(cutoff, ...baseParams);
1138
-
1139
- // Type distribution (recent)
1140
- const types = db.prepare(`
1141
- SELECT type, COUNT(*) as c FROM observations
1142
- WHERE created_at_epoch >= ? ${projectFilter}
1143
- GROUP BY type ORDER BY c DESC
1144
- `).all(cutoff, ...baseParams);
1145
-
1146
- // Top projects (global view — skipped when filtering by single project; aligned with MCP)
1147
- const projects = project ? [] : db.prepare(`
1148
- SELECT project, COUNT(*) as c FROM observations
1149
- GROUP BY project ORDER BY c DESC LIMIT 20
1150
- `).all();
1151
-
1152
- // Daily activity (last 7 days; aligned with MCP mem_stats)
1153
- const daily = db.prepare(`
1154
- SELECT date(created_at) as day, COUNT(*) as c FROM observations
1155
- WHERE created_at_epoch >= ? ${projectFilter}
1156
- GROUP BY day ORDER BY day DESC LIMIT 7
1157
- `).all(now - 7 * 86400000, ...baseParams);
1158
-
1159
- // Data health (aligned with MCP mem_stats)
1160
- const tokenEst = db.prepare(`
1161
- SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
1162
- FROM observations WHERE 1=1 ${projectFilter}
1163
- `).get(...baseParams);
1164
- const avgImp = db.prepare(
1165
- `SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`
1166
- ).get(...baseParams);
1167
- const thirtyDaysAgo = now - 30 * 86400000;
1168
- // v3.23 noise-gauge de-blinding: the prior `importance = 1` predicate was
1169
- // structurally blind to imp=0 — decay's floor + the LLM low-signal filter push
1170
- // dormant rows to 0, which on a real store is ~half the live corpus. The gauge
1171
- // therefore reported "0.0% noise" while the store was dormant-heavy. `<= 1` makes
1172
- // imp=0 visible; injection_count=0 mirrors decay's NEVER-INJECTED guard so an
1173
- // injected-but-decayed row (pinned noise, tracked separately) is not miscounted
1174
- // as "never used".
1175
- const lowVal = db.prepare(`
1176
- SELECT COUNT(*) as c FROM observations
1177
- WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
1178
- AND COALESCE(injection_count,0) = 0
1179
- AND COALESCE(compressed_into, 0) = 0
1180
- AND created_at_epoch < ? ${projectFilter}
1181
- `).get(thirtyDaysAgo, ...baseParams);
1182
- // Low-signal-title population: template / tool-log titles (Modified, Worked on,
1183
- // Error while working, Error:, node/npm/npx …) that the retrieval layer already
1184
- // filters out by default. The imp=1 "Low-value" metric above structurally can't
1185
- // see these — they often carry inflated importance and recent access — so the
1186
- // health gauge under-reports real noise without this line. Same LOW_SIGNAL
1187
- // pattern source as the read-side filter (lib/low-signal-patterns.mjs).
1188
- const lowSignalTitle = db.prepare(`
1189
- SELECT COUNT(*) as c FROM observations
1190
- WHERE NOT ${buildNotLowSignalSql()}
1191
- AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
1192
- `).get(...baseParams);
1193
- // F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
1194
- // obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
1195
- // Shared with the MCP mem_stats gauge via computeNoiseGauge (lib/stats-quality.mjs).
1196
- const liveTotal = db.prepare(
1197
- `SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
1198
- ).get(...baseParams);
1199
- const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
1200
- const compressedCount = db.prepare(
1201
- `SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}`
1202
- ).get(...baseParams);
1203
- const supersededOnlyCount = db.prepare(
1204
- `SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
1205
- ).get(...baseParams);
1117
+ const {
1118
+ obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
1119
+ types, projects, daily, tokenEst, avgImp, lowVal, lowSignalTitle,
1120
+ noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount, tierMap,
1121
+ } = computeStatsFeed(db, { project, days, now });
1206
1122
 
1207
1123
  // Hook self-observation: count PreToolUse / Skill-bridge script failures
1208
1124
  // recorded in the last 24h. Surfaces silent breakage (DB corruption,
@@ -1210,17 +1126,6 @@ async function cmdStats(db, args) {
1210
1126
  // failure mode that left code-graph's matcher bug undetected for 10 sessions.
1211
1127
  const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
1212
1128
 
1213
- // Tier distribution (aligned with MCP mem_stats)
1214
- const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
1215
- const tdParams = tierSqlParams(tierCtx);
1216
- const tierDist = db.prepare(`
1217
- SELECT tier, COUNT(*) as c FROM (
1218
- SELECT ${TIER_CASE_SQL} as tier FROM observations
1219
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
1220
- ) GROUP BY tier ORDER BY tier
1221
- `).all(...tdParams, ...baseParams);
1222
- const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));
1223
-
1224
1129
  if (jsonOutput) {
1225
1130
  out(JSON.stringify({
1226
1131
  project,
@@ -1508,43 +1413,13 @@ function cmdDelete(db, args) {
1508
1413
  return;
1509
1414
  }
1510
1415
 
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();
1416
+ // Full delete orchestration (snapshot + related_ids cleanup + child recovery + delete
1417
+ // transaction) lives in lib/delete-core.mjs single source of truth shared with the MCP
1418
+ // mem_delete path (was inlined here + kept in sync by parity comments, the #1 drift risk).
1419
+ const result = deleteObservations(db, ids);
1545
1420
  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.` : ''}`);
1421
+ const recoveredNote = result.recoveredChildren > 0 ? ` Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.` : '';
1422
+ out(`[mem] Deleted ${result.deleted} observation(s).${recoveredNote}${missing.length > 0 ? ` Note: ID(s) ${missing.join(', ')} not found.` : ''}`);
1548
1423
  }
1549
1424
 
1550
1425
  // ─── Update ──────────────────────────────────────────────────────────────────
@@ -1599,7 +1474,7 @@ function cmdUpdate(db, args) {
1599
1474
  updates.push('narrative = ?'); params.push(scrubSecrets(flags.narrative));
1600
1475
  }
1601
1476
  if (flags.type) {
1602
- const validTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
1477
+ const validTypes = OBS_TYPE_SET;
1603
1478
  if (!validTypes.has(flags.type)) {
1604
1479
  fail(`[mem] Invalid type "${flags.type}". Valid: ${[...validTypes].join(', ')}`);
1605
1480
  return;
@@ -1683,7 +1558,7 @@ function cmdExport(db, args) {
1683
1558
  if (flags.type) {
1684
1559
  // Reject unknown types — silently returning [] for `--type bogus` looked like a
1685
1560
  // legitimate empty filter result, hiding the typo. Mirrors cmdSearch / cmdSave / cmdUpdate.
1686
- const validObsTypes = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
1561
+ const validObsTypes = OBS_TYPE_SET;
1687
1562
  if (!validObsTypes.has(flags.type)) {
1688
1563
  fail(`[mem] Invalid --type "${flags.type}". Valid: ${[...validObsTypes].join(', ')}`);
1689
1564
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.47.0",
3
+ "version": "3.49.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,11 @@
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",
95
+ "lib/stats-core.mjs",
96
+ "lib/obs-types.mjs",
97
+ "lib/save-nudge.mjs",
93
98
  "lib/maintain-core.mjs",
94
99
  "lib/dedup-constants.mjs",
95
100
  "lib/deferred-work.mjs",
package/registry.mjs CHANGED
@@ -376,7 +376,7 @@ const UPSERT_SQL = `
376
376
  repo_url=CASE WHEN excluded.repo_url != '' THEN excluded.repo_url ELSE repo_url END,
377
377
  repo_stars=CASE WHEN excluded.repo_stars > 0 THEN excluded.repo_stars ELSE repo_stars END,
378
378
  local_path=CASE WHEN excluded.local_path != '' THEN excluded.local_path ELSE local_path END,
379
- file_hash=excluded.file_hash,
379
+ file_hash=CASE WHEN excluded.file_hash IS NOT NULL AND excluded.file_hash != '' THEN excluded.file_hash ELSE file_hash END,
380
380
  invocation_name=CASE WHEN excluded.invocation_name != '' THEN excluded.invocation_name ELSE invocation_name END,
381
381
  -- Preserve-on-empty (mirror repo_stars/invocation_name above): a PARTIAL re-upsert --
382
382
  -- e.g. "registry import --name X --capability-summary ...", where mem-cli defaults every
package/schema.mjs CHANGED
@@ -176,7 +176,7 @@ const CORE_SCHEMA = `
176
176
  memory_session_id TEXT NOT NULL,
177
177
  project TEXT NOT NULL,
178
178
  text TEXT,
179
- type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
179
+ type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')), -- keep in sync with lib/obs-types.mjs OBS_TYPES (locked by tests/obs-types-invariant.test.mjs)
180
180
  title TEXT,
181
181
  subtitle TEXT,
182
182
  facts TEXT,
@@ -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') {
@@ -787,8 +787,11 @@ async function main() {
787
787
  if (matched) {
788
788
  const cooldown = getSkillCooldown();
789
789
  if (!cooldown[matched]) {
790
+ // Registry skill names come from third-party repos (tools/adopt import) — an
791
+ // untrusted boundary like every other DB-derived string on this surface.
792
+ const safeName = neutralizeContextDelimiters(matched);
790
793
  process.stdout.write(
791
- `\n[mem] Skill "${matched}" may apply — invoke via SkillTool or run: claude-mem-lite registry show ${matched}\n`
794
+ `\n[mem] Skill "${safeName}" may apply — invoke via SkillTool or run: claude-mem-lite registry show ${safeName}\n`
792
795
  );
793
796
  setSkillCooldown(matched);
794
797
  }