claude-mem-lite 3.96.0 → 3.97.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.
Files changed (143) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/adopt-cli.mjs +125 -51
  4. package/bash-utils.mjs +105 -29
  5. package/claudemd.mjs +63 -19
  6. package/cli/activity.mjs +36 -18
  7. package/cli/common.mjs +101 -18
  8. package/cli/doctor.mjs +29 -9
  9. package/cli.mjs +69 -10
  10. package/deep-search.mjs +63 -20
  11. package/format-utils.mjs +15 -7
  12. package/haiku-client.mjs +189 -111
  13. package/hash-utils.mjs +15 -6
  14. package/hook-context.mjs +135 -46
  15. package/hook-episode.mjs +76 -23
  16. package/hook-handoff.mjs +216 -84
  17. package/hook-llm.mjs +476 -216
  18. package/hook-memory.mjs +106 -43
  19. package/hook-optimize.mjs +272 -109
  20. package/hook-precompact.mjs +1 -1
  21. package/hook-semaphore.mjs +46 -12
  22. package/hook-shared.mjs +101 -131
  23. package/hook-update.mjs +286 -88
  24. package/hook.mjs +1207 -617
  25. package/install-metadata.mjs +246 -125
  26. package/install.mjs +1327 -917
  27. package/lib/activity.mjs +52 -29
  28. package/lib/atomic-write.mjs +23 -4
  29. package/lib/binding-probe.mjs +40 -38
  30. package/lib/browse-core.mjs +22 -8
  31. package/lib/citation-tracker.mjs +189 -79
  32. package/lib/cite-back-hint.mjs +37 -19
  33. package/lib/cite-recall-path.mjs +3 -1
  34. package/lib/cli-flags.mjs +4 -2
  35. package/lib/cli-project.mjs +8 -5
  36. package/lib/compress-core.mjs +34 -9
  37. package/lib/cooldown-path.mjs +3 -1
  38. package/lib/db-backup.mjs +33 -11
  39. package/lib/deferred-work.mjs +73 -32
  40. package/lib/delete-core.mjs +25 -9
  41. package/lib/doctor-benchmark.mjs +5 -1
  42. package/lib/doctor-drift.mjs +25 -8
  43. package/lib/edge-attribution.mjs +23 -9
  44. package/lib/env-number.mjs +7 -8
  45. package/lib/err-sampler.mjs +22 -11
  46. package/lib/error-recall-core.mjs +15 -17
  47. package/lib/events-injection.mjs +25 -8
  48. package/lib/export-columns.mjs +49 -10
  49. package/lib/fast-summary.mjs +22 -9
  50. package/lib/file-edge-match.mjs +4 -2
  51. package/lib/file-intel.mjs +38 -13
  52. package/lib/frontmatter.mjs +22 -5
  53. package/lib/get-core.mjs +78 -11
  54. package/lib/handoff-constants.mjs +18 -0
  55. package/lib/hook-stdin.mjs +10 -3
  56. package/lib/hook-stdout.mjs +21 -7
  57. package/lib/hook-telemetry.mjs +44 -17
  58. package/lib/id-routing.mjs +11 -6
  59. package/lib/import-jsonl.mjs +92 -41
  60. package/lib/inject-search-core.mjs +5 -2
  61. package/lib/injected-ids.mjs +19 -12
  62. package/lib/install-shape.mjs +47 -23
  63. package/lib/keyctx-marker.mjs +3 -1
  64. package/lib/lesson-bridge.mjs +8 -5
  65. package/lib/llm-call.mjs +76 -0
  66. package/lib/llm-provider-probe.mjs +24 -12
  67. package/lib/low-signal-patterns.mjs +25 -22
  68. package/lib/maintain-core.mjs +221 -92
  69. package/lib/mem-override.mjs +4 -2
  70. package/lib/metrics.mjs +30 -25
  71. package/lib/native-binding-hint.mjs +29 -8
  72. package/lib/observation-write.mjs +74 -19
  73. package/lib/patha-exclude-meter.mjs +10 -6
  74. package/lib/persist-reminder.mjs +22 -10
  75. package/lib/plan-reader.mjs +2 -2
  76. package/lib/private-strip.mjs +3 -3
  77. package/lib/proc-lock.mjs +15 -3
  78. package/lib/proxy-fetch.mjs +49 -13
  79. package/lib/quiet-scope.mjs +42 -0
  80. package/lib/recall-core.mjs +19 -7
  81. package/lib/recent-core.mjs +18 -5
  82. package/lib/registry-core.mjs +12 -6
  83. package/lib/release-digest.mjs +4 -1
  84. package/lib/relevance-floor.mjs +5 -2
  85. package/lib/reread-guard.mjs +16 -8
  86. package/lib/resolve-data-dir.mjs +1 -1
  87. package/lib/rrf.mjs +4 -1
  88. package/lib/save-enrich.mjs +36 -18
  89. package/lib/save-observation.mjs +78 -35
  90. package/lib/scrub-record.mjs +20 -6
  91. package/lib/search-core.mjs +456 -123
  92. package/lib/shard-gc.mjs +50 -0
  93. package/lib/startup-dashboard.mjs +20 -9
  94. package/lib/stats-core.mjs +101 -35
  95. package/lib/stats-quality.mjs +56 -20
  96. package/lib/summary-extractor.mjs +2 -2
  97. package/lib/task-imperative.mjs +12 -4
  98. package/lib/timeline-core.mjs +88 -28
  99. package/lib/tmp-fixture-sweep.mjs +32 -9
  100. package/lib/transcript-scan.mjs +20 -4
  101. package/lib/upgrade-banner.mjs +11 -7
  102. package/mem-cli.mjs +1314 -506
  103. package/memdir.mjs +53 -21
  104. package/nlp.mjs +68 -38
  105. package/npm-shrinkwrap.json +2 -2
  106. package/package.json +9 -2
  107. package/plugin-cache-guard.mjs +31 -15
  108. package/project-utils.mjs +44 -20
  109. package/registry-enricher.mjs +26 -6
  110. package/registry-github.mjs +4 -2
  111. package/registry-importer.mjs +151 -40
  112. package/registry-recommend.mjs +189 -96
  113. package/registry-retriever.mjs +229 -70
  114. package/registry-scanner.mjs +11 -6
  115. package/registry.mjs +163 -66
  116. package/rerank.mjs +18 -4
  117. package/resource-discovery.mjs +14 -4
  118. package/schema.mjs +258 -99
  119. package/scoring-sql.mjs +21 -10
  120. package/scripts/binding-probe-cli.mjs +17 -12
  121. package/scripts/hook-launcher.mjs +148 -31
  122. package/scripts/launch-preflight.mjs +5 -7
  123. package/scripts/launch.mjs +21 -9
  124. package/scripts/post-tool-recall.js +21 -5
  125. package/scripts/pre-agent-inject.js +51 -13
  126. package/scripts/pre-skill-bridge.js +22 -7
  127. package/scripts/pre-tool-recall.js +157 -77
  128. package/scripts/prompt-search-utils.mjs +70 -25
  129. package/scripts/setup.sh +16 -1
  130. package/scripts/user-prompt-search.js +238 -97
  131. package/search-engine.mjs +329 -96
  132. package/search-scoring.mjs +75 -33
  133. package/secret-scrub.mjs +48 -12
  134. package/server/fts-check.mjs +12 -9
  135. package/server.mjs +674 -243
  136. package/skip-tools.mjs +19 -8
  137. package/source-files.mjs +54 -21
  138. package/stop-words.mjs +152 -19
  139. package/synonyms.mjs +579 -164
  140. package/tfidf.mjs +130 -40
  141. package/tier.mjs +1 -1
  142. package/tool-schemas.mjs +381 -131
  143. package/utils.mjs +184 -36
