claude-mem-lite 2.98.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/search-core.mjs +200 -0
- package/lib/timeline-core.mjs +195 -0
- package/mem-cli.mjs +54 -257
- package/package.json +3 -1
- package/server.mjs +63 -273
- package/source-files.mjs +7 -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": "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",
|
|
@@ -69,6 +69,8 @@
|
|
|
69
69
|
"lib/save-observation.mjs",
|
|
70
70
|
"lib/observation-write.mjs",
|
|
71
71
|
"lib/recall-core.mjs",
|
|
72
|
+
"lib/timeline-core.mjs",
|
|
73
|
+
"lib/search-core.mjs",
|
|
72
74
|
"lib/compress-core.mjs",
|
|
73
75
|
"lib/maintain-core.mjs",
|
|
74
76
|
"lib/dedup-constants.mjs",
|