claude-mem-lite 2.89.0 → 2.90.1

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.89.0",
13
+ "version": "2.90.1",
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.89.0",
3
+ "version": "2.90.1",
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/activity.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  import { inferProject } from '../utils.mjs';
12
12
  import { resolveProject } from '../project-utils.mjs';
13
13
  import { parseArgs, out, fail } from './common.mjs';
14
+ import { parseIntFlag } from '../lib/cli-flags.mjs';
14
15
 
15
16
  function formatActivityResults(rows) {
16
17
  if (!rows || rows.length === 0) return '(no events)';
@@ -77,17 +78,20 @@ export async function cmdActivity(db, args) {
77
78
  fail(`[mem] activity search: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
78
79
  return;
79
80
  }
80
- const limit = flags.limit !== undefined ? parseInt(flags.limit, 10) : 10;
81
+ const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 1000 });
81
82
  const rows = searchEvents(db, q, { project, type, limit });
82
83
  out(formatActivityResults(rows));
83
84
  return;
84
85
  }
85
86
 
86
87
  if (sub === 'recent') {
87
- // Accept either `activity recent 5` or `activity recent --limit 5`.
88
- const posLimit = positional.length > 0 ? parseInt(positional[0], 10) : NaN;
89
- const flagLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
90
- const limit = Number.isFinite(posLimit) ? posLimit : (Number.isFinite(flagLimit) ? flagLimit : 20);
88
+ // Accept either `activity recent 5` or `activity recent --limit 5`. Both routed
89
+ // through parseIntFlag so garbage ("2abc"), negatives (SQLite LIMIT -1 = UNLIMITED
90
+ // full-table dump), and uncapped huge values warn + clamp to default/max, matching
91
+ // the search/recent/browse siblings.
92
+ const limit = positional.length > 0
93
+ ? parseIntFlag(positional[0], { name: 'count', defaultValue: 20, max: 1000 })
94
+ : parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
91
95
  const type = flags.type || null;
92
96
  if (type !== null && !VALID_EVENT_TYPES.has(type)) {
93
97
  fail(`[mem] activity recent: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
package/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'citation-stats', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'import-jsonl', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
2
+ const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'citation-stats', 'delete', 'update', 'export', 'restore', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'import-jsonl', 'enrich', 'activity', 'adopt', 'unadopt', 'memdir-audit', 'defer', 'help']);
3
3
  const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'repair', 'release']);
4
4
 
5
5
  const cmd = process.argv[2];
@@ -13,14 +13,16 @@ if (cmd === '--version' || cmd === '-v') {
13
13
  } else if (cmd === '--help' || cmd === '-h') {
14
14
  const { run } = await import('./mem-cli.mjs');
15
15
  await run(['help']);
16
- } else if (cmd === 'doctor' && process.argv.slice(3).some(a => a.startsWith('--') && a.length > 2)) {
17
- // Per #8217 single-source-of-truth: any flagged `doctor --X` is a DB-layer
18
- // inspection tool (--benchmark, --metrics, --session-audit, future flags)
19
- // and routes to mem-cli. Plain `doctor` (no flags) keeps running the
20
- // install health-check below adding a new flag in cli/doctor.mjs no
21
- // longer requires touching this enumeration. The `length > 2` guard
22
- // ignores a bare `--` (POSIX end-of-options separator) so `doctor --`
23
- // continues to route to install.mjs, not mem-cli.
16
+ } else if (cmd === 'doctor' && process.argv.slice(3).some(a => a === '--benchmark' || a === '--metrics' || a === '--session-audit')) {
17
+ // Per #8217: the DB-layer doctor modes (--benchmark / --metrics / --session-audit,
18
+ // each implemented in cli/doctor.mjs) route to mem-cli. Everything else — plain
19
+ // `doctor`, `doctor --` (POSIX end-of-options), and `doctor --json` stays with
20
+ // install.mjs's health-check, which OWNS --json (install.mjs doctor() line ~1216).
21
+ // Pre-fix the router forwarded ANY flagged `doctor --X` to mem-cli, so the documented
22
+ // `doctor --json` (install health JSON, advertised in install.mjs usage) was shadowed
23
+ // and rejected by cli/doctor.mjs. Gating on the three DB-layer flags keeps --json
24
+ // (and any future install-doctor flag) on the install path. Adding a NEW DB-layer
25
+ // mode requires extending this list — a deliberate trade for a working --json.
24
26
  const { run } = await import('./mem-cli.mjs');
25
27
  await run(process.argv.slice(2));
26
28
  } else if (CLI_COMMANDS.has(cmd)) {
@@ -30,7 +32,10 @@ if (cmd === '--version' || cmd === '-v') {
30
32
  // No command: show CLI help if installed, install help if not
31
33
  const { existsSync } = await import('fs');
32
34
  const { join } = await import('path');
33
- 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');
34
39
  if (existsSync(dbPath)) {
35
40
  const { run } = await import('./mem-cli.mjs');
36
41
  await run(['help']);
package/hook-handoff.mjs CHANGED
@@ -32,12 +32,26 @@ import * as taskReaderModule from './lib/task-reader.mjs';
32
32
  * @param {string|null} [scopeSessionId=null] CC UUID for session_handoffs.session_id column
33
33
  */
34
34
  export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapshot, scopeSessionId = null) {
35
- // 1. Working objective — from user prompts
36
- const prompts = db.prepare(`
37
- SELECT prompt_text FROM user_prompts
38
- WHERE content_session_id = ?
39
- ORDER BY prompt_number ASC LIMIT 5
40
- `).all(sessionId);
35
+ // 1. Working objective — from user prompts.
36
+ // D#26: getSessionId() is project-scoped, so multiple CC sessions in one project
37
+ // share `content_session_id`. When a genuine CC scope is passed (scopeSessionId is
38
+ // the CC UUID, i.e. differs from the mem-internal sessionId), filter to THIS CC
39
+ // session's prompts so working_on doesn't merge concurrent/sequential sessions.
40
+ // `OR cc_session_id IS NULL` keeps legacy rows + non-CC/no-stdin invocations. When
41
+ // scopeSessionId is absent or == sessionId (legacy/test/no-stdin), fall back to the
42
+ // unfiltered query (identical to pre-D#26 behavior).
43
+ const ccScope = scopeSessionId && scopeSessionId !== sessionId ? scopeSessionId : null;
44
+ const prompts = ccScope
45
+ ? db.prepare(`
46
+ SELECT prompt_text FROM user_prompts
47
+ WHERE content_session_id = ? AND (cc_session_id = ? OR cc_session_id IS NULL)
48
+ ORDER BY prompt_number ASC LIMIT 5
49
+ `).all(sessionId, ccScope)
50
+ : db.prepare(`
51
+ SELECT prompt_text FROM user_prompts
52
+ WHERE content_session_id = ?
53
+ ORDER BY prompt_number ASC LIMIT 5
54
+ `).all(sessionId);
41
55
  if (prompts.length === 0) return; // Empty session — nothing to hand off
42
56
 
43
57
  // Filter prompts whose only content is workflow/control language ("继续",
@@ -73,12 +87,30 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
73
87
  }
74
88
  }
75
89
 
90
+ // D#28 (completes D#26): observations carry the project-scoped memory_session_id, shared by
91
+ // parallel/sequential same-project CC sessions. Lower-bound the observation queries below to
92
+ // THIS CC session's start (earliest prompt epoch for ccScope) so Completed / Key Files / Key
93
+ // Decisions stop merging a prior session's work — the observation-side complement of
94
+ // working_on's cc-scoping. When ccScope is absent or its session has no prompts (MIN→null),
95
+ // ccWindowStart stays null and the queries run unscoped (pre-D#28 behavior). Residual: truly
96
+ // concurrent same-project sessions whose windows overlap can still co-attribute a few rows.
97
+ let ccWindowStart = null;
98
+ if (ccScope) {
99
+ const w = db.prepare(`
100
+ SELECT MIN(created_at_epoch) AS startEpoch FROM user_prompts
101
+ WHERE content_session_id = ? AND cc_session_id = ?
102
+ `).get(sessionId, ccScope);
103
+ if (typeof w?.startEpoch === 'number') ccWindowStart = w.startEpoch;
104
+ }
105
+ const obsWindowClause = ccWindowStart !== null ? 'AND created_at_epoch >= ?' : '';
106
+ const obsWindowParams = ccWindowStart !== null ? [ccWindowStart] : [];
107
+
76
108
  // 2. Completed — from observations (include narrative for richer handoff)
77
109
  const completed = db.prepare(`
78
110
  SELECT title, type, narrative FROM observations
79
- WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
111
+ WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0 ${obsWindowClause}
80
112
  ORDER BY created_at_epoch DESC LIMIT 15
81
- `).all(sessionId);
113
+ `).all(sessionId, ...obsWindowParams);
82
114
 
83
115
  // 3. Recent activity — episode snapshot + full session edit history from narratives.
84
116
  // Keep only entries that represent in-flight work (file edits) or outright failures
@@ -131,9 +163,9 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
131
163
  if (episodeSnapshot?.files) episodeSnapshot.files.filter(isValidFile).forEach(f => fileSet.add(f));
132
164
  const obsFiles = db.prepare(`
133
165
  SELECT files_modified FROM observations
134
- WHERE memory_session_id = ? AND files_modified IS NOT NULL
166
+ WHERE memory_session_id = ? AND files_modified IS NOT NULL ${obsWindowClause}
135
167
  ORDER BY created_at_epoch DESC LIMIT 10
136
- `).all(sessionId);
168
+ `).all(sessionId, ...obsWindowParams);
137
169
  for (const row of obsFiles) {
138
170
  try { JSON.parse(row.files_modified).filter(isValidFile).forEach(f => fileSet.add(f)); } catch {}
139
171
  }
