claude-mem-lite 2.69.0 → 2.71.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": "2.69.0",
13
+ "version": "2.71.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.69.0",
3
+ "version": "2.71.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -209,14 +209,14 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
209
209
 
210
210
  ### MCP Tools (used automatically by Claude)
211
211
 
212
- As of v2.34.0, the server registers 17 tools in total but only the 6 **core**
212
+ As of v2.70.0, the server registers 20 tools in total but only the 9 **core**
213
213
  tools appear in `tools/list`. The 11 **hidden** tools remain callable at the
214
214
  protocol layer (`tools/call` by exact name still routes normally); they're
215
215
  omitted from the list response so Claude Code sessions don't load 11 extra
216
216
  tool schemas at startup. Hidden tools are the maintenance / admin / browser
217
217
  surface — reach them through the CLI column in the second table.
218
218
 
219
- **Core (6, exposed to Claude Code)**
219
+ **Core (9, exposed to Claude Code)**
220
220
 
221
221
  | Tool | Description |
222
222
  |------|-------------|
@@ -225,7 +225,10 @@ surface — reach them through the CLI column in the second table.
225
225
  | `mem_recall` | Recall observations related to a file. Use before editing to surface past bugfixes and context. |
226
226
  | `mem_timeline` | Browse observations chronologically around an anchor point. |
227
227
  | `mem_get` | Retrieve full details for specific observation IDs (includes importance and related_ids). |
228
- | `mem_save` | Manually save a memory/observation. |
228
+ | `mem_save` | Manually save a memory/observation. Accepts `closes_deferred` array for transactional closure of deferred work. |
229
+ | `mem_defer` | Mark work for a future session (v2.70+). First-class carry-forward signal, surfaced in SessionStart `### Deferred Work` block. |
230
+ | `mem_defer_list` | List open deferred items for the current project. |
231
+ | `mem_defer_drop` | Drop a deferred item without fixing it; requires a `reason` for the audit trail. |
229
232
 
230
233
  **Hidden-but-callable (11, CLI-routed)**
231
234
 
package/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'help']);
2
+ const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
3
3
  const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'release']);
4
4
 
5
5
  const cmd = process.argv[2];
package/hook-context.mjs CHANGED
@@ -407,26 +407,30 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
407
407
  handoffLines.push('');
408
408
  }
409
409
 
