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
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.99.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "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
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// + vector writes in one db.transaction so a failure can't leave a partial row).
|
|
10
10
|
|
|
11
11
|
import { getVocabulary, computeVector } from '../tfidf.mjs';
|
|
12
|
-
import { debugCatch } from '../utils.mjs';
|
|
12
|
+
import { debugCatch, cjkBigrams } from '../utils.mjs';
|
|
13
13
|
|
|
14
14
|
// Canonical column order — must mirror the observations schema (schema.mjs).
|
|
15
15
|
const OBS_COLUMNS = [
|
|
@@ -65,3 +65,20 @@ export function insertObservationVector(db, obsId, vecText) {
|
|
|
65
65
|
.run(obsId, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
66
66
|
} catch (e) { debugCatch(e, 'insertObservationVector'); }
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Rebuild an observation's derived columns after a field UPDATE: the FTS `text`
|
|
71
|
+
* column (incl. CJK bigrams + search_aliases, matching the ingest paths) and the
|
|
72
|
+
* TF-IDF vector. cmdUpdate (CLI) and mem_update (MCP) previously hand-copied this
|
|
73
|
+
* block — the same drift class #8614/#8639 closed for compress/maintain. Caller
|
|
74
|
+
* owns the transaction (vector write is internally non-critical).
|
|
75
|
+
*/
|
|
76
|
+
export function rebuildObservationDerived(db, obsId) {
|
|
77
|
+
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(obsId);
|
|
78
|
+
if (!row) return;
|
|
79
|
+
const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
|
|
80
|
+
const bigrams = cjkBigrams((row.title || '') + ' ' + (row.narrative || ''));
|
|
81
|
+
const textField = bigrams ? base + ' ' + bigrams : base;
|
|
82
|
+
db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, obsId);
|
|
83
|
+
insertObservationVector(db, obsId, textField);
|
|
84
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Single source of truth for file-keyed recall: the observation_files junction
|
|
2
|
+
// query, LIKE-wildcard escaping, noise filtering, and the access-count bump.
|
|
3
|
+
// cmdRecall (mem-cli.mjs) and mem_recall (server.mjs) previously hand-copied all
|
|
4
|
+
// four — the drift class that produced the mem_get formatter drift (#8678) and
|
|
5
|
+
// the maintain hand-sync drift (#8614). Renderers stay per-surface; the data
|
|
6
|
+
// contract lives here.
|
|
7
|
+
|
|
8
|
+
import { basename } from 'path';
|
|
9
|
+
import { notLowSignalTitleClause } from '../utils.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recall observations linked to a file (basename or full path). Returns
|
|
13
|
+
* { filename, rows } where rows carry the column superset both surfaces render.
|
|
14
|
+
* Side effect: bumps access_count / last_accessed_at on every returned row —
|
|
15
|
+
* recall IS engagement, and the tier/decay system feeds on these counters.
|
|
16
|
+
*/
|
|
17
|
+
export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}) {
|
|
18
|
+
const filename = basename(file);
|
|
19
|
+
const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
20
|
+
const likePattern = `%${escaped}`;
|
|
21
|
+
const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
|
|
22
|
+
const rows = db.prepare(`
|
|
23
|
+
SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
|
|
24
|
+
o.created_at, o.created_at_epoch, o.project
|
|
25
|
+
FROM observations o
|
|
26
|
+
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
27
|
+
WHERE COALESCE(o.compressed_into, 0) = 0
|
|
28
|
+
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
29
|
+
${noiseClause}
|
|
30
|
+
ORDER BY o.created_at_epoch DESC
|
|
31
|
+
LIMIT ?
|
|
32
|
+
`).all(filename, likePattern, limit);
|
|
33
|
+
|
|
34
|
+
if (rows.length > 0) {
|
|
35
|
+
const ph = rows.map(() => '?').join(',');
|
|
36
|
+
try {
|
|
37
|
+
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`)
|
|
38
|
+
.run(Date.now(), ...rows.map(r => r.id));
|
|
39
|
+
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { filename, rows };
|
|
43
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Shared cross-source search core (query build / source queries / scoring
|
|
2
|
+
// normalization / sort / pagination math).
|
|
3
|
+
//
|
|
4
|
+
// Single source of truth for cmdSearch (CLI) and mem_search (MCP). The
|
|
5
|
+
// observation path already converged in search-engine.mjs (#8198/#8212); the
|
|
6
|
+
// sessions/prompts FTS queries, CJK precision + LIKE fallback, cross-source
|
|
7
|
+
// score normalization, user-sort, over-fetch sizing, and date-bound parsing
|
|
8
|
+
// were still copy-pasted and synced by "paired-path" comments — the drift
|
|
9
|
+
// class compress-core (ARCH-1), recall-core, and timeline-core were extracted
|
|
10
|
+
// to close. Call sites keep what legitimately differs: flag/schema parsing,
|
|
11
|
+
// result-row dialect (CLI `_source`+raw columns vs MCP `source`+mapped
|
|
12
|
+
// fields), error-message wording, and output rendering.
|
|
13
|
+
//
|
|
14
|
+
// Behavioral asymmetries that are PRESERVED, not converged (documented so a
|
|
15
|
+
// future "fix" is a deliberate contract change, not an accident):
|
|
16
|
+
// • CLI forces source=observations when --type/--tier/--importance/--branch
|
|
17
|
+
// is set; MCP only forces it for obs_type.
|
|
18
|
+
// • CLI warns on inverted --from/--to ranges; MCP does not.
|
|
19
|
+
// • CLI wraps session/prompt FTS in try/catch for pre-FTS legacy DBs.
|
|
20
|
+
|
|
21
|
+
import { sanitizeFtsQuery, relaxFtsQueryToOr, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS } from '../utils.mjs';
|
|
22
|
+
import { cjkPrecisionOk, extractCjkLikePatterns } from '../nlp.mjs';
|
|
23
|
+
import { computeTier } from '../tier.mjs';
|
|
24
|
+
|
|
25
|
+
/** Sanitize a user query to FTS5 syntax; optionally force OR semantics. */
|
|
26
|
+
export function buildSearchFtsQuery(query, { or = false } = {}) {
|
|
27
|
+
let ftsQuery = sanitizeFtsQuery(query);
|
|
28
|
+
if (ftsQuery && or) ftsQuery = relaxFtsQueryToOr(ftsQuery) || ftsQuery;
|
|
29
|
+
return ftsQuery;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse from/to date bounds to epoch ms. Date-only `to` (YYYY-MM-DD) extends
|
|
34
|
+
* to end-of-day so "to 2026-06-12" includes that day's rows.
|
|
35
|
+
* @returns {{ ok: true, epochFrom: number|null, epochTo: number|null }
|
|
36
|
+
* | { ok: false, bad: 'from'|'to', value: string }}
|
|
37
|
+
*/
|
|
38
|
+
export function parseDateBounds(fromRaw, toRaw) {
|
|
39
|
+
const epochFrom = fromRaw ? new Date(fromRaw).getTime() : null;
|
|
40
|
+
let epochTo = toRaw ? new Date(toRaw).getTime() : null;
|
|
41
|
+
if (epochTo !== null && toRaw && /^\d{4}-\d{2}-\d{2}$/.test(toRaw)) {
|
|
42
|
+
epochTo += 86400000 - 1; // extend to 23:59:59.999
|
|
43
|
+
}
|
|
44
|
+
if (epochFrom !== null && isNaN(epochFrom)) return { ok: false, bad: 'from', value: fromRaw };
|
|
45
|
+
if (epochTo !== null && isNaN(epochTo)) return { ok: false, bad: 'to', value: toRaw };
|
|
46
|
+
return { ok: true, epochFrom, epochTo };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Over-fetch window: every source fetches from offset 0 and the caller slices
|
|
51
|
+
* [offset, offset+limit) exactly ONCE post-merge. Pushing OFFSET into the
|
|
52
|
+
* per-source SQL double-applied it and gapped/overlapped pages, because the
|
|
53
|
+
* obs hybrid path (AND→OR fallback / vector / concept stages) re-adds rows the
|
|
54
|
+
* SQL OFFSET already skipped (#8217/#8638).
|
|
55
|
+
*/
|
|
56
|
+
export function computePerSourceWindow(limit, offset) {
|
|
57
|
+
return { perSourceLimit: Math.max(limit * 3, offset + limit + 10), perSourceOffset: 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** obs-side total query: when the AND→OR fallback fired, count the OR set. */
|
|
61
|
+
export function effectiveObsFtsQuery(ftsQuery, orFallbackFired) {
|
|
62
|
+
return orFallbackFired ? (relaxFtsQueryToOr(ftsQuery) || ftsQuery) : ftsQuery;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Session FTS search with recency decay + same-project boost. Returns raw SQL
|
|
67
|
+
* rows: { id, request, completed, project, created_at, created_at_epoch, score }.
|
|
68
|
+
* `projectBoost` is the inferred current project, only applied when the caller
|
|
69
|
+
* did NOT filter by project explicitly (pass null then).
|
|
70
|
+
*/
|
|
71
|
+
export function searchSessionsFts(db, { ftsQuery, project = null, projectBoost = null, epochFrom = null, epochTo = null, perSourceLimit, perSourceOffset = 0 }) {
|
|
72
|
+
const wheres = ['session_summaries_fts MATCH ?'];
|
|
73
|
+
const params = [Date.now(), projectBoost, projectBoost, ftsQuery];
|
|
74
|
+
if (project) { wheres.push('s.project = ?'); params.push(project); }
|
|
75
|
+
if (epochFrom !== null) { wheres.push('s.created_at_epoch >= ?'); params.push(epochFrom); }
|
|
76
|
+
if (epochTo !== null) { wheres.push('s.created_at_epoch <= ?'); params.push(epochTo); }
|
|
77
|
+
params.push(perSourceLimit, perSourceOffset);
|
|
78
|
+
return db.prepare(`
|
|
79
|
+
SELECT s.id, s.request, s.completed, s.project, s.created_at, s.created_at_epoch,
|
|
80
|
+
${SESS_BM25}
|
|
81
|
+
* (1.0 + EXP(-0.693 * (? - s.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
|
|
82
|
+
* (CASE WHEN ? IS NOT NULL AND s.project = ? THEN 2.0 ELSE 1.0 END) as score
|
|
83
|
+
FROM session_summaries_fts
|
|
84
|
+
JOIN session_summaries s ON session_summaries_fts.rowid = s.id
|
|
85
|
+
WHERE ${wheres.join(' AND ')}
|
|
86
|
+
ORDER BY score
|
|
87
|
+
LIMIT ? OFFSET ?
|
|
88
|
+
`).all(...params);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Prompt FTS search with CJK precision gate + CJK LIKE fallback. Returns raw
|
|
93
|
+
* SQL rows: { id, prompt_text, content_session_id, created_at,
|
|
94
|
+
* created_at_epoch, score } (fallback rows carry score = 0).
|
|
95
|
+
*
|
|
96
|
+
* The precision gate applies to BOTH paths: unicode61 degrades CJK bigram
|
|
97
|
+
* queries to single-char AND, and the LIKE fallback is an OR'd substring scan
|
|
98
|
+
* — without the gate each re-admits the common-char noise band the other
|
|
99
|
+
* dropped (that asymmetry was the actual leak source: FTS returned 0,
|
|
100
|
+
* fallback filled 20).
|
|
101
|
+
*/
|
|
102
|
+
export function searchPromptsFts(db, { query, ftsQuery, project = null, epochFrom = null, epochTo = null, perSourceLimit, perSourceOffset = 0 }) {
|
|
103
|
+
const wheres = ['user_prompts_fts MATCH ?', "p.prompt_text NOT LIKE '<task-notification>%'"];
|
|
104
|
+
const params = [ftsQuery];
|
|
105
|
+
if (project) { wheres.push('s.project = ?'); params.push(project); }
|
|
106
|
+
if (epochFrom !== null) { wheres.push('p.created_at_epoch >= ?'); params.push(epochFrom); }
|
|
107
|
+
if (epochTo !== null) { wheres.push('p.created_at_epoch <= ?'); params.push(epochTo); }
|
|
108
|
+
params.push(perSourceLimit, perSourceOffset);
|
|
109
|
+
const rows = db.prepare(`
|
|
110
|
+
SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch,
|
|
111
|
+
bm25(user_prompts_fts, 1) as score
|
|
112
|
+
FROM user_prompts_fts
|
|
113
|
+
JOIN user_prompts p ON user_prompts_fts.rowid = p.id
|
|
114
|
+
JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
|
|
115
|
+
WHERE ${wheres.join(' AND ')}
|
|
116
|
+
ORDER BY score
|
|
117
|
+
LIMIT ? OFFSET ?
|
|
118
|
+
`).all(...params);
|
|
119
|
+
const kept = query ? rows.filter((r) => cjkPrecisionOk(query, r.prompt_text)) : rows;
|
|
120
|
+
if (kept.length > 0 || !query) return kept;
|
|
121
|
+
|
|
122
|
+
// CJK LIKE fallback: FTS5 unicode61 can't tokenize CJK substrings in prompts
|
|
123
|
+
const cjkPatterns = extractCjkLikePatterns(query);
|
|
124
|
+
if (cjkPatterns.length === 0) return kept;
|
|
125
|
+
const likeConds = cjkPatterns.map(() => 'p.prompt_text LIKE ?');
|
|
126
|
+
const likeParams = cjkPatterns.map((p) => `%${p}%`);
|
|
127
|
+
if (project) likeParams.push(project);
|
|
128
|
+
if (epochFrom !== null) likeParams.push(epochFrom);
|
|
129
|
+
if (epochTo !== null) likeParams.push(epochTo);
|
|
130
|
+
likeParams.push(perSourceLimit, perSourceOffset);
|
|
131
|
+
const fallbackRows = db.prepare(`
|
|
132
|
+
SELECT p.id, p.prompt_text, p.content_session_id, p.created_at, p.created_at_epoch
|
|
133
|
+
FROM user_prompts p
|
|
134
|
+
JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
|
|
135
|
+
WHERE (${likeConds.join(' OR ')})
|
|
136
|
+
AND p.prompt_text NOT LIKE '<task-notification>%'
|
|
137
|
+
${project ? 'AND s.project = ?' : ''}
|
|
138
|
+
${epochFrom !== null ? 'AND p.created_at_epoch >= ?' : ''}
|
|
139
|
+
${epochTo !== null ? 'AND p.created_at_epoch <= ?' : ''}
|
|
140
|
+
ORDER BY p.created_at_epoch DESC
|
|
141
|
+
LIMIT ? OFFSET ?
|
|
142
|
+
`).all(...likeParams);
|
|
143
|
+
return fallbackRows
|
|
144
|
+
.filter((r) => cjkPrecisionOk(query, r.prompt_text))
|
|
145
|
+
.map((r) => ({ ...r, score: 0 }));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Normalize each source's BM25 scores to [-1, 0] before cross-source merge.
|
|
150
|
+
* Prevents observations (BM25 can reach -40) from systematically outranking
|
|
151
|
+
* sessions (-6) and prompts (-1) regardless of relevance. Sources with a
|
|
152
|
+
* single scored row are skipped — normalizing would inflate a weak match to
|
|
153
|
+
* -1.0. Mutates `results` in place; callers re-sort afterwards.
|
|
154
|
+
*/
|
|
155
|
+
export function normalizeCrossSourceScores(results, sourceKey) {
|
|
156
|
+
for (const src of ['obs', 'session', 'prompt']) {
|
|
157
|
+
const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined);
|
|
158
|
+
if (srcResults.length < 2) continue;
|
|
159
|
+
const maxAbs = Math.max(...srcResults.map((r) => Math.abs(r.score)));
|
|
160
|
+
if (maxAbs > 0) {
|
|
161
|
+
for (const r of srcResults) r.score = r.score / maxAbs;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Apply the user-requested sort AFTER relevance scoring. 'relevance' is a
|
|
168
|
+
* no-op — BM25 score order is already in place from the merge sort.
|
|
169
|
+
*/
|
|
170
|
+
export function applyUserSort(results, sort) {
|
|
171
|
+
if (sort === 'time') {
|
|
172
|
+
results.sort((a, b) => (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
|
|
173
|
+
} else if (sort === 'importance') {
|
|
174
|
+
results.sort((a, b) => (b.importance ?? 1) - (a.importance ?? 1) || (b.created_at_epoch ?? 0) - (a.created_at_epoch ?? 0));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Tier post-filter: batch-lookup full obs rows and keep only those whose
|
|
180
|
+
* computed tier matches. Non-obs rows pass through untouched. Classification
|
|
181
|
+
* uses the explicitly-requested project when given — CWD-inferred fallback
|
|
182
|
+
* breaks computeTier's "obs.project === currentProject" rules on
|
|
183
|
+
* cross-project searches and silently drops valid rows.
|
|
184
|
+
* @returns filtered array (input is not mutated)
|
|
185
|
+
*/
|
|
186
|
+
export function applyTierFilter(db, results, { tier, sourceKey, currentProject }) {
|
|
187
|
+
const obsIds = results.filter((r) => r[sourceKey] === 'obs').map((r) => r.id);
|
|
188
|
+
if (obsIds.length === 0) return results;
|
|
189
|
+
const placeholders = obsIds.map(() => '?').join(',');
|
|
190
|
+
const fullRows = db.prepare(
|
|
191
|
+
`SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${placeholders})`
|
|
192
|
+
).all(...obsIds);
|
|
193
|
+
const rowMap = new Map(fullRows.map((r) => [r.id, r]));
|
|
194
|
+
const tierCtx = { now: Date.now(), currentProject, currentSessionId: '' };
|
|
195
|
+
return results.filter((r) => {
|
|
196
|
+
if (r[sourceKey] !== 'obs') return true;
|
|
197
|
+
const full = rowMap.get(r.id);
|
|
198
|
+
return full && computeTier(full, tierCtx) === tier;
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Shared "timeline around an anchor" core.
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for cmdTimeline (CLI) and mem_timeline (MCP). Pre-
|
|
4
|
+
// extraction the anchor-resolution ladder (P#/S# token → nearest obs,
|
|
5
|
+
// bare int → obs with compressed_into re-anchor → prompt/session fallback),
|
|
6
|
+
// the query-anchor wrapper around findFtsAnchor, and the before/after window
|
|
7
|
+
// queries were copy-pasted across both and kept in sync by hand-written
|
|
8
|
+
// "aligned with" comments — the same drift vector compress-core (ARCH-1) and
|
|
9
|
+
// recall-core were extracted to close. Call sites keep what legitimately
|
|
10
|
+
// differs: argument parsing, output rendering (CLI relativeTime text / JSON vs
|
|
11
|
+
// MCP fmtDate lines), and error-message dialect (formatAnchorError owns both
|
|
12
|
+
// dialects so the wording cannot drift independently).
|
|
13
|
+
|
|
14
|
+
import { parseIdToken } from './id-routing.mjs';
|
|
15
|
+
import { findFtsAnchor } from '../search-engine.mjs';
|
|
16
|
+
import { sanitizeFtsQuery } from '../utils.mjs';
|
|
17
|
+
|
|
18
|
+
const TIMELINE_COLS = 'id, type, title, subtitle, project, created_at, created_at_epoch';
|
|
19
|
+
|
|
20
|
+
/** Nearest non-compressed observation to `epoch` (optionally project-scoped). */
|
|
21
|
+
function nearestObservation(db, epoch, project) {
|
|
22
|
+
return db.prepare(`
|
|
23
|
+
SELECT id FROM observations
|
|
24
|
+
WHERE COALESCE(compressed_into, 0) = 0 ${project ? 'AND project = ?' : ''}
|
|
25
|
+
ORDER BY ABS(created_at_epoch - ?) ASC LIMIT 1
|
|
26
|
+
`).get(...(project ? [project, epoch] : [epoch]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a raw anchor token (number, "N", "#N", "P#N", "S#N") to an
|
|
31
|
+
* observation id. Prompt/session anchors resolve to the nearest-in-time
|
|
32
|
+
* observation so before/after semantics still apply; compressed observations
|
|
33
|
+
* re-anchor to their live parent (negative sentinels error — no canonical
|
|
34
|
+
* parent); bare ints that miss observations fall back to prompt, then session.
|
|
35
|
+
*
|
|
36
|
+
* @returns {{ ok: true, anchorId: number, anchorNote: string|null }
|
|
37
|
+
* | { ok: false, error: object }} — render error via formatAnchorError
|
|
38
|
+
*/
|
|
39
|
+
export function resolveAnchorToken(db, rawAnchor, { project = null } = {}) {
|
|
40
|
+
const parsed = parseIdToken(rawAnchor);
|
|
41
|
+
if (!parsed) {
|
|
42
|
+
return { ok: false, error: { code: 'invalid-token', raw: rawAnchor } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (parsed.source === 'prompt' || parsed.source === 'session') {
|
|
46
|
+
const srcTable = parsed.source === 'prompt' ? 'user_prompts' : 'session_summaries';
|
|
47
|
+
const srcPrefix = parsed.source === 'prompt' ? 'P#' : 'S#';
|
|
48
|
+
const srcName = parsed.source === 'prompt' ? 'Prompt' : 'Session';
|
|
49
|
+
const row = db.prepare(`SELECT created_at_epoch FROM ${srcTable} WHERE id = ?`).get(parsed.id);
|
|
50
|
+
if (!row) return { ok: false, error: { code: 'source-not-found', name: srcName, prefix: srcPrefix, id: parsed.id } };
|
|
51
|
+
const nearest = nearestObservation(db, row.created_at_epoch, project);
|
|
52
|
+
if (!nearest) return { ok: false, error: { code: 'no-obs-near', prefix: srcPrefix, id: parsed.id } };
|
|
53
|
+
return { ok: true, anchorId: nearest.id, anchorNote: `(anchored to #${nearest.id}, closest obs to ${srcPrefix}${parsed.id})` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Bare "#N" or "N" — observation first. Route compressed obs to its live
|
|
57
|
+
// parent so the window (which filters compressed) isn't shown around a dead
|
|
58
|
+
// record; negative sentinels (-1 dropped, -2 pending purge) have no parent.
|
|
59
|
+
const obsRow = db.prepare('SELECT compressed_into FROM observations WHERE id = ?').get(parsed.id);
|
|
60
|
+
if (obsRow) {
|
|
61
|
+
const ci = obsRow.compressed_into;
|
|
62
|
+
if (ci && ci > 0) {
|
|
63
|
+
return { ok: true, anchorId: ci, anchorNote: `(anchored to #${ci}, #${parsed.id} was compressed into it)` };
|
|
64
|
+
}
|
|
65
|
+
if (ci && ci < 0) {
|
|
66
|
+
return { ok: false, error: { code: 'compressed-pruned', id: parsed.id } };
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, anchorId: parsed.id, anchorNote: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Fall back to user_prompts then session_summaries so pasted P#/S# ids still
|
|
72
|
+
// work when the prefix is omitted — matches prefix-aware routing in search/probe.
|
|
73
|
+
const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
|
|
74
|
+
const sessionRow = promptRow ? null : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
|
|
75
|
+
const hit = promptRow ? { row: promptRow, prefix: 'P#', name: 'prompt' }
|
|
76
|
+
: sessionRow ? { row: sessionRow, prefix: 'S#', name: 'session' }
|
|
77
|
+
: null;
|
|
78
|
+
if (!hit) return { ok: false, error: { code: 'id-not-found', id: parsed.id } };
|
|
79
|
+
const nearest = nearestObservation(db, hit.row.created_at_epoch, project);
|
|
80
|
+
if (!nearest) return { ok: false, error: { code: 'no-obs-near', prefix: hit.prefix, id: parsed.id, srcName: hit.name } };
|
|
81
|
+
return { ok: true, anchorId: nearest.id, anchorNote: `(anchored to #${nearest.id}, closest obs to ${hit.prefix}${parsed.id})` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Render a resolveAnchorToken error in either caller dialect. Owning BOTH
|
|
86
|
+
* renderings here is deliberate: the strings are regression-anchored on each
|
|
87
|
+
* side (tests/cli.test.mjs, tests/server.test.mjs) and previously drifted only
|
|
88
|
+
* in prefix/period; one table keeps the divergence explicit and frozen.
|
|
89
|
+
*
|
|
90
|
+
* cli: "[mem] "-prefixed, no trailing period, flag spelled "--anchor".
|
|
91
|
+
* mcp: bare sentence with trailing period.
|
|
92
|
+
*/
|
|
93
|
+
export function formatAnchorError(error, dialect) {
|
|
94
|
+
const cli = dialect === 'cli';
|
|
95
|
+
switch (error.code) {
|
|
96
|
+
case 'invalid-token':
|
|
97
|
+
return cli
|
|
98
|
+
? `[mem] Invalid --anchor "${error.raw}". Expected N, #N, P#N, or S#N.`
|
|
99
|
+
: `Invalid anchor "${error.raw}". Expected N, #N, P#N, or S#N.`;
|
|
100
|
+
case 'source-not-found':
|
|
101
|
+
return cli
|
|
102
|
+
? `[mem] ${error.name} ${error.prefix}${error.id} not found`
|
|
103
|
+
: `${error.name} ${error.prefix}${error.id} not found.`;
|
|
104
|
+
case 'no-obs-near': {
|
|
105
|
+
const suffix = error.srcName ? ` (${error.srcName})` : '';
|
|
106
|
+
return cli
|
|
107
|
+
? `[mem] No observations near ${error.prefix}${error.id}${suffix}`
|
|
108
|
+
: `No observations near ${error.prefix}${error.id}${suffix}.`;
|
|
109
|
+
}
|
|
110
|
+
case 'compressed-pruned':
|
|
111
|
+
return cli
|
|
112
|
+
? `[mem] Observation #${error.id} was compressed and pruned; no canonical anchor available`
|
|
113
|
+
: `Observation #${error.id} was compressed and pruned; no canonical anchor available.`;
|
|
114
|
+
case 'id-not-found':
|
|
115
|
+
return cli
|
|
116
|
+
? `[mem] Observation, prompt, or session with id ${error.id} not found`
|
|
117
|
+
: `Observation, prompt, or session with id ${error.id} not found.`;
|
|
118
|
+
default:
|
|
119
|
+
return cli ? `[mem] Anchor resolution failed` : 'Anchor resolution failed.';
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Query-based anchor: route through shared findFtsAnchor so CLI
|
|
125
|
+
* `timeline --query` and MCP mem_timeline keep identical AND→OR fallback
|
|
126
|
+
* semantics (#8217). Returns null when the query sanitizes to nothing or
|
|
127
|
+
* matches no row; anchorNote is set only when the OR relaxation fired.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveQueryAnchor(db, queryStr, { project = null } = {}) {
|
|
130
|
+
const ftsQuery = sanitizeFtsQuery(queryStr);
|
|
131
|
+
const found = findFtsAnchor(db, { ftsQuery, project });
|
|
132
|
+
if (!found) return null;
|
|
133
|
+
return {
|
|
134
|
+
anchorId: found.id,
|
|
135
|
+
anchorNote: found.relaxed
|
|
136
|
+
? `(query "${queryStr}" relaxed AND→OR — no row matched all terms)`
|
|
137
|
+
: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** No-anchor fallback: most recent non-compressed observations, newest first. */
|
|
142
|
+
export function fetchRecentTimeline(db, { project = null, limit }) {
|
|
143
|
+
const compressedFilter = 'COALESCE(compressed_into, 0) = 0';
|
|
144
|
+
const where = project ? `WHERE ${compressedFilter} AND project = ?` : `WHERE ${compressedFilter}`;
|
|
145
|
+
const params = project ? [project, limit] : [limit];
|
|
146
|
+
return db.prepare(`
|
|
147
|
+
SELECT ${TIMELINE_COLS}
|
|
148
|
+
FROM observations ${where}
|
|
149
|
+
ORDER BY created_at_epoch DESC
|
|
150
|
+
LIMIT ?
|
|
151
|
+
`).all(...params);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Fetch the before/after window around a resolved anchor id. Bumps the
|
|
156
|
+
* anchor's access_count (read-path popularity signal), and auto-scopes to the
|
|
157
|
+
* anchor's project when the caller didn't pass one — "timeline around #N"
|
|
158
|
+
* means same-project context, not cross-project time-bleed.
|
|
159
|
+
*
|
|
160
|
+
* @returns {null | { anchor, beforeRows, afterRows, effectiveProject }}
|
|
161
|
+
* null when the anchor row vanished (e.g. deleted between resolve and fetch).
|
|
162
|
+
* beforeRows are CHRONOLOGICAL (oldest→newest) — callers no longer reverse.
|
|
163
|
+
*/
|
|
164
|
+
export function fetchTimelineWindow(db, anchorId, { before, after, project = null }) {
|
|
165
|
+
const anchorRow = db.prepare('SELECT created_at_epoch, project FROM observations WHERE id = ?').get(anchorId);
|
|
166
|
+
if (!anchorRow) return null;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?').run(Date.now(), anchorId);
|
|
170
|
+
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
171
|
+
|
|
172
|
+
const effectiveProject = project || anchorRow.project;
|
|
173
|
+
const projectFilter = effectiveProject ? 'AND project = ?' : '';
|
|
174
|
+
const baseParams = effectiveProject ? [effectiveProject] : [];
|
|
175
|
+
|
|
176
|
+
const beforeRows = db.prepare(`
|
|
177
|
+
SELECT ${TIMELINE_COLS}
|
|
178
|
+
FROM observations
|
|
179
|
+
WHERE created_at_epoch < ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
180
|
+
ORDER BY created_at_epoch DESC
|
|
181
|
+
LIMIT ?
|
|
182
|
+
`).all(anchorRow.created_at_epoch, ...baseParams, before).reverse();
|
|
183
|
+
|
|
184
|
+
const afterRows = db.prepare(`
|
|
185
|
+
SELECT ${TIMELINE_COLS}
|
|
186
|
+
FROM observations
|
|
187
|
+
WHERE created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
|
|
188
|
+
ORDER BY created_at_epoch ASC
|
|
189
|
+
LIMIT ?
|
|
190
|
+
`).all(anchorRow.created_at_epoch, ...baseParams, after);
|
|
191
|
+
|
|
192
|
+
const anchor = db.prepare(`SELECT ${TIMELINE_COLS} FROM observations WHERE id = ?`).get(anchorId);
|
|
193
|
+
|
|
194
|
+
return { anchor, beforeRows, afterRows, effectiveProject };
|
|
195
|
+
}
|