claude-mem-lite 6.8.3 → 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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.8.3",
12
+ "version": "6.9.0",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
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)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.8.3",
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
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -128,7 +128,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
128
128
  - **FTS integrity management** -- `mem_fts_check` tool verifies FTS5 index health or rebuilds indexes on demand, useful after database recovery or when search results seem wrong
129
129
  - **Atomic multi-table writes** -- `saveObservation` wraps the observations + observation_files INSERTs in a single `db.transaction()`, preventing orphaned rows on crash
130
130
  - **Modular NLP pipeline** -- Synonym maps, stop words, scoring constants, and query building extracted into focused modules (`synonyms.mjs`, `stop-words.mjs`, `scoring-sql.mjs`, `nlp.mjs`) for independent testing and maintenance
131
- - **Porter-aligned PRF** -- Pseudo-relevance feedback terms are now stemmed with the same Porter algorithm used by FTS5, ensuring PRF expansion terms match the search index
131
+ - **Surface-form PRF expansion** -- `observations_fts` is built on FTS5's default `unicode61` tokenizer, so the index is **not stemmed**: a query term matches the word forms actually stored, and `crash` does not match a row that only contains `crashes`. Pseudo-relevance feedback therefore uses the Porter stemmer only to *bucket* morphological variants when judging which candidate terms are discriminative, and emits the most frequent **surface** form of each — emitting a bare stem (`cach`) would match nothing and kill expansion recall
132
132
 
133
133
  ## Platform Support
134
134
 
@@ -735,7 +735,7 @@ claude-mem-lite/
735
735
  hook-semaphore.mjs # LLM concurrency control: file-based semaphore for background workers
736
736
  schema.mjs # Database schema: single source of truth for tables, migrations, FTS5
737
737
  tool-schemas.mjs # Shared Zod schemas for MCP tool validation
738
- tfidf.mjs # tokenization + Porter stemming (name is historical: the TF-IDF vector engine it held was removed)
738
+ tfidf.mjs # the Porter stemmer (name is historical: the TF-IDF vector engine it held was removed)
739
739
  tier.mjs # Temporal tier system: activity-based time window classification
740
740
  utils.mjs # Re-export hub: backward-compatible surface for all utility modules
741
741
  nlp.mjs # FTS5 query building: synonym expansion, CJK bigrams, sanitization
package/cli/common.mjs CHANGED
@@ -83,7 +83,120 @@ export function parseArgs(argv) {
83
83
  i++;
84
84
  }
85
85
  }
