claude-mem-lite 3.75.0 → 3.75.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.75.0",
13
+ "version": "3.75.1",
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.75.0",
3
+ "version": "3.75.1",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/haiku-client.mjs CHANGED
@@ -190,8 +190,14 @@ export async function callHaiku(prompt, { timeout = 10000, maxTokens = 500, temp
190
190
  // out-of-credit key must not silently drop background summaries.
191
191
  let primary = null;
192
192
  try {
193
+ // callModelAPI, not a second copy of it: the two were byte-identical apart from
194
+ // where the model id came from (MODEL_MAP[model] vs resolveModel().api — the same
195
+ // value, since resolveModel().cli is a MODEL_MAP key) and a hardcoded 'haiku-api'
196
+ // log label that lied under CLAUDE_MEM_MODEL=sonnet. Two copies of an HTTP client
197
+ // means every proxy fix has to land twice, on the path where missing the proxy is
198
+ // the difference between 1.4s and 13.5s.
193
199
  primary = mode === 'api'
194
- ? await callHaikuAPI(prompt, { timeout, maxTokens, temperature })
200
+ ? await callModelAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature })
195
201
  : await callOpenRouterAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
196
202
  } catch (e) {
197
203
  debugCatch(e, `callHaiku:${mode}`);
@@ -683,63 +689,6 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
683
689
  return second.result;
684
690
  }
685
691
 
686
- // ─── API Mode ────────────────────────────────────────────────────────────────
687
-
688
- async function callHaikuAPI(prompt, { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE }) {
689
- const apiKey = process.env.ANTHROPIC_API_KEY;
690
- if (!apiKey) return null;
691
-
692
- const { api: modelId } = resolveModel();
693
- const controller = new AbortController();
694
- const timer = setTimeout(() => controller.abort(), timeout);
695
-
696
- try {
697
- const { system, user } = splitPrompt(prompt);
698
- const body = {
699
- model: modelId,
700
- max_tokens: maxTokens,
701
- temperature,
702
- messages: [{ role: 'user', content: user }],
703
- };
704
- // See callModelAPI: cache_control on the constant system slot.
705
- if (system) {
706
- body.system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
707
- }
708
-
709
- // Proxy-aware, same as the OpenRouter site below. Missing it here meant the
710
- // ANTHROPIC_API_KEY paths were the one keyed provider still doing a bare
711
- // fetch — a silent outage behind a proxy, and one the new doctor check would
712
- // have certified as healthy because it probes the hop this code was ASSUMED
713
- // to use. (pre-tag review SHOULD-FIX 3)
714
- const apiUrl = 'https://api.anthropic.com/v1/messages';
715
- const apiHeaders = {
716
- 'Content-Type': 'application/json',
717
- 'x-api-key': apiKey,
718
- 'anthropic-version': '2023-06-01',
719
- };
720
- const apiProxy = httpConnectProxyFor(apiUrl);
721
- const res = apiProxy
722
- ? await postViaConnectProxy(apiProxy, apiUrl, { headers: apiHeaders, body: JSON.stringify(body), timeout })
723
- : await fetch(apiUrl, {
724
- method: 'POST',
725
- headers: apiHeaders,
726
- body: JSON.stringify(body),
727
- signal: controller.signal,
728
- });
729
-
730
- if (!res.ok) {
731
- debugLog('WARN', 'haiku-api', `HTTP ${res.status}`);
732
- return null;
733
- }
734
-
735
- const data = await res.json();
736
- const text = data.content?.[0]?.text;
737
- return text ? { text } : null;
738
- } finally {
739
- clearTimeout(timer);
740
- }
741
- }
742
-
743
692
  // ─── OpenRouter Mode ─────────────────────────────────────────────────────────
744
693
 
745
694
  // OpenRouter exposes an OpenAI-compatible chat-completions API (NOT the
package/hook-episode.mjs CHANGED
@@ -246,37 +246,61 @@ export function mergePendingEntries(episode) {
246
246
  }
247
247
  }
248
248
 
249
+ /** Rule 4's threshold — 8+ Read/Grep entries read as investigation. */
250
+ const RESEARCH_ENTRY_THRESHOLD = 8;
251
+
249
252
  /**
250
- * Check if an episode has significant content worth processing with LLM.
251
- * Significant = contains file edits, Bash errors, or a review/research pattern
252
- * (8+ Read/Grep entries indicate investigation worth recording).
253
- * @param {object} episode The episode to check
254
- * @returns {boolean} true if the episode has significant content
253
+ * The significance decision WITH its reasoning, for instrumentation.
254
+ * `episodeHasSignificantContent` is the boolean face of this same body, so the meter
255
+ * and the decision cannot drift (audit 2026-08-22 P2-14).
256
+ *
257
+ * `grepDecisive` answers the one question the "move Grep into the bash skip list"
258
+ * decision is blocked on: would this episode still have been kept without its Grep
259
+ * entries? It is true ONLY when rule 4 decided AND the non-Grep entries alone fall
260
+ * short — an edit-driven episode that happens to contain Greps is not evidence that
261
+ * Grep carries research episodes.
262
+ *
263
+ * @param {object} episode
264
+ * @returns {{significant: boolean, rule: 1|2|3|4|null, readCount: number,
265
+ * grepCount: number, grepDecisive: boolean}}
255
266
  */
