claude-mem-lite 6.8.2 → 6.9.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.
@@ -116,6 +116,34 @@ export function hasManagedCodeInstall(installDir) {
116
116
  return MANAGED_ENTRY_POINTS.every((f) => existsSync(join(installDir, f)));
117
117
  }
118
118
 
119
+ /**
120
+ * True when `installDir` holds ANY of `files` — i.e. code was deployed here at some point,
121
+ * even if it is now incomplete.
122
+ *
123
+ * The complement of `hasManagedCodeInstall` above is not one population but two, and they
124
+ * take OPPOSITE remedies: an install that is DAMAGED (some of it survives) is what `repair`
125
+ * exists for, while one that was never deployed here needs `install`. `repair` runs from
126
+ * `<installDir>/cli.mjs`, so prescribing it where nothing was deployed names a binary that
127
+ * cannot start.
128
+ *
129
+ * `files` is a PARAMETER and the caller passes the whole managed list, because this answers
130
+ * a question about a WIDER population than `hasManagedCodeInstall` does. Pinning both to
131
+ * MANAGED_ENTRY_POINTS looked tidy — one array, `every` vs `some` — and made the verdict a
132
+ * claim about two files while the message it gates says "none present" about all of them.
133
+ * An install holding cli.mjs and every lib/ module but neither entry point was reported as
134
+ * a data directory with no install behind it, and told to re-`install` rather than
135
+ * `repair` — which was runnable, sitting right there.
136
+ *
137
+ * @param {string} installDir
138
+ * @param {string[]} files The managed file list to test against (SOURCE_FILES at the only
139
+ * call site; a narrower list answers a narrower question).
140
+ * @returns {boolean}
141
+ */
142
+ export function hasAnyManagedCode(installDir, files) {
143
+ if (!installDir || !existsSync(installDir)) return false;
144
+ return files.some((f) => existsSync(join(installDir, f)));
145
+ }
146
+
119
147
  /**
120
148
  * Plugin-cache version dirs that carry runnable code, newest first.
121
149
  *
package/mem-cli.mjs CHANGED
@@ -5,7 +5,15 @@
5
5
  import { homedir } from 'os';
6
6
  import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, CODE_DIR } from './schema.mjs';
7
7
  import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
8
- import { truncate, typeIcon, inferProject, scrubSecrets, COMPRESSED_PENDING_PURGE } from './utils.mjs';
8
+ import { isFtsCorruptionError, FTS_CORRUPTION_REMEDY } from './lib/db-unusable.mjs';
9
+ import {
10
+ truncate,
11
+ queryLabel,
12
+ typeIcon,
13
+ inferProject,
14
+ scrubSecrets,
15
+ COMPRESSED_PENDING_PURGE,
16
+ } from './utils.mjs';
9
17
  import { resolveProject } from './project-utils.mjs';
10
18
  // READ commands resolve the project DB-aware: a subdirectory whose own name holds no rows
11
19
  // falls back to the enclosing work-tree root, so `cd src/auth && … recent` reads what the
@@ -98,6 +106,9 @@ import {
98
106
  rejectBareStringFlags,
99
107
  resolvePositionalAlias,
100
108
  suggestUnknownFlags,
109
+ resetFlagTracking,
110
+ inertFilterFlags,
111
+ inertFilterFlagNotice,
101
112
  OBS_TIME_FIELDS,
102
113
  formatObsFieldValue,
103
114
  obsFieldLabel,
@@ -190,7 +201,7 @@ function emitRecallHint(db, query) {
190
201
  const n = countRecallableByFile(db, q);
191
202
  if (n > 0) {
192
203
  out(`[mem] ${n} observation(s) are linked to that file — search indexes text, not file paths.`);
193
- out(`[mem] Try: claude-mem-lite recall "${q}"`);
204
+ out(`[mem] Try: claude-mem-lite recall "${queryLabel(q)}"`);
194
205
  }
195
206
  } catch {
196
207
  /* hint is best-effort; never break search */
@@ -272,12 +283,13 @@ async function cmdSearch(db, args, { llm } = {}) {
272
283
  // Haiku call + N hybrid searches; observations-only. NOT the passive path — this
273
284
  // is the explicit "search harder" lever for vocabulary-mismatch recall misses.
274
285
  // --deep forces deep; --no-deep forces normal; neither = unset (env/default decide).
275
- const explicitDeep =
276
- flags.deep === true || flags.deep === 'true'
277
- ? true
278
- : flags['no-deep'] === true || flags['no-deep'] === 'true'
279
- ? false
280
- : undefined;
286
+ // Both are read before the precedence contest, deliberately. The ternary short-circuited,
287
+ // so `search --deep --no-deep` never touched `flags['no-deep']` and the inert-flag notice
288
+ // called it a flag `search` "does not filter on" with "results above are UNFILTERED" — both
289
+ // false. A flag that LOST a precedence contest was read; it just did not win.
290
+ const wantsDeep = flags.deep === true || flags.deep === 'true';
291
+ const wantsNoDeep = flags['no-deep'] === true || flags['no-deep'] === 'true';
292
+ const explicitDeep = wantsDeep ? true : wantsNoDeep ? false : undefined;
281
293
  const deepMode = resolveDeepMode(explicitDeep, { surface: 'cli' });
282
294
 
283
295
  // --rerank: opt-in LLM rerank of the fused top-20 (option C, deep-search.mjs).
@@ -325,7 +337,7 @@ async function cmdSearch(db, args, { llm } = {}) {
325
337
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: false, results: [] }));
326
338
  } else {
327
339
  emitDeferredTrailer();
328
- fail(`[mem] No valid search terms in "${query}"`);
340
+ fail(`[mem] No valid search terms in "${queryLabel(query)}"`);
329
341
  }
