superlocalmemory 3.3.4 → 3.3.6
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.
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +32 -1
- package/src/superlocalmemory/cli/commands.py +129 -9
- package/src/superlocalmemory/cli/main.py +19 -0
- package/src/superlocalmemory/hooks/claude_code_hooks.py +351 -122
- package/src/superlocalmemory/hooks/hook_handlers.py +394 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.6",
|
|
4
4
|
"description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-memory",
|
package/pyproject.toml
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -213,17 +213,48 @@ if (fs.existsSync(V2_HOME) && fs.existsSync(path.join(V2_HOME, 'memory.db'))) {
|
|
|
213
213
|
console.log('');
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
// --- Step 5: Auto-install Claude Code hooks ---
|
|
217
|
+
// "Install once, forget forever" — hooks enable automatic memory lifecycle
|
|
218
|
+
const hooksDisabledFile = path.join(SLM_HOME, 'hooks', '.hooks-disabled');
|
|
219
|
+
if (fs.existsSync(hooksDisabledFile)) {
|
|
220
|
+
console.log('⊘ Claude Code hooks: skipped (user opted out via slm hooks remove)');
|
|
221
|
+
} else {
|
|
222
|
+
console.log('\nInstalling Claude Code hooks (auto-memory lifecycle)...');
|
|
223
|
+
const hookResult = spawnSync(pythonParts[0], [
|
|
224
|
+
...pythonParts.slice(1), '-m', 'superlocalmemory.cli.main', 'hooks', 'install',
|
|
225
|
+
], {
|
|
226
|
+
stdio: 'pipe', timeout: 15000,
|
|
227
|
+
env: {
|
|
228
|
+
...process.env,
|
|
229
|
+
PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:' + (process.env.PATH || ''),
|
|
230
|
+
PYTHONPATH: path.join(__dirname, '..', 'src') + ':' + (process.env.PYTHONPATH || ''),
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (hookResult.status === 0) {
|
|
235
|
+
console.log('✓ Claude Code hooks installed (auto-recall, auto-observe, auto-save)');
|
|
236
|
+
console.log(' SLM: Hooks installed into Claude Code (slm hooks remove to undo)');
|
|
237
|
+
} else {
|
|
238
|
+
console.log('⚠ Claude Code hooks not installed (run: slm hooks install)');
|
|
239
|
+
// Non-fatal — don't block npm install
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
216
243
|
// --- Done ---
|
|
217
244
|
console.log('════════════════════════════════════════════════════════════');
|
|
218
245
|
console.log(' ✓ SuperLocalMemory V3 installed successfully!');
|
|
219
246
|
console.log('');
|
|
220
247
|
console.log(' Quick start:');
|
|
221
|
-
console.log('
|
|
248
|
+
console.log(' Just open Claude Code — memory works automatically!');
|
|
249
|
+
console.log('');
|
|
250
|
+
console.log(' Other commands:');
|
|
222
251
|
console.log(' slm doctor # Pre-flight check (verify everything works)');
|
|
223
252
|
console.log(' slm warmup # Pre-download embedding model (~500MB)');
|
|
224
253
|
console.log(' slm remember "..." # Store a memory');
|
|
225
254
|
console.log(' slm recall "..." # Search memories');
|
|
226
255
|
console.log(' slm dashboard # Open 17-tab web dashboard');
|
|
256
|
+
console.log(' slm hooks status # Check hook installation');
|
|
257
|
+
console.log(' slm hooks remove # Opt out of auto-memory hooks');
|
|
227
258
|
console.log('');
|
|
228
259
|
console.log(' Prerequisites satisfied:');
|
|
229
260
|
console.log(' ✓ Python 3.11+');
|
|
@@ -18,7 +18,16 @@ from argparse import Namespace
|
|
|
18
18
|
|
|
19
19
|
def dispatch(args: Namespace) -> None:
|
|
20
20
|
"""Route CLI command to the appropriate handler."""
|
|
21
|
+
# Auto-install/upgrade hooks on version change (single file read, ~0.1ms)
|
|
22
|
+
if args.command not in ("hooks", "init", "mcp"):
|
|
23
|
+
try:
|
|
24
|
+
from superlocalmemory.hooks.claude_code_hooks import auto_install_if_needed
|
|
25
|
+
auto_install_if_needed()
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
21
29
|
handlers = {
|
|
30
|
+
"init": cmd_init,
|
|
22
31
|
"setup": cmd_setup,
|
|
23
32
|
"mode": cmd_mode,
|
|
24
33
|
"provider": cmd_provider,
|
|
@@ -923,6 +932,14 @@ def cmd_trace(args: Namespace) -> None:
|
|
|
923
932
|
|
|
924
933
|
def cmd_mcp(_args: Namespace) -> None:
|
|
925
934
|
"""Start the V3 MCP server (stdio transport for IDE integration)."""
|
|
935
|
+
# Auto-install hooks on MCP startup (fast path: ~0.1ms if already current)
|
|
936
|
+
# CRITICAL: No stdout — MCP uses stdio transport, any print corrupts protocol
|
|
937
|
+
try:
|
|
938
|
+
from superlocalmemory.hooks.claude_code_hooks import auto_install_if_needed
|
|
939
|
+
auto_install_if_needed()
|
|
940
|
+
except Exception:
|
|
941
|
+
pass
|
|
942
|
+
|
|
926
943
|
from superlocalmemory.mcp.server import server
|
|
927
944
|
|
|
928
945
|
server.run(transport="stdio")
|
|
@@ -1142,6 +1159,99 @@ def cmd_profile(args: Namespace) -> None:
|
|
|
1142
1159
|
# -- Active Memory commands (V3.1) ------------------------------------------
|
|
1143
1160
|
|
|
1144
1161
|
|
|
1162
|
+
def cmd_init(args: Namespace) -> None:
|
|
1163
|
+
"""One-command setup: mode + hooks + IDE connect + warmup."""
|
|
1164
|
+
from pathlib import Path
|
|
1165
|
+
from superlocalmemory.core.config import SLMConfig
|
|
1166
|
+
|
|
1167
|
+
force = getattr(args, "force", False)
|
|
1168
|
+
|
|
1169
|
+
config_exists = (Path.home() / ".superlocalmemory" / "config.json").exists()
|
|
1170
|
+
|
|
1171
|
+
print()
|
|
1172
|
+
print("SuperLocalMemory — One-Time Setup")
|
|
1173
|
+
print("=" * 40)
|
|
1174
|
+
|
|
1175
|
+
# Step 1: Mode selection (interactive)
|
|
1176
|
+
if force or not config_exists:
|
|
1177
|
+
print()
|
|
1178
|
+
from superlocalmemory.cli.setup_wizard import run_wizard
|
|
1179
|
+
run_wizard()
|
|
1180
|
+
else:
|
|
1181
|
+
config = SLMConfig.load()
|
|
1182
|
+
print(f"\n Already configured: Mode {config.mode.value.upper()}")
|
|
1183
|
+
print(f" Profile: {config.active_profile}")
|
|
1184
|
+
|
|
1185
|
+
# Step 2: Install hooks (gate always OFF by default)
|
|
1186
|
+
print()
|
|
1187
|
+
print("Installing Claude Code hooks...")
|
|
1188
|
+
from superlocalmemory.hooks.claude_code_hooks import install_hooks, check_status
|
|
1189
|
+
|
|
1190
|
+
status = check_status()
|
|
1191
|
+
|
|
1192
|
+
if status["installed"] and not force:
|
|
1193
|
+
if status["needs_upgrade"]:
|
|
1194
|
+
from superlocalmemory.hooks.claude_code_hooks import upgrade_hooks
|
|
1195
|
+
result = upgrade_hooks()
|
|
1196
|
+
if result.get("upgraded"):
|
|
1197
|
+
print(f" Hooks upgraded: {result['from_version']} -> {result['to_version']}")
|
|
1198
|
+
else:
|
|
1199
|
+
print(f" Upgrade issue: {result.get('reason', result.get('errors', ''))}")
|
|
1200
|
+
else:
|
|
1201
|
+
print(f" Hooks already installed (v{status['version']})")
|
|
1202
|
+
else:
|
|
1203
|
+
result = install_hooks(include_gate=False)
|
|
1204
|
+
if result["success"]:
|
|
1205
|
+
print(f" Hooks installed: {', '.join(result['hooks_added'])}")
|
|
1206
|
+
print(" SLM: Hooks installed into Claude Code (slm hooks remove to undo)")
|
|
1207
|
+
else:
|
|
1208
|
+
print(f" Hook install failed: {result['errors']}")
|
|
1209
|
+
|
|
1210
|
+
# Step 3: IDE connection
|
|
1211
|
+
print()
|
|
1212
|
+
print("Detecting IDEs...")
|
|
1213
|
+
try:
|
|
1214
|
+
from superlocalmemory.hooks.ide_connector import IDEConnector
|
|
1215
|
+
connector = IDEConnector()
|
|
1216
|
+
results = connector.connect_all()
|
|
1217
|
+
for ide_id, ide_status in results.items():
|
|
1218
|
+
print(f" {ide_id}: {ide_status}")
|
|
1219
|
+
except Exception as exc:
|
|
1220
|
+
print(f" IDE detection skipped: {exc}")
|
|
1221
|
+
|
|
1222
|
+
# Step 4: Warmup (embedding model)
|
|
1223
|
+
print()
|
|
1224
|
+
print("Checking embedding model...")
|
|
1225
|
+
try:
|
|
1226
|
+
from superlocalmemory.core.config import SLMConfig as _Cfg
|
|
1227
|
+
cfg = _Cfg.load()
|
|
1228
|
+
model_name = cfg.embedding.model_name
|
|
1229
|
+
print(f" Model: {model_name}")
|
|
1230
|
+
# Quick check: try creating embedding service (auto-downloads if needed)
|
|
1231
|
+
from superlocalmemory.core.embeddings import EmbeddingService
|
|
1232
|
+
svc = EmbeddingService(cfg.embedding)
|
|
1233
|
+
test_result = svc.embed_text("test")
|
|
1234
|
+
if test_result is not None and len(test_result) > 0:
|
|
1235
|
+
print(" Status: ready")
|
|
1236
|
+
else:
|
|
1237
|
+
print(" Status: model not available (run: slm warmup)")
|
|
1238
|
+
except Exception as exc:
|
|
1239
|
+
print(f" Warmup skipped: {exc}")
|
|
1240
|
+
print(" Run 'slm warmup' later to download the embedding model.")
|
|
1241
|
+
|
|
1242
|
+
# Done
|
|
1243
|
+
print()
|
|
1244
|
+
print("=" * 40)
|
|
1245
|
+
print("SLM is active. Your AI now remembers you.")
|
|
1246
|
+
print()
|
|
1247
|
+
print("What happens next:")
|
|
1248
|
+
print(" - Open Claude Code in any project")
|
|
1249
|
+
print(" - SLM auto-injects your memory context")
|
|
1250
|
+
print(" - Decisions, bugs, preferences are captured automatically")
|
|
1251
|
+
print(" - Session summaries saved when you close")
|
|
1252
|
+
print()
|
|
1253
|
+
|
|
1254
|
+
|
|
1145
1255
|
def cmd_hooks(args: Namespace) -> None:
|
|
1146
1256
|
"""Manage Claude Code hooks for invisible memory injection."""
|
|
1147
1257
|
from superlocalmemory.hooks.claude_code_hooks import (
|
|
@@ -1149,28 +1259,38 @@ def cmd_hooks(args: Namespace) -> None:
|
|
|
1149
1259
|
)
|
|
1150
1260
|
|
|
1151
1261
|
action = getattr(args, "action", "status")
|
|
1262
|
+
# Gate is OFF by default. --gate opts in (for brave users).
|
|
1263
|
+
include_gate = getattr(args, "gate", False)
|
|
1264
|
+
|
|
1152
1265
|
if action == "install":
|
|
1153
|
-
result = install_hooks()
|
|
1154
|
-
if result["
|
|
1266
|
+
result = install_hooks(include_gate=include_gate)
|
|
1267
|
+
if result["success"]:
|
|
1155
1268
|
print("SLM hooks installed in Claude Code.")
|
|
1156
|
-
print("
|
|
1269
|
+
print(f" Hook types: {', '.join(result['hooks_added'])}")
|
|
1270
|
+
if include_gate:
|
|
1271
|
+
print(" Gate: ON (enforces session_init — experimental)")
|
|
1272
|
+
print(" SLM: Hooks installed into Claude Code (slm hooks remove to undo)")
|
|
1157
1273
|
else:
|
|
1158
|
-
print(f"Installation
|
|
1274
|
+
print(f"Installation failed: {result['errors']}")
|
|
1159
1275
|
elif action == "remove":
|
|
1160
1276
|
result = remove_hooks()
|
|
1161
|
-
if result["
|
|
1277
|
+
if result["success"]:
|
|
1162
1278
|
print("SLM hooks removed from Claude Code.")
|
|
1163
1279
|
else:
|
|
1164
|
-
print(f"Removal
|
|
1280
|
+
print(f"Removal failed: {result['errors']}")
|
|
1165
1281
|
else:
|
|
1166
1282
|
result = check_status()
|
|
1167
1283
|
if result["installed"]:
|
|
1168
|
-
print("SLM hooks: INSTALLED")
|
|
1169
|
-
print(f"
|
|
1170
|
-
print("
|
|
1284
|
+
print(f"SLM hooks: INSTALLED (v{result['version']})")
|
|
1285
|
+
print(f" Hook types: {', '.join(result['hook_types'])}")
|
|
1286
|
+
print(f" Gate: {'ON' if result['gate_enabled'] else 'OFF'}")
|
|
1287
|
+
if result["needs_upgrade"]:
|
|
1288
|
+
print(f" Update available: {result['version']} -> {result['latest_version']}")
|
|
1289
|
+
print(" Run: slm hooks install")
|
|
1171
1290
|
else:
|
|
1172
1291
|
print("SLM hooks: NOT INSTALLED")
|
|
1173
1292
|
print(" Run: slm hooks install")
|
|
1293
|
+
print(" Or: slm init (full setup)")
|
|
1174
1294
|
|
|
1175
1295
|
|
|
1176
1296
|
def cmd_session_context(args: Namespace) -> None:
|
|
@@ -70,6 +70,12 @@ documentation:
|
|
|
70
70
|
|
|
71
71
|
def main() -> None:
|
|
72
72
|
"""Parse CLI arguments and dispatch to command handlers."""
|
|
73
|
+
# Fast path: hook invocations bypass argparse entirely (stdlib only, ~30ms)
|
|
74
|
+
if len(sys.argv) >= 3 and sys.argv[1] == "hook":
|
|
75
|
+
from superlocalmemory.hooks.hook_handlers import handle_hook
|
|
76
|
+
handle_hook(sys.argv[2])
|
|
77
|
+
return
|
|
78
|
+
|
|
73
79
|
from superlocalmemory.cli.json_output import _get_version
|
|
74
80
|
_ver = _get_version()
|
|
75
81
|
|
|
@@ -85,6 +91,15 @@ def main() -> None:
|
|
|
85
91
|
sub = parser.add_subparsers(dest="command", title="commands")
|
|
86
92
|
|
|
87
93
|
# -- Setup & Config ------------------------------------------------
|
|
94
|
+
init_p = sub.add_parser("init", help="One-command setup: mode + hooks + IDE + warmup")
|
|
95
|
+
init_p.add_argument(
|
|
96
|
+
"--force", action="store_true", help="Re-run full setup even if already configured",
|
|
97
|
+
)
|
|
98
|
+
init_p.add_argument(
|
|
99
|
+
"--gate", action="store_true",
|
|
100
|
+
help="Enable PreToolUse gate (experimental — blocks tools until session_init)",
|
|
101
|
+
)
|
|
102
|
+
|
|
88
103
|
sub.add_parser("setup", help="Interactive first-time setup wizard")
|
|
89
104
|
|
|
90
105
|
mode_p = sub.add_parser("mode", help="Get or set operating mode (a/b/c)")
|
|
@@ -182,6 +197,10 @@ def main() -> None:
|
|
|
182
197
|
"action", nargs="?", default="status",
|
|
183
198
|
choices=["install", "remove", "status"], help="Action (default: status)",
|
|
184
199
|
)
|
|
200
|
+
hooks_p.add_argument(
|
|
201
|
+
"--gate", action="store_true",
|
|
202
|
+
help="Enable PreToolUse gate (experimental — blocks tools until session_init)",
|
|
203
|
+
)
|
|
185
204
|
|
|
186
205
|
ctx_p = sub.add_parser("session-context", help="Print session context (for hooks)")
|
|
187
206
|
ctx_p.add_argument("query", nargs="?", default="", help="Optional context query")
|
|
@@ -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
|
|
5
|
+
"""Claude Code hook integration — hybrid approach (v3.3.6).
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
12
|
-
slm hooks
|
|
13
|
-
slm hooks
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
slm
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
243
|
+
result["errors"].append(f"Settings update failed: {exc}")
|
|
78
244
|
|
|
79
|
-
# 2. Update Claude Code settings.json
|
|
80
245
|
try:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
252
|
+
result["errors"].append(f"Version file failed: {exc}")
|
|
112
253
|
|
|
113
|
-
return
|
|
254
|
+
return result
|
|
114
255
|
|
|
115
256
|
|
|
116
257
|
def remove_hooks() -> dict:
|
|
117
|
-
"""Remove SLM hooks from Claude Code settings.
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
271
|
+
result["errors"].append(f"Settings cleanup failed: {exc}")
|
|
127
272
|
|
|
128
|
-
# 2. Remove from Claude Code settings
|
|
129
273
|
try:
|
|
130
|
-
if
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
282
|
+
return result
|
|
148
283
|
|
|
149
284
|
|
|
150
285
|
def check_status() -> dict:
|
|
151
|
-
"""Check
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
294
|
+
hook_types_found: list[str] = []
|
|
295
|
+
has_gate = False
|
|
158
296
|
try:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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":
|
|
172
|
-
"
|
|
173
|
-
"
|
|
174
|
-
"
|
|
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)
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Claude Code hook handlers — zero-dependency, cross-platform.
|
|
6
|
+
|
|
7
|
+
All handlers use ONLY Python stdlib (sys, os, json, tempfile, subprocess, time).
|
|
8
|
+
No SLM imports in the hot path. Called via: slm hook <start|gate|init-done|checkpoint|stop>
|
|
9
|
+
|
|
10
|
+
The main() entry point in cli/main.py has a fast path that dispatches here
|
|
11
|
+
BEFORE argparse or any heavy imports.
|
|
12
|
+
|
|
13
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Cross-platform temp paths
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
_TMP = tempfile.gettempdir()
|
|
29
|
+
_MARKER = os.path.join(_TMP, "slm-session-initialized")
|
|
30
|
+
_START_TIME = os.path.join(_TMP, "slm-session-start-time")
|
|
31
|
+
_ACTIVITY_LOG = os.path.join(_TMP, "slm-session-activity")
|
|
32
|
+
_LAST_CONSOLIDATION = os.path.join(
|
|
33
|
+
os.path.expanduser("~"), ".superlocalmemory", ".last-consolidation",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def handle_hook(action: str) -> None:
|
|
38
|
+
"""Dispatch to the appropriate hook handler. Called from main() fast path."""
|
|
39
|
+
handlers = {
|
|
40
|
+
"start": _hook_start,
|
|
41
|
+
"gate": _hook_gate,
|
|
42
|
+
"init-done": _hook_init_done,
|
|
43
|
+
"checkpoint": _hook_checkpoint,
|
|
44
|
+
"stop": _hook_stop,
|
|
45
|
+
}
|
|
46
|
+
handler = handlers.get(action)
|
|
47
|
+
if handler is None:
|
|
48
|
+
print(f"Unknown hook action: {action}", file=sys.stderr)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
handler()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# 1. SESSION START — SessionStart hook
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def _hook_start() -> None:
|
|
58
|
+
"""Clean markers, inject SQL-fast context, print session_init mandate."""
|
|
59
|
+
# Clean stale markers from previous sessions
|
|
60
|
+
for f in (_MARKER, _START_TIME, _ACTIVITY_LOG):
|
|
61
|
+
try:
|
|
62
|
+
os.remove(f)
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# Record session start time
|
|
67
|
+
with open(_START_TIME, "w") as f:
|
|
68
|
+
f.write(str(int(time.time())))
|
|
69
|
+
|
|
70
|
+
# Initialize activity log
|
|
71
|
+
with open(_ACTIVITY_LOG, "w") as f:
|
|
72
|
+
f.write("")
|
|
73
|
+
|
|
74
|
+
# Reap orphan MCP processes (background, best-effort)
|
|
75
|
+
try:
|
|
76
|
+
if sys.platform != "win32":
|
|
77
|
+
subprocess.Popen(
|
|
78
|
+
["sh", "-c",
|
|
79
|
+
"ps -eo pid,args 2>/dev/null"
|
|
80
|
+
" | grep -E 'node.*\\.bin/|node.*slm |uv tool uvx'"
|
|
81
|
+
" | grep -v grep"
|
|
82
|
+
" | awk '{print $1, $NF}'"
|
|
83
|
+
" | sort -k2,2 -k1,1rn"
|
|
84
|
+
" | awk '{if($2==p)print $1; p=$2}'"
|
|
85
|
+
" | xargs kill 2>/dev/null"],
|
|
86
|
+
stdout=subprocess.DEVNULL,
|
|
87
|
+
stderr=subprocess.DEVNULL,
|
|
88
|
+
)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# Print session context (SQL-fast path, <500ms)
|
|
93
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
94
|
+
project_name = os.path.basename(project_dir)
|
|
95
|
+
try:
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
["slm", "session-context", project_name],
|
|
98
|
+
capture_output=True, text=True, timeout=12,
|
|
99
|
+
)
|
|
100
|
+
if result.stdout.strip():
|
|
101
|
+
print(result.stdout.strip())
|
|
102
|
+
except Exception:
|
|
103
|
+
print("# SLM Session Context — unavailable")
|
|
104
|
+
|
|
105
|
+
# Mandatory session_init instruction
|
|
106
|
+
print()
|
|
107
|
+
print("## MANDATORY: SLM Session Init")
|
|
108
|
+
print("BEFORE your first response, call:")
|
|
109
|
+
print(f" mcp__superlocalmemory__session_init with project_path='{project_dir}'"
|
|
110
|
+
" and a topic from the user's first message")
|
|
111
|
+
print("session_init returns both context AND memories — no separate recall needed.")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# 2. GATE — PreToolUse hook (default, enforces session_init)
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def _hook_gate() -> None:
|
|
119
|
+
"""Block non-SLM tools until session_init has been called.
|
|
120
|
+
|
|
121
|
+
Fast path (~30ms): marker file exists → exit 0.
|
|
122
|
+
Slow path (~80ms): parse JSON stdin, allow SLM tools, block rest.
|
|
123
|
+
"""
|
|
124
|
+
# Fast path: already initialized
|
|
125
|
+
if os.path.exists(_MARKER):
|
|
126
|
+
sys.exit(0)
|
|
127
|
+
|
|
128
|
+
# Safety: if session-start never ran, don't gate (avoid lockout)
|
|
129
|
+
if not os.path.exists(_START_TIME):
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
|
|
132
|
+
# Parse tool name from stdin
|
|
133
|
+
tool_name = ""
|
|
134
|
+
if not sys.stdin.isatty():
|
|
135
|
+
try:
|
|
136
|
+
data = json.load(sys.stdin)
|
|
137
|
+
tool_name = data.get("tool_name", "")
|
|
138
|
+
except Exception:
|
|
139
|
+
# Can't parse input — don't block (safety)
|
|
140
|
+
sys.exit(0)
|
|
141
|
+
|
|
142
|
+
# Allow SLM tools through (needed to call session_init itself)
|
|
143
|
+
if tool_name.startswith("mcp__superlocalmemory__"):
|
|
144
|
+
sys.exit(0)
|
|
145
|
+
|
|
146
|
+
# Allow ToolSearch through (needed to fetch SLM tool schemas)
|
|
147
|
+
if tool_name == "ToolSearch":
|
|
148
|
+
sys.exit(0)
|
|
149
|
+
|
|
150
|
+
# Block everything else
|
|
151
|
+
print("[SLM-GATE] BLOCKED: Call mcp__superlocalmemory__session_init"
|
|
152
|
+
" before using other tools.")
|
|
153
|
+
sys.exit(2)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# 3. INIT DONE — PostToolUse hook for session_init
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _hook_init_done() -> None:
|
|
161
|
+
"""Create marker file to lift the gate for the rest of the session."""
|
|
162
|
+
with open(_MARKER, "w") as f:
|
|
163
|
+
f.write(str(int(time.time())))
|
|
164
|
+
sys.exit(0)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# 4. CHECKPOINT — PostToolUse hook for Write|Edit
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
_OBSERVE_COOLDOWN = 300 # 5 minutes per file
|
|
172
|
+
_RECALL_INTERVAL = 900 # 15 minutes
|
|
173
|
+
_LEARN_INTERVAL = 1800 # 30 minutes
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _hook_checkpoint() -> None:
|
|
177
|
+
"""Auto-observe file changes + periodic recall/learn reminders.
|
|
178
|
+
|
|
179
|
+
1. Directly calls `slm observe` for file change tracking (no Claude needed)
|
|
180
|
+
2. Suggests richer observe to Claude
|
|
181
|
+
3. Periodic recall refresh reminder
|
|
182
|
+
4. Periodic learn/patterns reminder
|
|
183
|
+
"""
|
|
184
|
+
now = int(time.time())
|
|
185
|
+
|
|
186
|
+
# Parse file_path from stdin
|
|
187
|
+
file_path = ""
|
|
188
|
+
if not sys.stdin.isatty():
|
|
189
|
+
try:
|
|
190
|
+
data = json.load(sys.stdin)
|
|
191
|
+
tool_input = data.get("tool_input", {})
|
|
192
|
+
if isinstance(tool_input, dict):
|
|
193
|
+
file_path = tool_input.get("file_path", "")
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
# --- Auto-observe file change (direct, no Claude needed) ---
|
|
198
|
+
if file_path:
|
|
199
|
+
basename = os.path.basename(file_path)
|
|
200
|
+
lock_file = os.path.join(_TMP, f"slm-obs-{_safe_hash(file_path)}")
|
|
201
|
+
|
|
202
|
+
if _cooldown_elapsed(lock_file, _OBSERVE_COOLDOWN, now):
|
|
203
|
+
_write_timestamp(lock_file, now)
|
|
204
|
+
|
|
205
|
+
# Direct observe — SLM records the change even if Claude ignores
|
|
206
|
+
try:
|
|
207
|
+
subprocess.Popen(
|
|
208
|
+
["slm", "observe", f"File changed: {basename}"],
|
|
209
|
+
stdout=subprocess.DEVNULL,
|
|
210
|
+
stderr=subprocess.DEVNULL,
|
|
211
|
+
)
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
# Log to session activity
|
|
216
|
+
try:
|
|
217
|
+
with open(_ACTIVITY_LOG, "a") as f:
|
|
218
|
+
f.write(f"{now}|{basename}\n")
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
# Suggest richer observe to Claude (with semantic context)
|
|
223
|
+
print(f"[SLM-AUTO] File changed: {basename}"
|
|
224
|
+
" — Call mcp__superlocalmemory__observe with a 1-line"
|
|
225
|
+
" summary of what was changed and why.")
|
|
226
|
+
|
|
227
|
+
# --- Periodic recall reminder (every 15 min) ---
|
|
228
|
+
recall_lock = os.path.join(_TMP, "slm-recall-reminder")
|
|
229
|
+
if _cooldown_elapsed(recall_lock, _RECALL_INTERVAL, now):
|
|
230
|
+
_write_timestamp(recall_lock, now)
|
|
231
|
+
print("[SLM] 15+ min since last context refresh."
|
|
232
|
+
" Call mcp__superlocalmemory__recall with current work topic.")
|
|
233
|
+
|
|
234
|
+
# --- Periodic learn reminder (every 30 min) ---
|
|
235
|
+
learn_lock = os.path.join(_TMP, "slm-learn-reminder")
|
|
236
|
+
if _cooldown_elapsed(learn_lock, _LEARN_INTERVAL, now):
|
|
237
|
+
_write_timestamp(learn_lock, now)
|
|
238
|
+
print("[SLM] Call mcp__superlocalmemory__get_learned_patterns"
|
|
239
|
+
" to adapt to learned preferences.")
|
|
240
|
+
|
|
241
|
+
sys.exit(0)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# 5. STOP — Stop hook (session end)
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def _hook_stop() -> None:
|
|
249
|
+
"""Save rich session summary + trigger auto-consolidation."""
|
|
250
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
251
|
+
project_name = os.path.basename(project_dir)
|
|
252
|
+
timestamp = time.strftime("%Y-%m-%d %H:%M")
|
|
253
|
+
|
|
254
|
+
# --- Git context ---
|
|
255
|
+
git_branch = _run_quiet(["git", "-C", project_dir, "branch", "--show-current"])
|
|
256
|
+
git_diff = _run_quiet(
|
|
257
|
+
["git", "-C", project_dir, "diff", "--stat"],
|
|
258
|
+
postprocess=lambda s: s.strip().rsplit("\n", 1)[-1].strip() if s.strip() else "",
|
|
259
|
+
)
|
|
260
|
+
recent_commits = _run_quiet(
|
|
261
|
+
["git", "-C", project_dir, "log", "--oneline", "-5", "--since=3 hours ago"],
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# --- Files from activity log ---
|
|
265
|
+
modified = ""
|
|
266
|
+
try:
|
|
267
|
+
if os.path.exists(_ACTIVITY_LOG):
|
|
268
|
+
with open(_ACTIVITY_LOG) as f:
|
|
269
|
+
files = sorted({line.split("|", 1)[1].strip()
|
|
270
|
+
for line in f if "|" in line})
|
|
271
|
+
modified = ", ".join(files[:20])
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
# --- Build summary ---
|
|
276
|
+
parts = [f"[{project_name}] session ended {timestamp}"]
|
|
277
|
+
if git_branch:
|
|
278
|
+
parts.append(f"branch: {git_branch}")
|
|
279
|
+
if git_diff:
|
|
280
|
+
parts.append(f"uncommitted: {git_diff}")
|
|
281
|
+
if recent_commits:
|
|
282
|
+
commits = "; ".join(recent_commits.strip().split("\n")[:5])
|
|
283
|
+
parts.append(f"recent: {commits}")
|
|
284
|
+
if modified:
|
|
285
|
+
parts.append(f"files: {modified}")
|
|
286
|
+
|
|
287
|
+
summary = " | ".join(parts)
|
|
288
|
+
|
|
289
|
+
# --- Save to SLM ---
|
|
290
|
+
try:
|
|
291
|
+
subprocess.run(
|
|
292
|
+
["slm", "observe", summary],
|
|
293
|
+
capture_output=True, timeout=8,
|
|
294
|
+
)
|
|
295
|
+
except Exception:
|
|
296
|
+
try:
|
|
297
|
+
subprocess.run(
|
|
298
|
+
["slm", "remember", summary],
|
|
299
|
+
capture_output=True, timeout=8,
|
|
300
|
+
)
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
# --- Auto-consolidation (if >24h since last run) ---
|
|
305
|
+
_maybe_consolidate()
|
|
306
|
+
|
|
307
|
+
# --- Clean up session markers ---
|
|
308
|
+
for f in (_MARKER, _START_TIME, _ACTIVITY_LOG):
|
|
309
|
+
try:
|
|
310
|
+
os.remove(f)
|
|
311
|
+
except OSError:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
# Clean rate-limit locks
|
|
315
|
+
for name in os.listdir(_TMP):
|
|
316
|
+
if name.startswith("slm-obs-") or name.startswith("slm-recall-") or name.startswith("slm-learn-"):
|
|
317
|
+
try:
|
|
318
|
+
os.remove(os.path.join(_TMP, name))
|
|
319
|
+
except OSError:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
sys.exit(0)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# Helpers (stdlib only)
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
def _safe_hash(s: str) -> str:
|
|
330
|
+
"""Simple string hash for rate-limit lock file names."""
|
|
331
|
+
h = 0
|
|
332
|
+
for c in s:
|
|
333
|
+
h = (h * 31 + ord(c)) & 0xFFFFFFFF
|
|
334
|
+
return format(h, "08x")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _cooldown_elapsed(lock_file: str, interval: int, now: int) -> bool:
|
|
338
|
+
"""Check if enough time has passed since last timestamp in lock_file."""
|
|
339
|
+
try:
|
|
340
|
+
if os.path.exists(lock_file):
|
|
341
|
+
with open(lock_file) as f:
|
|
342
|
+
last = int(f.read().strip())
|
|
343
|
+
return (now - last) >= interval
|
|
344
|
+
except (ValueError, OSError):
|
|
345
|
+
pass
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _write_timestamp(path: str, ts: int) -> None:
|
|
350
|
+
"""Write a unix timestamp to a file."""
|
|
351
|
+
try:
|
|
352
|
+
with open(path, "w") as f:
|
|
353
|
+
f.write(str(ts))
|
|
354
|
+
except OSError:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _run_quiet(cmd: list[str], timeout: int = 5, postprocess=None) -> str:
|
|
359
|
+
"""Run a command quietly, return stdout or empty string."""
|
|
360
|
+
try:
|
|
361
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
362
|
+
out = result.stdout.strip()
|
|
363
|
+
if postprocess and out:
|
|
364
|
+
out = postprocess(out)
|
|
365
|
+
return out
|
|
366
|
+
except Exception:
|
|
367
|
+
return ""
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _maybe_consolidate() -> None:
|
|
371
|
+
"""Run cognitive consolidation if last run was >24h ago. Non-blocking."""
|
|
372
|
+
try:
|
|
373
|
+
last_ts = 0
|
|
374
|
+
if os.path.exists(_LAST_CONSOLIDATION):
|
|
375
|
+
with open(_LAST_CONSOLIDATION) as f:
|
|
376
|
+
last_ts = int(f.read().strip())
|
|
377
|
+
|
|
378
|
+
now = int(time.time())
|
|
379
|
+
if (now - last_ts) < 86400: # 24 hours
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
# Update timestamp FIRST to prevent concurrent runs
|
|
383
|
+
os.makedirs(os.path.dirname(_LAST_CONSOLIDATION), exist_ok=True)
|
|
384
|
+
with open(_LAST_CONSOLIDATION, "w") as f:
|
|
385
|
+
f.write(str(now))
|
|
386
|
+
|
|
387
|
+
# Run consolidation in background (don't block session end)
|
|
388
|
+
subprocess.Popen(
|
|
389
|
+
["slm", "consolidate", "--cognitive"],
|
|
390
|
+
stdout=subprocess.DEVNULL,
|
|
391
|
+
stderr=subprocess.DEVNULL,
|
|
392
|
+
)
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|