256
- export function episodeHasSignificantContent(episode) {
267
+ export function explainSignificance(episode) {
268
+ const entries = episode?.entries || [];
269
+ const grepCount = entries.filter(e => e.tool === 'Grep').length;
270
+ const readCount = entries.filter(e => e.tool === 'Read' || e.tool === 'Grep').length;
271
+ const base = { readCount, grepCount, grepDecisive: false };
272
+
257
273
  // 1. File edits → always significant (code changes matter)
258
- const hasEdits = episode.entries.some(e => EDIT_TOOLS.has(e.tool));
259
- if (hasEdits) return true;
274
+ if (entries.some(e => EDIT_TOOLS.has(e.tool))) return { ...base, significant: true, rule: 1 };
260
275
 
261
276
  // 2. Test/build errors → significant (actionable failures)
262
277
  // Plain bash errors without edits are noise (e.g. typos, exploration errors)
263
- const hasTestOrBuildError = episode.entries.some(e =>
264
- e.tool === 'Bash' && e.isError && (e.bashSig?.isTest || e.bashSig?.isBuild)
265
- );
266
- if (hasTestOrBuildError) return true;
278
+ if (entries.some(e => e.tool === 'Bash' && e.isError && (e.bashSig?.isTest || e.bashSig?.isBuild))) {
279
+ return { ...base, significant: true, rule: 2 };
280
+ }
267
281
 
268
282
  // 3. Important files touched (config, schema, security, migration)
269
283
  // Checks episode.files (all touched files, including reads) — catches important-file investigation
270
- const allFiles = episode.files || [];
271
- const hasImportantFile = allFiles.some(f =>
284
+ const allFiles = episode?.files || [];
285
+ if (allFiles.some(f =>
272
286
  /\.(env|yml|yaml|toml|lock|sql|prisma|proto)$/.test(f) ||
273
287
  /(config|schema|migration|auth|security)/i.test(f)
274
- );
275
- if (hasImportantFile) return true;
288
+ )) return { ...base, significant: true, rule: 3 };
276
289
 
277
290
  // 4. Research pattern: reading many files indicates investigation
278
- const readCount = episode.entries.filter(e =>
279
- e.tool === 'Read' || e.tool === 'Grep'
280
- ).length;
281
- return readCount >= 8;
291
+ if (readCount >= RESEARCH_ENTRY_THRESHOLD) {
292
+ return { ...base, significant: true, rule: 4, grepDecisive: readCount - grepCount < RESEARCH_ENTRY_THRESHOLD };
293
+ }
294
+ return { ...base, significant: false, rule: null };
295
+ }
296
+
297
+ /**
298
+ * Check if an episode has significant content worth processing with LLM.
299
+ * Significant = contains file edits, Bash errors, or a review/research pattern
300
+ * (8+ Read/Grep entries indicate investigation worth recording).
301
+ * @param {object} episode The episode to check
302
+ * @returns {boolean} true if the episode has significant content
303
+ */
304
+ export function episodeHasSignificantContent(episode) {
305
+ return explainSignificance(episode).significant;
282
306
  }
package/hook-memory.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // claude-mem-lite — Semantic Memory Injection
2
2
  // Search past observations for relevant memories to inject as context at user-prompt time.
3
3
 
4
- import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters, basenameAnySep } from './utils.mjs';
4
+ import { relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters, basenameAnySep } from './utils.mjs';
5
+ import { upsFtsQuery } from './lib/ups-query.mjs';
5
6
  import { citeFactorJs, TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
6
7
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
7
8
  import { recordMetric } from './lib/metrics.mjs';
@@ -192,7 +193,12 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
192
193
  };
193
194
 
194
195
  try {
195
- const ftsQuery = sanitizeFtsQuery(userPrompt);
196
+ // upsFtsQuery, not bare sanitizeFtsQuery: this is the SECOND hook UserPromptSubmit
197
+ // fires, and v3.75.0 capped only the first. This one is the worse half — its stdin
198
+ // ceiling is MAX_HOOK_STDIN_BYTES (256KB) against path A's 64KB, and nothing
199
+ // truncates between stdin and here. The caps are shared, not copied, so the two
200
+ // faces of one event cannot drift apart again.
201
+ const ftsQuery = upsFtsQuery(userPrompt);
196
202
  if (!ftsQuery) return [];
197
203
 
198
204
  const cutoff = Date.now() - MEMORY_LOOKBACK_MS;
@@ -243,7 +249,15 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
243
249
  if (rows.length === 0) {
244
250
  const orQuery = relaxFtsQueryToOr(ftsQuery);
245
251
  if (orQuery && (queryIsCjkDominant || queryTokenCount <= orFallbackMaxTokens)) {
246
- try { rows = selectStmt.all(orQuery, project, cutoff); usedOrFallback = true; } catch {}
252
+ // debugCatch, not a bare swallow: this is the injection chain's LAST query, and
253
+ // an FTS5 fault here (corrupt index, malformed relaxed query) degrades to an
254
+ // EMPTY injection that reads exactly like "nothing matched" — invisible to
255
+ // stats and doctor alike. Still non-fatal; the prompt must go through.
256
+ // (The two bare catches further down, around the per-row access bumps, are
257
+ // deliberately left bare: they are write-path and per-row, so logging them would
258
+ // flood the debug stream on the same corruption this one reports once.)
259
+ try { rows = selectStmt.all(orQuery, project, cutoff); usedOrFallback = true; }
260
+ catch (e) { debugCatch(e, 'injectMemory:orFallback'); }
247
261
  }
248
262
  }
249
263
 
@@ -274,7 +288,10 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
274
288
  if (crossRows.length === 0) {
275
289
  const orQuery = relaxFtsQueryToOr(ftsQuery);
276
290
  if (orQuery && (queryIsCjkDominant || queryTokenCount <= orFallbackMaxTokens)) {
277
- try { crossRows = crossStmt.all(orQuery, project, cutoff); crossUsedOr = true; } catch {}
291
+ // Same reasoning as the same-project OR fallback above: a fault here silently
292
+ // drops the cross-project half of the injection.
293
+ try { crossRows = crossStmt.all(orQuery, project, cutoff); crossUsedOr = true; }
294
+ catch (e) { debugCatch(e, 'injectMemory:crossOrFallback'); }
278
295
  }
279
296
  }
280
297
  } catch (e) { debugCatch(e, 'crossProjectSearch'); }