@@ -12,8 +12,8 @@
12
12
  // loaded only on the enabled Agent path). Fail-open: never exits non-zero, never blocks
13
13
  // a dispatch (a thrown hook would abort the user's subagent).
14
14
 
15
- const ENABLED = process.env.CLAUDE_MEM_SUBAGENT_INJECT === 'on'
16
- || process.env.CLAUDE_MEM_SUBAGENT_INJECT === '1';
15
+ const ENABLED =
16
+ process.env.CLAUDE_MEM_SUBAGENT_INJECT === 'on' || process.env.CLAUDE_MEM_SUBAGENT_INJECT === '1';
17
17
 
18
18
  // Telemetry via DYNAMIC import so the default-off fast path stays import-free
19
19
  // (the file's stated contract). Only ever reached on the enabled path's failure
@@ -30,7 +30,9 @@ async function recordFailure(scope, err, ctx) {
30
30
  // the error path, which already dynamic-imports, so the script's zero-import budget on
31
31
  // the HAPPY path is untouched.
32
32
  recordHookError(scope, err, resolveRuntimeDir(resolveDataDir(process.env.CLAUDE_MEM_DIR)), ctx);
33
- } catch { /* never */ }
33
+ } catch {
34
+ /* never */
35
+ }
34
36
  }
35
37
 
36
38
  // The ONE hand-written stdin reader left in the tree, and it stays deliberately (P1-9).