86
- return { positional, flags };
86
+ return { positional, flags: trackFlagReads(flags) };
87
+ }
88
+
89
+ // ─── Inert selection flags ───────────────────────────────────────────────────
90
+ //
91
+ // The third cause of the harm parseArgs' docblock names twice. `--include_noise` (underscore
92
+ // spelling) and `--obs_type` (an MCP field name) both parsed, matched no reader, and let the
93
+ // command answer the unfiltered question; both are fixed above. `suggestUnknownFlags` fixes a
94
+ // third case, flags unknown to the whole CLI. What is left is the case where NOTHING is
95
+ // misspelled: `--type` is canonical and real — on `search`, `save` and `export` — so it clears
96
+ // the global known-flag set, and `browse` simply never reads it. Measured before the fix:
97
+ // `browse --type bugfix` printed rows of every type, exit 0, not a word.
98
+ //
99
+ // The check is read-tracking, not a per-command flag manifest, and that is the whole design.
100
+ // A manifest rots, and worse, it cannot see a flag that a command forwards wholesale into a
101
+ // core helper (`cmdSearch` hands the object to the pipeline, `cmdExport` to the writer) —
102
+ // deriving "which flags does this command read" from its own body would fire on every one of
103
+ // those. Watching what the object is actually ASKED for is exact in both directions.
104
+ //
105
+ // Scope is SELECTION-shaped flags only. Those are the ones whose silent drop hands back a
106
+ // wider set that reads as the answer, which is the harm. A `--confirm` that a short-circuited
107
+ // branch never reached is deliberately out of scope: nothing there is wrong, and a warning on
108
+ // correct usage is worse than the silence it replaces.
109
+ //
110
+ // `prompts-limit` is NOT here, and the reason generalises: `doctor --benchmark --prompts-limit`
111
+ // is read off raw `process.argv` in cli/doctor.mjs and never touches a flags object, so
112
+ // read-tracking would call a working flag inert. Anything read off argv must stay out.
113
+ export const FILTER_FLAGS = new Set([
114
+ 'type',
115
+ 'source',
116
+ 'project',
117
+ 'tier',
118
+ 'since',
119
+ 'from',
120
+ 'to',
121
+ 'branch',
122
+ 'scope',
123
+ 'importance',
124
+ 'limit',
125
+ 'offset',
126
+ 'sort',
127
+ 'days',
128
+ 'age-days',
129
+ // Inclusion toggles, added after the same correct-usage sweep the first batch passed. They
130
+ // widen or narrow the SET rather than filter within it, and the harm runs the other way:
131
+ // an ignored `--include-noise` hands back FEWER rows than asked for, and "I searched and it
132
+ // was not there" is the worst answer a memory tool can give. Held back at first only
133
+ // because booleans are read inside branches and were the likelier false-alarm shape; the
134
+ // exit-code gate below turned out to cover that class, and the sweep reads zero.
135
+ 'all',
136
+ 'include-noise',
137
+ 'include-compressed',
138
+ 'deep',
139
+ 'no-deep',
140
+ 'or',
141
+ 'rerank',
142
+ ]);
143
+
144
+ let suppliedFlags = new Set();
145
+ let readFlags = new Set();
146
+
147
+ /**
148
+ * Wrap a parsed flags object so every lookup is recorded.
149
+ *
150
+ * `get` and `has` are both trapped: a reader spelled `if ('tier' in flags)` must count as a
151
+ * read exactly like `flags.tier`. `ownKeys` deliberately is NOT — `Object.keys(flags)` is
152
+ * enumeration, not consumption, and `suggestUnknownFlags` does exactly that on its own parse.
153
+ */
154
+ function trackFlagReads(flags) {
155
+ for (const k of Object.keys(flags)) suppliedFlags.add(k);
156
+ return new Proxy(flags, {
157
+ get(target, prop, recv) {
158
+ if (typeof prop === 'string') readFlags.add(prop);
159
+ return Reflect.get(target, prop, recv);
160
+ },
161
+ has(target, prop) {
162
+ if (typeof prop === 'string') readFlags.add(prop);
163
+ return Reflect.has(target, prop);
164
+ },
165
+ });
166
+ }
167
+
168
+ /** Start a fresh recording. `run()` calls this per invocation so tests can drive it in a loop. */
169
+ export function resetFlagTracking() {
170
+ suppliedFlags = new Set();
171
+ readFlags = new Set();
172
+ }
173
+
174
+ /**
175
+ * Selection flags the user supplied that nothing looked at, sorted.
176
+ *
177
+ * Read AFTER the command has finished — a flag consumed late (inside a branch, or by a helper
178
+ * the command awaits) has still been read, and reporting it early would be a false alarm.
179
+ * @returns {string[]}
180
+ */
181
+ export function inertFilterFlags() {
182
+ return [...suppliedFlags].filter((f) => FILTER_FLAGS.has(f) && !readFlags.has(f)).sort();
183
+ }
184
+
185
+ /**
186
+ * The sentence the user gets. Says what happened to their results, not what the parser did:
187
+ * "ignored" alone leaves them to work out whether the output is still the answer they asked
188
+ * for. It is not.
189
+ * @param {string} cmd
190
+ * @param {string[]} inert
191
+ * @returns {string}
192
+ */
193
+ export function inertFilterFlagNotice(cmd, inert) {
194
+ const names = inert.map((f) => `--${f}`).join(' and ');
195
+ const verb = inert.length > 1 ? 'were' : 'was';
196
+ return (
197
+ `[mem] ${names} ${verb} ignored — \`${cmd}\` does not filter on ${inert.length > 1 ? 'them' : 'it'}, ` +
198
+ `so the results above are UNFILTERED. Run "claude-mem-lite help" for the flags this command reads.`
199
+ );
87
200
  }
88
201
 
89
202
  // ─── Output Helpers ──────────────────────────────────────────────────────────
package/cli.mjs CHANGED
@@ -81,6 +81,28 @@ function fileFromError(e) {
81
81
  return quoted ? quoted[1] : null;
82
82
  }
83
83
 
