superlocalmemory 3.4.34 → 3.4.36

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,276 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Persistent hook daemon — Unix socket server for sub-200ms recall.
6
+
7
+ Eliminates Python subprocess startup (~300-500ms) by keeping a long-lived
8
+ process that Claude Code hooks talk to via Unix domain socket.
9
+
10
+ Protocol (newline-delimited JSON):
11
+ Client → {"prompt": "...", "session_id": "..."}\n
12
+ Server → {"hookSpecificOutput": {...}}\n (or {}\n for ack/empty)
13
+
14
+ MEMORY SAFETY: This module NEVER imports MemoryEngine. All recall goes
15
+ through recall_queue.db → QueueConsumer → pool.recall(). The hook daemon
16
+ stays at ~15-20MB RSS.
17
+
18
+ Lifecycle: started by unified_daemon.py alongside QueueConsumer. If it
19
+ crashes, auto_recall_hook.py falls back to subprocess (v3.4.35 path).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ import os
27
+ import socket
28
+ import threading
29
+ import time
30
+ from pathlib import Path
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _DEFAULT_SOCK_NAME = "hook_daemon.sock"
35
+
36
+
37
+ def _default_sock_path() -> Path:
38
+ return Path.home() / ".superlocalmemory" / _DEFAULT_SOCK_NAME
39
+
40
+
41
+ def _default_queue_db_path() -> Path:
42
+ return Path.home() / ".superlocalmemory" / "recall_queue.db"
43
+
44
+
45
+ class HookDaemon:
46
+ """Unix socket server for persistent auto-recall.
47
+
48
+ Accepts newline-delimited JSON requests, runs the same logic as
49
+ auto_recall_hook.main() but without Python startup cost.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ sock_path: Path | None = None,
55
+ queue_db_path: Path | None = None,
56
+ ) -> None:
57
+ self._sock_path = sock_path or _default_sock_path()
58
+ self._queue_db_path = queue_db_path or _default_queue_db_path()
59
+ self._running = False
60
+ self._stop_event = threading.Event()
61
+ self._thread: threading.Thread | None = None
62
+ self._server_sock: socket.socket | None = None
63
+ self._queue = None
64
+
65
+ @property
66
+ def running(self) -> bool:
67
+ return self._running
68
+
69
+ def start(self) -> None:
70
+ if self._running:
71
+ return
72
+ if self._sock_path.exists():
73
+ self._sock_path.unlink()
74
+
75
+ from superlocalmemory.core.recall_queue import RecallQueue
76
+ self._queue = RecallQueue(self._queue_db_path)
77
+
78
+ self._server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
79
+ self._server_sock.bind(str(self._sock_path))
80
+ self._server_sock.listen(8)
81
+ self._server_sock.settimeout(1.0)
82
+
83
+ self._stop_event.clear()
84
+ self._running = True
85
+ self._thread = threading.Thread(
86
+ target=self._accept_loop,
87
+ daemon=True,
88
+ name="slm-hook-daemon",
89
+ )
90
+ self._thread.start()
91
+ logger.info("HookDaemon started on %s", self._sock_path)
92
+
93
+ def stop(self) -> None:
94
+ if not self._running:
95
+ return
96
+ self._stop_event.set()
97
+ self._running = False
98
+ if self._server_sock is not None:
99
+ try:
100
+ self._server_sock.close()
101
+ except Exception:
102
+ pass
103
+ self._server_sock = None
104
+ if self._thread is not None:
105
+ self._thread.join(timeout=3.0)
106
+ self._thread = None
107
+ if self._sock_path.exists():
108
+ try:
109
+ self._sock_path.unlink()
110
+ except Exception:
111
+ pass
112
+ if self._queue is not None:
113
+ try:
114
+ self._queue.close()
115
+ except Exception:
116
+ pass
117
+ self._queue = None
118
+ logger.info("HookDaemon stopped")
119
+
120
+ def _accept_loop(self) -> None:
121
+ while not self._stop_event.is_set():
122
+ try:
123
+ client, _ = self._server_sock.accept()
124
+ except socket.timeout:
125
+ continue
126
+ except OSError:
127
+ if self._stop_event.is_set():
128
+ break
129
+ continue
130
+ threading.Thread(
131
+ target=self._handle_client,
132
+ args=(client,),
133
+ daemon=True,
134
+ name="slm-hook-client",
135
+ ).start()
136
+
137
+ def _handle_client(self, client: socket.socket) -> None:
138
+ try:
139
+ client.settimeout(30.0)
140
+ data = b""
141
+ while b"\n" not in data:
142
+ chunk = client.recv(4096)
143
+ if not chunk:
144
+ return
145
+ data += chunk
146
+
147
+ line = data.decode("utf-8").strip()
148
+ if not line:
149
+ client.sendall(b"{}\n")
150
+ return
151
+
152
+ try:
153
+ payload = json.loads(line)
154
+ except Exception:
155
+ client.sendall(b"{}\n")
156
+ return
157
+
158
+ response = self._process_request(payload)
159
+ client.sendall((json.dumps(response) + "\n").encode("utf-8"))
160
+ except Exception:
161
+ try:
162
+ client.sendall(b"{}\n")
163
+ except Exception:
164
+ pass
165
+ finally:
166
+ try:
167
+ client.close()
168
+ except Exception:
169
+ pass
170
+
171
+ def _process_request(self, payload: dict) -> dict:
172
+ from superlocalmemory.hooks.auto_recall_hook import (
173
+ _is_ack, _get_mode_timeout, _detect_mode, _format_envelope,
174
+ _DEFAULT_LIMIT,
175
+ )
176
+ from superlocalmemory.core.recall_queue import QueueTimeoutError
177
+
178
+ prompt = payload.get("prompt", "")
179
+ session_id = payload.get("session_id", "")
180
+
181
+ if not prompt or not isinstance(prompt, str):
182
+ return {}
183
+
184
+ if _is_ack(prompt):
185
+ return {}
186
+
187
+ try:
188
+ mode = _detect_mode()
189
+ timeout = _get_mode_timeout(mode)
190
+ stall_timeout = max(timeout - 5.0, 5.0)
191
+
192
+ request_id = self._queue.enqueue(
193
+ query=prompt,
194
+ limit_n=_DEFAULT_LIMIT,
195
+ mode=mode,
196
+ agent_id="hook_daemon",
197
+ session_id=session_id,
198
+ priority="high",
199
+ stall_timeout_s=stall_timeout,
200
+ )
201
+
202
+ result = self._queue.poll_result(request_id, timeout_s=timeout)
203
+
204
+ if isinstance(result, dict) and result.get("ok") is not False:
205
+ results = result.get("results", [])
206
+ if results:
207
+ return _format_envelope(results)
208
+ return {}
209
+ except (QueueTimeoutError, Exception):
210
+ return {}
211
+
212
+
213
+ def try_socket_recall(
214
+ sock_path: Path | None = None,
215
+ prompt: str = "",
216
+ session_id: str = "",
217
+ timeout: float = 15.0,
218
+ ) -> dict | None:
219
+ """Try to get recall result via the persistent hook daemon socket.
220
+
221
+ Returns the hook envelope dict on success, or None if the daemon
222
+ is unavailable (triggers subprocess fallback in auto_recall_hook).
223
+ """
224
+ path = sock_path or _default_sock_path()
225
+ if not path.exists():
226
+ return None
227
+
228
+ try:
229
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
230
+ client.settimeout(timeout)
231
+ client.connect(str(path))
232
+
233
+ request = json.dumps({"prompt": prompt, "session_id": session_id}) + "\n"
234
+ client.sendall(request.encode("utf-8"))
235
+
236
+ data = b""
237
+ while b"\n" not in data:
238
+ chunk = client.recv(8192)
239
+ if not chunk:
240
+ break
241
+ data += chunk
242
+
243
+ client.close()
244
+
245
+ if not data.strip():
246
+ return None
247
+
248
+ response = json.loads(data.decode("utf-8").strip())
249
+ return response if isinstance(response, dict) else None
250
+ except Exception:
251
+ return None
252
+
253
+
254
+ def ensure_hook_daemon(
255
+ sock_path: Path | None = None,
256
+ queue_db_path: Path | None = None,
257
+ ) -> HookDaemon | None:
258
+ """Start hook daemon if not already running. Returns daemon or None."""
259
+ path = sock_path or _default_sock_path()
260
+
261
+ if path.exists():
262
+ try:
263
+ test = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
264
+ test.settimeout(1.0)
265
+ test.connect(str(path))
266
+ test.close()
267
+ return None
268
+ except Exception:
269
+ pass
270
+
271
+ daemon = HookDaemon(
272
+ sock_path=path,
273
+ queue_db_path=queue_db_path or _default_queue_db_path(),
274
+ )
275
+ daemon.start()
276
+ return daemon
@@ -82,6 +82,9 @@ def handle_hook(action: str) -> None:
82
82
  if action == "stop_outcome":
83
83
  from superlocalmemory.hooks.stop_outcome_hook import main as _main
84
84
  sys.exit(_main())
85
+ if action == "auto_recall":
86
+ from superlocalmemory.hooks.auto_recall_hook import main as _main
87
+ sys.exit(_main())
85
88
 
86
89
  handlers = {
87
90
  "start": _hook_start,
@@ -422,6 +422,38 @@ async def lifespan(application: FastAPI):
422
422
  logger.warning("Embedding warmup failed: %s", exc)
423
423
  threading.Thread(target=_warmup_embedder, daemon=True, name="embed-warmup").start()
424
424
 
425
+ # v3.4.26: Start QueueConsumer — drains recall_queue.db via pool.recall().
426
+ # Must start AFTER WorkerPool.warmup() so the worker is ready.
427
+ try:
428
+ from pathlib import Path as _QP
429
+ from superlocalmemory.core.queue_consumer import QueueConsumer
430
+ from superlocalmemory.core.recall_queue import RecallQueue
431
+ _queue_db = _QP.home() / ".superlocalmemory" / "recall_queue.db"
432
+ _recall_queue = RecallQueue(_queue_db)
433
+ _queue_consumer = QueueConsumer(
434
+ queue=_recall_queue,
435
+ pool=WorkerPool.shared(),
436
+ )
437
+ _queue_consumer.start()
438
+ application.state.queue_consumer = _queue_consumer
439
+ application.state.recall_queue = _recall_queue
440
+ logger.info("QueueConsumer started (recall_queue.db)")
441
+
442
+ # v3.4.36: Start persistent hook daemon (Unix socket server).
443
+ # Eliminates Python subprocess startup for each recall hook call.
444
+ try:
445
+ from superlocalmemory.hooks.hook_daemon import HookDaemon
446
+ _hook_daemon = HookDaemon(queue_db_path=_queue_db)
447
+ _hook_daemon.start()
448
+ application.state.hook_daemon = _hook_daemon
449
+ except Exception as _hd_exc:
450
+ logger.warning("HookDaemon start failed (non-fatal): %s", _hd_exc)
451
+ application.state.hook_daemon = None
452
+ except Exception as _qc_exc:
453
+ logger.warning("QueueConsumer start failed (non-fatal): %s", _qc_exc)
454
+ application.state.queue_consumer = None
455
+ application.state.recall_queue = None
456
+
425
457
  except Exception as exc:
426
458
  logger.warning("Engine init failed: %s", exc)
427
459
  application.state.engine = None
@@ -571,6 +603,28 @@ async def lifespan(application: FastAPI):
571
603
  except Exception as exc: # pragma: no cover — defensive
572
604
  logger.warning("bandit_tasks cancel failed: %s", exc)
573
605
 
606
+ # v3.4.36: Stop HookDaemon (Unix socket server).
607
+ _hd = getattr(application.state, "hook_daemon", None)
608
+ if _hd is not None:
609
+ try:
610
+ _hd.stop()
611
+ except Exception as exc: # pragma: no cover — defensive
612
+ logger.warning("hook_daemon stop failed: %s", exc)
613
+
614
+ # v3.4.26: Stop QueueConsumer (recall_queue.db drainer).
615
+ _qc = getattr(application.state, "queue_consumer", None)
616
+ if _qc is not None:
617
+ try:
618
+ _qc.stop()
619
+ except Exception as exc: # pragma: no cover — defensive
620
+ logger.warning("queue_consumer stop failed: %s", exc)
621
+ _rq = getattr(application.state, "recall_queue", None)
622
+ if _rq is not None:
623
+ try:
624
+ _rq.close()
625
+ except Exception as exc: # pragma: no cover — defensive
626
+ logger.warning("recall_queue close failed: %s", exc)
627
+
574
628
  # Stop HealthMonitor (health_monitor.py owns a daemon thread).
575
629
  _health = getattr(application.state, "health_monitor", None)
576
630
  if _health is not None: