superlocalmemory 3.4.35 → 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 +26 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/hooks/auto_recall_hook.py +32 -0
- package/src/superlocalmemory/hooks/hook_daemon.py +276 -0
- package/src/superlocalmemory/server/unified_daemon.py +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## [3.4.36] - 2026-04-25
|
|
14
|
+
|
|
15
|
+
Persistent hook daemon: recall latency drops from ~2.2s to sub-second by
|
|
16
|
+
eliminating Python subprocess startup on every prompt.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **`hooks/hook_daemon.py`** — Unix domain socket server that keeps a
|
|
20
|
+
long-lived process for recall requests. Claude Code connects via socket
|
|
21
|
+
instead of spawning a fresh Python interpreter per prompt. Eliminates
|
|
22
|
+
~300-500ms of subprocess overhead. Starts/stops with the SLM daemon.
|
|
23
|
+
- **Auto-restart watchdog:** `ensure_hook_daemon()` checks socket health
|
|
24
|
+
and restarts the daemon if it died. Claude Code hooks call this before
|
|
25
|
+
connecting, so a crashed daemon is transparent to the user.
|
|
26
|
+
- **Graceful fallback:** if the socket is unavailable, the hook
|
|
27
|
+
automatically falls back to the v3.4.35 subprocess path. Claude Code
|
|
28
|
+
performance is NEVER impacted by daemon failure.
|
|
29
|
+
- **9 new tests** for daemon lifecycle, socket protocol, ack detection,
|
|
30
|
+
watchdog, fallback, and memory safety.
|
|
31
|
+
|
|
32
|
+
### Performance
|
|
33
|
+
- Ack prompts: ~5ms via socket (was 30ms via subprocess)
|
|
34
|
+
- Substantive recall: target sub-1s (was 2.2s p50 via subprocess)
|
|
35
|
+
- Hook daemon RSS: ~15-20MB (no engine, no ONNX, no PyTorch)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
13
39
|
## [3.4.35] - 2026-04-25
|
|
14
40
|
|
|
15
41
|
Production auto-recall: every Claude Code prompt automatically retrieves the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.36",
|
|
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
|
@@ -76,6 +76,30 @@ def _get_queue_db_path():
|
|
|
76
76
|
return slm_dir / "recall_queue.db"
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def _try_socket_first(prompt: str, session_id: str) -> dict | None:
|
|
80
|
+
"""Try the persistent hook daemon socket. Returns full envelope or None.
|
|
81
|
+
|
|
82
|
+
The socket path returns an already-formatted Claude Code envelope.
|
|
83
|
+
If this returns a non-None dict, the caller writes it to stdout directly
|
|
84
|
+
(skip _do_recall + _format_envelope). Returns None on any failure,
|
|
85
|
+
triggering the subprocess fallback.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
from superlocalmemory.hooks.hook_daemon import try_socket_recall
|
|
89
|
+
response = try_socket_recall(
|
|
90
|
+
prompt=prompt,
|
|
91
|
+
session_id=session_id,
|
|
92
|
+
timeout=_get_mode_timeout(_detect_mode()),
|
|
93
|
+
)
|
|
94
|
+
if response is None or not isinstance(response, dict):
|
|
95
|
+
return None
|
|
96
|
+
if not response:
|
|
97
|
+
return {}
|
|
98
|
+
return response
|
|
99
|
+
except Exception:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
79
103
|
def _do_recall(query: str, limit: int = _DEFAULT_LIMIT, session_id: str = "") -> list[dict] | None:
|
|
80
104
|
"""Enqueue recall to queue, poll for result. Returns list of dicts or None."""
|
|
81
105
|
try:
|
|
@@ -192,6 +216,14 @@ def main() -> int:
|
|
|
192
216
|
sys.stdout.write("{}")
|
|
193
217
|
return 0
|
|
194
218
|
|
|
219
|
+
try:
|
|
220
|
+
socket_result = _try_socket_first(prompt, session_id)
|
|
221
|
+
if socket_result is not None:
|
|
222
|
+
sys.stdout.write(json.dumps(socket_result) if socket_result else "{}")
|
|
223
|
+
return 0
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
|
|
195
227
|
try:
|
|
196
228
|
results = _do_recall(prompt, limit=_DEFAULT_LIMIT, session_id=session_id)
|
|
197
229
|
except Exception:
|
|
@@ -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
|
|
@@ -438,6 +438,17 @@ async def lifespan(application: FastAPI):
|
|
|
438
438
|
application.state.queue_consumer = _queue_consumer
|
|
439
439
|
application.state.recall_queue = _recall_queue
|
|
440
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
|
|
441
452
|
except Exception as _qc_exc:
|
|
442
453
|
logger.warning("QueueConsumer start failed (non-fatal): %s", _qc_exc)
|
|
443
454
|
application.state.queue_consumer = None
|
|
@@ -592,6 +603,14 @@ async def lifespan(application: FastAPI):
|
|
|
592
603
|
except Exception as exc: # pragma: no cover — defensive
|
|
593
604
|
logger.warning("bandit_tasks cancel failed: %s", exc)
|
|
594
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
|
+
|
|
595
614
|
# v3.4.26: Stop QueueConsumer (recall_queue.db drainer).
|
|
596
615
|
_qc = getattr(application.state, "queue_consumer", None)
|
|
597
616
|
if _qc is not None:
|