claude-recall 0.37.0 → 0.37.2

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 (48) hide show
  1. package/.claude/hooks/search_enforcer.py +29 -2
  2. package/README.md +1 -1
  3. package/dist/cli/claude-recall-cli.js +16 -2
  4. package/dist/hooks/memory-sync-hook.js +101 -8
  5. package/dist/hooks/precompact-preserve.js +24 -16
  6. package/dist/hooks/rule-injector.js +95 -5
  7. package/dist/memory/schema.sql +0 -0
  8. package/dist/memory/storage.js +15 -0
  9. package/dist/services/search-monitor.js +17 -0
  10. package/dist/services/session-link.js +92 -0
  11. package/docs/hooks.md +1 -1
  12. package/package.json +1 -1
  13. package/scripts/uninstall.js +0 -0
  14. package/dist/cli/commands/live-test.js +0 -245
  15. package/dist/cli/commands/migrate.js +0 -317
  16. package/dist/core/patterns.js +0 -56
  17. package/dist/hooks/bash-failure-watcher.js +0 -253
  18. package/dist/mcp/memory-capture-middleware.js +0 -349
  19. package/dist/mcp/queue-tools.js +0 -532
  20. package/dist/mcp/tools/live-testing-tools.js +0 -231
  21. package/dist/mcp/tools/test-tools.js +0 -320
  22. package/dist/memory/database-adapter.js +0 -256
  23. package/dist/memory/pattern-store.js +0 -68
  24. package/dist/services/claude-json-watcher.js +0 -243
  25. package/dist/services/context-enhancer.js +0 -215
  26. package/dist/services/conversation-context-manager.js +0 -254
  27. package/dist/services/embedding-service.js +0 -183
  28. package/dist/services/memory-enhancer.js +0 -148
  29. package/dist/services/memory-evolution.js +0 -249
  30. package/dist/services/memory-usage-tracker.js +0 -227
  31. package/dist/services/preference-analyzer.js +0 -242
  32. package/dist/services/queue-api.js +0 -560
  33. package/dist/services/queue-integration.js +0 -409
  34. package/dist/services/queue-migration.js +0 -415
  35. package/dist/services/queue-system.js +0 -1092
  36. package/dist/services/restart-continuity.js +0 -361
  37. package/dist/services/semantic-preference-extractor.js +0 -432
  38. package/dist/testing/auto-correction-engine.js +0 -338
  39. package/dist/testing/live-testing-manager.js +0 -402
  40. package/dist/testing/mock-claude.js +0 -249
  41. package/dist/testing/observable-database.js +0 -178
  42. package/dist/testing/scenario-runner.js +0 -354
  43. package/dist/testing/test-orchestrator.js +0 -328
  44. package/docs/2026-07-follow-up-article-draft.md +0 -68
  45. package/docs/2026-07-follow-up-article-linkedin-tight.md +0 -46
  46. package/docs/2026-07-positioning-vs-steering-files.md +0 -92
  47. package/docs/design-hybrid-retrieval-fts5.md +0 -185
  48. package/docs/using-claude-code-subscription-instead-of-api-key.md +0 -69
@@ -16,7 +16,29 @@ from datetime import datetime
16
16
 
17
17
  STATE_DIR = Path.home() / '.claude-recall' / 'hook-state'
18
18
  SEARCH_TTL_MS = int(os.environ.get('CLAUDE_RECALL_SEARCH_TTL', 60 * 1000)) # 1 min default (once per task)
19
- ENFORCE_MODE = os.environ.get('CLAUDE_RECALL_ENFORCE_MODE', 'block') # block, warn, off
19
+
20
+
21
+ def _resolve_enforce_mode() -> str:
22
+ """Enforcement mode: env var wins, then ~/.claude-recall/config.json
23
+ ("enforceMode"), else default 'warn'. A file-based switch is reachable
24
+ from inside a running session (an agent can edit config.json to escape a
25
+ stuck gate); an env var set before launch is not. Default is 'warn' — this
26
+ gate is an advisory nudge (see module docstring), so it should never
27
+ hard-block a session by default."""
28
+ env = os.environ.get('CLAUDE_RECALL_ENFORCE_MODE')
29
+ if env:
30
+ return env.strip().lower()
31
+ try:
32
+ cfg = json.load(open(Path.home() / '.claude-recall' / 'config.json'))
33
+ mode = cfg.get('enforceMode')
34
+ if mode:
35
+ return str(mode).strip().lower()
36
+ except Exception:
37
+ pass
38
+ return 'warn'
39
+
40
+
41
+ ENFORCE_MODE = _resolve_enforce_mode() # block, warn, off
20
42
  MAX_BLOCKS = int(os.environ.get('CLAUDE_RECALL_MAX_BLOCKS', 3)) # degrade to warn after N blocks
