claude-mem-lite 2.80.1 → 2.82.1

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.80.1",
13
+ "version": "2.82.1",
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.80.1",
3
+ "version": "2.82.1",
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/adopt-cli.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // --dry-run = print intent without writing
10
10
  // --status = list all adopted projects + versions
11
11
 
12
- import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
12
+ import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
13
13
  import { homedir } from 'os';
14
14
  import { join } from 'path';
15
15
  import {
@@ -49,6 +49,24 @@ function listAllMemdirs() {
49
49
 
50
50
  function hasFlag(args, flag) { return Array.isArray(args) && args.includes(flag); }
51
51
 
52
+ // ─── Per-project auto-adopt opt-out sentinel ─────────────────────────────────
53
+ // `<memdir>/.mem-no-auto-adopt` is the durable, project-scoped escape hatch.
54
+ // Survives marker deletion, sentinel removal, and plugin reinstalls — that's
55
+ // the point: "user said no for this project" should not be reversible by
56
+ // `rm ~/.claude-mem-lite/runtime/.auto-adopt-*`. Managed via
57
+ // `claude-mem-lite adopt --disable` / `--enable`. silentAutoAdopt checks it
58
+ // at entry and skips WITHOUT writing the runtime marker, so toggling
59
+ // `--enable` re-arms auto-adopt on the next SessionStart.
60
+ const DISABLE_SENTINEL_BASENAME = '.mem-no-auto-adopt';
61
+
62
+ export function disableSentinelPath(memdir) {
63
+ return join(memdir, DISABLE_SENTINEL_BASENAME);
64
+ }
65
+
66
+ export function isAutoAdoptDisabled(memdir) {
67
+ return existsSync(disableSentinelPath(memdir));
68
+ }
69
+
52
70
  /**
53
71
  * cmdAdopt — write sentinel section + plugin doc to memdir.
54
72
  * Exit code 1 on any hard failure; skipped (--all + UserEditedError) doesn't
@@ -56,6 +74,8 @@ function hasFlag(args, flag) { return Array.isArray(args) && args.includes(flag)
56
74
  */
57
75
  export function cmdAdopt(args = []) {
58
76
  if (hasFlag(args, '--status')) return statusAll();
77
+ if (hasFlag(args, '--disable')) return cmdDisable(args);
78
+ if (hasFlag(args, '--enable')) return cmdEnable(args);
59
79
 
60
80
  const all = hasFlag(args, '--all');
61
81
  const force = hasFlag(args, '--force');
@@ -122,14 +142,18 @@ function adoptOne(memdir, { force, dryRun, all }) {
122
142
  }
123
143
 
124
144
  /**
125
- * silentAutoAdopt — plugin-mode first-run auto-adopt helper (v2.33.0).
145
+ * silentAutoAdopt — plugin-mode first-run auto-adopt helper (v2.33.0+).
126
146
  *
127
147
  * Preconditions (caller must gate): CLAUDE_PLUGIN_ROOT set, MEM_NO_AUTO_ADOPT!=1,
128
- * MEM_QUIET_HOOKS!=1, first-attempt marker absent. This helper does NOT re-check
129
- * those — it only does the write + marker persistence.
148
+ * first-attempt marker absent. This helper does NOT re-check those — it only
149
+ * does the write + marker persistence. (v2.82.0: dropped MEM_QUIET_HOOKS gate;
150
+ * quiet is a stdout control, not a side-effect control.)
130
151
  *
131
152
  * Behavior:
132
- * - Writes plugin sentinel + detail doc to the memdir for `cwd`.
153
+ * - If `<memdir>/.mem-no-auto-adopt` exists: skip silently, do NOT write the
154
+ * runtime marker. This keeps `--enable` re-armable: deleting the disable
155
+ * sentinel lets the next SessionStart try again.
156
+ * - Else: writes plugin sentinel + detail doc to the memdir for `cwd`.
133
157
  * - Writes a per-project first-attempt marker under `markerDir` so a later
134
158
  * `/unadopt` is respected (no re-adopt loop).
135
159
  * - Silent: never logs, never throws. Returns structured result.
@@ -139,6 +163,9 @@ function adoptOne(memdir, { force, dryRun, all }) {
139
163
  export function silentAutoAdopt({ cwd, markerDir, markerKey }) {
140
164
  const memdir = memdirPath(cwd);
141
165
  try {
166
+ if (isAutoAdoptDisabled(memdir)) {
167
+ return { ok: true, action: 'disabled', reason: 'disabled-by-sentinel' };
168
+ }
142
169
  if (isAdopted(memdir, PLUGIN_SLUG)) {
143
170
  writeMarker(markerDir, markerKey);
144
171
  return { ok: true, action: 'already-adopted' };
@@ -173,20 +200,110 @@ export function hasAutoAdoptMarker(markerDir, markerKey) {
173
200
  return existsSync(join(markerDir, `.auto-adopt-${markerKey}`));
174
201
  }
175
202
 
203
+ /**
204
+ * cmdDisable — `claude-mem-lite adopt --disable [--all]`.
205
+ *
206
+ * Writes `<memdir>/.mem-no-auto-adopt` so SessionStart auto-adopt skips this
207
+ * project permanently. Idempotent: re-running on an already-disabled memdir is
208
+ * a no-op. Does NOT remove an existing sentinel — pair with `unadopt` if you
209
+ * want both. The two operations are deliberately separate:
210
+ * - `unadopt` = "remove the contract now"
211
+ * - `adopt --disable` = "and don't auto-write it back"
212
+ */
213
+ function cmdDisable(args) {
214
+ const all = hasFlag(args, '--all');
215
+ const targets = all
216
+ ? listAllMemdirs().map((m) => m.memdir)
217
+ : [memdirPath(detectCwd())];
218
+
219
+ if (targets.length === 0) {
220
+ log('[adopt --disable] no memdirs found');
221
+ return;
222
+ }
223
+
224
+ let disabled = 0, already = 0;
225
+ for (const memdir of targets) {
226
+ if (!existsSync(memdir)) mkdirSync(memdir, { recursive: true });
227
+ const path = disableSentinelPath(memdir);
228
+ if (existsSync(path)) {
229
+ log(`[adopt --disable] ${memdir} → already-disabled`);
230
+ already++;
231
+ continue;
232
+ }
233
+ writeFileSync(path, JSON.stringify({ disabledAt: new Date().toISOString() }) + '\n');
234
+ log(`[adopt --disable] ${memdir} → disabled`);
235
+ disabled++;
236
+ }
237
+
238
+ log('');
239
+ log(`[adopt --disable] ${targets.length} target(s): ${disabled} newly disabled, ${already} already disabled`);
240
+ }
241
+
242
+ /**
243
+ * cmdEnable — `claude-mem-lite adopt --enable [--all]`.
244
+ *
245
+ * Removes the `<memdir>/.mem-no-auto-adopt` sentinel so the next SessionStart
246
+ * can auto-adopt again. Idempotent. Does NOT trigger an immediate adoption —
247
+ * run plain `claude-mem-lite adopt` if you want that now.
248
+ */
249
+ function cmdEnable(args) {
250
+ const all = hasFlag(args, '--all');
251
+ const targets = all
252
+ ? listAllMemdirs().map((m) => m.memdir)
253
+ : [memdirPath(detectCwd())];
254
+
255
+ if (targets.length === 0) {
256
+ log('[adopt --enable] no memdirs found');
257
+ return;
258
+ }
259
+
260
+ let enabled = 0, absent = 0;
261
+ for (const memdir of targets) {
262
+ const path = disableSentinelPath(memdir);
263
+ if (!existsSync(path)) {
264
+ log(`[adopt --enable] ${memdir} → absent`);
265
+ absent++;
266
+ continue;
267
+ }
268
+ try { unlinkSync(path); } catch { /* best-effort */ }
269
+ log(`[adopt --enable] ${memdir} → enabled`);
270
+ enabled++;
271
+ }
272
+
273
+ log('');
274
+ log(`[adopt --enable] ${targets.length} target(s): ${enabled} re-enabled, ${absent} not-disabled`);
275
+ }
276
+
176
277
  function statusAll() {
177
278
  const dirs = listAllMemdirs();
178
279
  log('[adopt --status] scanning ~/.claude/projects/*/memory/');
179
280
  if (dirs.length === 0) { log(' (no memdirs found)'); return; }
180
- let adopted = 0;
281
+ let adopted = 0, disabled = 0;
181
282
  for (const { projectSlug, memdir } of dirs) {
182
- if (isAdopted(memdir, PLUGIN_SLUG)) {
283
+ const isAdoptedHere = isAdopted(memdir, PLUGIN_SLUG);
284
+ const isDisabledHere = isAutoAdoptDisabled(memdir);
285
+ if (isAdoptedHere) {
183
286
  const idx = readMemoryIndex(memdir, PLUGIN_SLUG);
184
- log(` ✓ ${projectSlug} (${idx.version})`);
287
+ const suffix = isDisabledHere ? ' [auto-adopt disabled]' : '';
288
+ log(` ✓ ${projectSlug} (${idx.version})${suffix}`);
185
289
  adopted++;
290
+ if (isDisabledHere) disabled++;
291
+ } else if (isDisabledHere) {
292
+ log(` ✗ ${projectSlug} (auto-adopt disabled, no sentinel)`);
293
+ disabled++;
186
294
  }
187
295
  }
188
296
  log('');
189
- log(`[adopt --status] ${adopted}/${dirs.length} adopted`);
297
+ log(`[adopt --status] ${adopted}/${dirs.length} adopted${disabled > 0 ? `, ${disabled} disabled` : ''}`);
298
+
299
+ // Gating snapshot — helps debug "why didn't auto-adopt fire?"
300
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ? 'set' : 'unset';
301
+ const noAutoAdopt = process.env.MEM_NO_AUTO_ADOPT === '1' ? '1 (opt-out)' : 'unset';
302
+ log('');
303
+ log('Auto-adopt gates (next SessionStart will fire only if both pass):');
304
+ log(` CLAUDE_PLUGIN_ROOT = ${pluginRoot} (plugin-mode install required; npx stays opt-in)`);
305
+ log(` MEM_NO_AUTO_ADOPT = ${noAutoAdopt} (global escape hatch)`);
306
+ log('Per-project opt-out: `claude-mem-lite adopt --disable` (run --enable to re-arm).');
190
307
  }
191
308
 
192
309
  /**
package/hook.mjs CHANGED
@@ -61,6 +61,7 @@ import { checkForUpdate } from './hook-update.mjs';
61
61
  import { handleLLMOptimize } from './hook-optimize.mjs';
62
62
  import { silentAutoAdopt, hasAutoAdoptMarker } from './adopt-cli.mjs';
63
63
  import { emitV270UpgradeBanner } from './lib/upgrade-banner.mjs';
64
+ import { loadCiteBackForEpisode } from './lib/cite-back-hint.mjs';
64
65
  // plugin-cache-guard.mjs loaded dynamically — pre-2.31.2 installs that auto-upgraded
65
66
  // from an older hook-update.mjs SOURCE_FILES (which did not list this module) would
66
67
  // crash on static import. Degrade gracefully to no-op when the module is absent.
@@ -196,6 +197,12 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
196
197
  const filesHint = uniqueFiles.length > 0 ? ` (${uniqueFiles.join(', ')})` : '';
197
198
  lines.push(`[mem] 💡 error→fix pattern${filesHint} — consider: mem_save(type="bugfix", lesson_learned="<root cause + fix>")`);
198
199
  }
200
+ // v2.81: cite-back hint — fires when this episode edits a file that
201
+ // PreToolUse:Read/Edit nudged earlier in the same session. Precision
202
+ // signal (we know the file was warned about); orthogonal to the
203
+ // error→fix pattern above and may co-fire.
204
+ const citeBack = loadCiteBackForEpisode(episode, RUNTIME_DIR);
205
+ if (citeBack) lines.push(citeBack);
199
206
  process.stdout.write(JSON.stringify({
200
207
  suppressOutput: true,
201
208
  hookSpecificOutput: {
@@ -645,22 +652,27 @@ async function handleSessionStart() {
645
652
  }
646
653
  } catch (e) { debugCatch(e, 'session-start-cache-heal'); }
647
654
 
648
- // v2.33.0: plugin-mode first-run auto-adopt. /plugin install IS consent to
649
- // integrationwriting the MEMORY.md sentinel once per project on first
650
- // SessionStart avoids the opt-in friction. Scope is narrow:
651
- // - gated by CLAUDE_PLUGIN_ROOT (npm/npx installs stay opt-in)
652
- // - gated by !MEM_NO_AUTO_ADOPT (explicit escape hatch)
653
- // - gated by !MEM_QUIET_HOOKS (quiet = no side-effects semantics)
655
+ // First-run auto-adopt (v2.33.0 plugin-mode v2.82.1 install-mode-agnostic).
656
+ // ANY install path `/plugin install`, `npm install -g`, `npx`, manual is
657
+ // consent to integration. Writing the MEMORY.md sentinel once per project on
658
+ // first SessionStart avoids the opt-in friction that left ~zero users on
659
+ // auto-adopt (runtime-marker directory was empty machine-wide despite v2.33
660
+ // shipping ~5 weeks earlier `install.mjs`-written hooks don't propagate
661
+ // ${CLAUDE_PLUGIN_ROOT}, so the v2.33.0 gate was a no-op for npm/manual
662
+ // installs, which is most of them). Scope is now:
663
+ // - gated by !MEM_NO_AUTO_ADOPT (explicit global escape hatch)
664
+ // - per-project opt-out via `<memdir>/.mem-no-auto-adopt` sentinel
665
+ // (managed by `claude-mem-lite adopt --disable / --enable`); checked
666
+ // inside silentAutoAdopt so the helper is safe to call directly too.
654
667
  // - first-attempt marker persists in RUNTIME_DIR so a subsequent /unadopt
655
668
  // is respected (no re-adopt loop).
669
+ // Note v2.82.0: removed MEM_QUIET_HOOKS gate. That env var suppresses stdout
670
+ // noise; it must NOT also disable side-effect work (PostToolUse writes the
671
+ // DB unconditionally — auto-adopt should follow the same rule).
656
672
  // Failures (user-edited sentinel, budget exceeded, FS errors) are swallowed;
657
673
  // the marker is still written so we don't retry on every SessionStart.
658
674
  try {
659
- if (
660
- process.env.CLAUDE_PLUGIN_ROOT
661
- && process.env.MEM_NO_AUTO_ADOPT !== '1'
662
- && process.env.MEM_QUIET_HOOKS !== '1'
663
- ) {
675
+ if (process.env.MEM_NO_AUTO_ADOPT !== '1') {
664
676
  const project = inferProject();
665
677
  if (!hasAutoAdoptMarker(RUNTIME_DIR, project)) {
666
678
  const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -0,0 +1,70 @@
1
+ // claude-mem-lite: PostToolUse cite-back hint builder.
2
+ //
3
+ // Fires when a flushed episode edits a file that PreToolUse:Read/Edit had
4
+ // nudged earlier in the same session — the canonical "you fixed something we
5
+ // warned about, save the lesson?" moment.
6
+ //
7
+ // Pure function: takes an episode + the session-scoped pre-recall cooldown
8
+ // object and returns a hint string (or null). Cooldown I/O lives elsewhere so
9
+ // this stays unit-testable without disk fixtures.
10
+ //
11
+ // Cooldown schema (post-v2.81): { "<path>": { ts: <number>, lessonIds: [#NN, ...] } }
12
+ // Legacy schema (pre-v2.81): { "<path>": <number> } — tolerated, never emits.
13
+
14
+ import { basename, join } from 'path';
15
+ import { readFileSync } from 'fs';
16
+ import { EDIT_TOOLS } from '../utils.mjs';
17
+
18
+ const MAX_FILES = 2;
19
+
20
+ export function buildCiteBackHint(episode, cooldown) {
21
+ if (!episode || !cooldown) return null;
22
+ const entries = episode.entries;
23
+ if (!Array.isArray(entries) || entries.length === 0) return null;
24
+
25
+ const seen = new Set();
26
+ const matches = [];
27
+ for (const e of entries) {
28
+ if (!EDIT_TOOLS.has(e.tool)) continue;
29
+ for (const file of e.files || []) {
30
+ if (seen.has(file)) continue;
31
+ const entry = cooldown[file];
32
+ if (!entry || typeof entry !== 'object') continue;
33
+ const ids = Array.isArray(entry.lessonIds) ? entry.lessonIds : null;
34
+ if (!ids || ids.length === 0) continue;
35
+ seen.add(file);
36
+ matches.push({ file, ids });
37
+ if (matches.length >= MAX_FILES) break;
38
+ }
39
+ if (matches.length >= MAX_FILES) break;
40
+ }
41
+
42
+ if (matches.length === 0) return null;
43
+
44
+ const lines = ['[mem] 💡 Cite-back: you edited file(s) that received PreToolUse lessons this session.'];
45
+ for (const m of matches) {
46
+ const fname = basename(m.file);
47
+ const idList = m.ids.map(id => `#${id}`).join(', ');
48
+ lines.push(` • ${fname} ← ${idList} — if you fixed it: /lesson --file ${fname} "<root cause + fix>"`);
49
+ }
50
+ return lines.join('\n');
51
+ }
52
+
53
+ // Path scheme MUST mirror scripts/pre-tool-recall.js cooldownPathFor() — drift
54
+ // silently zeros cite-back across the release. Pinned by tests/cite-back-hint.test.mjs
55
+ // 'sanitizes the sessionId the same way pre-tool-recall.js does'.
56
+ function cooldownPathFor(sessionId, runtimeDir) {
57
+ const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
58
+ return join(runtimeDir, `pre-recall-cooldown-${safe}.json`);
59
+ }
60
+
61
+ export function loadCiteBackForEpisode(episode, runtimeDir) {
62
+ if (!episode || !episode.sessionId || !runtimeDir) return null;
63
+ let cooldown;
64
+ try {
65
+ cooldown = JSON.parse(readFileSync(cooldownPathFor(episode.sessionId, runtimeDir), 'utf8'));
66
+ } catch {
67
+ return null;
68
+ }
69
+ return buildCiteBackHint(episode, cooldown);
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.80.1",
3
+ "version": "2.82.1",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -57,6 +57,7 @@
57
57
  "lib/low-signal-patterns.mjs",
58
58
  "lib/private-strip.mjs",
59
59
  "lib/citation-tracker.mjs",
60
+ "lib/cite-back-hint.mjs",
60
61
  "lib/summary-extractor.mjs",
61
62
  "lib/id-routing.mjs",
62
63
  "lib/err-sampler.mjs",
@@ -49,6 +49,15 @@ function readCooldown(cooldownPath) {
49
49
  try { return JSON.parse(readFileSync(cooldownPath, 'utf8')); } catch { return {}; }
50
50
  }
51
51
 
52
+ // v2.81: cooldown entries are {ts, lessonIds} objects so the PostToolUse
53
+ // cite-back hint can name the lessons that were nudged. Legacy entries
54
+ // (pre-v2.81) are bare numbers — entryTimestamp() reads both shapes.
55
+ function entryTimestamp(v) {
56
+ if (typeof v === 'number') return v;
57
+ if (v && typeof v === 'object' && typeof v.ts === 'number') return v.ts;
58
+ return 0;
59
+ }
60
+
52
61
  function writeCooldown(cooldownPath, data, isSessionScoped) {
53
62
  try {
54
63
  mkdirSync(RUNTIME_DIR, { recursive: true });
@@ -59,7 +68,8 @@ function writeCooldown(cooldownPath, data, isSessionScoped) {
59
68
  const cleaned = isSessionScoped ? data : {};
60
69
  if (!isSessionScoped) {
61
70
  for (const [k, v] of Object.entries(data)) {
62
- if (now - v < STALE_MS) cleaned[k] = v;
71
+ const ts = entryTimestamp(v);
72
+ if (ts && now - ts < STALE_MS) cleaned[k] = v;
63
73
  }
64
74
  }
65
75
  writeFileSync(cooldownPath, JSON.stringify(cleaned));
@@ -124,7 +134,8 @@ try {
124
134
  if (isSessionScoped) {
125
135
  if (cooldown[filePath]) process.exit(0); // already recalled this file in-session
126
136
  } else {
127
- if (cooldown[filePath] && (now - cooldown[filePath]) < COOLDOWN_MS) process.exit(0);
137
+ const ts = entryTimestamp(cooldown[filePath]);
138
+ if (ts && (now - ts) < COOLDOWN_MS) process.exit(0);
128
139
  }
129
140
 
130
141
  // Open DB readonly
@@ -275,7 +286,10 @@ try {
275
286
  // Cooldown applies on ALL branches (including silent-Read) so subsequent
276
287
  // calls on the same file in the same session don't re-query — preserving
277
288
  // the per-filePath invariant that underpins Read→Edit dedup.
278
- cooldown[filePath] = now;
289
+ // v2.81: record the emitted lesson IDs so flushEpisode (hook.mjs) can
290
+ // build the PostToolUse cite-back hint when the user actually edits the
291
+ // file. Empty array on no-lesson branches keeps the schema uniform.
292
+ cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id) };
279
293
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
280
294
  } catch (e) {
281
295
  // Silent failure — never block editing, but record for self-observation.
package/source-files.mjs CHANGED
@@ -40,6 +40,7 @@ export const SOURCE_FILES = [
40
40
  'lib/low-signal-patterns.mjs',
41
41
  'lib/private-strip.mjs',
42
42
  'lib/citation-tracker.mjs',
43
+ 'lib/cite-back-hint.mjs',
43
44
  'lib/summary-extractor.mjs',
44
45
  'lib/id-routing.mjs',
45
46
  'lib/err-sampler.mjs',