claude-mem-lite 2.34.4 → 2.34.6

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.34.4",
13
+ "version": "2.34.6",
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.34.4",
3
+ "version": "2.34.6",
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/hooks/hooks.json CHANGED
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "PreToolUse": [
22
22
  {
23
- "matcher": "Edit|Write|NotebookEdit",
23
+ "matcher": "Edit|Write|NotebookEdit|Read",
24
24
  "hooks": [
25
25
  {
26
26
  "type": "command",
package/install.mjs CHANGED
@@ -489,7 +489,10 @@ async function install() {
489
489
  };
490
490
 
491
491
  const memPreToolRecall = {
492
- matcher: 'Edit|Write|NotebookEdit',
492
+ // v2.34.6: Read added to cover planning-Read (pre-Edit exploration).
493
+ // Read-path uses a tighter filter (lesson_learned required, top-1,
494
+ // 120-char truncation, silent-on-empty) — see scripts/pre-tool-recall.js.
495
+ matcher: 'Edit|Write|NotebookEdit|Read',
493
496
  hooks: [
494
497
  {
495
498
  type: 'command',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.34.4",
3
+ "version": "2.34.6",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {
@@ -91,14 +91,23 @@ try {
91
91
  // Parse event
92
92
  let filePath;
93
93
  let sessionId;
94
+ let toolName;
94
95
  try {
95
96
  const event = JSON.parse(input);
96
97
  filePath = event.tool_input?.file_path;
97
98
  sessionId = event.session_id || null;
99
+ toolName = event.tool_name || null;
98
100
  } catch { process.exit(0); }
99
101
 
100
102
  if (!filePath) process.exit(0);
101
103
 
104
+ // v2.34.6 Gap 3: Read-side recall with asymmetric quiet-mode. Reads have
105
+ // lower per-event information value than Edits (passive observation, may
106
+ // not lead to action), so inject less per Read. Cooldown is shared with
107
+ // Edit via per-filePath session state — Read→Edit in the same session is
108
+ // not double-injected. See CHANGELOG v2.34.6 for the data behind 120/1/no-nudge.
109
+ const isRead = toolName === 'Read';
110
+
102
111
  // v2.33.1: session-scoped cooldown. Within one session, same file recalls
103
112
  // once; cross-session, each session gets fresh nudges. Legacy 5-min global
104
113
  // cooldown only applies when no session_id is present.
@@ -135,6 +144,18 @@ try {
135
144
  // Priority: 1) observations with lesson_learned (most actionable for preventing repeat bugs)
136
145
  // 2) bugfix/decision types with importance>=2 (contextual history)
137
146
  // Skip pure change/discovery without lessons — they add noise without actionable value.
147
+ //
148
+ // v2.34.6: Read tightens the filter to require lesson_learned (drops type-OR
149
+ // fallback — decision/bugfix WITHOUT lesson add context noise to passive Reads
150
+ // where the agent isn't committed to a change). Edit/Write keep the wider
151
+ // filter for decision-point context.
152
+ const typeFallback = isRead
153
+ ? 'AND o.lesson_learned IS NOT NULL AND o.lesson_learned != \'\''
154
+ : `AND (
155
+ (o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
156
+ OR o.type IN ('bugfix', 'decision')
157
+ )`;
158
+ const obsLimit = isRead ? 1 : 2;
138
159
  const rows = db.prepare(`
139
160
  SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
140
161
  FROM observations o
@@ -145,14 +166,11 @@ try {
145
166
  AND o.superseded_at IS NULL
146
167
  AND o.created_at_epoch > ?
147
168
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
148
- AND (
149
- (o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
150
- OR o.type IN ('bugfix', 'decision')
151
- )
169
+ ${typeFallback}
152
170
  ORDER BY
153
171
  CASE WHEN o.lesson_learned IS NOT NULL AND o.lesson_learned != '' THEN 0 ELSE 1 END,
154
172
  o.created_at_epoch DESC
155
- LIMIT 2
173
+ LIMIT ${obsLimit}
156
174
  `).all(project, cutoff, filePath, likePattern);
157
175
 
158
176
  // T9: also query the `events` table — after T9, bugfix/lesson/decision/etc.
@@ -163,6 +181,11 @@ try {
163
181
  // matching "myfoo.mjs".
164
182
  const fnameEscaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
165
183
  const filePathEscaped = filePath.replace(/%/g, '\\%').replace(/_/g, '\\_');
184
+ // v2.34.6: Read also tightens the events query — only rows with a non-empty
185
+ // body (= lesson equivalent). Edit path keeps the wider net since the agent
186
+ // is about to change the file and benefits from any contextual signal.
187
+ const eventsBodyFilter = isRead ? "AND body IS NOT NULL AND body != ''" : '';
188
+ const eventsLimit = isRead ? 1 : 2;
166
189
  let eventRows = [];
167
190
  try {
168
191
  eventRows = db.prepare(`
@@ -173,25 +196,29 @@ try {
173
196
  AND superseded_at_epoch IS NULL
174
197
  AND created_at_epoch > ?
175
198
  AND (file_paths LIKE ? ESCAPE '\\' OR file_paths LIKE ? ESCAPE '\\')
199
+ ${eventsBodyFilter}
176
200
  ORDER BY created_at_epoch DESC
177
- LIMIT 2
201
+ LIMIT ${eventsLimit}
178
202
  `).all(project, cutoff, `%"${fnameEscaped}"%`, `%"${filePathEscaped}"%`);
179
203
  } catch { /* events table may not exist on pre-v2.31 DBs — silent */ }
180
204
 
181
- // Merge: observations first (they carry richer lesson_learned), then events,
182
- // capped at 3 total so the injected context stays small per Edit/Write.
183
- const allRows = [...rows, ...eventRows].slice(0, 3);
205
+ // Merge: observations first (they carry richer lesson_learned), then events.
206
+ // Edit/Write caps at 3 total; Read caps at 1 (single most-actionable hit).
207
+ const mergeCap = isRead ? 1 : 3;
208
+ const allRows = [...rows, ...eventRows].slice(0, mergeCap);
184
209
 
185
210
  // v2.31 T2: emit JSON with hookSpecificOutput.additionalContext so the message
186
211
  // reliably renders across CC variants (sdscc drops plain-text stdout from PreToolUse).
187
212
  // suppressOutput:true hides it from transcript mode per CC hook docs.
188
213
  const lines = [];
214
+ // v2.34.6: Read mode uses 120-char truncation (Edit mode keeps the 240-char
215
+ // cap from R3-UX). Rationale: Read is a one-shot nudge with 1 lesson max;
216
+ // Edit is a 3-lesson decision-support injection where the fuller lesson tail
217
+ // carries the actionable "Fix:" guidance — short enough per-lesson at 240,
218
+ // but the total payload is bounded by the 3-row limit and the cooldown.
219
+ const LESSON_MAX = isRead ? 120 : 240;
189
220
  if (allRows.length > 0) {
190
221
  lines.push(`[mem] Lessons for ${fname}:`);
191
- // R3-UX: raised from 120 → 240 after measuring 97% of lessons exceed 120 chars
192
- // (p50=218, avg=247). Previous limit truncated the actionable "Fix:" tail in 80%
193
- // of lessons containing it. 3 × 240 ≈ 180 tokens/Edit — negligible context cost.
194
- const LESSON_MAX = 240;
195
222
  for (const r of allRows) {
196
223
  if (r.lesson_learned) {
197
224
  const lesson = r.lesson_learned.length > LESSON_MAX
@@ -205,22 +232,29 @@ try {
205
232
  lines.push(` #${r.id} [${r.type}] ${title}`);
206
233
  }
207
234
  }
208
- } else {
209
- // R-4: emit a short backfill reminder instead of staying silent.
210
- // Two goals: (1) Claude sees that the system actually ran, (2) Claude is
211
- // nudged to save a lesson when solving a non-obvious bug. The reminder
212
- // is one line to minimize per-Edit context cost.
235
+ } else if (!isRead) {
236
+ // R-4: Edit/Write empty short backfill reminder. Two goals: (1) Claude
237
+ // sees that the system actually ran, (2) Claude is nudged to save a lesson
238
+ // after a non-obvious bug. Reminder is one line to keep per-Edit cost low.
239
+ //
240
+ // v2.34.6: Read does NOT emit this nudge. Read is passive — the agent
241
+ // isn't necessarily about to solve anything, so /lesson prompts are noise.
242
+ // Empty Reads exit silently, saving ~60 tokens × (every empty-file Read).
213
243
  lines.push(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`);
214
244
  }
215
245
 
216
- process.stdout.write(JSON.stringify({
217
- suppressOutput: true,
218
- hookSpecificOutput: {
219
- hookEventName: 'PreToolUse',
220
- additionalContext: lines.join('\n'),
221
- },
222
- }));
223
- // Cooldown applies to BOTH branches so the reminder doesn't spam every Edit.
246
+ if (lines.length > 0) {
247
+ process.stdout.write(JSON.stringify({
248
+ suppressOutput: true,
249
+ hookSpecificOutput: {
250
+ hookEventName: 'PreToolUse',
251
+ additionalContext: lines.join('\n'),
252
+ },
253
+ }));
254
+ }
255
+ // Cooldown applies on ALL branches (including silent-Read) so subsequent
256
+ // calls on the same file in the same session don't re-query — preserving
257
+ // the per-filePath invariant that underpins Read→Edit dedup.
224
258
  cooldown[filePath] = now;
225
259
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
226
260
  } catch {
@@ -163,6 +163,43 @@ function searchByFile(db, files, project, limit) {
163
163
  });
164
164
  }
165
165
 
166
+ // v2.34.5 Gap 1: prompts-table fallback. When observations-based paths
167
+ // (FTS / file-recall / sigRows / recent) all return empty, scan the user's
168
+ // own past prompts — meta/UX/"did we discuss this" questions often match
169
+ // prior prompts even when no observation was saved. Uses a simpler BM25
170
+ // ranking with no scoring multipliers and no top-|rel| gate (prompts are
171
+ // sparser and more surface-form than observations; the gate would rarely
172
+ // fire and mostly kill real hits).
173
+ function searchByUserPrompts(db, queryText, project, limit) {
174
+ const ftsQuery = sanitizeFtsQuery(queryText);
175
+ if (!ftsQuery) return [];
176
+
177
+ const cutoff = Date.now() - LOOKBACK_MS;
178
+ const sql = `
179
+ SELECT up.id, up.prompt_text, up.created_at_epoch,
180
+ bm25(user_prompts_fts) as relevance
181
+ FROM user_prompts_fts
182
+ JOIN user_prompts up ON up.id = user_prompts_fts.rowid
183
+ JOIN sdk_sessions s ON s.content_session_id = up.content_session_id
184
+ WHERE user_prompts_fts MATCH ?
185
+ AND s.project = ?
186
+ AND up.created_at_epoch > ?
187
+ ORDER BY relevance
188
+ LIMIT ?
189
+ `;
190
+
191
+ let rows = db.prepare(sql).all(ftsQuery, project, cutoff, limit);
192
+
193
+ if (rows.length === 0) {
194
+ const orQuery = relaxFtsQueryToOr(ftsQuery);
195
+ if (orQuery) {
196
+ try { rows = db.prepare(sql).all(orQuery, project, cutoff, limit); } catch {}
197
+ }
198
+ }
199
+
200
+ return rows;
201
+ }
202
+
166
203
  function searchRecent(db, project, limit) {
167
204
  const cutoff = Date.now() - LOOKBACK_MS;
168
205
  // R1: exclude LOW_SIGNAL degraded titles from "recent" recall intent
@@ -225,6 +262,20 @@ function formatResults(rows) {
225
262
  return lines.join('\n');
226
263
  }
227
264
 
265
+ // v2.34.5 Gap 1: distinct header signals to Claude that these are prior
266
+ // *user questions*, not codebase lessons — helps the reader interpret the
267
+ // row correctly (surface-form match, not a saved insight). Truncate to 80
268
+ // chars (slightly longer than obs titles because prompts carry more context).
269
+ function formatPromptResults(rows) {
270
+ if (!rows || rows.length === 0) return null;
271
+ const lines = ['[mem] Past similar questions:'];
272
+ for (const r of rows) {
273
+ const text = truncate((r.prompt_text || '').replace(/\s+/g, ' '), 80);
274
+ lines.push(`P#${r.id} 💬 ${text}`);
275
+ }
276
+ return lines.join('\n');
277
+ }
278
+
228
279
  // ─── Registry Skill Pointer (T4 v2.31) ─────────────────────────────────────
229
280
  // Formerly "auto-load": we used to read the full SKILL.md body (up to 16KB)
230
281
  // and inject it into stdout on keyword match. Now we only emit a short
@@ -376,10 +427,24 @@ async function main() {
376
427
  rows = [...sigRows, ...rows.filter(r => !sigIds.has(r.id))].slice(0, MAX_RESULTS);
377
428
  }
378
429
 
379
- const candidateIds = rows.map(r => r.id);
430
+ // v2.34.5 Gap 1: if observations-based search drew a blank, try the
431
+ // user_prompts corpus. Only fires when `rows` is empty (obs hits
432
+ // suppress the fallback to avoid noise). Namespace prompt IDs with
433
+ // a "P" prefix so shouldSkipByDedup's Set comparison doesn't collide
434
+ // with future observation IDs.
435
+ let promptRows = [];
436
+ if (rows.length === 0) {
437
+ promptRows = searchByUserPrompts(db, promptText, project, 3);
438
+ }
439
+
440
+ const candidateIds = rows.length > 0
441
+ ? rows.map(r => r.id)
442
+ : promptRows.map(r => `P${r.id}`);
380
443
  const dedupSkip = shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE);
381
444
 
382
- const output = !dedupSkip ? formatResults(rows) : null;
445
+ const output = !dedupSkip
446
+ ? (rows.length > 0 ? formatResults(rows) : formatPromptResults(promptRows))
447
+ : null;
383
448
  if (output) {
384
449
  process.stdout.write(output + '\n');
385
450
  // Write injected IDs for dedup with hook.mjs handleUserPrompt + self-dedup