claude-mem-lite 3.75.0 → 3.76.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.75.0",
13
+ "version": "3.76.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.75.0",
3
+ "version": "3.76.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/adopt-content.mjs CHANGED
@@ -159,7 +159,7 @@ PreToolUse hook 在你 Read / Edit / Write 文件前已自动 \`mem_recall\` 该
159
159
  | 改某条 | \`${CLI_INVOKE} update <id> [--lesson "<≤500>"] [--title T] [--type T] [--importance 1-3] [--narrative T] [--concepts "a b c"]\` |
160
160
  | 事件日志 | \`${CLI_INVOKE} activity save --type <bugfix\\|lesson\\|bug\\|discovery\\|refactor\\|feature\\|observation\\|decision> "<title>" [--body T] [--files f1,f2]\` |
161
161
 
162
- \`maintain\` / \`optimize\` / \`compress\` 见上方「维护 / 管理类工具」;\`maintain --ops\` 取值 \`cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum\`,\`--retain-days\` ∈ [7,365]。
162
+ \`maintain\` / \`optimize\` / \`compress\` 见上方「维护 / 管理类工具」;\`maintain --ops\` 取值 \`cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum\`,省略时默认 \`cleanup,decay,boost,demote_pinned\`(顺序有意义:demote_pinned 必须在 boost 之后);\`--retain-days\` ∈ [7,365]。
163
163
 
164
164
  ## 卸载 / 关闭
165
165
 
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';
@@ -50,7 +50,7 @@ import { formatHookError } from './lib/native-binding-hint.mjs';
50
50
  import { recordHookError } from './lib/hook-telemetry.mjs';
51
51
  import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
52
52
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
53
- import { cleanupBroken, decayAndMarkIdle, boostAccessed, markAutoCompressible, selectFuzzyDedupeIds, stampDedupSuperseded, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
53
+ import { cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, resolveDefaultMaintainOps, markAutoCompressible, selectFuzzyDedupeIds, stampDedupSuperseded, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
54
54
  import { snapshotDb } from './lib/db-backup.mjs';
55
55
  import {
56
56
  extractCitationsFromTranscript,
@@ -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`);
@@ -1073,6 +1122,36 @@ function runSessionStartAutoMaintain(db, project) {
1073
1122
  }
1074
1123
  }
1075
1124
 
1125
+ // v3.76.0: the automatic path used to promote and never demote. boostAccessed ran
1126
+ // above; demotePinned was not even imported here, and it sat outside the default op
1127
+ // set of the CLI and MCP faces too — so the only op that can reach a
1128
+ // heavily-injected-but-uncited row (regular decay deliberately protects
1129
+ // injection_count>0) ran solely when a human typed `--ops demote_pinned`. Measured
1130
+ // on the maintainer's live DB before the fix: 148 rows demoted by citation decay,
1131
+ // never cited, and back at importance>=3, 148/148 of them boost-eligible.
1132
+ //
1133
+ // Placed AFTER boost, for the obvious reason: boostAccessed lifts any
1134
+ // access_count>3 row with importance<3, so demoting first hands the row straight
1135
+ // back at 2 (mem-cli had exactly that order and silently undid its own demotion
1136
+ // inside a single run).
1137
+ //
1138
+ // Placed AFTER fuzzy dedup, for a less obvious one, found by pre-tag review: the
1139
+ // dedup block above re-SELECTs `importance` and selectFuzzyDedupeIds keeps the
1140
+ // higher-importance member of a near-duplicate pair. Demoting first inverted that
1141
+ // rule using a value rewritten 40 lines earlier in the same pass — the pinned row
1142
+ // lost and was tombstoned, keeping the copy WITHOUT the injection history. Dedup
1143
+ // now decides on pre-demotion importance. Nothing between boost and here reads
1144
+ // importance, so the move is free.
1145
+ //
1146
+ // Whole-DB mctx (projectFilter ''), so unlike markAutoCompressibleIfDue this needs
1147
+ // NO per-project gate: one run under the global 24h gate covers every project at
1148
+ // once. Do not "fix" it into a per-project gate — that is the v3.75.0 regression in
1149
+ // reverse.
1150
+ const demotedPinned = resolveDefaultMaintainOps().includes('demote_pinned')
1151
+ ? demotePinned(db, mctx)
1152
+ : 0;
1153
+ if (demotedPinned > 0) debugLog('DEBUG', 'auto-maintain', `demoted ${demotedPinned} pinned-but-uncited observations (no lesson → 1, lesson → 2)`);
1154
+
1076
1155
  // Orphan sweep: remove `ep-flush-*` / `pending-*` runtime files older
