claude-mem-lite 2.90.1 → 2.92.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.90.1",
13
+ "version": "2.92.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.90.1",
3
+ "version": "2.92.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"
package/hook-llm.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
13
13
  import { scrubRecord } from './lib/scrub-record.mjs';
14
14
  import { getVocabulary, computeVector } from './tfidf.mjs';
15
+ import { insertObservationRow, insertObservationFiles, insertObservationVector } from './lib/observation-write.mjs';
15
16
  import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
16
17
  import {
17
18
  RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
@@ -209,48 +210,23 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
209
210
  search_aliases: obs.searchAliases || null,
210
211
  });
211
212
 
212
- // Atomic: observation INSERT + observation_files + vector in one transaction
213
+ // Atomic: observation INSERT + observation_files + vector in one transaction.
214
+ // Column list single-sourced in lib/observation-write (shared with manual mem_save).
213
215
  const savedId = db.transaction(() => {
214
- const result = db.prepare(`
215
- INSERT INTO observations (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, search_aliases, branch, created_at, created_at_epoch)
216
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
217
- `).run(
218
- sessionId, project,
219
- safe.text, obs.type, safe.title, safe.subtitle,
220
- safe.narrative,
221
- safe.concepts,
222
- safe.facts,
223
- JSON.stringify(obs.filesRead || []),
224
- JSON.stringify(obs.files || []),
225
- obs.importance ?? 1,
226
- minhashSig,
227
- safe.lesson_learned,
228
- safe.search_aliases,
229
- getCurrentBranch(),
230
- now.toISOString(), now.getTime()
231
- );
232
- const id = Number(result.lastInsertRowid);
233
-
234
- // Populate observation_files junction table
235
- if (id && obs.files && obs.files.length > 0) {
236
- const insertFile = db.prepare('INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)');
237
- for (const f of obs.files) {
238
- if (typeof f === 'string' && f.length > 0) insertFile.run(id, f);
239
- }
240
- }
216
+ const id = insertObservationRow(db, {
217
+ memory_session_id: sessionId, project, text: safe.text, type: obs.type,
218
+ title: safe.title, subtitle: safe.subtitle, narrative: safe.narrative,
219
+ concepts: safe.concepts, facts: safe.facts,
220
+ files_read: JSON.stringify(obs.filesRead || []),
221
+ files_modified: JSON.stringify(obs.files || []),
222
+ importance: obs.importance ?? 1, minhash_sig: minhashSig,
223
+ lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases,
224
+ branch: getCurrentBranch(), created_at: now.toISOString(), created_at_epoch: now.getTime(),
225
+ });
241
226
 
242
- // Write TF-IDF vector (non-critical catch inside transaction to avoid rollback)
243
- try {
244
- const vocab = getVocabulary(db);
245
- if (vocab) {
246
- const vecText = [obs.title || '', obs.narrative || '', (Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '')].filter(Boolean).join(' ');
247
- const vec = computeVector(vecText, vocab);
248
- if (vec) {
249
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
250
- .run(id, Buffer.from(vec.buffer), vocab.version, Date.now());
251
- }
252
- }
253
- } catch (e) { debugCatch(e, 'saveObservation-vector'); }
227
+ insertObservationFiles(db, id, obs.files);
228
+ const vecText = [obs.title || '', obs.narrative || '', (Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '')].filter(Boolean).join(' ');
229
+ insertObservationVector(db, id, vecText);
254
230
 
255
231
  return id;
256
232
  })();
@@ -492,6 +492,18 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
492
492
  decay_seen_count = decay_seen_count + 1
493
493
  WHERE id = ?
494
494
  `);
495
+ // Suppressed (non-adopting) projects never demote, so uncited_streak would grow
496
+ // UNBOUNDED — and citeFactorClause penalizes -0.25*streak (floor 0.4), pinning every
497
+ // memory at the ranking floor with no recovery path. Cap at UNCITED_STREAK_THRESHOLD-1
498
+ // to hold the [0, threshold-1] steady state the scoring header asserts (in an adopting
499
+ // project the streak resets to 0 on demote, so the STORED value never exceeds 2).
500
+ const updateStreakCapped = db.prepare(`
501
+ UPDATE observations
502
+ SET uncited_streak = MIN(uncited_streak + 1, ?),
503
+ last_decided_session_id = ?,
504
+ decay_seen_count = decay_seen_count + 1
505
+ WHERE id = ?
506
+ `);
495
507
  const updateDemote = db.prepare(`
496
508
  UPDATE observations
497
509
  SET importance = MAX(?, importance - 1),
@@ -520,6 +532,9 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
520
532
  if (nextStreak >= UNCITED_STREAK_THRESHOLD && !suppressDemotion) {
521
533
  updateDemote.run(IMPORTANCE_FLOOR, sessionId, Date.now(), id);
522
534
  demoted++;
535
+ } else if (suppressDemotion) {
536
+ // Never-demoting project: cap the streak so cite_factor can't sink to floor.
537
+ updateStreakCapped.run(UNCITED_STREAK_THRESHOLD - 1, sessionId, id);
523
538
  } else {
524
539
  updateStreakOnly.run(sessionId, id);
525
540
  }
@@ -29,15 +29,28 @@ export const MINHASH_PRE_THRESHOLD = MINHASH_PRE_THRESHOLD_SRC;
29
29
  export const PINNED_INJ_THRESHOLD = 8;
30
30
 