410
- // 5b. Deferred Work — project-level importance≥3 obs that survived prior
411
- // session boundaries. Independent of the per-session handoff: even when the
412
- // most recent /clear or /exit handoff has stale or meta-only `working_on`,
413
- // genuine carry-forward decisions stay surfaced. Capped at 3 to keep the
414
- // banner skim-able. Quiet-hooks does NOT suppress: the whole point is
415
- // visibility for cross-session continuity.
416
- const deferredObs = db.prepare(`
417
- SELECT id, type, title FROM observations
418
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0
419
- AND superseded_at IS NULL
420
- AND COALESCE(importance, 1) >= 3
421
- AND ${notLowSignalTitleClause('')}
422
- ORDER BY created_at_epoch DESC LIMIT 3
410
+ // 5b. Deferred Work — backed by deferred_work table (v2.70.0).
411
+ // Replaces the prior importance≥3 obs proxy. Items shown by per-project
412
+ // ordinal so user can refer to "处理1" / "handle item 1" naturally; D#<id>
413
+ // is the stable handle for tool-layer references (closes_deferred=[N]).
414
+ // Quiet-hooks does NOT suppress: cross-session continuity is the whole point.
415
+ const deferredItems = db.prepare(`
416
+ SELECT id, title, priority,
417
+ ROW_NUMBER() OVER (
418
+ ORDER BY priority DESC, created_at_epoch ASC
419
+ ) AS ordinal
420
+ FROM deferred_work
421
+ WHERE project = ? AND status = 'open'
422
+ ORDER BY priority DESC, created_at_epoch ASC
423
+ LIMIT 5
423
424
  `).all(project);
424
425
 
425
426
  const deferredLines = [];
426
- if (deferredObs.length > 0) {
427
+ if (deferredItems.length > 0) {
427
428
  deferredLines.push('### Deferred Work');
428
- for (const o of deferredObs) {
429
- deferredLines.push(`- ${typeIcon(o.type)} [${o.type}] ${truncate(o.title, 140)} (#${o.id})`);
429
+ for (const d of deferredItems) {
430
+ const pTag = d.priority === 3 ? '🔴' : d.priority === 1 ? '⚪' : '🟡';
431
+ deferredLines.push(
432
+ `${d.ordinal}. ${pTag} [P${d.priority}] ${truncate(d.title, 120)} (D#${d.id})`
433
+ );
430
434
  }
431
435
  deferredLines.push('');
432
436
  }
package/hook-handoff.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  // Extracted for testability — hook.mjs has module-level side effects
3
3
 
4
4
  import { basename } from 'path';
5
- import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, LOW_SIGNAL_TITLE, EDIT_TOOLS, isMetaTriggerPrompt, notLowSignalTitleClause } from './utils.mjs';
5
+ import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, scrubSecrets, LOW_SIGNAL_TITLE, EDIT_TOOLS, isMetaTriggerPrompt, notLowSignalTitleClause } from './utils.mjs';
6
+ import { scrubRecord } from './lib/scrub-record.mjs';
6
7
  import {
7
8
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT, HANDOFF_ANCHOR_MAX_AGE,
8
9
  HANDOFF_MATCH_THRESHOLD, CONTINUE_KEYWORDS,
@@ -161,6 +162,23 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
161
162
  // `scopeSessionId` (CC UUID) tags the row for parallel scoping; falls back to
162
163
  // the mem-internal `sessionId` when the caller didn't supply one (tests + legacy).
163
164
  const storedSessionId = scopeSessionId || sessionId;
165
+ // Defense-in-depth: aggregates are built from already-stored rows + raw
166
+ // session memory; scrub at the persistence boundary regardless of source.
167
+ // Order matters: scrub raw values BEFORE truncation, so a secret straddling
168
+ // the truncation boundary doesn't fall below scrubSecrets's regex length
169
+ // floors. JSON-stringified fields (key_files) are pre-scrubbed at the
170
+ // element level before stringify — letting scrubSecrets rewrite the JSON
171
+ // string would risk breaking downstream JSON.parse.
172
+ const safe = scrubRecord('session_handoffs', {
173
+ working_on: workingOn,
174
+ completed: completed.map(c => `[${c.type}] ${c.title}`).join('\n'),
175
+ unfinished,
176
+ key_decisions: decisions.map(d => d.title).join('\n'),
177
+ match_keywords: keywords,
178
+ });
179
+ const safeKeyFiles = JSON.stringify(
180
+ [...fileSet].slice(0, 20).map(f => scrubSecrets(String(f)))
181
+ );
164
182
  db.prepare(`
165
183
  INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch, git_sha_at_handoff)
166
184
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -175,12 +193,12 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
175
193
  git_sha_at_handoff = excluded.git_sha_at_handoff
176
194
  `).run(
177
195
  project, type, storedSessionId,
178
- truncate(workingOn, 1000),
179
- completed.map(c => `[${c.type}] ${c.title}`).join('\n'),
180
- unfinished.length > 3000 ? unfinished.slice(0, 2999) + '…' : unfinished,
181
- JSON.stringify([...fileSet].slice(0, 20)),
182
- decisions.map(d => d.title).join('\n'),
183
- keywords,
196
+ truncate(safe.working_on, 1000),
197
+ safe.completed,
198
+ safe.unfinished.length > 3000 ? safe.unfinished.slice(0, 2999) + '…' : safe.unfinished,
199
+ safeKeyFiles,
200
+ safe.key_decisions,
201
+ safe.match_keywords,
184
202
  Date.now(),
185
203
  gitShaAtHandoff,
186
204
  );
package/hook-llm.mjs CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  getCurrentBranch, notLowSignalTitleClause,
11
11
  } from './utils.mjs';
12
12
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
13
+ import { scrubRecord } from './lib/scrub-record.mjs';
13
14
  import { getVocabulary, computeVector } from './tfidf.mjs';