package/hook.mjs CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  readEpisodeRaw, episodeFile,
34
34
  acquireLock, releaseLock, readEpisode, writeEpisode,
35
35
  createEpisode, addFileToEpisode, planEpisodeFlush,
36
- writePendingEntry, mergePendingEntries, episodeHasSignificantContent,
36
+ writePendingEntry, mergePendingEntries, episodeHasSignificantContent, explainSignificance,
37
37
  } from './hook-episode.mjs';
38
38
  import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-context.mjs';
39
39
  import { entry as preCompactEntry } from './hook-precompact.mjs';
@@ -294,7 +294,21 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
294
294
  // suppresses the detached enrichment spawn (test determinism; sibling of
295
295
  // CLAUDE_MEM_SKIP_COMPRESS / _OPTIMIZE) — the synchronous immediate obs still lands.
296
296
  function flushEpisodeGroup(ep, db) {
297
- const isSignificant = episodeHasSignificantContent(ep);
297
+ const verdict = explainSignificance(ep);
298
+ const isSignificant = verdict.significant;
299
+ // Audit P2-14 instrument: moving Grep into the bash prefilter would save 85ms per Grep
300
+ // (91.2ms handoff vs 6.1ms for the already-skipped Read), but nothing records how many
301
+ // episodes are kept ONLY because of their Greps — and demoting what the product
302
+ // remembers on a deduction is how work disappears silently. This is that counter.
303
+ // Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
304
+ recordMetric(join(RUNTIME_DIR, '..'), {
305
+ event: 'episode_significance',
306
+ rule: verdict.rule,
307
+ significant: isSignificant,
308
+ readCount: verdict.readCount,
309
+ grepCount: verdict.grepCount,
310
+ grepDecisive: verdict.grepDecisive,
311
+ });
298
312
 
299
313
  // Immediate save: rule-based observation for instant visibility; the LLM
300
314
  // background worker upgrades title/narrative/importance later. `db` is
@@ -809,10 +823,13 @@ async function handleStop() {
809
823
  } catch (e) { debugCatch(e, 'handleStop-citation-decay'); }
810
824
 
811
825
  // Persist cite-recall ratio for the next SessionStart to surface as
812
- // feedback. We deliberately scan the transcript a second time here
813
- // (cheap; the file is already in OS cache) rather than threading the
814
- // count through `extractCitationsFromTranscript` so the bump path stays
815
- // unchanged.
826
+ // feedback. This block re-scans the transcript rather than threading the
827
+ // count through `extractCitationsFromTranscript`, so the bump path stays
828
+ // unchanged and since P2-8 every scanner in it shares ONE parse via
829
+ // lib/transcript-scan.mjs, so "scan again" costs an array iteration, not a
830
+ // re-read. (The old wording, "cheap; the file is already in OS cache", was
831
+ // arguing the pre-memo case: the OS cache saved the read, never the parse,
832
+ // which was ~all of the cost.)
816
833
  try {
817
834
  const stats = computeCiteRecall(transcriptPath);
818
835
  // B2 (v2.83.1): also persist the bugfix-shape nudge/save delta so
@@ -939,7 +956,48 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
939
956
  })();
940
957
  }
941
958
 
