superlocalmemory 3.3.5 → 3.3.7

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.
@@ -2,15 +2,17 @@
2
2
  # Licensed under the MIT License - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
4
 
5
- """Claude Code Hook Integrationinvisible memory injection.
5
+ """Claude Code hook integrationhybrid approach (v3.3.6).
6
6
 
7
- Installs hooks into Claude Code's settings.json that auto-inject
8
- SLM context on session start and auto-capture on tool use.
7
+ CRITICAL PATH (gate + init-done): Shell built-ins only. Cannot crash.
8
+ VALUE-ADD (start, checkpoint, stop): Python via `slm hook <name>`,
9
+ wrapped with `2>/dev/null || true` so errors are invisible.
9
10
 
10
11
  Usage:
11
- slm hooks install Install hooks into Claude Code settings
12
- slm hooks status Check if hooks are installed
13
- slm hooks remove Remove SLM hooks from settings
12
+ slm hooks install Install all hooks into Claude Code
13
+ slm hooks remove Remove SLM hooks from Claude Code
14
+ slm hooks status Check installation status
15
+ slm init Full setup including hooks
14
16
 
15
17
  Part of Qualixar | Author: Varun Pratap Bhardwaj
16
18
  """
@@ -19,157 +21,384 @@ from __future__ import annotations
19
21
 
20
22
  import json
21
23
  import logging
22
- import shutil
24
+ import sys
25
+ import tempfile
23
26
  from pathlib import Path
24
27
 
25
28
  logger = logging.getLogger(__name__)
26
29
 
27
30
  CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
28
- HOOKS_DIR = Path.home() / ".superlocalmemory" / "hooks"
29
-
30
- # The hook scripts that Claude Code will execute
31
- HOOK_SCRIPTS = {
32
- "slm-session-start.sh": """\
33
- #!/bin/bash
34
- # SLM Active Memory — Session Start Hook
35
- # Auto-recalls relevant context at session start
36
- slm session-context 2>/dev/null || true
37
- """,
38
- "slm-auto-capture.sh": """\
39
- #!/bin/bash
40
- # SLM Active Memory — Auto-Capture Hook
41
- # Evaluates tool output for decisions/bugs/preferences
42
- # Input comes via stdin from Claude Code PostToolUse event
43
- INPUT=$(cat)
44
- if [ -n "$INPUT" ]; then
45
- echo "$INPUT" | slm observe 2>/dev/null || true
46
- fi
47
- """,
48
- }
49
-
50
- # Hook definitions for Claude Code settings.json
51
- HOOK_DEFINITIONS = {
52
- "hooks": {
31
+ VERSION_DIR = Path.home() / ".superlocalmemory" / "hooks"
32
+ VERSION_FILE = VERSION_DIR / ".version"
33
+ DISABLED_FILE = VERSION_DIR / ".hooks-disabled"
34
+ HOOKS_VERSION = "3.3.6"
35
+
36
+ # Cross-platform temp dir and marker paths
37
+ _TMP = tempfile.gettempdir()
38
+ _MARKER = f"{_TMP}/slm-session-initialized"
39
+ _START_MARKER = f"{_TMP}/slm-session-start-time"
40
+
41
+ # Tools that the gate should block (everything except SLM/ToolSearch)
42
+ _GATED_TOOLS = "Bash|Read|Write|Edit|Glob|Grep|Agent|WebFetch|WebSearch|NotebookEdit"
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Platform-specific gate commands (shell built-ins only CANNOT crash)
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def _gate_cmd() -> str:
49
+ """Gate command: pure shell, no Python, ~1ms.
50
+
51
+ Logic: if initialized → allow. If no session started → allow. Else → block.
52
+ Uses specific matcher to exclude SLM tools, so no stdin parsing needed.
53
+ """
54
+ if sys.platform == "win32":
55
+ marker_win = _MARKER.replace("/", "\\")
56
+ start_win = _START_MARKER.replace("/", "\\")
57
+ return (
58
+ f'cmd /c "if exist {marker_win} (exit /b 0)'
59
+ f' else if not exist {start_win} (exit /b 0)'
60
+ f' else (echo [SLM] Call mcp__superlocalmemory__session_init first & exit /b 2)"'
61
+ )
62
+ return (
63
+ f"test -f {_MARKER}"
64
+ f" || test ! -f {_START_MARKER}"
65
+ " || { echo '[SLM] Call mcp__superlocalmemory__session_init first'; exit 2; }"
66
+ )
67
+
68
+
69
+ def _init_done_cmd() -> str:
70
+ """Init-done command: pure shell touch, ~1ms."""
71
+ if sys.platform == "win32":
72
+ return f'cmd /c "echo.>{_MARKER.replace("/", chr(92))}"'
73
+ return f"touch {_MARKER}"
74
+
75
+
76
+ def _wrap_python_cmd(hook_name: str) -> str:
77
+ """Wrap a Python hook with error absorption. Any crash → invisible."""
78
+ if sys.platform == "win32":
79
+ return f'cmd /c "slm hook {hook_name} 2>NUL || exit /b 0"'
80
+ return f"slm hook {hook_name} 2>/dev/null || true"
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Hook definitions for settings.json
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def _hook_definitions(include_gate: bool = False) -> dict[str, list]:
88
+ """Build Claude Code hook entries.
89
+
90
+ Critical path (gate, init-done): Shell built-ins. Cannot crash.
91
+ Value-add (start, checkpoint, stop): Python with error wrapper.
92
+ """
93
+ defs: dict[str, list] = {
53
94
  "SessionStart": [
54
95
  {
55
- "type": "command",
56
- "command": str(HOOKS_DIR / "slm-session-start.sh"),
57
- "timeout": 10000,
96
+ "hooks": [
97
+ {
98
+ "type": "command",
99
+ "command": _wrap_python_cmd("start"),
100
+ "timeout": 15000,
101
+ }
102
+ ]
103
+ }
104
+ ],
105
+ "PostToolUse": [
106
+ {
107
+ "matcher": "Write|Edit",
108
+ "hooks": [
109
+ {
110
+ "type": "command",
111
+ "command": _wrap_python_cmd("checkpoint"),
112
+ "timeout": 5000,
113
+ }
114
+ ],
115
+ }
116
+ ],
117
+ "Stop": [
118
+ {
119
+ "hooks": [
120
+ {
121
+ "type": "command",
122
+ "command": _wrap_python_cmd("stop"),
123
+ "timeout": 10000,
124
+ }
125
+ ]
58
126
  }
59
127
  ],
60
128
  }
61
- }
62
129
 
130
+ if include_gate:
131
+ defs["PreToolUse"] = [
132
+ {
133
+ "matcher": _GATED_TOOLS,
134
+ "hooks": [
135
+ {
136
+ "type": "command",
137
+ "command": _gate_cmd(),
138
+ "timeout": 500,
139
+ }
140
+ ],
141
+ }
142
+ ]
143
+ defs["PostToolUse"].insert(0, {
144
+ "matcher": "mcp__superlocalmemory__session_init",
145
+ "hooks": [
146
+ {
147
+ "type": "command",
148
+ "command": _init_done_cmd(),
149
+ "timeout": 500,
150
+ }
151
+ ],
152
+ })
153
+
154
+ return defs
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Identify SLM hooks in existing settings
159
+ # ---------------------------------------------------------------------------
160
+
161
+ def _is_slm_hook_entry(entry: dict) -> bool:
162
+ """Check if a hook entry belongs to SLM."""
163
+ for hook in entry.get("hooks", []):
164
+ cmd = hook.get("command", "")
165
+ if ("slm hook" in cmd
166
+ or "slm-session" in cmd
167
+ or ".superlocalmemory/hooks/" in cmd
168
+ or "slm-session-initialized" in cmd):
169
+ return True
170
+ return False
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Safe settings.json merge / removal
175
+ # ---------------------------------------------------------------------------
176
+
177
+ def _merge_hooks(settings: dict, hook_defs: dict) -> dict:
178
+ """Merge SLM hooks into settings, preserving all non-SLM hooks."""
179
+ if "hooks" not in settings:
180
+ settings["hooks"] = {}
181
+
182
+ for hook_type, slm_entries in hook_defs.items():
183
+ existing = settings["hooks"].get(hook_type, [])
184
+ cleaned = [e for e in existing if not _is_slm_hook_entry(e)]
185
+ cleaned.extend(slm_entries)
186
+ settings["hooks"][hook_type] = cleaned
187
+
188
+ return settings
189
+
190
+
191
+ def _remove_slm_hooks(settings: dict) -> dict:
192
+ """Remove all SLM hook entries, preserve non-SLM hooks."""
193
+ hooks = settings.get("hooks", {})
194
+ for hook_type in list(hooks.keys()):
195
+ cleaned = [e for e in hooks[hook_type] if not _is_slm_hook_entry(e)]
196
+ if cleaned:
197
+ hooks[hook_type] = cleaned
198
+ else:
199
+ del hooks[hook_type]
200
+ if not hooks and "hooks" in settings:
201
+ del settings["hooks"]
202
+ return settings
203
+
204
+
205
+ def _read_settings() -> dict:
206
+ """Read Claude Code settings.json, return empty dict if missing."""
207
+ if CLAUDE_SETTINGS.exists():
208
+ return json.loads(CLAUDE_SETTINGS.read_text())
209
+ return {}
210
+
211
+
212
+ def _write_settings(settings: dict) -> None:
213
+ """Write settings.json with pretty formatting."""
214
+ CLAUDE_SETTINGS.parent.mkdir(parents=True, exist_ok=True)
215
+ CLAUDE_SETTINGS.write_text(json.dumps(settings, indent=2) + "\n")
63
216
 
64
- def install_hooks() -> dict:
65
- """Install SLM hooks into Claude Code settings."""
66
- results = {"scripts": False, "settings": False, "errors": []}
67
217
 
68
- # 1. Create hook scripts
218
+ # ---------------------------------------------------------------------------
219
+ # Public API
220
+ # ---------------------------------------------------------------------------
221
+
222
+ def install_hooks(include_gate: bool = False) -> dict:
223
+ """Install SLM hooks into Claude Code settings.json.
224
+
225
+ Critical path uses shell built-ins (cannot crash).
226
+ Value-add uses Python with error wrappers (crashes invisible).
227
+ Never overwrites non-SLM hooks.
228
+ Clears .hooks-disabled marker (explicit install = user wants hooks).
229
+ """
230
+ result = {
231
+ "success": False, "errors": [],
232
+ "hooks_added": [], "gate_enabled": include_gate,
233
+ }
234
+
69
235
  try:
70
- HOOKS_DIR.mkdir(parents=True, exist_ok=True)
71
- for name, content in HOOK_SCRIPTS.items():
72
- path = HOOKS_DIR / name
73
- path.write_text(content)
74
- path.chmod(0o755)
75
- results["scripts"] = True
236
+ settings = _read_settings()
237
+ hook_defs = _hook_definitions(include_gate=include_gate)
238
+ settings = _merge_hooks(settings, hook_defs)
239
+ _write_settings(settings)
240
+ result["hooks_added"] = list(hook_defs.keys())
241
+ result["success"] = True
76
242
  except Exception as exc:
77
- results["errors"].append(f"Script creation failed: {exc}")
243
+ result["errors"].append(f"Settings update failed: {exc}")
78
244
 
79
- # 2. Update Claude Code settings.json
80
245
  try:
81
- if not CLAUDE_SETTINGS.parent.exists():
82
- CLAUDE_SETTINGS.parent.mkdir(parents=True, exist_ok=True)
83
-
84
- settings = {}
85
- if CLAUDE_SETTINGS.exists():
86
- settings = json.loads(CLAUDE_SETTINGS.read_text())
87
-
88
- # Merge hooks without overwriting existing ones
89
- if "hooks" not in settings:
90
- settings["hooks"] = {}
91
-
92
- # Add SessionStart hook if not present
93
- session_hooks = settings["hooks"].get("SessionStart", [])
94
- slm_hook_cmd = str(HOOKS_DIR / "slm-session-start.sh")
95
- already_installed = any(
96
- h.get("command", "") == slm_hook_cmd
97
- for h in session_hooks if isinstance(h, dict)
98
- )
99
-
100
- if not already_installed:
101
- session_hooks.append({
102
- "type": "command",
103
- "command": slm_hook_cmd,
104
- "timeout": 10000,
105
- })
106
- settings["hooks"]["SessionStart"] = session_hooks
107
-
108
- CLAUDE_SETTINGS.write_text(json.dumps(settings, indent=2))
109
- results["settings"] = True
246
+ VERSION_DIR.mkdir(parents=True, exist_ok=True)
247
+ VERSION_FILE.write_text(HOOKS_VERSION)
248
+ # Clear disabled marker — explicit install means user wants hooks
249
+ if DISABLED_FILE.exists():
250
+ DISABLED_FILE.unlink()
110
251
  except Exception as exc:
111
- results["errors"].append(f"Settings update failed: {exc}")
252
+ result["errors"].append(f"Version file failed: {exc}")
112
253
 
113
- return results
254
+ return result
114
255
 
115
256
 
116
257
  def remove_hooks() -> dict:
117
- """Remove SLM hooks from Claude Code settings."""
118
- results = {"scripts": False, "settings": False, "errors": []}
258
+ """Remove all SLM hooks from Claude Code settings.json.
259
+
260
+ Writes a .hooks-disabled marker so auto-install paths respect
261
+ the user's explicit choice. Cleared by explicit `install_hooks()`.
262
+ """
263
+ result = {"success": False, "errors": []}
119
264
 
120
- # 1. Remove hook scripts
121
265
  try:
122
- if HOOKS_DIR.exists():
123
- shutil.rmtree(HOOKS_DIR)
124
- results["scripts"] = True
266
+ settings = _read_settings()
267
+ settings = _remove_slm_hooks(settings)
268
+ _write_settings(settings)
269
+ result["success"] = True
125
270
  except Exception as exc:
126
- results["errors"].append(f"Script removal failed: {exc}")
271
+ result["errors"].append(f"Settings cleanup failed: {exc}")
127
272
 
128
- # 2. Remove from Claude Code settings
129
273
  try:
130
- if CLAUDE_SETTINGS.exists():
131
- settings = json.loads(CLAUDE_SETTINGS.read_text())
132
- if "hooks" in settings and "SessionStart" in settings["hooks"]:
133
- slm_hook_cmd = str(HOOKS_DIR / "slm-session-start.sh")
134
- settings["hooks"]["SessionStart"] = [
135
- h for h in settings["hooks"]["SessionStart"]
136
- if not (isinstance(h, dict) and h.get("command", "") == slm_hook_cmd)
137
- ]
138
- if not settings["hooks"]["SessionStart"]:
139
- del settings["hooks"]["SessionStart"]
140
- if not settings["hooks"]:
141
- del settings["hooks"]
142
- CLAUDE_SETTINGS.write_text(json.dumps(settings, indent=2))
143
- results["settings"] = True
144
- except Exception as exc:
145
- results["errors"].append(f"Settings cleanup failed: {exc}")
274
+ if VERSION_FILE.exists():
275
+ VERSION_FILE.unlink()
276
+ # Mark as explicitly disabled auto-install will respect this
277
+ VERSION_DIR.mkdir(parents=True, exist_ok=True)
278
+ DISABLED_FILE.write_text("removed by user\n")
279
+ except Exception:
280
+ pass
146
281
 
147
- return results
282
+ return result
148
283
 
149
284
 
150
285
  def check_status() -> dict:
151
- """Check if SLM hooks are installed."""
152
- scripts_ok = all(
153
- (HOOKS_DIR / name).exists()
154
- for name in HOOK_SCRIPTS
155
- )
286
+ """Check SLM hook installation status."""
287
+ installed_version = ""
288
+ if VERSION_FILE.exists():
289
+ try:
290
+ installed_version = VERSION_FILE.read_text().strip()
291
+ except Exception:
292
+ pass
156
293
 
157
- settings_ok = False
294
+ hook_types_found: list[str] = []
295
+ has_gate = False
158
296
  try:
159
- if CLAUDE_SETTINGS.exists():
160
- settings = json.loads(CLAUDE_SETTINGS.read_text())
161
- session_hooks = settings.get("hooks", {}).get("SessionStart", [])
162
- slm_hook_cmd = str(HOOKS_DIR / "slm-session-start.sh")
163
- settings_ok = any(
164
- h.get("command", "") == slm_hook_cmd
165
- for h in session_hooks if isinstance(h, dict)
166
- )
297
+ settings = _read_settings()
298
+ for hook_type, entries in settings.get("hooks", {}).items():
299
+ if any(_is_slm_hook_entry(e) for e in entries):
300
+ hook_types_found.append(hook_type)
301
+ has_gate = "PreToolUse" in hook_types_found
167
302
  except Exception:
168
303
  pass
169
304
 
305
+ installed = len(hook_types_found) >= 3
306
+
170
307
  return {
171
- "installed": scripts_ok and settings_ok,
172
- "scripts": scripts_ok,
173
- "settings": settings_ok,
174
- "hooks_dir": str(HOOKS_DIR),
308
+ "installed": installed,
309
+ "version": installed_version,
310
+ "latest_version": HOOKS_VERSION,
311
+ "needs_upgrade": bool(installed_version and installed_version != HOOKS_VERSION),
312
+ "hook_types": hook_types_found,
313
+ "gate_enabled": has_gate,
175
314
  }
315
+
316
+
317
+ def upgrade_hooks() -> dict:
318
+ """Upgrade existing hooks to current version. Non-interactive."""
319
+ status = check_status()
320
+
321
+ if not status["installed"] and not status["version"]:
322
+ return {"upgraded": False, "reason": "No hooks installed"}
323
+
324
+ include_gate = status["gate_enabled"]
325
+ result = install_hooks(include_gate=include_gate)
326
+ result["upgraded"] = result["success"]
327
+ result["from_version"] = status["version"]
328
+ result["to_version"] = HOOKS_VERSION
329
+ return result
330
+
331
+
332
+ def auto_install_if_needed() -> dict | None:
333
+ """Auto-install hooks if not present and not explicitly disabled.
334
+
335
+ Called from MCP server startup and npm postinstall.
336
+ Returns install result, or None if skipped.
337
+
338
+ Fast path: version file exists and matches → ~0.1ms, returns None.
339
+ """
340
+ try:
341
+ # Respect explicit opt-out
342
+ if DISABLED_FILE.exists():
343
+ return None
344
+
345
+ # Already installed and current → skip
346
+ if VERSION_FILE.exists():
347
+ installed = VERSION_FILE.read_text().strip()
348
+ if installed == HOOKS_VERSION:
349
+ return None
350
+
351
+ # Install with clear message
352
+ result = install_hooks(include_gate=False)
353
+ if result["success"]:
354
+ logger.info(
355
+ "SLM: Hooks installed into Claude Code (slm hooks remove to undo)"
356
+ )
357
+ return result
358
+ except Exception as exc:
359
+ logger.debug("Auto-install check failed: %s", exc)
360
+ return None
361
+
362
+
363
+ def auto_upgrade_check() -> None:
364
+ """Silent auto-upgrade on version mismatch. ~0.1ms when current."""
365
+ try:
366
+ if not VERSION_FILE.exists():
367
+ legacy_script = VERSION_DIR / "slm-session-start.sh"
368
+ if legacy_script.exists():
369
+ _migrate_legacy_hooks()
370
+ return
371
+
372
+ installed = VERSION_FILE.read_text().strip()
373
+ if installed == HOOKS_VERSION:
374
+ return
375
+
376
+ result = upgrade_hooks()
377
+ if result.get("upgraded"):
378
+ logger.info("SLM hooks upgraded %s -> %s", installed, HOOKS_VERSION)
379
+ except Exception as exc:
380
+ logger.debug("Hook auto-upgrade failed: %s", exc)
381
+
382
+
383
+ def _migrate_legacy_hooks() -> None:
384
+ """Migrate from bash-script hooks (pre-3.3.6) to hybrid hooks."""
385
+ try:
386
+ settings = _read_settings()
387
+ has_legacy = False
388
+ for entries in settings.get("hooks", {}).values():
389
+ for e in entries:
390
+ for h in e.get("hooks", []):
391
+ if ".superlocalmemory/hooks/" in h.get("command", ""):
392
+ has_legacy = True
393
+ break
394
+
395
+ if has_legacy:
396
+ settings = _remove_slm_hooks(settings)
397
+ hook_defs = _hook_definitions(include_gate=False)
398
+ settings = _merge_hooks(settings, hook_defs)
399
+ _write_settings(settings)
400
+ VERSION_DIR.mkdir(parents=True, exist_ok=True)
401
+ VERSION_FILE.write_text(HOOKS_VERSION)
402
+ logger.info("Migrated legacy bash hooks to hybrid hooks (v%s)", HOOKS_VERSION)
403
+ except Exception as exc:
404
+ logger.debug("Legacy hook migration failed: %s", exc)