claude-mem-lite 6.8.3 → 6.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.8.3",
12
+ "version": "6.9.1",
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.1",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/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 {
@@ -114,6 +136,46 @@ const INSTALL_COMMANDS = new Set([
114
136
  'release',
115
137
  ]);
116
138
 
139
+ // A reader that leaves is not an error. `claude-mem-lite search x | head -1`,
140
+ // `| grep -q`, or quitting `less` closes the read end while we are still writing;
141
+ // Node then emits 'error' on the stdout Socket, and with no listener that is an
142
+ // UNHANDLED error event — a ~20-line stack ending in `outVerbatim` where the user
143
+ // expected the shell prompt.
144
+ //
145
+ // WHICH COMMANDS, measured rather than generalised (20 trials each, `| head -1`,
146
+ // pre-fix): `search`, `export`, `recent`, `stats`, `doctor`, `timeline`,
147
+ // `citation-stats` 20/20; `browse` 19/20; `help`, `status`, `context`, `get`,
148
+ // `memdir-audit` 0/20. So NOT "every stdout-bearing command" — what decides it is
149
+ // whether a write is still pending when the reader goes, which depends on how many
150
+ // lines the consumer takes and how the output is batched — NOT on the 64 KB pipe
151
+ // buffer, which an earlier draft of this comment blamed: pre-ship review found
152
+ // `stats` crashing at `head -20` on an output far under it. That output's size is
153
+ // corpus dependent, so no byte count is quoted here. This is also why the crash
154
+ // survived so long — it is invisible to exactly the pipe depths a smoke test picks.
155
+ //
156
+ // Lives HERE, at the published `bin`, and not at `cli/common.mjs`'s `out()`: the
157
+ // crash reproduces on `doctor` too, whose writes are `console.log` inside
158
+ // install.mjs, so a chokepoint fix would cover the CLI half and leave the installer
159
+ // half loud. One process-level listener covers both routes below.
160
+ //
161
+ // SWALLOW, DO NOT EXIT. The first cut called `process.exit(0)` here, on the
162
+ // reasoning that a CLI whose consumer has gone should stop rather than serialise a
163
+ // whole-DB `export` into a dead pipe. Pre-ship review measured what that costs:
164
+ // `doctor | head -1` under `pipefail` exited 0 on 10/10 runs while the same doctor
165
+ // exits 1 unpiped, because `runDoctor` assigns `process.exitCode = 1` AFTER its last
166
+ // print (install.mjs, "Diagnostic-tool exit-code contract") and the forced exit lands
167
+ // first. That silently turns a failing `claude-mem-lite doctor || alert` — the
168
+ // wrapper that contract names — into a passing one. `process.exitCode ?? 0` does not
169
+ // rescue it: the verdict does not exist yet at kill time. Returning instead reads
170
+ // exit 1 on 10/10 and keeps the crash fixed (doctor 0/10, search 0/10 EPIPE stacks).
171
+ // Correctness over the saved work: the process finishes into a pipe nobody reads,
172
+ // which is wasted effort but never a wrong answer. Non-EPIPE is rethrown — this is a
173
+ // classifier, not a blanket swallow, the same charter `explainBrokenInstall` follows.
174
+ process.stdout.on('error', (err) => {
175
+ if (err && err.code === 'EPIPE') return;
176
+ throw err;
177
+ });
178
+
117
179
  const cmd = process.argv[2];
118
180
 
119
181
  // `version` and `-V` are aliases, not extra syntax: the bare subcommand is what a user