21
43
 
22
44
  # Tools that count as "search performed"
@@ -182,6 +204,7 @@ STALE RULES — consider reloading before {tool_name}
182
204
 
183
205
  Rules were loaded earlier but TTL expired.
184
206
  Run: mcp__claude-recall__load_rules({{}})
207
+ (If not directly callable, ToolSearch it first — see below.)
185
208
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
186
209
  """
187
210
  print(msg.strip(), file=sys.stderr)
@@ -214,10 +237,14 @@ LOAD RULES REQUIRED before {tool_name} (attempt {block_count}/{MAX_BLOCKS})
214
237
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
215
238
 
216
239
  Run: mcp__claude-recall__load_rules({{}})
240
+ (If that tool is not directly callable, first run
241
+ ToolSearch({{query:"select:mcp__claude-recall__load_rules"}}) —
242
+ some harnesses defer MCP tool schemas until discovered.)
217
243
 
218
244
  This ensures you apply user preferences and avoid past mistakes.
219
245
 
220
- To disable: CLAUDE_RECALL_ENFORCE_MODE=off
246
+ To disable this gate: set "enforceMode":"off" in ~/.claude-recall/config.json
247
+ (or export CLAUDE_RECALL_ENFORCE_MODE=off before launching Claude Code).
221
248
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
222
249
  """
223
250
  print(msg.strip(), file=sys.stderr)
package/README.md CHANGED
@@ -460,7 +460,7 @@ Defaults work out of the box; tune via environment variables as needed.
460
460
  | `CLAUDE_RECALL_AUTO_CLEANUP` | `false` | Auto-kill stale MCP processes on start (otherwise reports and exits). |
461
461
  | `CLAUDE_RECALL_COMPACT_THRESHOLD` | `10MB` | DB size at which automatic compaction kicks in. |
462
462
  | `CLAUDE_RECALL_MAX_MEMORIES` | `10000` | Memory-row soft cap. |
463
- | `CLAUDE_RECALL_ENFORCE_MODE` | `on` | Set to `off` to bypass the search-enforcer hook. |
463
+ | `CLAUDE_RECALL_ENFORCE_MODE` | `warn` | `block` / `warn` / `off` for the search-enforcer hook. Env wins, else `~/.claude-recall/config.json` `"enforceMode"`, else `warn`. |
464
464
  | `CLAUDE_RECALL_LLM_TIMEOUT_MS` | `5000` | Timeout for hook-context LLM calls (classification, hindsight hints). Hooks fall back to regex when it fires. |
465
465
  | `CLAUDE_RECALL_STOP_DEBOUNCE_MS` | `300000` | Debounce for the heavy Stop-hook pipeline (episodes, session extraction, promotion). `0` disables. |
466
466
  | `CLAUDE_RECALL_PROJECT_ID` | *(cwd)* | Pin the project scope to a fixed id, overriding working-directory detection. |
@@ -51,9 +51,10 @@ const hook_commands_1 = require("./commands/hook-commands");
51
51
  const kiro_commands_1 = require("./commands/kiro-commands");
52
52
  const repair_1 = require("./commands/repair");
53
53
  // v14 = add PreToolUse rule-injector + Post resolver for JITRI.
54
+ // v15 = bound the search_enforcer PreToolUse entry with timeout: 5.
54
55
  // Bump when the hook block template changes — setup skips the settings
55
56
  // rewrite when the installed hooksVersion already matches.
56
- const HOOKS_VERSION = '14.0.0';
57
+ const HOOKS_VERSION = '15.0.0';
57
58
  const parse_utils_1 = require("./parse-utils");