959
+ // Per-project 24h gate for the auto-compressible marking. Separate from the global
960
+ // maintain gate on purpose: the marking is project-scoped work that every project needs
961
+ // daily, while the pass it used to ride on (VACUUM snapshot + purge + decay + dedup) is
962
+ // whole-DB work that should happen once a day in total.
963
+ function markCompressibleGateFile(project) {
964
+ // Project names arrive already mangled by inferProject (`projects--mem`), but this is
965
+ // a filename, so do not trust that — one stray separator writes outside RUNTIME_DIR.
966
+ return join(RUNTIME_DIR, `last-mark-compressible-${String(project).replace(/[^A-Za-z0-9._-]/g, '-')}.json`);
967
+ }
968
+
969
+ function markAutoCompressibleIfDue(db, project) {
970
+ if (!project) {
971
+ // Every production spawn passes it (spawnBackground('auto-maintain', project)); a
972
+ // hand-run `hook.mjs auto-maintain` does not. Say so rather than skipping in
973
+ // silence — work vanishing without a word is this codebase's recurring shape.
974
+ debugLog('DEBUG', 'auto-maintain', 'no project argument — skipping auto-compress marking');
975
+ return;
976
+ }
977
+ const gate = markCompressibleGateFile(project);
978
+ try {
979
+ const last = JSON.parse(readFileSync(gate, 'utf8'));
980
+ if (Date.now() - last.epoch < 24 * 3600000) return;
981
+ } catch { /* no gate file → due */ }
982
+ try {
983
+ const marked = markAutoCompressible(db, project);
984
+ if (marked.aged > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.aged} old observations`);
985
+ if (marked.noise > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.noise} LOW_SIGNAL noise (7d window)`);
986
+ writeFileSync(gate, JSON.stringify({ epoch: Date.now() }));
987
+ } catch (e) { debugCatch(e, 'auto-maintain-mark-compressible'); }
988
+ }
989
+
942
990
  function runSessionStartAutoMaintain(db, project) {
991
+ // The auto-compressible marking runs on its OWN per-project gate, before the global
992
+ // one below. v3.75.0 moved this work off SessionStart onto this worker (P2-11) and,
993
+ // by putting it inside the global `shouldMaintain` block, cut its coverage from
994
+ // "every project, every boot" to "one project per 24h" — RUNTIME_DIR is a single
995
+ // global directory, so whichever project wins the gate is the only one marked, and
996
+ // with N projects in rotation N-1 never get the 7-day noise pass. The move was right;
997
+ // the shared gate was not. Heavy work (VACUUM snapshot, purge, decay, dedup) stays on
998
+ // the global gate — that genuinely should run once a day, not once per project.
999
+ markAutoCompressibleIfDue(db, project);
1000
+
943
1001
  // Auto-maintain: cleanup + decay + boost + purge, gated to once per 24h
944
1002
  const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
945
1003
  let shouldMaintain = true;
@@ -969,19 +1027,10 @@ function runSessionStartAutoMaintain(db, project) {
969
1027
  // children (compressed_into dangling at a deleted id). purgeStale recovers them
970
1028
  // first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
971
1029
  // created_at_epoch: 30d marking gate + 7d grace = 37d.
972
- // Mark auto-compressible rows BEFORE the purge/decay ops below, preserving the
973
- // order the SessionStart transaction had relative to them (marking ran first, at
974
- // boot). Project-scoped exactly as before P2-11 moved the cadence, not the scope.
975
- if (project) {
976
- const marked = markAutoCompressible(db, project);
977
- if (marked.aged > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.aged} old observations`);
978
- if (marked.noise > 0) debugLog('DEBUG', 'auto-maintain', `auto-compressed ${marked.noise} LOW_SIGNAL noise (7d window)`);
979
- } else {
980
- // Every production spawn passes it (spawnBackground('auto-maintain', project)); a
981
- // hand-run `hook.mjs auto-maintain` does not. Say so rather than skipping in
982
- // silence — work vanishing without a word is this codebase's recurring shape.
983
- debugLog('DEBUG', 'auto-maintain', 'no project argument — skipping auto-compress marking');
984
- }
1030
+ // The marking itself already ran at the top of this function, on its own
1031
+ // per-project gate it must not be conditioned on the global one (see
1032
+ // markAutoCompressibleIfDue). It still happens BEFORE the purge/decay ops below,
1033
+ // preserving the order the SessionStart transaction had relative to them.
985
1034
 
986
1035
  const purged = purgeStale(db, mctx, Date.now() - 37 * DAY_MS);
987
1036
  if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
@@ -1112,11 +1161,19 @@ function runSessionStartAutoMaintain(db, project) {
1112
1161
  // a detached `auto-maintain` worker via spawnBackground so it never blocks interactive
1113
1162
  // session start. The worker re-checks the same gate (idempotent) before doing the work.
1114
1163
  function scheduleSessionStartAutoMaintain(project) {
1115
- const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
1116
- try {
1117
- const last = JSON.parse(readFileSync(maintainFile, 'utf8'));
1118
- if (Date.now() - last.epoch < 24 * 3600000) return; // not due — no spawn
1119
- } catch { /* no gate file due */ }
1164
+ // TWO gates, either of which is enough to spawn. Checking only the global one is what
1165
+ // made the marking single-project in v3.75.0: in a multi-project rotation the global
1166
+ // stamp is already fresh by the time the second project boots, so its worker never
1167
+ // ran and its rows were never marked.
1168
+ const due = (file) => {
1169
+ try {
1170
+ const last = JSON.parse(readFileSync(file, 'utf8'));
1171
+ return Date.now() - last.epoch >= 24 * 3600000;
1172
+ } catch { return true; } // no gate file → due
1173
+ };
1174
+ const maintainDue = due(join(RUNTIME_DIR, 'last-auto-maintain.json'));
1175
+ const markingDue = Boolean(project) && due(markCompressibleGateFile(project));
1176
+ if (!maintainDue && !markingDue) return;
1120
1177
  if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
1121
1178
  }
1122
1179
 
@@ -15,6 +15,9 @@ import { join } from 'path';
15
15
  import { debugCatch } from '../utils.mjs';
16
16
  import { keyContextIdsFileName } from './injected-ids.mjs';
17
17
  import { readTranscriptEntries } from './transcript-scan.mjs';
18
+ // The emitter's own prefix — see SURFACE_MATCHERS.task_imperative. Importing it rather
19
+ // than re-typing the framing is what keeps emit and extract from becoming two lists.
20
+ import { TASK_IMPERATIVE_PREFIX } from './task-imperative.mjs';
18
21
 
19
22
  import { DAY_MS } from './time-constants.mjs';
20
23
  // `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
@@ -231,15 +234,21 @@ const FYI_LINE_ID_RE = /^#(\d{1,7})\s/;
231
234
 
232
235
  /**
233
236
  * The injection FACES memory can reach the model through, as stored in
234
- * `citation_surface_log.surface` (schema v45). The first four are
237
+ * `citation_surface_log.surface` (schema v45). The first five are
235
238
  * query-conditioned — a row appears there because it MATCHED something — and
236
239
  * are the ones that feed the citation-decay denominator via extractAllInjected.
237
240
  * `keyctx` is the odd one out: an unconditional SessionStart render, recorded
238
241
  * for VISIBILITY only and promotion-only in the decay loop (see
239
242
  * extractInjectedFromKeyContext).
240
- * @type {ReadonlyArray<'pretool'|'ups'|'error_recall'|'fyi'|'keyctx'>}
243
+ *
244
+ * `task_imperative` (v3.76) rides the SAME attachment as `ups` — one
245
+ * `hook.mjs user-prompt` invocation writes the `<memory-context>` block and then the
246
+ * imperative line — which is how it stayed unmetered since v3.23 while being a live
247
+ * injection: the `ups` matcher gates on `<memory-context` and collects only `- [` rows.
248
+ * The two faces therefore OVERLAP on attachments but never on ids.
249
+ * @type {ReadonlyArray<'pretool'|'ups'|'error_recall'|'fyi'|'task_imperative'|'keyctx'>}
241
250
  */
242
- export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'keyctx'];
251
+ export const CITATION_SURFACES = ['pretool', 'ups', 'error_recall', 'fyi', 'task_imperative', 'keyctx'];
243
252
 
244
253
  // Single source of truth for "which attachment belongs to which face, and how
245
254
  // its ids are read off". Both the per-face extractors below AND the one-pass
@@ -307,6 +316,24 @@ const SURFACE_MATCHERS = {
307
316
  }
308
317
  },
309
318
  },
