claude-mem-lite 3.95.1 → 3.96.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.95.1",
13
+ "version": "3.96.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.95.1",
3
+ "version": "3.96.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.mjs CHANGED
@@ -4,7 +4,11 @@ const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'c
4
4
 
5
5
  const cmd = process.argv[2];
6
6
 
7
- if (cmd === '--version' || cmd === '-v') {
7
+ // `version` and `-V` are aliases, not extra syntax: the bare subcommand is what a user
8
+ // types first (`claude-mem-lite version`), and it is far enough from every real command
9
+ // name that the edit-distance suggester below fell through to the generic
10
+ // "Run help / Run install" line — a wrong answer to a question the CLI can answer.
11
+ if (cmd === '--version' || cmd === '-v' || cmd === '-V' || cmd === 'version') {
8
12
  const { readFileSync } = await import('fs');
9
13
  const { fileURLToPath } = await import('url');
10
14
  const { dirname, join } = await import('path');
package/hook-update.mjs CHANGED
@@ -1061,11 +1061,33 @@ function copyReleaseIntoStaging(sourceDir, stagingDir, manifest = { SOURCE_FILES
1061
1061
  function hasInstallManagedSettingsHooks() {
1062
1062
  const settingsPath = join(homedir(), '.claude', 'settings.json');
1063
1063
  if (!existsSync(settingsPath)) return false;
1064
- try {
1065
- const s = JSON.parse(readFileSync(settingsPath, 'utf8'));
1066
- const serialized = JSON.stringify(s.hooks || {});
1067
- return serialized.includes('.claude-mem-lite/') || serialized.includes('/claude-mem-lite/');
1068
- } catch { return false; }
1064
+ let s;
1065
+ try { s = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { return false; }
1066
+ const serialized = JSON.stringify(s.hooks || {});
1067
+ if (!(serialized.includes('.claude-mem-lite/') || serialized.includes('/claude-mem-lite/'))) return false;
1068
+ // Liveness, mirroring plugin-cache-guard.hasLiveInstallManagedHooks (see its docblock).
1069
+ // The string test alone says settings.json MENTIONS a path of ours, not that the path
1070
+ // still exists — and a stale entry left by a removed global install fires nothing while
1071
+ // making this function authorise emptying the plugin manifest that does. Narrow by
1072
+ // construction: only a command we parsed a path out of, ALL of whose paths are gone,
1073
+ // flips the answer; an unfamiliar shape yields no path and keeps the old result.
1074
+ let checked = 0;
1075
+ for (const matchers of Object.values(s?.hooks || {})) {
1076
+ if (!Array.isArray(matchers)) continue;
1077
+ for (const m of matchers) {
1078
+ for (const h of (Array.isArray(m?.hooks) ? m.hooks : [])) {
1079
+ const c = typeof h?.command === 'string' ? h.command : '';
1080
+ if (!(c.includes('.claude-mem-lite/') || c.includes('/claude-mem-lite/'))) continue;
1081
+ let paths = [...c.matchAll(/"([^"]+)"/g)].map(x => x[1]).filter(p => p.startsWith('/'));
1082
+ if (paths.length === 0) paths = c.split(/\s+/).filter(t => t.startsWith('/'));
1083
+ for (const p of paths) {
1084
+ checked++;
1085
+ if (existsSync(p)) return true;
1086
+ }
1087
+ }
1088
+ }
1089
+ }
1090
+ return checked === 0;
1069
1091
  }
1070
1092
  export function clearCacheHookResidue() {
1071
1093
  // Same precondition plugin-cache-guard.mjs documents and hook.mjs's self-heal
package/hook.mjs CHANGED
@@ -1855,12 +1855,21 @@ async function handleSessionStart() {
1855
1855
  // Plugin cache self-heal: Claude Code auto-updates the marketplace plugin can
1856
1856
  // re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
1857
1857
  // registration alongside install.mjs-managed settings.json entries. Silently
1858
- // clear — gated by hasInstallManagedHooks to avoid breaking plugin-only users.
1858
+ // clear — gated to avoid breaking plugin-only users.
1859
+ //
1860
+ // The gate is hasLiveInstallManagedHooks, not the bare hasInstallManagedHooks: this
1861
+ // branch EMPTIES the manifest, which is a dedup only while settings.json is really the
1862
+ // other registration. A settings.json entry naming a deleted `~/.claude-mem-lite`
1863
+ // launcher (global install removed by hand, plugin kept) satisfies the string test and
1864
+ // fires nothing — so the self-heal read a dead registration as live and wiped the one
1865
+ // that worked, every SessionStart. `?? hasInstallManagedHooks` keeps a guard module
1866
+ // that predates the predicate working exactly as before.
1859
1867
  // Dynamic-import fallback: if plugin-cache-guard.mjs is missing (pre-2.31.2
1860
1868
  // auto-upgrade install), skip self-heal instead of crashing the entire hook.
1861
1869
  try {
1862
1870
  const guard = await loadCacheGuard();
1863
- if (guard.hasInstallManagedHooks && guard.hasInstallManagedHooks()) {
1871
+ const ownsHooks = guard.hasLiveInstallManagedHooks ?? guard.hasInstallManagedHooks;
1872
+ if (ownsHooks && ownsHooks()) {
1864
1873
  const cleared = guard.clearPluginCacheHooks({
1865
1874
  reason: 'Auto-healed by hook.mjs session-start — install.mjs-managed hooks active in settings.json',
1866
1875
  });
package/install.mjs CHANGED
@@ -1869,7 +1869,16 @@ async function doctor() {
1869
1869
  // `validate` job (where the "old processes" were vitest's own workers) and it
1870
1870
  // reddens doctor-install-shape-e2e's "instead of going red forever" case on any
1871
1871
  // dev box with a previous-version session still open.
1872
- warn(`Old processes running${currentVersion ? ` (current: v${currentVersion})` : ''}:\n ` + stale.join('\n '));
1872
+ //
1873
+ // `dwarn`, not the bare `warn`: the first cut called the bare one, which prints the
1874
+ // ⚠ line but never touches the `warnings` counter — so a doctor run whose ONLY
1875
+ // finding was a stale launcher printed the ⚠ and then closed with
1876
+ // "All checks passed!". That is the exact sentence buildDoctorSummary's docblock
1877
+ // says must not lie, and the exact case tests/doctor-summary.test.mjs pins at the
1878
+ // pure-function level; the counter simply never reached it from here. `dwarn`
1879
+ // increments `warnings` only — `issues` stays 0, so the paragraph above still
1880
+ // holds and `doctor` still exits 0.
1881
+ dwarn(`Old processes running${currentVersion ? ` (current: v${currentVersion})` : ''}:\n ` + stale.join('\n '));
1873
1882
  } else {
1874
1883
  ok('No stale processes');
1875
1884
  }
@@ -58,6 +58,16 @@ function importPrompt(db, ev, project, seenPrompts) {
58
58
  ? ev.message.content.filter(c => c?.type === 'text').map(c => c.text).join('\n')
59
59
  : '');
60
60
  if (!text) return false;
61
+ // Same sentinel the two LIVE writers refuse on (hook.mjs handleUserPrompt,
62
+ // scripts/user-prompt-search.js): <task-notification> is Claude Code protocol, not user
63
+ // input. Backfill is the third input boundary into user_prompts and was the only one
64
+ // persisting them — so a cold-start import seeded rows the live path would never write,
65
+ // which every reader then has to filter back out (`prompt_text NOT LIKE
66
+ // '<task-notification>%'` in search-core, search-engine and the UPS fallback). A reader
67
+ // that forgets the filter — `get P#N` and the timeline P# anchor do not have it — hands
68
+ // the agent protocol chatter as recalled context. Counted as `skipped`, which is what it
69
+ // is; the import stays idempotent because a skipped row was never inserted to re-match.
70
+ if (text.startsWith('<task-notification>')) return false;
61
71
  const sessionId = ev.sessionId || 'imported';
62
72
  const ts = ev.timestamp || new Date().toISOString();
63
73
  const safe = scrubSecrets(text.slice(0, 10000));
@@ -54,3 +54,34 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
54
54
 
55
55
  return { filename, rows };
56
56
  }
57
+
58
+ /**
59
+ * Does a file-keyed recall have anything for `file`? COUNT only — no rows, and
60
+ * deliberately NO access_count/last_accessed_at bump.
61
+ *
62
+ * The bump in recallByFile above is correct there because a recall IS engagement and the
63
+ * tier/decay system feeds on it. This helper exists for the opposite situation: `search`
64
+ * wants to know, on a zero-result query that looks like a path, whether `recall` would
65
+ * have answered — a question ABOUT the store, asked on the user's behalf but not by them.
66
+ * Answering it through recallByFile would push the counters of rows nobody read, i.e. a
67
+ * measurement writing to what it measures. It shares the predicate rather than re-typing
68
+ * it, so `search`'s hint and `recall`'s answer can never disagree about what matches.
69
+ *
70
+ * `superseded_at IS NULL` and the LOW_SIGNAL filter come along for the same reason: a hint
71
+ * must promise only what the default `recall` will actually print.
72
+ *
73
+ * @param {import('better-sqlite3').Database} db
74
+ * @param {string} file Path or filename, same forms recallByFile accepts.
75
+ * @returns {number}
76
+ */
77
+ export function countRecallableByFile(db, file) {
78
+ const { c = 0 } = db.prepare(`
79
+ SELECT COUNT(DISTINCT o.id) AS c
80
+ FROM observations o
81
+ JOIN observation_files of2 ON of2.obs_id = o.id
82
+ WHERE ${liveObsFilterSql('o')}
83
+ AND ${fileMatchClause('of2')}
84
+ AND ${notLowSignalTitleClause('o')}
85
+ `).get(...fileMatchParams(file)) || {};
86
+ return c;
87
+ }
package/mem-cli.mjs CHANGED
@@ -58,7 +58,7 @@ import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdT
58
58
  import { saveObservation, saveWithClosures, formatSupersedeSkipped, formatSupersededNote } from './lib/save-observation.mjs';
59
59
  import { normalizeScope, insertObservationVector, applyObsUpdate } from './lib/observation-write.mjs';
60
60
  import { EXPORT_COLUMNS_SQL, buildExportWhere } from './lib/export-columns.mjs';
61
- import { recallByFile } from './lib/recall-core.mjs';
61
+ import { recallByFile, countRecallableByFile } from './lib/recall-core.mjs';
62
62
  import { fetchRecent, RECENT_MAX } from './lib/recent-core.mjs';
63
63
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
64
64
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
@@ -90,6 +90,35 @@ import { shouldQueueSaveEnrich, queueSaveEnrich } from './lib/save-enrich.mjs';
90
90
 
91
91
  // ─── Commands ────────────────────────────────────────────────────────────────
92
92
 
93
+ // A path query is not a text query, and `search` cannot tell the user so.
94
+ //
95
+ // OBS_FTS_COLUMNS (scoring-sql.mjs) indexes title/narrative/lesson/aliases/concepts — it
96
+ // does NOT index `files`. File association lives in the observation_files junction, which
97
+ // is `recall`'s table and only `recall`'s. So a save that named `src/payments/webhook.ts`
98
+ // in --files and never mentioned it in prose is reachable by `recall` and unreachable by
99
+ // `search`, and the user typing the path they were just editing gets a flat
100
+ // "No results" — a true statement about the FTS index that reads as a false one about the
101
+ // store. This is the one zero-result shape the CLI can positively disprove, so it does,
102
+ // with the exact command that answers it.
103
+ //
104
+ // Cheap and quiet: one COUNT, only on a zero-result query that is a single whitespace-free
105
+ // token shaped like a path or filename, and silent when that count is 0 (the ordinary case
106
+ // — an ordinary prose query never reaches the COUNT at all). countRecallableByFile does
107
+ // not bump access counters, so offering the hint cannot inflate the engagement signal of
108
+ // rows the user has not read.
109
+ function emitRecallHint(db, query) {
110
+ const q = String(query || '').trim();
111
+ if (!q || /\s/.test(q)) return;
112
+ if (!(q.includes('/') || q.includes('\\') || /\.[A-Za-z0-9]{1,8}$/.test(q))) return;
113
+ try {
114
+ const n = countRecallableByFile(db, q);
115
+ if (n > 0) {
116
+ out(`[mem] ${n} observation(s) are linked to that file — search indexes text, not file paths.`);
117
+ out(`[mem] Try: claude-mem-lite recall "${q}"`);
118
+ }
119
+ } catch { /* hint is best-effort; never break search */ }
120
+ }
121
+
93
122
  async function cmdSearch(db, args, { llm } = {}) {
94
123
  const { positional, flags } = parseArgs(args);
95
124
 
@@ -295,6 +324,7 @@ async function cmdSearch(db, args, { llm } = {}) {
295
324
  out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: isDeep, variants: isDeep ? deepVariants : undefined, results: [] }));
296
325
  } else {
297
326
  out(`[mem] No results for "${query}"`);
327
+ emitRecallHint(db, query);
298
328
  // The zero-result path is where the trailer earns its keep — the D#92
299
329
  // failure chain was exactly "searched, found nothing, item was deferred".
300
330
  emitDeferredTrailer();
@@ -1696,9 +1726,24 @@ function cmdExport(db, args) {
1696
1726
  // truncated backup that lost rows on restore, and `--limit 5000` was REJECTED back
1697
1727
  // to 200 (can't back up >1000 at all). Now: omit --limit → LIMIT -1 (SQLite = no
1698
1728
  // limit); pass --limit N → honor any positive N (a backup may exceed 1000).
1729
+ //
1730
+ // The invalid-value branch has to land on that same -1, not on the sibling commands'
1731
+ // `defaultValue: 200`. `parseIntFlag`'s warn-and-default contract is right for `search`
1732
+ // and `recent`, where the default is a display width; here the default is COMPLETENESS,
1733
+ // and defaulting a backup to 200 rows reopens the truncation the paragraph above closed
1734
+ // — through the invalid door instead of the absent one. It is the same failure shape as
1735
+ // the bare `--to` guard at the top of this function: `export --limit "$N" > backup.json`
1736
+ // with `$N` unset or typo'd writes 200 rows, warns on a stderr the redirect usually
1737
+ // discards, and exits 0. Recovering to the complete set is the only direction that
1738
+ // cannot lose a row on restore.
1699
1739
  const limitGiven = flags.limit !== undefined && flags.limit !== null && flags.limit !== '';
1700
1740
  const limit = limitGiven
1701
- ? parseIntFlag(flags.limit, { name: '--limit', defaultValue: 200 })
1741
+ ? parseIntFlag(flags.limit, {
1742
+ name: '--limit',
1743
+ defaultValue: -1,
1744
+ warn: () => process.stderr.write(
1745
+ `[mem] Invalid --limit "${flags.limit}" (must be an integer ≥ 1); exporting the COMPLETE matching set instead\n`),
1746
+ })
1702
1747
  : -1;
1703
1748
  const format = flags.format || 'json';
1704
1749
  if (!['json', 'jsonl'].includes(format)) {
@@ -1742,7 +1787,10 @@ function cmdExport(db, args) {
1742
1787
  outVerbatim(JSON.stringify(rows, null, 2));
1743
1788
  }
1744
1789
 
1745
- if (limitGiven && rows.length >= limit) {
1790
+ // `limit > 0`, not just `limitGiven`: an invalid `--limit` now recovers to the complete
1791
+ // set (-1), and `rows.length >= -1` is always true — so the guard as written announced
1792
+ // "Results capped at -1" on the one path that is guaranteed NOT to be capped.
1793
+ if (limitGiven && limit > 0 && rows.length >= limit) {
1746
1794
  process.stderr.write(`[mem] Note: Results capped at ${limit}. Raise --limit or narrow --from/--to to export more.\n`);
1747
1795
  }
1748
1796
  // Fidelity caveat at backup-creation time (mirrors the restore-side note). stderr,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.95.1",
3
+ "version": "3.96.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.95.1",
9
+ "version": "3.96.0",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.30.0",
12
12
  "better-sqlite3": "^12.11.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.95.1",
3
+ "version": "3.96.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",
@@ -105,3 +105,77 @@ export function hasInstallManagedHooks(opts) {
105
105
  return serialized.includes(`.${plugin}/`) || serialized.includes(`/${plugin}/`);
106
106
  } catch { return false; }
107
107
  }
108
+
109
+ /** Every `command` string under settings.json `hooks`, in registration order. */
110
+ function settingsHookCommands(home) {
111
+ const settingsPath = join(home, '.claude', 'settings.json');
112
+ if (!existsSync(settingsPath)) return [];
113
+ let s;
114
+ try { s = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { return []; }
115
+ const out = [];
116
+ for (const matchers of Object.values(s?.hooks || {})) {
117
+ if (!Array.isArray(matchers)) continue;
118
+ for (const m of matchers) {
119
+ for (const h of (Array.isArray(m?.hooks) ? m.hooks : [])) {
120
+ if (typeof h?.command === 'string') out.push(h.command);
121
+ }
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * Absolute paths a hook command names. install.mjs writes `node "<abs>" …` /
129
+ * `bash "<abs>"`, so the quoted form is the shipped shape; the unquoted arm covers
130
+ * hand-edited and pre-quoting entries.
131
+ */
132
+ function commandPaths(command) {
133
+ const paths = [];
134
+ for (const m of command.matchAll(/"([^"]+)"/g)) if (m[1].startsWith('/')) paths.push(m[1]);
135
+ if (paths.length === 0) {
136
+ for (const tok of command.split(/\s+/)) if (tok.startsWith('/')) paths.push(tok);
137
+ }
138
+ return paths;
139
+ }
140
+
141
+ /**
142
+ * Does settings.json register hooks we manage that can ACTUALLY RUN?
143
+ *
144
+ * `hasInstallManagedHooks` answers a string question — "does settings.json mention a
145
+ * path of ours" — and that is the right question for install(), which has just written
146
+ * those entries itself. It is the wrong question for the SessionStart self-heal, whose
147
+ * action is DESTRUCTIVE: clearing the plugin cache manifest is a dedup only while
148
+ * settings.json really is the other registration. A user who installed globally, later
149
+ * switched to the plugin, and removed `~/.claude-mem-lite` by hand (or by an
150
+ * `npm uninstall -g` that never ran our `uninstall`) leaves entries that name a deleted
151
+ * launcher. They fire nothing — and on that state the self-heal read them as a live
152
+ * registration and emptied the ONE manifest that was working, on every single
153
+ * SessionStart. v3.95.1 taught status/doctor to SEE that end state; this is the half
154
+ * that stops producing it.
155
+ *
156
+ * Deliberately narrow: a `false` is returned only when at least one managed command was
157
+ * parsed AND none of the paths it names exist. An unparseable or unfamiliar command shape
158
+ * keeps the old answer, so this can only ever remove the destructive branch from a case
159
+ * we positively verified as dead — never add it to one.
160
+ *
161
+ * @param {object} [opts]
162
+ * @param {string} [opts.home]
163
+ * @param {string} [opts.plugin]
164
+ * @returns {boolean}
165
+ */
166
+ export function hasLiveInstallManagedHooks(opts) {
167
+ if (!hasInstallManagedHooks(opts)) return false;
168
+ const home = opts?.home || homedir();
169
+ const plugin = opts?.plugin || DEFAULT_PLUGIN;
170
+ const managed = settingsHookCommands(home)
171
+ .filter(c => c.includes(`.${plugin}/`) || c.includes(`/${plugin}/`));
172
+ let checked = 0;
173
+ for (const c of managed) {
174
+ for (const p of commandPaths(c)) {
175
+ checked++;
176
+ if (existsSync(p)) return true;
177
+ }
178
+ }
179
+ // No path we could check → keep hasInstallManagedHooks' answer (see docblock).
180
+ return checked === 0;
181
+ }
@@ -76,6 +76,19 @@ export const INTENTS = [
76
76
  { pattern: /implement|feature\b|add\s+(?:a\s+)?new|实现|添加|新功能|新增|开发|编写|创建|构建|做一个|加一个|写一个/i, type: null, limit: 3 },
77
77
  // Recall/history intent (catch-all temporal, lowest priority)
78
78
  // CJK: 刚才/历史/回顾 from real prompts; 碰到过|遇到过|见过|同样的问题 from spoken CN
79
+ //
80
+ // MEASURED AND REJECTED — do not re-add `remind me` here without a ruler (2026-09-05,
81
+ // 10-row typed corpus, sandbox install). The reasoning that it belongs is seductive and
82
+ // wrong: `remember` is already in this arm, `remind me` is its imperative twin, and its
83
+ // absence is the sole reason "remind me what we decided about session cookies" reaches
84
+ // hasExplicitSignal with no error signature, no file, no identifier and no CJK, and is
85
+ // dropped before FTS runs. Added, the prompt does fire — and this arm carries
86
+ // useRecent + limit 5, so when topical FTS comes back empty (it does: the OR floor
87
+ // drops a long multi-topic prompt whose best row shares only "session"/"cookies") the
88
+ // recency fallback spends FIVE injection slots on the five newest rows, and on the
89
+ // measured corpus the session-cookies decision was NOT among them. Five noise rows and
90
+ // no answer is worse than the silence it replaced. Any future attempt needs
91
+ // benchmark/citation-live-replay.mjs on the `fyi` face, not this intuition.
79
92
  { pattern: /before|previously|last time|remember|seen this|same\s+issue|之前|上次|以前|记得|刚才|历史|回顾|碰到过|遇到过|见过|同样的问题|类似的问题/i, type: null, limit: 5, useRecent: true },
80
93
  ];
81
94