14
15
  import {
15
16
  RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
@@ -194,6 +195,19 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
194
195
 
195
196
  const { conceptsText, factsText, textField } = buildFtsTextField(obs);
196
197
 
198
+ // Defense-in-depth: scrub text fields before INSERT. Source is LLM output
199
+ // (Haiku occasionally regurgitates input verbatim — error logs, hashes).
200
+ const safe = scrubRecord('observations', {
201
+ text: textField,
202
+ title: obs.title || '',
203
+ subtitle: obs.subtitle || '',
204
+ narrative: obs.narrative || '',
205
+ concepts: conceptsText,
206
+ facts: factsText,
207
+ lesson_learned: obs.lessonLearned || null,
208
+ search_aliases: obs.searchAliases || null,
209
+ });
210
+
197
211
  // Atomic: observation INSERT + observation_files + vector in one transaction
198
212
  const savedId = db.transaction(() => {
199
213
  const result = db.prepare(`
@@ -201,16 +215,16 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
201
215
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
202
216
  `).run(
203
217
  sessionId, project,
204
- textField, obs.type, obs.title, obs.subtitle || '',
205
- obs.narrative || '',
206
- conceptsText,
207
- factsText,
218
+ safe.text, obs.type, safe.title, safe.subtitle,
219
+ safe.narrative,
220
+ safe.concepts,
221
+ safe.facts,
208
222
  JSON.stringify(obs.filesRead || []),
209
223
  JSON.stringify(obs.files || []),
210
224
  obs.importance ?? 1,
211
225
  minhashSig,
212
- obs.lessonLearned || null,
213
- obs.searchAliases || null,
226
+ safe.lesson_learned,
227
+ safe.search_aliases,
214
228
  getCurrentBranch(),
215
229
  now.toISOString(), now.getTime()
216
230
  );
@@ -823,19 +837,32 @@ ${actionList}`;
823
837
  // so the enriched FTS text field + minhash + vector are refreshed atomically.
824
838
  const { conceptsText, factsText, textField } = buildFtsTextField(obs);
825
839
  const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
840
+ // Scrub LLM-output text fields at the UPDATE boundary, mirroring the
841
+ // INSERT path. type is an enum, importance is numeric, files_read is a
842
+ // JSON array (already scrubbed upstream), minhash_sig is hash bytes.
843
+ const safe = scrubRecord('observations', {
844
+ title: truncate(obs.title, 120),
845
+ subtitle: obs.subtitle || '',
846
+ narrative: truncate(obs.narrative || '', 500),
847
+ concepts: conceptsText,
848
+ facts: factsText,
849
+ text: textField,
850
+ lesson_learned: obs.lessonLearned || null,
851
+ search_aliases: obs.searchAliases || null,
852
+ });
826
853
  db.prepare(`
827
854
  UPDATE observations SET type=?, title=?, subtitle=?, narrative=?, concepts=?, facts=?,
828
855
  text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
829
856
  WHERE id = ?
830
857
  `).run(
831
- obs.type, truncate(obs.title, 120), obs.subtitle || '',
832
- truncate(obs.narrative || '', 500),
833
- conceptsText, factsText, textField,
858
+ obs.type, safe.title, safe.subtitle,
859
+ safe.narrative,
860
+ safe.concepts, safe.facts, safe.text,
834
861
  obs.importance,
835
862
  JSON.stringify(obs.filesRead || []),
836
863
  minhashSig,
837
- obs.lessonLearned || null,
838
- obs.searchAliases || null,
864
+ safe.lesson_learned,
865
+ safe.search_aliases,
839
866
  episode.savedId
840
867
  );
841
868
  savedId = episode.savedId;
@@ -973,6 +1000,23 @@ ${obsList}`;
973
1000
  // empty for that field. Without COALESCE, a degraded Haiku pass would erase
974
1001
  // the deterministic floor — the exact regression that made 72% of prod
975
1002
  // session_summaries ship with empty remaining_items.
1003
+ //
1004
+ // Scrub LLM-output text fields at the UPDATE boundary. lessons /
1005
+ // key_decisions are JSON.stringify(array<string>); we scrub the JSON
1006
+ // string here to match the sibling INSERT path. scrubSecrets uses
1007
+ // opaque placeholders that preserve JSON structure; element-level
1008
+ // pre-scrub remains safer in principle but would diverge from the
1009
+ // merged INSERT contract.
1010
+ const safe = scrubRecord('session_summaries', {
1011
+ request: llmParsed.request || '',
1012
+ investigated: llmParsed.investigated || '',
1013
+ learned: llmParsed.learned || '',
1014
+ completed: llmParsed.completed || '',
1015
+ next_steps: llmParsed.next_steps || '',
1016
+ remaining_items: llmParsed.remaining_items || '',
1017
+ lessons: lessonsJson,
1018
+ key_decisions: decisionsJson,
1019
+ });
976
1020
  db.prepare(`
977
1021
  UPDATE session_summaries
978
1022
  SET request = COALESCE(NULLIF(?, ''), request),
@@ -988,23 +1032,33 @@ ${obsList}`;
988
1032
  created_at_epoch = ?
989
1033
  WHERE id = ?
990
1034
  `).run(
991
- llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
992
- llmParsed.completed || '', llmParsed.next_steps || '',
993
- llmParsed.remaining_items || '',
994
- lessonsJson, decisionsJson,
1035
+ safe.request, safe.investigated, safe.learned,
1036
+ safe.completed, safe.next_steps,
1037
+ safe.remaining_items,
1038
+ safe.lessons, safe.key_decisions,
995
1039
  now.toISOString(), now.getTime(),
996
1040
  existingFast.id
997
1041
  );
998
1042
  } else {
1043
+ const safe = scrubRecord('session_summaries', {
1044
+ request: llmParsed.request || '',
1045
+ investigated: llmParsed.investigated || '',
1046
+ learned: llmParsed.learned || '',
1047
+ completed: llmParsed.completed || '',
1048
+ next_steps: llmParsed.next_steps || '',
1049
+ remaining_items: llmParsed.remaining_items || '',
1050
+ lessons: lessonsJson,
1051
+ key_decisions: decisionsJson,
1052
+ });
999
1053
  db.prepare(`
1000
1054
  INSERT INTO session_summaries (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, lessons, key_decisions, created_at, created_at_epoch)
1001
1055
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', '[]', '', ?, ?, ?, ?)
1002
1056
  `).run(
1003
1057
  sessionId, project,
1004
- llmParsed.request || '', llmParsed.investigated || '', llmParsed.learned || '',
1005
- llmParsed.completed || '', llmParsed.next_steps || '',
1006
- llmParsed.remaining_items || '',
1007
- lessonsJson, decisionsJson,
1058
+ safe.request, safe.investigated, safe.learned,
1059
+ safe.completed, safe.next_steps,
1060
+ safe.remaining_items,
1061
+ safe.lessons, safe.key_decisions,
1008
1062
  now.toISOString(), now.getTime()
1009
1063
  );
1010
1064
  }
@@ -1013,3 +1067,25 @@ ${obsList}`;
1013
1067
  db.close();
1014
1068
  }
1015
1069
  }
1070
+
1071
+ // Test-only — DO NOT import outside tests/. Underscore prefix is a
1072
+ // convention; the plugin has no `main`/`exports` field so external imports
1073
+ // are blocked at the package level, but a misguided sibling import inside
1074
+ // this repo could drag this into prod by accident. If that ever needs
1075
+ // enforcing, move the helper to a tests/_helpers/ module that takes a
1076
+ // db-insert callback.
1077
+ //
1078
+ // Exercises the same scrubRecord path used by saveObservation without
1079
+ // spinning up the full LLM dispatcher. Lets the e2e leak test verify that
1080
+ // the observations INSERT path scrubs all configured text fields.
1081
+ export const __insertObservationForTest = (db, obs) => {
1082
+ const safe = scrubRecord('observations', obs);
1083
+ db.prepare(`INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, search_aliases, branch, created_at, created_at_epoch)
1084
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
1085
+ obs.session_id, obs.project, safe.text, 'change',
1086
+ safe.title, safe.subtitle, safe.narrative,
1087
+ safe.concepts, safe.facts, obs.files_read, obs.files_modified,
1088
+ obs.importance, obs.minhash_sig, safe.lesson_learned, safe.search_aliases,
1089
+ obs.branch, new Date().toISOString(), Date.now(),
1090
+ );
1091
+ };
package/hook-optimize.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  } from './utils.mjs';
12
12
  import { callModelJSON } from './haiku-client.mjs';
13
13
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
14
+ import { scrubRecord } from './lib/scrub-record.mjs';
14
15
  import { getVocabulary, computeVector, cosineSimilarity } from './tfidf.mjs';
15
16
  import { DB_DIR } from './schema.mjs';
16
17
 
@@ -159,12 +160,22 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
159
160
  const textField = [conceptsText, factsText, searchAliases || '', bigramText].filter(Boolean).join(' ');
160
161
  const minhashSig = computeMinHash((title || '') + ' ' + (narrative || ''));
161
162
 
163
+ // Scrub LLM-output text fields at the UPDATE boundary. type is an
164
+ // enum, importance is numeric, minhash_sig is hash bytes.
165
+ const safe = scrubRecord('observations', {
166
+ title, narrative,
167
+ concepts: conceptsText,
168
+ facts: factsText,
169
+ text: textField,
170
+ lesson_learned: lessonLearned,
171
+ search_aliases: searchAliases,
172
+ });
162
173
  db.prepare(`
163
174
  UPDATE observations SET type=?, title=?, narrative=?, concepts=?, facts=?,
164
175
  text=?, importance=?, lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?
165
176
  WHERE id = ?
166
- `).run(type, title, narrative, conceptsText, factsText, textField,
167
- importance, lessonLearned, searchAliases, minhashSig, Date.now(), cand.id);
177
+ `).run(type, safe.title, safe.narrative, safe.concepts, safe.facts, safe.text,
178
+ importance, safe.lesson_learned, safe.search_aliases, minhashSig, Date.now(), cand.id);
168
179
 
169
180
  rebuildVector(db, cand.id, [title, narrative, conceptsText]);
170
181
 
@@ -277,7 +288,14 @@ export function applyNormalization(db, groups) {
277
288
  const existingAliases = row.search_aliases || '';
278
289
  const originalTerms = terms.filter(t => aliasMap.has(t.toLowerCase()) && aliasMap.get(t.toLowerCase()) !== t);
279
290
  const newAliases = [existingAliases, ...originalTerms].filter(Boolean).join(' ');
280
- updateStmt.run(uniqueConcepts, newAliases, Date.now(), row.id);
291
+ // Defense-in-depth scrub. Canonical concept names come from LLM output
292
+ // (identifySynonymGroups via Sonnet); existing values are already
293
+ // scrubbed but free LLM tokens can re-introduce secret-shaped strings.
294
+ const safe = scrubRecord('observations', {
295
+ concepts: uniqueConcepts,
296
+ search_aliases: newAliases,
297
+ });
298
+ updateStmt.run(safe.concepts, safe.search_aliases, Date.now(), row.id);
281
299
  updated++;
282
300
  }
283
301
  }
@@ -397,13 +415,22 @@ Return ONLY valid JSON:
397
415
  const minhashSig = computeMinHash((title || '') + ' ' + (narrative || ''));
398
416
  const importance = clampImportance(parsed.importance || 2);
399
417
 
418
+ // Scrub LLM-output cluster-merge text fields at the UPDATE boundary.
419
+ // importance is numeric; minhash_sig is hash bytes.
420
+ const safe = scrubRecord('observations', {
421
+ title, narrative,
422
+ concepts: conceptsText,
423
+ facts: factsText,
424
+ text: textField,
425
+ lesson_learned: lessonLearned,
426
+ });
400
427
  db.transaction(() => {
401
428
  db.prepare(`
402
429
  UPDATE observations SET title=?, narrative=?, concepts=?, facts=?, text=?,
403
430
  importance=?, lesson_learned=?, minhash_sig=?, optimized_at=?
404
431
  WHERE id = ?
405
- `).run(title, narrative, conceptsText, factsText, textField,
406
- importance, lessonLearned, minhashSig, Date.now(), keeper.id);
432
+ `).run(safe.title, safe.narrative, safe.concepts, safe.facts, safe.text,
433
+ importance, safe.lesson_learned, minhashSig, Date.now(), keeper.id);
407
434
 
408
435
  const otherIds = others.map(o => o.id);
409
436
  const ph = otherIds.map(() => '?').join(',');
@@ -573,13 +600,24 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
573
600
  VALUES (?,?,?,?,?,'active')`
574
601
  ).run(sessionId, sessionId, project, now.toISOString(), now.getTime());
575
602
 
603
+ // Defense-in-depth: title/narrative/etc. are LLM-generated compression
604
+ // output; scrub at the persistence boundary regardless of upstream trust.
605
+ const safe = scrubRecord('observations', {
606
+ text: textField,
607
+ title,
608
+ narrative,
609
+ concepts: conceptsText,
610
+ facts: factsText,
611
+ lesson_learned: lessonLearned,
612
+ search_aliases: searchAliases,
613
+ });
576
614
  const result = db.prepare(`INSERT INTO observations
577
615
  (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts,
578
616
  files_read, files_modified, importance, lesson_learned, search_aliases, optimized_at,
579
617
  created_at, created_at_epoch)
580
618
  VALUES (?,?,?,?,?,'',?,?,?,'[]','[]',2,?,?,?,?,?)`
581
- ).run(sessionId, project, textField, 'discovery', title, narrative,
582
- conceptsText, factsText, lessonLearned, searchAliases, Date.now(),
619
+ ).run(sessionId, project, safe.text, 'discovery', safe.title, safe.narrative,
620
+ safe.concepts, safe.facts, safe.lesson_learned, safe.search_aliases, Date.now(),
583
621
  new Date(medianEpoch).toISOString(), medianEpoch);
584
622
 
585
623
  const sId = Number(result.lastInsertRowid);
@@ -0,0 +1,44 @@
1
+ // claude-mem-lite: PreCompact hook handler.
2
+ // Fires immediately before Claude Code auto-compaction begins. Emits a
3
+ // fresh <claude-mem-context> block on stdout so the summarizer that
4
+ // produces the compacted context has the most relevant memory in scope.
5
+ // Differs from SessionStart-on-compact (which fires AFTER compaction):
6
+ // PreCompact ensures memory survives the compaction step itself.
7
+
8
+ import { buildSessionContextLines } from './hook-context.mjs';
9
+ import { inferProject, debugCatch, debugLog } from './utils.mjs';
10
+
11
+ /**
12
+ * Build + emit the memory context block on stdout. Pure read; no DB writes.
13
+ *
14
+ * @param {object} ctx
15
+ * @param {import('better-sqlite3').Database} ctx.db
16
+ * @param {string} ctx.project
17
+ * @param {string} [ctx.sessionId]
18
+ * @returns {void}
19
+ */
20
+ export function handlePreCompact({ db, project, sessionId }) {
21
+ try {
22
+ const body = buildSessionContextLines(db, project, new Date(), sessionId || null);
23
+ if (!body || String(body).trim() === '') return;
24
+ process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
25
+ } catch (e) {
26
+ debugCatch(e, 'handlePreCompact');
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Default-export entry for hook.mjs dispatcher. Caller passes an opened DB
32
+ * and the parsed stdin payload — no I/O performed inside this function
33
+ * beyond what handlePreCompact does.
34
+ *
35
+ * @param {import('better-sqlite3').Database} db
36
+ * @param {object} hookData Parsed JSON from hook stdin
37
+ * @returns {Promise<void>}
38
+ */
39
+ export async function entry(db, hookData) {
40
+ const project = inferProject();
41
+ const sessionId = hookData?.session_id;
42
+ debugLog('DEBUG', 'pre-compact', `project=${project} sessionId=${sessionId || 'none'}`);
43
+ handlePreCompact({ db, project, sessionId });
44
+ }