claude-mem-lite 2.98.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/lib/search-core.mjs +200 -0
- package/lib/timeline-core.mjs +195 -0
- package/mem-cli.mjs +54 -257
- package/package.json +6 -2
- package/scripts/pre-tool-recall.js +69 -3
- package/server.mjs +63 -273
- package/source-files.mjs +13 -0
package/mem-cli.mjs
CHANGED
|
@@ -4,14 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { homedir } from 'os';
|
|
6
6
|
import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
7
|
-
import {
|
|
8
|
-
import { cjkPrecisionOk } from './nlp.mjs';
|
|
9
|
-
import { extractCjkLikePatterns } from './nlp.mjs';
|
|
7
|
+
import { truncate, typeIcon, inferProject, scrubSecrets } from './utils.mjs';
|
|
10
8
|
import { resolveProject } from './project-utils.mjs';
|
|
11
|
-
import {
|
|
9
|
+
import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
12
10
|
import { _resetVocabCache } from './tfidf.mjs';
|
|
13
11
|
import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
|
|
14
|
-
import { searchObservationsHybrid,
|
|
12
|
+
import { searchObservationsHybrid, countSearchTotal } from './search-engine.mjs';
|
|
15
13
|
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
16
14
|
import { searchResources } from './registry-retriever.mjs';
|
|
17
15
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
@@ -37,6 +35,8 @@ import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatP
|
|
|
37
35
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
38
36
|
import { rebuildObservationDerived } from './lib/observation-write.mjs';
|
|
39
37
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
38
|
+
import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
|
|
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
42
|
import {
|
|
@@ -63,11 +63,9 @@ function cmdSearch(db, args) {
|
|
|
63
63
|
}
|
|
64
64
|
const source = flags.source || null; // observations|sessions|prompts (null = all)
|
|
65
65
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (flags.from && isNaN(dateFrom)) { fail(`[mem] Invalid --from date: "${flags.from}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
70
|
-
if (flags.to && isNaN(dateTo)) { fail(`[mem] Invalid --to date: "${flags.to}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
66
|
+
const bounds = parseDateBounds(flags.from, flags.to);
|
|
67
|
+
if (!bounds.ok) { fail(`[mem] Invalid --${bounds.bad} date: "${bounds.value}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
68
|
+
const { epochFrom: dateFrom, epochTo: dateTo } = bounds;
|
|
71
69
|
// Inverted range silently returns 0 rows; warn so users see the cause, don't error
|
|
72
70
|
// (a deliberate "search for nothing in this window" is not malformed input).
|
|
73
71
|
if (dateFrom !== null && dateTo !== null && dateFrom > dateTo) {
|
|
@@ -106,8 +104,7 @@ function cmdSearch(db, args) {
|
|
|
106
104
|
return;
|
|
107
105
|
}
|
|
108
106
|
|
|
109
|
-
|
|
110
|
-
if (ftsQuery && useOr) ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
|
|
107
|
+
const ftsQuery = buildSearchFtsQuery(query, { or: useOr });
|
|
111
108
|
if (!ftsQuery) {
|
|
112
109
|
fail(`[mem] No valid search terms in "${query}"`);
|
|
113
110
|
return;
|
|
@@ -126,17 +123,12 @@ function cmdSearch(db, args) {
|
|
|
126
123
|
const effectiveSource = source || ((type || tier || minImportance || branch) ? 'observations' : null);
|
|
127
124
|
|
|
128
125
|
// Cross-source mode: each source needs more candidates than the final limit
|
|
129
|
-
// so the post-merge sort has room to pick the best from each (
|
|
130
|
-
//
|
|
126
|
+
// so the post-merge sort has room to pick the best from each (shared sizing
|
|
127
|
+
// with mem_search — without this, obs gets systematically squeezed out by
|
|
128
|
+
// sessions). Over-fetch from offset 0; --offset applies ONCE at the final
|
|
129
|
+
// slice below (see computePerSourceWindow for the #8217/#8638 rationale).
|
|
131
130
|
const isCrossSourceMode = !effectiveSource;
|
|
132
|
-
|
|
133
|
-
// ALL modes — mirrors server.mjs. Pushing OFFSET into the obs hybrid path was
|
|
134
|
-
// unreliable: its AND→OR fallback / vector / concept-cooccurrence stages re-add
|
|
135
|
-
// rows the SQL OFFSET already skipped, so engine-side paging dropped (or
|
|
136
|
-
// duplicated) rows on the --type/--tier/--importance/--branch path (a page that
|
|
137
|
-
// MCP returned came back empty).
|
|
138
|
-
const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
|
|
139
|
-
const perSourceOffset = 0;
|
|
131
|
+
const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
|
|
140
132
|
|
|
141
133
|
const results = [];
|
|
142
134
|
// Tracks whether AND returned 0 and OR recovered non-empty. Mirrors server.mjs
|
|
@@ -168,107 +160,31 @@ function cmdSearch(db, args) {
|
|
|
168
160
|
|
|
169
161
|
// Tier post-filter — applied to ALL obs results from the engine.
|
|
170
162
|
if (tier) {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const ph = obsIds.map(() => '?').join(',');
|
|
175
|
-
const fullRows = db.prepare(
|
|
176
|
-
`SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${ph})`
|
|
177
|
-
).all(...obsIds);
|
|
178
|
-
const rowMap = new Map(fullRows.map(r => [r.id, r]));
|
|
179
|
-
const tierCtx = { now: Date.now(), currentProject: project || inferProject(), currentSessionId: '' };
|
|
180
|
-
const allowedIds = new Set();
|
|
181
|
-
for (const [id, full] of rowMap) {
|
|
182
|
-
if (computeTier(full, tierCtx) === tier) allowedIds.add(id);
|
|
183
|
-
}
|
|
184
|
-
for (let i = results.length - 1; i >= 0; i--) {
|
|
185
|
-
if (results[i]._source === 'obs' && !allowedIds.has(results[i].id)) results.splice(i, 1);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
163
|
+
const filtered = applyTierFilter(db, results, { tier, sourceKey: '_source', currentProject: project || inferProject() });
|
|
164
|
+
results.length = 0;
|
|
165
|
+
results.push(...filtered);
|
|
188
166
|
}
|
|
189
167
|
}
|
|
190
168
|
|
|
191
|
-
// Search sessions (
|
|
169
|
+
// Search sessions (shared engine with MCP mem_search — lib/search-core.mjs)
|
|
192
170
|
if (!effectiveSource || effectiveSource === 'sessions') {
|
|
193
|
-
const now = Date.now();
|
|
194
|
-
const sessionProjectBoost = project ? null : inferProject();
|
|
195
|
-
const sessWheres = ['session_summaries_fts MATCH ?'];
|
|
196
|
-
const sessParams = [now, sessionProjectBoost, sessionProjectBoost, ftsQuery];
|
|
197
|
-
if (project) { sessWheres.push('s.project = ?'); sessParams.push(project); }
|
|
198
|
-
if (dateFrom) { sessWheres.push('s.created_at_epoch >= ?'); sessParams.push(dateFrom); }
|
|
199
|
-
if (dateTo) { sessWheres.push('s.created_at_epoch <= ?'); sessParams.push(dateTo); }
|
|
200
|
-
sessParams.push(perSourceLimit, perSourceOffset);
|
|
201
171
|
try {
|
|
202
|
-
const sessRows = db
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
* (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
|
|
207
|
-
FROM session_summaries_fts
|
|
208
|
-
JOIN session_summaries s ON session_summaries_fts.rowid = s.id
|
|
209
|
-
WHERE ${sessWheres.join(' AND ')}
|
|
210
|
-
ORDER BY score
|
|
211
|
-
LIMIT ? OFFSET ?
|
|
212
|
-
`).all(...sessParams);
|
|
172
|
+
const sessRows = searchSessionsFts(db, {
|
|
173
|
+
ftsQuery, project, projectBoost: project ? null : inferProject(),
|
|
174
|
+
epochFrom: dateFrom, epochTo: dateTo, perSourceLimit, perSourceOffset,
|
|
175
|
+
});
|
|
213
176
|
for (const r of sessRows) results.push({ ...r, _source: 'session' });
|
|
214
177
|
} catch { /* session FTS may not exist in older DBs */ }
|
|
215
178
|
}
|
|
216
179
|
|
|
217
|
-
// Search prompts (
|
|
180
|
+
// Search prompts (shared engine incl. CJK precision gate + LIKE fallback)
|
|
218
181
|
if (!effectiveSource || effectiveSource === 'prompts') {
|
|
219
|
-
const promptWheres = ['user_prompts_fts MATCH ?', "p.prompt_text NOT LIKE '<task-notification>%'"];
|
|
220
|
-
const promptParams = [ftsQuery];
|
|
221
|
-
if (project) { promptWheres.push('s.project = ?'); promptParams.push(project); }
|
|
222
|
-
if (dateFrom) { promptWheres.push('p.created_at_epoch >= ?'); promptParams.push(dateFrom); }
|
|
223
|
-
if (dateTo) { promptWheres.push('p.created_at_epoch <= ?'); promptParams.push(dateTo); }
|
|
224
|
-
promptParams.push(perSourceLimit, perSourceOffset);
|
|
225
182
|
try {
|
|
226
|
-
const promptRows = db
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
|
|
232
|
-
WHERE ${promptWheres.join(' AND ')}
|
|
233
|
-
ORDER BY score
|
|
234
|
-
LIMIT ? OFFSET ?
|
|
235
|
-
`).all(...promptParams);
|
|
236
|
-
// CJK precision filter (read-path parity with server.mjs): unicode61
|
|
237
|
-
// degrades bigram queries to single-char AND, letting common-char
|
|
238
|
-
// Chinese prose leak through. Drop rows that miss < 20% of query
|
|
239
|
-
// bigrams/keywords as contiguous substrings.
|
|
240
|
-
const keptPromptRows = promptRows.filter(r => cjkPrecisionOk(query, r.prompt_text));
|
|
241
|
-
for (const r of keptPromptRows) results.push({ ...r, _source: 'prompt' });
|
|
242
|
-
// CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
|
|
243
|
-
if (keptPromptRows.length === 0) {
|
|
244
|
-
const cjkPatterns = extractCjkLikePatterns(query);
|
|
245
|
-
if (cjkPatterns.length > 0) {
|
|
246
|
-
const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
|
|
247
|
-
const likeParams = cjkPatterns.map(p => `%${p}%`);
|
|
248
|
-
if (project) likeParams.push(project);
|
|
249
|
-
if (dateFrom) likeParams.push(dateFrom);
|
|
250
|
-
if (dateTo) likeParams.push(dateTo);
|
|
251
|
-
likeParams.push(perSourceLimit, perSourceOffset);
|
|
252
|
-
const fallbackRows = db.prepare(`
|
|
253
|
-
SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
|
|
254
|
-
FROM user_prompts p
|
|
255
|
-
JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
|
|
256
|
-
WHERE (${likeConds.join(' OR ')})
|
|
257
|
-
AND p.prompt_text NOT LIKE '<task-notification>%'
|
|
258
|
-
${project ? 'AND s.project = ?' : ''}
|
|
259
|
-
${dateFrom ? 'AND p.created_at_epoch >= ?' : ''}
|
|
260
|
-
${dateTo ? 'AND p.created_at_epoch <= ?' : ''}
|
|
261
|
-
ORDER BY p.created_at_epoch DESC
|
|
262
|
-
LIMIT ? OFFSET ?
|
|
263
|
-
`).all(...likeParams);
|
|
264
|
-
// CJK precision filter applies here too: the LIKE fallback is just
|
|
265
|
-
// OR'd substring bigrams; without the precision gate it re-admits
|
|
266
|
-
// the same common-char noise the FTS path dropped (this was the
|
|
267
|
-
// actual leak source — FTS returned 0, fallback filled 20).
|
|
268
|
-
const keptFallback = fallbackRows.filter(r => cjkPrecisionOk(query, r.prompt_text));
|
|
269
|
-
for (const r of keptFallback) results.push({ ...r, _source: 'prompt', score: 0 });
|
|
270
|
-
}
|
|
271
|
-
}
|
|
183
|
+
const promptRows = searchPromptsFts(db, {
|
|
184
|
+
query, ftsQuery, project,
|
|
185
|
+
epochFrom: dateFrom, epochTo: dateTo, perSourceLimit, perSourceOffset,
|
|
186
|
+
});
|
|
187
|
+
for (const r of promptRows) results.push({ ...r, _source: 'prompt' });
|
|
272
188
|
} catch { /* prompt FTS may not exist in older DBs */ }
|
|
273
189
|
}
|
|
274
190
|
|
|
@@ -281,18 +197,11 @@ function cmdSearch(db, args) {
|
|
|
281
197
|
return;
|
|
282
198
|
}
|
|
283
199
|
|
|
284
|
-
// Cross-source score normalization (
|
|
200
|
+
// Cross-source score normalization (shared with mem_search).
|
|
285
201
|
// ftsQuery gate prevents normalization when scores are all 0 (no-FTS path).
|
|
286
202
|
const isCrossSource = isCrossSourceMode;
|
|
287
203
|
if (isCrossSource && results.length > 0 && ftsQuery) {
|
|
288
|
-
|
|
289
|
-
const srcResults = results.filter(r => r._source === src && r.score !== null && r.score !== undefined);
|
|
290
|
-
if (srcResults.length < 2) continue;
|
|
291
|
-
const maxAbs = Math.max(...srcResults.map(r => Math.abs(r.score)));
|
|
292
|
-
if (maxAbs > 0) {
|
|
293
|
-
for (const r of srcResults) r.score = r.score / maxAbs;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
204
|
+
normalizeCrossSourceScores(results, '_source');
|
|
296
205
|
results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
297
206
|
}
|
|
298
207
|
|
|
@@ -306,13 +215,8 @@ function cmdSearch(db, args) {
|
|
|
306
215
|
if (isCrossSource) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
307
216
|
}
|
|
308
217
|
|
|
309
|
-
// Apply user-requested sort (after relevance scoring)
|
|
310
|
-
|
|
311
|
-
results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
|
|
312
|
-
} else if (sort === 'importance') {
|
|
313
|
-
results.sort((a, b) => (b.importance ?? 1) - (a.importance ?? 1) || (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
|
|
314
|
-
}
|
|
315
|
-
// else 'relevance' keeps BM25 score order (already sorted)
|
|
218
|
+
// Apply user-requested sort (after relevance scoring; shared with mem_search)
|
|
219
|
+
applyUserSort(results, sort);
|
|
316
220
|
|
|
317
221
|
// Trim to limit with offset. The engine always received perSourceOffset=0 and
|
|
318
222
|
// over-fetched (see above), so the merged+reranked `results` start at row 0 and
|
|
@@ -326,7 +230,7 @@ function cmdSearch(db, args) {
|
|
|
326
230
|
const trueTotal = countSearchTotal(db, {
|
|
327
231
|
effectiveSource,
|
|
328
232
|
ftsQuery,
|
|
329
|
-
obsFtsQuery:
|
|
233
|
+
obsFtsQuery: effectiveObsFtsQuery(ftsQuery, orFallbackFired),
|
|
330
234
|
args: { project: project || null, obs_type: type || null, importance: minImportance || null, branch: branch || null },
|
|
331
235
|
project: project || null,
|
|
332
236
|
epochFrom: dateFrom,
|
|
@@ -713,113 +617,39 @@ function cmdTimeline(db, args) {
|
|
|
713
617
|
});
|
|
714
618
|
|
|
715
619
|
// Parse --anchor, accepting P#/S#/# prefix so callers can paste search-result IDs verbatim.
|
|
716
|
-
//
|
|
717
|
-
//
|
|
620
|
+
// Resolution ladder (prompt/session → nearest obs, compressed re-anchor, bare-int
|
|
621
|
+
// fallback) is shared with MCP mem_timeline via lib/timeline-core.mjs.
|
|
718
622
|
let anchorId = null;
|
|
719
623
|
let anchorNote = null; // hint line for output when anchor was resolved via conversion
|
|
720
624
|
if (flags.anchor !== undefined && flags.anchor !== true) {
|
|
721
|
-
const
|
|
722
|
-
if (!
|
|
723
|
-
fail(
|
|
625
|
+
const resolved = resolveAnchorToken(db, flags.anchor, { project });
|
|
626
|
+
if (!resolved.ok) {
|
|
627
|
+
fail(formatAnchorError(resolved.error, 'cli'));
|
|
724
628
|
return;
|
|
725
629
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
if (!row) { fail(`[mem] Prompt P#${parsed.id} not found`); return; }
|
|
729
|
-
const proj = project;
|
|
730
|
-
const nearest = db.prepare(`
|
|
731
|
-
SELECT id FROM observations
|
|
732
|
-
WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
|
|
733
|
-
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
734
|
-
`).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
|
|
735
|
-
if (!nearest) { fail(`[mem] No observations near P#${parsed.id}`); return; }
|
|
736
|
-
anchorId = nearest.id;
|
|
737
|
-
anchorNote = `(anchored to #${nearest.id}, closest obs to P#${parsed.id})`;
|
|
738
|
-
} else if (parsed.source === 'session') {
|
|
739
|
-
const row = db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
|
|
740
|
-
if (!row) { fail(`[mem] Session S#${parsed.id} not found`); return; }
|
|
741
|
-
const proj = project;
|
|
742
|
-
const nearest = db.prepare(`
|
|
743
|
-
SELECT id FROM observations
|
|
744
|
-
WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
|
|
745
|
-
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
746
|
-
`).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
|
|
747
|
-
if (!nearest) { fail(`[mem] No observations near S#${parsed.id}`); return; }
|
|
748
|
-
anchorId = nearest.id;
|
|
749
|
-
anchorNote = `(anchored to #${nearest.id}, closest obs to S#${parsed.id})`;
|
|
750
|
-
} else {
|
|
751
|
-
// Bare integer (no prefix): try observation first. Fall back to user_prompts
|
|
752
|
-
// then session_summaries so pasted P#/S# IDs still work when the prefix is
|
|
753
|
-
// omitted — matches the prefix-aware routing used by search/probe.
|
|
754
|
-
const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
|
|
755
|
-
if (obsRow) {
|
|
756
|
-
const ci = obsRow.compressed_into;
|
|
757
|
-
if (ci && ci > 0) {
|
|
758
|
-
// Compressed into a live parent: re-anchor so the window doesn't silently
|
|
759
|
-
// straddle a dead record. Negative sentinels (-1 dropped, -2 pending purge)
|
|
760
|
-
// have no canonical parent — surface an explicit error instead.
|
|
761
|
-
anchorId = ci;
|
|
762
|
-
anchorNote = `(anchored to #${ci}, #${parsed.id} was compressed into it)`;
|
|
763
|
-
} else if (ci && ci < 0) {
|
|
764
|
-
fail(`[mem] Observation #${parsed.id} was compressed and pruned; no canonical anchor available`);
|
|
765
|
-
return;
|
|
766
|
-
} else {
|
|
767
|
-
anchorId = parsed.id;
|
|
768
|
-
}
|
|
769
|
-
} else {
|
|
770
|
-
const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
|
|
771
|
-
const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
|
|
772
|
-
const hit = promptRow ? { row: promptRow, prefix: 'P', name: 'prompt' }
|
|
773
|
-
: sessionRow ? { row: sessionRow, prefix: 'S', name: 'session' }
|
|
774
|
-
: null;
|
|
775
|
-
if (!hit) {
|
|
776
|
-
fail(`[mem] Observation, prompt, or session with id ${parsed.id} not found`);
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
const proj = project;
|
|
780
|
-
const nearest = db.prepare(`
|
|
781
|
-
SELECT id FROM observations
|
|
782
|
-
WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
|
|
783
|
-
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
784
|
-
`).get(...(proj ? [proj, hit.row.created_at_epoch] : [hit.row.created_at_epoch]));
|
|
785
|
-
if (!nearest) { fail(`[mem] No observations near ${hit.prefix}#${parsed.id} (${hit.name})`); return; }
|
|
786
|
-
anchorId = nearest.id;
|
|
787
|
-
anchorNote = `(anchored to #${nearest.id}, closest obs to ${hit.prefix}#${parsed.id})`;
|
|
788
|
-
}
|
|
789
|
-
}
|
|
630
|
+
anchorId = resolved.anchorId;
|
|
631
|
+
anchorNote = resolved.anchorNote;
|
|
790
632
|
}
|
|
791
633
|
|
|
792
634
|
// Support query-based anchor: `timeline --query "search terms"` or positional.
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
// search would otherwise find via OR relaxation.
|
|
635
|
+
// Shared with MCP so AND→OR fallback semantics match `search` — without this,
|
|
636
|
+
// queries like "ep-flush leak" miss rows whose title is "ep-flush ... leaked"
|
|
637
|
+
// that search would otherwise find via OR relaxation.
|
|
797
638
|
const queryStr = flags.query || positional.join(' ');
|
|
798
639
|
if ((!anchorId || isNaN(anchorId)) && queryStr) {
|
|
799
|
-
const
|
|
800
|
-
const found = findFtsAnchor(db, { ftsQuery, project: project ?? null });
|
|
640
|
+
const found = resolveQueryAnchor(db, queryStr, { project: project ?? null });
|
|
801
641
|
if (found) {
|
|
802
|
-
anchorId = found.
|
|
803
|
-
if (found.
|
|
804
|
-
anchorNote = `(query "${queryStr}" relaxed AND→OR — no row matched all terms)`;
|
|
805
|
-
}
|
|
642
|
+
anchorId = found.anchorId;
|
|
643
|
+
if (found.anchorNote && !anchorNote) anchorNote = found.anchorNote;
|
|
806
644
|
}
|
|
807
645
|
}
|
|
808
646
|
|
|
809
|
-
// No anchor: show most recent observations (
|
|
647
|
+
// No anchor: show most recent observations (shared fallback with MCP mem_timeline)
|
|
810
648
|
if (!anchorId || isNaN(anchorId)) {
|
|
811
649
|
if (queryStr) {
|
|
812
650
|
process.stderr.write(`[mem] No anchor found for "${queryStr}", showing recent timeline\n`);
|
|
813
651
|
}
|
|
814
|
-
const
|
|
815
|
-
const projectFilter = project ? `WHERE ${compressedFilter} AND project = ?` : `WHERE ${compressedFilter}`;
|
|
816
|
-
const fallbackParams = project ? [project, before + after + 1] : [before + after + 1];
|
|
817
|
-
const rows = db.prepare(`
|
|
818
|
-
SELECT id, type, title, subtitle, created_at, created_at_epoch
|
|
819
|
-
FROM observations ${projectFilter}
|
|
820
|
-
ORDER BY created_at_epoch DESC
|
|
821
|
-
LIMIT ?
|
|
822
|
-
`).all(...fallbackParams);
|
|
652
|
+
const rows = fetchRecentTimeline(db, { project, limit: before + after + 1 });
|
|
823
653
|
|
|
824
654
|
if (jsonOutput) {
|
|
825
655
|
out(JSON.stringify({
|
|
@@ -847,58 +677,25 @@ function cmdTimeline(db, args) {
|
|
|
847
677
|
return;
|
|
848
678
|
}
|
|
849
679
|
|
|
850
|
-
//
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
854
|
-
|
|
855
|
-
// Get anchor epoch
|
|
856
|
-
const anchorRow = db.prepare('SELECT created_at_epoch, project FROM observations WHERE id = ?').get(anchorId);
|
|
857
|
-
if (!anchorRow) {
|
|
680
|
+
// Window fetch (access-count bump + project auto-scope) shared with MCP.
|
|
681
|
+
const win = fetchTimelineWindow(db, anchorId, { before, after, project });
|
|
682
|
+
if (!win) {
|
|
858
683
|
fail(`[mem] Observation #${anchorId} not found`);
|
|
859
684
|
return;
|
|
860
685
|
}
|
|
861
|
-
|
|
862
|
-
// Auto-scope to anchor's project when --project not explicitly given: users asking
|
|
863
|
-
// "what happened around #N" expect same-project context, not cross-project time-bleed.
|
|
864
|
-
const effectiveProject = project || anchorRow.project;
|
|
865
|
-
const projectFilter = effectiveProject ? 'AND project = ?' : '';
|
|
866
|
-
const baseParams = effectiveProject ? [effectiveProject] : [];
|
|
867
|
-
|
|
868
|
-
// Before anchor
|
|
869
|
-
const beforeRows = db.prepare(`
|
|
870
|
-
SELECT id, type, title, subtitle, created_at, created_at_epoch
|
|
871
|
-
FROM observations
|
|
872
|
-
WHERE created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
873
|
-
ORDER BY created_at_epoch DESC
|
|
874
|
-
LIMIT ?
|
|
875
|
-
`).all(anchorRow.created_at_epoch, ...baseParams, before);
|
|
876
|
-
|
|
877
|
-
// After anchor
|
|
878
|
-
const afterRows = db.prepare(`
|
|
879
|
-
SELECT id, type, title, subtitle, created_at, created_at_epoch
|
|
880
|
-
FROM observations
|
|
881
|
-
WHERE created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
882
|
-
ORDER BY created_at_epoch ASC
|
|
883
|
-
LIMIT ?
|
|
884
|
-
`).all(anchorRow.created_at_epoch, ...baseParams, after);
|
|
885
|
-
|
|
886
|
-
// Anchor itself
|
|
887
|
-
const anchor = db.prepare(
|
|
888
|
-
'SELECT id, type, title, subtitle, created_at, created_at_epoch FROM observations WHERE id = ?'
|
|
889
|
-
).get(anchorId);
|
|
686
|
+
const { anchor, beforeRows, afterRows } = win;
|
|
890
687
|
|
|
891
688
|
if (jsonOutput) {
|
|
892
689
|
out(JSON.stringify({
|
|
893
690
|
anchor: toRow(anchor),
|
|
894
691
|
anchor_note: anchorNote,
|
|
895
|
-
before: beforeRows.
|
|
692
|
+
before: beforeRows.map(toRow),
|
|
896
693
|
after: afterRows.map(toRow),
|
|
897
694
|
}));
|
|
898
695
|
return;
|
|
899
696
|
}
|
|
900
697
|
|
|
901
|
-
const all = [...beforeRows
|
|
698
|
+
const all = [...beforeRows, anchor, ...afterRows];
|
|
902
699
|
|
|
903
700
|
out(`[mem] Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:`);
|
|
904
701
|
for (const r of all) {
|
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,12 +63,16 @@
|
|
|
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",
|
|
69
71
|
"lib/save-observation.mjs",
|
|
70
72
|
"lib/observation-write.mjs",
|
|
71
73
|
"lib/recall-core.mjs",
|
|
74
|
+
"lib/timeline-core.mjs",
|
|
75
|
+
"lib/search-core.mjs",
|
|
72
76
|
"lib/compress-core.mjs",
|
|
73
77
|
"lib/maintain-core.mjs",
|
|
74
78
|
"lib/dedup-constants.mjs",
|
|
@@ -138,7 +142,7 @@
|
|
|
138
142
|
"zod": "^4.3.6"
|
|
139
143
|
},
|
|
140
144
|
"overrides": {
|
|
141
|
-
"hono": ">=4.12.
|
|
145
|
+
"hono": ">=4.12.26",
|
|
142
146
|
"fast-uri": ">=3.1.2",
|
|
143
147
|
"ip-address": ">=10.1.1"
|
|
144
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
|