claude-mem-lite 3.70.2 → 3.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.70.2",
13
+ "version": "3.72.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.70.2",
3
+ "version": "3.72.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/cli/activity.mjs CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { inferProject } from '../utils.mjs';
12
12
  import { resolveProject } from '../project-utils.mjs';
13
+ import { resolveCliProject as cliProject } from '../lib/cli-project.mjs';
13
14
  import { parseArgs, out, fail, rejectBareStringFlags } from './common.mjs';
14
15
  import { parseIntFlag, isNumericToken } from '../lib/cli-flags.mjs';
15
16
 
@@ -28,7 +29,13 @@ export async function cmdActivity(db, args) {
28
29
  const { positional, flags } = parseArgs(args.slice(1));
29
30
  const { saveEvent, searchEvents, recentEvents, getEvent, EVENT_TYPES, promoteInsightEvents } = await import('../lib/activity.mjs');
30
31
  const VALID_EVENT_TYPES = new Set(EVENT_TYPES);
31
- const project = flags.project ? resolveProject(db, flags.project) : inferProject();
32
+ // `save` CREATES a row, so it keeps plain inferProject(): the DB-aware fallback is a read
33
+ // affordance, and applying it to a write absorbs a not-yet-born subproject's first event
34
+ // into the enclosing repo (pre-tag review, reproduced). Every other subcommand reads or
35
+ // operates on rows that already exist, where falling back is what finds them.
36
+ const project = flags.project
37
+ ? resolveProject(db, flags.project)
38
+ : (sub === 'save' ? inferProject() : cliProject(db));
32
39
 
33
40
  if (sub === 'save') {
34
41
  // Reject value-less string flags before they reach saveEvent as a boolean `true`
package/cli/doctor.mjs CHANGED
@@ -5,13 +5,13 @@
5
5
  // for install health checks). With --benchmark or --metrics it is routed to
6
6
  // mem-cli which delegates to this handler.
7
7
 
8
- import { inferProject } from '../utils.mjs';
8
+ import { resolveCliProject as cliProject } from '../lib/cli-project.mjs';
9
9
  import { out } from './common.mjs';
10
10
 
11
11
  export async function cmdDoctor(db, args) {
12
12
  if (args.includes('--benchmark')) {
13
13
  const { runBenchmark } = await import('../lib/doctor-benchmark.mjs');
14
- const project = inferProject();
14
+ const project = cliProject(db);
15
15
  // Sample recent user prompts so the CLI report has non-null injection_rate
16
16
  // and hook latency. Without this, runBenchmark's prompts default of [] makes
17
17
  // every metric 0/null — a dead command from the user's perspective. Tests
@@ -21,19 +21,25 @@ import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
21
21
  * @param {string} [ctx.sessionId]
22
22
  * @returns {void}
23
23
  */
24
- export function handlePreCompact({ db, project, sessionId }) {
24
+ export function handlePreCompact({ db, project, sessionId, runtimeDir = RUNTIME_DIR }) {
25
25
  try {
26
26
  const collector = {};
27
27
  const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
28
- if (!body || String(body).trim() === '') return;
29
- process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
30
- // Same recorder as handleSessionStart: marker + injection_count bump (D#124).
31
- // A re-render into a compacted context is a fresh injection of those rows.
28
+ const rendered = body && String(body).trim() !== '';
29
+ if (rendered) {
30
+ process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
31
+ }
32
+ // Recorded even when NOTHING was re-rendered, matching handleSessionStart — the two
33
+ // callers must describe the same set (keyctx-marker.mjs header), and the marker is an
34
+ // exclude-set for what is actually in context. The old empty-body early return left the
35
+ // PREVIOUS render's ids standing while compaction removed the block they described, so
36
+ // <memory-context> went on suppressing rows that were no longer shown: D#123 review C-1
37
+ // exactly, on the twin leg. An empty render means an empty exclude-set, not a stale one.
32
38
  recordKeyContextInjection(db, {
33
- runtimeDir: RUNTIME_DIR,
39
+ runtimeDir,
34
40
  project,
35
41
  sessionId: sessionId || null,
36
- ids: collector.keyContextIds || [],
42
+ ids: rendered ? (collector.keyContextIds || []) : [],
37
43
  });
38
44
  } catch (e) {
39
45
  debugCatch(e, 'handlePreCompact');
package/hook.mjs CHANGED
@@ -48,7 +48,7 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
48
48
  import { scrubRecord } from './lib/scrub-record.mjs';
49
49
  import { formatHookError } from './lib/native-binding-hint.mjs';
50
50
  import { recordHookError } from './lib/hook-telemetry.mjs';
51
- import { queueHookContext, flushHookStdout } from './lib/hook-stdout.mjs';
51
+ import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
52
52
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
53
53
  import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
54
54
  import { snapshotDb } from './lib/db-backup.mjs';
@@ -73,7 +73,7 @@ import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs
73
73
  import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
74
74
  import { detectMemOverride } from './lib/mem-override.mjs';
75
75
  import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
76
- import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
76
+ import { recordKeyContextInjection, touchKeyContextMarker } from './lib/keyctx-marker.mjs';
77
77
  import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
78
78
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
79
79
  import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
@@ -1549,7 +1549,12 @@ async function handleSessionStart() {
1549
1549
  let updateCheckDue = false;
1550
1550
  try {
1551
1551
  const banner = getCachedUpdateBanner();
1552
- if (banner) stdoutParts.push(String(banner).trim());
1552
+ // The human channel, not additionalContext: "vX available" is a notice for the
1553
+ // USER. Folding it into additionalContext under suppressOutput:true kept its
1554
+ // content and lost its audience. Claude Code renders a command hook's top-level
1555
+ // systemMessage as its own hook_system_message, independent of the context
1556
+ // block — see lib/hook-stdout.mjs for the bundle evidence.
1557
+ if (banner) queueHookSystemMessage(String(banner));
1553
1558
  updateCheckDue = isUpdateCheckDue();
1554
1559
  } catch (e) { debugCatch(e, 'session-start-update'); }
1555
1560
 
@@ -1755,6 +1760,11 @@ async function handleUserPrompt() {
1755
1760
  if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
1756
1761
  keyContextIds.push(...ids);
1757
1762
  }
1763
+ // The marker's validity is session-lifetime but gcStalePreRecallCooldowns sweeps it
1764
+ // by AGE. Stamping it on read makes that sweep mean "24h with no prompt in this
1765
+ // session" instead of "24h since the render" — otherwise a session running past a
1766
+ // day loses its own exclude-set and re-injects what Key Context is still showing.
1767
+ touchKeyContextMarker({ runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId });
1758
1768
  } catch { /* no marker — nothing was injected, exclude nothing */ }
1759
1769
  const pathAInjectedIds = [];
1760
1770
 
package/install.mjs CHANGED
@@ -1852,6 +1852,44 @@ async function doctor() {
1852
1852
  dwarn('Dev drift: check failed — ' + e.message);
1853
1853
  }
1854
1854
 
1855
+ // Hook scripts: the check above grades SOURCE_FILES, which holds zero `scripts/` entries.
1856
+ // Hook scripts ship from the separate HOOK_SCRIPT_FILES manifest into
1857
+ // ~/.claude-mem-lite/scripts/, and every settings.json hook command names one of those
1858
+ // absolute paths — so "the tarball shipped without scripts/" (source-files.mjs:243) killed
1859
+ // every hook while doctor printed an all-clear. Both classes are issues here; see
1860
+ // checkHookScriptDrift for why the managed-files demote branch must not be copied over.
1861
+ try {
1862
+ // Same gate as the managed-files check: a plugin-only install never deploys into
1863
+ // ~/.claude-mem-lite, and its hooks run from ${CLAUDE_PLUGIN_ROOT}/scripts/ instead.
1864
+ const skipScripts = !shape.managed && !!shape.activePluginVersion;
1865
+ const { checkHookScriptDrift, HOOK_SCRIPT_ENTRY_POINTS } = await import('./lib/doctor-drift.mjs');
1866
+ const h = skipScripts ? null : checkHookScriptDrift(INSTALL_DIR, HOOK_SCRIPT_FILES);
1867
+ const scriptRemedy = `claude-mem-lite self-update (or: node ${join(INSTALL_DIR, 'install.mjs')} repair)`;
1868
+ if (skipScripts) {
1869
+ ok('Hook scripts: n/a (plugin-only install — hooks run from the plugin cache)');
1870
+ } else if (!h.present) {
1871
+ warn(`Hook scripts: ${join(INSTALL_DIR, 'scripts')} `
1872
+ + `${h.dirSymlink ? 'is a dangling symlink' : 'is absent'} — all ${HOOK_SCRIPT_ENTRY_POINTS.size} hook `
1873
+ + `commands name absolute paths under it, so no hook can fire. Fix: ${scriptRemedy}`);
1874
+ issues++;
1875
+ } else if (h.missingCount > 0) {
1876
+ const parts = [];
1877
+ if (h.missingEntryFiles.length > 0) {
1878
+ parts.push(`${h.missingEntryFiles.length} hook entry (${h.missingEntryFiles.join(', ')}) — the command cannot start`);
1879
+ }
1880
+ if (h.missingModuleFiles.length > 0) {
1881
+ parts.push(`${h.missingModuleFiles.length} imported helper (${h.missingModuleFiles.join(', ')}) — ERR_MODULE_NOT_FOUND at hook time`);
1882
+ }
1883
+ warn(`Hook scripts: ${h.missingCount} missing — ${parts.join('; ')}. Fix: ${scriptRemedy}`);
1884
+ issues++;
1885
+ } else {
1886
+ ok(`Hook scripts: ${HOOK_SCRIPT_FILES.length} present `
1887
+ + `(${h.dirSymlink ? 'dev — scripts/ symlinked to the repo' : 'copy install'})`);
1888
+ }
1889
+ } catch (e) {
1890
+ dwarn('Hook scripts: check failed — ' + e.message);
1891
+ }
1892
+
1855
1893
  // Stale temp files
1856
1894
  try {
1857
1895
  // hook-update + the episode workers write runtime/ + staging under DB_DIR
@@ -0,0 +1,125 @@
1
+ // lib/cli-project.mjs — which project a terminal-invoked CLI command should read.
2
+ //
3
+ // `inferProject()` names the directory the process stands in (CLAUDE_PROJECT_DIR || PWD ||
4
+ // cwd). Inside a hook that is always the session root, because Claude Code sets
5
+ // CLAUDE_PROJECT_DIR. In a bare terminal it is not set, so the name follows the user:
6
+ // `cd src/auth && claude-mem-lite recent` asks for `src--auth`, which holds nothing, while
7
+ // the session's hooks have been writing `projects--mem`. The command answers "No recent
8
+ // observations" about a project full of them.
9
+ //
10
+ // Anchoring on the git work-tree root instead was tried and reverted before shipping (see
11
+ // project-utils.mjs): Claude Code started in `mono/packages/api` sets CLAUDE_PROJECT_DIR to
12
+ // the PACKAGE dir, so hooks write `packages--api` while a git anchor sends the CLI to
13
+ // `mono--monorepo` — the same split, mirrored. No purely path-derived rule separates the two
14
+ // cases, because the difference is which name the hooks actually chose. The DB knows.
15
+ //
16
+ // So: compute both candidates, prefer whichever already holds rows, cwd winning ties.
17
+ //
18
+ // READ COMMANDS ONLY. The tie rule ("can only redirect when the cwd-derived name holds
19
+ // NOTHING") reads like a universal safety argument, and pre-tag review reproduced why it is
20
+ // not: for a read, "cwd holds nothing" means there is nothing to lose; for a WRITE it is the
21
+ // normal precondition of a project about to be born. Applying the fallback to save /
22
+ // defer add / restore / import-jsonl absorbed a fresh package dir's first rows into the
23
+ // enclosing repo, and once the session's hooks later wrote `packages--api`, those rows were
24
+ // unreachable from the directory they were written in. Write paths keep plain
25
+ // inferProject(); callers in mem-cli.mjs and cli/activity.mjs mark which is which.
26
+ //
27
+ // Lives here, not in project-utils.mjs: that module is DB-free and on the hook hot path, and
28
+ // hook-side resolution must stay byte-identical (hooks already have the right answer).
29
+
30
+ import { existsSync } from 'fs';
31
+ import { homedir } from 'os';
32
+ import { dirname, join, resolve } from 'path';
33
+ // inferProject through the utils.mjs barrel ON PURPOSE, not projectNameFromDir directly:
34
+ // "what does this process call its project" must keep exactly ONE definition, so anything
35
+ // that later changes it (a config file, a new env var) moves both faces together instead of
36
+ // letting this module drift into a second answer. It is also the seam mem-cli's own tests
37
+ // stub, and a resolver that bypassed it would quietly stop resolving what they exercise.
38
+ import { inferProject } from '../utils.mjs';
39
+ import { projectNameFromDir } from '../project-utils.mjs';
40
+
41
+ // Bounded so a pathological path (symlink loop, very deep tree) cannot spin. 64 levels is
42
+ // far past any real repo checkout; the loop also stops when dirname() reaches a fixed point.
43
+ const MAX_WALK_DEPTH = 64;
44
+
45
+ /**
46
+ * Nearest enclosing git work-tree root, or null.
47
+ *
48
+ * Checks for a `.git` ENTRY, not a directory: a linked worktree and a submodule both put a
49
+ * `.git` FILE at their root, and both are work-tree roots for this purpose.
50
+ *
51
+ * @param {string} startDir
52
+ * @returns {string|null}
53
+ */
54
+ export function findGitRoot(startDir) {
55
+ let dir = resolve(startDir);
56
+ for (let i = 0; i < MAX_WALK_DEPTH; i++) {
57
+ if (existsSync(join(dir, '.git'))) return dir;
58
+ const parent = dirname(dir);
59
+ if (parent === dir) return null; // filesystem root
60
+ dir = parent;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // Memoized per process: every CLI command resolves once, but a single command can ask
66
+ // several times (search resolves for the query, the reranker and the tier window).
67
+ let _cache = new Map();
68
+
69
+ /** Reset the per-process memo (for tests). */
70
+ export function _resetCliProjectCache() { _cache = new Map(); }
71
+
72
+ // Ordered cheapest-signal-last. Each is a distinct way a directory can already BE a project:
73
+ // sdk_sessions — hook.mjs inserts a row on the first SessionStart, long before any
74
+ // observation exists. Without it, a package dir Claude Code had been
75
+ // running in all morning still read as "holds nothing" and the fallback
76
+ // fired on a directory that was already its own project (pre-tag review).
77
+ // deferred_work — `defer add` in a fresh repo writes no observation either.
78
+ // observations — the common case.
79
+ const ROW_PROBES = [
80
+ 'SELECT 1 FROM observations WHERE project = ? LIMIT 1',
81
+ 'SELECT 1 FROM sdk_sessions WHERE project = ? LIMIT 1',
82
+ 'SELECT 1 FROM deferred_work WHERE project = ? LIMIT 1',
83
+ ];
84
+
85
+ function hasRows(db, project) {
86
+ for (const sql of ROW_PROBES) {
87
+ if (db.prepare(sql).get(project)) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ /**
93
+ * The project a CLI command should target when the user gave no --project.
94
+ *
95
+ * @param {import('better-sqlite3').Database} db
96
+ * @param {{dir?: string}} [opts] `dir` overrides the directory to resolve from (tests).
97
+ * @returns {string} Canonical project name
98
+ */
99
+ export function resolveCliProject(db, { dir, homeDir = homedir() } = {}) {
100
+ const base = dir || process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
101
+ // `dir` is a test seam for pointing the walk at a fixture; the real path asks inferProject.
102
+ const cwdName = dir ? projectNameFromDir(dir) : inferProject();
103
+ if (_cache.has(base)) return _cache.get(base);
104
+ let chosen = cwdName;
105
+ try {
106
+ const root = findGitRoot(base);
107
+ // A dotfiles repo at ~/.git puts EVERY directory under home in one work tree, which
108
+ // would make an unrelated scratch dir resolve to the home project. Home is a container,
109
+ // not a project — unless the user is standing in it, in which case it is the ordinary
110
+ // case and the candidates coincide anyway.
111
+ const rootName = root && !(root === resolve(homeDir) && resolve(base) !== resolve(homeDir))
112
+ ? projectNameFromDir(root)
113
+ : null;
114
+ // Only the case where cwd holds nothing can move the answer — see the tie rule above.
115
+ if (rootName && rootName !== cwdName && !hasRows(db, cwdName) && hasRows(db, rootName)) {
116
+ chosen = rootName;
117
+ }
118
+ } catch {
119
+ // Resolution runs before every command; a probe that throws (locked DB, a table an older
120
+ // schema lacks) must degrade to today's behaviour, never take the command down.
121
+ chosen = cwdName;
122
+ }
123
+ _cache.set(base, chosen);
124
+ return chosen;
125
+ }
@@ -19,13 +19,12 @@ import { join } from 'path';
19
19
  // dead weight there — while an absent ENTRY POINT is fatal in every shape, because the
20
20
  // command names that path directly.
21
21
  //
22
- // Scope note (pre-tag review): this classifies only what the CALLER passes in, and
23
- // install.mjs passes SOURCE_FILES, which holds zero `scripts/` entries — hook scripts are
24
- // installed from the separate HOOK_SCRIPT_FILES manifest. An earlier draft mapped those
25
- // into this set; it could never match a single path, so it is gone rather than left as
26
- // inert code implying coverage it does not have. Extending doctor to check the hook-script
27
- // manifest is real work with its own fixture, and is tracked as deferred rather than
28
- // implied here.
22
+ // Scope note: this classifies only what the CALLER passes in, and install.mjs passes
23
+ // SOURCE_FILES, which holds zero `scripts/` entries — hook scripts are installed from the
24
+ // separate HOOK_SCRIPT_FILES manifest. An earlier draft mapped those into this set; it
25
+ // could never match a single path, so it was removed rather than left as inert code
26
+ // implying coverage it did not have. The hook-script manifest now has its own check with
27
+ // its own severity rules: `checkHookScriptDrift` below.
29
28
  const ENTRY_POINTS = new Set([
30
29
  'cli.mjs', 'mem-cli.mjs', 'server.mjs', 'hook.mjs', 'install.mjs',
31
30
  ]);
@@ -72,3 +71,64 @@ export function checkDevDrift(installDir, sourceFiles) {
72
71
  details: plainFiles.slice(0, 5),
73
72
  };
74
73
  }
74
+
75
+ // Hook scripts a hook COMMAND LINE names directly — install.mjs's settings.json template
76
+ // and hooks/hooks.json both spell these paths out. Absent ⇒ the command cannot start.
77
+ // `prompt-search-utils.mjs` is deliberately absent from this set: nothing invokes it, it is
78
+ // imported by user-prompt-search.js. The split drives the message, not the severity — see
79
+ // checkHookScriptDrift.
80
+ //
81
+ // Exported so the set is not a second hand-maintained copy of the hook wiring that can go
82
+ // stale in silence: tests/doctor-hook-script-manifest.test.mjs re-derives it from the
83
+ // `command` strings in hooks/hooks.json and asserts equality, so registering a new hook
84
+ // script without classifying it here goes red.
85
+ export const HOOK_SCRIPT_ENTRY_POINTS = new Set([
86
+ 'post-tool-use.sh', 'user-prompt-search.js', 'pre-tool-recall.js',
87
+ 'post-tool-recall.js', 'pre-skill-bridge.js', 'pre-agent-inject.js',
88
+ 'hook-launcher.mjs',
89
+ ]);
90
+
91
+ /**
92
+ * Integrity of the HOOK_SCRIPT_FILES manifest under `<installDir>/scripts/`.
93
+ *
94
+ * Separate from checkDevDrift because the two manifests install in different SHAPES, and
95
+ * #10686's rule — grade by which path RESOLVES the file — lands differently for each:
96
+ *
97
+ * • dev install: install.mjs symlinks the whole `scripts/` DIRECTORY (one link), not each
98
+ * file. So per-file lstat sees plain files through the link, and "plain file among
99
+ * symlinks = drift" — the signal checkDevDrift is built on — does not exist here at all.
100
+ * Applying it would flag every healthy dev install with 8 phantom drifts. What a missing
101
+ * file means instead: the install dir IS the repo dir, so it is missing from the repo.
102
+ * • copy install: an entry script is named by a command line (dead hook if absent), and
103
+ * user-prompt-search.js resolves `./prompt-search-utils.mjs` against the install dir
104
+ * (ERR_MODULE_NOT_FOUND on every user prompt if absent).
105
+ *
106
+ * Both classes are therefore fatal in both shapes. The entry/module split is kept for the
107
+ * MESSAGE — telling the reader which consequence they have — and must NOT be re-used to
108
+ * demote the module class the way the managed-files check legitimately does.
109
+ *
110
+ * `present:false` covers a scripts/ dir that was never created AND a dangling symlink:
111
+ * existsSync follows links, and the hook commands resolve through it to the same nothing.
112
+ *
113
+ * @param {string} installDir
114
+ * @param {string[]} hookScriptFiles HOOK_SCRIPT_FILES manifest
115
+ */
116
+ export function checkHookScriptDrift(installDir, hookScriptFiles) {
117
+ const scriptsDir = join(installDir, 'scripts');
118
+ let dirSymlink = false;
119
+ try { dirSymlink = lstatSync(scriptsDir).isSymbolicLink(); } catch { /* absent — handled below */ }
120
+ const classify = (missing) => ({
121
+ missingCount: missing.length,
122
+ missingEntryFiles: missing.filter((n) => HOOK_SCRIPT_ENTRY_POINTS.has(n)),
123
+ missingModuleFiles: missing.filter((n) => !HOOK_SCRIPT_ENTRY_POINTS.has(n)),
124
+ });
125
+ if (!existsSync(scriptsDir)) {
126
+ const all = classify([...hookScriptFiles]);
127
+ return { present: false, dirSymlink, ...all };
128
+ }
129
+ const missing = [];
130
+ for (const name of hookScriptFiles) {
131
+ if (!existsSync(join(scriptsDir, name))) missing.push(name);
132
+ }
133
+ return { present: true, dirSymlink, ...classify(missing) };
134
+ }
@@ -26,15 +26,17 @@
26
26
 
27
27
  let parts = [];
28
28
  let queuedEvent = null;
29
+ let systemParts = [];
29
30
 
30
31
  /**
31
32
  * Queue a contribution to this process's single stdout envelope.
32
33
  *
33
34
  * @param {string} hookEventName Event name for hookSpecificOutput.
34
35
  * @param {string} text additionalContext contribution; empty/blank is ignored.
36
+ * @param {{warn?: (msg: string) => void}} [deps]
35
37
  * @returns {void}
36
38
  */
37
- export function queueHookContext(hookEventName, text) {
39
+ export function queueHookContext(hookEventName, text, deps = {}) {
38
40
  if (!hookEventName) return;
39
41
  const body = String(text ?? '').trim();
40
42
  if (!body) return;
@@ -42,11 +44,46 @@ export function queueHookContext(hookEventName, text) {
42
44
  // hookSpecificOutput.hookEventName does not match the event it dispatched.
43
45
  // In practice one process serves one event; keep the first and drop the
44
46
  // stragglers rather than emit an envelope the host rejects outright.
45
- if (queuedEvent && queuedEvent !== hookEventName) return;
47
+ //
48
+ // The drop is NOISY on purpose. It is unreachable today (all call sites are
49
+ // event-consistent), but flushEpisode's hookEventName DEFAULTS to 'PostToolUse',
50
+ // so a future caller that omits the argument would both mis-tag its receipt and
51
+ // have it swallowed without a trace. Silently vanishing work is this repo's
52
+ // most-repeated defect class; stderr is safe here because the host never parses it
53
+ // as the envelope.
54
+ if (queuedEvent && queuedEvent !== hookEventName) {
55
+ const warn = deps.warn || ((m) => { try { process.stderr.write(m); } catch { /* never block on a warning */ } });
56
+ warn(`[claude-mem-lite] hook-stdout: dropped a ${hookEventName} contribution — this process `
57
+ + `already queued ${queuedEvent}, and one envelope carries exactly one hookEventName. `
58
+ + 'This is a wiring bug: the contribution is lost.\n');
59
+ return;
60
+ }
46
61
  queuedEvent = hookEventName;
47
62
  parts.push(body);
48
63
  }
49
64
 
65
+ /**
66
+ * Queue a line for the HUMAN, not the model.
67
+ *
68
+ * Claude Code renders a command hook's top-level `systemMessage` as its own
69
+ * `hook_system_message` conversation message, independently of
70
+ * `hookSpecificOutput.additionalContext` — verified in the 2.1.234 bundle
71
+ * (`if (G.systemMessage) { … yield { message: yc({ type: "hook_system_message", … }) } }`)
72
+ * and documented there as "Display a message to the user (all hooks)". One envelope
73
+ * can therefore carry context for the model AND a notice for the user.
74
+ *
75
+ * Needed because v3.70.0's merge folded the update banner into additionalContext with
76
+ * `suppressOutput: true`, which kept its content and lost its audience.
77
+ *
78
+ * @param {string} text Notice for the user; empty/blank is ignored.
79
+ * @returns {void}
80
+ */
81
+ export function queueHookSystemMessage(text) {
82
+ const body = String(text ?? '').trim();
83
+ if (!body) return;
84
+ systemParts.push(body);
85
+ }
86
+
50
87
  /**
51
88
  * Write the queued contributions as one envelope. Idempotent: a second call
52
89
  * with nothing queued writes nothing, so calling it from both the dispatcher
@@ -56,18 +93,25 @@ export function queueHookContext(hookEventName, text) {
56
93
  * @returns {boolean} true when an envelope was written.
57
94
  */
58
95
  export function flushHookStdout(deps = {}) {
59
- if (!queuedEvent || parts.length === 0) return false;
96
+ const hasContext = queuedEvent && parts.length > 0;
97
+ const hasSystem = systemParts.length > 0;
98
+ if (!hasContext && !hasSystem) return false;
60
99
  const write = deps.write || ((s) => process.stdout.write(s));
61
- const payload = JSON.stringify({
62
- suppressOutput: true,
63
- hookSpecificOutput: {
100
+ const envelope = { suppressOutput: true };
101
+ if (hasSystem) envelope.systemMessage = systemParts.join('\n');
102
+ // Omitted entirely when there is no model-facing context: Stop's schema REJECTS a
103
+ // hookSpecificOutput block, and an envelope carrying only a user notice must not
104
+ // invent an event name to hang one on.
105
+ if (hasContext) {
106
+ envelope.hookSpecificOutput = {
64
107
  hookEventName: queuedEvent,
65
108
  additionalContext: parts.join('\n\n'),
66
- },
67
- }) + '\n';
109
+ };
110
+ }
68
111
  parts = [];
69
112
  queuedEvent = null;
70
- write(payload);
113
+ systemParts = [];
114
+ write(JSON.stringify(envelope) + '\n');
71
115
  return true;
72
116
  }
73
117
 
@@ -75,9 +119,10 @@ export function flushHookStdout(deps = {}) {
75
119
  export function resetHookStdout() {
76
120
  parts = [];
77
121
  queuedEvent = null;
122
+ systemParts = [];
78
123
  }
79
124
 
80
125
  /** Test seam: what is queued right now. */
81
126
  export function peekHookStdout() {
82
- return { hookEventName: queuedEvent, parts: [...parts] };
127
+ return { hookEventName: queuedEvent, parts: [...parts], systemParts: [...systemParts] };
83
128
  }
@@ -18,11 +18,19 @@
18
18
  // Never throws: a marker-write failure must not break context delivery, and it
19
19
  // must not cost the metering either — the bump runs first.
20
20
 
21
- import { writeFileSync } from 'fs';
21
+ import { writeFileSync, statSync, utimesSync } from 'fs';
22
22
  import { join } from 'path';
23
23
  import { debugCatch } from '../utils.mjs';
24
24
  import { keyContextIdsFileName } from './injected-ids.mjs';
25
25
 
26
+ /**
27
+ * How stale the marker's stamp must be before a read refreshes it.
28
+ *
29
+ * The reader runs on every user prompt, so an unconditional touch would be one write per
30
+ * prompt for no gain. One hour is far below the 24h sweep and far above prompt cadence.
31
+ */
32
+ export const KEYCTX_TOUCH_AFTER_MS = 60 * 60 * 1000;
33
+
26
34
  /**
27
35
  * Record one Key Context render: bump the rendered rows, then persist the id
28
36
  * list for the prompt-time exclude-set and the citation extractor.
@@ -72,3 +80,37 @@ export function recordKeyContextInjection(db, { runtimeDir, project, sessionId =
72
80
 
73
81
  return { bumped, written };
74
82
  }
83
+
84
+ /**
85
+ * Refresh an existing marker's mtime so the 24h sweep in hook.mjs measures time since the
86
+ * session last USED it rather than time since the render.
87
+ *
88
+ * The marker's validity is session-lifetime (injected-ids.mjs), but its GC is age-based, on
89
+ * the policy borrowed from the cooldown / injected-ids markers — whose semantics genuinely
90
+ * ARE time-windowed. A session that outlives 24h therefore had its own exclude-set deleted
91
+ * mid-session, after which handleUserPrompt re-injects rows the Key Context block is still
92
+ * showing. Keying the sweep on session liveness instead does not work: hook.mjs marks any
93
+ * session older than STALE_SESSION_MS 'abandoned' by started_at_epoch, so the long session
94
+ * reads as dead there too. Still being read is the signal that separates the two.
95
+ *
96
+ * Only refreshes an EXISTING file: a missing marker means "nothing was injected, exclude
97
+ * nothing", and fabricating an empty one would invent an exclude-set for a session whose
98
+ * block may well be on screen. Never throws — it runs in the user-prompt hot path.
99
+ *
100
+ * @param {{runtimeDir: string, project: string, sessionId?: string|null}} ctx
101
+ * @param {number} [nowMs] injectable clock
102
+ * @returns {boolean} true when the stamp was moved
103
+ */
104
+ export function touchKeyContextMarker({ runtimeDir, project, sessionId = null } = {}, nowMs = Date.now()) {
105
+ if (!runtimeDir || !project) return false;
106
+ try {
107
+ const p = join(runtimeDir, keyContextIdsFileName(project, sessionId));
108
+ if (nowMs - statSync(p).mtimeMs <= KEYCTX_TOUCH_AFTER_MS) return false;
109
+ const stamp = new Date(nowMs);
110
+ utimesSync(p, stamp, stamp);
111
+ return true;
112
+ } catch (e) {
113
+ debugCatch(e, 'keyctx-marker-touch');
114
+ return false;
115
+ }
116
+ }
package/mem-cli.mjs CHANGED
@@ -6,6 +6,14 @@ import { homedir } from 'os';
6
6
  import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
7
7
  import { truncate, typeIcon, inferProject, scrubSecrets, COMPRESSED_PENDING_PURGE } from './utils.mjs';
8
8
  import { resolveProject } from './project-utils.mjs';
9
+ // READ commands resolve the project DB-aware: a subdirectory whose own name holds no rows
10
+ // falls back to the enclosing work-tree root, so `cd src/auth && … recent` reads what the
11
+ // session's hooks wrote. WRITE commands (save / defer add / restore / import-jsonl) keep
12
+ // plain inferProject() — pre-tag review reproduced the reason: for a read, "cwd holds
13
+ // nothing" means there is nothing to lose, but for a write it is the normal precondition of
14
+ // a project about to be born, and absorbing it into the enclosing repo strands the row once
15
+ // the session's hooks start writing the subdirectory's own name. Hook-side is untouched.
16
+ import { resolveCliProject as cliProject } from './lib/cli-project.mjs';
9
17
  import { _resetVocabCache, vecTextForRow, vectorsEnabled } from './tfidf.mjs';
10
18
  import { reRankWithContext } from './search-scoring.mjs';
11
19
  import { searchObservationsHybrid } from './search-engine.mjs';
@@ -174,7 +182,7 @@ async function cmdSearch(db, args, { llm } = {}) {
174
182
  const emitDeferredTrailer = () => {
175
183
  if (!wantDeferredTrailer) return;
176
184
  try {
177
- const rows = searchDeferredWork(db, query, project || inferProject());
185
+ const rows = searchDeferredWork(db, query, project || cliProject(db));
178
186
  for (const line of formatDeferredSearchTrailer(rows, 'claude-mem-lite get D#<id>')) out(line);
179
187
  } catch { /* trailer is best-effort; never break search */ }
180
188
  };
@@ -236,7 +244,7 @@ async function cmdSearch(db, args, { llm } = {}) {
236
244
 
237
245
  const res = await coreRunSearchPipeline(
238
246
  {
239
- db, currentProject: project ? null : inferProject(), env: process.env,
247
+ db, currentProject: project ? null : cliProject(db), env: process.env,
240
248
  searchObservationsHybrid, deepSearch, shouldEscalateToDeep, autoDeepLlmReady,
241
249
  reRankWithContext, llm,
242
250
  },
@@ -249,11 +257,11 @@ async function cmdSearch(db, args, { llm } = {}) {
249
257
  obsTypeFallback: false, // #8217 removed list-by-type fallback from the CLI
250
258
  crossSourceEpochSortNoFts: false, // CLI never reaches cross-source with empty ftsQuery (fails earlier)
251
259
  rerankPolicy: 'cli', // re-rank/supersede on any obs; re-sort gated on cross-source
252
- rerankProject: project || inferProject(),
260
+ rerankProject: project || cliProject(db),
253
261
  recentListingNoFts: false,
254
262
  tolerateMissingFts: true, // pre-FTS legacy DBs: swallow session/prompt FTS errors
255
263
  tierPosition: 'early', // tier filter inside the obs block (before sessions/prompts)
256
- tierProject: project || inferProject(),
264
+ tierProject: project || cliProject(db),
257
265
  }
258
266
  );
259
267
  const isDeep = res.isDeep;
@@ -404,7 +412,7 @@ function cmdRecent(db, args) {
404
412
  const limit = isValid
405
413
  ? rawLimit
406
414
  : parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: RECENT_MAX });
407
- const project = flags.project ? resolveProject(db, flags.project) : inferProject();
415
+ const project = flags.project ? resolveProject(db, flags.project) : cliProject(db);
408
416
  const jsonOutput = flags.json === true || flags.json === 'true';
409
417
 
410
418
  // `recent --type bugfix` previously parsed as a silent no-op — users naturally
@@ -1061,7 +1069,7 @@ function cmdDeferAdd(db, args) {
1061
1069
 
1062
1070
  function cmdDeferList(db, args) {
1063
1071
  const { flags } = parseArgs(args);
1064
- const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1072
+ const project = flags.project ? resolveProject(db, flags.project) : cliProject(db);
1065
1073
  const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 100 });
1066
1074
  const list = listOpenWithOrdinal(db, project, limit);
1067
1075
  if (list.length === 0) {
@@ -1100,7 +1108,7 @@ function cmdDeferDrop(db, args) {
1100
1108
  // without N shell invocations.
1101
1109
  const rawTokens = idStr.split(',').map(s => s.trim()).filter(Boolean);
1102
1110
  const tokens = rawTokens.map(t => /^\d+$/.test(t) ? parseInt(t, 10) : t);
1103
- const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1111
+ const project = flags.project ? resolveProject(db, flags.project) : cliProject(db);
1104
1112
 
1105
1113
  let realIds;
1106
1114
  try {
@@ -1329,7 +1337,7 @@ function cmdContext(db, args) {
1329
1337
  // Generate context live from DB — same builder the SessionStart hook uses.
1330
1338
  // Pre-v2.30 this command parsed a snapshot out of CLAUDE.md, but the hook no
1331
1339
  // longer writes there; DB is now the single source of truth.
1332
- const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1340
+ const project = flags.project ? resolveProject(db, flags.project) : cliProject(db);
1333
1341
  const block = buildSessionContextLines(db, project).trim();
1334
1342
 
1335
1343
  if (!block) {
@@ -1369,7 +1377,7 @@ function cmdContext(db, args) {
1369
1377
 
1370
1378
  function cmdBrowse(db, args) {
1371
1379
  const { flags } = parseArgs(args);
1372
- const project = flags.project ? resolveProject(db, flags.project) : inferProject();
1380
+ const project = flags.project ? resolveProject(db, flags.project) : cliProject(db);
1373
1381
  const tierFilter = flags.tier || null;
1374
1382
  if (tierFilter && !['working', 'active', 'archive'].includes(tierFilter)) {
1375
1383
  fail(`[mem] Invalid tier: "${tierFilter}". Use: working, active, or archive`);
@@ -2783,7 +2791,7 @@ Commands:
2783
2791
  (aliases: backfill search_aliases on substantive rows that
2784
2792
  lack them — incl. lesson-bearing manual saves — adds ONLY
2785
2793
  aliases, never rewrites title/narrative/lesson)
2786
- --project P Limit to a single project (.|current = inferProject())
2794
+ --project P Limit to a single project (.|current = the current project)
2787
2795
  --verbose / -v Preview also dumps cluster contents + re-enrich samples
2788
2796
 
2789
2797
  doctor Environment diagnostics and benchmarks
@@ -3130,12 +3138,12 @@ async function cmdOptimize(db, args) {
3130
3138
  }
3131
3139
  // --project <name> filters all 4 tasks to one project. Opt-in; absence
3132
3140
  // preserves prior cross-project default. `.` or `current` auto-resolve via
3133
- // inferProject() so users don't need to remember the exact name.
3141
+ // the CLI project resolver so users don't need to remember the exact name.
3134
3142
  const projectIdx = args.indexOf('--project');
3135
3143
  let project;
3136
3144
  if (projectIdx >= 0 && args[projectIdx + 1]) {
3137
3145
  const raw = args[projectIdx + 1];
3138
- project = (raw === '.' || raw === 'current') ? inferProject() : raw;
3146
+ project = (raw === '.' || raw === 'current') ? cliProject(db) : raw;
3139
3147
  }
3140
3148
 
3141
3149
  if (!run && !runAll) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.70.2",
3
+ "version": "3.72.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.70.2",
9
+ "version": "3.72.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.70.2",
3
+ "version": "3.72.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",
@@ -57,6 +57,7 @@
57
57
  "lib/startup-dashboard.mjs",
58
58
  "lib/doctor-benchmark.mjs",
59
59
  "lib/doctor-drift.mjs",
60
+ "lib/cli-project.mjs",
60
61
  "lib/stats-quality.mjs",
61
62
  "lib/low-signal-patterns.mjs",
62
63
  "lib/private-strip.mjs",
package/project-utils.mjs CHANGED
@@ -29,7 +29,22 @@ const _cache = new Map();
29
29
  * @returns {string} Sanitized project identifier safe for use in filenames
30
30
  */
31
31
  export function inferProject() {
32
- const p = process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
32
+ return projectNameFromDir(process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd());
33
+ }
34
+
35
+ /**
36
+ * The naming rule alone, applied to an arbitrary directory: "parent--basename", sanitized.
37
+ *
38
+ * Split out of inferProject() so lib/cli-project.mjs can build its second candidate (the git
39
+ * work-tree root) with THIS rule rather than a copy of it — two copies of the rule would let
40
+ * the CLI face and the hook face drift apart on the next sanitization change, which is the
41
+ * exact class of bug this module's candidate-selection exists to close. Still DB-free and
42
+ * allocation-cheap, so the hook hot path is unaffected.
43
+ *
44
+ * @param {string} p Absolute directory path
45
+ * @returns {string} Sanitized project identifier safe for use in filenames
46
+ */
47
+ export function projectNameFromDir(p) {
33
48
  const base = basename(p);
34
49
  const parent = basename(dirname(p));
35
50
  const raw = parent && parent !== '.' && parent !== '/' ? `${parent}--${base}` : base;
@@ -32,6 +32,15 @@ const ROOT = process.env.PROBE_ROOT || join(dirname(fileURLToPath(import.meta.ur
32
32
  // broken flag, and a helperless broken tree is repaired by the hook-launcher
33
33
  // path instead. Out of process like every other probe here — loading a stale
34
34
  // .node caches a dead module handle for the rest of THIS process.
35
+ // Output-identical twin of lib/binding-probe.mjs::flattenBindingError, kept here
36
+ // because bareProbe runs when lib/ could not be imported. Same 240 cap, same
37
+ // ellipsis, same 'unknown' floor — asserted for parity by the tests.
38
+ function flattenLocal(err, max = 240) {
39
+ const s = String(err ?? '').replace(/\s+/g, ' ').trim();
40
+ if (!s) return 'unknown';
41
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
42
+ }
43
+
35
44
  function bareProbe(root) {
36
45
  const script =
37
46
  'try {'
@@ -49,11 +58,23 @@ function bareProbe(root) {
49
58
  //
50
59
  // Flattening is inlined, NOT lib/binding-probe.mjs::flattenBindingError, because
51
60
  // this function is the fallback for a tree where lib/ failed to import — `helpers`
52
- // is still null on every path that reaches here. Duplicated deliberately; keep the
53
- // two in step.
54
- const why = String(r.stdout || '').replace(/\s+/g, ' ').trim().slice(0, 240)
55
- || (r.error && r.error.message)
56
- || `probe exited ${r.status ?? `on signal ${r.signal}`}`;
61
+ // is still null on every path that reaches here.
62
+ //
63
+ // The twin must stay byte-identical in OUTPUT, and it did not: the first draft
64
+ // capped with a bare `.slice(0, 240)` while the shared helper appends an ellipsis,
65
+ // so they already disagreed at the one boundary the duplication exists to protect.
66
+ // A comment is not a guard, and this repo's hand-maintained twins have drifted
67
+ // before. tests/binding-error-diagnosis.test.mjs now drives THIS path in a
68
+ // lib/-less tree and asserts parity with the shared helper.
69
+ // Order matters: flattenLocal floors to the string 'unknown', which is truthy, so
70
+ // `flattenLocal(x) || fallback` would make the fallbacks unreachable and swallow a
71
+ // spawn error or an exit code whenever the child printed nothing. Pick the source
72
+ // FIRST, then flatten it.
73
+ const printed = String(r.stdout || '').trim();
74
+ const spawnErr = r.error && r.error.message;
75
+ const why = printed ? flattenLocal(printed)
76
+ : spawnErr ? flattenLocal(spawnErr)
77
+ : `probe exited ${r.status ?? `on signal ${r.signal}`}`;
57
78
  process.stderr.write(`[claude-mem-lite] binding probe: ${why}\n`);
58
79
  return false;
59
80
  }
@@ -20,9 +20,32 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
20
20
  });
21
21
  process.stderr.write('[claude-mem-lite] Dependencies installed\n');
22
22
  } catch (e) {
23
- // Plugin-cache / multi-user / disk-full installs can fail here. Without this
24
- // catch the user sees a Node stack trace; with it they get an actionable line.
25
- const detail = e.message?.split('\n')[0] || e.code || 'unknown error';
23
+ // Plugin-cache / multi-user / disk-full installs can fail here, and this is not a
24
+ // rare path: Claude Code materializes each new plugin-cache version WITHOUT
25
+ // node_modules, so the guard above opens on the first MCP launch after every
26
+ // plugin update. Without this catch the user sees a Node stack trace.
27
+ //
28
+ // `.split('\n')[0]` is CORRECT here, unlike the four binding-error sites fixed in
29
+ // v3.70.2, and the difference is the `stdio` above: stderr is **inherit**, so
30
+ // npm's own diagnosis (`npm error code EROFS`, `path …`, `rofs EROFS: read-only
31
+ // file system …`) has already streamed straight to the user's terminal by the time
32
+ // we get here — verified by running this file against an unwritable ROOT. With
33
+ // stderr inherited, execSync's `e.message` holds only "Command failed: <cmd>";
34
+ // there is no captured diagnosis to lose. Do NOT "fix" this by piping stderr to
35
+ // recover it: piping is what made a compiling better-sqlite3 look hung under the
36
+ // 5-min bash timeout (bug audit 2026-05), which is why stderr is inherited.
37
+ //
38
+ // A pre-tag review measured `e.message` under `stdio: 'pipe'`, where stderr IS
39
+ // folded into the message, and concluded this line drops the diagnosis. It does
40
+ // not — the stdio differs. Recorded here because the same wrong conclusion is
41
+ // easy to reach from the code alone.
42
+ //
43
+ // `e.status` not `e.code`: execSync failures carry the exit status on `status`,
44
+ // so the old `|| e.code` rung was dead.
45
+ const detail = e?.message?.split('\n')[0]
46
+ || (e?.status != null ? `npm exited ${e.status}` : '')
47
+ || (e?.signal ? `npm killed by ${e.signal}` : '')
48
+ || 'unknown error';
26
49
  process.stderr.write(`[claude-mem-lite] npm install failed in ${ROOT} — ${detail}\n`);
27
50
  process.stderr.write(`[claude-mem-lite] Likely cause: read-only directory, disk full, or network blocked.\n`);
28
51
  process.stderr.write(`[claude-mem-lite] Repair: cd "${ROOT}" && npm install --omit=dev\n`);
package/source-files.mjs CHANGED
@@ -45,6 +45,10 @@ export const SOURCE_FILES = [
45
45
  'lib/startup-dashboard.mjs',
46
46
  'lib/doctor-benchmark.mjs',
47
47
  'lib/doctor-drift.mjs',
48
+ // DB-aware project pick for terminal-invoked CLI commands. Statically imported by
49
+ // mem-cli.mjs, cli/activity.mjs and cli/doctor.mjs — ship it or every CLI command
50
+ // throws ERR_MODULE_NOT_FOUND in installed/tarball runtimes.
51
+ 'lib/cli-project.mjs',
48
52
  'lib/stats-quality.mjs',
49
53
  'lib/low-signal-patterns.mjs',
50
54
  'lib/private-strip.mjs',