31
31
  /** Delete broken observations (no title AND no narrative). Returns rows deleted. */
32
+ // Before hard-deleting observations, un-hide any rows merged INTO them. A child has
33
+ // compressed_into = <keeperId>; deleting that keeper (compressed_into has no FK) would
34
+ // leave the child dangling behind a now-missing parent — hidden from every
35
+ // COALESCE(compressed_into,0)=0 view and unrecoverable. Recovery = resurface the child
36
+ // as live (NULL) rather than lose it silently. Shared by both hard-delete paths.
37
+ function recoverChildrenOf(db, ids) {
38
+ if (!ids.length) return 0;
39
+ const ph = ids.map(() => '?').join(',');
40
+ return db.prepare(`UPDATE observations SET compressed_into = NULL WHERE compressed_into IN (${ph})`).run(...ids).changes;
41
+ }
42
+
32
43
  export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP }) {
33
- return db.prepare(`
34
- DELETE FROM observations WHERE id IN (
35
- SELECT id FROM observations
36
- WHERE COALESCE(compressed_into, 0) = 0
37
- AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
38
- ${projectFilter} LIMIT ${opCap}
39
- )
40
- `).run(...baseParams).changes;
44
+ const doomed = db.prepare(`
45
+ SELECT id FROM observations
46
+ WHERE COALESCE(compressed_into, 0) = 0
47
+ AND (title IS NULL OR title = '') AND (narrative IS NULL OR narrative = '')
48
+ ${projectFilter} LIMIT ${opCap}
49
+ `).all(...baseParams).map(r => r.id);
50
+ if (!doomed.length) return 0;
51
+ recoverChildrenOf(db, doomed); // empty-content row could still be a cluster keeper
52
+ const ph = doomed.map(() => '?').join(',');
53
+ return db.prepare(`DELETE FROM observations WHERE id IN (${ph})`).run(...doomed).changes;
41
54
  }
42
55
 
43
56
  /**
@@ -115,12 +128,54 @@ export function demotePinned(db, { projectFilter, baseParams, opCap = OP_CAP })
115
128
  * number of rows merged. Callers parse their own input (CLI string / MCP array).
116
129
  */