84
+ /**
85
+ * Is this `doctor` invocation one of the DB-layer modes?
86
+ *
87
+ * DYNAMIC on purpose. A static import here would put lib/doctor-modes.mjs in the LAUNCHER's
88
+ * load graph, and a missing file there kills cli.mjs before any of its own error handling
89
+ * exists — the user gets a raw ERR_MODULE_NOT_FOUND instead of "this install is incomplete,
90
+ * run repair". tests/doctor-startup-closure.test.mjs caught exactly that when the import was
91
+ * static. Same rule as "a recovery path must not import the thing it recovers", applied to
92
+ * the entry point: nothing the launcher needs before it can speak may be a hard edge.
93
+ * install.mjs may import it statically — that failure is caught by loadInstaller() below.
94
+ */
95
+ async function isDoctorDbMode() {
96
+ try {
97
+ const { DOCTOR_DB_MODES } = await import('./lib/doctor-modes.mjs');
98
+ return process.argv.slice(3).some((a) => DOCTOR_DB_MODES.some((m) => a === `--${m}`));
99
+ } catch {
100
+ // Unreadable module → treat as the plain install health check, which is the branch that
101
+ // can still explain a broken install.
102
+ return false;
103
+ }
104
+ }
105
+
84
106
  async function loadInstaller() {
85
107
  let mod;
86
108
  try {
@@ -129,10 +151,7 @@ if (cmd === '--version' || cmd === '-v' || cmd === '-V' || cmd === 'version') {
129
151
  } else if (cmd === '--help' || cmd === '-h') {
130
152
  const { run } = await import('./mem-cli.mjs');
131
153
  await run(['help']);
132
- } else if (
133
- cmd === 'doctor' &&
134
- process.argv.slice(3).some((a) => a === '--benchmark' || a === '--metrics' || a === '--session-audit')
135
- ) {
154
+ } else if (cmd === 'doctor' && (await isDoctorDbMode())) {
136
155
  // Per #8217: the DB-layer doctor modes (--benchmark / --metrics / --session-audit,
137
156
  // each implemented in cli/doctor.mjs) route to mem-cli. Everything else — plain
138
157
  // `doctor`, `doctor --` (POSIX end-of-options), and `doctor --json` — stays with
package/format-utils.mjs CHANGED
@@ -25,6 +25,36 @@ export function truncate(str, max = 80) {
25
25
  return str.slice(0, end) + '\u2026';
26
26
  }
27
27
 
28
+ /**
29
+ * Longest query echoed back verbatim in a result label. Long enough that no query a human
30
+ * or an agent actually types is touched — the shapes that exceed it are pasted stack traces,
31
+ * file dumps and multi-paragraph questions.
32
+ */
33
+ export const QUERY_LABEL_MAX = 200;
34
+
35
+ /**
36
+ * A query as it should appear in output handed back to whoever asked.
37
+ *
38
+ * Every search surface labels its answer with the query (`Found N result(s) for "<query>"`),
39
+ * which is how a caller confirms what was actually searched. Unbounded, that made the size
40
+ * of the answer track the size of the question: a 50,000-character query produced 50,024
41
+ * characters of CLI output, and the MCP face — with no argv ceiling — carried the whole
42
+ * thing back into the model's context. For a tool whose purpose is to spend context
43
+ * carefully, returning several KB of the caller's own input is the budget it was invoked to
44
+ * protect.
45
+ *
46
+ * Bounded, not silently truncated: the real length travels with the prefix, so the label
47
+ * still answers the question it exists for. `truncate` handles the surrogate-pair boundary.
48
+ *
49
+ * @param {string} query
50
+ * @returns {string}
51
+ */
52
+ export function queryLabel(query) {
53
+ if (typeof query !== 'string') return '';
54
+ if (query.length <= QUERY_LABEL_MAX) return query;
55
+ return `${truncate(query, QUERY_LABEL_MAX)} [query truncated; ${query.length} chars]`;
56
+ }
57
+
28
58
  // Two delimiter classes are defanged here:
29
59
  // 1. The blocks claude-mem-lite wraps injected context in (claude-mem-context /
30
60
  // memory-context / session-handoff). User-derived text containing one LITERALLY
package/hook-episode.mjs CHANGED
@@ -18,12 +18,21 @@ import { inferProject, EDIT_TOOLS } from './utils.mjs';
18
18
  import { RUNTIME_DIR } from './hook-shared.mjs';
19
19
 
20
20
  /**
21
- * Read episode file without locking (for signal handlers only).
21
+ * Read the episode buffer WITHOUT holding the lock: the dying-process salvage in hook.mjs's
22
+ * signal handler, and the snapshot Stop / SessionStart take before they flush.
23
+ *
24
+ * Same body as `readEpisode` on purpose — the two names record which locking contract the
25
+ * CALLER is under, and neither function takes a lock itself. What must not differ is the
26
+ * PATH, so both go through `episodeFile()`. This one used to re-spell it inline, which put
27
+ * the buffer's name in three places (here, `episodeFile`, and the signal handler's unlink)
28
+ * and left the salvage path — the one that runs while the process is dying, and the hardest
29
+ * to notice when it is wrong — as the only one not reading the accessor.
30
+ *
22
31
  * @returns {object|null} Parsed episode or null on failure
23
32
  */
24
33
  export function readEpisodeRaw() {
25
34
  try {
26
- return JSON.parse(readFileSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`), 'utf8'));
35
+ return JSON.parse(readFileSync(episodeFile(), 'utf8'));
27
36
  } catch {
28
37
  return null;
29
38
  }
package/hook-llm.mjs CHANGED
@@ -924,7 +924,7 @@ export async function handleLLMEpisode() {
924
924
  type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
925
925
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
926
926
  importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
927
- lesson_learned: The non-obvious insight a future session would benefit from. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". Look hard before giving up — most coding episodes contain at least one micro-lesson (an undocumented flag, a surprising default, a debugging shortcut, an unexpected interaction). If literally no insight worth teaching (e.g. version bump, whitespace fix, file rename), output JSON null. Do NOT invent a lesson, do NOT write the strings "none"/"n/a"/"todo"/"tbd"/"-" — those will be discarded as noise.
927
+ lesson_learned: The non-obvious insight a future session would benefit from. Examples: "FTS5's default tokenizer doesn't split CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". Look hard before giving up — most coding episodes contain at least one micro-lesson (an undocumented flag, a surprising default, a debugging shortcut, an unexpected interaction). If literally no insight worth teaching (e.g. version bump, whitespace fix, file rename), output JSON null. Do NOT invent a lesson, do NOT write the strings "none"/"n/a"/"todo"/"tbd"/"-" — those will be discarded as noise.
928
928
  scope: ${SCOPE_PROMPT_LEGEND}
929
929
  search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
930
930
 
package/hook.mjs CHANGED
@@ -288,8 +288,13 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
288
288
  if (db) {
289
289
  try {
290
290
  for (const sub of planEpisodeFlush(ep)) saveEpisodeImmediate(sub, db);
291
+ // episodeFile(), not a second spelling of `ep-<project>.json`: this is a
292
+ // DESTRUCTIVE step on the buffer the salvage just persisted, and a path that
293
+ // drifts from the accessor deletes nothing while reporting success — the next
294
+ // fire then salvages the same entries again. Every other flush path
295
+ // (PostToolUse, Stop, SessionStart) already unlinks through the accessor.
291
296
  try {
292
- unlinkSync(join(RUNTIME_DIR, `ep-${inferProject()}.json`));
297
+ unlinkSync(episodeFile());
293
298
  } catch {}
294
299
  } finally {
295
300
  try {
package/install.mjs CHANGED
@@ -55,6 +55,7 @@ const HOOK_PATH = join(INSTALL_DIR, 'hook.mjs');
55
55
  // P2-7: both constants and the predicate come from lib/plugin-key.mjs, which hook.mjs also
56
56
  // imports — this pair used to be typed out in each.
57
57
  import { MARKETPLACE_KEY, PLUGIN_KEY, PLUGIN_NAME, isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
58
+ import { doctorDbModeHint } from './lib/doctor-modes.mjs';
58
59
  const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
59
60
 
60
61
  import {
@@ -70,7 +71,7 @@ import {
70
71
  nativeBindingRepairHint,
71
72
  isNativeBindingError,
72
73
  } from './lib/binding-probe.mjs';
73
- import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
74
+ import { detectInstallShape, probeRuntimeRoots, hasAnyManagedCode } from './lib/install-shape.mjs';
74
75
  import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
75
76
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
76
77
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
@@ -2366,6 +2367,24 @@ async function doctor() {
2366
2367
  // was ever deployed there, so every entry reads as "missing" and this reported
2367
2368
  // `⚠ Managed files: 121 missing` + an issue on a correct install — prescribing
2368
2369
  // a repair against a path that does not exist.
2370
+ // ...and a THIRD state under the same `!shape.managed`: nothing was ever deployed here.
2371
+ // Both checks below otherwise prescribe `repair`, which re-syncs an install from the signed
2372
+ // release and runs from `<INSTALL_DIR>/cli.mjs` — one of the very entry points whose absence
2373
+ // produced the verdict, so on this shape it hands the reader a command that cannot start.
2374
+ // Damaged (some of the managed files survive) and never-deployed (none do) are different
2375
+ // populations with opposite commands, the same conflation the plugin-only branch above fixed
2376
+ // once already. Declared out here because the hook-script check needs it too and a `const`
2377
+ // inside the try below is not in scope there.
2378
+ //
2379
+ // The population is SOURCE_FILES, not the two entry points. Asking about the entry points
2380
+ // alone made this verdict a claim about two files while the message it gates says "none
2381
+ // present" about all of them: an install holding cli.mjs and every lib/ module, with only
2382
+ // server.mjs and hook.mjs gone, was reported as a data directory with no install behind it
2383
+ // — and sent to re-`install` instead of `repair`, which was runnable from the cli.mjs
2384
+ // already there.
2385
+ const noCodeInstall =
2386
+ !shape.managed && !shape.activePluginVersion && !hasAnyManagedCode(INSTALL_DIR, SOURCE_FILES);
2387
+ const installRemedy = `node ${join(PROJECT_DIR, 'install.mjs')} install`;
2369
2388
  try {
2370
2389
  const skipDrift = !shape.managed && !!shape.activePluginVersion;
2371
2390
  const { checkDevDrift } = await import('./lib/doctor-drift.mjs');
@@ -2436,9 +2455,13 @@ async function doctor() {
2436
2455
  // self-updater is `self-update`. Naming the wrong one sent the user to a
2437
2456
  // usage error at the exact moment their install was incomplete.
2438
2457
  issueWarn(
2439
- `Managed files: ${r.missingCount} missing (${parts.join('; ')}) — a copy install resolves ` +
2440
- `imports against the install dir, so these throw at hook time. Fix: claude-mem-lite self-update ` +
2441
- `(or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`,
2458
+ noCodeInstall
2459
+ ? `Managed files: no claude-mem-lite code is deployed in ${INSTALL_DIR} (${r.missingCount} ` +
2460
+ `file(s) absent, none present) — this is a data directory with no install behind it, not ` +
2461
+ `a damaged one. Fix: ${installRemedy}`
2462
+ : `Managed files: ${r.missingCount} missing (${parts.join('; ')}) — a copy install resolves ` +
2463
+ `imports against the install dir, so these throw at hook time. Fix: claude-mem-lite self-update ` +
2464
+ `(or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`,
2442
2465
  );
2443
2466
  }
2444
2467
  // Complete copy install: no message — drift is a dev-install concern.
@@ -2462,7 +2485,11 @@ async function doctor() {
2462
2485
  // missing files, and install.mjs is the one entry that cannot survive that —
2463
2486
  // its static imports resolve before its first statement. cli.mjs has no static
2464
2487
  // local imports and catches the failure (D#26). Same route, same command.
2465
- const scriptRemedy = `claude-mem-lite self-update (or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`;
2488
+ // Never-deployed gets the install command instead, for the reason spelled out at
2489
+ // `noCodeInstall` above: the `repair` route runs from an entry point that is itself absent.
2490
+ const scriptRemedy = noCodeInstall
2491
+ ? installRemedy
2492
+ : `claude-mem-lite self-update (or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`;
2466
2493
  if (skipScripts) {
2467
2494
  ok('Hook scripts: n/a (plugin-only install — hooks run from the plugin cache)');
2468
2495
  } else if (!h.present) {
@@ -2671,7 +2698,17 @@ async function doctor() {
2671
2698
  ),
2672
2699
  );
2673
2700
  } else {
2674
- console.log(`\n ${buildDoctorSummary(issues, warnings)}\n`);
2701
+ console.log(`\n ${buildDoctorSummary(issues, warnings)}`);
2702
+ // This run checked the INSTALL. The DB-layer modes are a different implementation reached
2703
+ // through the same command name, and nothing else told the user they exist -- a healthy
2704
+ // install with bad retrieval read "All checks passed!" and ended there. Derived from
2705
+ // DOCTOR_DB_MODES so it cannot become a second list to forget. Text only: the exit-code
2706
+ // contract `claude-mem-lite doctor || alert` depends on is untouched.
2707
+ // Phrased as prose, not as `doctor a | b | c`: a line that looks like a command gets
2708
+ // copy-pasted, and `|` is a shell pipe. See doctorDbModeHint()'s note.
2709
+ console.log(
2710
+ ` Deeper checks (database layer): run \`claude-mem-lite doctor\` with ${doctorDbModeHint()}\n`,
2711
+ );
2675
2712
  }
2676
2713
  // Diagnostic-tool exit-code contract: any ✗-level finding must propagate non-zero
2677
2714
  // so CI / wrapper scripts (`claude-mem-lite doctor || alert`) actually trip. Keeps
@@ -0,0 +1,31 @@
1
+ // lib/doctor-modes.mjs — the DB-layer `doctor` modes, in one place.
2
+ //
3
+ // `doctor` is one command name with two implementations: install.mjs's health check, which
4
+ // owns `--json`, and cli/doctor.mjs's DB-layer modes. cli.mjs decides between them by looking
5
+ // for one of these flags, and its comment used to say so with a warning attached — "Adding a
6
+ // NEW DB-layer mode requires extending this list — a deliberate trade for a working --json".
7
+ // A mode added to cli/doctor.mjs and not to that list is answered silently by the install
8
+ // check instead, which is a command answering as a different command.
9
+ //
10
+ // Three consumers now read this instead of spelling the list:
11
+ // cli.mjs — the router condition
12
+ // install.mjs — the pointer plain `doctor` prints, so the modes are discoverable at all
13
+ // tests/doctor-mode-router-sync.test.mjs — pins it against what cli/doctor.mjs implements
14
+ //
15
+ // A zero-dependency leaf on purpose. install.mjs is a recovery path and must not import
16
+ // anything that drags a load graph behind it (the lesson lib/data-paths.mjs exists for).
17
+ export const DOCTOR_DB_MODES = ['benchmark', 'metrics', 'session-audit'];
18
+
19
+ /**
20
+ * The modes as PROSE — `--benchmark, --metrics or --session-audit`.
21
+ *
22
+ * Not `a | b | c`. A line shaped like a command invites a copy-paste, and `|` is a shell
23
+ * pipe: pasting the first draft produced `bash: --metrics: command not found`. That is the
24
+ * same defect 32c8923 fixed one commit earlier in this branch (a remedy that named a binary
25
+ * which could not run), reintroduced two commits later in a different spelling. The wording
26
+ * around it has to make clear this is a list of options, not a command line.
27
+ */
28
+ export function doctorDbModeHint() {
29
+ const flags = DOCTOR_DB_MODES.map((m) => `--${m}`);
30
+ return `${flags.slice(0, -1).join(', ')} or ${flags[flags.length - 1]}`;
31
+ }
@@ -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
@@ -6,7 +6,14 @@ 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
8
  import { isFtsCorruptionError, FTS_CORRUPTION_REMEDY } from './lib/db-unusable.mjs';
9
- import { truncate, typeIcon, inferProject, scrubSecrets, COMPRESSED_PENDING_PURGE } from './utils.mjs';
9
+ import {
10
+ truncate,
11
+ queryLabel,
12
+ typeIcon,
13
+ inferProject,
14
+ scrubSecrets,
15
+ COMPRESSED_PENDING_PURGE,
16
+ } from './utils.mjs';
10
17
  import { resolveProject } from './project-utils.mjs';
11
18
  // READ commands resolve the project DB-aware: a subdirectory whose own name holds no rows
12
19
  // falls back to the enclosing work-tree root, so `cd src/auth && … recent` reads what the
@@ -99,6 +106,9 @@ import {
99
106
  rejectBareStringFlags,
100
107
  resolvePositionalAlias,
101
108
  suggestUnknownFlags,
109
+ resetFlagTracking,
110
+ inertFilterFlags,
111
+ inertFilterFlagNotice,
102
112
  OBS_TIME_FIELDS,
103
113
  formatObsFieldValue,
104
114
  obsFieldLabel,
@@ -191,7 +201,7 @@ function emitRecallHint(db, query) {
191
201
  const n = countRecallableByFile(db, q);
192
202
  if (n > 0) {
193
203
  out(`[mem] ${n} observation(s) are linked to that file — search indexes text, not file paths.`);
194
- out(`[mem] Try: claude-mem-lite recall "${q}"`);
204
+ out(`[mem] Try: claude-mem-lite recall "${queryLabel(q)}"`);
195
205
  }
196
206
  } catch {
197
207
  /* hint is best-effort; never break search */
@@ -273,12 +283,13 @@ async function cmdSearch(db, args, { llm } = {}) {
273
283
  // Haiku call + N hybrid searches; observations-only. NOT the passive path — this
274
284
  // is the explicit "search harder" lever for vocabulary-mismatch recall misses.
275
285
  // --deep forces deep; --no-deep forces normal; neither = unset (env/default decide).
276
- const explicitDeep =
277
- flags.deep === true || flags.deep === 'true'
278
- ? true
279
- : flags['no-deep'] === true || flags['no-deep'] === 'true'
280
- ? false
281
- : 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;
282
293
  const deepMode = resolveDeepMode(explicitDeep, { surface: 'cli' });
283
294
 
284
295
  // --rerank: opt-in LLM rerank of the fused top-20 (option C, deep-search.mjs).
@@ -326,7 +337,7 @@ async function cmdSearch(db, args, { llm } = {}) {
326
337
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: false, results: [] }));
327
338
  } else {
328
339
  emitDeferredTrailer();
329
- fail(`[mem] No valid search terms in "${query}"`);
340
+ fail(`[mem] No valid search terms in "${queryLabel(query)}"`);
330
341
  }
331
342
  return;
332
343
  }
@@ -477,7 +488,7 @@ async function cmdSearch(db, args, { llm } = {}) {
477
488
  }),
478
489
  );
479
490
  } else {
480
- out(`[mem] No results for "${query}"`);
491
+ out(`[mem] No results for "${queryLabel(query)}"`);
481
492
  emitRecallHint(db, query);
482
493
  // The zero-result path is where the trailer earns its keep — the D#92
483
494
  // failure chain was exactly "searched, found nothing, item was deferred".
@@ -515,7 +526,7 @@ async function cmdSearch(db, args, { llm } = {}) {
515
526
  }),
516
527
  );
517
528
  } else {
518
- out(`[mem] No results for "${query}" at offset ${offset}`);
529
+ out(`[mem] No results for "${queryLabel(query)}" at offset ${offset}`);
519
530
  }
520
531
  return;
521
532
  }
@@ -576,7 +587,7 @@ async function cmdSearch(db, args, { llm } = {}) {
576
587
  // Pluralize on total — "Found 1 of 44 result" reads wrong; the population (44) drives
577
588
  // grammatical number, not the page slice (1).
578
589
  out(
579
- `[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)' : ''}`,
580
591
  );
581
592
  // `~Nt` = est. tokens to fetch this row's full body via mem_get (attachBodyTokens, paired with
582
593
  // MCP). Conditional so a row that skipped enrichment renders cleanly, not "~undefinedt".
@@ -949,7 +960,16 @@ function cmdGet(db, args) {
949
960
  sections.push(dRows.map(formatDeferredDetail).join('\n\n'));
950
961
  totalFound += dRows.length;
951
962
  }
952
- 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) {
953
973
  process.stderr.write(
954
974
  `[mem] Deferred item(s) not found: ${deferredMissing.map((i) => `D#${i}`).join(', ')}\n`,
955
975
  );
@@ -3593,7 +3613,57 @@ import { cmdActivity } from './cli/activity.mjs';
3593
3613
  import { DAY_MS } from './lib/time-constants.mjs';
3594
3614
  // ─── Main Entry Point ────────────────────────────────────────────────────────
3595
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
+
3596
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) {
3597
3667
  const cmd = argv[0];
3598
3668
  const cmdArgs = argv.slice(1);
3599
3669
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.8.3",
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.3",
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.3",
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,
@@ -420,13 +420,18 @@ function formatSearchOutput(
420
420
  'This is a recall miss (the rewrite ran), not a query-syntax issue; the memory likely has no related observations.',
421
421
  );
422
422
  } else if (args.query && !ftsQuery) {
423
- 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).`);
424
424
  hint.push('Tip: use content words instead of operators (AND, OR, NOT, NEAR).');
425
425
  } else {
426
426
  hint.push('No results found.');
427
427
  if (args.query) {
428
428
  const expanded = ftsQuery || args.query;
429
- 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)}`);
430
435
  hint.push('Tip: check spelling, try broader terms, or use mem_stats to see available data.');
