superlocalmemory 3.3.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.3.5",
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.3.5"
3
+ version = "3.3.6"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -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(' slm setup # First-time configuration');
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["scripts"] and result["settings"]:
1266
+ result = install_hooks(include_gate=include_gate)
1267
+ if result["success"]:
1155
1268
  print("SLM hooks installed in Claude Code.")
1156
- print("Memory context will auto-inject on every new session.")
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 incomplete: {result['errors']}")
1274
+ print(f"Installation failed: {result['errors']}")
1159
1275
  elif action == "remove":
1160
1276
  result = remove_hooks()
1161
- if result["scripts"] and result["settings"]:
1277
+ if result["success"]:
1162
1278
  print("SLM hooks removed from Claude Code.")
1163
1279
  else:
1164
- print(f"Removal incomplete: {result['errors']}")
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" Scripts: {result['hooks_dir']}")
1170
- print(" Claude Code settings: configured")
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 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)
@@ -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