claude-mem-lite 2.90.0 → 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.0",
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.0",
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"
package/cli.mjs CHANGED
@@ -32,7 +32,10 @@ if (cmd === '--version' || cmd === '-v') {
32
32
  // No command: show CLI help if installed, install help if not
33
33
  const { existsSync } = await import('fs');
34
34
  const { join } = await import('path');
35
- const dbPath = join(process.env.HOME || '', '.claude-mem-lite', 'claude-mem-lite.db');
35
+ // D#29: honor CLAUDE_MEM_DIR so the install-vs-CLI help routing is correct on
36
+ // relocated installs (matches schema.mjs DB_DIR; HOME fallback when env unset).
37
+ const dataDir = process.env.CLAUDE_MEM_DIR || join(process.env.HOME || '', '.claude-mem-lite');
38
+ const dbPath = join(dataDir, 'claude-mem-lite.db');
36
39
  if (existsSync(dbPath)) {
37
40
  const { run } = await import('./mem-cli.mjs');
38
41
  await run(['help']);
@@ -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';
@@ -26,7 +26,7 @@ import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
26
26
  import { parseIntFlag, isNumericToken } from './lib/cli-flags.mjs';
27
27
  import { auditMemdir, memdirPath } from './memdir.mjs';
28
28
  import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
29
- import { basename, join } from 'path';
29
+ import { basename, join, sep } from 'path';
30
30
  import { readFileSync, existsSync, readdirSync } from 'fs';
31
31
 
32
32
  // v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
@@ -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
  }
@@ -2180,7 +2195,7 @@ function cmdRegistry(_memDb, args) {
2180
2195
  for (const r of results) {
2181
2196
  const badge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
2182
2197
  const categoryLabel = r.category ? ` [${r.category}]` : '';
2183
- const isManaged = r.local_path && r.local_path.includes('/.claude-mem-lite/managed/');
2198
+ const isManaged = r.local_path && r.local_path.includes(join(DB_DIR, 'managed') + sep);
2184
2199
  const portablePath = isManaged && r.local_path.startsWith(home) ? '~' + r.local_path.slice(home.length) : (r.local_path || '');
2185
2200
  let howToUse;
2186
2201
  if (isManaged) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.90.0",
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",
@@ -8,9 +8,12 @@ import { debugLog, isPathConfined } from './utils.mjs';
8
8
  import { createHash } from 'crypto';
9
9
  import { mkdirSync, writeFileSync } from 'fs';
10
10
  import { join } from 'path';
11
- import { homedir } from 'os';
11
+ import { DB_DIR } from './schema.mjs';
12
12
 
13
- const MANAGED_DIR = join(homedir(), '.claude-mem-lite', 'managed');
13
+ // DATA artifact managed resources live under the env-aware data dir (DB_DIR),
14
+ // NOT a hardcoded homedir, so GitHub imports land where install.mjs + registry-scanner
15
+ // read them under CLAUDE_MEM_DIR relocation (D#29). Equals homedir when the env is unset.
16
+ const MANAGED_DIR = join(DB_DIR, 'managed');
14
17
 
15
18
  // ─── Tree Discovery ─────────────────────────────────────────────────────────
16
19
 
@@ -11,9 +11,15 @@ import { recordHookError } from '../lib/hook-telemetry.mjs';
11
11
  // CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
12
12
  const DATA_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
13
13
  const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
14
- const REGISTRY_DB_PATH = join(homedir(), '.claude-mem-lite', 'resource-registry.db');
15
- const MANAGED_BASE = join(homedir(), '.claude-mem-lite');
16
- const MANAGED_MARKER = '/.claude-mem-lite/managed/';
14
+ // D#29: all data artifacts follow DATA_DIR (CLAUDE_MEM_DIR-aware), not a hardcoded
15
+ // homedir — previously REGISTRY_DB_PATH/MANAGED_BASE/MARKER pinned homedir while line 12
16
+ // honored the env, so relocated installs opened the wrong DB and the marker never matched
17
+ // the relocated local_path. MANAGED_MARKER is only a coarse LIKE prefilter; the exact
18
+ // MANAGED_BASE prefix check below is the real confinement gate (so LIKE-wildcard chars in
19
+ // a relocated path can at worst over-admit to that gate, never bypass it).
20
+ const REGISTRY_DB_PATH = join(DATA_DIR, 'resource-registry.db');
21
+ const MANAGED_BASE = DATA_DIR;
22
+ const MANAGED_MARKER = join(DATA_DIR, 'managed') + sep;
17
23
 
18
24
  try {
19
25
  // Skip if recursive hook
@@ -8,7 +8,7 @@ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject,
8
8
  import { citeFactorClause } from '../scoring-sql.mjs';
9
9
  import { cjkPrecisionOk } from '../nlp.mjs';
10
10
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
11
- import { join } from 'path';
11
+ import { join, sep } from 'path';
12
12
  import Database from 'better-sqlite3';
13
13
  import { shouldSkip, computeEffectiveLen, detectIntent, shouldSkipByDedup, extractFiles, extractErrorSignature, DEDUP_STALE_MS, matchRegistrySkillName, detectMemOverride } from './prompt-search-utils.mjs';
14
14
 
@@ -439,10 +439,18 @@ function loadManagedSkillNames() {
439
439
  const rdb = new Database(REGISTRY_DB_PATH, { readonly: true });
440
440
  rdb.pragma('busy_timeout = 500');
441
441
  try {
442
+ // D#29: derive the managed marker from the env-aware data dir, not a hardcoded
443
+ // homedir literal — under CLAUDE_MEM_DIR relocation the stored local_path lives at
444
+ // DB_DIR/managed, so the old literal matched nothing and dropped every managed skill
445
+ // from injection. Coarse LIKE prefilter; resource names are re-validated downstream.
446
+ // D#29: derive the managed marker from the env-aware data dir, not a hardcoded
447
+ // homedir literal — under CLAUDE_MEM_DIR relocation the stored local_path lives at
448
+ // DB_DIR/managed, so the old literal matched nothing and dropped every managed skill
449
+ // from injection. Coarse LIKE prefilter; resource names are re-validated downstream.
442
450
  const rows = rdb.prepare(`
443
451
  SELECT name FROM resources
444
- WHERE status = 'active' AND local_path LIKE '%/.claude-mem-lite/managed/%'
445
- `).all();
452
+ WHERE status = 'active' AND local_path LIKE ?
453
+ `).all(`%${join(DB_DIR, 'managed') + sep}%`);
446
454
  return new Set(rows.map(r => r.name.toLowerCase()));
447
455
  } finally { rdb.close(); }
448
456
  } catch { return new Set(); }
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
@@ -8,9 +8,9 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { truncate, typeIcon, sanitizeFtsQuery, relaxFtsQueryToOr, inferProject, scrubSecrets, cjkBigrams, fmtDate, debugLog, debugCatch, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, isPathConfined, notLowSignalTitleClause } from './utils.mjs';
9
9
  import { extractCjkLikePatterns, cjkPrecisionOk } from './nlp.mjs';
10
10
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
11
- import { ensureDb, DB_PATH, REGISTRY_DB_PATH } from './schema.mjs';
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,
@@ -30,7 +30,7 @@ function descriptionOf(name) {
30
30
  return d;
31
31
  }
32
32
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
33
- import { basename, join } from 'path';
33
+ import { basename, join, sep } from 'path';
34
34
  import { homedir } from 'os';
35
35
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
36
36
  import { searchResources } from './registry-retriever.mjs';
@@ -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
 
@@ -1506,7 +1532,7 @@ server.registerTool(
1506
1532
  const lines = results.map(r => {
1507
1533
  const qualityBadge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
1508
1534
  const categoryLabel = r.category ? ` [${r.category}]` : '';
1509
- const isManaged = r.local_path && r.local_path.includes('/.claude-mem-lite/managed/');
1535
+ const isManaged = r.local_path && r.local_path.includes(join(DB_DIR, 'managed') + sep);
1510
1536
  const portablePath = isManaged ? toPortable(r.local_path) : '';
1511
1537
  let howToUse;
1512
1538
  if (isManaged) {
@@ -1651,7 +1677,9 @@ server.registerTool(
1651
1677
  if (!row.local_path) {
1652
1678
  return { content: [{ type: 'text', text: `No local_path for ${args.name}` }], isError: true };
1653
1679
  }
1654
- const enrichBase = join(homedir(), '.claude-mem-lite');
1680
+ // Confine to the env-aware data dir (managed/ relocates with CLAUDE_MEM_DIR, D#29);
1681
+ // === homedir when the env is unset, so non-relocated confinement is unchanged.
1682
+ const enrichBase = DB_DIR;
1655
1683
  if (!isPathConfined(row.local_path, enrichBase)) {
1656
1684
  return { content: [{ type: 'text', text: `Access denied: path outside managed directory` }], isError: true };
1657
1685
  }
@@ -1719,8 +1747,10 @@ server.registerTool(
1719
1747
  }
1720
1748
  }
1721
1749
 
1722
- // 4. Path confinement check — prevent reading arbitrary files via crafted local_path
1723
- const managedBase = join(homedir(), '.claude-mem-lite');
1750
+ // 4. Path confinement check — prevent reading arbitrary files via crafted local_path.
1751
+ // Base is the env-aware data dir (D#29): managed/ relocates with CLAUDE_MEM_DIR and
1752
+ // equals homedir when unset, so this does not weaken the non-relocated confinement.
1753
+ const managedBase = DB_DIR;
1724
1754
  if (skillPath && !isPathConfined(skillPath, managedBase)) {
1725
1755
  return { content: [{ type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` }], isError: true };
1726
1756
  }