58
59
  const program = new commander_1.Command();
59
60
  class ClaudeRecallCLI {
@@ -1095,6 +1096,15 @@ class ClaudeRecallCLI {
1095
1096
  const stats = fs.statSync(dbPath);
1096
1097
  console.log(` Path: ${dbPath}`);
1097
1098
  console.log(` Size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
1099
+ // Surface the write-ahead log size. A WAL that has grown to a large
1100
+ // fraction of the DB usually means a long-lived connection isn't
1101
+ // checkpointing; it's truncated on the next clean shutdown (close()).
1102
+ const walPath = `${dbPath}-wal`;
1103
+ if (fs.existsSync(walPath)) {
1104
+ const walMb = fs.statSync(walPath).size / 1024 / 1024;
1105
+ const warn = walMb > 16 ? ' ⚠️ large — will truncate on next clean shutdown' : '';
1106
+ console.log(` WAL: ${walMb.toFixed(2)} MB${warn}`);
1107
+ }
1098
1108
  }
1099
1109
  // Memory stats
1100
1110
  const memStats = this.memoryService.getStats();
@@ -1375,7 +1385,11 @@ async function main() {
1375
1385
  hooks: [
1376
1386
  {
1377
1387
  type: "command",
1378
- command: `python3 ${hookDest}`
1388
+ command: `python3 ${hookDest}`,
1389
+ // Runs before EVERY tool call (matcher .*). Bound the python3
1390
+ // cold start so it can't become a per-call latency tax or an
1391
+ // unbounded failure mode.
1392
+ timeout: 5
1379
1393
  },
1380
1394
  {
1381
1395
  type: "command",
@@ -75,12 +75,94 @@ function deriveAutoMemoryPath(cwd, homedir) {
75
75
  * Extract display value from a memory record.
76
76
  */
77
77
  function extractValue(value) {
78
- if (typeof value === 'string')
79
- return value;
80
- if (typeof value === 'object' && value !== null) {
81
- return value.content || value.value || JSON.stringify(value);
78
+ // Always resolve to readable text. Handles every historical shape:
79
+ // - clean structured `{ title, description, content }` → prefer the title
80
+ // - failure content objects "what_failed what_should_do"
81
+ // - nested `{ content: { ... } }` / `{ content: "{...json...}" }` wrappers
82
+ // - stringified-JSON stored as a plain string
83
+ // Without this, a failure stored as `JSON.stringify(content)` rendered its
84
+ // raw JSON as the file title/slug (e.g. `[{"what_failed":"Bash command...`),
85
+ // and clean object-content memories stringified to "[object Object]".
86
+ let v = value;
87
+ for (let depth = 0; depth < 6; depth++) {
88
+ if (typeof v === 'string') {
89
+ const t = v.trim();
90
+ if (t.startsWith('{') || t.startsWith('[')) {
91
+ try {
92
+ v = JSON.parse(t);
93
+ continue;
94
+ }
95
+ catch {
96
+ return v;
97
+ }
98
+ }
99
+ return v;
100
+ }
101
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
102
+ if (typeof v.title === 'string' && v.title.trim())
103
+ return v.title.trim();
104
+ if (typeof v.what_failed === 'string') {
105
+ return v.what_should_do ? `${v.what_failed} → ${v.what_should_do}` : v.what_failed;
106
+ }
107
+ const next = v.content ?? v.value ?? v.text;
108
+ if (next === undefined)
109
+ return JSON.stringify(v);
110
+ v = next;
111
+ continue;
112
+ }
113
+ break;
82
114
  }
83
- return String(value ?? '');
115
+ return typeof v === 'string' ? v : JSON.stringify(v ?? '');
116
+ }
117
+ /**
118
+ * Generic default "lessons" the failure detectors emit when no fix has been
119
+ * paired yet. A promoted memory whose only takeaway is one of these teaches
120
+ * nothing — it just costs context. We skip syncing those to the file-based
121
+ * memory; the specific failure still lives in the DB, so fix-pairing and
122
+ * evidence counting are unaffected, and once a fix enriches what_should_do
123
+ * (e.g. "Fix: <command>") the memory syncs normally.
124
+ */
125
+ const BOILERPLATE_LESSONS = new Set([
126
+ 'check inputs and prerequisites before retrying',
127
+ 'check command syntax, file paths, and prerequisites before running',
128
+ 'review error details and adjust approach',
129
+ ]);
130
+ /** Unwrap a memory value to the object that carries the failure fields, or null. */
131
+ function unwrapToFailureObject(value) {
132
+ let v = value;
133
+ for (let depth = 0; depth < 6; depth++) {
134
+ if (typeof v === 'string') {
135
+ const t = v.trim();
136
+ if (t.startsWith('{')) {
137
+ try {
138
+ v = JSON.parse(t);
139
+ continue;
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
148
+ if (typeof v.what_should_do === 'string' || typeof v.what_failed === 'string')
149
+ return v;
150
+ const next = v.content ?? v.value;
151
+ if (next === undefined)
152
+ return null;
153
+ v = next;
154
+ continue;
155
+ }
156
+ return null;
157
+ }
158
+ return null;
159
+ }
160
+ function isBoilerplateFailure(rule) {
161
+ if (rule.crType !== 'failure')
162
+ return false;
163
+ const obj = unwrapToFailureObject(rule.value);
164
+ const wsd = obj && typeof obj.what_should_do === 'string' ? obj.what_should_do.trim().toLowerCase() : '';
165
+ return BOILERPLATE_LESSONS.has(wsd);
84
166
  }
85
167
  /**
86
168
  * Check if a memory key matches test data patterns.
@@ -231,12 +313,23 @@ async function handleMemorySync(input) {
231
313
  const memoryService = memory_1.MemoryService.getInstance();
232
314
  // Get top rules ranked for sync
233
315
  const rules = memoryService.getTopRulesForSync(projectId, MAX_SYNC_FILES);
234
- // Filter out test data and secrets
316
+ // Filter out test data, secrets, and boilerplate-only failure lessons.
235
317
  const filtered = rules.filter(r => {
236
318
  if (isTestData(r.key))
237
319
  return false;
238
- const val = extractValue(r.value);
239
- if (containsSecret(val))
320
+ // Scan the FULL raw value for secrets, not just the display gist —
321
+ // extractValue now returns a summary that could omit a secret buried in
322
+ // a non-title field.
323
+ let raw;
324
+ try {
325
+ raw = typeof r.value === 'string' ? r.value : JSON.stringify(r.value);
326
+ }
327
+ catch {
328
+ raw = String(r.value ?? '');
329
+ }
330
+ if (containsSecret(raw))
331
+ return false;
332
+ if (isBoilerplateFailure(r))
240
333
  return false;
241
334
  return true;
242
335
  });
@@ -97,29 +97,37 @@ async function handlePrecompactPreserve(input) {
97
97
  console.log(`💾 Recall: preserved ${stored} memories before context compression`);
98
98
  }
99
99
  (0, shared_1.hookLog)('precompact', `PreCompact sweep: stored ${stored} memories from ${entries.length} entries`);
100
- // Reset search enforcer hook-state so Claude is forced to re-load rules
101
- // after context compression. Without this, the enforcer thinks rules are
102
- // still loaded even though they may have been lost during compaction.
103
- resetEnforcerState(input?.session_id);
100
+ // Reset per-session hook-state so Claude is forced to re-load AND re-inject
101
+ // rules after context compression. Without this, the enforcer thinks rules
102
+ // are still loaded and the rule-injector thinks it already injected them
103
+ // but compaction may have dropped both from context.
104
+ resetSessionHookState(input?.session_id);
104
105
  }
105
106
  /**
106
- * Delete the search enforcer's hook-state file for this session,
107
- * forcing a fresh load_rules gate on the next tool call.
107
+ * Delete this session's per-session hook-state files, forcing a fresh
108
+ * load_rules gate (search enforcer) and re-injection (rule-injector) on the
109
+ * next tool call after compaction.
108
110
  */
109
- function resetEnforcerState(sessionId) {
111
+ function resetSessionHookState(sessionId) {
110
112
  if (!sessionId) {
111
- (0, shared_1.hookLog)('precompact', 'No session_id — cannot reset enforcer state');
113
+ (0, shared_1.hookLog)('precompact', 'No session_id — cannot reset session hook-state');
112
114
  return;
113
115
  }
114
116
  const safeId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '_') || 'default';
115
- const stateFile = path.join(os.homedir(), '.claude-recall', 'hook-state', `${safeId}.json`);
116
- try {
117
- if (fs.existsSync(stateFile)) {
118
- fs.unlinkSync(stateFile);
119
- (0, shared_1.hookLog)('precompact', `Reset enforcer state for session ${safeId} — rules will re-gate`);
117
+ const stateDir = path.join(os.homedir(), '.claude-recall', 'hook-state');
118
+ const stateFiles = [
119
+ path.join(stateDir, `${safeId}.json`), // search-enforcer gate state
120
+ path.join(stateDir, `rule-injector-${safeId}.json`), // rule-injector dedup set
121
+ ];
122
+ for (const stateFile of stateFiles) {
123
+ try {
124
+ if (fs.existsSync(stateFile)) {
125
+ fs.unlinkSync(stateFile);
126
+ (0, shared_1.hookLog)('precompact', `Reset hook-state ${path.basename(stateFile)} for session ${safeId}`);
127
+ }
128
+ }
129
+ catch (err) {
130
+ (0, shared_1.hookLog)('precompact', `Failed to reset ${path.basename(stateFile)}: ${err.message}`);
120
131
  }
121
- }
122
- catch (err) {
123
- (0, shared_1.hookLog)('precompact', `Failed to reset enforcer state: ${err.message}`);
124
132
  }
125
133
  }
@@ -24,15 +24,52 @@
24
24
  * effectiveness directly. This is the meter that replaces the broken
25
25
  * citation-detection regex.
26
26
  */
27
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
28
+ if (k2 === undefined) k2 = k;
29
+ var desc = Object.getOwnPropertyDescriptor(m, k);
30
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
31
+ desc = { enumerable: true, get: function() { return m[k]; } };
32
+ }
33
+ Object.defineProperty(o, k2, desc);
34
+ }) : (function(o, m, k, k2) {
35
+ if (k2 === undefined) k2 = k;
36
+ o[k2] = m[k];
37
+ }));
38
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
39
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
40
+ }) : function(o, v) {
41
+ o["default"] = v;
42
+ });
43
+ var __importStar = (this && this.__importStar) || (function () {
44
+ var ownKeys = function(o) {
45
+ ownKeys = Object.getOwnPropertyNames || function (o) {
46
+ var ar = [];
47
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
48
+ return ar;
49
+ };
50
+ return ownKeys(o);
51
+ };
52
+ return function (mod) {
53
+ if (mod && mod.__esModule) return mod;
54
+ var result = {};
55
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
56
+ __setModuleDefault(result, mod);
57
+ return result;
58
+ };
59
+ })();
27
60
  Object.defineProperty(exports, "__esModule", { value: true });
