superlocalmemory 3.3.5 → 3.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -16,6 +16,7 @@ License: MIT
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import atexit
19
20
  import json
20
21
  import logging
21
22
  import os
@@ -23,10 +24,14 @@ import subprocess
23
24
  import sys
24
25
  import threading
25
26
  import time
27
+ import weakref
26
28
  from typing import Any
27
29
 
28
30
  from superlocalmemory.storage.models import AtomicFact
29
31
 
32
+ # Track all live reranker instances for atexit cleanup
33
+ _live_rerankers: set[weakref.ref] = set()
34
+
30
35
  logger = logging.getLogger(__name__)
31
36
 
32
37
  _IDLE_TIMEOUT_SECONDS = 120 # 2 min → kill worker
@@ -64,11 +69,22 @@ class CrossEncoderReranker:
64
69
  self._idle_timer: threading.Timer | None = None
65
70
  self._request_count: int = 0
66
71
 
72
+ # Register for atexit cleanup (prevent orphaned workers)
73
+ ref = weakref.ref(self, _live_rerankers.discard)
74
+ _live_rerankers.add(ref)
75
+
67
76
  # Start background warmup immediately — worker loads model
68
77
  # while the rest of init continues. First recall gets instant
69
78
  # fallback; second recall uses the warm model.
70
79
  self._start_background_warmup()
71
80
 
81
+ def __del__(self) -> None:
82
+ """Kill worker subprocess when reranker is garbage-collected."""
83
+ try:
84
+ self._kill_worker()
85
+ except Exception:
86
+ pass
87
+
72
88
  # ------------------------------------------------------------------
73
89
  # Background warmup (non-blocking model load)
74
90
  # ------------------------------------------------------------------
@@ -330,3 +346,26 @@ class CrossEncoderReranker:
330
346
  if resp is None:
331
347
  return False
332
348
  return resp.get("ok", False)
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Module-level atexit: kill ALL reranker workers on process exit
353
+ # ---------------------------------------------------------------------------
354
+
355
+ def _cleanup_all_rerankers() -> None:
356
+ """Kill all reranker worker subprocesses on interpreter exit.
357
+
358
+ Prevents orphaned 1.3 GB ONNX/PyTorch workers surviving after
359
+ parent exits (especially during test runs with parallel agents).
360
+ """
361
+ for ref in list(_live_rerankers):
362
+ reranker = ref()
363
+ if reranker is not None:
364
+ try:
365
+ reranker._kill_worker()
366
+ except Exception:
367
+ pass
368
+ _live_rerankers.clear()
369
+
370
+
371
+ atexit.register(_cleanup_all_rerankers)