319
+ task_imperative: {
320
+ // Same hook entry as `ups`, different row. Gating on the emitter's own exported
321
+ // prefix (not a copy of the wording) keeps the meter tied to the framing; gating on
322
+ // the COMMAND too means a transcript that merely quotes the framing — a review of
323
+ // this code, say — is not counted as an injection.
324
+ accepts: ({ command, text }) =>
325
+ command.includes(UPS_COMMAND_SUFFIX) && text.includes(TASK_IMPERATIVE_PREFIX),
326
+ collect: (text, add) => {
327
+ for (const line of text.split('\n')) {
328
+ if (!line.startsWith(TASK_IMPERATIVE_PREFIX)) continue;
329
+ // Trailing `(#NN)` is this lesson's own id; earlier ones are cross-references
330
+ // inside the body (#8850 — an inlined lesson body must not pollute the set).
331
+ const matches = [...line.matchAll(UPS_ID_RE)];
332
+ if (matches.length === 0) continue;
333
+ add(matches[matches.length - 1][1]);
334
+ }
335
+ },
336
+ },
310
337
  };
311
338
 
312
339
  // The query-conditioned faces, in citation_surface_log label order. keyctx is
@@ -452,19 +479,39 @@ export function extractAllInjected(transcriptPath, opts = {}) {
452
479
  return unionSurfaces(extractInjectedBySurface(transcriptPath, opts));
453
480
  }
454
481
 
482
+ /**
483
+ * Faces that are METERED (a citation_surface_log row) but deliberately kept OUT of the
484
+ * citation-decay denominator, with the reason each is here. Membership is a decision,
485
+ * never a default: `unionSurfaces` still walks every SURFACE_MATCHERS key, and a test
486
+ * requires each face to be either in the union or listed here — so a face added later
487
+ * cannot slip out of the denominator by being forgotten, only by being argued for.
488
+ *
489
+ * - `task_imperative` (v3.76): metering it is the point of adding it — D#137/D#150/D#151
490
+ * are all blocked on not knowing this face's cite-rate. Widening the denominator at the
491
+ * same moment would change what gets demoted in every live install on upgrade, and it
492
+ * would do so on the face whose framing is itself the open question: if the imperative
493
+ * framing under-performs, the penalty lands on the LESSONS it carried (imperativePick
494
+ * selects high-value ones) rather than on the framing. Read the rate first, then decide.
495
+ * Removing an entry from this set is the one-line change that widens the denominator.
496
+ */
497
+ const DECAY_EXCLUDED_SURFACES = new Set(['task_imperative']);
498
+
499
+ /** @type {ReadonlyArray<string>} faces that DO feed the decay denominator. */
500
+ export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DECAY_EXCLUDED_SURFACES.has(f));
501
+
455
502
  /**
456
503
  * Flatten a per-face breakdown into the single injected set the decay loop
457
504
  * takes. Derived — NOT a second hand-maintained face list — so adding a face to
458
505
  * SURFACE_MATCHERS automatically widens the denominator (v45; the pre-v45 union
459
506
  * enumerated the faces a second time and that is exactly how a face goes
460
- * unmetered).
507
+ * unmetered), unless that face is argued into DECAY_EXCLUDED_SURFACES above.
461
508
  *
462
509
  * @param {Record<string, Set<number>>} bySurface
463
510
  * @returns {Set<number>}
464
511
  */
