claude-mem-lite 3.71.0 → 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.71.0",
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.71.0",
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
@@ -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';
@@ -1760,6 +1760,11 @@ async function handleUserPrompt() {
1760
1760
  if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
1761
1761
  keyContextIds.push(...ids);
1762
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 });
1763
1768
  } catch { /* no marker — nothing was injected, exclude nothing */ }
1764
1769
  const pathAInjectedIds = [];
1765
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
+ }
@@ -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.71.0",
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.71.0",
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.71.0",
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;
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',