claude-mem-lite 2.99.0 → 3.0.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/lib/file-intel.mjs +160 -0
- package/lib/reread-guard.mjs +55 -0
- package/package.json +4 -2
- package/scripts/pre-tool-recall.js +69 -3
- package/source-files.mjs +6 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "
|
|
13
|
+
"version": "3.0.0",
|
|
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": "
|
|
3
|
+
"version": "3.0.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
|
"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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.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",
|
|
@@ -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.
|
|
145
|
+
"hono": ">=4.12.26",
|
|
144
146
|
"fast-uri": ">=3.1.2",
|
|
145
147
|
"ip-address": ">=10.1.1"
|
|
146
148
|
},
|
|
@@ -10,6 +10,8 @@ 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';
|
|
13
15
|
|
|
14
16
|
// CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
|
|
15
17
|
// whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
|
|
@@ -39,6 +41,23 @@ const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
|
|
|
39
41
|
|| process.env.CLAUDE_MEM_SALIENCE === '0';
|
|
40
42
|
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
43
|
const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
|
|
44
|
+
// Feature ① (file intelligence): on the first Read of a file each session, inject
|
|
45
|
+
// its approximate token size + a one-line summary so the agent can decide to read
|
|
46
|
+
// fully, slice, or grep. Read-only (Edit/Write already commit to the file). Default
|
|
47
|
+
// ON; CLAUDE_MEM_FILE_INTEL=0 disables. Files below the token floor stay silent so
|
|
48
|
+
// small reads carry no noise. Env names mirror schema.mjs CLAUDE_MEM_* convention (#8447).
|
|
49
|
+
const FILE_INTEL_OFF = ['0', 'off', 'false', 'no'].includes(
|
|
50
|
+
String(process.env.CLAUDE_MEM_FILE_INTEL || '').toLowerCase());
|
|
51
|
+
const FILE_INTEL_MIN_TOKENS = Math.max(1,
|
|
52
|
+
parseInt(process.env.CLAUDE_MEM_FILE_INTEL_MIN_TOKENS, 10) || 800);
|
|
53
|
+
// Feature ② (repeated-read guard): when the agent does a FULL re-read of a file
|
|
54
|
+
// it already read this session and the file is unchanged (mtime), nudge it to
|
|
55
|
+
// reuse context instead of re-slurping. Read-only; only fires above the floor and
|
|
56
|
+
// never on offset/limit paging. Default ON; CLAUDE_MEM_REREAD_GUARD=0 disables.
|
|
57
|
+
const REREAD_GUARD_OFF = ['0', 'off', 'false', 'no'].includes(
|
|
58
|
+
String(process.env.CLAUDE_MEM_REREAD_GUARD || '').toLowerCase());
|
|
59
|
+
const REREAD_MIN_TOKENS = Math.max(1,
|
|
60
|
+
parseInt(process.env.CLAUDE_MEM_REREAD_MIN_TOKENS, 10) || 600);
|
|
42
61
|
// Stale-cooldown GC moved to hook.mjs::handleSessionStart — running it on every
|
|
43
62
|
// Edit cost 15-30 disk stats per call. SessionStart fires once at session boot,
|
|
44
63
|
// which is enough to keep RUNTIME_DIR from growing unbounded.
|
|
@@ -153,11 +172,17 @@ try {
|
|
|
153
172
|
let filePath;
|
|
154
173
|
let sessionId;
|
|
155
174
|
let toolName;
|
|
175
|
+
// isFullRead: a Read with no offset/limit reads the whole file. The reread
|
|
176
|
+
// guard only flags full-vs-full re-reads, so paging never trips it.
|
|
177
|
+
let isFullRead = true;
|
|
156
178
|
try {
|
|
157
179
|
const event = JSON.parse(input);
|
|
158
180
|
filePath = event.tool_input?.file_path;
|
|
159
181
|
sessionId = event.session_id || null;
|
|
160
182
|
toolName = event.tool_name || null;
|
|
183
|
+
const off = event.tool_input?.offset;
|
|
184
|
+
const lim = event.tool_input?.limit;
|
|
185
|
+
isFullRead = (off === undefined || off === null) && (lim === undefined || lim === null);
|
|
161
186
|
} catch (e) {
|
|
162
187
|
recordHookError('pre-recall:json', e, RUNTIME_DIR, { inputLen: input.length });
|
|
163
188
|
process.exit(0);
|
|
@@ -218,6 +243,22 @@ try {
|
|
|
218
243
|
}));
|
|
219
244
|
cooldown[filePath] = { ...entry, mode: 'edit' };
|
|
220
245
|
writeCooldown(cooldownPath, cooldown, isSessionScoped);
|
|
246
|
+
} else if (isRead && !REREAD_GUARD_OFF && typeof entry === 'object' && entry.reread) {
|
|
247
|
+
// ② repeated-read guard: a full re-read of an unchanged, sizable file —
|
|
248
|
+
// nudge to reuse what's already in context. Read-only; never throws.
|
|
249
|
+
const meta = readFileMeta(filePath);
|
|
250
|
+
if (shouldWarnReread(entry.reread, meta ? meta.mtimeMs : null, isFullRead, REREAD_MIN_TOKENS)) {
|
|
251
|
+
process.stdout.write(JSON.stringify({
|
|
252
|
+
suppressOutput: true,
|
|
253
|
+
hookSpecificOutput: {
|
|
254
|
+
hookEventName: 'PreToolUse',
|
|
255
|
+
additionalContext: [
|
|
256
|
+
'[mem] PreToolUse recall — system-injected context, continue your planned action:',
|
|
257
|
+
buildRereadWarning(basename(filePath), entry.reread.tokens),
|
|
258
|
+
].join('\n'),
|
|
259
|
+
},
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
221
262
|
}
|
|
222
263
|
process.exit(0); // already recalled this file in-session
|
|
223
264
|
}
|
|
@@ -340,6 +381,13 @@ try {
|
|
|
340
381
|
// v2.31 T2: emit JSON with hookSpecificOutput.additionalContext so the message
|
|
341
382
|
// reliably renders across CC variants (sdscc drops plain-text stdout from PreToolUse).
|
|
342
383
|
// suppressOutput:true hides it from transcript mode per CC hook docs.
|
|
384
|
+
// Feature ①: file intelligence (size + summary) for the first Read of this
|
|
385
|
+
// file this session. Read-only; opt out via CLAUDE_MEM_FILE_INTEL=0. Never
|
|
386
|
+
// throws — fileIntelFor returns null on unreadable/below-threshold files.
|
|
387
|
+
let fileIntelLine = null;
|
|
388
|
+
if (isRead && !FILE_INTEL_OFF) {
|
|
389
|
+
try { fileIntelLine = fileIntelFor(filePath, { minTokens: FILE_INTEL_MIN_TOKENS }); } catch {}
|
|
390
|
+
}
|
|
343
391
|
const lines = [];
|
|
344
392
|
// v2.34.6: Read mode uses 120-char truncation (Edit mode keeps the 240-char
|
|
345
393
|
// cap from R3-UX). Rationale: Read is a one-shot nudge with 1 lesson max;
|
|
@@ -347,11 +395,20 @@ try {
|
|
|
347
395
|
// carries the actionable "Fix:" guidance — short enough per-lesson at 240,
|
|
348
396
|
// but the total payload is bounded by the 3-row limit and the cooldown.
|
|
349
397
|
const LESSON_MAX = isRead ? 120 : 240;
|
|
350
|
-
|
|
398
|
+
// Feature ① (file-intel): null on Edit/Write and on below-threshold or
|
|
399
|
+
// unreadable files. When present (first Read of a sizable file this session),
|
|
400
|
+
// it leads the injection, above any lessons.
|
|
401
|
+
const hasLessons = allRows.length > 0;
|
|
402
|
+
const showFraming = hasLessons || Boolean(fileIntelLine)
|
|
403
|
+
|| (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1');
|
|
404
|
+
if (showFraming) {
|
|
351
405
|
// Framing line mirrors #7758 handoff-injection fix: without an explicit
|
|
352
406
|
// "system-injected, continue" disclaimer, observed turn-end after Edit+reminder
|
|
353
407
|
// when the model misreads passive lesson context as a closing note.
|
|
354
408
|
lines.push(`[mem] PreToolUse recall — system-injected context, continue your planned action:`);
|
|
409
|
+
}
|
|
410
|
+
if (fileIntelLine) lines.push(fileIntelLine);
|
|
411
|
+
if (hasLessons) {
|
|
355
412
|
lines.push(`[mem] Lessons for ${fname}:`);
|
|
356
413
|
for (const r of allRows) {
|
|
357
414
|
if (r.lesson_learned) {
|
|
@@ -386,7 +443,7 @@ try {
|
|
|
386
443
|
//
|
|
387
444
|
// Read never emitted this (passive). The cooldown write below still runs on
|
|
388
445
|
// every branch, so Read→Edit dedup + cite-back lessonId tracking are intact.
|
|
389
|
-
|
|
446
|
+
// (Framing line already pushed above via showFraming.)
|
|
390
447
|
lines.push(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`);
|
|
391
448
|
}
|
|
392
449
|
|
|
@@ -408,7 +465,16 @@ try {
|
|
|
408
465
|
// v2.98: mode records WHERE the injection happened so the Read→Edit ack
|
|
409
466
|
// nudge can distinguish "lessons seen passively at Read" from "already
|
|
410
467
|
// surfaced at an action point".
|
|
411
|
-
|
|
468
|
+
// ② repeated-read guard: record file metadata on the first Read so a later
|
|
469
|
+
// full re-read of the unchanged file can be flagged. Read-only, session-scoped;
|
|
470
|
+
// one stat + bounded read, first-read only.
|
|
471
|
+
const rereadMeta = (isRead && !REREAD_GUARD_OFF && isSessionScoped) ? readFileMeta(filePath) : null;
|
|
472
|
+
cooldown[filePath] = {
|
|
473
|
+
ts: now,
|
|
474
|
+
lessonIds: allRows.map(r => r.id),
|
|
475
|
+
mode: isRead ? 'read' : 'edit',
|
|
476
|
+
...(rereadMeta ? { reread: { mtimeMs: rereadMeta.mtimeMs, tokens: rereadMeta.tokens, full: isFullRead } } : {}),
|
|
477
|
+
};
|
|
412
478
|
writeCooldown(cooldownPath, cooldown, isSessionScoped);
|
|
413
479
|
// A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
|
|
414
480
|
// 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
|