465
512
  export function unionSurfaces(bySurface) {
466
513
  const out = new Set();
467
- for (const face of ATTACHMENT_SURFACES) {
514
+ for (const face of DECAY_DENOMINATOR_SURFACES) {
468
515
  for (const id of bySurface?.[face] || []) out.add(id);
469
516
  }
470
517
  return out;
@@ -11,7 +11,7 @@
11
11
  // the context). Remind-only by design: false positives cost one line, so the
12
12
  // signal list stays conservative and never auto-writes anything.
13
13
 
14
- import { readFileSync, existsSync } from 'fs';
14
+ import { readTranscriptEntries } from './transcript-scan.mjs';
15
15
 
16
16
  // Distinctive finalization word forms (CJK + EN). Deliberately NOT included:
17
17
  // "方案 A 定" and bare "定" (too FP-prone), bare "ok/好" (noise). The list is
@@ -62,14 +62,14 @@ const MEMDIR_WRITE_RE = /[\\/]\.claude[\\/]projects[\\/][^\\/]+[\\/]memory[\\/][
62
62
  * `save` / `defer add`) in the session transcript. 0 on missing/unreadable.
63
63
  */
64
64
  export function countDeliberatePersistence(transcriptPath) {
65
- if (!transcriptPath || !existsSync(transcriptPath)) return 0;
66
- let raw;
67
- try { raw = readFileSync(transcriptPath, 'utf8'); } catch { return 0; }
65
+ // Shares the Stop-time parse (lib/transcript-scan.mjs) rather than doing its own
66
+ // read+split+parse. It was the NINTH scanner of the same file and was missed when the
67
+ // other eight were collapsed sitting two lines away from two that were migrated, so
68
+ // it was quietly charging a full re-parse (~20-25ms on a 5.7MB transcript) against a
69
+ // pass whose whole point was to stop doing that. Missing/unreadable paths still yield
70
+ // 0, because readTranscriptEntries returns [] for them.
68
71
  let count = 0;
69
- for (const line of raw.split('\n')) {
70
- if (!line.trim()) continue;
71
- let entry;
72
- try { entry = JSON.parse(line); } catch { continue; }
72
+ for (const entry of readTranscriptEntries(transcriptPath)) {
73
73
  if (entry.type !== 'assistant' && entry.message?.role !== 'assistant') continue;
74
74
  const content = entry.message?.content;
75
75
  if (!Array.isArray(content)) continue;
@@ -12,11 +12,18 @@ import { neutralizeContextDelimiters } from '../format-utils.mjs';
12
12
  // dropped.
13
13
  // Spec: docs/superpowers/specs/2026-06-29-task-imperative-memory-injection-design.md
14
14
 
15
+ // The emitted line's fixed head. Exported because lib/citation-tracker.mjs matches on
16
+ // it to meter this face: an extractor carrying its own copy of the framing is the exact
17
+ // shape that silenced Go panics in v3.74.0 (trigger and filter were two lists that were
18
+ // never the same list). One constant, both directions — change the framing and the
19
+ // meter follows, or nothing does.
20
+ export const TASK_IMPERATIVE_PREFIX = 'Memory — a past lesson applies to THIS task.';
21
+
15
22
  export function formatTaskImperative(lesson, id) {
16
23
  const body = neutralizeContextDelimiters(String(lesson || '').trim().replace(/\.$/, ''));
17
24
  if (!body) return '';
18
25
  const tag = (id === undefined || id === null || id === '') ? '' : ` (#${id})`;
19
- return `Memory — a past lesson applies to THIS task. You must: ${body}.${tag}`;
26
+ return `${TASK_IMPERATIVE_PREFIX} You must: ${body}.${tag}`;
20
27
  }
21
28
 
22
29
  // Subagent-dispatch framing. Subagents are memory-blind (plugin hooks don't fire
@@ -0,0 +1,26 @@
1
+ // lib/ups-query.mjs — the ONE query-cap definition for the UserPromptSubmit event.
2
+ //
3
+ // That event fires two hooks: scripts/user-prompt-search.js (the FYI block) and
4
+ // `hook.mjs user-prompt` (the <memory-context> block). v3.75.0 capped the first and left
5
+ // the second building an uncapped query from the raw prompt — the guard-on-one-path shape
6
+ // this codebase pays for more than any other. Both faces now import from here, so the cap
7
+ // cannot be present on one and absent on the other.
8
+ //
9
+ // The caps bound what is COMPUTED, not what is read. The stdin guards upstream
10
+ // (MAX_UPS_PROMPT_BYTES 64KB on path A, MAX_HOOK_STDIN_BYTES 256KB on path B) cap the
11
+ // input; sanitizeFtsQuery still costs 0.8ms on a normal prompt, 6.2ms on a 64KB ASCII one
12
+ // and 31.8ms on a 64KB CJK one (extractCjkKeywords is O(len x dict) over an unsegmented
13
+ // run), all of it before the model sees the turn. 2000 characters is a long prompt by any
14
+ // measure, and past ~64 meaningful AND-joined terms an FTS5 query matches nothing anyway
15
+ // and survives only through the OR fallback.
16
+ //
17
+ // An explicit `claude-mem-lite search` stays UNCAPPED — a person who types a long query
18
+ // meant it. Only these two automatic surfaces pass the caps.
19
+ import { sanitizeFtsQuery } from '../utils.mjs';
20
+
21
+ export const UPS_QUERY_CAPS = { maxChars: 2000, maxTokens: 64 };
22
+
23
+ /** The capped query builder every automatic prompt-time search path goes through. */
24
+ export function upsFtsQuery(text) {
25
+ return sanitizeFtsQuery(text, UPS_QUERY_CAPS);
26
+ }
package/mem-cli.mjs CHANGED
@@ -70,11 +70,12 @@ import { computeCitationFunnelTrend, computeSurfaceFunnel } from './lib/citation
70
70
  // the citation-stats face table lines up; the enum itself lives in
71
71
  // lib/citation-tracker.mjs (CITATION_SURFACES).
72
72
  const SURFACE_LABELS = {
73
- pretool: 'PreToolUse recall ',
74
- ups: 'UserPromptSubmit ',
75
- error_recall: 'error-recall ',
76
- fyi: 'FYI (prompt-search)',
77
- keyctx: 'Key Context ',
73
+ pretool: 'PreToolUse recall ',
74
+ ups: 'UserPromptSubmit ',
75
+ error_recall: 'error-recall ',
76
+ fyi: 'FYI (prompt-search)',
77
+ task_imperative: 'task-imperative ',
78
+ keyctx: 'Key Context ',
78
79
  };
79
80
  import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
80
81
  import {
@@ -2806,6 +2807,9 @@ Commands:
2806
2807
 
2807
2808
  doctor Environment diagnostics and benchmarks
2808
2809
  --benchmark Run perf benchmark and emit JSON
2810
+ --metrics Summarize the recorded metrics window (CLAUDE_MEM_METRICS=1)
2811
+ --session-audit Audit session/episode state for orphans and drift
2812
+ --json Machine-readable output (plain doctor run)
2809
2813
 
2810
2814
  fts-check <check|rebuild> FTS5 index check or rebuild
2811
2815
 
@@ -2841,6 +2845,7 @@ Commands:
2841
2845
  import Import resource --name N --resource-type T [--repo-url U] [--local-path P] [--use-cases U]
2842
2846
  remove Remove resource --name N --resource-type T
2843
2847
  reindex Rebuild FTS5 index
2848
+ recommend-stats Shadow recommendation funnel [--days N] [--sweep] [--json]
2844
2849
 
2845
2850
  import-jsonl <file-or-dir> Import Claude Code JSONL transcripts (cold-start backfill)
2846
2851
  --project P Project name (default: inferred from cwd)
package/memdir.mjs CHANGED
@@ -209,7 +209,11 @@ export function writePluginSection(memdir, { slug, version, contentLine, force =
209
209
  }
210
210
  }
211
211
 
212
- const next = raw.replace(match[0], freshSection);
212
+ // Function form, NOT the string form: a string second argument has `$&` / `$1` /
213
+ // "$`" / `$'` interpreted, and freshSection carries caller-supplied contentLine — a
214
+ // `$&` there would expand to the whole matched sentinel block. Same fix claudemd.mjs
215
+ // took at its own replace (v3.40); this was the twin that kept the string form.
216
+ const next = raw.replace(match[0], () => freshSection);
213
217
  const changed = next !== raw;
214
218
  if (changed) atomicWrite(path, next);
215
219
  writeState(memdir, slug, { version, bodyHash: freshHash, writtenAt: new Date().toISOString() });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.75.0",
3
+ "version": "3.75.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.75.0",
9
+ "version": "3.75.1",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
@@ -21,6 +21,7 @@
21
21
  "eslint": "^10.0.0",
22
22
  "fast-check": "^4.5.3",
23
23
  "knip": "^6.12.1",
24
+ "picomatch": "^4.0.4",
24
25
  "vitest": "^4.0.18"
25
26
  },
26
27
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.75.0",
3
+ "version": "3.75.1",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -113,6 +113,7 @@
113
113
  "lib/maintain-core.mjs",
114
114
  "lib/fast-summary.mjs",
115
115
  "lib/transcript-scan.mjs",
116
+ "lib/ups-query.mjs",
116
117
  "lib/dedup-constants.mjs",
117
118
  "lib/deferred-work.mjs",
118
119
  "lib/upgrade-banner.mjs",
@@ -198,6 +199,7 @@
198
199
  "eslint": "^10.0.0",
199
200
  "fast-check": "^4.5.3",
200
201
  "knip": "^6.12.1",
202
+ "picomatch": "^4.0.4",
201
203
  "vitest": "^4.0.18"
202
204
  }
203
205
  }
package/schema.mjs CHANGED
@@ -280,7 +280,9 @@ const CORE_SCHEMA = `
280
280
  -- v45: per-INJECTION-FACE twin of citation_log. One row per
281
281
  -- (project, session, surface); the surface column is one of the
282
282
  -- CITATION_SURFACES enum in lib/citation-tracker.mjs
283
- -- (pretool | ups | error_recall | fyi | keyctx).
283
+ -- (pretool | ups | error_recall | fyi | task_imperative | keyctx). The column is plain
284
+ -- TEXT with no CHECK: the JS enum is the gate (recordCitationSurfaces drops unknown
285
+ -- labels), which is why adding a face needs no migration.
284
286
  --
285
287
  -- session_id is the CLAUDE CODE session id, NOT the memory session id that
286
288
  -- keys citation_log. The two tables therefore do NOT join, on purpose. The
File without changes
@@ -4,9 +4,10 @@
4
4
  // Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
5
5
 
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, notLowSignalTitleClause, stripPrivate, neutralizeContextDelimiters, MAX_UPS_PROMPT_BYTES } from '../utils.mjs';
7
+ import { relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, notLowSignalTitleClause, stripPrivate, neutralizeContextDelimiters, MAX_UPS_PROMPT_BYTES } from '../utils.mjs';
8
8
  import { liveObsFilterSql, injectionRelevanceSql } from '../lib/inject-search-core.mjs';
9
9
  import { cjkPrecisionOk } from '../nlp.mjs';
10
+ import { upsFtsQuery } from '../lib/ups-query.mjs';
10
11
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
11
12
  import { join, sep } from 'path';
12
13
  import { pathToFileURL } from 'url';
@@ -343,21 +344,11 @@ export function hasExplicitSignal(text, { errSig, files, intent } = {}) {
343
344
  // ×3 runs) → VERDICT NET-POSITIVE. The only behavior delta is on-topic eagerness (naming
344
345
  // an identifier surfaces its obs) — the highest-precision injection trigger there is. The
345
346
  // prose stop-list (IDENTIFIER_STOPWORDS) keeps the extractor off ordinary English.
346
- export // Query caps for THIS surface only (audit 2026-08-22 P2-13). The 64KB byte guard on
347
- // stdin bounds what we read, not what we compute: sanitizeFtsQuery costs 0.8ms on a
348
- // normal prompt, 6.2ms on a 64KB ASCII one and 31.8ms on a 64KB CJK one, and this hook
349
- // runs on every prompt before the model sees the turn. 2000 characters is a long prompt
350
- // by any measure, and past ~64 meaningful AND-joined terms an FTS5 query matches nothing
351
- // anyway and survives only through the OR fallback. An explicit `search` stays uncapped
352
- // — a person who types a long query meant it.
353
- const UPS_QUERY_CAPS = { maxChars: 2000, maxTokens: 64 };
354
-
355
- /** The capped query builder both search paths on this surface go through. */
356
- export function upsFtsQuery(text) {
357
- return sanitizeFtsQuery(text, UPS_QUERY_CAPS);
358
- }
347
+ // Query caps live in lib/ups-query.mjs shared with `hook.mjs user-prompt`, the OTHER
348
+ // hook this same event fires. v3.75.0 capped this face only; a second copy of the
349
+ // constants here is what would let them drift apart again.
359
350
 
360
- const IDENTIFIER_BYPASS = process.env.CLAUDE_MEM_UPS_IDENTIFIER_BYPASS !== '0';
351
+ export const IDENTIFIER_BYPASS = process.env.CLAUDE_MEM_UPS_IDENTIFIER_BYPASS !== '0';
361
352
  const TECH_IDENTIFIER_RE_G = new RegExp(TECH_IDENTIFIER_RE.source, 'g');
362
353
 
363
354
  // All tech-identifier tokens in `text`, lowercased + de-duped (for case-insensitive
package/source-files.mjs CHANGED
@@ -149,6 +149,9 @@ export const SOURCE_FILES = [
149
149
  // search-engine.mjs AND the standalone hook scripts — missing it from the
150
150
  // manifest kills every retrieval surface on auto-update.
151
151
  'lib/inject-search-core.mjs',
152
+ // Shared UserPromptSubmit query caps — imported by BOTH hooks that event fires
153
+ // (scripts/user-prompt-search.js and hook.mjs user-prompt via hook-memory.mjs).
154
+ 'lib/ups-query.mjs',
152
155
  // P2-12 twin cores: get/browse shared data collection for the CLI/MCP pairs
153
156
  // (update lives in observation-write, delete-preview in delete-core, registry
154
157
  // stats/list in registry.mjs — all already listed).
package/utils.mjs CHANGED
@@ -21,6 +21,9 @@ export { detectBashSignificance, extractErrorKeywords, planErrorRecall, extractF
21
21
  // Internal imports for functions that remain in this module
22
22
  import { truncate } from './format-utils.mjs';
23
23
  import { stripTestSuffix } from './bash-utils.mjs';
24
+ // Static, and deliberately the dependency-free resolver (node:os + node:path only) —
25
+ // debugCatch's sampler must not pull in the DB layer. See its comment below.
26
+ import { resolveDataDir } from './lib/resolve-data-dir.mjs';
24
27
 
25
28
  // ─── Sentinel Values ────────────────────────────────────────────────────────
26
29
 
@@ -266,14 +269,19 @@ export function debugCatch(e, context) {
266
269
  // Sampled-to-disk surface for post-mortem. Lazy-loaded so fs-less paths
267
270
  // don't pay the module cost; wrapped in try so sampler faults never crash
268
271
  // the caller (debugCatch is the error-handler-of-last-resort path).
272
+ //
273
+ // The data dir comes from lib/resolve-data-dir.mjs (imports: node:os, node:path) and
274
+ // NOT from schema.mjs's DB_DIR. This is the last-resort error path, so it must not
275
+ // inherit the DB layer's import graph: with schema.mjs unresolvable, the sampler wrote
276
+ // NOTHING — the trail meant to explain a broken install disappeared with it (verified
277
+ // by blocking the specifier; tests/debug-catch-sampler-deps.test.mjs). Resolving at
278
+ // call time also honours a data dir redirected after module load, which DB_DIR (a
279
+ // load-time constant) does not.
269
280
  if (process.env.CLAUDE_MEM_CATCH_SAMPLE) {
270
281
  (async () => {
271
282
  try {
272
- const [{ maybeSampleError }, { DB_DIR }] = await Promise.all([
273
- import('./lib/err-sampler.mjs'),
274
- import('./schema.mjs'),
275
- ]);
276
- maybeSampleError(e, context, DB_DIR);
283
+ const { maybeSampleError } = await import('./lib/err-sampler.mjs');
284
+ maybeSampleError(e, context, resolveDataDir(process.env.CLAUDE_MEM_DIR));
277
285
  } catch { /* sampler dynamic-import fault must not propagate */ }
278
286
  })();
279
287
  }