431
436
  }
432
437
  }
@@ -449,7 +454,7 @@ function formatSearchOutput(
449
454
  );
450
455
  // P2-6: empty/omitted query falls through to a "listing recent" path — label it explicitly
451
456
  // so callers don't mistake BM25-less results for relevance-ranked ones.
452
- const qLabel = args.query ? ` for "${args.query}"` : ' (no query — listing recent)';
457
+ const qLabel = args.query ? ` for "${queryLabel(args.query)}"` : ' (no query — listing recent)';
453
458
  // Surface AND→OR fallback so callers (incl. Claude) know a strict multi-term
454
459
  // query actually matched only a subset of the terms. Suppressed when the caller
455
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
- }
package/utils.mjs CHANGED
@@ -9,37 +9,35 @@ import { buildLowSignalRegex } from './lib/low-signal-patterns.mjs';
9
9
  import { scrubSecrets as _scrubSecrets } from './secret-scrub.mjs';
10
10
 
11
11
  // ─── Re-exports from extracted modules ──────────────────────────────────────
12
- // Backward compatibility: all consumers import from utils.mjs
13
-
12
+ // Backward compatibility: consumers that predate the extraction import from utils.mjs.
13
+ //
14
+ // The barrel carries what in-tree code actually imports THROUGH it, and nothing else.
15
+ // "Backward compatible" here means compatible with this repo's own call sites: there is no
16
+ // `main`/`exports` in package.json, so nothing outside the tree can import this module, and
17
+ // a re-export with no importer is not a compatibility guarantee — it is a name knip has to
18
+ // keep reporting. Eleven were dropped 2026-09-14 after both knip (whose entry set includes
19
+ // tests, so a test-only consumer would have kept them) and a per-name import scan covering
20
+ // named, namespace, dynamic and re-export forms agreed nothing imports them from here.
21
+ // Each one is still exported by its own leaf module, where its real callers import it.
22
+ // Same disposal the `extractResponseFromError` docblock records for the same reason.
14
23
  export {
15
24
  DECAY_HALF_LIFE_BY_TYPE,
16
25
  DEFAULT_DECAY_HALF_LIFE_MS,
17
26
  OBS_BM25,
18
27
  SESS_BM25,
19
28
  EVT_BM25,
20
- TYPE_DECAY_CASE,
21
29
  TYPE_QUALITY_CASE,
22
30
  OBS_FTS_COLUMNS,
23
31
  notLowSignalTitleClause,
24
32
  noisePenaltyClause,
25
33
  } from './scoring-sql.mjs';
