claude-mem-lite 2.99.0 → 3.0.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.99.0",
13
+ "version": "3.0.1",
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": "2.99.0",
3
+ "version": "3.0.1",
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"
@@ -0,0 +1,160 @@
1
+ // lib/file-intel.mjs — pure, zero-dependency builder for the PreToolUse:Read
2
+ // "file intelligence" injection (feature ①). Before Claude reads a file, surface
3
+ // its approximate token size + a one-line "what's in it" so the agent can decide
4
+ // to read fully, read a slice, or grep instead.
5
+ //
6
+ // Imported by the hot standalone scripts/pre-tool-recall.js, so it MUST stay
7
+ // dependency-free and cheap: one bounded file read + regex, no heavy imports.
8
+ // (Lesson #8447: fast-path scripts can't pull in utils.mjs, which drags in
9
+ // child_process/nlp/scoring-sql.) estimateContentTokens is therefore a hand-
10
+ // mirror of utils.estimateTokens; tests/file-intel.test.mjs pins the two so a
11
+ // change to the canonical estimator surfaces as a failing mirror test.
12
+
13
+ import { statSync, openSync, readSync, closeSync } from 'fs';
14
+ import { basename as pathBasename } from 'path';
15
+
16
+ const SUMMARY_MAX = 80;
17
+ const DEFAULT_MIN_TOKENS = 800;
18
+ const DEFAULT_MAX_READ_BYTES = 24 * 1024;
19
+
20
+ // Mirror of utils.estimateTokens (ASCII ~4 chars/token, CJK ~1.5). Kept local so
21
+ // the standalone hook stays lean — see file header + the mirror test.
22
+ export function estimateContentTokens(text) {
23
+ const s = text || '';
24
+ if (!s) return 1;
25
+ let cjkCount = 0;
26
+ for (let i = 0; i < s.length; i++) {
27
+ const c = s.charCodeAt(i);
28
+ if ((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf) ||
29
+ (c >= 0x3000 && c <= 0x303f) || (c >= 0xff00 && c <= 0xffef) ||
30
+ (c >= 0xac00 && c <= 0xd7af)) {
31
+ cjkCount++;
32
+ }
33
+ }
34
+ const asciiLen = s.length - cjkCount;
35
+ return Math.max(1, Math.ceil(asciiLen / 4) + Math.ceil(cjkCount / 1.5));
36
+ }
37
+
38
+ // 850 → "850", 6100 → "6.1k", 12000 → "12k".
39
+ export function humanTokens(n) {
40
+ if (n < 1000) return String(n);
41
+ if (n < 10000) return (n / 1000).toFixed(1) + 'k';
42
+ return Math.round(n / 1000) + 'k';
43
+ }
44
+
45
+ function cap(s) {
46
+ const t = s.replace(/\s+/g, ' ').trim();
47
+ return t.length <= SUMMARY_MAX ? t : t.slice(0, SUMMARY_MAX - 1) + '…';
48
+ }
49
+
50
+ function isGenericComment(text) {
51
+ if (/^[-=*_#·]{3,}$/.test(text)) return true;
52
+ const l = text.toLowerCase();
53
+ return l.startsWith('eslint') || l.startsWith('prettier') || l.startsWith('tslint') ||
54
+ l.startsWith('stylelint') || l.startsWith('istanbul') || l.startsWith('c8 ') ||
55
+ l.startsWith('copyright') || l.startsWith('license') || l.startsWith('spdx') ||
56
+ l.startsWith('use strict') || l.startsWith('@') || l.startsWith('global ') ||
57
+ l.startsWith('generated') || l.startsWith('auto-generated') || l.startsWith('nolint');
58
+ }
59
+
60
+ // First meaningful header comment in the first 15 lines, skipping blanks,
61
+ // shebangs, and boilerplate (eslint/license/etc). Stops at the first real code
62
+ // line so we don't scan deep into the body.
63
+ function extractHeaderComment(content) {
64
+ const lines = content.split('\n');
65
+ const limit = Math.min(lines.length, 15);
66
+ for (let i = 0; i < limit; i++) {
67
+ const t = lines[i].trim();
68
+ if (!t) continue;
69
+ if (t.startsWith('#!')) continue; // shebang
70
+ const m = t.match(/^(?:\/\/\/?|#|--|\/\*\*?|\*)\s*(.+)/);
71
+ if (m) {
72
+ const text = m[1].replace(/\*\/\s*$/, '').trim();
73
+ if (text.length > 4 && !isGenericComment(text)) return text;
74
+ continue;
75
+ }
76
+ break; // real code line — no header comment
77
+ }
78
+ return '';
79
+ }
80
+
81
+ function extractExports(content) {
82
+ const names = [];
83
+ const re = /export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|interface|type|enum)\s+(\w+)/g;
84
+ let m;
85
+ while ((m = re.exec(content)) !== null) {
86
+ if (!names.includes(m[1])) names.push(m[1]);
87
+ }
88
+ if (names.length === 0) return '';
89
+ const shown = names.slice(0, 5).join(', ');
90
+ return names.length > 5 ? `Exports ${shown} + ${names.length - 5} more` : `Exports ${shown}`;
91
+ }
92
+
93
+ // Best-effort one-line "what's in it". '' when nothing useful is found.
94
+ export function extractFileSummary(content, filename) {
95
+ const src = content || '';
96
+ if (!src.trim()) return '';
97
+ const name = (filename || '').toLowerCase();
98
+ const dot = name.lastIndexOf('.');
99
+ const ext = dot >= 0 ? name.slice(dot) : '';
100
+
101
+ if (ext === '.md' || ext === '.mdx') {
102
+ const m = src.match(/^#{1,6}\s+(.+)$/m);
103
+ if (m) return cap(m[1]);
104
+ }
105
+
106
+ if (ext === '.json') {
107
+ try {
108
+ const obj = JSON.parse(src);
109
+ if (obj && typeof obj.description === 'string' && obj.description.trim()) return cap(obj.description);
110
+ if (obj && typeof obj.name === 'string' && obj.name.trim()) return cap(obj.name);
111
+ } catch { /* partial / invalid JSON — no summary */ }
112
+ return '';
113
+ }
114
+
115
+ const hdr = extractHeaderComment(src);
116
+ if (hdr) return cap(hdr);
117
+
118
+ if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) {
119
+ const exp = extractExports(src);
120
+ if (exp) return cap(exp);
121
+ }
122
+
123
+ return '';
124
+ }
125
+
126
+ export function formatFileIntelLine({ basename, tokens, summary }) {
127
+ const head = `[mem] 📄 ${basename} ~${humanTokens(tokens)} tok`;
128
+ return summary ? `${head} · ${summary}` : head;
129
+ }
130
+
131
+ // IO wrapper: returns the formatted intel line for filePath, or null when the
132
+ // file is unreadable or below the token threshold. Never throws — it runs inside
133
+ // a PreToolUse hook that must always exit 0.
134
+ export function fileIntelFor(filePath, opts = {}) {
135
+ const minTokens = opts.minTokens ?? DEFAULT_MIN_TOKENS;
136
+ const maxReadBytes = opts.maxReadBytes ?? DEFAULT_MAX_READ_BYTES;
137
+
138
+ let size;
139
+ try {
140
+ const st = statSync(filePath);
141
+ if (!st.isFile()) return null;
142
+ size = st.size;
143
+ } catch { return null; }
144
+
145
+ try {
146
+ const fd = openSync(filePath, 'r');
147
+ try {
148
+ const buf = Buffer.allocUnsafe(Math.min(size, maxReadBytes));
149
+ const n = buf.length > 0 ? readSync(fd, buf, 0, buf.length, 0) : 0;
150
+ const sample = buf.subarray(0, n).toString('utf8');
151
+ // Files within the read window are estimated exactly; larger files estimate
152
+ // from byte size (≈4 ASCII bytes/token). The '~' already signals approximation
153
+ // and we never slurp a multi-MB file inside a hook.
154
+ const tokens = size <= maxReadBytes ? estimateContentTokens(sample) : Math.ceil(size / 4);
155
+ if (tokens < minTokens) return null;
156
+ const summary = extractFileSummary(sample, filePath);
157
+ return formatFileIntelLine({ basename: pathBasename(filePath), tokens, summary });
158
+ } finally { closeSync(fd); }
159
+ } catch { return null; }
160
+ }
@@ -0,0 +1,55 @@
1
+ // lib/reread-guard.mjs — pure logic + one IO helper for feature ② (repeated-read
2
+ // guard). When the agent does a full Read of a file it already read this session
3
+ // and the file is unchanged, nudge it to reuse what it has instead of re-slurping.
4
+ //
5
+ // Imported by the hot standalone scripts/pre-tool-recall.js — stays light, reuses
6
+ // the token estimator from ./file-intel.mjs (also pure). Never throws.
7
+ //
8
+ // False-positive guards (the bit OpenWolf's equivalent omits — its "unless
9
+ // modified" lives only in instructions, not the hook):
10
+ // - full-vs-full only: paging with offset/limit never warns
11
+ // - mtime check: a file changed since the prior read never warns
12
+ // - token floor: re-reading a tiny file is cheap, not worth a nudge
13
+
14
+ import { statSync, openSync, readSync, closeSync } from 'fs';
15
+ import { estimateContentTokens, humanTokens } from './file-intel.mjs';
16
+
17
+ const DEFAULT_MIN_TOKENS = 600;
18
+ const DEFAULT_MAX_READ_BYTES = 24 * 1024;
19
+
20
+ // IO: { mtimeMs, tokens } for an on-disk file, or null. Never throws.
21
+ export function readFileMeta(filePath, maxReadBytes = DEFAULT_MAX_READ_BYTES) {
22
+ let st;
23
+ try {
24
+ st = statSync(filePath);
25
+ if (!st.isFile()) return null;
26
+ } catch { return null; }
27
+
28
+ const size = st.size;
29
+ if (size > maxReadBytes) {
30
+ return { mtimeMs: st.mtimeMs, tokens: Math.ceil(size / 4) };
31
+ }
32
+ try {
33
+ const fd = openSync(filePath, 'r');
34
+ try {
35
+ const buf = Buffer.allocUnsafe(size);
36
+ const n = size > 0 ? readSync(fd, buf, 0, size, 0) : 0;
37
+ return { mtimeMs: st.mtimeMs, tokens: estimateContentTokens(buf.subarray(0, n).toString('utf8')) };
38
+ } finally { closeSync(fd); }
39
+ } catch { return null; }
40
+ }
41
+
42
+ // Pure: should a repeat read warn? recorded = { mtimeMs, tokens, full }.
43
+ export function shouldWarnReread(recorded, currentMtimeMs, isFullRead, minTokens = DEFAULT_MIN_TOKENS) {
44
+ if (!recorded || typeof recorded !== 'object') return false;
45
+ if (!recorded.full || !isFullRead) return false; // only full-vs-full re-reads
46
+ if (!(recorded.tokens >= minTokens)) return false; // big enough to matter
47
+ if (currentMtimeMs === null || currentMtimeMs === undefined) return false;
48
+ return currentMtimeMs <= recorded.mtimeMs; // unchanged since last read
49
+ }
50
+
51
+ // Pure: the warning line (no framing — the hook prepends the shared framing line).
52
+ export function buildRereadWarning(basename, tokens) {
53
+ return `[mem] 🔁 You already read ${basename} this session (~${humanTokens(tokens)} tok, unchanged) `
54
+ + `— reuse what you have instead of re-reading; pass offset/limit if you need a specific part.`;
55
+ }
package/mem-cli.mjs CHANGED
@@ -39,6 +39,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
39
39
  import { buildSearchFtsQuery, parseDateBounds, computePerSourceWindow, effectiveObsFtsQuery, searchSessionsFts, searchPromptsFts, normalizeCrossSourceScores, applyUserSort, applyTierFilter } from './lib/search-core.mjs';
40
40
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
41
41
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
42
+ import { aggregateMetrics } from './lib/metrics.mjs';
42
43
  import {
43
44
  insertDeferred, listOpenWithOrdinal, dropDeferred,
44
45
  resolveDeferredIds, closeDeferredItems,
@@ -1154,6 +1155,13 @@ async function cmdStats(db, args) {
1154
1155
  out(` Low-value (imp=1, never accessed, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`);
1155
1156
  out(` Compressed: ${compressedCount.c}`);
1156
1157
  out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
1158
+ // Tier-1 firing counters for ① file-intel + ② reread-guard (recorded by
1159
+ // pre-tool-recall.js via lib/metrics.mjs; CLAUDE_MEM_METRICS=1 to enable).
1160
+ const featAgg = aggregateMetrics(DB_DIR, 7);
1161
+ const fiN = featAgg.file_intel?.count ?? 0;
1162
+ const rrN = featAgg.reread_warn?.count ?? 0;
1163
+ const metricsOn = process.env.CLAUDE_MEM_METRICS === '1';
1164
+ out(` Feature injections (7d): 📄 file-intel ${fiN} · 🔁 reread-warn ${rrN}${(!metricsOn && fiN + rrN === 0) ? ' (set CLAUDE_MEM_METRICS=1 to record)' : ''}`);
1157
1165
  if (noiseRatio > 0.6) out(' ⚠️ High noise ratio — consider running mem compress');
1158
1166
  out('');
1159
1167
  // Tier counts only live (uncompressed, non-superseded) observations — surface the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.99.0",
3
+ "version": "3.0.1",
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",
@@ -63,6 +63,8 @@
63
63
  "lib/id-routing.mjs",
64
64
  "lib/err-sampler.mjs",
65
65
  "lib/hook-telemetry.mjs",
66
+ "lib/file-intel.mjs",
67
+ "lib/reread-guard.mjs",
66
68
  "lib/metrics.mjs",
67
69
  "lib/binding-probe.mjs",
68
70
  "lib/mem-override.mjs",
@@ -140,7 +142,7 @@
140
142
  "zod": "^4.3.6"
141
143
  },
142
144
  "overrides": {
143
- "hono": ">=4.12.21",
145
+ "hono": ">=4.12.26",
144
146
  "fast-uri": ">=3.1.2",
145
147
  "ip-address": ">=10.1.1"
146
148
  },
@@ -10,6 +10,9 @@ import { homedir } from 'os';
10
10
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
11
11
  import { recordHookError } from '../lib/hook-telemetry.mjs';
12
12
  import { citeFactorClause } from '../scoring-sql.mjs';
13
+ import { fileIntelFor } from '../lib/file-intel.mjs';
14
+ import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
15
+ import { recordMetric } from '../lib/metrics.mjs';
13
16
 
14
17
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
15
18
  // whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
@@ -39,6 +42,23 @@ const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
39
42
  || process.env.CLAUDE_MEM_SALIENCE === '0';
40
43
  const ACK_DIRECTIVE = "apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
41
44
  const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
45
+ // Feature ① (file intelligence): on the first Read of a file each session, inject
46
+ // its approximate token size + a one-line summary so the agent can decide to read
47
+ // fully, slice, or grep. Read-only (Edit/Write already commit to the file). Default
48
+ // ON; CLAUDE_MEM_FILE_INTEL=0 disables. Files below the token floor stay silent so
49
+ // small reads carry no noise. Env names mirror schema.mjs CLAUDE_MEM_* convention (#8447).
50
+ const FILE_INTEL_OFF = ['0', 'off', 'false', 'no'].includes(
51
+ String(process.env.CLAUDE_MEM_FILE_INTEL || '').toLowerCase());
52
+ const FILE_INTEL_MIN_TOKENS = Math.max(1,
53
+ parseInt(process.env.CLAUDE_MEM_FILE_INTEL_MIN_TOKENS, 10) || 800);
54
+ // Feature ② (repeated-read guard): when the agent does a FULL re-read of a file
55
+ // it already read this session and the file is unchanged (mtime), nudge it to
56
+ // reuse context instead of re-slurping. Read-only; only fires above the floor and
57
+ // never on offset/limit paging. Default ON; CLAUDE_MEM_REREAD_GUARD=0 disables.
58
+ const REREAD_GUARD_OFF = ['0', 'off', 'false', 'no'].includes(
59
+ String(process.env.CLAUDE_MEM_REREAD_GUARD || '').toLowerCase());
60
+ const REREAD_MIN_TOKENS = Math.max(1,
61
+ parseInt(process.env.CLAUDE_MEM_REREAD_MIN_TOKENS, 10) || 600);
42
62
  // Stale-cooldown GC moved to hook.mjs::handleSessionStart — running it on every
43
63
  // Edit cost 15-30 disk stats per call. SessionStart fires once at session boot,
44
64
  // which is enough to keep RUNTIME_DIR from growing unbounded.
@@ -153,11 +173,17 @@ try {
153
173
  let filePath;
154
174
  let sessionId;
155
175
  let toolName;
176
+ // isFullRead: a Read with no offset/limit reads the whole file. The reread
177
+ // guard only flags full-vs-full re-reads, so paging never trips it.
178
+ let isFullRead = true;
156
179
  try {
157
180
  const event = JSON.parse(input);
158
181
  filePath = event.tool_input?.file_path;
159
182
  sessionId = event.session_id || null;
160
183
  toolName = event.tool_name || null;
184
+ const off = event.tool_input?.offset;
185
+ const lim = event.tool_input?.limit;
186
+ isFullRead = (off === undefined || off === null) && (lim === undefined || lim === null);
161
187
  } catch (e) {
162
188
  recordHookError('pre-recall:json', e, RUNTIME_DIR, { inputLen: input.length });
163
189
  process.exit(0);
@@ -218,6 +244,23 @@ try {
218
244
  }));
219
245
  cooldown[filePath] = { ...entry, mode: 'edit' };
220
246
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
247
+ } else if (isRead && !REREAD_GUARD_OFF && typeof entry === 'object' && entry.reread) {
248
+ // ② repeated-read guard: a full re-read of an unchanged, sizable file —
249
+ // nudge to reuse what's already in context. Read-only; never throws.
250
+ const meta = readFileMeta(filePath);
251
+ if (shouldWarnReread(entry.reread, meta ? meta.mtimeMs : null, isFullRead, REREAD_MIN_TOKENS)) {
252
+ process.stdout.write(JSON.stringify({
253
+ suppressOutput: true,
254
+ hookSpecificOutput: {
255
+ hookEventName: 'PreToolUse',
256
+ additionalContext: [
257
+ '[mem] PreToolUse recall — system-injected context, continue your planned action:',
258
+ buildRereadWarning(basename(filePath), entry.reread.tokens),
259
+ ].join('\n'),
260
+ },
261
+ }));
262
+ recordMetric(DATA_DIR, { event: 'reread_warn' }); // tier-1 firing counter (②)
263
+ }
221
264
  }
222
265
  process.exit(0); // already recalled this file in-session
223
266
  }
@@ -340,6 +383,16 @@ try {
340
383
  // v2.31 T2: emit JSON with hookSpecificOutput.additionalContext so the message
341
384
  // reliably renders across CC variants (sdscc drops plain-text stdout from PreToolUse).
342
385
  // suppressOutput:true hides it from transcript mode per CC hook docs.
386
+ // Feature ①: file intelligence (size + summary) for the first Read of this
387
+ // file this session. Read-only; opt out via CLAUDE_MEM_FILE_INTEL=0. Never
388
+ // throws — fileIntelFor returns null on unreadable/below-threshold files.
389
+ let fileIntelLine = null;
390
+ if (isRead && !FILE_INTEL_OFF) {
391
+ try { fileIntelLine = fileIntelFor(filePath, { minTokens: FILE_INTEL_MIN_TOKENS }); } catch {}
392
+ }
393
+ // Tier-1 firing counter (①). recordMetric no-ops unless CLAUDE_MEM_METRICS=1,
394
+ // so default users pay nothing; observers see counts in `doctor` / `stats`.
395
+ if (fileIntelLine) recordMetric(DATA_DIR, { event: 'file_intel' });
343
396
  const lines = [];
344
397
  // v2.34.6: Read mode uses 120-char truncation (Edit mode keeps the 240-char
345
398
  // cap from R3-UX). Rationale: Read is a one-shot nudge with 1 lesson max;
@@ -347,11 +400,20 @@ try {
347
400
  // carries the actionable "Fix:" guidance — short enough per-lesson at 240,
348
401
  // but the total payload is bounded by the 3-row limit and the cooldown.
349
402
  const LESSON_MAX = isRead ? 120 : 240;
350
- if (allRows.length > 0) {
403
+ // Feature (file-intel): null on Edit/Write and on below-threshold or
404
+ // unreadable files. When present (first Read of a sizable file this session),
405
+ // it leads the injection, above any lessons.
406
+ const hasLessons = allRows.length > 0;
407
+ const showFraming = hasLessons || Boolean(fileIntelLine)
408
+ || (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1');
409
+ if (showFraming) {
351
410
  // Framing line mirrors #7758 handoff-injection fix: without an explicit
352
411
  // "system-injected, continue" disclaimer, observed turn-end after Edit+reminder
353
412
  // when the model misreads passive lesson context as a closing note.
354
413
  lines.push(`[mem] PreToolUse recall — system-injected context, continue your planned action:`);
414
+ }
415
+ if (fileIntelLine) lines.push(fileIntelLine);
416
+ if (hasLessons) {
355
417
  lines.push(`[mem] Lessons for ${fname}:`);
356
418
  for (const r of allRows) {
357
419
  if (r.lesson_learned) {
@@ -386,7 +448,7 @@ try {
386
448
  //
387
449
  // Read never emitted this (passive). The cooldown write below still runs on
388
450
  // every branch, so Read→Edit dedup + cite-back lessonId tracking are intact.
389
- lines.push(`[mem] PreToolUse recall system-injected context, continue your planned action:`);
451
+ // (Framing line already pushed above via showFraming.)
390
452
  lines.push(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`);
391
453
  }
392
454
 
@@ -408,7 +470,16 @@ try {
408
470
  // v2.98: mode records WHERE the injection happened so the Read→Edit ack
409
471
  // nudge can distinguish "lessons seen passively at Read" from "already
410
472
  // surfaced at an action point".
411
- cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id), mode: isRead ? 'read' : 'edit' };
473
+ // repeated-read guard: record file metadata on the first Read so a later
474
+ // full re-read of the unchanged file can be flagged. Read-only, session-scoped;
475
+ // one stat + bounded read, first-read only.
476
+ const rereadMeta = (isRead && !REREAD_GUARD_OFF && isSessionScoped) ? readFileMeta(filePath) : null;
477
+ cooldown[filePath] = {
478
+ ts: now,
479
+ lessonIds: allRows.map(r => r.id),
480
+ mode: isRead ? 'read' : 'edit',
481
+ ...(rereadMeta ? { reread: { mtimeMs: rereadMeta.mtimeMs, tokens: rereadMeta.tokens, full: isFullRead } } : {}),
482
+ };
412
483
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
413
484
  // A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
414
485
  // file so the next UPS prompt skips them too. Always write, even on
package/source-files.mjs CHANGED
@@ -52,6 +52,12 @@ export const SOURCE_FILES = [
52
52
  // and mem-cli.mjs (countRecentHookErrors for `stats`). Missing from
53
53
  // manifest → tarball ships hooks that ERR_MODULE_NOT_FOUND on every fire.
54
54
  'lib/hook-telemetry.mjs',
55
+ // v3.0: read-time file-intelligence (①) + repeated-read guard (②). Imported
56
+ // ONLY by scripts/pre-tool-recall.js (reread-guard also imports file-intel) —
57
+ // NOT reachable from the 5 ENTRY_MODULES, so the hook-script coverage test in
58
+ // source-files-sync.test.mjs is what keeps these from being dropped on bump.
59
+ 'lib/file-intel.mjs',
60
+ 'lib/reread-guard.mjs',
55
61
  'lib/metrics.mjs',
56
62
  // v2.71.x: better-sqlite3 ABI probe + auto-rebuild. Shared by install.mjs
57
63
  // (post-`npm install` verify) and scripts/launch.mjs (pre-server-launch