@@ -142,9 +174,9 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
142
174
  const decisions = db.prepare(`
143
175
  SELECT title FROM observations
144
176
  WHERE memory_session_id = ? AND COALESCE(importance, 1) >= 2
145
- AND COALESCE(compressed_into, 0) = 0
177
+ AND COALESCE(compressed_into, 0) = 0 ${obsWindowClause}
146
178
  ORDER BY created_at_epoch DESC LIMIT 10
147
- `).all(sessionId).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
179
+ `).all(sessionId, ...obsWindowParams).filter(d => d.title && !LOW_SIGNAL_TITLE.test(d.title)).slice(0, 5);
148
180
 
149
181
  // 6. Match keywords
150
182
  const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
package/hook-update.mjs CHANGED
@@ -7,7 +7,7 @@ import { readFileSync, writeFileSync, copyFileSync, cpSync, readdirSync, existsS
7
7
  import { join, dirname } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { tmpdir, homedir } from 'node:os';
10
- import { DB_DIR } from './schema.mjs';
10
+ import { DB_DIR, CODE_DIR } from './schema.mjs';
11
11
  import { debugCatch, debugLog } from './utils.mjs';
12
12
  // Local manifest is fallback only — the active manifest is loaded from the
13
13
  // extracted tarball's own source-files.mjs inside installExtractedRelease.
@@ -16,8 +16,15 @@ import { SOURCE_FILES as LOCAL_SOURCE_FILES, HOOK_SCRIPT_FILES as LOCAL_HOOK_SCR
16
16
 
17
17
  // ── Configuration ──────────────────────────────────────────
18
18
  const GITHUB_REPO = 'sdsrss/claude-mem-lite';
19
- const INSTALL_DIR = DB_DIR; // ~/.claude-mem-lite/
20
- const STATE_FILE = join(INSTALL_DIR, 'runtime', 'update-state.json');
19
+ // Plugin CODE location (server.mjs / package.json / install target) — always
20
+ // homedir-rooted, NEVER follows CLAUDE_MEM_DIR (see schema.mjs CODE_DIR). Used
21
+ // for dev-mode detection, current-version read, and the install target dir.
22
+ const INSTALL_DIR = CODE_DIR; // ~/.claude-mem-lite/ (code)
23
+ // DATA/state location — runtime/update-state.json lives with the data (env-aware
24
+ // DB_DIR), matching hook-shared RUNTIME_DIR and install.mjs doctor's read path.
25
+ // Equal to INSTALL_DIR unless CLAUDE_MEM_DIR relocates the data dir.
26
+ const STATE_DIR = DB_DIR;
27
+ const STATE_FILE = join(STATE_DIR, 'runtime', 'update-state.json');
21
28
  const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
22
29
  const FETCH_TIMEOUT_MS = 3000; // 3s network timeout
23
30
  const RATE_LIMIT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h if rate-limited
@@ -558,7 +565,7 @@ function readState() {
558
565
 
559
566
  function saveState(state) {
560
567
  try {
561
- const dir = join(INSTALL_DIR, 'runtime');
568
+ const dir = join(STATE_DIR, 'runtime');
562
569
  mkdirSync(dir, { recursive: true });
563
570
  const tmpFile = STATE_FILE + `.tmp-${process.pid}`;
564
571
  writeFileSync(tmpFile, JSON.stringify(state, null, 2));
package/hook.mjs CHANGED
@@ -1208,7 +1208,12 @@ async function handleUserPrompt() {
1208
1208
  // every downstream consumer (user_prompts INSERT, FTS query, continuation
1209
1209
  // detection, semantic-memory injection) sees the redacted text — single
1210
1210
  // source of truth for the privacy primitive.
1211
- const promptText = stripPrivate(rawPrompt);
1211
+ // Strip NUL / C0 control chars (keep \t \n \r) before any downstream use: an
1212
+ // embedded NUL terminates SQLite's C string, silently truncating the stored
1213
+ // prompt_text at the first NUL (and breaking FTS). Single source of truth, so the
1214
+ // user_prompts INSERT, FTS query, and continuation detection all see clean text.
1215
+ // eslint-disable-next-line no-control-regex -- intentional: NUL/C0 strip prevents SQLite C-string truncation
1216
+ const promptText = stripPrivate(rawPrompt).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
1212
1217
 
1213
1218
  const sessionId = getSessionId();
1214
1219
  const db = openDb();
@@ -1233,24 +1238,27 @@ async function handleUserPrompt() {
1233
1238
  ).get(sessionId);
1234
1239
  const promptNumber = bumped?.prompt_counter || 1;
1235
1240
 
1241
+ // Claude Code's real session_id (CC UUID) from hook stdin. Persisted on the
1242
+ // prompt row (cc_session_id) so buildAndSaveHandoff can scope working_on to ONE
1243
+ // CC session — getSessionId() is project-scoped (no CC-UUID), so without this
1244
+ // concurrent/within-TTL same-project sessions merge each other's prompts (D#26).
1245
+ // Also scopes handoff-row injection below. Null (legacy) when stdin lacks session_id.
1246
+ const ccSessionId = typeof hookData.session_id === 'string' && hookData.session_id.length > 0
1247
+ ? hookData.session_id
1248
+ : null;
1249
+
1236
1250
  db.prepare(`