28
61
  exports.computeInjection = computeInjection;
29
62
  exports.handleRuleInjector = handleRuleInjector;
63
+ const crypto_1 = require("crypto");
64
+ const fs = __importStar(require("fs"));
65
+ const path = __importStar(require("path"));
30
66
  const shared_1 = require("./shared");
31
67
  const memory_1 = require("../services/memory");
32
68
  const config_1 = require("../services/config");
33
69
  const outcome_storage_1 = require("../services/outcome-storage");
34
70
  const rule_retrieval_1 = require("../services/rule-retrieval");
35
71
  const memory_tools_1 = require("../mcp/tools/memory-tools");
72
+ const session_link_1 = require("../services/session-link");
36
73
  const TYPE_LABELS = {
37
74
  correction: 'correction',
38
75
  devops: 'devops',
@@ -81,6 +118,44 @@ function formatInjection(matches, toolName) {
81
118
  `but defer to safety and correctness if any conflict.\n${lines.join('\n')}\n` +
82
119
  `</recalled-memory>`);
83
120
  }
121
+ /**
122
+ * Per-session injection dedup. The hook fires on EVERY tool call, so without
123
+ * this the same rule is re-injected verbatim before every Read/Bash/Edit —
124
+ * pure per-call token overhead, and identical repetition trains the model to
125
+ * tune the block out. We remember which rules were already injected this
126
+ * session (keyed by rule identity + a content hash, so a rule re-injects only
127
+ * if its content actually changed) and skip them next time.
128
+ */
129
+ function injectionStateFile(sessionId) {
130
+ const safe = (sessionId || 'default').replace(/[^a-zA-Z0-9_-]/g, '_') || 'default';
131
+ return path.join((0, shared_1.hookStateDir)(), `rule-injector-${safe}.json`);
132
+ }
133
+ function loadInjectedSet(sessionId) {
134
+ try {
135
+ const parsed = JSON.parse(fs.readFileSync(injectionStateFile(sessionId), 'utf-8'));
136
+ if (Array.isArray(parsed?.injected))
137
+ return new Set(parsed.injected);
138
+ }
139
+ catch { /* no state yet — first injection this session */ }
140
+ return new Set();
141
+ }
142
+ function saveInjectedSet(sessionId, injected) {
143
+ try {
144
+ fs.writeFileSync(injectionStateFile(sessionId), JSON.stringify({ injected: [...injected] }), 'utf-8');
145
+ }
146
+ catch { /* best-effort — dedup is an optimization, never block the call */ }
147
+ }
148
+ function ruleInjectionId(rule) {
149
+ let payload;
150
+ try {
151
+ payload = JSON.stringify(rule.value);
152
+ }
153
+ catch {
154
+ payload = String(rule.value);
155
+ }
156
+ const hash = (0, crypto_1.createHash)('sha1').update(payload).digest('hex').slice(0, 8);
157
+ return `${rule.type}:${rule.key}:${hash}`;
158
+ }
84
159
  /**
85
160
  * Runtime-agnostic core: rank active rules against this tool call, record
86
161
  * the injections for outcome resolution, and return the formatted context
@@ -88,7 +163,7 @@ function formatInjection(matches, toolName) {
88
163
  * runtime (Claude Code wants a hookSpecificOutput JSON envelope; Kiro adds
89
164
  * raw stdout to context).
90
165
  */