1077
1156
  // than 1h. handleLLMEpisode normally unlinks its own tmpFile on every
1078
1157
  // exit path, but a crashed worker (OOM, host reboot, kill -9) leaves
@@ -1112,11 +1191,19 @@ function runSessionStartAutoMaintain(db, project) {
1112
1191
  // a detached `auto-maintain` worker via spawnBackground so it never blocks interactive
1113
1192
  // session start. The worker re-checks the same gate (idempotent) before doing the work.
1114
1193
  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 */ }
1194
+ // TWO gates, either of which is enough to spawn. Checking only the global one is what
1195
+ // made the marking single-project in v3.75.0: in a multi-project rotation the global
1196
+ // stamp is already fresh by the time the second project boots, so its worker never
1197
+ // ran and its rows were never marked.
1198
+ const due = (file) => {
1199
+ try {
1200
+ const last = JSON.parse(readFileSync(file, 'utf8'));
1201
+ return Date.now() - last.epoch >= 24 * 3600000;
1202
+ } catch { return true; } // no gate file → due
1203
+ };
1204
+ const maintainDue = due(join(RUNTIME_DIR, 'last-auto-maintain.json'));
1205
+ const markingDue = Boolean(project) && due(markCompressibleGateFile(project));
1206
+ if (!maintainDue && !markingDue) return;
1120
1207
  if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
1121
1208
  }
1122
1209
 
@@ -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;
@@ -30,6 +30,45 @@ export const MINHASH_PRE_THRESHOLD = MINHASH_PRE_THRESHOLD_SRC;
30
30
  // the regular decay op can't touch (decay protects injection_count>0).
31
31
  export const PINNED_INJ_THRESHOLD = 8;
32
32
 
33
+ // Single home for the default maintenance op set AND its order.
34
+ //
35
+ // Three faces run maintenance — hook.mjs auto-maintain, CLI `maintain execute`,
36
+ // MCP `mem_maintain` — and each used to hand-list its own default set. They had
37
+ // drifted in both ways a hand-copied list can:
38
+ // - `demote_pinned` was in NOBODY's default set, and hook.mjs did not even
39
+ // import demotePinned. Its opponent `boostAccessed` was in all three. So the
40
+ // automatic path promoted and never demoted: measured on the maintainer's live
41
+ // DB, 148 rows sat demoted-by-citation-decay, never cited, and back at
42
+ // importance>=3 — 148/148 of them boostAccessed-eligible (access_count>3).
43
+ // - the two faces that DID wire the op ran it in opposite orders: mem-cli did
44
+ // demote-then-boost, which hands the row straight back (importance 1 → 2);
45
+ // server.mjs did boost-then-demote, which lands it at 1.
46
+ // Hence: order matters, and `demote_pinned` MUST come after `boost`.
47
+ export const DEFAULT_MAINTAIN_OPS = Object.freeze(['cleanup', 'decay', 'boost', 'demote_pinned']);
48
+
49
+ // Opt-out for the v3.76.0 default change. Scoped to the DEFAULT set ONLY — an
50
+ // explicit `--ops demote_pinned` / `operations:["demote_pinned"]` still runs. An
51
+ // accepted value that silently means something else is worse than an unsupported
52
+ // one (cf. CLAUDE_MEM_RECOMMEND_MODE=live, which parses and then does not do what
53
+ // it says).
54
+ // The first cut of this compared `=== '1'`, which silently ignored `=true` / `=yes` /
55
+ // `= 1` — precisely the failure mode the comment above warns about, committed three
56
+ // lines under it. Sibling skip-flags in this repo are bare truthiness checks
57
+ // (`if (!process.env.CLAUDE_MEM_SKIP_COMPRESS)`), so any non-empty value opts out;
58
+ // the falsey WORDS are honoured too, because `=0` or `=false` reading as "skip" is the
59
+ // same class of silent surprise in the other direction.
60
+ function envFlagEnabled(raw) {
61
+ if (raw === undefined || raw === null) return false;
62
+ const v = String(raw).trim().toLowerCase();
63
+ return v !== '' && v !== '0' && v !== 'false' && v !== 'no' && v !== 'off';
64
+ }
65
+
66
+ export function resolveDefaultMaintainOps(env = process.env) {
67
+ return envFlagEnabled(env?.CLAUDE_MEM_SKIP_DEMOTE_PINNED)
68
+ ? DEFAULT_MAINTAIN_OPS.filter((op) => op !== 'demote_pinned')
69
+ : [...DEFAULT_MAINTAIN_OPS];
70
+ }
71
+
33
72
  // Two trimmed bodies count as "the same body" when both are empty (a genuine