1237
- INSERT INTO user_prompts (content_session_id, prompt_text, prompt_number, created_at, created_at_epoch)
1238
- VALUES (?, ?, ?, ?, ?)
1251
+ INSERT INTO user_prompts (content_session_id, prompt_text, prompt_number, cc_session_id, created_at, created_at_epoch)
1252
+ VALUES (?, ?, ?, ?, ?, ?)
1239
1253
  `).run(
1240
1254
  sessionId,
1241
1255
  scrubSecrets(promptText.slice(0, 10000)),
1242
1256
  promptNumber,
1257
+ ccSessionId,
1243
1258
  now.toISOString(), now.getTime()
1244
1259
  );
1245
1260
 
1246
1261
  // Cross-session handoff injection (first 3 prompts window, before semantic memory).
1247
- // Use Claude Code's real session_id from hook stdin to scope handoffs to this CC
1248
- // session — prevents cross-session bleed when running parallel sessions for the
1249
- // same project (see docs/bug.txt). Falls back to null (legacy behavior) if the
1250
- // hook input does not carry session_id.
1251
- const ccSessionId = typeof hookData.session_id === 'string' && hookData.session_id.length > 0
1252
- ? hookData.session_id
1253
- : null;
1254
1262
  if (promptNumber <= 3) {
1255
1263
  try {
1256
1264
  if (detectContinuationIntent(db, promptText, project, ccSessionId)) {
package/install.mjs CHANGED
@@ -10,8 +10,18 @@ import { createRequire } from 'node:module';
10
10
 
11
11
  const PROJECT_DIR = resolve(import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)));
12
12
  const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
13
+ // Plugin CODE / install location — ALWAYS homedir-rooted. Claude Code's
14
+ // settings.json + MCP registration bake ABSOLUTE paths to server.mjs / hooks here,
15
+ // and env vars are per-shell (the MCP launcher won't reliably inherit
16
+ // CLAUDE_MEM_DIR), so code must NOT follow the relocation env var.
13
17
  const DATA_DIR = join(homedir(), '.claude-mem-lite');
14
- const DB_PATH = join(DATA_DIR, 'claude-mem-lite.db');
18
+ // User DATA location — DB, managed resources, registry DB, runtime/. Honors
19
+ // CLAUDE_MEM_DIR exactly like schema.mjs DB_DIR so the installer WRITES data where
20
+ // the runtime/data layer READS it (pre-fix: installer wrote homedir, runtime read
21
+ // the relocated dir → preinstalled skills silently vanished, doctor read the wrong
22
+ // DB). Equals DATA_DIR when CLAUDE_MEM_DIR is unset (the common case).
23
+ const MEM_DATA_DIR = process.env.CLAUDE_MEM_DIR || DATA_DIR;
24
+ const DB_PATH = join(MEM_DATA_DIR, 'claude-mem-lite.db');
15
25
  const OLD_DATA_DIR = join(homedir(), '.claude-mem');
16
26
 
17
27
  // Detect ephemeral context (npx) — files won't persist after exit
@@ -319,6 +329,8 @@ async function install() {
319
329
  }
320
330
 
321
331
  if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
332
+ // Under relocation the DB/managed/runtime live here, not in the code dir — create it too.
333
+ if (!existsSync(MEM_DATA_DIR)) mkdirSync(MEM_DATA_DIR, { recursive: true });
322
334
 
323
335
  if (IS_DEV) {
324
336
  log('Dev mode — creating symlinks in ~/.claude-mem-lite/...');
@@ -675,7 +687,7 @@ async function install() {
675
687
  // "no such column: memory_session_id". Rename to a timestamped backup
676
688
  // so the new install creates a fresh v28 DB.
677
689
  try {
678
- const r = migrateLegacyClaudeMemData(OLD_DATA_DIR, DATA_DIR);
690
+ const r = migrateLegacyClaudeMemData(OLD_DATA_DIR, MEM_DATA_DIR);
679
691
  if (r.action === 'backed-up') {
680
692
  ok(`Legacy ~/.claude-mem/ DB backed up to ${r.backupPath}`);
681
693
  log('New v28 DB will be created on first launch (legacy schema is incompatible).');
@@ -685,7 +697,7 @@ async function install() {
685
697
  }
686
698
 
687
699
  // 5b. Rename claude-mem.db → claude-mem-lite.db in same directory
688
- const oldDbInDir = join(DATA_DIR, 'claude-mem.db');
700
+ const oldDbInDir = join(MEM_DATA_DIR, 'claude-mem.db');
689
701
  if (existsSync(oldDbInDir) && !existsSync(DB_PATH)) {
690
702
  renameSync(oldDbInDir, DB_PATH);
691
703
  for (const ext of ['-wal', '-shm']) {
@@ -714,7 +726,7 @@ async function install() {
714
726
  const resources = manifest.resources || [];
715
727
 
716
728
  if (resources.length > 0) {
717
- const managedDir = join(DATA_DIR, 'managed');
729
+ const managedDir = join(MEM_DATA_DIR, 'managed');
718
730
 
719
731
  // 6a. Git shallow clone unique repos
720
732
  const repos = new Map();
@@ -805,7 +817,7 @@ async function install() {
805
817
 
806
818
  // 6b. Init registry DB and record preinstalled entries
807
819
  const { ensureRegistryDb } = await importFromInstall('registry.mjs');
808
- const regDbPath = join(DATA_DIR, 'resource-registry.db');
820
+ const regDbPath = join(MEM_DATA_DIR, 'resource-registry.db');
809
821
  const rdb = ensureRegistryDb(regDbPath);
810
822
 
811
823
  const insertPre = rdb.prepare(`