@@ -43,7 +45,14 @@ async function recordFailure(scope, err, ctx) {
43
45
  function readStdin() {
44
46
  return new Promise((resolve) => {
45
47
  let data = '';
46
- const timer = setTimeout(() => { try { process.stdin.destroy(); } catch { /* */ } resolve(data); }, 1500);
48
+ const timer = setTimeout(() => {
49
+ try {
50
+ process.stdin.destroy();
51
+ } catch {
52
+ /* */
53
+ }
54
+ resolve(data);
55
+ }, 1500);
47
56
  process.stdin.setEncoding('utf8');
48
57
  process.stdin.on('data', (c) => {
49
58
  data += c;
@@ -51,21 +60,39 @@ function readStdin() {
51
60
  // its own (see the no-forced-exit note at the bottom) rather than streaming to
52
61
  // the 1.5s timeout. 262144 = MAX_HOOK_STDIN_BYTES (utils.mjs) repeated as a
53
62
  // literal ON PURPOSE: the default-off fast path above must stay import-free.
54
- if (data.length > 262144) { clearTimeout(timer); try { process.stdin.destroy(); } catch { /* */ } resolve(data.slice(0, 262144)); }
63
+ if (data.length > 262144) {
64
+ clearTimeout(timer);
65
+ try {
66
+ process.stdin.destroy();
67
+ } catch {
68
+ /* */
69
+ }
70
+ resolve(data.slice(0, 262144));
71
+ }
72
+ });
73
+ process.stdin.on('end', () => {
74
+ clearTimeout(timer);
75
+ resolve(data);
76
+ });
77
+ process.stdin.on('error', () => {
78
+ clearTimeout(timer);
79
+ resolve(data);
55
80
  });
56
- process.stdin.on('end', () => { clearTimeout(timer); resolve(data); });
57
- process.stdin.on('error', () => { clearTimeout(timer); resolve(data); });
58
81
  process.stdin.resume();
59
82
  });
60
83
  }
61
84
 
62
85
  async function main() {
63
- if (!ENABLED) return; // default: cheapest possible no-op
64
- if (process.env.CLAUDE_MEM_HOOK_RUNNING) return; // recursion guard (background claude -p)
86
+ if (!ENABLED) return; // default: cheapest possible no-op
87
+ if (process.env.CLAUDE_MEM_HOOK_RUNNING) return; // recursion guard (background claude -p)
65
88
 
66
89
  const raw = await readStdin();
67
90
  let hook;
68
- try { hook = JSON.parse(raw); } catch { return; }
91
+ try {
92
+ hook = JSON.parse(raw);
93
+ } catch {
94
+ return;
95
+ }
69
96
  if (!hook || typeof hook !== 'object') return;
70
97
  if (hook.tool_name !== 'Agent' && hook.tool_name !== 'Task') return;
71
98
 
@@ -83,7 +110,12 @@ async function main() {
83
110
  const { queueHookUpdatedInput, flushHookStdout } = await import('../lib/hook-stdout.mjs');
84
111
 
85
112
  let db;
86
- try { db = ensureDb(); } catch (e) { await recordFailure('agent-inject:db-open', e); return; }
113
+ try {
114
+ db = ensureDb();
115
+ } catch (e) {
116
+ await recordFailure('agent-inject:db-open', e);
117
+ return;
118
+ }
87
119
  try {
88
120
  const updatedInput = buildSubagentInjection(db, hook.tool_input, inferProject());
89
121
  if (updatedInput) {
@@ -97,8 +129,14 @@ async function main() {
97
129
  queueHookUpdatedInput('PreToolUse', updatedInput);
98
130
  flushHookStdout();
99
131
  }
100
- } catch (e) { await recordFailure('agent-inject:query', e); /* never break a dispatch */ } finally {
101
- try { db.close(); } catch { /* */ }
132
+ } catch (e) {
133
+ await recordFailure('agent-inject:query', e); /* never break a dispatch */
134
+ } finally {
135
+ try {
136
+ db.close();
137
+ } catch {
138
+ /* */
139
+ }
102
140
  }
103
141
  }
104
142
 
@@ -10,7 +10,11 @@ import { recordHookError } from '../lib/hook-telemetry.mjs';
10
10
  import { resolveDataDir, resolveRuntimeDir } from '../lib/resolve-data-dir.mjs';
11
11
  // format-utils.mjs is import-free — pulling three defang helpers keeps this script
12
12
  // inside its "lightweight standalone" budget (no heavy transitive deps).
13
- import { neutralizeContextDelimiters, neutralizeSkillDelimiters, neutralizeSkillBridgeDelimiters } from '../format-utils.mjs';
13
+ import {
14
+ neutralizeContextDelimiters,
15
+ neutralizeSkillDelimiters,
16
+ neutralizeSkillBridgeDelimiters,
17
+ } from '../format-utils.mjs';
14
18
  // D#154: single envelope writer. Also import-free (no runtime deps), so it stays
15
19
  // inside this script's "lightweight standalone" budget.
16
20
  import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
@@ -68,13 +72,17 @@ try {
68
72
 
69
73
  try {
70
74
  // Query: find by name or invocation_name, ONLY if managed path
71
- const row = db.prepare(`
75
+ const row = db
76
+ .prepare(
77
+ `
72
78
  SELECT name, local_path FROM resources
73
79
  WHERE status = 'active'
74
80
  AND (name = ? OR invocation_name = ?)
75
81
  AND local_path LIKE ?
76
82
  LIMIT 1
77
- `).get(skillName, skillName, `%${MANAGED_MARKER}%`);
83
+ `,
84
+ )
85
+ .get(skillName, skillName, `%${MANAGED_MARKER}%`);
78
86
 
79
87
  if (!row || !row.local_path) process.exit(0);
80
88
 
@@ -97,7 +105,9 @@ try {
97
105
  // (notably sdscc) silently drop plain-text stdout from PreToolUse — the previous
98
106
  // console.log() form would render on stock CC but no-op on those variants.
99
107
  // Token budget: ~4 chars per token, 4000 token limit = 16000 chars.
100
- const portablePath = resolvedPath.startsWith(homedir()) ? '~' + resolvedPath.slice(homedir().length) : resolvedPath;
108
+ const portablePath = resolvedPath.startsWith(homedir())
109
+ ? '~' + resolvedPath.slice(homedir().length)
110
+ : resolvedPath;
101
111
  // Defang the untrusted skill body + name before wrapping (audit 2026-08-14 M-4):
102
112
  // registry rows come from third-party repos, and this was the one AUTO injection
103
113
  // surface of that data with zero neutralization — a body carrying a literal
@@ -106,7 +116,8 @@ try {
106
116
  // chars: it lands in an ATTRIBUTE position, where `"` breaks out of the wrapper
107
117
  // tag itself. Applied to the truncated summary too (a cut can't be trusted to
108
118
  // land mid-tag).
109
- const defang = (s) => neutralizeSkillBridgeDelimiters(neutralizeSkillDelimiters(neutralizeContextDelimiters(s)));
119
+ const defang = (s) =>
120
+ neutralizeSkillBridgeDelimiters(neutralizeSkillDelimiters(neutralizeContextDelimiters(s)));
110
121
  const safeName = String(row.name).replace(/["'<>]/g, '');
111
122
  let additionalContext;
112
123
  if (content.length > 16000) {
@@ -123,9 +134,13 @@ try {
123
134
  // Silent failure — never block Skill tool, but record for self-observation.
124
135
  recordHookError('skill-bridge:query', e, RUNTIME_DIR, { skillName });
125
136
  } finally {
126
- try { db.close(); } catch {}
137
+ try {
138
+ db.close();
139
+ } catch {}
127
140
  }
128
141
  } catch (e) {
129
142
  // Top-level catch — exit 0 no matter what, but record what slipped past.
130
- try { recordHookError('skill-bridge:top', e, RUNTIME_DIR); } catch {}
143
+ try {
144
+ recordHookError('skill-bridge:top', e, RUNTIME_DIR);
145
+ } catch {}
131
146
  }
@@ -8,7 +8,13 @@ import { existsSync, readFileSync, mkdirSync } from 'fs';
8
8
  import { basename, join } from 'path';
9
9
  import { resolveDataDir, resolveRuntimeDir } from '../lib/resolve-data-dir.mjs';
10
10
  import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
11
- import { injectedIdsFileName, injectedIdKey, EVENT_ID_PREFIX, readInjectedMarker, mergeInjectedMarker } from '../lib/injected-ids.mjs';
11
+ import {
12
+ injectedIdsFileName,
13
+ injectedIdKey,
14
+ EVENT_ID_PREFIX,
15
+ readInjectedMarker,
16
+ mergeInjectedMarker,
17
+ } from '../lib/injected-ids.mjs';
12
18
  import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
13
19
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
14
20
  import { recordHookError } from '../lib/hook-telemetry.mjs';
@@ -102,32 +108,36 @@ const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes (used only for legacy fallback)
102
108
  // directive, and Read→Edit re-surfaces the Read-time lesson IDs as a one-line
103
109
  // ack nudge at the actual action point. CLAUDE_MEM_SALIENCE=legacy (or 0)
104
110
  // restores the pre-v2.98 passive behavior.
105
- const SALIENCE_LEGACY = process.env.CLAUDE_MEM_SALIENCE === 'legacy'
106
- || process.env.CLAUDE_MEM_SALIENCE === '0';
111
+ const SALIENCE_LEGACY =
112
+ process.env.CLAUDE_MEM_SALIENCE === 'legacy' || process.env.CLAUDE_MEM_SALIENCE === '0';
107
113
  const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
108
114
  const SALIENCE_BRIDGE = process.env.CLAUDE_MEM_SALIENCE === 'bridge';
109
- const ACK_DIRECTIVE = "apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
115
+ const ACK_DIRECTIVE =
116
+ "apply each lesson to this edit or rule it out — state '#NN applied' or '#NN n/a — <reason>' in your next user-facing message.";
110
117
  // v-bind salience forcing-function (#8771 audit: ack ≠ act). Instead of a cheap
111
118
  // '#NN applied / n/a' verdict, demand the model bind the lesson to the concrete
112
119
  // line it's editing and quote the satisfying edit line. Selected by
113
120
  // CLAUDE_MEM_SALIENCE=bind; default stays ACK_DIRECTIVE.
114
- const BIND_DIRECTIVE = "For each lesson: state the one concrete check it forces on the line(s) you're editing, quote the edit line that satisfies it, then report '#NN: <check> — pass' or '#NN: n/a — <why this edit can't reach it>'.";
121
+ const BIND_DIRECTIVE =
122
+ "For each lesson: state the one concrete check it forces on the line(s) you're editing, quote the edit line that satisfies it, then report '#NN: <check> — pass' or '#NN: n/a — <why this edit can't reach it>'.";
115
123
  const ACTIVE_DIRECTIVE = SALIENCE_BIND ? BIND_DIRECTIVE : ACK_DIRECTIVE;
116
- const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
124
+ const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy file
117
125
  // Feature ① (file intelligence): on the first Read of a file each session, inject
118
126
  // its approximate token size + a one-line summary so the agent can decide to read
119
127
  // fully, slice, or grep. Read-only (Edit/Write already commit to the file). Default
120
128
  // ON; CLAUDE_MEM_FILE_INTEL=0 disables. Files below the token floor stay silent so
121
129
  // small reads carry no noise. Env names mirror schema.mjs CLAUDE_MEM_* convention (#8447).
122
130
  const FILE_INTEL_OFF = ['0', 'off', 'false', 'no'].includes(
123
- String(process.env.CLAUDE_MEM_FILE_INTEL || '').toLowerCase());
131
+ String(process.env.CLAUDE_MEM_FILE_INTEL || '').toLowerCase(),
132
+ );
124
133
  // P2 (D#78): edge-level decay ENFORCEMENT — opt-in (default OFF, shadow-first).
125
134
  // When on, a (obs,file) edge whose miss_streak reached K consecutive uncited
126
135
  // injections stops firing on this surface; the lesson stays reachable via
127
136
  // search / UPS / error-recall. P1 counting (Stop-side attribution) is always
128
137
  // on regardless of this flag. Flip only after real-DB cite-rate evidence.
129
138
  const EDGE_DECAY_ON = ['1', 'on', 'true', 'yes'].includes(
130
- String(process.env.CLAUDE_MEM_EDGE_DECAY || '').toLowerCase());
139
+ String(process.env.CLAUDE_MEM_EDGE_DECAY || '').toLowerCase(),
140
+ );
131
141
  // NaN-checked, not `|| 3`: an explicit K=0 is falsy and would silently become
132
142
  // the default instead of clamping to the declared minimum of 1 (review D#78).
133
143
  const EDGE_DECAY_K_RAW = parseInt(process.env.CLAUDE_MEM_EDGE_DECAY_K, 10);
@@ -176,22 +186,32 @@ const EDGE_DECAY_K = Math.max(1, Number.isNaN(EDGE_DECAY_K_RAW) ? 3 : EDGE_DECAY
176
186
  // era-confounded. The `(null)` bucket and the face-overall figure ARE, being
177
187
  // dominated by legacy rows.
178
188
  const SCOPE_FILTER_ON = ['1', 'on', 'true', 'yes'].includes(
179
- String(process.env.CLAUDE_MEM_SCOPE_FILTER || '').toLowerCase());
189
+ String(process.env.CLAUDE_MEM_SCOPE_FILTER || '').toLowerCase(),
190
+ );
180
191
  // `min: 1` replaces the old `Math.max(1, parseInt(…) || 800)`. The wrapper made the
181
192
  // clamp look like the whole story, but the `|| 800` inside it swallowed an explicit 0 —
182
193
  // `CLAUDE_MEM_FILE_INTEL_MIN_TOKENS=0` (a user asking for no floor) landed on 800, not on
183
194
  // 1. Same class as the UPS knobs; caught by the widened tree sweep in
184
195
  // tests/env-number.test.mjs once it learned the trailing-default shape.
185
- const FILE_INTEL_MIN_TOKENS = envNumber(process.env.CLAUDE_MEM_FILE_INTEL_MIN_TOKENS,
186
- { name: 'CLAUDE_MEM_FILE_INTEL_MIN_TOKENS', defaultValue: 800, min: 1, integer: true });
196
+ const FILE_INTEL_MIN_TOKENS = envNumber(process.env.CLAUDE_MEM_FILE_INTEL_MIN_TOKENS, {
197
+ name: 'CLAUDE_MEM_FILE_INTEL_MIN_TOKENS',
198
+ defaultValue: 800,
199
+ min: 1,
200
+ integer: true,
201
+ });
187
202
  // Feature ② (repeated-read guard): when the agent does a FULL re-read of a file
188
203
  // it already read this session and the file is unchanged (mtime), nudge it to
189
204
  // reuse context instead of re-slurping. Read-only; only fires above the floor and
190
205
  // never on offset/limit paging. Default ON; CLAUDE_MEM_REREAD_GUARD=0 disables.
191
206
  const REREAD_GUARD_OFF = ['0', 'off', 'false', 'no'].includes(
192
- String(process.env.CLAUDE_MEM_REREAD_GUARD || '').toLowerCase());
193
- const REREAD_MIN_TOKENS = envNumber(process.env.CLAUDE_MEM_REREAD_MIN_TOKENS,
194
- { name: 'CLAUDE_MEM_REREAD_MIN_TOKENS', defaultValue: 600, min: 1, integer: true });
207
+ String(process.env.CLAUDE_MEM_REREAD_GUARD || '').toLowerCase(),
208
+ );
209
+ const REREAD_MIN_TOKENS = envNumber(process.env.CLAUDE_MEM_REREAD_MIN_TOKENS, {
210
+ name: 'CLAUDE_MEM_REREAD_MIN_TOKENS',
211
+ defaultValue: 600,
212
+ min: 1,
213
+ integer: true,
214
+ });
195
215
  // Stale-cooldown GC moved to hook.mjs::handleSessionStart — running it on every
196
216
  // Edit cost 15-30 disk stats per call. SessionStart fires once at session boot,
197
217
  // which is enough to keep RUNTIME_DIR from growing unbounded.
@@ -216,13 +236,18 @@ async function bridgeTopLesson(rows, changeText) {
216
236
  try {
217
237
  ({ extractIdents } = await import('../lib/lesson-idents.mjs'));
218
238
  if (!fake) ({ bridgeLesson } = await import('../lib/lesson-bridge.mjs'));
219
- } catch { return null; }
239
+ } catch {
240
+ return null;
241
+ }
220
242
  for (const r of rows) {
221
243
  const lesson = r.lesson_learned;
222
244
  if (!lesson) continue;
223
245
  if (!extractIdents(lesson).some((id) => changeText.includes(id))) continue;
224
246
  let res;
225
- if (fake) res = /^n\s*\/?\s*a$/i.test(fake.trim()) ? { ok: false } : { ok: true, check: fake.trim().slice(0, 200) };
247
+ if (fake)
248
+ res = /^n\s*\/?\s*a$/i.test(fake.trim())
249
+ ? { ok: false }
250
+ : { ok: true, check: fake.trim().slice(0, 200) };
226
251
  else res = await bridgeLesson({ lesson, hunk: changeText });
227
252
  if (res.ok) return { id: r.id, check: res.check };
228
253
  return null; // top bound lesson abstained → fall back to ACK, don't scan further
@@ -233,7 +258,11 @@ async function bridgeTopLesson(rows, changeText) {
233
258
  // ─── Helpers ────────────────────────────────────────────────────────────────
234
259
 
235
260
  function readCooldown(cooldownPath) {
236
- try { return JSON.parse(readFileSync(cooldownPath, 'utf8')); } catch { return {}; }
261
+ try {
262
+ return JSON.parse(readFileSync(cooldownPath, 'utf8'));
263
+ } catch {
264
+ return {};
265
+ }
237
266
  }
238
267
 
239
268
  // v2.81: cooldown entries are {ts, lessonIds} objects so the PostToolUse
@@ -266,8 +295,10 @@ function crossHookInjectedFile(project, sessionId) {
266
295
  // script held two of the five hand-typed copies. The window stays this script's own
267
296
  // constant; only the gate and the write are shared.
268
297
  function readCrossHookInjected(project, sessionId) {
269
- const { ids } = readInjectedMarker(crossHookInjectedFile(project, sessionId),
270
- { sessionId, maxAgeMs: CROSS_HOOK_DEDUP_MS });
298
+ const { ids } = readInjectedMarker(crossHookInjectedFile(project, sessionId), {
299
+ sessionId,
300
+ maxAgeMs: CROSS_HOOK_DEDUP_MS,
301
+ });
271
302
  return new Set(ids.map(String));
272
303
  }
273
304
 
@@ -275,9 +306,14 @@ function mergeCrossHookInjected(project, newIds, sessionId) {
275
306
  if (!newIds || newIds.length === 0) return;
276
307
  try {
277
308
  mkdirSync(RUNTIME_DIR, { recursive: true });
278
- mergeInjectedMarker(crossHookInjectedFile(project, sessionId), newIds,
279
- { sessionId, maxAgeMs: CROSS_HOOK_DEDUP_MS, mode: 'union' });
280
- } catch { /* silent — dedup is best-effort */ }
309
+ mergeInjectedMarker(crossHookInjectedFile(project, sessionId), newIds, {
310
+ sessionId,
311
+ maxAgeMs: CROSS_HOOK_DEDUP_MS,
312
+ mode: 'union',
313
+ });
314
+ } catch {
315
+ /* silent — dedup is best-effort */
316
+ }
281
317
  }
282
318
 
283
319
  function writeCooldown(cooldownPath, data, isSessionScoped) {
@@ -298,7 +334,9 @@ function writeCooldown(cooldownPath, data, isSessionScoped) {
298
334
  // PostToolUse bind-salience check reads back — a torn write turned that
299
335
  // check into a zero-trace no-op.
300
336
  atomicWriteFileSync(cooldownPath, JSON.stringify(cleaned));
301
- } catch { /* silent */ }
337
+ } catch {
338
+ /* silent */
339
+ }
302
340
  }
303
341
 
304
342
  // ─── Main ───────────────────────────────────────────────────────────────────
@@ -368,7 +406,9 @@ try {
368
406
  // rename silently zeroes injection like code-graph's matcher bug.
369
407
  if (!filePath) {
370
408
  if (toolName && !['Edit', 'Write', 'NotebookEdit', 'Read'].includes(toolName)) {
371
- recordHookError('pre-recall:unknown-tool', new Error(`tool_name=${toolName}`), RUNTIME_DIR, { toolName });
409
+ recordHookError('pre-recall:unknown-tool', new Error(`tool_name=${toolName}`), RUNTIME_DIR, {
410
+ toolName,
411
+ });
372
412
  } else if (!toolName) {
373
413
  recordHookError('pre-recall:no-toolname', new Error('event missing tool_name'), RUNTIME_DIR);
374
414
  }
@@ -399,15 +439,17 @@ try {
399
439
  // naming the IDs (no lesson bodies — token cost stays minimal), then mark
400
440
  // the entry handled so the next Edit is silent again. Entries without a
401
441
  // mode field (pre-v2.98) are treated as already handled.
402
- const seenIds = (typeof entry === 'object' && Array.isArray(entry.lessonIds))
403
- ? entry.lessonIds : [];
442
+ const seenIds = typeof entry === 'object' && Array.isArray(entry.lessonIds) ? entry.lessonIds : [];
404
443
  const wasReadMode = typeof entry === 'object' && entry.mode === 'read';
405
444
  if (!isRead && wasReadMode && seenIds.length > 0 && !SALIENCE_LEGACY) {
406
- const idList = seenIds.map(id => `#${id}`).join(', ');
407
- queueHookContext('PreToolUse', [
408
- '[mem] PreToolUse recall — system-injected context, continue your planned action:',
409
- `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
410
- ].join('\n'));
445
+ const idList = seenIds.map((id) => `#${id}`).join(', ');
446
+ queueHookContext(
447
+ 'PreToolUse',
448
+ [
449
+ '[mem] PreToolUse recall — system-injected context, continue your planned action:',
450
+ `[mem] ⚠ Lessons ${idList} were shown when you Read ${basename(filePath)} — ${ACTIVE_DIRECTIVE}`,
451
+ ].join('\n'),
452
+ );
411
453
  flushHookStdout();
412
454
  cooldown[filePath] = { ...entry, mode: 'edit' };
413
455
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
@@ -416,10 +458,13 @@ try {
416
458
  // nudge to reuse what's already in context. Read-only; never throws.
417
459
  const meta = readFileMeta(filePath);
418
460
  if (shouldWarnReread(entry.reread, meta ? meta.mtimeMs : null, isFullRead, REREAD_MIN_TOKENS)) {
419
- queueHookContext('PreToolUse', [
420
- '[mem] PreToolUse recall — system-injected context, continue your planned action:',
421
- buildRereadWarning(basename(filePath), entry.reread.tokens),
422
- ].join('\n'));
461
+ queueHookContext(
462
+ 'PreToolUse',
463
+ [
464
+ '[mem] PreToolUse recall — system-injected context, continue your planned action:',
465
+ buildRereadWarning(basename(filePath), entry.reread.tokens),
466
+ ].join('\n'),
467
+ );
423
468
  flushHookStdout();
424
469
  recordMetric(DATA_DIR, { event: 'reread_warn' }); // tier-1 firing counter (②)
425
470
  }
@@ -428,7 +473,7 @@ try {
428
473
  }
429
474
  } else {
430
475
  const ts = entryTimestamp(cooldown[filePath]);
431
- if (ts && (now - ts) < COOLDOWN_MS) process.exit(0);
476
+ if (ts && now - ts < COOLDOWN_MS) process.exit(0);
432
477
  }
433
478
 
434
479
  // Open DB readonly
@@ -481,7 +526,7 @@ try {
481
526
  // title isn't a LOW_SIGNAL auto-fallback (those carry pipe-delimited raw
482
527
  // output or filename-stubs, no guidance value for the about-to-Edit agent).
483
528
  const typeFallback = isRead
484
- ? 'AND o.lesson_learned IS NOT NULL AND o.lesson_learned != \'\''
529
+ ? "AND o.lesson_learned IS NOT NULL AND o.lesson_learned != ''"
485
530
  : `AND (
486
531
  (o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
487
532
  OR (o.type IN ('bugfix', 'decision') AND ${notLowSignalSql})
@@ -513,24 +558,30 @@ try {
513
558
  let edgeDecayFilter = '';
514
559
  if (EDGE_DECAY_ON) {
515
560
  try {
516
- const hasCol = db.prepare(
517
- `SELECT 1 FROM pragma_table_info('observation_files') WHERE name = 'miss_streak'`
518
- ).get();
561
+ const hasCol = db
562
+ .prepare(`SELECT 1 FROM pragma_table_info('observation_files') WHERE name = 'miss_streak'`)
563
+ .get();
519
564
  if (hasCol) edgeDecayFilter = `AND of2.miss_streak < ${EDGE_DECAY_K}`;
520
- } catch { /* probe failure → unfiltered */ }
565
+ } catch {
566
+ /* probe failure → unfiltered */
567
+ }
521
568
  }
522
569
  // P3 (D#78): environment-scope filter — same probe discipline (readonly
523
570
  // fast-path may hit a pre-v43 DB where observations.scope doesn't exist).
524
571
  let scopeFilter = '';
525
572
  if (SCOPE_FILTER_ON) {
526
573
  try {
527
- const hasScope = db.prepare(
528
- `SELECT 1 FROM pragma_table_info('observations') WHERE name = 'scope'`
529
- ).get();
574
+ const hasScope = db
575
+ .prepare(`SELECT 1 FROM pragma_table_info('observations') WHERE name = 'scope'`)
576
+ .get();
530
577
  if (hasScope) scopeFilter = `AND (o.scope IS NULL OR o.scope != 'environment')`;
531
- } catch { /* probe failure → unfiltered */ }
578
+ } catch {
579
+ /* probe failure → unfiltered */
580
+ }
532
581
  }
533
- const rows = db.prepare(`
582
+ const rows = db
583
+ .prepare(
584
+ `
534
585
  SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
535
586
  FROM observations o
536
587
  JOIN observation_files of2 ON of2.obs_id = o.id
@@ -547,7 +598,9 @@ try {
547
598
  ${citeFactorClause('o')} DESC,
548
599
  o.created_at_epoch DESC
549
600
  LIMIT ${obsLimit}
550
- `).all(project, cutoff, ...fileParams);
601
+ `,
602
+ )
603
+ .all(project, cutoff, ...fileParams);
551
604
 
552
605
  // T9: also query the `events` table — after T9, bugfix/lesson/decision/etc.
553
606
  // route here instead of observations, so we must read both sources to keep
@@ -573,7 +626,9 @@ try {
573
626
  const eventsLimit = (isRead ? 1 : 2) + dedupSlack;
574
627
  let eventRows = [];
575
628
  try {
576
- eventRows = db.prepare(`
629
+ eventRows = db
630
+ .prepare(
631
+ `
577
632
  SELECT id, event_type AS type, title, body AS lesson_learned
578
633
  FROM events
579
634
  WHERE project = ?
@@ -586,8 +641,12 @@ try {
586
641
  CASE WHEN body IS NOT NULL AND body != '' THEN 0 ELSE 1 END,
587
642
  created_at_epoch DESC
588
643
  LIMIT ${eventsLimit}
589
- `).all(project, cutoff, `%"${escaped}"%`, `%"${filePathEscaped}"%`);
590
- } catch { /* events table may not exist on pre-v2.31 DBs — silent */ }
644
+ `,
645
+ )
646
+ .all(project, cutoff, `%"${escaped}"%`, `%"${filePathEscaped}"%`);
647
+ } catch {
648
+ /* events table may not exist on pre-v2.31 DBs — silent */
649
+ }
591
650
 
592
651
  // A3 (v2.83): cross-hook dedup. UPS may have already injected some of
593
652
  // these obs ids this prompt — re-emitting wastes the PreToolUse slot
@@ -599,16 +658,17 @@ try {
599
658
  // feed an event id into observation_files updates.
600
659
  // (crossHookSeen is read above, before the two SELECTs — it sizes their LIMITs.)
601
660
  const sourcedRows = [
602
- ...rows.map(r => ({ ...r, src: 'obs' })),
603
- ...eventRows.map(r => ({ ...r, src: 'evt' })),
661
+ ...rows.map((r) => ({ ...r, src: 'obs' })),
662
+ ...eventRows.map((r) => ({ ...r, src: 'evt' })),
604
663
  ];
605
664
  // D#188: compare on the NAMESPACED key, not the bare number. The `src` tag two
606
665
  // comments up exists precisely because the two tables share an id space; the
607
666
  // predicate that consumed it did not use it, so an observation injected by UPS
608
667
  // silently blocked the same-numbered event and vice versa.
609
- const dedupedRows = crossHookSeen.size > 0
610
- ? sourcedRows.filter(r => !crossHookSeen.has(injectedIdKey(r.id, r.src)))
611
- : sourcedRows;
668
+ const dedupedRows =
669
+ crossHookSeen.size > 0
670
+ ? sourcedRows.filter((r) => !crossHookSeen.has(injectedIdKey(r.id, r.src)))
671
+ : sourcedRows;
612
672
 
613
673
  // Merge: observations first (they carry richer lesson_learned), then events.
614
674
  // Edit/Write caps at 3 total; Read caps at 1 (single most-actionable hit).
@@ -623,7 +683,9 @@ try {
623
683
  // throws — fileIntelFor returns null on unreadable/below-threshold files.
624
684
  let fileIntelLine = null;
625
685
  if (isRead && !FILE_INTEL_OFF) {
626
- try { fileIntelLine = fileIntelFor(filePath, { minTokens: FILE_INTEL_MIN_TOKENS }); } catch {}
686
+ try {
687
+ fileIntelLine = fileIntelFor(filePath, { minTokens: FILE_INTEL_MIN_TOKENS });
688
+ } catch {}
627
689
  }
628
690
  // Tier-1 firing counter (①). recordMetric no-ops unless CLAUDE_MEM_METRICS=1,
629
691
  // so default users pay nothing; observers see counts in `doctor` / `stats`.
@@ -647,13 +709,13 @@ try {
647
709
  recordMetric(DATA_DIR, {
648
710
  event: 'pretool_recall',
649
711
  injected: allRows.length,
650
- obs: allRows.filter(r => r.src === 'obs').length,
651
- evt: allRows.filter(r => r.src === 'evt').length,
712
+ obs: allRows.filter((r) => r.src === 'obs').length,
713
+ evt: allRows.filter((r) => r.src === 'evt').length,
652
714
  mode: isRead ? 'read' : 'edit',
653
715
  });
654
716
  }
655
- const showFraming = hasLessons || Boolean(fileIntelLine)
656
- || (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1');
717
+ const showFraming =
718
+ hasLessons || Boolean(fileIntelLine) || (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1');
657
719
  if (showFraming) {
658
720
  // Framing line mirrors #7758 handoff-injection fix: without an explicit
659
721
  // "system-injected, continue" disclaimer, observed turn-end after Edit+reminder
@@ -691,14 +753,14 @@ try {
691
753
  for (const r of allRows) {
692
754
  const idTag = `${r.src === 'evt' ? EVENT_ID_PREFIX : '#'}${r.id}`;
693
755
  if (r.lesson_learned) {
694
- const lesson = r.lesson_learned.length > LESSON_MAX
695
- ? r.lesson_learned.slice(0, LESSON_MAX - 3) + '...'
696
- : r.lesson_learned;
756
+ const lesson =
757
+ r.lesson_learned.length > LESSON_MAX
758
+ ? r.lesson_learned.slice(0, LESSON_MAX - 3) + '...'
759
+ : r.lesson_learned;
697
760
  lines.push(` ${idTag} [${r.type}] ${neutralizeContextDelimiters(lesson)}`);
698
761
  } else {
699
- const title = (r.title || '').length > LESSON_MAX
700
- ? r.title.slice(0, LESSON_MAX - 3) + '...'
701
- : (r.title || '');
762
+ const title =
763
+ (r.title || '').length > LESSON_MAX ? r.title.slice(0, LESSON_MAX - 3) + '...' : r.title || '';
702
764
  lines.push(` ${idTag} [${r.type}] ${neutralizeContextDelimiters(title)}`);
703
765
  }
704
766
  }
@@ -709,9 +771,13 @@ try {
709
771
  // via the Read→Edit ack nudge above.
710
772
  if (!isRead && !SALIENCE_LEGACY) {
711
773
  const changeText = [toolInput?.old_string, toolInput?.new_string, toolInput?.content]
712
- .filter(Boolean).join('\n');
774
+ .filter(Boolean)
775
+ .join('\n');
713
776
  const bridged = await bridgeTopLesson(allRows, changeText);
714
- if (bridged) lines.push(`[mem] ⚠ #${bridged.id} → this edit must: ${neutralizeContextDelimiters(bridged.check)}. Confirm your new code satisfies it.`);
777
+ if (bridged)
778
+ lines.push(
779
+ `[mem] ⚠ #${bridged.id} → this edit must: ${neutralizeContextDelimiters(bridged.check)}. Confirm your new code satisfies it.`,
780
+ );
715
781
  else lines.push(`[mem] ⚠ Before this edit: ${ACTIVE_DIRECTIVE}`);
716
782
  }
717
783
  } else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
@@ -727,7 +793,9 @@ try {
727
793
  // Read never emitted this (passive). The cooldown write below still runs on
728
794
  // every branch, so Read→Edit dedup + cite-back lessonId tracking are intact.
729
795
  // (Framing line already pushed above via showFraming.)
730
- lines.push(`[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`);
796
+ lines.push(
797
+ `[mem] No prior lessons for ${fname} — if you solve a non-obvious bug here, run: /lesson --file ${fname} "<root cause + fix>"`,
798
+ );
731
799
  }
732
800
 
733
801
  if (lines.length > 0) {
@@ -746,7 +814,7 @@ try {
746
814
  // ② repeated-read guard: record file metadata on the first Read so a later
747
815
  // full re-read of the unchanged file can be flagged. Read-only, session-scoped;
748
816
  // one stat + bounded read, first-read only.
749
- const rereadMeta = (isRead && !REREAD_GUARD_OFF && isSessionScoped) ? readFileMeta(filePath) : null;
817
+ const rereadMeta = isRead && !REREAD_GUARD_OFF && isSessionScoped ? readFileMeta(filePath) : null;
750
818
  // bind salience (component 2): record the identifiers each lesson NAMES that
751
819
  // ALSO appear in the current (pre-edit) file, so post-tool-recall.js can flag
752
820
  // an edit that drops one. Only under =bind with lessons — keeps the default
@@ -761,18 +829,22 @@ try {
761
829
  if (present.length) acc[r.id] = present;
762
830
  }
763
831
  if (Object.keys(acc).length) lessonIdents = acc;
764
- } catch { /* unreadable pre-edit file — skip the diff check */ }
832
+ } catch {
833
+ /* unreadable pre-edit file — skip the diff check */
834
+ }
765
835
  }
766
836
  cooldown[filePath] = {
767
837
  ts: now,
768
- lessonIds: allRows.map(r => r.id),
838
+ lessonIds: allRows.map((r) => r.id),
769
839
  // P1 (D#78): observation-sourced ids only — consumed by the Stop-side
770
840
  // edge attribution (lib/edge-attribution.mjs). lessonIds stays mixed for
771
841
  // the cite-back hint contract.
772
- obsIds: allRows.filter(r => r.src === 'obs').map(r => r.id),
842
+ obsIds: allRows.filter((r) => r.src === 'obs').map((r) => r.id),
773
843
  mode: isRead ? 'read' : 'edit',
774
844
  ...(lessonIdents ? { lessonIdents } : {}),
775
- ...(rereadMeta ? { reread: { mtimeMs: rereadMeta.mtimeMs, tokens: rereadMeta.tokens, full: isFullRead } } : {}),
845
+ ...(rereadMeta
846
+ ? { reread: { mtimeMs: rereadMeta.mtimeMs, tokens: rereadMeta.tokens, full: isFullRead } }
847
+ : {}),
776
848
  };
777
849
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
778
850
  // A3 (v2.83): merge our newly-emitted IDs into the cross-hook injected
@@ -792,14 +864,22 @@ try {
792
864
  // pathAInjectedIds; it reaches there, but suppresses nothing, because this
793
865
  // function stringifies every id and that consumer compares against a numeric
794
866
  // row id. That inertness is a separate live defect — D#193.)
795
- mergeCrossHookInjected(project, allRows.map(r => injectedIdKey(r.id, r.src)), sessionId);
867
+ mergeCrossHookInjected(
868
+ project,
869
+ allRows.map((r) => injectedIdKey(r.id, r.src)),
870
+ sessionId,
871
+ );
796
872
  } catch (e) {
797
873
  // Silent failure — never block editing, but record for self-observation.
798
874
  recordHookError('pre-recall:query', e, RUNTIME_DIR, { filePath });
799
875
  } finally {
800
- try { db.close(); } catch {}
876
+ try {
877
+ db.close();
878
+ } catch {}
801
879
  }
802
880
  } catch (e) {
803
881
  // Top-level catch — exit 0 no matter what, but record what slipped past.
804
- try { recordHookError('pre-recall:top', e, RUNTIME_DIR); } catch {}
882
+ try {
883
+ recordHookError('pre-recall:top', e, RUNTIME_DIR);
884
+ } catch {}
805
885
  }