34
73
  // no-body re-save) or their word-set Jaccard clears the floor. One-empty-one-not
35
74
  // is treated as DISTINCT so a body-bearing observation is never hidden by a
@@ -158,10 +197,23 @@ export function markAutoCompressible(db, project, {
158
197
  // The write-side capNoiseImportance already forces imp=1 on these; this only shrinks GC
159
198
  // latency so the corpus reduction materializes within a week instead of bleeding into
160
199
  // the 30-day tier.
200
+ //
201
+ // v3.76.0: `injection_count = 0` added here to match the aged pass above, which has
202
+ // carried it since v2.56.0. Until this release nothing could reach BOTH `importance<=1`
203
+ // and `injection_count>=1`, so the omission was unobservable; `demote_pinned` joining
204
+ // the default op set creates exactly that population. Without this clause a demoted row
205
+ // could be marked COMPRESSED_AUTO on the NEXT maintain run — hidden from every
206
+ // `COALESCE(compressed_into,0)=0` read path, therefore never injected, therefore never
207
+ // cited, therefore with no path back. Pre-tag review reproduced that chain end to end.
208
+ // Today the two passes are also protected by an interlock — the title patterns below
209
+ // are the same set `notLowSignalTitleClause` (lib/low-signal-patterns.mjs) keeps off the
210
+ // only surfaces that bump `injection_count` — but that is two hand-listed sets in two
211
+ // files agreeing by maintenance, which is not a guarantee. This clause is.
161
212
  const noise = db.prepare(`
162
213
  UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
163
214
  WHERE COALESCE(compressed_into, 0) = 0
164
215
  AND COALESCE(importance, 1) <= 1
216
+ AND COALESCE(injection_count, 0) = 0
165
217
  AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
166
218
  AND (facts IS NULL OR facts = '' OR facts = '[]')
167
219
  AND (
@@ -340,21 +392,44 @@ export function boostAccessed(db, { projectFilter, baseParams, opCap = OP_CAP })
340
392
  `).run(...baseParams).changes;
341
393
  }
342
394
 
395
+ // Every other automatic pass in this file carries this clause verbatim (lines 179, 192,
396
+ // 299, 334) — a lesson is the distilled value a lessons store exists to hold, and
397
+ // background machinery must not quietly dispose of one.
398
+ const NO_LESSON_SQL = "(lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')";
399
+
343
400
  /**
344
401
  * Repair the citation-decay blind spot: heavy-injection + zero-citation rows that
345
- * decay protects (injection_count>0) stay pinned at max importance forever. Drop
346
- * them to importance 1 in one pass (injection priority is binary at >=2, so a
347
- * single step would not de-rank). Floor 1, not purge.
402
+ * decay protects (injection_count>0) stay pinned at max importance forever. Floor
403
+ * them; never purge.
404
+ *
405
+ * TWO floors, and the asymmetry is the point. `importance >= 2` is a hard WHERE on the
406
+ * injection faces that actually earn citations — pre-tool-recall
407
+ * (scripts/pre-tool-recall.js:428,470), SessionStart Key Context (hook-context.mjs:95,315)
408
+ * and cross-project (hook-memory.mjs:280) — whereas `injection_count`, the signal that
409
+ * triggers this op at all, is incremented ONLY on the two UserPromptSubmit faces
410
+ * (hook-memory.mjs:367, scripts/user-prompt-search.js:979). Those are the weakest faces
411
+ * by measured cite-rate. Dropping straight to 1 therefore convicts a row on its weakest
412
+ * surface and evicts it from its strongest — which pre-tag review caught: on the
413
+ * maintainer's live DB, 16 of the 17 rows this op would have moved were lesson-bearing.
414
+ *
415
+ * no lesson -> 1 (fully de-ranked; the original behaviour, unchanged)
416
+ * lesson -> 2 (loses the top tier and its ranking weight, keeps eligibility on
417
+ * every importance>=2 face)
418
+ *
419
+ * The floor doubles as the WHERE bound so a row already sitting at its floor is not
420
+ * re-touched: SQLite counts a same-value UPDATE in `changes`, which would otherwise
421
+ * report phantom demotions on every run forever.
348
422
  */
349
423
  export function demotePinned(db, { projectFilter, baseParams, opCap = OP_CAP }) {
424
+ const floor = `(CASE WHEN ${NO_LESSON_SQL} THEN 1 ELSE 2 END)`;
350
425
  return db.prepare(`
351
- UPDATE observations SET importance = 1
426
+ UPDATE observations SET importance = ${floor}
352
427
  WHERE id IN (
353
428
  SELECT id FROM observations
354
429
  WHERE COALESCE(compressed_into, 0) = 0
355
430
  AND COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
356
431
  AND COALESCE(cited_count, 0) = 0
357
- AND COALESCE(importance, 1) > 1
432
+ AND COALESCE(importance, 1) > ${floor}
358
433
  ${projectFilter} LIMIT ${opCap}
359
434
  )
360
435
  `).run(...baseParams).changes;
@@ -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
@@ -30,7 +30,7 @@ import {
30
30
  recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
31
31
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
32
32
  hardDeleteCandidateCount,
33
- OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
33
+ OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD, resolveDefaultMaintainOps,
34
34
  } from './lib/maintain-core.mjs';
35
35
  import { snapshotDb, listSnapshots, backupBudgetBytes } from './lib/db-backup.mjs';
36
36
  import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
@@ -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 {
@@ -2011,7 +2012,7 @@ function cmdMaintain(db, args) {
2011
2012
  out(` Stale (>30d, imp=1, no access, never injected): ${stats.stale}`);
2012
2013
  out(` Broken (no title/narrative): ${stats.broken}`);
2013
2014
  out(` Boostable (accessed>3, imp<3): ${stats.boostable}`);
2014
- out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — run: maintain execute --ops demote_pinned`);
2015
+ out(` Pinned-but-uncited (inj>=${PINNED_INJ_THRESHOLD}, cited=0, imp>1): ${stats.pinned} — cleared by the default maintain set since v3.76.0 (opt out: CLAUDE_MEM_SKIP_DEMOTE_PINNED=1)`);
2015
2016
  out(formatPendingPurgeLine(stats.pendingPurge));
2016
2017
  if (duplicates.length > 0) {
2017
2018
  const autoMergeable = duplicates.filter(d => parseFloat(d.similarity) >= AUTO_MERGE_THRESHOLD);
@@ -2046,9 +2047,11 @@ function cmdMaintain(db, args) {
2046
2047
  const VALID_OPS = ['cleanup', 'decay', 'boost', 'demote_pinned', 'dedup', 'purge_stale', 'rebuild_vectors', 'vacuum'];
2047
2048
  // Distinguish flag-absent (use default op set) from flag-present-but-empty
2048
2049
  // (`--ops ""`, e.g. an unset shell var). The latter previously coerced via `||`
2049
- // to the destructive default cleanup,decay,boost and EXECUTED it; route it to the
2050
- // VALID_OPS check below instead so it's rejected like `--ops " "` / `--ops "decay,"`.
2051
- const opsStr = flags.ops === undefined ? 'cleanup,decay,boost' : String(flags.ops);
2050
+ // to the destructive default set and EXECUTED it; route it to the VALID_OPS check
2051
+ // below instead so it's rejected like `--ops " "` / `--ops "decay,"`. (That default
2052
+ // was the literal `cleanup,decay,boost` when this was written; it now comes from
2053
+ // DEFAULT_MAINTAIN_OPS, which is why the list is no longer spelled out here.)
2054
+ const opsStr = flags.ops === undefined ? resolveDefaultMaintainOps().join(',') : String(flags.ops);
2052
2055
  const ops = opsStr.split(',').map(s => s.trim());
2053
2056
  const invalidOps = ops.filter(op => !VALID_OPS.includes(op));
2054
2057
  if (invalidOps.length > 0) {
@@ -2137,17 +2140,23 @@ function cmdMaintain(db, args) {
2137
2140
  results.push(`Decayed ${decayed} stale observations, marked ${idleMarked} idle as pending-purge${decayCap}`);
2138
2141
  }
2139
2142
 
2143
+ if (ops.includes('boost')) {
2144
+ const boosted = boostAccessed(db, mctx);
2145
+ results.push(`Boosted ${boosted} frequently-accessed observations${capHint(boosted)}`);
2146
+ }
2147
+
2148
+ // AFTER boost, matching server.mjs and hook.mjs. This block used to sit BEFORE
2149
+ // it, and the order was load-bearing in the wrong direction: boostAccessed lifts
2150
+ // any access_count>3 row with importance<3, so demoting a pinned row to 1 and
2151
+ // then boosting handed it straight back at 2 — the demotion silently undone
2152
+ // inside a single maintain run. DEFAULT_MAINTAIN_OPS pins the order; this block
2153
+ // has to physically follow the boost block for that order to be real.
2140
2154
  if (ops.includes('demote_pinned')) {
2141
2155
  // Repair the citation-decay blind spot: decay protects injection_count>0, so a
2142
2156
  // heavily-injected-but-uncited memory stays pinned at max importance forever.
2143
2157
  // demotePinned (maintain-core) drops it to 1 in one pass. Floor 1, not purge.
2144
2158
  const demoted = demotePinned(db, mctx);
2145
- results.push(`Demoted ${demoted} pinned-but-uncited observations to importance 1 (inj>=${PINNED_INJ_THRESHOLD}, cited=0)${capHint(demoted)}`);
2146
- }
2147
-
2148
- if (ops.includes('boost')) {
2149
- const boosted = boostAccessed(db, mctx);
2150
- results.push(`Boosted ${boosted} frequently-accessed observations${capHint(boosted)}`);
2159
+ results.push(`Demoted ${demoted} pinned-but-uncited observations (inj>=${PINNED_INJ_THRESHOLD}, cited=0; no lesson → importance 1, lesson → 2)${capHint(demoted)}`);
2151
2160
  }
2152
2161
 
2153
2162
  if (ops.includes('dedup') && flags['merge-ids']) {
@@ -2783,10 +2792,15 @@ Commands:
2783
2792
 
2784
2793
  maintain <scan|execute> Memory maintenance
2785
2794
  --ops O Comma-separated: cleanup,decay,boost,demote_pinned,dedup,purge_stale,rebuild_vectors,vacuum
2795
+ Default when omitted: cleanup,decay,boost,demote_pinned (in that order)
2786
2796
  --merge-ids K:R,... For dedup: keepId:removeId pairs (e.g. 10:11,20:21:22)
2787
2797
  --project P Filter by project
2788
2798
  --retain-days N For purge_stale: keep last N days (default 30)
2789
- demote_pinned: importance→1 for inj>=8 & cited=0 (clears pinned noise)
2799
+ demote_pinned: importance→1 for inj>=8 & cited=0 (clears pinned noise).
2800
+ In the default set since v3.76.0; runs AFTER boost, which would
2801
+ otherwise hand the row straight back. Opt out of the DEFAULT with
2802
+ CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 — an explicit --ops demote_pinned
2803
+ still runs.
2790
2804
  vacuum: reclaim freelist dead space (whole-DB, ignores --project)
2791
2805
 
2792
2806
  optimize LLM-powered memory optimization (preview by default)
@@ -2806,6 +2820,9 @@ Commands:
2806
2820
 
2807
2821
  doctor Environment diagnostics and benchmarks
2808
2822
  --benchmark Run perf benchmark and emit JSON
2823
+ --metrics Summarize the recorded metrics window (CLAUDE_MEM_METRICS=1)
2824
+ --session-audit Audit session/episode state for orphans and drift
2825
+ --json Machine-readable output (plain doctor run)
2809
2826
 
2810
2827
  fts-check <check|rebuild> FTS5 index check or rebuild
2811
2828
 
@@ -2841,6 +2858,7 @@ Commands:
2841
2858
  import Import resource --name N --resource-type T [--repo-url U] [--local-path P] [--use-cases U]
2842
2859
  remove Remove resource --name N --resource-type T
2843
2860
  reindex Rebuild FTS5 index
2861
+ recommend-stats Shadow recommendation funnel [--days N] [--sweep] [--json]
2844
2862
 
2845
2863
  import-jsonl <file-or-dir> Import Claude Code JSONL transcripts (cold-start backfill)
2846
2864
  --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.76.0",
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.76.0",
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.76.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",
@@ -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
@@ -42,8 +42,15 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
42
42
  //
43
43
  // `e.status` not `e.code`: execSync failures carry the exit status on `status`,
44
44
  // so the old `|| e.code` rung was dead.
45
+ // `?? null` not `!= null`: the loose form is the idiom, but this file is
46
+ // linted under `eqeqeq: always`, and rewriting it as `!== undefined` would
47
+ // be a BEHAVIOUR change — execSync reports a signal kill with `status: null`,
48
+ // which `!== undefined` accepts and would render as "npm exited null".
49
+ // Coalescing first keeps the original both-nullish semantics exactly, `0`
50
+ // included.
51
+ const status = e?.status ?? null;
45
52
  const detail = e?.message?.split('\n')[0]
46
- || (e?.status != null ? `npm exited ${e.status}` : '')
53
+ || (status !== null ? `npm exited ${status}` : '')
47
54
  || (e?.signal ? `npm killed by ${e.signal}` : '')
48
55
  || 'unknown error';
49
56
  process.stderr.write(`[claude-mem-lite] npm install failed in ${ROOT} — ${detail}\n`);
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/server.mjs CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
20
20
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
21
21
  hardDeleteCandidateCount,
22
- OP_CAP, STALE_AGE_MS,
22
+ OP_CAP, STALE_AGE_MS, resolveDefaultMaintainOps,
23
23
  } from './lib/maintain-core.mjs';
24
24
  import { snapshotDb } from './lib/db-backup.mjs';
25
25
  import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
@@ -1148,7 +1148,7 @@ server.registerTool(
1148
1148
  if (action === 'execute') {
1149
1149
  const ops = args.operations && args.operations.length > 0
1150
1150
  ? args.operations
1151
- : ['cleanup', 'decay', 'boost'];
1151
+ : resolveDefaultMaintainOps();
1152
1152
  // T2-P1-A: reject explicit empty array (vs. omitted → defaults above). Empty-array
1153
1153
  // callers are almost always mistakes; silently running only FTS5 optimize hides the error.
1154
1154
  if (args.operations && args.operations.length === 0) {
@@ -1239,7 +1239,7 @@ server.registerTool(
1239
1239
 
1240
1240
  if (ops.includes('demote_pinned')) {
1241
1241
  const demoted = demotePinned(db, mctx);
1242
- results.push(`Demoted ${demoted} pinned-but-uncited observations to importance 1 (inj>=8, cited=0)` + (demoted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1242
+ results.push(`Demoted ${demoted} pinned-but-uncited observations (inj>=8, cited=0; no lesson → importance 1, lesson → 2)` + (demoted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1243
1243
  }
1244
1244
 
1245
1245
  if (ops.includes('dedup') && args.merge_ids) {
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/tool-schemas.mjs CHANGED
@@ -247,7 +247,7 @@ export const memOptimizeSchema = {
247
247
  export const memMaintainSchema = {
248
248
  action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
249
249
  operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'demote_pinned', 'purge_stale', 'rebuild_vectors', 'vacuum'])).optional()
250
- .describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=importance→1 for obs injected>=8 times but never cited (clears pinned noise the decay op cannot reach), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
250
+ .describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=importance→1 for obs injected>=8 times but never cited (clears pinned noise the decay op cannot reach; in the default set since v3.76.0 and ordered after boost, since boost would otherwise raise the row straight back — set CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 to drop it from the DEFAULT set only), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
251
251
  merge_ids: z.preprocess(
252
252
  (v) => Array.isArray(v) ? v.map(g => Array.isArray(g) ? g.map(x => typeof x === 'string' ? parseInt(x, 10) : x) : g) : v,
253
253
  z.array(z.array(z.number().int()).min(2))
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
  }