91
- async function computeInjection(toolName, toolInput, toolUseId) {
166
+ async function computeInjection(toolName, toolInput, toolUseId, sessionId = 'default') {
92
167
  if (!toolName)
93
168
  return null;
94
169
  // Skip the hook for our own tools so we don't recursively inject rules
@@ -99,6 +174,10 @@ async function computeInjection(toolName, toolInput, toolUseId) {
99
174
  }
100
175
  const projectId = config_1.ConfigService.getInstance().getProjectId();
101
176
  const memoryService = memory_1.MemoryService.getInstance();
177
+ // Record the harness session id for this project so the MCP server can
178
+ // correlate its own logs with hook-side state (#6). This hook runs on every
179
+ // tool call with the harness session_id, so the link stays fresh.
180
+ (0, session_link_1.writeHarnessSessionLink)(sessionId, projectId);
102
181
  // Fetch all active rules for this project. We pass them all to the ranker
103
182
  // because the ranking function is fast and we want sticky rules to surface
104
183
  // even when token overlap is low.
@@ -126,10 +205,17 @@ async function computeInjection(toolName, toolInput, toolUseId) {
126
205
  (0, shared_1.hookLog)('rule-injector', `No relevant rules for ${toolName} (scanned ${allRules.length})`);
127
206
  return null;
128
207
  }
208
+ // Drop rules already injected this session (unless their content changed).
209
+ const injected = loadInjectedSet(sessionId);
210
+ const freshMatches = matches.filter(m => !injected.has(ruleInjectionId(m.rule)));
211
+ if (freshMatches.length === 0) {
212
+ (0, shared_1.hookLog)('rule-injector', `All ${matches.length} matched rule(s) already injected this session (${sessionId}) — skipping`);
213
+ return null;
214
+ }
129
215
  // Record each injection so PostToolUse can resolve it with the outcome
130
216
  try {
131
217
  const outcomeStorage = outcome_storage_1.OutcomeStorage.getInstance();
132
- for (const m of matches) {
218
+ for (const m of freshMatches) {
133
219
  outcomeStorage.recordRuleInjection({
134
220
  rule_key: m.rule.key,
135
221
  tool_name: toolName,
@@ -144,12 +230,16 @@ async function computeInjection(toolName, toolInput, toolUseId) {
144
230
  // Non-critical — failure to record shouldn't block the injection itself
145
231
  (0, shared_1.hookLog)('rule-injector', `Failed to record injections: ${err.message}`);
146
232
  }
147
- (0, shared_1.hookLog)('rule-injector', `Injected ${matches.length} rule(s) for ${toolName} (top score=${matches[0].score.toFixed(3)})`);
148
- return formatInjection(matches, toolName);
233
+ // Mark them injected so they don't repeat on the next tool call.
234
+ for (const m of freshMatches)
235
+ injected.add(ruleInjectionId(m.rule));
236
+ saveInjectedSet(sessionId, injected);
237
+ (0, shared_1.hookLog)('rule-injector', `Injected ${freshMatches.length} rule(s) for ${toolName} (top score=${freshMatches[0].score.toFixed(3)})`);
238
+ return formatInjection(freshMatches, toolName);
149
239
  }
150
240
  async function handleRuleInjector(input) {
151
241
  try {
152
- const additionalContext = await computeInjection(input?.tool_name ?? '', input?.tool_input ?? {}, input?.tool_use_id ?? '');
242
+ const additionalContext = await computeInjection(input?.tool_name ?? '', input?.tool_input ?? {}, input?.tool_use_id ?? '', input?.session_id ?? 'default');
153
243
  if (!additionalContext) {
154
244
  // Nothing to inject — print empty JSON so CC parses it cleanly
155
245
  process.stdout.write('{}\n');
File without changes
@@ -49,6 +49,11 @@ class MemoryStorage {
49
49
  this.db.pragma('journal_mode = WAL');
50
50
  // Ensure changes are synced to disk
51
51
  this.db.pragma('synchronous = NORMAL');
52
+ // Cap WAL growth: after each checkpoint SQLite truncates the -wal file
53
+ // back to this ceiling, so it can't balloon (a stuck reader once left it
54
+ // at 34MB) and linger across restarts. Paired with a TRUNCATE checkpoint
55
+ // in close().
56
+ this.db.pragma('journal_size_limit = 8388608'); // 8 MB
52
57
  this.initialize();
53
58
  }
54
59
  initialize() {
@@ -1055,6 +1060,16 @@ class MemoryStorage {
1055
1060
  return this.db;
1056
1061
  }
1057
1062
  close() {
1063
+ // Flush the WAL into the main DB and truncate the -wal/-shm files so they
1064
+ // don't linger at tens of MB after the process exits. Best-effort: a
1065
+ // checkpoint can fail if another connection still holds a read lock, in
1066
+ // which case db.close() below still runs an implicit passive checkpoint.
1067
+ try {
1068
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
1069
+ }
1070
+ catch {
1071
+ // ignore — proceed to close regardless
1072
+ }
1058
1073
  this.db.close();
1059
1074
  }
1060
1075
  }
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.SearchMonitor = void 0;
37
37
  const logging_1 = require("./logging");
38
+ const config_1 = require("./config");
39
+ const session_link_1 = require("./session-link");
38
40
  const fs = __importStar(require("fs"));
39
41
  const path = __importStar(require("path"));
40
42
  const os = __importStar(require("os"));
@@ -58,6 +60,20 @@ class SearchMonitor {
58
60
  fs.mkdirSync(dir, { recursive: true });
59
61
  }
60
62
  }
63
+ /**
64
+ * Best-effort lookup of the harness session_id for this process's project.
65
+ * A hook records it (see session-link.ts); returns undefined if none has run
66
+ * yet or on any error — this must never disrupt a search.
67
+ */
68
+ resolveHarnessSessionId() {
69
+ try {
70
+ const projectId = config_1.ConfigService.getInstance().getProjectId();
71
+ return (0, session_link_1.readHarnessSessionLink)(projectId) ?? undefined;
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ }
61
77
  recordSearch(query, resultCount, sessionId, source, context) {
62
78
  if (!this.monitoringEnabled)
63
79
  return;
@@ -66,6 +82,7 @@ class SearchMonitor {
66
82
  query,
67
83
  resultCount,
68
84
  sessionId,
85
+ harnessSessionId: this.resolveHarnessSessionId(),
69
86
  source,
70
87
  context
71
88
  };
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.harnessSessionLinkPath = harnessSessionLinkPath;
37
+ exports.writeHarnessSessionLink = writeHarnessSessionLink;
38
+ exports.readHarnessSessionLink = readHarnessSessionLink;
39
+ /**
40
+ * Session-identity bridge (#6).
41
+ *
42
+ * There are two unrelated session ids in the system:
43
+ * - Claude Code's harness `session_id`, known only to the hook processes
44
+ * (they receive it on stdin) and used to name hook-state files.
45
+ * - The long-lived MCP server's own per-process id (`session_<ts>_<rand>`),
46
+ * which is what ends up in search-monitor.log.
47
+ *
48
+ * Because they never met, hook-side state couldn't be correlated with
49
+ * MCP-side activity when debugging. This module is the meeting point: a hook
50
+ * that has the harness id writes it here (keyed by project); the MCP server
51
+ * reads it so it can stamp the harness id onto its own logs.
52
+ *
53
+ * Deliberately dependency-free (fs/path/os only) so both the hooks layer and
54
+ * the services layer can import it without creating a cycle. The directory
55
+ * mirrors the hook-state dir used elsewhere (CLAUDE_RECALL_DB_PATH override or
56
+ * ~/.claude-recall).
57
+ */
58
+ const fs = __importStar(require("fs"));
59
+ const path = __importStar(require("path"));
60
+ const os = __importStar(require("os"));
61
+ function linkDir() {
62
+ const base = process.env.CLAUDE_RECALL_DB_PATH || path.join(os.homedir(), '.claude-recall');
63
+ return path.join(base, 'hook-state');
64
+ }
65
+ function safeId(id) {
66
+ return (id || 'default').replace(/[^a-zA-Z0-9_-]/g, '_') || 'default';
67
+ }
68
+ function harnessSessionLinkPath(projectId) {
69
+ return path.join(linkDir(), `harness-session-${safeId(projectId)}.json`);
70
+ }
71
+ /** Persist the harness session id for a project. No-op for empty/'default'. */
72
+ function writeHarnessSessionLink(sessionId, projectId) {
73
+ if (!sessionId || sessionId === 'default')
74
+ return;
75
+ try {
76
+ fs.mkdirSync(linkDir(), { recursive: true });
77
+ fs.writeFileSync(harnessSessionLinkPath(projectId), JSON.stringify({ harnessSessionId: sessionId, projectId, updatedAt: Date.now() }), 'utf8');
78
+ }
79
+ catch {
80
+ // best-effort — correlation is a debugging aid, never block the caller
81
+ }
82
+ }
83
+ /** Read the most recently recorded harness session id for a project, or null. */
84
+ function readHarnessSessionLink(projectId) {
85
+ try {
86
+ const parsed = JSON.parse(fs.readFileSync(harnessSessionLinkPath(projectId), 'utf8'));
87
+ return typeof parsed?.harnessSessionId === 'string' ? parsed.harnessSessionId : null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ }
package/docs/hooks.md CHANGED
@@ -58,7 +58,7 @@ Common read-only commands are exempt from enforcement:
58
58
  | Environment Variable | Default | Description |
59
59
  |---|---|---|
60
60
  | `CLAUDE_RECALL_SEARCH_TTL` | `300000` (5 min) | Milliseconds a search remains valid |
61
- | `CLAUDE_RECALL_ENFORCE_MODE` | `block` | `block` (exit 2), `warn` (exit 0 + stderr message), or `off` (disabled) |
61
+ | `CLAUDE_RECALL_ENFORCE_MODE` | `warn` | `block` (exit 2), `warn` (exit 0 + stderr message), or `off` (disabled). Env wins; otherwise read from `~/.claude-recall/config.json` → `"enforceMode"`; else `warn`. The file switch is reachable from inside a running session — an env var set before launch is not. |
62
62
 
63
63
  ### Exit Codes
64
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
4
4
  "description": "Persistent memory for Claude Code and Pi with native Skills integration, automatic capture, failure learning, and project scoping",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
File without changes