claude-recall 0.9.2 → 0.9.3

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.
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Minimal Search Enforcer for Claude Recall v0.9.3+
4
+
5
+ Ensures memory search is performed before Write/Edit/Bash/Task operations.
6
+ Works alongside native Claude Skills - skill teaches, hook enforces.
7
+
8
+ State file: ~/.claude-recall/hook-state/{session_id}.json
9
+ Exit codes: 0 = allow, 2 = block
10
+ """
11
+ import json
12
+ import sys
13
+ import os
14
+ from pathlib import Path
15
+ from datetime import datetime
16
+
17
+ STATE_DIR = Path.home() / '.claude-recall' / 'hook-state'
18
+ SEARCH_TTL_MS = int(os.environ.get('CLAUDE_RECALL_SEARCH_TTL', 5 * 60 * 1000)) # 5 min default
19
+ ENFORCE_MODE = os.environ.get('CLAUDE_RECALL_ENFORCE_MODE', 'block') # block, warn, off
20
+
21
+ # Tools that count as "search performed"
22
+ SEARCH_TOOLS = [
23
+ 'mcp__claude-recall__search',
24
+ 'mcp__claude-recall__mcp__claude-recall__search',
25
+ 'mcp__claude-recall__retrieve_memory',
26
+ 'mcp__claude-recall__mcp__claude-recall__retrieve_memory',
27
+ ]
28
+
29
+ # Tools that require search first
30
+ ENFORCE_TOOLS = ['Write', 'Edit', 'Bash', 'Task']
31
+
32
+ # Read-only bash commands that don't need memory search
33
+ READ_ONLY_BASH = [
34
+ 'ls', 'cat', 'head', 'tail', 'less', 'more', 'file', 'stat', 'wc',
35
+ 'find', 'locate', 'which', 'whereis', 'type', 'pwd', 'whoami',
36
+ 'git status', 'git log', 'git diff', 'git show', 'git branch',
37
+ 'git remote', 'git fetch', 'git stash list', 'git tag',
38
+ 'npm list', 'npm ls', 'npm view', 'npm outdated', 'npm audit',
39
+ 'npm test', 'npm run test', 'npm run build', 'npm run lint',
40
+ 'pip list', 'pip show', 'pip freeze',
41
+ 'pytest', 'jest', 'cargo test', 'go test', 'tsc --noEmit',
42
+ 'grep', 'rg', 'ag', 'awk', 'sed', 'sort', 'uniq', 'diff',
43
+ 'tree', 'realpath', 'dirname', 'basename', 'date', 'env', 'echo',
44
+ 'ps', 'top', 'df', 'du', 'free', 'uptime', 'hostname',
45
+ ]
46
+
47
+
48
+ def get_state_file(session_id: str) -> Path:
49
+ safe_id = "".join(c if c.isalnum() or c in '-_' else '_' for c in session_id) or 'default'
50
+ return STATE_DIR / f'{safe_id}.json'
51
+
52
+
53
+ def load_state(session_id: str) -> dict:
54
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
55
+ state_file = get_state_file(session_id)
56
+ if state_file.exists():
57
+ try:
58
+ return json.load(open(state_file))
59
+ except:
60
+ pass
61
+ return {'lastSearchAt': None, 'searchQuery': None}
62
+
63
+
64
+ def save_state(session_id: str, state: dict):
65
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
66
+ try:
67
+ json.dump(state, open(get_state_file(session_id), 'w'), indent=2)
68
+ except:
69
+ pass
70
+
71
+
72
+ def is_search_tool(tool_name: str) -> bool:
73
+ return any(s in tool_name for s in SEARCH_TOOLS)
74
+
75
+
76
+ def is_read_only_bash(command: str) -> bool:
77
+ if not command:
78
+ return False
79
+ cmd = command.strip().lower()
80
+ # Check direct match or pipe starting with read-only
81
+ for ro in READ_ONLY_BASH:
82
+ if cmd.startswith(ro):
83
+ return True
84
+ if '|' in cmd:
85
+ first = cmd.split('|')[0].strip()
86
+ for ro in READ_ONLY_BASH:
87
+ if first.startswith(ro):
88
+ return True
89
+ return False
90
+
91
+
92
+ def main():
93
+ if ENFORCE_MODE == 'off':
94
+ sys.exit(0)
95
+
96
+ try:
97
+ data = json.load(sys.stdin)
98
+ except:
99
+ sys.exit(0)
100
+
101
+ tool_name = data.get('tool_name', '')
102
+ tool_input = data.get('tool_input', {})
103
+ session_id = data.get('session_id', '') or 'default'
104
+
105
+ # Track search calls
106
+ if is_search_tool(tool_name):
107
+ state = load_state(session_id)
108
+ state['lastSearchAt'] = int(datetime.now().timestamp() * 1000)
109
+ state['searchQuery'] = tool_input.get('query', '')
110
+ save_state(session_id, state)
111
+ sys.exit(0)
112
+
113
+ # Only enforce on specific tools
114
+ if tool_name not in ENFORCE_TOOLS:
115
+ sys.exit(0)
116
+
117
+ # Skip read-only bash
118
+ if tool_name == 'Bash' and is_read_only_bash(tool_input.get('command', '')):
119
+ sys.exit(0)
120
+
121
+ # Check if search was recent
122
+ state = load_state(session_id)
123
+ last_search = state.get('lastSearchAt')
124
+
125
+ if last_search:
126
+ now = int(datetime.now().timestamp() * 1000)
127
+ if (now - last_search) <= SEARCH_TTL_MS:
128
+ sys.exit(0)
129
+
130
+ # Block or warn
131
+ msg = f"""
132
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
133
+ 🔍 MEMORY SEARCH REQUIRED before {tool_name}
134
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
135
+
136
+ Run: mcp__claude-recall__search({{"query": "relevant keywords"}})
137
+
138
+ This ensures you apply user preferences and avoid past mistakes.
139
+
140
+ To disable: CLAUDE_RECALL_ENFORCE_MODE=off
141
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
142
+ """
143
+ print(msg.strip(), file=sys.stderr)
144
+ sys.exit(0 if ENFORCE_MODE == 'warn' else 2)
145
+
146
+
147
+ if __name__ == '__main__':
148
+ main()
@@ -565,23 +565,24 @@ async function main() {
565
565
  }
566
566
  }
567
567
  }
568
- // Install skills and clean up old hooks (v0.9.0+ uses Skills, not hooks)
569
- function installSkillsAndCleanupHooks(force = false) {
568
+ // Install skills + minimal enforcement hook (v0.9.3+ hybrid approach)
569
+ function installSkillsAndHook(force = false) {
570
570
  const cwd = process.cwd();
571
571
  const projectName = path.basename(cwd);
572
- console.log('\n📦 Claude Recall v0.9.x Setup\n');
572
+ console.log('\n📦 Claude Recall v0.9.3+ Setup\n');
573
573
  console.log(`📍 Project: ${projectName}`);
574
574
  console.log(`📍 Directory: ${cwd}\n`);
575
575
  // Find the package directory (where claude-recall is installed)
576
576
  const packageDir = path.resolve(__dirname, '../..');
577
577
  const packageSkillsDir = path.join(packageDir, '.claude/skills');
578
+ const packageHooksDir = path.join(packageDir, '.claude/hooks');
578
579
  const claudeDir = path.join(cwd, '.claude');
579
580
  const hooksDir = path.join(claudeDir, 'hooks');
580
581
  const settingsPath = path.join(claudeDir, 'settings.json');
581
- // === CLEANUP: Remove old hooks (v0.9.0+ doesn't use hooks) ===
582
+ // === CLEANUP: Remove OLD hooks (not the new search_enforcer.py) ===
582
583
  if (fs.existsSync(hooksDir)) {
583
584
  const oldHooks = [
584
- 'memory_enforcer.py',
585
+ 'memory_enforcer.py', // Old v0.8.x hook
585
586
  'pre_tool_search_enforcer.py',
586
587
  'mcp_tool_tracker.py',
587
588
  'pubnub_pre_tool_hook.py',
@@ -597,49 +598,53 @@ async function main() {
597
598
  removedCount++;
598
599
  }
599
600
  }
600
- // Remove hooks directory if empty
601
- try {
602
- const remaining = fs.readdirSync(hooksDir);
603
- if (remaining.length === 0) {
604
- fs.rmdirSync(hooksDir);
605
- }
606
- }
607
- catch (e) {
608
- // Ignore
609
- }
610
601
  if (removedCount > 0) {
611
- console.log(`🧹 Removed ${removedCount} old hook file(s) from .claude/hooks/`);
602
+ console.log(`🧹 Removed ${removedCount} old hook file(s)`);
612
603
  }
613
604
  }
614
- // === CLEANUP: Clear hook configuration from settings.json ===
605
+ // === INSTALL: New minimal search_enforcer.py ===
606
+ if (!fs.existsSync(hooksDir)) {
607
+ fs.mkdirSync(hooksDir, { recursive: true });
608
+ }
609
+ const hookSource = path.join(packageHooksDir, 'search_enforcer.py');
610
+ const hookDest = path.join(hooksDir, 'search_enforcer.py');
611
+ if (fs.existsSync(hookSource)) {
612
+ fs.copyFileSync(hookSource, hookDest);
613
+ fs.chmodSync(hookDest, 0o755);
614
+ console.log('✅ Installed search_enforcer.py to .claude/hooks/');
615
+ }
616
+ else {
617
+ console.log(`⚠️ Hook not found at: ${hookSource}`);
618
+ }
619
+ // === CONFIGURE: Update settings.json with new hook ===
615
620
  let settings = {};
616
621
  if (fs.existsSync(settingsPath)) {
617
622
  try {
618
623
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
619
- const hadHooks = settings.hooks && Object.keys(settings.hooks).length > 0;
620
- // Clear hooks - v0.9.0+ uses Skills instead
621
- settings.hooks = {};
622
- settings.hooksVersion = '2.0.0';
623
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
624
- if (hadHooks) {
625
- console.log('🧹 Cleared old hook configuration from .claude/settings.json');
626
- }
627
624
  }
628
625
  catch (e) {
629
- // If settings.json is invalid, create fresh one
630
- if (!fs.existsSync(claudeDir)) {
631
- fs.mkdirSync(claudeDir, { recursive: true });
632
- }
633
- fs.writeFileSync(settingsPath, JSON.stringify({ hooks: {}, hooksVersion: '2.0.0' }, null, 2));
626
+ settings = {};
634
627
  }
635
628
  }
636
- else {
637
- // Create new settings.json without hooks
638
- if (!fs.existsSync(claudeDir)) {
639
- fs.mkdirSync(claudeDir, { recursive: true });
640
- }
641
- fs.writeFileSync(settingsPath, JSON.stringify({ hooks: {}, hooksVersion: '2.0.0' }, null, 2));
629
+ settings.hooksVersion = '3.0.0'; // v3 = hybrid (skill + minimal hook)
630
+ settings.hooks = {
631
+ PreToolUse: [
632
+ {
633
+ matcher: "mcp__claude-recall__.*|Write|Edit|Bash|Task",
634
+ hooks: [
635
+ {
636
+ type: "command",
637
+ command: `python3 ${hookDest}`
638
+ }
639
+ ]
640
+ }
641
+ ]
642
+ };
643
+ if (!fs.existsSync(claudeDir)) {
644
+ fs.mkdirSync(claudeDir, { recursive: true });
642
645
  }
646
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
647
+ console.log('✅ Configured search enforcement hook');
643
648
  // === INSTALL: Copy skills directory ===
644
649
  if (fs.existsSync(packageSkillsDir)) {
645
650
  const skillsDir = path.join(claudeDir, 'skills');
@@ -650,7 +655,7 @@ async function main() {
650
655
  console.log(`⚠️ Skills not found at: ${packageSkillsDir}`);
651
656
  }
652
657
  console.log('\n✅ Setup complete!\n');
653
- console.log('ℹ️ v0.9.0+ uses native Claude Skills instead of hooks.');
658
+ console.log('ℹ️ v0.9.3+ uses Skills (guidance) + minimal hook (enforcement).');
654
659
  console.log('Restart Claude Code to activate.\n');
655
660
  }
656
661
  // Setup command - shows activation instructions or installs skills
@@ -660,8 +665,8 @@ async function main() {
660
665
  .option('--install', 'Install skills and clean up old hooks')
661
666
  .action((options) => {
662
667
  if (options.install) {
663
- // Install skills and clean up old hooks
664
- installSkillsAndCleanupHooks();
668
+ // Install skills and enforcement hook
669
+ installSkillsAndHook();
665
670
  }
666
671
  else {
667
672
  // Show activation instructions
@@ -692,7 +697,7 @@ async function main() {
692
697
  .description('Clean up old hooks and install skills (v0.9.0+ migration)')
693
698
  .option('--force', 'Force overwrite existing configuration')
694
699
  .action((options) => {
695
- installSkillsAndCleanupHooks(options.force || false);
700
+ installSkillsAndHook(options.force || false);
696
701
  process.exit(0);
697
702
  });
698
703
  // Check hooks function
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-recall",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Persistent memory for Claude Code with native Skills integration, automatic capture, failure learning, and project scoping via MCP server",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -104,22 +104,22 @@ try {
104
104
  console.log('⚠️ Failed to register project (non-fatal):', error.message);
105
105
  }
106
106
 
107
- // Install skills and clean up old hooks (v0.9.0+ uses Skills, not hooks)
107
+ // Install skills + minimal enforcement hook (v0.9.3+ hybrid approach)
108
108
  try {
109
109
  const cwd = process.cwd();
110
110
  const projectName = path.basename(cwd);
111
111
  const packageSkillsDir = path.join(__dirname, '../.claude/skills');
112
+ const packageHooksDir = path.join(__dirname, '../.claude/hooks');
112
113
 
113
114
  if (projectName !== 'claude-recall' && !cwd.includes('node_modules/.pnpm') && !cwd.includes('node_modules/claude-recall')) {
114
115
  const claudeDir = path.join(cwd, '.claude');
115
116
  const hooksDir = path.join(claudeDir, 'hooks');
116
117
  const settingsPath = path.join(claudeDir, 'settings.json');
117
118
 
118
- // === CLEANUP: Remove old hooks (v0.9.0+ doesn't use hooks) ===
119
+ // === CLEANUP: Remove OLD hooks (not the new search_enforcer.py) ===
119
120
  if (fs.existsSync(hooksDir)) {
120
- // Remove known hook files from previous versions
121
121
  const oldHooks = [
122
- 'memory_enforcer.py',
122
+ 'memory_enforcer.py', // Old v0.8.x hook
123
123
  'pre_tool_search_enforcer.py',
124
124
  'mcp_tool_tracker.py',
125
125
  'pubnub_pre_tool_hook.py',
@@ -137,48 +137,56 @@ try {
137
137
  }
138
138
  }
139
139
 
140
- // Remove hooks directory if empty
141
- try {
142
- const remaining = fs.readdirSync(hooksDir);
143
- if (remaining.length === 0) {
144
- fs.rmdirSync(hooksDir);
145
- }
146
- } catch (e) {
147
- // Ignore
148
- }
149
-
150
140
  if (removedCount > 0) {
151
- console.log(`🧹 Removed ${removedCount} old hook file(s) from .claude/hooks/`);
141
+ console.log(`🧹 Removed ${removedCount} old hook file(s)`);
152
142
  }
153
143
  }
154
144
 
155
- // === CLEANUP: Clear hook configuration from settings.json ===
156
- if (fs.existsSync(settingsPath)) {
157
- try {
158
- let settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
159
- const hadHooks = settings.hooks && Object.keys(settings.hooks).length > 0;
145
+ // === INSTALL: New minimal search_enforcer.py ===
146
+ if (!fs.existsSync(hooksDir)) {
147
+ fs.mkdirSync(hooksDir, { recursive: true });
148
+ }
160
149
 
161
- // Clear hooks - v0.9.0+ uses Skills instead
162
- settings.hooks = {};
163
- settings.hooksVersion = '2.0.0'; // Bump version to indicate skills-based
150
+ const hookSource = path.join(packageHooksDir, 'search_enforcer.py');
151
+ const hookDest = path.join(hooksDir, 'search_enforcer.py');
164
152
 
165
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
153
+ if (fs.existsSync(hookSource)) {
154
+ fs.copyFileSync(hookSource, hookDest);
155
+ fs.chmodSync(hookDest, 0o755);
156
+ console.log('✅ Installed search_enforcer.py to .claude/hooks/');
157
+ }
166
158
 
167
- if (hadHooks) {
168
- console.log('🧹 Cleared old hook configuration from .claude/settings.json');
169
- }
159
+ // === CONFIGURE: Update settings.json with new hook ===
160
+ let settings = {};
161
+ if (fs.existsSync(settingsPath)) {
162
+ try {
163
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
170
164
  } catch (e) {
171
- // If settings.json is invalid, create fresh one
172
- fs.writeFileSync(settingsPath, JSON.stringify({ hooks: {}, hooksVersion: '2.0.0' }, null, 2));
165
+ settings = {};
173
166
  }
174
- } else {
175
- // Create new settings.json without hooks
176
- if (!fs.existsSync(claudeDir)) {
177
- fs.mkdirSync(claudeDir, { recursive: true });
178
- }
179
- fs.writeFileSync(settingsPath, JSON.stringify({ hooks: {}, hooksVersion: '2.0.0' }, null, 2));
180
167
  }
181
168
 
169
+ settings.hooksVersion = '3.0.0'; // v3 = hybrid (skill + minimal hook)
170
+ settings.hooks = {
171
+ PreToolUse: [
172
+ {
173
+ matcher: "mcp__claude-recall__.*|Write|Edit|Bash|Task",
174
+ hooks: [
175
+ {
176
+ type: "command",
177
+ command: `python3 ${hookDest}`
178
+ }
179
+ ]
180
+ }
181
+ ]
182
+ };
183
+
184
+ if (!fs.existsSync(claudeDir)) {
185
+ fs.mkdirSync(claudeDir, { recursive: true });
186
+ }
187
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
188
+ console.log('✅ Configured search enforcement hook');
189
+
182
190
  // === INSTALL: Copy skills directory ===
183
191
  if (fs.existsSync(packageSkillsDir)) {
184
192
  const skillsDir = path.join(claudeDir, 'skills');
@@ -187,7 +195,7 @@ try {
187
195
  }
188
196
  }
189
197
  } catch (error) {
190
- console.log('⚠️ Failed to install skills (non-fatal):', error.message);
198
+ console.log('⚠️ Failed to install (non-fatal):', error.message);
191
199
  }
192
200
 
193
201
  console.log('\n✅ Installation complete!\n');
@@ -201,7 +209,7 @@ try {
201
209
  console.log('');
202
210
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
203
211
  console.log('');
204
- console.log('ℹ️ v0.9.0+ uses native Claude Skills instead of hooks.');
212
+ console.log('ℹ️ v0.9.3+ uses Skills (guidance) + minimal hook (enforcement).');
205
213
  console.log('💡 Your memories persist across conversations and restarts.\n');
206
214
 
207
215
  } catch (error) {