claude-mem-lite 2.90.1 → 2.91.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.91.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.91.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"
@@ -120,7 +120,13 @@ export function mergeDuplicates(db, groups) {
120
120
  for (const group of groups) {
121
121
  if (!group || group.length < 2) continue;
122
122
  const [keepId, ...removeIds] = group;
123
- for (const removeId of removeIds) merged += mergeStmt.run(keepId, removeId).changes;
123
+ for (const removeId of removeIds) {
124
+ // Skip self-merge: compressed_into=self hides the row from every
125
+ // compressed_into=0 view (recent/search/browse) — silent data loss from a
126
+ // typo like `--merge-ids 5:5`. Shared core, so this covers CLI + MCP.
127
+ if (removeId === keepId) continue;
128
+ merged += mergeStmt.run(keepId, removeId).changes;
129
+ }
124
130
  }
125
131
  return merged;
126
132
  }
@@ -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)
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.91.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",
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,
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