claude-mem-lite 2.97.0 → 2.99.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/observation-write.mjs +18 -1
- package/lib/recall-core.mjs +43 -0
- package/lib/search-core.mjs +200 -0
- package/lib/timeline-core.mjs +195 -0
- package/mem-cli.mjs +63 -304
- package/package.json +4 -1
- package/scripts/pre-tool-recall.js +50 -2
- package/server.mjs +75 -319
- package/source-files.mjs +8 -0
- package/tool-schemas.mjs +6 -2
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 {
|
|
12
|
-
import {
|
|
9
|
+
import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
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';
|
|
@@ -27,7 +25,7 @@ import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
|
|
|
27
25
|
import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
|
|
28
26
|
import { auditMemdir, memdirPath } from './memdir.mjs';
|
|
29
27
|
import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
|
|
30
|
-
import {
|
|
28
|
+
import { join, sep } from 'path';
|
|
31
29
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
32
30
|
|
|
33
31
|
// v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
|
|
@@ -35,6 +33,10 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
|
35
33
|
// move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
|
|
36
34
|
import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
|
|
37
35
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
36
|
+
import { rebuildObservationDerived } from './lib/observation-write.mjs';
|
|
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';
|
|
38
40
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
39
41
|
import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
|
|
40
42
|
import {
|
|
@@ -61,11 +63,9 @@ function cmdSearch(db, args) {
|
|
|
61
63
|
}
|
|
62
64
|
const source = flags.source || null; // observations|sessions|prompts (null = all)
|
|
63
65
|
const project = flags.project ? resolveProject(db, flags.project) : null;
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (flags.from && isNaN(dateFrom)) { fail(`[mem] Invalid --from date: "${flags.from}". Use YYYY-MM-DD or ISO 8601.`); return; }
|
|
68
|
-
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;
|
|
69
69
|
// Inverted range silently returns 0 rows; warn so users see the cause, don't error
|
|
70
70
|
// (a deliberate "search for nothing in this window" is not malformed input).
|
|
71
71
|
if (dateFrom !== null && dateTo !== null && dateFrom > dateTo) {
|
|
@@ -104,8 +104,7 @@ function cmdSearch(db, args) {
|
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
if (ftsQuery && useOr) ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
|
|
107
|
+
const ftsQuery = buildSearchFtsQuery(query, { or: useOr });
|
|
109
108
|
if (!ftsQuery) {
|
|
110
109
|
fail(`[mem] No valid search terms in "${query}"`);
|
|
111
110
|
return;
|
|
@@ -124,17 +123,12 @@ function cmdSearch(db, args) {
|
|
|
124
123
|
const effectiveSource = source || ((type || tier || minImportance || branch) ? 'observations' : null);
|
|
125
124
|
|
|
126
125
|
// Cross-source mode: each source needs more candidates than the final limit
|
|
127
|
-
// so the post-merge sort has room to pick the best from each (
|
|
128
|
-
//
|
|
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).
|
|
129
130
|
const isCrossSourceMode = !effectiveSource;
|
|
130
|
-
|
|
131
|
-
// ALL modes — mirrors server.mjs. Pushing OFFSET into the obs hybrid path was
|
|
132
|
-
// unreliable: its AND→OR fallback / vector / concept-cooccurrence stages re-add
|
|
133
|
-
// rows the SQL OFFSET already skipped, so engine-side paging dropped (or
|
|
134
|
-
// duplicated) rows on the --type/--tier/--importance/--branch path (a page that
|
|
135
|
-
// MCP returned came back empty).
|
|
136
|
-
const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
|
|
137
|
-
const perSourceOffset = 0;
|
|
131
|
+
const { perSourceLimit, perSourceOffset } = computePerSourceWindow(limit, offset);
|
|
138
132
|
|
|
139
133
|
const results = [];
|
|
140
134
|
// Tracks whether AND returned 0 and OR recovered non-empty. Mirrors server.mjs
|
|
@@ -166,107 +160,31 @@ function cmdSearch(db, args) {
|
|
|
166
160
|
|
|
167
161
|
// Tier post-filter — applied to ALL obs results from the engine.
|
|
168
162
|
if (tier) {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const ph = obsIds.map(() => '?').join(',');
|
|
173
|
-
const fullRows = db.prepare(
|
|
174
|
-
`SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${ph})`
|
|
175
|
-
).all(...obsIds);
|
|
176
|
-
const rowMap = new Map(fullRows.map(r => [r.id, r]));
|
|
177
|
-
const tierCtx = { now: Date.now(), currentProject: project || inferProject(), currentSessionId: '' };
|
|
178
|
-
const allowedIds = new Set();
|
|
179
|
-
for (const [id, full] of rowMap) {
|
|
180
|
-
if (computeTier(full, tierCtx) === tier) allowedIds.add(id);
|
|
181
|
-
}
|
|
182
|
-
for (let i = results.length - 1; i >= 0; i--) {
|
|
183
|
-
if (results[i]._source === 'obs' && !allowedIds.has(results[i].id)) results.splice(i, 1);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
163
|
+
const filtered = applyTierFilter(db, results, { tier, sourceKey: '_source', currentProject: project || inferProject() });
|
|
164
|
+
results.length = 0;
|
|
165
|
+
results.push(...filtered);
|
|
186
166
|
}
|
|
187
167
|
}
|
|
188
168
|
|
|
189
|
-
// Search sessions (
|
|
169
|
+
// Search sessions (shared engine with MCP mem_search — lib/search-core.mjs)
|
|
190
170
|
if (!effectiveSource || effectiveSource === 'sessions') {
|
|
191
|
-
const now = Date.now();
|
|
192
|
-
const sessionProjectBoost = project ? null : inferProject();
|
|
193
|
-
const sessWheres = ['session_summaries_fts MATCH ?'];
|
|
194
|
-
const sessParams = [now, sessionProjectBoost, sessionProjectBoost, ftsQuery];
|
|
195
|
-
if (project) { sessWheres.push('s.project = ?'); sessParams.push(project); }
|
|
196
|
-
if (dateFrom) { sessWheres.push('s.created_at_epoch >= ?'); sessParams.push(dateFrom); }
|
|
197
|
-
if (dateTo) { sessWheres.push('s.created_at_epoch <= ?'); sessParams.push(dateTo); }
|
|
198
|
-
sessParams.push(perSourceLimit, perSourceOffset);
|
|
199
171
|
try {
|
|
200
|
-
const sessRows = db
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
* (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
|
|
205
|
-
FROM session_summaries_fts
|
|
206
|
-
JOIN session_summaries s ON session_summaries_fts.rowid = s.id
|
|
207
|
-
WHERE ${sessWheres.join(' AND ')}
|
|
208
|
-
ORDER BY score
|
|
209
|
-
LIMIT ? OFFSET ?
|
|
210
|
-
`).all(...sessParams);
|
|
172
|
+
const sessRows = searchSessionsFts(db, {
|
|
173
|
+
ftsQuery, project, projectBoost: project ? null : inferProject(),
|
|
174
|
+
epochFrom: dateFrom, epochTo: dateTo, perSourceLimit, perSourceOffset,
|
|
175
|
+
});
|
|
211
176
|
for (const r of sessRows) results.push({ ...r, _source: 'session' });
|
|
212
177
|
} catch { /* session FTS may not exist in older DBs */ }
|
|
213
178
|
}
|
|
214
179
|
|
|
215
|
-
// Search prompts (
|
|
180
|
+
// Search prompts (shared engine incl. CJK precision gate + LIKE fallback)
|
|
216
181
|
if (!effectiveSource || effectiveSource === 'prompts') {
|
|
217
|
-
const promptWheres = ['user_prompts_fts MATCH ?', "p.prompt_text NOT LIKE '<task-notification>%'"];
|
|
218
|
-
const promptParams = [ftsQuery];
|
|
219
|
-
if (project) { promptWheres.push('s.project = ?'); promptParams.push(project); }
|
|
220
|
-
if (dateFrom) { promptWheres.push('p.created_at_epoch >= ?'); promptParams.push(dateFrom); }
|
|
221
|
-
if (dateTo) { promptWheres.push('p.created_at_epoch <= ?'); promptParams.push(dateTo); }
|
|
222
|
-
promptParams.push(perSourceLimit, perSourceOffset);
|
|
223
182
|
try {
|
|
224
|
-
const promptRows = db
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
|
|
230
|
-
WHERE ${promptWheres.join(' AND ')}
|
|
231
|
-
ORDER BY score
|
|
232
|
-
LIMIT ? OFFSET ?
|
|
233
|
-
`).all(...promptParams);
|
|
234
|
-
// CJK precision filter (read-path parity with server.mjs): unicode61
|
|
235
|
-
// degrades bigram queries to single-char AND, letting common-char
|
|
236
|
-
// Chinese prose leak through. Drop rows that miss < 20% of query
|
|
237
|
-
// bigrams/keywords as contiguous substrings.
|
|
238
|
-
const keptPromptRows = promptRows.filter(r => cjkPrecisionOk(query, r.prompt_text));
|
|
239
|
-
for (const r of keptPromptRows) results.push({ ...r, _source: 'prompt' });
|
|
240
|
-
// CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
|
|
241
|
-
if (keptPromptRows.length === 0) {
|
|
242
|
-
const cjkPatterns = extractCjkLikePatterns(query);
|
|
243
|
-
if (cjkPatterns.length > 0) {
|
|
244
|
-
const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
|
|
245
|
-
const likeParams = cjkPatterns.map(p => `%${p}%`);
|
|
246
|
-
if (project) likeParams.push(project);
|
|
247
|
-
if (dateFrom) likeParams.push(dateFrom);
|
|
248
|
-
if (dateTo) likeParams.push(dateTo);
|
|
249
|
-
likeParams.push(perSourceLimit, perSourceOffset);
|
|
250
|
-
const fallbackRows = db.prepare(`
|
|
251
|
-
SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
|
|
252
|
-
FROM user_prompts p
|
|
253
|
-
JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
|
|
254
|
-
WHERE (${likeConds.join(' OR ')})
|
|
255
|
-
AND p.prompt_text NOT LIKE '<task-notification>%'
|
|
256
|
-
${project ? 'AND s.project = ?' : ''}
|
|
257
|
-
${dateFrom ? 'AND p.created_at_epoch >= ?' : ''}
|
|
258
|
-
${dateTo ? 'AND p.created_at_epoch <= ?' : ''}
|
|
259
|
-
ORDER BY p.created_at_epoch DESC
|
|
260
|
-
LIMIT ? OFFSET ?
|
|
261
|
-
`).all(...likeParams);
|
|
262
|
-
// CJK precision filter applies here too: the LIKE fallback is just
|
|
263
|
-
// OR'd substring bigrams; without the precision gate it re-admits
|
|
264
|
-
// the same common-char noise the FTS path dropped (this was the
|
|
265
|
-
// actual leak source — FTS returned 0, fallback filled 20).
|
|
266
|
-
const keptFallback = fallbackRows.filter(r => cjkPrecisionOk(query, r.prompt_text));
|
|
267
|
-
for (const r of keptFallback) results.push({ ...r, _source: 'prompt', score: 0 });
|
|
268
|
-
}
|
|
269
|
-
}
|
|
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' });
|
|
270
188
|
} catch { /* prompt FTS may not exist in older DBs */ }
|
|
271
189
|
}
|
|
272
190
|
|
|
@@ -279,18 +197,11 @@ function cmdSearch(db, args) {
|
|
|
279
197
|
return;
|
|
280
198
|
}
|
|
281
199
|
|
|
282
|
-
// Cross-source score normalization (
|
|
200
|
+
// Cross-source score normalization (shared with mem_search).
|
|
283
201
|
// ftsQuery gate prevents normalization when scores are all 0 (no-FTS path).
|
|
284
202
|
const isCrossSource = isCrossSourceMode;
|
|
285
203
|
if (isCrossSource && results.length > 0 && ftsQuery) {
|
|
286
|
-
|
|
287
|
-
const srcResults = results.filter(r => r._source === src && r.score !== null && r.score !== undefined);
|
|
288
|
-
if (srcResults.length < 2) continue;
|
|
289
|
-
const maxAbs = Math.max(...srcResults.map(r => Math.abs(r.score)));
|
|
290
|
-
if (maxAbs > 0) {
|
|
291
|
-
for (const r of srcResults) r.score = r.score / maxAbs;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
204
|
+
normalizeCrossSourceScores(results, '_source');
|
|
294
205
|
results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
295
206
|
}
|
|
296
207
|
|
|
@@ -304,13 +215,8 @@ function cmdSearch(db, args) {
|
|
|
304
215
|
if (isCrossSource) results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
|
|
305
216
|
}
|
|
306
217
|
|
|
307
|
-
// Apply user-requested sort (after relevance scoring)
|
|
308
|
-
|
|
309
|
-
results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
|
|
310
|
-
} else if (sort === 'importance') {
|
|
311
|
-
results.sort((a, b) => (b.importance ?? 1) - (a.importance ?? 1) || (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
|
|
312
|
-
}
|
|
313
|
-
// else 'relevance' keeps BM25 score order (already sorted)
|
|
218
|
+
// Apply user-requested sort (after relevance scoring; shared with mem_search)
|
|
219
|
+
applyUserSort(results, sort);
|
|
314
220
|
|
|
315
221
|
// Trim to limit with offset. The engine always received perSourceOffset=0 and
|
|
316
222
|
// over-fetched (see above), so the merged+reranked `results` start at row 0 and
|
|
@@ -324,7 +230,7 @@ function cmdSearch(db, args) {
|
|
|
324
230
|
const trueTotal = countSearchTotal(db, {
|
|
325
231
|
effectiveSource,
|
|
326
232
|
ftsQuery,
|
|
327
|
-
obsFtsQuery:
|
|
233
|
+
obsFtsQuery: effectiveObsFtsQuery(ftsQuery, orFallbackFired),
|
|
328
234
|
args: { project: project || null, obs_type: type || null, importance: minImportance || null, branch: branch || null },
|
|
329
235
|
project: project || null,
|
|
330
236
|
epochFrom: dateFrom,
|
|
@@ -493,35 +399,12 @@ function cmdRecall(db, args) {
|
|
|
493
399
|
return;
|
|
494
400
|
}
|
|
495
401
|
|
|
496
|
-
const filename = basename(file);
|
|
497
402
|
const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
|
|
498
403
|
const includeNoise = flags['include-noise'] === true || flags['include-noise'] === 'true';
|
|
499
404
|
const jsonOutput = flags.json === true || flags.json === 'true';
|
|
500
405
|
|
|
501
|
-
//
|
|
502
|
-
const
|
|
503
|
-
const likePattern = `%${escaped}`;
|
|
504
|
-
const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
|
|
505
|
-
const rows = db.prepare(`
|
|
506
|
-
SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
|
|
507
|
-
o.created_at, o.created_at_epoch, o.project
|
|
508
|
-
FROM observations o
|
|
509
|
-
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
510
|
-
WHERE COALESCE(o.compressed_into, 0) = 0
|
|
511
|
-
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
512
|
-
${noiseClause}
|
|
513
|
-
ORDER BY o.created_at_epoch DESC
|
|
514
|
-
LIMIT ?
|
|
515
|
-
`).all(filename, likePattern, limit);
|
|
516
|
-
|
|
517
|
-
if (rows.length > 0) {
|
|
518
|
-
// Update access_count for recalled observations (aligned with MCP mem_recall)
|
|
519
|
-
const recalledIds = rows.map(r => r.id);
|
|
520
|
-
const recallPh = recalledIds.map(() => '?').join(',');
|
|
521
|
-
try {
|
|
522
|
-
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${recallPh})`).run(Date.now(), ...recalledIds);
|
|
523
|
-
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
524
|
-
}
|
|
406
|
+
// Shared core with MCP mem_recall: query + escaping + access bump (lib/recall-core.mjs)
|
|
407
|
+
const { filename, rows } = recallByFile(db, file, { limit, includeNoise });
|
|
525
408
|
|
|
526
409
|
if (jsonOutput) {
|
|
527
410
|
out(JSON.stringify({
|
|
@@ -734,113 +617,39 @@ function cmdTimeline(db, args) {
|
|
|
734
617
|
});
|
|
735
618
|
|
|
736
619
|
// Parse --anchor, accepting P#/S#/# prefix so callers can paste search-result IDs verbatim.
|
|
737
|
-
//
|
|
738
|
-
//
|
|
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.
|
|
739
622
|
let anchorId = null;
|
|
740
623
|
let anchorNote = null; // hint line for output when anchor was resolved via conversion
|
|
741
624
|
if (flags.anchor !== undefined && flags.anchor !== true) {
|
|
742
|
-
const
|
|
743
|
-
if (!
|
|
744
|
-
fail(
|
|
625
|
+
const resolved = resolveAnchorToken(db, flags.anchor, { project });
|
|
626
|
+
if (!resolved.ok) {
|
|
627
|
+
fail(formatAnchorError(resolved.error, 'cli'));
|
|
745
628
|
return;
|
|
746
629
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
if (!row) { fail(`[mem] Prompt P#${parsed.id} not found`); return; }
|
|
750
|
-
const proj = project;
|
|
751
|
-
const nearest = db.prepare(`
|
|
752
|
-
SELECT id FROM observations
|
|
753
|
-
WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
|
|
754
|
-
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
755
|
-
`).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
|
|
756
|
-
if (!nearest) { fail(`[mem] No observations near P#${parsed.id}`); return; }
|
|
757
|
-
anchorId = nearest.id;
|
|
758
|
-
anchorNote = `(anchored to #${nearest.id}, closest obs to P#${parsed.id})`;
|
|
759
|
-
} else if (parsed.source === 'session') {
|
|
760
|
-
const row = db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
|
|
761
|
-
if (!row) { fail(`[mem] Session S#${parsed.id} not found`); return; }
|
|
762
|
-
const proj = project;
|
|
763
|
-
const nearest = db.prepare(`
|
|
764
|
-
SELECT id FROM observations
|
|
765
|
-
WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
|
|
766
|
-
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
767
|
-
`).get(...(proj ? [proj, row.created_at_epoch] : [row.created_at_epoch]));
|
|
768
|
-
if (!nearest) { fail(`[mem] No observations near S#${parsed.id}`); return; }
|
|
769
|
-
anchorId = nearest.id;
|
|
770
|
-
anchorNote = `(anchored to #${nearest.id}, closest obs to S#${parsed.id})`;
|
|
771
|
-
} else {
|
|
772
|
-
// Bare integer (no prefix): try observation first. Fall back to user_prompts
|
|
773
|
-
// then session_summaries so pasted P#/S# IDs still work when the prefix is
|
|
774
|
-
// omitted — matches the prefix-aware routing used by search/probe.
|
|
775
|
-
const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
|
|
776
|
-
if (obsRow) {
|
|
777
|
-
const ci = obsRow.compressed_into;
|
|
778
|
-
if (ci && ci > 0) {
|
|
779
|
-
// Compressed into a live parent: re-anchor so the window doesn't silently
|
|
780
|
-
// straddle a dead record. Negative sentinels (-1 dropped, -2 pending purge)
|
|
781
|
-
// have no canonical parent — surface an explicit error instead.
|
|
782
|
-
anchorId = ci;
|
|
783
|
-
anchorNote = `(anchored to #${ci}, #${parsed.id} was compressed into it)`;
|
|
784
|
-
} else if (ci && ci < 0) {
|
|
785
|
-
fail(`[mem] Observation #${parsed.id} was compressed and pruned; no canonical anchor available`);
|
|
786
|
-
return;
|
|
787
|
-
} else {
|
|
788
|
-
anchorId = parsed.id;
|
|
789
|
-
}
|
|
790
|
-
} else {
|
|
791
|
-
const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
|
|
792
|
-
const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
|
|
793
|
-
const hit = promptRow ? { row: promptRow, prefix: 'P', name: 'prompt' }
|
|
794
|
-
: sessionRow ? { row: sessionRow, prefix: 'S', name: 'session' }
|
|
795
|
-
: null;
|
|
796
|
-
if (!hit) {
|
|
797
|
-
fail(`[mem] Observation, prompt, or session with id ${parsed.id} not found`);
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
|
-
const proj = project;
|
|
801
|
-
const nearest = db.prepare(`
|
|
802
|
-
SELECT id FROM observations
|
|
803
|
-
WHERE COALESCE(compressed_into, 0) = 0 ${proj ? 'AND project = ?' : ''}
|
|
804
|
-
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
805
|
-
`).get(...(proj ? [proj, hit.row.created_at_epoch] : [hit.row.created_at_epoch]));
|
|
806
|
-
if (!nearest) { fail(`[mem] No observations near ${hit.prefix}#${parsed.id} (${hit.name})`); return; }
|
|
807
|
-
anchorId = nearest.id;
|
|
808
|
-
anchorNote = `(anchored to #${nearest.id}, closest obs to ${hit.prefix}#${parsed.id})`;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
630
|
+
anchorId = resolved.anchorId;
|
|
631
|
+
anchorNote = resolved.anchorNote;
|
|
811
632
|
}
|
|
812
633
|
|
|
813
634
|
// Support query-based anchor: `timeline --query "search terms"` or positional.
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
// 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.
|
|
818
638
|
const queryStr = flags.query || positional.join(' ');
|
|
819
639
|
if ((!anchorId || isNaN(anchorId)) && queryStr) {
|
|
820
|
-
const
|
|
821
|
-
const found = findFtsAnchor(db, { ftsQuery, project: project ?? null });
|
|
640
|
+
const found = resolveQueryAnchor(db, queryStr, { project: project ?? null });
|
|
822
641
|
if (found) {
|
|
823
|
-
anchorId = found.
|
|
824
|
-
if (found.
|
|
825
|
-
anchorNote = `(query "${queryStr}" relaxed AND→OR — no row matched all terms)`;
|
|
826
|
-
}
|
|
642
|
+
anchorId = found.anchorId;
|
|
643
|
+
if (found.anchorNote && !anchorNote) anchorNote = found.anchorNote;
|
|
827
644
|
}
|
|
828
645
|
}
|
|
829
646
|
|
|
830
|
-
// No anchor: show most recent observations (
|
|
647
|
+
// No anchor: show most recent observations (shared fallback with MCP mem_timeline)
|
|
831
648
|
if (!anchorId || isNaN(anchorId)) {
|
|
832
649
|
if (queryStr) {
|
|
833
650
|
process.stderr.write(`[mem] No anchor found for "${queryStr}", showing recent timeline\n`);
|
|
834
651
|
}
|
|
835
|
-
const
|
|
836
|
-
const projectFilter = project ? `WHERE ${compressedFilter} AND project = ?` : `WHERE ${compressedFilter}`;
|
|
837
|
-
const fallbackParams = project ? [project, before + after + 1] : [before + after + 1];
|
|
838
|
-
const rows = db.prepare(`
|
|
839
|
-
SELECT id, type, title, subtitle, created_at, created_at_epoch
|
|
840
|
-
FROM observations ${projectFilter}
|
|
841
|
-
ORDER BY created_at_epoch DESC
|
|
842
|
-
LIMIT ?
|
|
843
|
-
`).all(...fallbackParams);
|
|
652
|
+
const rows = fetchRecentTimeline(db, { project, limit: before + after + 1 });
|
|
844
653
|
|
|
845
654
|
if (jsonOutput) {
|
|
846
655
|
out(JSON.stringify({
|
|
@@ -868,58 +677,25 @@ function cmdTimeline(db, args) {
|
|
|
868
677
|
return;
|
|
869
678
|
}
|
|
870
679
|
|
|
871
|
-
//
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
875
|
-
|
|
876
|
-
// Get anchor epoch
|
|
877
|
-
const anchorRow = db.prepare('SELECT created_at_epoch, project FROM observations WHERE id = ?').get(anchorId);
|
|
878
|
-
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) {
|
|
879
683
|
fail(`[mem] Observation #${anchorId} not found`);
|
|
880
684
|
return;
|
|
881
685
|
}
|
|
882
|
-
|
|
883
|
-
// Auto-scope to anchor's project when --project not explicitly given: users asking
|
|
884
|
-
// "what happened around #N" expect same-project context, not cross-project time-bleed.
|
|
885
|
-
const effectiveProject = project || anchorRow.project;
|
|
886
|
-
const projectFilter = effectiveProject ? 'AND project = ?' : '';
|
|
887
|
-
const baseParams = effectiveProject ? [effectiveProject] : [];
|
|
888
|
-
|
|
889
|
-
// Before anchor
|
|
890
|
-
const beforeRows = db.prepare(`
|
|
891
|
-
SELECT id, type, title, subtitle, created_at, created_at_epoch
|
|
892
|
-
FROM observations
|
|
893
|
-
WHERE created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
894
|
-
ORDER BY created_at_epoch DESC
|
|
895
|
-
LIMIT ?
|
|
896
|
-
`).all(anchorRow.created_at_epoch, ...baseParams, before);
|
|
897
|
-
|
|
898
|
-
// After anchor
|
|
899
|
-
const afterRows = db.prepare(`
|
|
900
|
-
SELECT id, type, title, subtitle, created_at, created_at_epoch
|
|
901
|
-
FROM observations
|
|
902
|
-
WHERE created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
903
|
-
ORDER BY created_at_epoch ASC
|
|
904
|
-
LIMIT ?
|
|
905
|
-
`).all(anchorRow.created_at_epoch, ...baseParams, after);
|
|
906
|
-
|
|
907
|
-
// Anchor itself
|
|
908
|
-
const anchor = db.prepare(
|
|
909
|
-
'SELECT id, type, title, subtitle, created_at, created_at_epoch FROM observations WHERE id = ?'
|
|
910
|
-
).get(anchorId);
|
|
686
|
+
const { anchor, beforeRows, afterRows } = win;
|
|
911
687
|
|
|
912
688
|
if (jsonOutput) {
|
|
913
689
|
out(JSON.stringify({
|
|
914
690
|
anchor: toRow(anchor),
|
|
915
691
|
anchor_note: anchorNote,
|
|
916
|
-
before: beforeRows.
|
|
692
|
+
before: beforeRows.map(toRow),
|
|
917
693
|
after: afterRows.map(toRow),
|
|
918
694
|
}));
|
|
919
695
|
return;
|
|
920
696
|
}
|
|
921
697
|
|
|
922
|
-
const all = [...beforeRows
|
|
698
|
+
const all = [...beforeRows, anchor, ...afterRows];
|
|
923
699
|
|
|
924
700
|
out(`[mem] Timeline around #${anchorId}${anchorNote ? ' ' + anchorNote : ''}:`);
|
|
925
701
|
for (const r of all) {
|
|
@@ -1708,28 +1484,11 @@ function cmdUpdate(db, args) {
|
|
|
1708
1484
|
|
|
1709
1485
|
params.push(id);
|
|
1710
1486
|
|
|
1711
|
-
// Atomic: update fields + rebuild FTS text +
|
|
1487
|
+
// Atomic: update fields + rebuild derived columns (FTS text + vector) via the
|
|
1488
|
+
// shared core — single source with MCP mem_update (lib/observation-write.mjs).
|
|
1712
1489
|
db.transaction(() => {
|
|
1713
1490
|
db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
1714
|
-
|
|
1715
|
-
// Rebuild FTS text field
|
|
1716
|
-
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(id);
|
|
1717
|
-
const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
|
|
1718
|
-
const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
|
|
1719
|
-
const textField = bigrams ? base + ' ' + bigrams : base;
|
|
1720
|
-
db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, id);
|
|
1721
|
-
|
|
1722
|
-
// Re-vectorize (non-critical — catch to avoid rollback)
|
|
1723
|
-
try {
|
|
1724
|
-
const vocab = getVocabulary(db);
|
|
1725
|
-
if (vocab) {
|
|
1726
|
-
const vec = computeVector(textField, vocab);
|
|
1727
|
-
if (vec) {
|
|
1728
|
-
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
1729
|
-
.run(id, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
} catch { /* non-critical */ }
|
|
1491
|
+
rebuildObservationDerived(db, id);
|
|
1733
1492
|
})();
|
|
1734
1493
|
|
|
1735
1494
|
out(`[mem] Updated #${id}: ${updates.map(u => u.split(' =')[0]).join(', ')}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.99.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",
|
|
@@ -68,6 +68,9 @@
|
|
|
68
68
|
"lib/mem-override.mjs",
|
|
69
69
|
"lib/save-observation.mjs",
|
|
70
70
|
"lib/observation-write.mjs",
|
|
71
|
+
"lib/recall-core.mjs",
|
|
72
|
+
"lib/timeline-core.mjs",
|
|
73
|
+
"lib/search-core.mjs",
|
|
71
74
|
"lib/compress-core.mjs",
|
|
72
75
|
"lib/maintain-core.mjs",
|
|
73
76
|
"lib/dedup-constants.mjs",
|
|
@@ -29,6 +29,15 @@ const CROSS_HOOK_DEDUP_MS = 5 * 60 * 1000;
|
|
|
29
29
|
// legacy global path so env-less test harnesses still behave.
|
|
30
30
|
const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
|
|
31
31
|
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes (used only for legacy fallback)
|
|
32
|
+
// v2.98 salience forcing-function (#8651: verified injection only moved
|
|
33
|
+
// bug-reintroduction 100%→50% — the agent sees lessons and ignores them; the
|
|
34
|
+
// bottleneck is ACTING). Default ON: Edit/Write lesson blocks end with an ack
|
|
35
|
+
// directive, and Read→Edit re-surfaces the Read-time lesson IDs as a one-line
|
|
36
|
+
// ack nudge at the actual action point. CLAUDE_MEM_SALIENCE=legacy (or 0)
|
|
37
|
+
// restores the pre-v2.98 passive behavior.
|
|
38
|
+
const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
|
|
39
|
+
|| process.env.CLAUDE_MEM_SALIENCE === '0';
|
|
40
|
+
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.";
|
|
32
41
|
const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
|
|
33
42
|
// Stale-cooldown GC moved to hook.mjs::handleSessionStart — running it on every
|
|
34
43
|
// Edit cost 15-30 disk stats per call. SessionStart fires once at session boot,
|
|
@@ -183,7 +192,35 @@ try {
|
|
|
183
192
|
const cooldown = readCooldown(cooldownPath);
|
|
184
193
|
const now = Date.now();
|
|
185
194
|
if (isSessionScoped) {
|
|
186
|
-
|
|
195
|
+
const entry = cooldown[filePath];
|
|
196
|
+
if (entry) {
|
|
197
|
+
// v2.98 salience: the old full-dedup meant a Read-injected lesson left the
|
|
198
|
+
// actual Edit with ZERO context at the action point — the most likely spot
|
|
199
|
+
// for #8651's "saw it, ignored it". When this Edit/Write follows a
|
|
200
|
+
// Read-mode injection that surfaced lessons, emit a one-line ack nudge
|
|
201
|
+
// naming the IDs (no lesson bodies — token cost stays minimal), then mark
|
|
202
|
+
// the entry handled so the next Edit is silent again. Entries without a
|
|
203
|
+
// mode field (pre-v2.98) are treated as already handled.
|
|
204
|
+
const seenIds = (typeof entry === 'object' && Array.isArray(entry.lessonIds))
|
|
205
|
+
? entry.lessonIds : [];
|
|
206
|
+
const wasReadMode = typeof entry === 'object' && entry.mode === 'read';
|
|
207
|
+
if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
|
|
208
|
+
const idList = seenIds.map(id => `#${id}`).join(', ');
|
|
209
|
+
process.stdout.write(JSON.stringify({
|
|
210
|
+
suppressOutput: true,
|
|
211
|
+
hookSpecificOutput: {
|
|
212
|
+
hookEventName: 'PreToolUse',
|
|
213
|
+
additionalContext: [
|
|
214
|
+
'[mem] PreToolUse recall — system-injected context, continue your planned action:',
|
|
215
|
+
`[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACK_DIRECTIVE}`,
|
|
216
|
+
].join('\n'),
|
|
217
|
+
},
|
|
218
|
+
}));
|
|
219
|
+
cooldown[filePath] = { ...entry, mode: 'edit' };
|
|
220
|
+
writeCooldown(cooldownPath, cooldown, isSessionScoped);
|
|
221
|
+
}
|
|
222
|
+
process.exit(0); // already recalled this file in-session
|
|
223
|
+
}
|
|
187
224
|
} else {
|
|
188
225
|
const ts = entryTimestamp(cooldown[filePath]);
|
|
189
226
|
if (ts && (now - ts) < COOLDOWN_MS) process.exit(0);
|
|
@@ -329,6 +366,14 @@ try {
|
|
|
329
366
|
lines.push(` #${r.id} [${r.type}] ${title}`);
|
|
330
367
|
}
|
|
331
368
|
}
|
|
369
|
+
// v2.98 salience: Edit/Write is the action point — close the block with an
|
|
370
|
+
// explicit ack directive instead of leaving the lessons as passive FYI
|
|
371
|
+
// (#8651: passive framing was ignored ~half the time even when on-topic).
|
|
372
|
+
// Read keeps the quiet form; its forcing-function fires at the later Edit
|
|
373
|
+
// via the Read→Edit ack nudge above.
|
|
374
|
+
if (!isRead && !SALIENCE_LEGACY) {
|
|
375
|
+
lines.push(`[mem] ⚠ Before this edit: ${ACK_DIRECTIVE}`);
|
|
376
|
+
}
|
|
332
377
|
} else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
|
|
333
378
|
// R-4: Edit/Write empty → short backfill reminder. OPT-IN (default off) as
|
|
334
379
|
// of the cross-project audit: this "no prior lessons, remember to /lesson"
|
|
@@ -360,7 +405,10 @@ try {
|
|
|
360
405
|
// v2.81: record the emitted lesson IDs so flushEpisode (hook.mjs) can
|
|
361
406
|
// build the PostToolUse cite-back hint when the user actually edits the
|
|
362
407
|
// file. Empty array on no-lesson branches keeps the schema uniform.
|
|
363
|
-
|
|
408
|
+
// v2.98: mode records WHERE the injection happened so the Read→Edit ack
|
|
409
|
+
// nudge can distinguish "lessons seen passively at Read" from "already
|
|
410
|
+
// surfaced at an action point".
|
|
411
|
+
cooldown[filePath] = { ts: now, lessonIds: allRows.map(r => r.id), mode: isRead ? 'read' : 'edit' };
|
|
364
412
|
writeCooldown(cooldownPath, cooldown, isSessionScoped);
|
|
365
413
|
// A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
|
|
366
414
|
// file so the next UPS prompt skips them too. Always write, even on
|