26
- export {
27
- cjkBigrams,
28
- extractCjkSynonymTokens,
29
- extractCjkLikePatterns,
30
- SYNONYM_MAP,
31
- expandToken,
32
- sanitizeFtsQuery,
33
- ftsQueryTokens,
34
- relaxFtsQueryToOr,
35
- FTS_STOP_WORDS,
36
- CJK_COMPOUNDS,
37
- } from './nlp.mjs';
38
- export { inferProject, resolveProject, _resetProjectCache } from './project-utils.mjs';
39
- export { scrubSecrets, SECRET_PATTERNS } from './secret-scrub.mjs';
34
+ export { cjkBigrams, sanitizeFtsQuery, ftsQueryTokens, relaxFtsQueryToOr } from './nlp.mjs';
35
+ export { inferProject } from './project-utils.mjs';
36
+ export { scrubSecrets } from './secret-scrub.mjs';
40
37
  export { stripPrivate } from './lib/private-strip.mjs';
41
38
  export {
42
39
  truncate,
40
+ queryLabel,
43
41
  typeIcon,
44
42
  fmtDate,
45
43
  fmtTime,
@@ -51,7 +49,6 @@ export { computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity } from '.
51
49
  export {
52
50
  detectBashSignificance,
53
51
  extractErrorKeywords,
54
- planErrorRecall,
55
52
  extractFilePaths,
56
53
  stripTestSuffix,
57
54
  } from './bash-utils.mjs';