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.
- package/CHANGELOG.md +64 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/core/queue_consumer.py +168 -0
- package/src/superlocalmemory/core/recall_queue.py +16 -9
- package/src/superlocalmemory/hooks/auto_recall_hook.py +247 -0
- package/src/superlocalmemory/hooks/hook_daemon.py +276 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +3 -0
- package/src/superlocalmemory/server/unified_daemon.py +54 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -663
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -448
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -59
- package/src/superlocalmemory.egg-info/top_level.txt +0 -1
|
@@ -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:
|