@@ -129,10 +191,7 @@ if (cmd === '--version' || cmd === '-v' || cmd === '-V' || cmd === 'version') {
129
191
  } else if (cmd === '--help' || cmd === '-h') {
130
192
  const { run } = await import('./mem-cli.mjs');
131
193
  await run(['help']);
132
- } else if (
133
- cmd === 'doctor' &&
134
- process.argv.slice(3).some((a) => a === '--benchmark' || a === '--metrics' || a === '--session-audit')
135
- ) {
194
+ } else if (cmd === 'doctor' && (await isDoctorDbMode())) {
136
195
  // Per #8217: the DB-layer doctor modes (--benchmark / --metrics / --session-audit,
137
196
  // each implemented in cli/doctor.mjs) route to mem-cli. Everything else — plain
138
197
  // `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
@@ -44,6 +44,15 @@ const MEM_RUNTIME_DIR = resolveRuntimeDir(MEM_DATA_DIR);
44
44
  const DB_PATH = join(MEM_DATA_DIR, 'claude-mem-lite.db');
45
45
  const OLD_DATA_DIR = join(homedir(), '.claude-mem');
46
46
 
47
+ // The two directories `createCliSymlink` can land the `claude-mem-lite` command in, in the
48
+ // order it tries them. Uninstall already swept exactly this pair as an inline literal, and
49
+ // `status` now has to ask the same question ("is the command installed somewhere, just not
50
+ // on PATH?"), so the list is named once rather than typed a third time. `createCliSymlink`
51
+ // itself is deliberately NOT rewritten to iterate it: its shape is primary-then-fallback
52
+ // with different remedies per branch, and flattening that into a loop is a refactor wearing
53
+ // a constant's clothes.
54
+ const CLI_BIN_DIRS = [join(homedir(), '.local', 'bin'), '/usr/local/bin'];
55
+
47
56
  // Detect ephemeral context (npx) — files won't persist after exit
48
57
  const IS_NPX =
49
58
  process.env.npm_command === 'exec' || PROJECT_DIR.includes('_npx') || PROJECT_DIR.includes('.npm/_');
@@ -55,6 +64,7 @@ const HOOK_PATH = join(INSTALL_DIR, 'hook.mjs');
55
64
  // P2-7: both constants and the predicate come from lib/plugin-key.mjs, which hook.mjs also
56
65
  // imports — this pair used to be typed out in each.
57
66
  import { MARKETPLACE_KEY, PLUGIN_KEY, PLUGIN_NAME, isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
67
+ import { doctorDbModeHint } from './lib/doctor-modes.mjs';
58
68
  const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
59
69
 
60
70
  import {
@@ -70,7 +80,7 @@ import {
70
80
  nativeBindingRepairHint,
71
81
  isNativeBindingError,
72
82
  } from './lib/binding-probe.mjs';
73
- import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
83
+ import { detectInstallShape, probeRuntimeRoots, hasAnyManagedCode } from './lib/install-shape.mjs';
74
84
  import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
75
85
  import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
76
86
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
@@ -1270,7 +1280,7 @@ async function uninstall() {
1270
1280
  if (!removedAny) warn('MCP server not found or already removed');
1271
1281
 
1272
1282
  // 1b. Remove CLI symlink
1273
- for (const binDir of [join(homedir(), '.local', 'bin'), '/usr/local/bin']) {
1283
+ for (const binDir of CLI_BIN_DIRS) {
1274
1284
  const cliLink = join(binDir, 'claude-mem-lite');
1275
1285
  // No try/catch: clearLinkPath swallows a permissions failure and returns false, so the
1276
1286
  // wrapper this used to have was unreachable once the existsSync gate moved inside it.
@@ -1653,14 +1663,70 @@ async function status() {
1653
1663
  push('warn', 'database', 'Database: not found', { exists: false });
1654
1664
  }
1655
1665
 
1656
- // CLI
1666
+ // CLI.
1667
+ //
1668
+ // The probe resolves a BARE name, so a failure conflates three different worlds and the
1669
+ // old single remedy ("run install again to create symlink") was correct in only one of
1670
+ // them. On the common one — `createCliSymlink` put a working link in ~/.local/bin, a
1671
+ // directory a non-login shell frequently does not have on PATH — the advice sends the
1672
+ // user to re-run an installer that will create the very symlink that already exists,
1673
+ // report ✓, and leave `status` saying the same thing. Advice that cannot converge is
1674
+ // worse than the silence it replaced.
1675
+ //
1676
+ // Split on `err.code`: ENOENT is "the name did not resolve" and is the only world the
1677
+ // symlink question applies to. Anything else means the command WAS found and then failed
1678
+ // or timed out (a broken native binding is the live example), where naming PATH is a
1679
+ // second wrong answer — report what actually happened instead.
1680
+ // `linked` is a NEW field on this check, and `--json` republishes every extra key
1681
+ // (`const { level, key, message, ...extra }` below), so it is part of that face's output,
1682
+ // not an internal detail. Nothing in this repo reads it; external consumers of
1683
+ // `status --json` now see `linked: <path>|null`.
1657
1684
  try {
1658
1685
  execFileSync('claude-mem-lite', ['--help'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
1659
1686
  push('ok', 'cli', 'CLI: claude-mem-lite command available', { available: true });
1660
- } catch {
1661
- push('warn', 'cli', 'CLI: command not on PATH — run install again to create symlink', {
1662
- available: false,
1663
- });
1687
+ } catch (e) {
1688
+ if (e && e.code !== 'ENOENT') {
1689
+ // `e.message` already CARRIES the child's stderr — with stdio:'pipe' Node formats it
1690
+ // as "Command failed: <cmd>\n<stderr>", so the live example (a broken native binding,
1691
+ // whose `nativeBindingRepairHint` line is on stderr) reaches the user unaided.
1692
+ // Measured, because pre-ship review asserted the opposite and a redundant `e.stderr`
1693
+ // suffix was written and then withdrawn: printing both duplicates the text.
1694
+ // Note `e.code` is UNDEFINED for a non-zero exit — only a spawn failure sets ENOENT —
1695
+ // so `!== 'ENOENT'` is what routes this branch, not a truthiness check on the code.
1696
+ push('warn', 'cli', `CLI: on PATH but "claude-mem-lite --help" failed — ${e.message}`, {
1697
+ available: false,
1698
+ linked: null,
1699
+ });
1700
+ } else {
1701
+ // Two properties of `existsSync` matter here and they pull in opposite directions:
1702
+ // - it FOLLOWS the link, so a DANGLING one reads as absent. That is the answer we
1703
+ // want: a link pointing at a deleted install is the installer's problem, not
1704
+ // PATH's, and falls through to the reinstall remedy.
1705
+ // - it is also true for a DIRECTORY of that name, which would make us print
1706
+ // "installed at … add it to PATH" about something that can never be executed —
1707
+ // the exact non-converging advice this block exists to stop. Hence isFile().
1708
+ const isLinkedCli = (d) => {
1709
+ try {
1710
+ return statSync(join(d, 'claude-mem-lite')).isFile();
1711
+ } catch {
1712
+ return false; // ENOENT (absent or dangling), EACCES on the dir, anything else
1713
+ }
1714
+ };
1715
+ const binDir = CLI_BIN_DIRS.find(isLinkedCli);
1716
+ if (binDir) {
1717
+ push(
1718
+ 'warn',
1719
+ 'cli',
1720
+ `CLI: installed at ${join(binDir, 'claude-mem-lite')} but ${binDir} is not on PATH — add it: export PATH="${binDir}:$PATH"`,
1721
+ { available: false, linked: join(binDir, 'claude-mem-lite') },
1722
+ );
1723
+ } else {
1724
+ push('warn', 'cli', 'CLI: command not on PATH — run install again to create symlink', {
1725
+ available: false,
1726
+ linked: null,
1727
+ });
1728
+ }
1729
+ }
1664
1730
  }
1665
1731
 
1666
1732
  // Old system
@@ -2366,6 +2432,24 @@ async function doctor() {
2366
2432
  // was ever deployed there, so every entry reads as "missing" and this reported
2367
2433
  // `⚠ Managed files: 121 missing` + an issue on a correct install — prescribing
2368
2434
  // a repair against a path that does not exist.
2435
+ // ...and a THIRD state under the same `!shape.managed`: nothing was ever deployed here.
2436
+ // Both checks below otherwise prescribe `repair`, which re-syncs an install from the signed
2437
+ // release and runs from `<INSTALL_DIR>/cli.mjs` — one of the very entry points whose absence
2438
+ // produced the verdict, so on this shape it hands the reader a command that cannot start.
2439
+ // Damaged (some of the managed files survive) and never-deployed (none do) are different
2440
+ // populations with opposite commands, the same conflation the plugin-only branch above fixed
2441
+ // once already. Declared out here because the hook-script check needs it too and a `const`
2442
+ // inside the try below is not in scope there.
2443
+ //
2444
+ // The population is SOURCE_FILES, not the two entry points. Asking about the entry points
2445
+ // alone made this verdict a claim about two files while the message it gates says "none
2446
+ // present" about all of them: an install holding cli.mjs and every lib/ module, with only
2447
+ // server.mjs and hook.mjs gone, was reported as a data directory with no install behind it
2448
+ // — and sent to re-`install` instead of `repair`, which was runnable from the cli.mjs
2449
+ // already there.
2450
+ const noCodeInstall =
2451
+ !shape.managed && !shape.activePluginVersion && !hasAnyManagedCode(INSTALL_DIR, SOURCE_FILES);
2452
+ const installRemedy = `node ${join(PROJECT_DIR, 'install.mjs')} install`;
2369
2453
  try {
2370
2454
  const skipDrift = !shape.managed && !!shape.activePluginVersion;
2371
2455
  const { checkDevDrift } = await import('./lib/doctor-drift.mjs');
@@ -2436,9 +2520,13 @@ async function doctor() {
2436
2520
  // self-updater is `self-update`. Naming the wrong one sent the user to a
2437
2521
  // usage error at the exact moment their install was incomplete.
2438
2522
  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)`,
2523
+ noCodeInstall
2524
+ ? `Managed files: no claude-mem-lite code is deployed in ${INSTALL_DIR} (${r.missingCount} ` +
2525
+ `file(s) absent, none present) — this is a data directory with no install behind it, not ` +
2526
+ `a damaged one. Fix: ${installRemedy}`
2527
+ : `Managed files: ${r.missingCount} missing (${parts.join('; ')}) — a copy install resolves ` +
2528
+ `imports against the install dir, so these throw at hook time. Fix: claude-mem-lite self-update ` +
2529
+ `(or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`,
2442
2530
  );
2443
2531
  }
2444
2532
  // Complete copy install: no message — drift is a dev-install concern.
@@ -2462,7 +2550,11 @@ async function doctor() {
2462
2550
  // missing files, and install.mjs is the one entry that cannot survive that —
2463
2551
  // its static imports resolve before its first statement. cli.mjs has no static
2464
2552
  // 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)`;
2553
+ // Never-deployed gets the install command instead, for the reason spelled out at
2554
+ // `noCodeInstall` above: the `repair` route runs from an entry point that is itself absent.
2555
+ const scriptRemedy = noCodeInstall
2556
+ ? installRemedy
2557
+ : `claude-mem-lite self-update (or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`;
2466
2558
  if (skipScripts) {
2467
2559
  ok('Hook scripts: n/a (plugin-only install — hooks run from the plugin cache)');
2468
2560
  } else if (!h.present) {
@@ -2671,7 +2763,17 @@ async function doctor() {
2671
2763
  ),
2672
2764
  );
2673
2765
  } else {
2674
- console.log(`\n ${buildDoctorSummary(issues, warnings)}\n`);
2766
+ console.log(`\n ${buildDoctorSummary(issues, warnings)}`);
2767
+ // This run checked the INSTALL. The DB-layer modes are a different implementation reached
2768
+ // through the same command name, and nothing else told the user they exist -- a healthy
2769
+ // install with bad retrieval read "All checks passed!" and ended there. Derived from
2770
+ // DOCTOR_DB_MODES so it cannot become a second list to forget. Text only: the exit-code
2771
+ // contract `claude-mem-lite doctor || alert` depends on is untouched.
2772
+ // Phrased as prose, not as `doctor a | b | c`: a line that looks like a command gets
2773
+ // copy-pasted, and `|` is a shell pipe. See doctorDbModeHint()'s note.
2774
+ console.log(
2775
+ ` Deeper checks (database layer): run \`claude-mem-lite doctor\` with ${doctorDbModeHint()}\n`,
2776
+ );
2675
2777
  }
2676
2778
  // Diagnostic-tool exit-code contract: any ✗-level finding must propagate non-zero
2677
2779
  // 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
  *