117
130
  export function mergeDuplicates(db, groups) {
118
- let merged = 0;
119
- const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
131
+ // Resolve the WHOLE batch before writing so transitive merges can't orphan rows.
132
+ // A row is hidden from every view by `compressed_into != 0`, so pointing it at a
133
+ // keeper that is itself hidden buries it behind a hidden parent. Naively applying
134
+ // groups one update at a time loses data in three ways the old 1-line self-merge
135
+ // guard missed:
136
+ // - chain [[A,B],[B,C]] -> C.compressed_into=B, but B is now hidden into A;
137
+ // if B is later purgeStale-deleted, C's keeper vanishes and C is unrecoverable.
138
+ // - mutual [[A,B],[B,A]] -> BOTH hidden, the cluster loses its live representative.
139
+ // - already-compressed keeper [E,F] when E was merged in a prior call -> F buried
140
+ // behind hidden E.
141
+ // mem_maintain's "dedup" auto-suggests pairs that can form these chains (server.mjs),
142
+ // so this is reachable in normal use, not just typos. Fix: build the redirect map,
143
+ // collapse each removeId to a single live keeper (cycles -> smallest id as canonical),
144
+ // and only write removeId -> keeper when that keeper is currently live. Shared core,
145
+ // so CLI + MCP both inherit it.
146
+ const redirect = new Map(); // removeId -> keepId (first writer wins, deterministic)
120
147
  for (const group of groups) {
121
148
  if (!group || group.length < 2) continue;
122
149
  const [keepId, ...removeIds] = group;
123
- for (const removeId of removeIds) merged += mergeStmt.run(keepId, removeId).changes;
150
+ for (const removeId of removeIds) {
151
+ if (removeId === keepId) continue; // self-merge typo: no-op
152
+ if (!redirect.has(removeId)) redirect.set(removeId, keepId);
153
+ }
154
+ }
155
+ if (redirect.size === 0) return 0;
156
+
157
+ // Follow the redirect chain to the ultimate keeper. A cycle (mutual merge) collapses
158
+ // to the smallest id among the cycle members so every member agrees on one survivor.
159
+ const resolveKeeper = (start) => {
160
+ const seen = [];
161
+ let cur = start;
162
+ while (redirect.has(cur)) {
163
+ const at = seen.indexOf(cur);
164
+ if (at !== -1) return Math.min(...seen.slice(at)); // cycle -> canonical = min member
165
+ seen.push(cur);
166
+ cur = redirect.get(cur);
167
+ }
168
+ return cur; // an id with no outgoing redirect is a keeper
169
+ };
170
+
171
+ const isLive = db.prepare('SELECT 1 FROM observations WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
172
+ const mergeStmt = db.prepare('UPDATE observations SET compressed_into = ? WHERE id = ? AND COALESCE(compressed_into, 0) = 0');
173
+ let merged = 0;
174
+ for (const removeId of redirect.keys()) {
175
+ const keeper = resolveKeeper(removeId);
176
+ if (keeper === removeId) continue; // cycle canonical: this row survives
177
+ if (!isLive.get(keeper)) continue; // keeper not live -> skip, never orphan
178
+ merged += mergeStmt.run(keeper, removeId).changes;
124
179
  }
125
180
  return merged;
126
181
  }
@@ -136,13 +191,17 @@ export function purgeStalePreview(db, { projectFilter, baseParams }, retainCutof
136
191
 
137
192
  /** Delete pending-purge observations older than the retain cutoff. Returns rows deleted. */
138
193
  export function purgeStale(db, { projectFilter, baseParams, opCap = OP_CAP }, retainCutoff) {
139
- return db.prepare(`
140
- DELETE FROM observations WHERE id IN (
141
- SELECT id FROM observations
142
- WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ?
143
- ${projectFilter} LIMIT ${opCap}
144
- )
145
- `).run(retainCutoff, ...baseParams).changes;
194
+ const doomed = db.prepare(`
195
+ SELECT id FROM observations
196
+ WHERE compressed_into = ${COMPRESSED_PENDING_PURGE} AND created_at_epoch < ?
197
+ ${projectFilter} LIMIT ${opCap}
198
+ `).all(retainCutoff, ...baseParams).map(r => r.id);
199
+ if (!doomed.length) return 0;
200
+ // A keeper that absorbed dups can later be marked idle (compressed_into=PENDING_PURGE)
201
+ // and reach here; deleting it would orphan its children. Recover them first.
202
+ recoverChildrenOf(db, doomed);
203
+ const ph = doomed.map(() => '?').join(',');
204
+ return db.prepare(`DELETE FROM observations WHERE id IN (${ph})`).run(...doomed).changes;
146
205
  }
147
206
 
148
207
  /**
@@ -0,0 +1,67 @@
1
+ // Single source of truth for the observations-table write surface. Two ingest
2
+ // paths previously hand-wrote divergent INSERTs — lib/save-observation.mjs (manual
3
+ // mem_save, 16 cols, omitted subtitle/search_aliases) and hook-llm.mjs (LLM
4
+ // auto-ingest, 18 cols) — the exact column-drift hazard the compress/maintain
5
+ // single-source cores were extracted to eliminate (see #8614). Add a column HERE
6
+ // and both ingest paths pick it up; neither can silently fall out of sync again.
7
+ //
8
+ // Statement-only: callers own the transaction boundary (both wrap the row + files
9
+ // + vector writes in one db.transaction so a failure can't leave a partial row).
10
+
11
+ import { getVocabulary, computeVector } from '../tfidf.mjs';
12
+ import { debugCatch } from '../utils.mjs';
13
+
14
+ // Canonical column order — must mirror the observations schema (schema.mjs).
15
+ const OBS_COLUMNS = [
16
+ 'memory_session_id', 'project', 'text', 'type', 'title', 'subtitle',
17
+ 'narrative', 'concepts', 'facts', 'files_read', 'files_modified',
18
+ 'importance', 'minhash_sig', 'lesson_learned', 'search_aliases', 'branch',
19
+ 'created_at', 'created_at_epoch',
20
+ ];
21
+ // Defaults for columns a caller omits. NULL-default columns (subtitle,
22
+ // search_aliases) match the schema DEFAULT, so omitting == the old short INSERT.
23
+ // concepts/facts/files_read default to the empty literals the manual path used.
24
+ const OBS_DEFAULTS = {
25
+ subtitle: null, narrative: '', concepts: '', facts: '',
26
+ files_read: '[]', files_modified: '[]', search_aliases: null, importance: 1,
27
+ };
28
+
29
+ /**
30
+ * Insert one observations row from a {column: value} map and return its id.
31
+ * Omitted columns fall back to OBS_DEFAULTS (or NULL). The column list lives only
32
+ * here, so a schema column can never drift between the two ingest paths again.
33
+ */
34
+ export function insertObservationRow(db, fields) {
35
+ const values = OBS_COLUMNS.map(c =>
36
+ Object.prototype.hasOwnProperty.call(fields, c) ? fields[c]
37
+ : (c in OBS_DEFAULTS ? OBS_DEFAULTS[c] : null)
38
+ );
39
+ const placeholders = OBS_COLUMNS.map(() => '?').join(', ');
40
+ const result = db
41
+ .prepare(`INSERT INTO observations (${OBS_COLUMNS.join(', ')}) VALUES (${placeholders})`)
42
+ .run(...values);
43
+ return Number(result.lastInsertRowid);
44
+ }
45
+
46
+ /** Populate the observation_files junction (skips non-string / empty entries). */
47
+ export function insertObservationFiles(db, obsId, files) {
48
+ if (!obsId || !Array.isArray(files) || files.length === 0) return;
49
+ const stmt = db.prepare('INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)');
50
+ for (const f of files) if (typeof f === 'string' && f.length > 0) stmt.run(obsId, f);
51
+ }
52
+
53
+ /**
54
+ * Best-effort TF-IDF vector write. Non-critical: vocab may be uninitialized on a
55
+ * fresh DB, so failures are swallowed (caller's transaction must NOT roll back the
56
+ * observation over a missing vector).
57
+ */
58
+ export function insertObservationVector(db, obsId, vecText) {
59
+ try {
60
+ const vocab = getVocabulary(db);
61
+ if (!vocab) return;
62
+ const vec = computeVector(vecText, vocab);
63
+ if (!vec) return;
64
+ db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
65
+ .run(obsId, Buffer.from(vec.buffer), vocab.version, Date.now());
66
+ } catch (e) { debugCatch(e, 'insertObservationVector'); }
67
+ }
@@ -11,9 +11,9 @@
11
11
  // - argument parsing (CLI flags vs MCP Zod schema)
12
12
  // - result rendering (CLI stdout vs MCP content array)
13
13
 
14
- import { jaccardSimilarity, scrubSecrets, computeMinHash, cjkBigrams, getCurrentBranch, debugCatch } from '../utils.mjs';
15
- import { getVocabulary, computeVector } from '../tfidf.mjs';
14
+ import { jaccardSimilarity, scrubSecrets, computeMinHash, cjkBigrams, getCurrentBranch } from '../utils.mjs';
16
15
  import { DEDUP_JACCARD_THRESHOLD } from './dedup-constants.mjs';
16
+ import { insertObservationRow, insertObservationFiles, insertObservationVector } from './observation-write.mjs';
17
17
 
18
18
  const DEDUP_WINDOW_MS = 5 * 60 * 1000;
19
19
  const DEDUP_RECENT_LIMIT = 50;
@@ -40,6 +40,13 @@ export function saveObservation(db, params) {
40
40
  const project = params.project;
41
41
  const type = params.type || 'discovery';
42
42
  const content = params.content;
43
+ // Defensive single-source guard: never persist an empty/whitespace-only row.
44
+ // CLI's `!text` check and MCP's `z.string().min(1)` both let whitespace-only
45
+ // content through (" " is length>=1 and truthy), creating junk observations
46
+ // with blank title/text. Reject here so both call sites are covered at once.
47
+ if (typeof content !== 'string' || content.trim().length === 0) {
48
+ throw new Error('mem_save: content is empty or whitespace-only');
49
+ }
43
50
  const rawTitle = params.title || content.slice(0, 100);
44
51
  const importance = params.importance ?? 2;
45
52
  const files = Array.isArray(params.files)
@@ -92,31 +99,17 @@ export function saveObservation(db, params) {
92
99
  // (TF-IDF). Vector write is best-effort — vocab may be uninitialized on a
93
100
  // fresh DB; failure must not roll back the observation.
94
101
  const saveTx = db.transaction(() => {
95
- const result = db.prepare(`
96
- INSERT INTO observations (memory_session_id, project, text, type, title, narrative, concepts, facts, files_read, files_modified, importance, minhash_sig, lesson_learned, branch, created_at, created_at_epoch)
97
- VALUES (?, ?, ?, ?, ?, ?, '', '', '[]', ?, ?, ?, ?, ?, ?, ?)
98
- `).run(
99
- sessionId, project, textField, type, safeTitle, safeContent,
100
- JSON.stringify(files), importance, minhashSig, safeLesson, getCurrentBranch(),
101
- now.toISOString(), now.getTime()
102
- );
103
- const savedId = Number(result.lastInsertRowid);
104
-
105
- if (savedId && files.length > 0) {
106
- const insertFile = db.prepare('INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)');
107
- for (const f of files) insertFile.run(savedId, f);
108
- }
102
+ // Manual-save shape: narrative=content, concepts/facts/files_read empty, no
103
+ // subtitle/search_aliases (defaults). Column list single-sourced in lib/observation-write.
104
+ const savedId = insertObservationRow(db, {
105
+ memory_session_id: sessionId, project, text: textField, type, title: safeTitle,
106
+ narrative: safeContent, files_modified: JSON.stringify(files), importance,
107
+ minhash_sig: minhashSig, lesson_learned: safeLesson, branch: getCurrentBranch(),
108
+ created_at: now.toISOString(), created_at_epoch: now.getTime(),
109
+ });
109
110
 
110
- try {
111
- const vocab = getVocabulary(db);
112
- if (vocab) {
113
- const vec = computeVector(safeTitle + ' ' + safeContent, vocab);
114
- if (vec) {
115
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
116
- .run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
117
- }
118
- }
119
- } catch (e) { debugCatch(e, 'save-observation-vector'); }
111
+ insertObservationFiles(db, savedId, files);
112
+ insertObservationVector(db, savedId, safeTitle + ' ' + safeContent);
120
113
 
121
114
  return savedId;
122
115
  });
package/mem-cli.mjs CHANGED
@@ -11,7 +11,7 @@ import { resolveProject } from './project-utils.mjs';
11
11
  import { computeTier, TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
12
12
  import { getVocabulary, computeVector, _resetVocabCache } from './tfidf.mjs';
13
13
  import { autoBoostIfNeeded, reRankWithContext, markSuperseded } from './server-internals.mjs';
14
- import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
14
+ import { searchObservationsHybrid, findFtsAnchor, countSearchTotal } from './search-engine.mjs';
15
15
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
16
16
  import { searchResources } from './registry-retriever.mjs';
17
17
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
@@ -313,9 +313,24 @@ function cmdSearch(db, args) {
313
313
 
314
314
  // Trim to limit with offset. The engine always received perSourceOffset=0 and
315
315
  // over-fetched (see above), so the merged+reranked `results` start at row 0 and
316
- // the offset is applied exactly ONCE here — for every mode. `total` is the full
317
- // match count (capped at perSourceLimit), enabling the "N of M" display.
318
- const total = results.length;
316
+ // the offset is applied exactly ONCE here — for every mode.
317
+ //
318
+ // `total` must be the TRUE population, independent of --limit/--offset (else the
319
+ // over-fetched candidate count grew with the page and broke the "N of M" /
320
+ // pagination contract). countSearchTotal mirrors each source's MATCH+filters;
321
+ // clamp to >= results.length so it never understates the rows actually shown
322
+ // (vector/concept augmentation can add obs rows beyond the FTS count).
323
+ const trueTotal = countSearchTotal(db, {
324
+ effectiveSource,
325
+ ftsQuery,
326
+ obsFtsQuery: orFallbackFired ? (relaxFtsQueryToOr(ftsQuery) || ftsQuery) : ftsQuery,
327
+ args: { project: project || null, obs_type: type || null, importance: minImportance || null, branch: branch || null },
328
+ project: project || null,
329
+ epochFrom: dateFrom,
330
+ epochTo: dateTo,
331
+ includeNoise,
332
+ });
333
+ const total = Math.max(trueTotal, results.length);
319
334
  const paged = results.slice(offset, offset + limit);
320
335
 
321
336
  if (paged.length === 0) {
@@ -924,7 +939,7 @@ function cmdTimeline(db, args) {
924
939
  function cmdSave(db, args) {
925
940
  const { positional, flags } = parseArgs(args);
926
941
  const text = positional.join(' ');
927
- if (!text) {
942
+ if (!text.trim()) {
928
943
  fail('[mem] Usage: claude-mem-lite save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T] [--closes-deferred 1,D#42]');
929
944
  return;
930
945
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.90.1",
3
+ "version": "2.92.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",
@@ -67,6 +67,7 @@
67
67
  "lib/binding-probe.mjs",
68
68
  "lib/mem-override.mjs",
69
69
  "lib/save-observation.mjs",
70
+ "lib/observation-write.mjs",
70
71
  "lib/compress-core.mjs",
71
72
  "lib/maintain-core.mjs",
72
73
  "lib/dedup-constants.mjs",
@@ -136,7 +137,7 @@
136
137
  "zod": "^4.3.6"
137
138
  },
138
139
  "overrides": {
139
- "hono": ">=4.12.16",
140
+ "hono": ">=4.12.21",
140
141
  "fast-uri": ">=3.1.2",
141
142
  "ip-address": ">=10.1.1"
142
143
  },
package/registry.mjs CHANGED
@@ -339,135 +339,3 @@ export function upsertResource(db, r) {
339
339
  return row?.id || 0;
340
340
  })();
341
341
  }
342
-
343
- /**
344
- * Get all active resources.
345
- * @param {Database} db Registry database
346
- * @returns {object[]} Array of resource objects
347
- */
348
- export function getActiveResources(db) {
349
- return db.prepare('SELECT * FROM resources WHERE status = ? ORDER BY name').all('active');
350
- }
351
-
352
- /**
353
- * Get a resource by type and name.
354
- * @param {Database} db Registry database
355
- * @param {string} type 'skill' or 'agent'
356
- * @param {string} name Resource name
357
- * @returns {object|undefined} Resource or undefined
358
- */
359
- export function getResourceByName(db, type, name) {
360
- return db.prepare('SELECT * FROM resources WHERE type = ? AND name = ?').get(type, name);
361
- }
362
-
363
- /**
364
- * Get a resource by ID.
365
- * @param {Database} db Registry database
366
- * @param {number} id Resource ID
367
- * @returns {object|undefined} Resource or undefined
368
- */
369
- export function getResourceById(db, id) {
370
- return db.prepare('SELECT * FROM resources WHERE id = ?').get(id);
371
- }
372
-
373
- /**
374
- * Atomically increment a stats field on a resource.
375
- * @param {Database} db Registry database
376
- * @param {number} id Resource ID
377
- * @param {'recommend_count'|'adopt_count'|'success_count'} field Stats field
378
- */
379
- export function updateResourceStats(db, id, field) {
380
- const allowed = new Set(['recommend_count', 'adopt_count', 'success_count']);
381
- if (!allowed.has(field)) throw new Error(`Invalid stats field: ${field}`);
382
- // String interpolation required: SQLite cannot parameterize column names.
383
- // Safety: field is validated against allowlist above.
384
- db.prepare(`UPDATE resources SET ${field} = ${field} + 1, updated_at = datetime('now') WHERE id = ?`).run(id);
385
- }
386
-
387
- /**
388
- * Atomically increment weighted_adopt_sum by a continuous score value.
389
- * @param {Database} db Registry database
390
- * @param {number} id Resource ID
391
- * @param {number} score Adoption confidence score (0.0-1.0)
392
- */
393
- export function incrementWeightedAdopt(db, id, score) {
394
- db.prepare(`UPDATE resources SET weighted_adopt_sum = COALESCE(weighted_adopt_sum, 0) + ?, updated_at = datetime('now') WHERE id = ?`).run(score, id);
395
- }
396
-
397
- /**
398
- * Record a dispatch invocation.
399
- * @param {Database} db Registry database
400
- * @param {object} record Invocation record
401
- * @returns {number} Invocation ID
402
- */
403
- export function recordInvocation(db, record) {
404
- const result = db.prepare(`
405
- INSERT INTO invocations (resource_id, session_id, trigger, tier, recommended, adopted, outcome, score)
406
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
407
- `).run(
408
- record.resource_id, record.session_id || null,
409
- record.trigger || null, record.tier || null,
410
- record.recommended ?? 1, record.adopted ?? 0,
411
- record.outcome || null, record.score ?? null
412
- );
413
- return Number(result.lastInsertRowid);
414
- }
415
-
416
- /**
417
- * Get success rates for resources over a time period.
418
- * @param {Database} db Registry database
419
- * @param {number} [days=30] Lookback period
420
- * @returns {object[]} Array of {resource_id, total, adopted, avg_score}
421
- */
422
- export function getResourceSuccessRates(db, days = 30) {
423
- const since = new Date(Date.now() - days * 86400000).toISOString();
424
- return db.prepare(`
425
- SELECT resource_id,
426
- COUNT(*) as total,
427
- SUM(adopted) as adopted,
428
- AVG(CASE WHEN score IS NOT NULL THEN score END) as avg_score
429
- FROM invocations
430
- WHERE created_at > ?
431
- GROUP BY resource_id
432
- `).all(since);
433
- }
434
-
435
- /**
436
- * Get invocations for a specific session (for feedback collection).
437
- * @param {Database} db Registry database
438
- * @param {string} sessionId Session identifier
439
- * @returns {object[]} Array of invocation records
440
- */
441
- export function getSessionInvocations(db, sessionId) {
442
- return db.prepare(`
443
- SELECT i.*, r.name as resource_name, r.type as resource_type,
444
- r.invocation_name as invocation_name
445
- FROM invocations i
446
- JOIN resources r ON r.id = i.resource_id
447
- WHERE i.session_id = ?
448
- ORDER BY i.created_at
449
- `).all(sessionId);
450
- }
451
-
452
- /**
453
- * Update invocation outcome (for feedback).
454
- * @param {Database} db Registry database
455
- * @param {number} id Invocation ID
456
- * @param {object} update Fields to update
457
- */
458
- export function updateInvocation(db, id, update) {
459
- const allowed = new Set(['adopted', 'outcome', 'score', 'rejection_reason']);
460
- const sets = [];
461
- const vals = [];
462
- for (const [key, val] of Object.entries(update)) {
463
- if (val === undefined) continue;
464
- if (!allowed.has(key)) throw new Error(`Invalid invocation field: ${key}`);
465
- sets.push(`${key} = ?`);
466
- vals.push(val);
467
- }
468
- if (sets.length === 0) return;
469
- vals.push(id);
470
- // String interpolation required: SQLite cannot parameterize column names.
471
- // Safety: column names are validated against allowlist above.
472
- db.prepare(`UPDATE invocations SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
473
- }
package/search-engine.mjs CHANGED
@@ -72,6 +72,110 @@ export function buildObsFtsParams({ now, projectBoost, ftsQuery, args, epochFrom
72
72
  return params;
73
73
  }
74
74
 
75
+ // --- True match-count helpers (limit/offset-invariant search totals) ----------
76
+ // The search path over-fetches per source — perSourceLimit = max(limit*3,
77
+ // offset+limit+10) — and historically reported `total = results.length`. That made
78
+ // "Found N of M" and the JSON `total` field grow with --limit/--offset, breaking
79
+ // the documented pagination contract (a query's population must not change when you
80
+ // page through it). These COUNT(*) helpers mirror each source's MATCH + filters
81
+ // exactly, so `total` reflects the real population independent of paging. Shared by
82
+ // CLI and MCP per the paired-path single-source-of-truth rule (#8217).
83
+ //
84
+ // Known approximation: post-SQL filters that DROP rows after the query (CJK
85
+ // precision gate on prompts, --tier on obs) are not reflected here, so those niche
86
+ // queries may overcount. Callers clamp total to >= page size, so it never
87
+ // understates the rows actually shown.
88
+ export function countObsFtsMatches(db, { ftsQuery, args = {}, epochFrom = null, epochTo = null, includeNoise = false }) {
89
+ if (!ftsQuery) return 0;
90
+ const lowSignalClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
91
+ try {
92
+ const row = db.prepare(`
93
+ SELECT COUNT(*) as c
94
+ FROM observations_fts
95
+ JOIN observations o ON observations_fts.rowid = o.id
96
+ WHERE observations_fts MATCH ?
97
+ AND COALESCE(o.compressed_into, 0) = 0
98
+ AND o.superseded_at IS NULL
99
+ AND (? IS NULL OR o.project = ?)
100
+ AND (? IS NULL OR o.type = ?)
101
+ AND (? IS NULL OR o.created_at_epoch >= ?)
102
+ AND (? IS NULL OR o.created_at_epoch <= ?)
103
+ AND (? IS NULL OR COALESCE(o.importance, 1) >= ?)
104
+ AND (? IS NULL OR o.branch = ?)
105
+ ${lowSignalClause}
106
+ `).get(
107
+ ftsQuery,
108
+ args.project ?? null, args.project ?? null,
109
+ args.obs_type ?? null, args.obs_type ?? null,
110
+ epochFrom, epochFrom,
111
+ epochTo, epochTo,
112
+ args.importance ?? null, args.importance ?? null,
113
+ args.branch ?? null, args.branch ?? null,
114
+ );
115
+ return row?.c ?? 0;
116
+ } catch { return 0; }
117
+ }
118
+
119
+ export function countSessionFtsMatches(db, { ftsQuery, project = null, epochFrom = null, epochTo = null }) {
120
+ if (!ftsQuery) return 0;
121
+ try {
122
+ const wheres = ['session_summaries_fts MATCH ?'];
123
+ const params = [ftsQuery];
124
+ if (project) { wheres.push('s.project = ?'); params.push(project); }
125
+ if (epochFrom) { wheres.push('s.created_at_epoch >= ?'); params.push(epochFrom); }
126
+ if (epochTo) { wheres.push('s.created_at_epoch <= ?'); params.push(epochTo); }
127
+ const row = db.prepare(`
128
+ SELECT COUNT(*) as c
129
+ FROM session_summaries_fts
130
+ JOIN session_summaries s ON session_summaries_fts.rowid = s.id
131
+ WHERE ${wheres.join(' AND ')}
132
+ `).get(...params);
133
+ return row?.c ?? 0;
134
+ } catch { return 0; }
135
+ }
136
+
137
+ export function countPromptFtsMatches(db, { ftsQuery, project = null, epochFrom = null, epochTo = null }) {
138
+ if (!ftsQuery) return 0;
139
+ try {
140
+ const wheres = ['user_prompts_fts MATCH ?', "p.prompt_text NOT LIKE '<task-notification>%'"];
141
+ const params = [ftsQuery];
142
+ if (project) { wheres.push('s.project = ?'); params.push(project); }
143
+ if (epochFrom) { wheres.push('p.created_at_epoch >= ?'); params.push(epochFrom); }
144
+ if (epochTo) { wheres.push('p.created_at_epoch <= ?'); params.push(epochTo); }
145
+ const row = db.prepare(`
146
+ SELECT COUNT(*) as c
147
+ FROM user_prompts_fts
148
+ JOIN user_prompts p ON user_prompts_fts.rowid = p.id
149
+ JOIN sdk_sessions s ON p.content_session_id = s.content_session_id
150
+ WHERE ${wheres.join(' AND ')}
151
+ `).get(...params);
152
+ return row?.c ?? 0;
153
+ } catch { return 0; }
154
+ }
155
+
156
+ /**
157
+ * Sum true match counts across the sources that contribute to a cross-source (or
158
+ * source-restricted) search. `obsFtsQuery` lets callers pass the OR-relaxed query
159
+ * when obs AND→OR fallback fired (the displayed obs rows came from the OR query).
160
+ * @returns {number} population count, limit/offset-invariant
161
+ */
162
+ export function countSearchTotal(db, {
163
+ effectiveSource = null, ftsQuery, obsFtsQuery = null,
164
+ args = {}, project = null, epochFrom = null, epochTo = null, includeNoise = false,
165
+ }) {
166
+ let total = 0;
167
+ if (!effectiveSource || effectiveSource === 'observations') {
168
+ total += countObsFtsMatches(db, { ftsQuery: obsFtsQuery || ftsQuery, args, epochFrom, epochTo, includeNoise });
169
+ }
170
+ if (!effectiveSource || effectiveSource === 'sessions') {
171
+ total += countSessionFtsMatches(db, { ftsQuery, project, epochFrom, epochTo });
172
+ }
173
+ if (!effectiveSource || effectiveSource === 'prompts') {
174
+ total += countPromptFtsMatches(db, { ftsQuery, project, epochFrom, epochTo });
175
+ }
176
+ return total;
177
+ }
178
+
75
179
  export function ftsRowToResult(r, { scoreMultiplier, snippet } = {}) {
76
180
  return {
77
181
  source: 'obs', id: r.id, type: r.type, title: r.title, subtitle: r.subtitle,
@@ -262,6 +366,15 @@ export function searchObservationsHybrid(db, ctx) {
262
366
  if (vecResults.length === 0) return results;
263
367
 
264
368
  if (results.length > 0) {
369
+ // RRF fuses by RANK (array index), so the BM25 side must already be in
370
+ // composite-score order. `results` here is [full-FTS sorted, …concept ×0.7,
371
+ // …PRF ×0.6] with augmentation rows APPENDED, so its index order is only
372
+ // BM25-rank for the first block — a downweighted PRF row at the tail would be
373
+ // handed to RRF as a worse rank than its score warrants, and a strong one as
374
+ // better. Sort by the calibrated composite score (negative = more relevant)
375
+ // first so index == composite rank and the type-quality/decay/cite multipliers
376
+ // actually shape the fused ranking instead of being discarded by insertion order.
377
+ results.sort((a, b) => (a.score ?? 0) - (b.score ?? 0));
265
378
  const rrfRanking = rrfMerge(results, vecResults, ctx.rrfK); // undefined → RRF_K
266
379
  const resultMap = new Map(results.map(r => [r.id, r]));
267
380
  for (const vr of vecResults) {
package/server.mjs CHANGED
@@ -10,7 +10,7 @@ import { extractCjkLikePatterns, cjkPrecisionOk } from './nlp.mjs';
10
10
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
11
11
  import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
12
12
  import { reRankWithContext, markSuperseded, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './server-internals.mjs';
13
- import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
13
+ import { searchObservationsHybrid, findFtsAnchor, countSearchTotal } from './search-engine.mjs';
14
14
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
15
15
  import {
16
16
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
@@ -314,7 +314,7 @@ function searchPrompts(ctx) {
314
314
  return results;
315
315
  }
316
316
 
317
- function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, isCrossSource, orFallbackFired = false) {
317
+ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFallbackFired = false) {
318
318
  if (paginatedResults.length === 0) {
319
319
  const hint = [];
320
320
  if (args.query && !ftsQuery) {
@@ -332,7 +332,13 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, isCros
332
332
  }
333
333
 
334
334
  const lines = [];
335
- const countLabel = isCrossSource && totalCount > paginatedResults.length
335
+ // "N of M" whenever the population exceeds the page — NOT gated on isCrossSource.
336
+ // totalCount is the true limit/offset-invariant population (countSearchTotal), so
337
+ // single-source searches (obs_type / type / importance filters) must surface it too.
338
+ // The old isCrossSource gate predated countSearchTotal: back then single-source
339
+ // totalCount was just results.length, so suppressing "of M" hid nothing. Now it hid
340
+ // the real total, diverging from the CLI (mem-cli.mjs has no such gate). (#8217)
341
+ const countLabel = totalCount > paginatedResults.length
336
342
  ? `${paginatedResults.length} of ${totalCount}`
337
343
  : `${paginatedResults.length}`;
338
344
  const hasMixed = paginatedResults.some(r => r.source === 'session' || r.source === 'prompt');
@@ -385,9 +391,15 @@ server.registerTool(
385
391
  const searchType = args.type;
386
392
  const currentProject = inferProject();
387
393
 
388
- const isCrossSourceRaw = !searchType;
389
- const perSourceLimit = isCrossSourceRaw ? Math.max(limit * 3, offset + limit + 10) : limit;
390
- const perSourceOffset = isCrossSourceRaw ? 0 : offset;
394
+ // Over-fetch from offset 0 for EVERY mode, then apply `offset` exactly once at
395
+ // the merge slice below identical to the CLI (mem-cli.mjs perSourceOffset=0).
396
+ // The old single-source branch (perSourceLimit=limit, perSourceOffset=offset)
397
+ // double-applied offset: it pushed offset into the per-source SQL AND re-sliced
398
+ // by offset at merge, so explicit `type=observations` paging overlapped (page 0
399
+ // == page 1) and gapped (oldest rows unreachable). It also fetched only `limit`
400
+ // rows — fewer than offset+limit — so there was nothing to page into. (#8217)
401
+ const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
402
+ const perSourceOffset = 0;
391
403
 
392
404
  // Parse date bounds to epoch (with validation)
393
405
  // date_to with date-only format (YYYY-MM-DD) extends to end-of-day (23:59:59.999Z)
@@ -401,7 +413,7 @@ server.registerTool(
401
413
 
402
414
  // Early return when query was provided but sanitized to nothing (all FTS5 keywords/special chars)
403
415
  if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance) {
404
- return formatSearchOutput([], args, ftsQuery, 0, false);
416
+ return formatSearchOutput([], args, ftsQuery, 0);
405
417
  }
406
418
 
407
419
  // When obs_type is specified, implicitly restrict to observations only
@@ -502,11 +514,25 @@ server.registerTool(
502
514
  }
503
515
  // else 'relevance' keeps BM25 score order (already sorted)
504
516
 
505
- const totalBeforePagination = results.length;
517
+ // `total` must be the TRUE population, invariant to limit/offset. In cross-source
518
+ // mode results is over-fetched (perSourceLimit scales with limit+offset), so
519
+ // results.length is NOT the population — count the real MATCH set instead. Clamp
520
+ // to >= results.length so vector/concept-augmented obs rows are never undercounted.
521
+ // (paired-path with mem-cli.mjs via shared countSearchTotal — #8217)
522
+ const trueTotal = countSearchTotal(db, {
523
+ effectiveSource: effectiveType || null,
524
+ ftsQuery,
525
+ obsFtsQuery: ctx.orFallbackFired === true ? (relaxFtsQueryToOr(ftsQuery) || ftsQuery) : ftsQuery,
526
+ args: { project: args.project || null, obs_type: args.obs_type || null, importance: args.importance || null, branch: args.branch || null },
527
+ project: args.project || null,
528
+ epochFrom, epochTo,
529
+ includeNoise: args.include_noise === true,
530
+ });
531
+ const totalBeforePagination = Math.max(trueTotal, results.length);
506
532
  // Always apply pagination — single-source results can exceed SQL LIMIT due to expansion (concept co-occurrence, PRF, vector search)
507
533
  const paginatedResults = (offset > 0 || results.length > limit) ? results.slice(offset, offset + limit) : results;
508
534
 
509
- return formatSearchOutput(paginatedResults, args, ftsQuery, totalBeforePagination, isCrossSource, ctx.orFallbackFired === true);
535
+ return formatSearchOutput(paginatedResults, args, ftsQuery, totalBeforePagination, ctx.orFallbackFired === true);
510
536
  })
511
537
  );
512
538
 
package/source-files.mjs CHANGED
@@ -77,6 +77,11 @@ export const SOURCE_FILES = [
77
77
  // mem-cli.mjs::cmdSave and server.mjs::mem_save. Statically imported from both
78
78
  // entry points; missing it from the manifest broke MCP saves on auto-update.
79
79
  'lib/save-observation.mjs',
80
+ // Single-source observations-table write primitives (insertObservationRow/Files/
81
+ // Vector). Statically imported by lib/save-observation.mjs and hook-llm.mjs (both
82
+ // entry-point-reachable); missing it from the manifest would break ALL saves on
83
+ // auto-update. Same single-source-of-truth pattern (see #8217).
84
+ 'lib/observation-write.mjs',
80
85
  // Shared "compress old low-value observations into weekly summaries" core.
81
86
  // Statically imported by mem-cli.mjs (cmdCompress), server.mjs (mem_compress),
82
87
  // and hook.mjs (handleAutoCompress) — same single-source-of-truth pattern as