330
342
  return;
331
343
  }
@@ -476,7 +488,7 @@ async function cmdSearch(db, args, { llm } = {}) {
476
488
  }),
477
489
  );
478
490
  } else {
479
- out(`[mem] No results for "${query}"`);
491
+ out(`[mem] No results for "${queryLabel(query)}"`);
480
492
  emitRecallHint(db, query);
481
493
  // The zero-result path is where the trailer earns its keep — the D#92
482
494
  // failure chain was exactly "searched, found nothing, item was deferred".
@@ -514,7 +526,7 @@ async function cmdSearch(db, args, { llm } = {}) {
514
526
  }),
515
527
  );
516
528
  } else {
517
- out(`[mem] No results for "${query}" at offset ${offset}`);
529
+ out(`[mem] No results for "${queryLabel(query)}" at offset ${offset}`);
518
530
  }
519
531
  return;
520
532
  }
@@ -575,7 +587,7 @@ async function cmdSearch(db, args, { llm } = {}) {
575
587
  // Pluralize on total — "Found 1 of 44 result" reads wrong; the population (44) drives
576
588
  // grammatical number, not the page slice (1).
577
589
  out(
578
- `[mem] Found ${countLabel} result${total !== 1 ? 's' : ''} for "${query}"${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt, E# event)' : ''}`,
590
+ `[mem] Found ${countLabel} result${total !== 1 ? 's' : ''} for "${queryLabel(query)}"${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt, E# event)' : ''}`,
579
591
  );
580
592
  // `~Nt` = est. tokens to fetch this row's full body via mem_get (attachBodyTokens, paired with
581
593
  // MCP). Conditional so a row that skipped enrichment renders cleanly, not "~undefinedt".
@@ -948,7 +960,16 @@ function cmdGet(db, args) {
948
960
  sections.push(dRows.map(formatDeferredDetail).join('\n\n'));
949
961
  totalFound += dRows.length;
950
962
  }
951
- if (deferredMissing.length > 0) {
963
+ // This note exists for a MIXED request: other sections still render, so without it the
964
+ // output looks complete while an id the caller asked for silently produced nothing.
965
+ // A deferred-only request that matched nothing is not that shape — the terminal branch
966
+ // below prints the same list plus the `defer list` hint, and running both made
967
+ // `get D#99` say it twice, the second line a superset of the first. Conditions checked
968
+ // rather than the note dropped: silencing it outright would blind the case it is for.
969
+ const deferredOnlyMiss =
970
+ dRows.length === 0 &&
971
+ bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length === 0;
972
+ if (deferredMissing.length > 0 && !deferredOnlyMiss) {
952
973
  process.stderr.write(
953
974
  `[mem] Deferred item(s) not found: ${deferredMissing.map((i) => `D#${i}`).join(', ')}\n`,
954
975
  );
@@ -3592,7 +3613,57 @@ import { cmdActivity } from './cli/activity.mjs';
3592
3613
  import { DAY_MS } from './lib/time-constants.mjs';
3593
3614
  // ─── Main Entry Point ────────────────────────────────────────────────────────
3594
3615
 
3616
+ /**
3617
+ * Commands that read their own raw argv instead of the object parseArgs returns, so the
3618
+ * inert-flag notice below has no way to observe a flag being consumed and must stay quiet.
3619
+ * `adopt` / `unadopt` hand `cmdArgs` straight to adopt-cli.mjs; `doctor` selects its mode
3620
+ * with `args.includes('--x')`; `cmdOptimize` reads `--project` and `--scope` positionally
3621
+ * (`args.indexOf('--scope')`, and see its own note about parsing flags that way). Adding a
3622
+ * command here is the per-command twin of leaving a flag out of FILTER_FLAGS.
3623
+ *
3624
+ * Do not extend this by hand alone. `tests/cli-argv-parsed-commands-complete.test.mjs`
3625
+ * DERIVES the answer — it walks the dispatcher's switch, reads each handler's body, and
3626
+ * fails if a handler indexes raw argv for a FILTER_FLAGS name without being listed here.
3627
+ * `optimize` was the second miss on this list in one branch (`unadopt --all` was the first),
3628
+ * both found by sweeping invocations, and a sweep only ever covers the population somebody
3629
+ * remembered to enumerate.
3630
+ */
3631
+ const ARGV_PARSED_COMMANDS = new Set(['adopt', 'unadopt', 'doctor', 'optimize']);
3632
+
3595
3633
  export async function run(argv) {
3634
+ // The inert-selection-flag notice is emitted HERE rather than inside the dispatcher because
3635
+ // `runDispatch` returns from a dozen places (adopt / unadopt / memdir-audit before the DB is
3636
+ // even opened, plus every `fail()` path), and a notice wired at some of them is a notice that
3637
+ // reports a dropped filter on some commands and not others. `fail()` sets `process.exitCode`
3638
+ // and RETURNS rather than calling process.exit, so this `finally` covers the failure paths too.
3639
+ resetFlagTracking();
3640
+ try {
3641
+ return await runDispatch(argv);
3642
+ } finally {
3643
+ // Silent for commands that never hand their flags to parseArgs. `adopt` / `unadopt` pass
3644
+ // raw `cmdArgs` straight to adopt-cli.mjs, which reads the array itself, and `doctor`
3645
+ // selects its mode with `args.includes('--x')` — so read-tracking cannot see a flag being
3646
+ // consumed there and would call a documented, working flag inert. `unadopt --all` is
3647
+ // exactly that: adopt-cli.mjs's own header documents it and `unadoptAll()` implements it,
3648
+ // and the first version of this notice reported it as ignored. Same class as
3649
+ // `prompts-limit`, which FILTER_FLAGS excludes by name for the same reason — this is the
3650
+ // per-COMMAND half of that rule. Blindness must present as silence, never as a warning.
3651
+ const argvParsed = ARGV_PARSED_COMMANDS.has(argv[0]);
3652
+ // Only when the command SUCCEEDED. The notice is about an answer that is wider than the
3653
+ // one asked for; a command that failed has no answer for it to qualify, and saying "the
3654
+ // results above are UNFILTERED" under an error message describes results that do not
3655
+ // exist. Found by sweeping correct usage for false alarms: `activity search --limit 3`
3656
+ // (no query) fails its own usage check BEFORE anything reads `flags.limit`, so the flag
3657
+ // is genuinely unread and the notice was genuinely wrong. The error IS the feedback there.
3658
+ const inert = process.exitCode || argvParsed ? [] : inertFilterFlags();
3659
+ // stderr only: stdout is a data channel (`search --json | jq`, and commands/mem.md pipes
3660
+ // these outputs into model context), so the notice must not enter it. Exit code untouched —
3661
+ // the command did run, and its answer is real, just wider than the user asked for.
3662
+ if (inert.length > 0) process.stderr.write(inertFilterFlagNotice(argv[0], inert) + '\n');
3663
+ }
3664
+ }
3665
+
3666
+ async function runDispatch(argv) {
3596
3667
  const cmd = argv[0];
3597
3668
  const cmdArgs = argv.slice(1);
3598
3669
 
@@ -3847,6 +3918,12 @@ export async function run(argv) {
3847
3918
  // agent runs the CLI, the model's context — got a raw Node stack trace. Print the
3848
3919
  // message, keep the stack behind CLAUDE_MEM_DEBUG for whoever is actually debugging.
3849
3920
  process.stderr.write(`[mem] ${cmd || 'command'} failed: ${(e && e.message) || e}\n`);
3921
+ // A damaged FTS5 index reaches here as SQLITE_CORRUPT_VTAB from the first MATCH.
3922
+ // `fts-check` and `doctor` touch the index too — and both already explain themselves —
3923
+ // so `search` is the one command that DEAD-ENDS on SQLite's sentence, while `recent` /
3924
+ // `recall` / `browse` / `context` / `stats` never read the index and keep working.
3925
+ // The remedy is lossless (see FTS_CORRUPTION_REMEDY); the exit code stays 1.
3926
+ if (isFtsCorruptionError(e)) process.stderr.write(`[mem] ${FTS_CORRUPTION_REMEDY}\n`);
3850
3927
  if (process.env.CLAUDE_MEM_DEBUG) process.stderr.write(`${(e && e.stack) || ''}\n`);
3851
3928
  process.exitCode = 1;
3852
3929
  } finally {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.8.2",
3
+ "version": "6.9.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.8.2",
9
+ "version": "6.9.0",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.8.2",
3
+ "version": "6.9.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",
@@ -91,6 +91,7 @@
91
91
  "lib/hook-telemetry.mjs",
92
92
  "lib/resolve-data-dir.mjs",
93
93
  "lib/data-paths.mjs",
94
+ "lib/doctor-modes.mjs",
94
95
  "lib/export-columns.mjs",
95
96
  "lib/file-intel.mjs",
96
97
  "lib/reread-guard.mjs",
@@ -107,7 +107,25 @@ if [[ "$tool" == "Read" ]]; then
107
107
  fi
108
108
  fi
109
109
  fi
110
- runtime_dir="${_data_dir}/runtime"
110
+ # Mirror resolveRuntimeDir() (lib/resolve-data-dir.mjs): CLAUDE_MEM_RUNTIME_DIR wins when
111
+ # non-empty, and a relative value resolves against cwd. Without this the two sides of the
112
+ # channel disagreed whenever that override was set — bash appended to
113
+ # $CLAUDE_MEM_DIR/runtime while hook.mjs read (and hook-shared.mjs reaped) the override
114
+ # dir, which is both harms named at the top of this branch: every Read dropped from the
115
+ # episode, and an orphaned file nothing ever collects.
116
+ #
117
+ # The override deliberately wins over the test-containment redirect above, because the
118
+ # Node resolver ignores dataDir entirely once it is set. Mirroring it is the whole point;
119
+ # a "safer" bash rule here would be a second policy nobody reviewed.
120
+ #
121
+ # Builtins only. This is the ~5ms pre-filter — a `node -e` resolver of the kind setup.sh
122
+ # can afford at SessionStart costs ~27ms measured, on every tool call.
123
+ if [[ -n "${CLAUDE_MEM_RUNTIME_DIR:-}" ]]; then
124
+ runtime_dir="$CLAUDE_MEM_RUNTIME_DIR"
125
+ [[ "$runtime_dir" == /* ]] || runtime_dir="${PWD}/${runtime_dir}"
126
+ else
127
+ runtime_dir="${_data_dir}/runtime"
128
+ fi
111
129
  # Owner-only (0700 dir / 0600 file): reads-<project>.txt lists captured file
112
130
  # paths, so on a shared host the default umask leaked them to every local user.
113
131
  # umask is a shell builtin — no extra process on this ~5ms per-tool-call path
package/scripts/setup.sh CHANGED
@@ -13,7 +13,51 @@ else
13
13
  ROOT="$CLAUDE_PLUGIN_ROOT"
14
14
  fi
15
15
 
16
- DATA_DIR="$HOME/.claude-mem-lite"
16
+ # The same three locations lib/data-paths.mjs defines, under the same names. This script
17
+ # carried ONE variable for all three, and under CLAUDE_MEM_DIR that variable is the CODE
18
+ # dir — so every question about the DATABASE was asked of a directory holding none. This
19
+ # repo has now had that confusion three times (v6.3.0, the same fix reintroduced with the
20
+ # halves swapped, and here), which is why the names are spelled out rather than inferred.
21
+ #
22
+ # CODE_DIR — ALWAYS homedir. settings.json and the MCP registration bake absolute
23
+ # paths to server.mjs / hook.mjs under it, so it must not follow the
24
+ # relocation env var. Owns node_modules and the run-once install markers.
25
+ # DB_DIR — follows CLAUDE_MEM_DIR. Owns claude-mem-lite.db and its sidecars.
26
+ # RUNTIME_DIR — follows CLAUDE_MEM_RUNTIME_DIR, else DB_DIR/runtime. Owns state a hook
27
+ # writes and another component reads back (see .deps-broken below).
28
+ #
29
+ # ASK the shared resolver rather than re-deriving its rules (absolute-only, "undefined" /
30
+ # "null" rejected) in a second language. lib/resolve-data-dir.mjs is a leaf module — node:
31
+ # builtins only — so it still loads with node_modules missing, which is the state the
32
+ # .deps-broken flag exists to describe. Gated on an override actually being set: with
33
+ # neither var the resolver returns exactly these defaults, and SessionStart should not pay a
34
+ # node spawn to be told that. A resolver that is missing (truncated tree) or that throws
35
+ # (invalid override) leaves the defaults in place; the Node side rejects a bad value loudly
36
+ # enough on its own, and aborting here would fail the user's session start.
37
+ CODE_DIR="$HOME/.claude-mem-lite"
38
+ DB_DIR="$CODE_DIR"
39
+ RUNTIME_DIR="$CODE_DIR/runtime"
40
+ if [[ -n "${CLAUDE_MEM_DIR:-}" || -n "${CLAUDE_MEM_RUNTIME_DIR:-}" ]] && [[ -f "$ROOT/lib/resolve-data-dir.mjs" ]]; then
41
+ # shellcheck disable=SC2016 # node script single-quoted on purpose; path passed via env, not shell expansion
42
+ _resolved="$(RESOLVER_MOD="$ROOT/lib/resolve-data-dir.mjs" node -e '
43
+ const { pathToFileURL } = require("node:url");
44
+ import(pathToFileURL(process.env.RESOLVER_MOD).href)
45
+ .then((m) => {
46
+ const db = m.resolveDataDir(process.env.CLAUDE_MEM_DIR);
47
+ process.stdout.write(`${db}\n${m.resolveRuntimeDir(db)}\n`);
48
+ })
49
+ .catch(() => process.exit(1));
50
+ ' 2>/dev/null)" || _resolved=""
51
+ _db="$(printf '%s\n' "$_resolved" | sed -n 1p)"
52
+ _rt="$(printf '%s\n' "$_resolved" | sed -n 2p)"
53
+ # Both or neither: a half-applied override is the split this whole block exists to close.
54
+ if [[ -n "$_db" && -n "$_rt" ]]; then
55
+ DB_DIR="$_db"
56
+ RUNTIME_DIR="$_rt"
57
+ fi
58
+ unset _resolved _db _rt
59
+ fi
60
+
17
61
  OLD_UNHIDDEN_DIR="$HOME/claude-mem-lite"
18
62
 
19
63
  # Colors
@@ -36,23 +80,38 @@ log_warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; }
36
80
  log_err() { echo -e "${RED}✗${NC} $*" >&2; }
37
81
 
38
82
  # 1. Migrate unhidden dir (~/claude-mem-lite/ → ~/.claude-mem-lite/)
39
- if [[ -d "$OLD_UNHIDDEN_DIR" && ! -d "$DATA_DIR" ]]; then
40
- mv "$OLD_UNHIDDEN_DIR" "$DATA_DIR"
83
+ # CODE_DIR, not DB_DIR, and deliberately: the pre-v0.5 unhidden directory held the
84
+ # INSTALL — server.mjs, hook.mjs, package.json — and CODE_DIR is the one location that
85
+ # must never follow the relocation env var. Moving it into a relocated DB_DIR would
86
+ # strand every absolute path settings.json and the MCP registration baked.
87
+ if [[ -d "$OLD_UNHIDDEN_DIR" && ! -d "$CODE_DIR" ]]; then
88
+ mv "$OLD_UNHIDDEN_DIR" "$CODE_DIR"
41
89
  log_ok "Migrated ~/claude-mem-lite/ → ~/.claude-mem-lite/"
42
90
  fi
43
91
 
44
- # 2. Ensure data directory exists (runtime created after migration check)
45
- mkdir -p "$DATA_DIR"
46
- log_ok "Data directory: $DATA_DIR"
92
+ # 2. Ensure both locations exist (runtime created after migration check)
93
+ mkdir -p "$CODE_DIR"
94
+ mkdir -p "$DB_DIR"
95
+ if [[ "$DB_DIR" == "$CODE_DIR" ]]; then
96
+ log_ok "Data directory: $DB_DIR"
97
+ else
98
+ log_ok "Data directory: $DB_DIR (code: $CODE_DIR)"
99
+ fi
47
100
 
48
101
  # 3. Legacy ~/.claude-mem/ DB is schema-v16 (no memory_session_id) with no migration bridge to
49
102
  # the current schema — activating it FATALs on first launch ("no such column: memory_session_id")
50
103
  # and the "! -f claude-mem-lite.db" guard would re-copy it every time the user deletes the broken
51
104
  # DB (recovery loop). Mirror install.mjs migrateLegacyClaudeMemData: back it up (don't activate)
52
105
  # and let a fresh DB be created. Source ~/.claude-mem/ is left intact.
106
+ #
107
+ # DB_DIR, because that guard is the whole convergence argument: it closes when the product
108
+ # creates claude-mem-lite.db, and the product creates it in DB_DIR. Asked of CODE_DIR under
109
+ # a relocation it never closed — nothing ever writes a database THERE — so this block
110
+ # copied the legacy database again on every single SessionStart, without bound. Measured
111
+ # 2026-09-14: control arm stable at 1 backup across three runs, relocated arm 1 → 3.
53
112
  OLD_DIR="$HOME/.claude-mem"
54
- if [[ -f "$OLD_DIR/claude-mem.db" && ! -f "$DATA_DIR/claude-mem-lite.db" && ! -f "$DATA_DIR/claude-mem.db" ]]; then
55
- BACKUP="$DATA_DIR/claude-mem-lite.db.legacy-backup-$(date +%s)"
113
+ if [[ -f "$OLD_DIR/claude-mem.db" && ! -f "$DB_DIR/claude-mem-lite.db" && ! -f "$DB_DIR/claude-mem.db" ]]; then
114
+ BACKUP="$DB_DIR/claude-mem-lite.db.legacy-backup-$(date +%s)"
56
115
  if cp "$OLD_DIR/claude-mem.db" "$BACKUP" 2>/dev/null; then
57
116
  log_info "Legacy ~/.claude-mem/ DB is schema-incompatible; backed up to $(basename "$BACKUP") (a fresh DB will be created). Old ~/.claude-mem/ preserved."
58
117
  else
@@ -60,16 +119,20 @@ if [[ -f "$OLD_DIR/claude-mem.db" && ! -f "$DATA_DIR/claude-mem-lite.db" && ! -f
60
119
  fi
61
120
  fi
62
121
 
63
- # 4. Rename claude-mem.db → claude-mem-lite.db in same directory
64
- if [[ -f "$DATA_DIR/claude-mem.db" && ! -f "$DATA_DIR/claude-mem-lite.db" ]]; then
65
- mv "$DATA_DIR/claude-mem.db" "$DATA_DIR/claude-mem-lite.db"
66
- mv "$DATA_DIR/claude-mem.db-wal" "$DATA_DIR/claude-mem-lite.db-wal" 2>/dev/null || true
67
- mv "$DATA_DIR/claude-mem.db-shm" "$DATA_DIR/claude-mem-lite.db-shm" 2>/dev/null || true
122
+ # 4. Rename claude-mem.db → claude-mem-lite.db in same directory (DB_DIR: a relocated user's
123
+ # pre-rename database sits there, and asking CODE_DIR left it unrenamed and unopened).
124
+ if [[ -f "$DB_DIR/claude-mem.db" && ! -f "$DB_DIR/claude-mem-lite.db" ]]; then
125
+ mv "$DB_DIR/claude-mem.db" "$DB_DIR/claude-mem-lite.db"
126
+ mv "$DB_DIR/claude-mem.db-wal" "$DB_DIR/claude-mem-lite.db-wal" 2>/dev/null || true
127
+ mv "$DB_DIR/claude-mem.db-shm" "$DB_DIR/claude-mem-lite.db-shm" 2>/dev/null || true
68
128
  log_ok "Database renamed: claude-mem.db → claude-mem-lite.db"
69
129
  fi
70
130
 
71
- # 5. Ensure runtime directory exists (after migration to not mask migration check)
72
- mkdir -p "$DATA_DIR/runtime"
131
+ # 5. Ensure runtime directories exist (after migration to not mask migration check).
132
+ # Both: RUNTIME_DIR carries the cross-component flag below, while CODE_DIR/runtime keeps
133
+ # the two run-once install markers at the bottom of this file.
134
+ mkdir -p "$RUNTIME_DIR"
135
+ mkdir -p "$CODE_DIR/runtime"
73
136
 
74
137
  # 6. Ensure native dependencies available for hooks (ESM import needs node_modules in resolution chain)
75
138
  # Plugin cache doesn't include node_modules — symlink from data dir or npm install on first run
@@ -81,8 +144,25 @@ mkdir -p "$DATA_DIR/runtime"
81
144
  # and exit on the require() error. v2.79: write a JSON flag to runtime/.deps-broken
82
145
  # and hook.mjs SessionStart surfaces it in the Claude context as a HIGH-VISIBILITY
83
146
  # block; success branches remove the flag so a self-heal stays visible too.
84
- DEPS_FLAG="$DATA_DIR/runtime/.deps-broken"
85
- mkdir -p "$DATA_DIR/runtime" 2>/dev/null || true
147
+ #
148
+ # ...and that contract is a two-directory agreement, not a filename. hook.mjs renders the
149
+ # flag from `join(RUNTIME_DIR, '.deps-broken')`, where RUNTIME_DIR is
150
+ # `resolveRuntimeDir(resolveDataDir(CLAUDE_MEM_DIR))` (hook-shared.mjs). Hardcoding a
151
+ # homedir path here meant that under CLAUDE_MEM_DIR / CLAUDE_MEM_RUNTIME_DIR the writer and
152
+ # the reader named two different directories, so the one surface that says "your hooks are
153
+ # degraded" rendered nothing on exactly the installs that had relocated. Measured
154
+ # 2026-09-14: flag planted where this script wrote it → banner 0 times; planted where
155
+ # hook.mjs reads → 1. Same SPLIT shape lib/resolve-data-dir.mjs documents; it survived
156
+ # because tests/runtime-dir-single-home.test.mjs sweeps `walkShipped`, which is every
157
+ # shipped .mjs/.js — a bash hook is structurally outside that population. RUNTIME_DIR is
158
+ # resolved once at the top of this file.
159
+ #
160
+ # ONLY this marker follows the override. `.mcp-dedup-v2.78` and `.residue-warned-v2.55`
161
+ # below are one-shot state about THIS MACHINE's install — a ~/.claude.json edit and a
162
+ # settings.json warning, not state a hook hands to another component — so they stay under
163
+ # CODE_DIR. Read lib/resolve-data-dir.mjs's MOVES/STAYS list before relocating either:
164
+ # moving a run-once marker re-runs what it gated.
165
+ DEPS_FLAG="$RUNTIME_DIR/.deps-broken"
86
166
 
87
167
  mark_deps_broken() {
88
168
  local reason="$1"
@@ -112,9 +192,11 @@ mark_deps_ok() {
112
192
 
113
193
  if [[ ! -d "$ROOT/node_modules/better-sqlite3" ]]; then
114
194
  # Fast path: symlink from data dir (instant, no network needed)
115
- if [[ -d "$DATA_DIR/node_modules/better-sqlite3" ]]; then
116
- if ln -sfn "$DATA_DIR/node_modules" "$ROOT/node_modules" 2>/dev/null; then
117
- log_ok "Dependencies linked from $DATA_DIR"
195
+ # CODE_DIR: node_modules belongs to the install, not to the data, and must not follow
196
+ # CLAUDE_MEM_DIR — install.mjs writes it under the homedir install location.
197
+ if [[ -d "$CODE_DIR/node_modules/better-sqlite3" ]]; then
198
+ if ln -sfn "$CODE_DIR/node_modules" "$ROOT/node_modules" 2>/dev/null; then
199
+ log_ok "Dependencies linked from $CODE_DIR"
118
200
  fi
119
201
  fi
120
202
  # Slow path: npm install (first-time only, ~10-20s for native addon)
@@ -219,7 +301,9 @@ fi
219
301
  # pre-v2.79.1 — extra node spawn + JSON parse on every SessionStart for a
220
302
  # near-always no-op). Bump MCP_MIGRATION name to re-run cleanup in future
221
303
  # versions; same shape as the .deps-broken self-heal pattern.
222
- MCP_MIGRATION="$DATA_DIR/runtime/.mcp-dedup-v2.78"
304
+ # CODE_DIR/runtime, not RUNTIME_DIR: this marker gates a one-shot edit of ~/.claude.json,
305
+ # which is machine state, not per-data-dir state. Relocating it would re-run that edit.
306
+ MCP_MIGRATION="$CODE_DIR/runtime/.mcp-dedup-v2.78"
223
307
  if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$MCP_MIGRATION" ]]; then
224
308
  # shellcheck disable=SC2016 # node script single-quoted on purpose; CLAUDE_JSON passed via env, not shell expansion
225
309
  CLAUDE_JSON="$HOME/.claude.json" node -e '
@@ -298,7 +382,9 @@ fi
298
382
  # will run every hook twice (direct settings.json hooks AND plugin hooks)
299
383
  # until they run `claude-mem-lite uninstall` to clear the settings.json
300
384
  # entries. /plugin uninstall does not touch settings.json.
301
- RESIDUE_MARKER="$DATA_DIR/runtime/.residue-warned-v2.55"
385
+ # CODE_DIR/runtime, same reason: the residue it warns about is stale hook entries in
386
+ # ~/.claude/settings.json — one machine, one warning, regardless of where the data lives.
387
+ RESIDUE_MARKER="$CODE_DIR/runtime/.residue-warned-v2.55"
302
388
  if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
303
389
  SETTINGS="$HOME/.claude/settings.json"
304
390
  if [[ -f "$SETTINGS" ]]; then
package/server.mjs CHANGED
@@ -76,7 +76,7 @@ import { formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cl
76
76
  // The partial-export warning points the caller at the CLI twin, which exports the complete
77
77
  // set by default — the invocation has to be the one that actually works on this install.
78
78
  import { CLI_INVOKE } from './cli-path.mjs';
79
- import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from './format-utils.mjs';
79
+ import { neutralizeContextDelimiters, neutralizeSkillDelimiters, queryLabel } from './format-utils.mjs';
80
80
  import {
81
81
  memSearchSchema,
82
82
  memRecentSchema,
@@ -118,6 +118,7 @@ import { saveWithClosures, formatSupersedeSkipped, formatSupersededNote } from '
118
118
  import { applyObsUpdate } from './lib/observation-write.mjs';
119
119
  import { EXPORT_COLUMNS_SQL, buildExportWhere } from './lib/export-columns.mjs';
120
120
  import { recallByFile } from './lib/recall-core.mjs';
121
+ import { isFtsCorruptionError, FTS_CORRUPTION_REMEDY } from './lib/db-unusable.mjs';
121
122
  import { fetchRecent } from './lib/recent-core.mjs';
122
123
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
123
124
  import {
@@ -374,7 +375,17 @@ function safeHandler(fn, { verbatim = false } = {}) {
374
375
  const result = await fn(args, extra);
375
376
  return verbatim ? result : defangResult(result, { skillBlocks: true });
376
377
  } catch (err) {
377
- return defangResult({ content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true });
378
+ // A damaged FTS5 index arrives here as SQLITE_CORRUPT_VTAB from the first MATCH.
379
+ // Without this the model got SQLite's own sentence and nothing else, on a fault it
380
+ // could have had fixed in one command — and mem_recent / mem_recall / mem_browse
381
+ // keep answering, so the dead end reads as "nothing matched". Both channels carry the
382
+ // same string here, deliberately: unlike the file-level remedy this one is lossless
383
+ // (see FTS_CORRUPTION_REMEDY in lib/db-unusable.mjs).
384
+ const hint = isFtsCorruptionError(err) ? `\n${FTS_CORRUPTION_REMEDY}` : '';
385
+ return defangResult({
386
+ content: [{ type: 'text', text: `Error: ${err.message}${hint}` }],
387
+ isError: true,
388
+ });
378
389
  }
379
390
  };
380
391
  }
@@ -409,13 +420,18 @@ function formatSearchOutput(
409
420
  'This is a recall miss (the rewrite ran), not a query-syntax issue; the memory likely has no related observations.',
410
421
  );
411
422
  } else if (args.query && !ftsQuery) {
412
- hint.push(`Query "${args.query}" was filtered (FTS5 keywords/special chars only).`);
423
+ hint.push(`Query "${queryLabel(args.query)}" was filtered (FTS5 keywords/special chars only).`);
413
424
  hint.push('Tip: use content words instead of operators (AND, OR, NOT, NEAR).');
414
425
  } else {
415
426
  hint.push('No results found.');
416
427
  if (args.query) {
417
428
  const expanded = ftsQuery || args.query;
418
- if (expanded !== args.query) hint.push(`Searched as: ${expanded}`);
429
+ // Bounded like the other two echo sites. This one is the worst of the three: it
430
+ // prints the EXPANDED query, and synonym expansion makes it LARGER than the input
431
+ // (measured 13,691 chars out for 10,329 in, 1.33x). The first pass of this fix
432
+ // skipped the branch on the reasoning that it "never renders the label" — true of
433
+ // the label, false of the echo.
434
+ if (expanded !== args.query) hint.push(`Searched as: ${queryLabel(expanded)}`);
419
435
  hint.push('Tip: check spelling, try broader terms, or use mem_stats to see available data.');
420
436
  }
421
437
  }
@@ -438,7 +454,7 @@ function formatSearchOutput(
438
454
  );
439
455
  // P2-6: empty/omitted query falls through to a "listing recent" path — label it explicitly
440
456
  // so callers don't mistake BM25-less results for relevance-ranked ones.
441
- const qLabel = args.query ? ` for "${args.query}"` : ' (no query — listing recent)';
457
+ const qLabel = args.query ? ` for "${queryLabel(args.query)}"` : ' (no query — listing recent)';
442
458
  // Surface AND→OR fallback so callers (incl. Claude) know a strict multi-term
443
459
  // query actually matched only a subset of the terms. Suppressed when the caller
444
460
  // explicitly requested OR semantics — there's no "fallback" in that path.
package/source-files.mjs CHANGED
@@ -60,6 +60,7 @@ export const SOURCE_FILES = [
60
60
  // stays loadable without better-sqlite3. Missing from the manifest → auto-update leaves
61
61
  // schema.mjs and the repair path with ERR_MODULE_NOT_FOUND on every fire.
62
62
  'lib/data-paths.mjs',
63
+ 'lib/doctor-modes.mjs',
63
64
  // lib/ — statically imported by hook-llm.mjs (activity) + hook-handoff.mjs (git-state, task-reader);
64
65
  // dynamically imported by hook.mjs (startup-dashboard) + mem-cli.mjs (doctor-benchmark, plan-reader).
65
66
  'lib/activity.mjs',
package/tfidf.mjs CHANGED
@@ -1,16 +1,15 @@
1
- // tfidf.mjs — tokenization + Porter stemming.
1
+ // tfidf.mjs — the Porter stemmer.
2
2
  //
3
3
  // NAME IS HISTORICAL. This module was the TF-IDF vector search engine (vocabulary, vectors,
4
4
  // cosine similarity, vector search, RRF merge). Phase-1 (v3.17.0, 2026-06-27) gated that arm
5
5
  // off; Phase-2 removed it. What is left is the text-normalization half, which was never part
6
6
  // of the vector arm and has live consumers on the DEFAULT retrieval path:
7
7
  // - porterStem -> search-scoring.mjs (PRF term extraction)
8
- // - tokenize -> benchmark/adoption-cosine.mjs
8
+ // `tokenize` lived here too until it was moved to benchmark/adoption-cosine.mjs, beside the
9
+ // only callers it had left; this module is now one function under a two-word historical name.
9
10
  // RRF_K moved to lib/rrf.mjs, its actual home. See tests/vector-arm-removed.test.mjs for the
10
11
  // removal contract and tasks/specs/vector-arm-removal.md for the measurements behind it.
11
12
 
12
- import { cjkBigrams } from './utils.mjs';
13
-
14
13
  // ─── Porter Stemmer ──────────────────────────────────────────────────────────
15
14
  // Minimal Porter stemmer (1980). It used to normalize tokens for the TF-IDF vocabulary,
16
15
  // where query and document were both stemmed so the vector arm stayed internally
@@ -188,46 +187,3 @@ export function porterStem(w) {
188
187
 
189
188
  return word;
190
189
  }
191
-
192
- // ─── Tokenization ───────────────────────────────────────────────────────────
193
-
194
- const CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf]/;
195
-
196
- /**
197
- * Tokenize text into stemmed terms.
198
- * ASCII: lowercase + split + Porter stem.
199
- * CJK: reuse cjkBigrams() for consistency with FTS5.
200
- *
201
- * Built for the TF-IDF vocabulary, which is gone; the surviving consumer is
202
- * benchmark/adoption-cosine.mjs, which builds its own bags. The "aligned with FTS5's
203
- * porter tokenizer" claim the old docblock made here is NOT true and was already
204
- * contradicted by the stemmer's own note above — observations_fts uses unicode61 with no
205
- * stemming, so these terms are stems and FTS5's are surface forms.
206
- */
207
- export function tokenize(text) {
208
- if (!text) return [];
209
- text = String(text).toLowerCase();
210
-
211
- const tokens = [];
212
-
213
- // Split into ASCII and CJK segments
214
- const parts = text.split(/([\u4e00-\u9fff\u3400-\u4dbf]+)/);
215
- for (const part of parts) {
216
- if (CJK_RANGE.test(part)) {
217
- // CJK: use bigrams for consistency with FTS5 indexing
218
- const bigrams = cjkBigrams(part);
219
- if (bigrams) {
220
- for (const t of bigrams.split(/\s+/)) {
221
- if (t.length >= 2) tokens.push(t);
222
- }
223
- }
224
- } else {
225
- // ASCII: split on non-alphanumeric, then Porter stem
226
- for (const t of part.split(/[^a-z0-9]+/)) {
227
- if (t.length >= 2) tokens.push(porterStem(t));
228
- }
229
- }
230
- }
231
-
232
- return tokens;
233
- }