@@ -853,7 +865,7 @@ async function install() {
853
865
  // 6d. Scan and index resources (fallback-only, Haiku indexing deferred to first run)
854
866
  log(' Scanning resources...');
855
867
  const { scanAllResources, diffResources } = await importFromInstall('registry-scanner.mjs');
856
- const scanned = scanAllResources({ dataDir: DATA_DIR });
868
+ const scanned = scanAllResources({ dataDir: MEM_DATA_DIR });
857
869
 
858
870
  // Attach star counts and repo URLs
859
871
  for (const s of scanned) {
@@ -1063,15 +1075,26 @@ async function uninstall() {
1063
1075
 
1064
1076
  // 6. Purge data if requested
1065
1077
  if (flags.has('--purge')) {
1066
- const expectedPurgePath = join(homedir(), '.claude-mem-lite');
1067
- if (existsSync(DATA_DIR) && DATA_DIR === expectedPurgePath) {
1078
+ const homeDir = join(homedir(), '.claude-mem-lite');
1079
+ // Always remove the homedir code/install dir (guarded to the canonical path).
1080
+ if (existsSync(DATA_DIR) && DATA_DIR === homeDir) {
1068
1081
  rmSync(DATA_DIR, { recursive: true, force: true });
1069
1082
  ok('Data purged (~/.claude-mem-lite/)');
1070
1083
  } else if (existsSync(DATA_DIR)) {
1071
1084
  fail('DATA_DIR path mismatch, refusing to purge for safety: ' + DATA_DIR);
1072
1085
  }
1086
+ // Also remove the relocated data dir — but ONLY if it's genuinely our data dir
1087
+ // (contains claude-mem-lite.db), so a mistyped CLAUDE_MEM_DIR is never rm'd.
1088
+ if (MEM_DATA_DIR !== homeDir) {
1089
+ if (existsSync(join(MEM_DATA_DIR, 'claude-mem-lite.db'))) {
1090
+ rmSync(MEM_DATA_DIR, { recursive: true, force: true });
1091
+ ok(`Relocated data purged (${MEM_DATA_DIR})`);
1092
+ } else if (existsSync(MEM_DATA_DIR)) {
1093
+ warn(`CLAUDE_MEM_DIR (${MEM_DATA_DIR}) has no claude-mem-lite.db — left untouched. Remove manually if intended.`);
1094
+ }
1095
+ }
1073
1096
  } else {
1074
- log('Data preserved in ~/.claude-mem-lite/ (use --purge to remove)');
1097
+ log('Data preserved (use --purge to remove)');
1075
1098
  }
1076
1099
 
1077
1100
  console.log('\n Done!\n');
@@ -1383,7 +1406,7 @@ async function doctor() {
1383
1406
 
1384
1407
  // Update state
1385
1408
  try {
1386
- const stateFile = join(INSTALL_DIR, 'runtime', 'update-state.json');
1409
+ const stateFile = join(MEM_DATA_DIR, 'runtime', 'update-state.json');
1387
1410
  if (existsSync(stateFile)) {
1388
1411
  const state = JSON.parse(readFileSync(stateFile, 'utf8'));
1389
1412
  const parts = [];
@@ -1439,11 +1462,14 @@ async function doctor() {
1439
1462
 
1440
1463
  // Stale temp files
1441
1464
  try {
1442
- const runtimeDir = join(INSTALL_DIR, 'runtime');
1465
+ // hook-update + the episode workers write runtime/ + staging under DB_DIR
1466
+ // (= MEM_DATA_DIR, env-aware), NOT the homedir code dir — scan there so doctor
1467
+ // sees the real residue under relocation.
1468
+ const runtimeDir = join(MEM_DATA_DIR, 'runtime');
1443
1469
  let staleCount = 0;
1444
1470
  const stalePatterns = ['.update-staging-', '.update-backup-'];
1445
- if (existsSync(INSTALL_DIR)) {
1446
- for (const f of readdirSync(INSTALL_DIR)) {
1471
+ if (existsSync(MEM_DATA_DIR)) {
1472
+ for (const f of readdirSync(MEM_DATA_DIR)) {
1447
1473
  if (stalePatterns.some(p => f.startsWith(p))) staleCount++;
1448
1474
  }
1449
1475
  }
@@ -1712,10 +1738,11 @@ function cleanup() {
1712
1738
  console.log(`\nclaude-mem-lite cleanup${dryRun ? ' (--dry-run)' : ''}\n`);
1713
1739
  let removed = 0;
1714
1740
 
1715
- // Clean .update-staging-* / .update-backup-* in INSTALL_DIR
1741
+ // Clean .update-staging-* / .update-backup-* hook-update writes these under
1742
+ // DB_DIR (= MEM_DATA_DIR, env-aware), so scan the data dir, not the homedir code dir.
1716
1743
  const stalePatterns = ['.update-staging-', '.update-backup-'];
1717
- if (existsSync(INSTALL_DIR)) {
1718
- for (const f of readdirSync(INSTALL_DIR)) {
1744
+ if (existsSync(MEM_DATA_DIR)) {
1745
+ for (const f of readdirSync(MEM_DATA_DIR)) {
1719
1746
  if (stalePatterns.some(p => f.startsWith(p))) {
1720
1747
  if (dryRun) {
1721
1748
  ok(`Would remove: ${f}`);
@@ -1723,7 +1750,7 @@ function cleanup() {
1723
1750
  continue;
1724
1751
  }
1725
1752
  try {
1726
- rmSync(join(INSTALL_DIR, f), { recursive: true, force: true });
1753
+ rmSync(join(MEM_DATA_DIR, f), { recursive: true, force: true });
1727
1754
  ok(`Removed: ${f}`);
1728
1755
  removed++;
1729
1756
  } catch (e) {
@@ -1733,8 +1760,8 @@ function cleanup() {
1733
1760
  }
1734
1761
  }
1735
1762
 
1736
- // Clean pending-* / ep-flush-* in runtime/
1737
- const runtimeDir = join(INSTALL_DIR, 'runtime');
1763
+ // Clean pending-* / ep-flush-* in runtime/ (under the env-aware data dir)
1764
+ const runtimeDir = join(MEM_DATA_DIR, 'runtime');
1738
1765
  if (existsSync(runtimeDir)) {
1739
1766
  for (const f of readdirSync(runtimeDir)) {
1740
1767
  if (f.startsWith('pending-') || f.startsWith('ep-flush-')) {
package/lib/cli-flags.mjs CHANGED
@@ -19,6 +19,21 @@
19
19
 
20
20
  const DEFAULT_STDERR_WRITE = msg => process.stderr.write(msg);
21
21
 
22
+ /**
23
+ * True if `raw` is a clean integer or float-literal token — no trailing garbage,
24
+ * hex, or scientific notation. Float literals ARE accepted (callers truncate via
25
+ * parseInt, the deliberate #8277 decision); this only rejects shapes bare parseInt
26
+ * would silently coerce ("2abc"→2, "0x10"→0, "1e2"→1). Single source of the
27
+ * strict-shape rule shared by parseIntFlag and the reject-style numeric flags
28
+ * (save/update --importance, defer --priority).
29
+ *
30
+ * @param {string|number} raw Flag value as captured by parseArgs.
31
+ * @returns {boolean}
32
+ */
33
+ export function isNumericToken(raw) {
34
+ return /^-?\d+(\.\d+)?$/.test(String(raw).trim());
35
+ }
36
+
22
37
  /**
23
38
  * Validate and parse a CLI numeric flag with optional bounds.
24
39
  *
@@ -38,8 +53,15 @@ export function parseIntFlag(rawValue, opts) {
38
53
  return defaultValue;
39
54
  }
40
55
 
41
- const parsed = parseInt(rawValue, 10);
42
- if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
56
+ // Reject trailing-garbage / hex / scientific tokens that bare parseInt would
57
+ // silently coerce by stopping at the first non-digit ("2abc"→2, "0x10"→0,
58
+ // "1e2"→1) — those slip past the Number.isInteger gate and violate the
59
+ // warn+default contract above. Float literals ("3.7"→3) stay ACCEPTED via
60
+ // parseInt truncation: that's the deliberate #8277 decision pinned by the
61
+ // 'rejects floats' case in cli-flags.test.mjs, so the shape check admits them.
62
+ const str = String(rawValue).trim();
63
+ const parsed = parseInt(str, 10);
64
+ if (!isNumericToken(str) || !Number.isInteger(parsed) || parsed < min || parsed > max) {
43
65
  const range = max === Number.MAX_SAFE_INTEGER ? `≥ ${min}` : `between ${min} and ${max}`;
44
66
  warn(`[mem] Invalid ${name} "${rawValue}" (must be an integer ${range}); using default ${defaultValue}\n`);
45
67
  return defaultValue;
package/mem-cli.mjs CHANGED
@@ -23,10 +23,10 @@ import {
23
23
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
24
24
  import { buildSessionContextLines } from './hook-context.mjs';
25
25
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
26
- import { parseIntFlag } from './lib/cli-flags.mjs';
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
@@ -71,16 +71,16 @@ function cmdSearch(db, args) {
71
71
  process.stderr.write(`[mem] Note: --from "${flags.from}" is after --to "${flags.to}"; this range is empty\n`);
72
72
  }
73
73
  const minImportance = flags.importance !== undefined ? parseInt(flags.importance, 10) : null;
74
- if (minImportance !== null && (isNaN(minImportance) || minImportance < 1 || minImportance > 3)) {
74
+ // isNumericToken first: "2abc"→2 / "1e2"→1 would pass the range check and silently
75
+ // filter at a value the user never typed. Reject garbage like out-of-range does.
76
+ if (minImportance !== null && (!isNumericToken(flags.importance) || isNaN(minImportance) || minImportance < 1 || minImportance > 3)) {
75
77
  fail(`[mem] Invalid --importance "${flags.importance}". Must be 1, 2, or 3.`);
76
78
  return;
77
79
  }
78
80
  const branch = flags.branch || null;
79
- const rawOffset = flags.offset !== undefined ? parseInt(flags.offset, 10) : NaN;
80
- if (flags.offset !== undefined && (!Number.isInteger(rawOffset) || rawOffset < 0)) {
81
- process.stderr.write(`[mem] Invalid --offset "${flags.offset}" (must be a non-negative integer); using 0\n`);
82
- }
83
- const offset = Number.isInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
81
+ // parseIntFlag (min=0) rejects garbage ("2abc"→2, "1e2"→1) the old isInteger check let
82
+ // through, warns once, and falls back to 0 — same WARN-style contract, now garbage-proof.
83
+ const offset = parseIntFlag(flags.offset, { name: '--offset', defaultValue: 0, min: 0 });
84
84
  const tier = flags.tier || null;
85
85
  if (tier && !['working', 'active', 'archive'].includes(tier)) {
86
86
  fail(`[mem] Invalid --tier "${tier}". Use: working, active, archive`);
@@ -126,8 +126,14 @@ function cmdSearch(db, args) {
126
126
  // so the post-merge sort has room to pick the best from each (paired-path with
127
127
  // server.mjs:377 — without this, obs gets systematically squeezed out by sessions).
128
128
  const isCrossSourceMode = !effectiveSource;
129
- const perSourceLimit = isCrossSourceMode ? Math.max(limit * 3, offset + limit + 10) : limit;
130
- const perSourceOffset = isCrossSourceMode ? 0 : offset;
129
+ // Over-fetch from offset 0 and apply --offset ONCE at the final slice (below) in
130
+ // ALL modes mirrors server.mjs. Pushing OFFSET into the obs hybrid path was
131
+ // unreliable: its AND→OR fallback / vector / concept-cooccurrence stages re-add
132
+ // rows the SQL OFFSET already skipped, so engine-side paging dropped (or
133
+ // duplicated) rows on the --type/--tier/--importance/--branch path (a page that
134
+ // MCP returned came back empty).
135
+ const perSourceLimit = Math.max(limit * 3, offset + limit + 10);
136
+ const perSourceOffset = 0;
131
137
 
132
138
  const results = [];
133
139
  // Tracks whether AND returned 0 and OR recovered non-empty. Mirrors server.mjs
@@ -305,7 +311,10 @@ function cmdSearch(db, args) {
305
311
  }
306
312
  // else 'relevance' keeps BM25 score order (already sorted)
307
313
 
308
- // Trim to limit with offset
314
+ // Trim to limit with offset. The engine always received perSourceOffset=0 and
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.
309
318
  const total = results.length;
310
319
  const paged = results.slice(offset, offset + limit);
311
320
 
@@ -389,7 +398,9 @@ function cmdRecent(db, args) {
389
398
  const { positional, flags } = parseArgs(args);
390
399
  const rawArg = positional[0];
391
400
  const rawLimit = parseInt(rawArg, 10);
392
- const isValid = Number.isInteger(rawLimit) && rawLimit > 0;
401
+ // isNumericToken first: "2abc"→2 / "1e2"→1 are positive integers that the bare check
402
+ // accepted silently; the positional path must reject garbage like the --limit flag does.
403
+ const isValid = rawArg !== undefined && isNumericToken(rawArg) && Number.isInteger(rawLimit) && rawLimit > 0;
393
404
  if (rawArg !== undefined && !isValid) {
394
405
  process.stderr.write(`[mem] Invalid count "${rawArg}" (must be a positive integer); using default 10\n`);
395
406
  }
@@ -692,7 +703,9 @@ function cmdTimeline(db, args) {
692
703
  const parseWindow = (label, raw) => {
693
704
  if (raw === undefined) return 5;
694
705
  const n = parseInt(raw, 10);
695
- if (!Number.isInteger(n) || n < 0) {
706
+ // isNumericToken first: "2abc"→2 / "1e2"→1 are non-negative integers the bare check
707
+ // accepted silently; reject garbage tokens like the negative path already does.
708
+ if (!isNumericToken(raw) || !Number.isInteger(n) || n < 0) {
696
709
  process.stderr.write(`[mem] Invalid --${label} "${raw}" (must be a non-negative integer); using default 5\n`);
697
710
  return 5;
698
711
  }
@@ -925,7 +938,9 @@ function cmdSave(db, args) {
925
938
 
926
939
  // Explicit saves default to importance=2 (notable) — user chose to save
927
940
  const rawImp = flags.importance !== undefined ? parseInt(flags.importance, 10) : 2;
928
- if (flags.importance !== undefined && (isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
941
+ // isNumericToken first: bare parseInt would coerce "2abc"→2 / "1e2"→1 and persist a
942
+ // wrong importance that silently skews ranking/decay. Float literals still truncate (#8277).
943
+ if (flags.importance !== undefined && (!isNumericToken(flags.importance) || isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
929
944
  fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
930
945
  return;
931
946
  }
@@ -1041,6 +1056,12 @@ function cmdDeferAdd(db, args) {
1041
1056
  return;
1042
1057
  }
1043
1058
  const priority = flags.priority !== undefined ? parseInt(flags.priority, 10) : 2;
1059
+ // isNumericToken first: bare parseInt would coerce "3xyz"→3 and silently escalate a
1060
+ // deferred item's urgency. Float literals still truncate (#8277).
1061
+ if (flags.priority !== undefined && !isNumericToken(flags.priority)) {
1062
+ fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
1063
+ return;
1064
+ }
1044
1065
  if (![1, 2, 3].includes(priority)) {
1045
1066
  fail(`[mem] Invalid --priority "${flags.priority}". Must be 1 (low), 2 (normal), or 3 (urgent).`);
1046
1067
  return;
@@ -1255,6 +1276,7 @@ async function cmdStats(db, args) {
1255
1276
  const lowVal = db.prepare(`
1256
1277
  SELECT COUNT(*) as c FROM observations
1257
1278
  WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
1279
+ AND COALESCE(compressed_into, 0) = 0
1258
1280
  AND created_at_epoch < ? ${projectFilter}
1259
1281
  `).get(thirtyDaysAgo, ...baseParams);
1260
1282
  const noiseRatio = obsTotal.c > 0 ? lowVal.c / obsTotal.c : 0;
@@ -1607,6 +1629,19 @@ function cmdUpdate(db, args) {
1607
1629
  return;
1608
1630
  }
1609
1631
 
1632
+ // A value-less `--flag` (last arg, or immediately followed by another --flag)
1633
+ // parses to boolean `true` (cli/common.mjs parseArgs). For string-valued fields
1634
+ // that boolean would slip past the string-only empty guards below and reach the
1635
+ // SQLite bind, surfacing a raw "TypeError: SQLite3 can only bind ..." stacktrace
1636
+ // — the same accidental shell-strip class the empty-title guard (#8470) catches.
1637
+ // Reject it cleanly for every string-valued update flag.
1638
+ for (const key of ['title', 'narrative', 'lesson', 'lesson-learned', 'concepts']) {
1639
+ if (flags[key] === true) {
1640
+ fail(`[mem] --${key} requires a value (received a bare flag with no value).`);
1641
+ return;
1642
+ }
1643
+ }
1644
+
1610
1645
  const updates = [];
1611
1646
  const params = [];
1612
1647
  if (flags.title !== undefined) {
@@ -1629,7 +1664,9 @@ function cmdUpdate(db, args) {
1629
1664
  }
1630
1665
  if (flags.importance) {
1631
1666
  const imp = parseInt(flags.importance, 10);
1632
- if (isNaN(imp) || imp < 1 || imp > 3) {
1667
+ // isNumericToken first: bare parseInt would coerce "2abc"→2 and UPDATE the row to a
1668
+ // wrong importance. Float literals still truncate (#8277).
1669
+ if (!isNumericToken(flags.importance) || isNaN(imp) || imp < 1 || imp > 3) {
1633
1670
  fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
1634
1671
  return;
1635
1672
  }
@@ -1731,8 +1768,16 @@ function cmdExport(db, args) {
1731
1768
  return;
1732
1769
  }
1733
1770
 
1771
+ // Full round-trippable column set so `restore` rebuilds observations faithfully —
1772
+ // content + value-signals (access/cited/uncited/injection/decay) + branch + timing.
1773
+ // Additive vs the pre-v2.90 13-col shape; existing `export | jq '.[].title'` consumers
1774
+ // are unaffected. id + memory_session_id are informational (restore remaps id and
1775
+ // buckets under a restore session).
1734
1776
  const rows = db.prepare(`
1735
- SELECT id, project, type, title, subtitle, narrative, concepts, facts, lesson_learned, importance, files_modified, created_at, created_at_epoch
1777
+ SELECT id, memory_session_id, project, type, title, subtitle, narrative, concepts, facts,
1778
+ files_read, files_modified, lesson_learned, importance, branch,
1779
+ access_count, cited_count, uncited_streak, injection_count, decay_seen_count,
1780
+ last_accessed_at, created_at, created_at_epoch
1736
1781
  FROM observations WHERE ${wheres.join(' AND ')}
1737
1782
  ORDER BY created_at_epoch DESC LIMIT ?
1738
1783
  `).all(...params, limit);
@@ -1759,6 +1804,83 @@ function cmdExport(db, args) {
1759
1804
  }
1760
1805
  }
1761
1806
 
1807
+ // ─── Restore ───────────────────────────────────────────────────────────────
1808
+ // Inverse of `export` — the backup/restore half README:690 promises. Reuses
1809
+ // lib/save-observation.mjs so FK / FTS / TF-IDF vector / minhash / files-junction
1810
+ // stay consistent with cmdSave, then a targeted UPDATE re-applies the value-signals
1811
+ // (access/cited/uncited/injection/decay), branch, and concepts/facts/files_read that
1812
+ // saveObservation derives or zeros — so a restored backup keeps its citation-decay
1813
+ // history and original timing (created_at via the `now` param). Source ids are
1814
+ // discarded (local AUTOINCREMENT; export omits related_ids); session provenance
1815
+ // collapses to saveObservation's manual-<project> bucket (documented MVP tradeoff).
1816
+ function cmdRestore(db, argv) {
1817
+ const { positional, flags } = parseArgs(argv);
1818
+ const file = positional[0];
1819
+ if (!file) { fail('[mem] Usage: claude-mem-lite restore <file> [--project P] [--dry-run]'); return; }
1820
+ let raw;
1821
+ try { raw = readFileSync(file, 'utf8'); }
1822
+ catch (e) { fail(`[mem] Cannot read "${file}": ${e.message}`); return; }
1823
+ const trimmed = raw.trim();
1824
+ if (!trimmed) { out('[mem] Empty file — nothing to restore.'); return; }
1825
+ let rows;
1826
+ try {
1827
+ rows = trimmed[0] === '['
1828
+ ? JSON.parse(trimmed)
1829
+ : trimmed.split('\n').filter(l => l.trim()).map(l => JSON.parse(l));
1830
+ } catch (e) { fail(`[mem] "${file}" is not valid export JSON/JSONL: ${e.message}`); return; }
1831
+ if (!Array.isArray(rows) || rows.length === 0) { out('[mem] No observations in file.'); return; }
1832
+
1833
+ const projOverride = flags.project ? resolveProject(db, flags.project) : null;
1834
+ const dryRun = flags['dry-run'] === true || flags['dry-run'] === 'true';
1835
+ const num = (v) => Number.isFinite(Number(v)) ? Math.trunc(Number(v)) : 0;
1836
+
1837
+ const dupCheck = db.prepare('SELECT id FROM observations WHERE project = ? AND title = ? AND created_at_epoch = ? LIMIT 1');
1838
+ const signalUpdate = db.prepare(`UPDATE observations SET
1839
+ subtitle = ?, concepts = ?, facts = ?, files_read = ?, branch = COALESCE(?, branch),
1840
+ access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
1841
+ decay_seen_count = ?, last_accessed_at = ?
1842
+ WHERE id = ?`);
1843
+
1844
+ let restored = 0, skipped = 0, malformed = 0;
1845
+ for (const r of rows) {
1846
+ if (!r || typeof r !== 'object' || !r.type || !r.title) { malformed++; continue; }
1847
+ const project = projOverride || r.project || inferProject();
1848
+ const createdEpoch = Number.isFinite(Number(r.created_at_epoch)) ? Number(r.created_at_epoch) : Date.now();
1849
+ // Durable exact-dup guard — saveObservation's 5-min Jaccard window can't catch a
1850
+ // re-restore of an old-timestamped backup, so gate on project+title+created_at.
1851
+ if (dupCheck.get(project, r.title, createdEpoch)) { skipped++; continue; }
1852
+ if (dryRun) { restored++; continue; }
1853
+ try {
1854
+ let files = [];
1855
+ try { const fm = JSON.parse(r.files_modified || '[]'); if (Array.isArray(fm)) files = fm; } catch { /* leave [] */ }
1856
+ const imp = num(r.importance);
1857
+ const res = saveObservation(db, {
1858
+ content: r.narrative || r.title,
1859
+ title: r.title,
1860
+ type: r.type,
1861
+ importance: imp >= 1 && imp <= 3 ? imp : 1,
1862
+ project,
1863
+ files,
1864
+ lesson_learned: r.lesson_learned || null,
1865
+ now: new Date(createdEpoch),
1866
+ });
1867
+ if (res.kind !== 'saved') { skipped++; continue; } // saveObservation Jaccard dedup
1868
+ // Re-apply the fields saveObservation zeros/derives so the backup is faithful.
1869
+ signalUpdate.run(
1870
+ r.subtitle || '', r.concepts || '', r.facts || '', r.files_read || '[]', r.branch ?? null,
1871
+ num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
1872
+ num(r.decay_seen_count), r.last_accessed_at ?? null,
1873
+ res.id,
1874
+ );
1875
+ restored++;
1876
+ } catch (e) {
1877
+ malformed++;
1878
+ if (process.env.CLAUDE_MEM_DEBUG) process.stderr.write(`[mem] restore row failed: ${e.message}\n`);
1879
+ }
1880
+ }
1881
+ out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped, ${malformed} malformed/failed from ${rows.length} row(s).`);
1882
+ }
1883
+
1762
1884
  // ─── Compress ────────────────────────────────────────────────────────────────
1763
1885
 
1764
1886
  function cmdCompress(db, args) {
@@ -1876,7 +1998,11 @@ function cmdMaintain(db, args) {
1876
1998
 
1877
1999
  // Execute
1878
2000
  const VALID_OPS = ['cleanup', 'decay', 'boost', 'demote_pinned', 'dedup', 'purge_stale', 'rebuild_vectors', 'vacuum'];
1879
- const opsStr = flags.ops || 'cleanup,decay,boost';
2001
+ // Distinguish flag-absent (use default op set) from flag-present-but-empty
2002
+ // (`--ops ""`, e.g. an unset shell var). The latter previously coerced via `||`
2003
+ // to the destructive default cleanup,decay,boost and EXECUTED it; route it to the
2004
+ // VALID_OPS check below instead so it's rejected like `--ops " "` / `--ops "decay,"`.
2005
+ const opsStr = flags.ops === undefined ? 'cleanup,decay,boost' : String(flags.ops);
1880
2006
  const ops = opsStr.split(',').map(s => s.trim());
1881
2007
  const invalidOps = ops.filter(op => !VALID_OPS.includes(op));
1882
2008
  if (invalidOps.length > 0) {
@@ -2054,7 +2180,7 @@ function cmdRegistry(_memDb, args) {
2054
2180
  for (const r of results) {
2055
2181
  const badge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
2056
2182
  const categoryLabel = r.category ? ` [${r.category}]` : '';
2057
- const isManaged = r.local_path && r.local_path.includes('/.claude-mem-lite/managed/');
2183
+ const isManaged = r.local_path && r.local_path.includes(join(DB_DIR, 'managed') + sep);
2058
2184
  const portablePath = isManaged && r.local_path.startsWith(home) ? '~' + r.local_path.slice(home.length) : (r.local_path || '');
2059
2185
  let howToUse;
2060
2186
  if (isManaged) {
@@ -2119,6 +2245,12 @@ function cmdRegistry(_memDb, args) {
2119
2245
  }
2120
2246
 
2121
2247
  if (action === 'import') {
2248
+ // A bare value-less flag parses to boolean `true` (parseArgs); for these string
2249
+ // fields that boolean reaches the SQLite bind in upsertResource and throws a raw
2250
+ // TypeError — same class as the `update` guard above (#8470). Reject up front.
2251
+ for (const key of ['name', 'resource-type', 'invocation-name', 'source', 'repo-url', 'local-path', 'intent-tags', 'domain-tags', 'trigger-patterns', 'capability-summary', 'keywords', 'tech-stack', 'use-cases']) {
2252
+ if (flags[key] === true) { fail(`[mem] --${key} requires a value (received a bare flag with no value).`); return; }
2253
+ }
2122
2254
  const name = flags.name;
2123
2255
  const resourceType = flags['resource-type'];
2124
2256
  if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry import --name N --resource-type skill|agent [--invocation-name I] [--capability-summary S]'); return; }
@@ -2140,6 +2272,11 @@ function cmdRegistry(_memDb, args) {
2140
2272
  }
2141
2273
 
2142
2274
  if (action === 'remove') {
2275
+ // Bare value-less --name / --resource-type → boolean true → SQLite-bind crash
2276
+ // on the DELETE below; reject like the import branch and the `update` guard.
2277
+ for (const key of ['name', 'resource-type']) {
2278
+ if (flags[key] === true) { fail(`[mem] --${key} requires a value (received a bare flag with no value).`); return; }
2279
+ }
2143
2280
  const name = flags.name;
2144
2281
  const resourceType = flags['resource-type'];
2145
2282
  if (!name || !resourceType) { fail('[mem] Usage: claude-mem-lite registry remove --name N --resource-type skill|agent'); return; }
@@ -2414,6 +2551,7 @@ Commands:
2414
2551
  --concepts T Space-separated concept tags
2415
2552
 
2416
2553
  export Export observations as JSON/JSONL
2554
+ restore <file> Restore observations from an export file (JSON/JSONL); --dry-run to preview
2417
2555
  --project P Filter by project
2418
2556
  --type T Filter by type
2419
2557
  --format F json (default) or jsonl
@@ -2641,6 +2779,13 @@ async function cmdImportJsonl(db, argv) {
2641
2779
  out(`[mem] Total: ${totalPrompts} prompts, ${totalObs} observations, ${totalOrphans} orphan tool_use, ${totalSkip} skipped from ${files.length} file(s)${errorTail}.`);
2642
2780
  if (totalPrompts > 0 || totalObs > 0) {
2643
2781
  out(`[mem] Try: claude-mem-lite recent 5 --project ${project}`);
2782
+ } else if (totalSkip > 0 && errorCount === 0) {
2783
+ // Nothing imported but every line was skipped — almost always the wrong file
2784
+ // format (import-jsonl ingests Claude Code transcript JSONL, not `export` output,
2785
+ // which is observation-shaped). Pre-fix this exited 0 with no signal, so pointing
2786
+ // it at the wrong file looked like success. Make the no-op explicit (stdout, like
2787
+ // the summary lines above).
2788
+ out(`[mem] Warning: 0 imported, ${totalSkip} line(s) skipped — none matched the expected Claude Code transcript JSONL shape (user/assistant/tool_result). 'export' output is NOT re-importable via import-jsonl.`);
2644
2789
  }
2645
2790
  }
2646
2791
 
@@ -2865,6 +3010,7 @@ export async function run(argv) {
2865
3010
  case 'delete': cmdDelete(db, cmdArgs); break;
2866
3011
  case 'update': cmdUpdate(db, cmdArgs); break;
2867
3012
  case 'export': cmdExport(db, cmdArgs); break;
3013
+ case 'restore': cmdRestore(db, cmdArgs); break;
2868
3014
  case 'compress': cmdCompress(db, cmdArgs); break;
2869
3015
  case 'maintain': cmdMaintain(db, cmdArgs); break;
2870
3016
  case 'optimize': await cmdOptimize(db, cmdArgs); break;
package/nlp.mjs CHANGED
@@ -218,6 +218,12 @@ export const FTS_STOP_WORDS = new Set([...BASE_STOP_WORDS]);
218
218
  export function sanitizeFtsQuery(query) {
219
219
  if (!query) return null;
220
220
  const cleaned = query
221
+ // Strip ASCII control chars / NUL FIRST. A NUL survives tokenization (it's not
222
+ // \s), gets phrase-quoted by expandToken, and then terminates SQLite's C string
223
+ // mid-phrase → FTS5 "unterminated string" throw, breaking the documented
224
+ // "never throws on MATCH" invariant. The metachar class below doesn't cover them.
225
+ // eslint-disable-next-line no-control-regex -- intentional: stripping control chars IS the fix
226
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
221
227
  .replace(/[{}()[\]^~*:"\\]/g, ' ')
222
228
  .replace(/(^|\s)-/g, '$1')
223
229
  .trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.89.0",
3
+ "version": "2.90.1",
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
 
package/schema.mjs CHANGED
@@ -8,9 +8,17 @@ import { join } from 'path';
8
8
  import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, chmodSync } from 'fs';
9
9
  import { OBS_FTS_COLUMNS } from './utils.mjs';
10
10
 
11
+ // DATA location — DB, managed resources, registry DB, runtime/. Honors
12
+ // CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
11
13
  export const DB_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
12
14
  export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
13
15
  export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
16
+ // CODE / install location — server.mjs, hook.mjs, cli.mjs, package.json live
17
+ // here. ALWAYS homedir-rooted: Claude Code's settings.json + MCP registration
18
+ // bake ABSOLUTE paths to server.mjs/hooks, so the code must NOT follow the
19
+ // CLAUDE_MEM_DIR relocation env var (mirrors install.mjs INSTALL_DIR). Equals
20
+ // DB_DIR when CLAUDE_MEM_DIR is unset — the common, non-relocated case.
21
+ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
14
22
 
15
23
  // Increment when schema changes (tables, columns, indexes, FTS, migrations)
16
24
  //
@@ -61,13 +69,15 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
61
69
  // SQLITE_CORRUPT_VTAB blast radius v27 fixed for the other FTS tables. Version
62
70
  // bumped to force one migration pass; the conditional drop below replaces the
63
71
  // legacy trigger on existing DBs. LATEST_MIGRATION_COLUMN unchanged (no new column).
64
- export const CURRENT_SCHEMA_VERSION = 36;
72
+ // v37 (D#26): adds user_prompts.cc_session_id (additive, nullable). LATEST_MIGRATION_COLUMN
73
+ // MOVES to it so the half-migrated-DB self-heal fast-path covers the new column.
74
+ export const CURRENT_SCHEMA_VERSION = 37;
65
75
 
66
76
  // Sentinel column for the LATEST migration set. The fast-path uses this to
67
77
  // self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
68
78
  // back (observed once in dev during v2.74.0). Update both the column AND
69
79
  // (if needed) the table when adding a new migration batch.
70
- const LATEST_MIGRATION_COLUMN = { table: 'observations', column: 'decay_seen_count' };
80
+ const LATEST_MIGRATION_COLUMN = { table: 'user_prompts', column: 'cc_session_id' };
71
81
 
72
82
  function hasLatestMigrationColumn(db) {
73
83
  try {
@@ -205,6 +215,14 @@ const MIGRATIONS = [
205
215
  // share the unrelated injection_count column. Same-source numerator
206
216
  // (cited_count) + same-source denominator = meaningful ratio.
207
217
  'ALTER TABLE observations ADD COLUMN decay_seen_count INTEGER NOT NULL DEFAULT 0',
218
+ // v37 (D#26 — parallel-session handoff content scoping): the Claude-Code session
219
+ // UUID per user prompt. handleUserPrompt writes hookData.session_id here so
220
+ // buildAndSaveHandoff can scope working_on to ONE CC session — concurrent (and
221
+ // within-12h-TTL sequential) same-project sessions previously merged each other's
222
+ // prompts because getSessionId() is project-scoped (no CC-UUID component). Nullable:
223
+ // legacy rows + non-CC/no-stdin invocations read back NULL and the handoff falls
224
+ // back to its legacy unfiltered query.
225
+ 'ALTER TABLE user_prompts ADD COLUMN cc_session_id TEXT DEFAULT NULL',
208
226
  ];
209
227
 
210
228
  /**
@@ -355,6 +373,7 @@ export function initSchema(db) {
355
373
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sess_sum_epoch ON session_summaries(created_at_epoch DESC, project)`);
356
374
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_project_epoch_minhash ON observations(project, created_at_epoch DESC) WHERE minhash_sig IS NOT NULL`);
357
375
  db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_session ON user_prompts(content_session_id)`);
376
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_cc ON user_prompts(cc_session_id) WHERE cc_session_id IS NOT NULL`);
358
377
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_superseded ON observations(superseded_at) WHERE superseded_at IS NOT NULL`);
359
378
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_branch ON observations(branch) WHERE branch IS NOT NULL`);
360
379
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sdk_sessions(project)`);
@@ -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/server.mjs CHANGED
@@ -8,7 +8,7 @@ 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
13
  import { searchObservationsHybrid, findFtsAnchor } from './search-engine.mjs';
14
14
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
@@ -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';
@@ -475,7 +475,12 @@ server.registerTool(
475
475
  `SELECT id, compressed_into, superseded_at, memory_session_id, project, importance, last_accessed_at, created_at_epoch, type FROM observations WHERE id IN (${placeholders})`
476
476
  ).all(...obsIds);
477
477
  const rowMap = new Map(fullRows.map(r => [r.id, r]));
478
- const tierCtx = { now: Date.now(), currentProject: currentProject, currentSessionId: '' };
478
+ // Use the explicitly-requested project for tier classification, not the
479
+ // CWD-inferred one — else computeTier's "obs.project === currentProject"
480
+ // (working/active rules) fails for cross-project searches and the tier=
481
+ // filter silently drops valid rows. mem_stats/mem_browse already resolve
482
+ // args.project first; this restores parity.
483
+ const tierCtx = { now: Date.now(), currentProject: args.project || currentProject, currentSessionId: '' };
479
484
  const filtered = results.filter(r => {
480
485
  if (r.source !== 'obs') return true;
481
486
  const full = rowMap.get(r.id);
@@ -1108,6 +1113,7 @@ server.registerTool(
1108
1113
  const lowVal = db.prepare(`
1109
1114
  SELECT COUNT(*) as c FROM observations
1110
1115
  WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
1116
+ AND COALESCE(compressed_into, 0) = 0
1111
1117
  AND created_at_epoch < ? ${projectFilter}
1112
1118
  `).get(thirtyDaysAgo, ...baseParams);
1113
1119
 
@@ -1500,7 +1506,7 @@ server.registerTool(
1500
1506
  const lines = results.map(r => {
1501
1507
  const qualityBadge = r.quality_tier === 'installed' ? '[✓]' : r.quality_tier === 'verified' ? '[★]' : '[○]';
1502
1508
  const categoryLabel = r.category ? ` [${r.category}]` : '';
1503
- const isManaged = r.local_path && r.local_path.includes('/.claude-mem-lite/managed/');
1509
+ const isManaged = r.local_path && r.local_path.includes(join(DB_DIR, 'managed') + sep);
1504
1510
  const portablePath = isManaged ? toPortable(r.local_path) : '';
1505
1511
  let howToUse;
1506
1512
  if (isManaged) {
@@ -1645,7 +1651,9 @@ server.registerTool(
1645
1651
  if (!row.local_path) {
1646
1652
  return { content: [{ type: 'text', text: `No local_path for ${args.name}` }], isError: true };
1647
1653
  }
1648
- const enrichBase = join(homedir(), '.claude-mem-lite');
1654
+ // Confine to the env-aware data dir (managed/ relocates with CLAUDE_MEM_DIR, D#29);
1655
+ // === homedir when the env is unset, so non-relocated confinement is unchanged.
1656
+ const enrichBase = DB_DIR;
1649
1657
  if (!isPathConfined(row.local_path, enrichBase)) {
1650
1658
  return { content: [{ type: 'text', text: `Access denied: path outside managed directory` }], isError: true };
1651
1659
  }
@@ -1713,8 +1721,10 @@ server.registerTool(
1713
1721
  }
1714
1722
  }
1715
1723
 
1716
- // 4. Path confinement check — prevent reading arbitrary files via crafted local_path
1717
- const managedBase = join(homedir(), '.claude-mem-lite');
1724
+ // 4. Path confinement check — prevent reading arbitrary files via crafted local_path.
1725
+ // Base is the env-aware data dir (D#29): managed/ relocates with CLAUDE_MEM_DIR and
1726
+ // equals homedir when unset, so this does not weaken the non-relocated confinement.
1727
+ const managedBase = DB_DIR;
1718
1728
  if (skillPath && !isPathConfined(skillPath, managedBase)) {
1719
1729
  return { content: [{ type: 'text', text: `Access denied: path "${skillPath}" is outside managed directory` }], isError: true };
1720
1730
  }