superlocalmemory 3.4.11 → 3.4.13
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 +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/cli/commands.py +28 -18
- package/src/superlocalmemory/cli/daemon.py +47 -2
- package/src/superlocalmemory/core/embeddings.py +49 -6
- package/src/superlocalmemory/hooks/hook_handlers.py +43 -36
- package/src/superlocalmemory/retrieval/engine.py +34 -7
- package/src/superlocalmemory/retrieval/entity_channel.py +118 -0
- package/src/superlocalmemory/retrieval/reranker.py +30 -1
- package/src/superlocalmemory/server/unified_daemon.py +3 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +18 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.13",
|
|
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
|
@@ -230,20 +230,43 @@ def cmd_restart(args: Namespace) -> None:
|
|
|
230
230
|
_log(1, "Kill all SLM processes", "ok", f"{killed} processes killed")
|
|
231
231
|
time.sleep(3)
|
|
232
232
|
|
|
233
|
-
# Step 2: Clean stale files
|
|
233
|
+
# Step 2: Clean stale files + HOLD the lock to prevent races
|
|
234
|
+
# v3.4.13: Do NOT delete daemon.lock — HOLD it instead.
|
|
235
|
+
# If we delete it, `slm mcp` (still running in Claude) will see no lock,
|
|
236
|
+
# acquire a NEW lock, and start a second daemon during our restart.
|
|
234
237
|
cleaned = []
|
|
235
|
-
for fname in ("daemon.pid", "daemon.port", "
|
|
238
|
+
for fname in ("daemon.pid", "daemon.port", ".embedding-worker.pid", ".reranker-worker.pid"):
|
|
236
239
|
fpath = slm_dir / fname
|
|
237
240
|
if fpath.exists():
|
|
238
241
|
fpath.unlink(missing_ok=True)
|
|
239
242
|
cleaned.append(fname)
|
|
243
|
+
|
|
244
|
+
# Hold the lock file to block other processes from starting a daemon
|
|
245
|
+
_LOCK_FILE = slm_dir / "daemon.lock"
|
|
246
|
+
_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
247
|
+
restart_lock_fd = None
|
|
248
|
+
try:
|
|
249
|
+
restart_lock_fd = open(_LOCK_FILE, "w")
|
|
250
|
+
if sys.platform != "win32":
|
|
251
|
+
import fcntl
|
|
252
|
+
fcntl.flock(restart_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass # Best-effort — don't block restart if lock fails
|
|
255
|
+
|
|
240
256
|
_log(2, "Clean stale state files", "ok",
|
|
241
257
|
f"removed: {', '.join(cleaned)}" if cleaned else "already clean")
|
|
242
258
|
|
|
243
|
-
# Step 3: Start fresh daemon
|
|
259
|
+
# Step 3: Start fresh daemon (lock still held — no races)
|
|
244
260
|
time.sleep(1)
|
|
245
261
|
from superlocalmemory.cli.daemon import ensure_daemon
|
|
246
262
|
started = ensure_daemon()
|
|
263
|
+
|
|
264
|
+
# Release restart lock — daemon is now running with its own lock
|
|
265
|
+
if restart_lock_fd:
|
|
266
|
+
try:
|
|
267
|
+
restart_lock_fd.close()
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
247
270
|
_log(3, "Start fresh daemon", "ok" if started else "fail",
|
|
248
271
|
"daemon started" if started else "failed to start — check slm doctor")
|
|
249
272
|
|
|
@@ -750,8 +773,8 @@ def cmd_remember(args: Namespace) -> None:
|
|
|
750
773
|
except Exception:
|
|
751
774
|
pass # Fall through to pending store
|
|
752
775
|
|
|
753
|
-
#
|
|
754
|
-
|
|
776
|
+
# v3.4.13: Store to pending DB (zero data loss) — daemon processes in background.
|
|
777
|
+
# NO subprocess spawn. Daemon's background loop picks up pending memories.
|
|
755
778
|
from superlocalmemory.cli.pending_store import store_pending
|
|
756
779
|
|
|
757
780
|
row_id = store_pending(
|
|
@@ -759,19 +782,6 @@ def cmd_remember(args: Namespace) -> None:
|
|
|
759
782
|
tags=args.tags or "",
|
|
760
783
|
)
|
|
761
784
|
|
|
762
|
-
cmd = [sys.executable, "-m", "superlocalmemory.cli.main",
|
|
763
|
-
"remember", args.content, "--sync"]
|
|
764
|
-
if args.tags:
|
|
765
|
-
cmd.extend(["--tags", args.tags])
|
|
766
|
-
log_dir = __import__("pathlib").Path.home() / ".superlocalmemory" / "logs"
|
|
767
|
-
log_dir.mkdir(parents=True, exist_ok=True)
|
|
768
|
-
log_file = log_dir / "async-remember.log"
|
|
769
|
-
with open(log_file, "a") as lf:
|
|
770
|
-
subprocess.Popen(
|
|
771
|
-
cmd, stdout=subprocess.DEVNULL, stderr=lf,
|
|
772
|
-
start_new_session=True,
|
|
773
|
-
)
|
|
774
|
-
|
|
775
785
|
if use_json:
|
|
776
786
|
from superlocalmemory.cli.json_output import json_print
|
|
777
787
|
json_print("remember", data={"queued": True, "async": True,
|
|
@@ -288,15 +288,60 @@ def stop_daemon() -> bool:
|
|
|
288
288
|
except Exception:
|
|
289
289
|
pass
|
|
290
290
|
|
|
291
|
-
# Clean up PID/port files
|
|
291
|
+
# Clean up PID/port files + worker PID files
|
|
292
292
|
_PID_FILE.unlink(missing_ok=True)
|
|
293
293
|
_PORT_FILE.unlink(missing_ok=True)
|
|
294
|
+
# v3.4.13: Clean worker PID files (singleton guards)
|
|
295
|
+
for pidfile in (".embedding-worker.pid", ".reranker-worker.pid"):
|
|
296
|
+
(Path.home() / ".superlocalmemory" / pidfile).unlink(missing_ok=True)
|
|
294
297
|
|
|
298
|
+
# v3.4.13: Wait for ALL workers to actually die before returning.
|
|
299
|
+
# Without this, `slm restart` starts a new daemon before old workers exit,
|
|
300
|
+
# causing duplicate embedding_workers (1.6GB each).
|
|
295
301
|
if killed:
|
|
296
|
-
logger.info("Stopped %d SLM processes", killed)
|
|
302
|
+
logger.info("Stopped %d SLM processes, waiting for exit...", killed)
|
|
303
|
+
_wait_for_workers_dead(timeout=10)
|
|
304
|
+
|
|
297
305
|
return True
|
|
298
306
|
|
|
299
307
|
|
|
308
|
+
def _wait_for_workers_dead(timeout: int = 10) -> None:
|
|
309
|
+
"""Wait until no SLM worker processes remain alive."""
|
|
310
|
+
targets = [
|
|
311
|
+
"superlocalmemory.server.unified_daemon",
|
|
312
|
+
"superlocalmemory.core.embedding_worker",
|
|
313
|
+
"superlocalmemory.core.recall_worker",
|
|
314
|
+
"superlocalmemory.core.reranker_worker",
|
|
315
|
+
]
|
|
316
|
+
my_pid = os.getpid()
|
|
317
|
+
deadline = time.time() + timeout
|
|
318
|
+
|
|
319
|
+
while time.time() < deadline:
|
|
320
|
+
alive = False
|
|
321
|
+
try:
|
|
322
|
+
import psutil
|
|
323
|
+
for proc in psutil.process_iter(["pid", "cmdline"]):
|
|
324
|
+
try:
|
|
325
|
+
if proc.pid == my_pid:
|
|
326
|
+
continue
|
|
327
|
+
cmdline = " ".join(proc.info.get("cmdline") or [])
|
|
328
|
+
if any(t in cmdline for t in targets):
|
|
329
|
+
alive = True
|
|
330
|
+
break
|
|
331
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
332
|
+
pass
|
|
333
|
+
except ImportError:
|
|
334
|
+
# No psutil — just wait a fixed time
|
|
335
|
+
time.sleep(3)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
if not alive:
|
|
339
|
+
return
|
|
340
|
+
time.sleep(0.5)
|
|
341
|
+
|
|
342
|
+
logger.warning("Some SLM workers still alive after %ds timeout", timeout)
|
|
343
|
+
|
|
344
|
+
|
|
300
345
|
# ---------------------------------------------------------------------------
|
|
301
346
|
# Server: HTTP request handler with engine singleton
|
|
302
347
|
# ---------------------------------------------------------------------------
|
|
@@ -62,19 +62,50 @@ class DimensionMismatchError(RuntimeError):
|
|
|
62
62
|
# ---------------------------------------------------------------------------
|
|
63
63
|
|
|
64
64
|
_EMBEDDING_LOCK_FILE = Path.home() / ".superlocalmemory" / ".embedding.lock"
|
|
65
|
-
|
|
65
|
+
_EMBEDDING_PID_FILE = Path.home() / ".superlocalmemory" / ".embedding-worker.pid"
|
|
66
|
+
_MAX_CONCURRENT_WORKERS = int(os.environ.get("SLM_MAX_EMBEDDING_WORKERS", 1))
|
|
66
67
|
_embedding_lock_fd: int | None = None
|
|
67
68
|
|
|
68
69
|
|
|
70
|
+
def _is_embedding_worker_alive() -> bool:
|
|
71
|
+
"""Check if an embedding worker PID file exists and that PID is alive.
|
|
72
|
+
|
|
73
|
+
v3.4.13: Machine-wide singleton guard. Before spawning a new worker,
|
|
74
|
+
check if one is already running. Prevents duplicate 1.6GB workers.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
if not _EMBEDDING_PID_FILE.exists():
|
|
78
|
+
return False
|
|
79
|
+
pid = int(_EMBEDDING_PID_FILE.read_text().strip())
|
|
80
|
+
os.kill(pid, 0) # Signal 0 = check if alive
|
|
81
|
+
return True
|
|
82
|
+
except (ValueError, OSError, ProcessLookupError):
|
|
83
|
+
# PID file invalid or process dead — clean up stale file
|
|
84
|
+
_EMBEDDING_PID_FILE.unlink(missing_ok=True)
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def register_embedding_worker_pid(pid: int) -> None:
|
|
89
|
+
"""Write the embedding worker PID to the machine-wide PID file."""
|
|
90
|
+
_EMBEDDING_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
_EMBEDDING_PID_FILE.write_text(str(pid))
|
|
92
|
+
|
|
93
|
+
|
|
69
94
|
def acquire_embedding_lock(timeout: float = 5.0) -> bool:
|
|
70
95
|
"""Acquire system-wide embedding worker lock.
|
|
71
96
|
|
|
72
|
-
|
|
73
|
-
|
|
97
|
+
v3.4.13: First checks if a worker PID is already alive (fast path).
|
|
98
|
+
Falls back to fcntl.flock on Unix. On Windows, falls back to PID check only.
|
|
99
|
+
Returns True if lock acquired (safe to spawn), False if another worker active.
|
|
74
100
|
"""
|
|
75
101
|
global _embedding_lock_fd
|
|
102
|
+
|
|
103
|
+
# v3.4.13: Fast path — if a worker PID is alive, don't even try the lock
|
|
104
|
+
if _is_embedding_worker_alive():
|
|
105
|
+
return False
|
|
106
|
+
|
|
76
107
|
if sys.platform == "win32":
|
|
77
|
-
return True # No file locking on Windows —
|
|
108
|
+
return True # No file locking on Windows — PID check above is the guard
|
|
78
109
|
|
|
79
110
|
import fcntl
|
|
80
111
|
_EMBEDDING_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -389,11 +420,21 @@ class EmbeddingService:
|
|
|
389
420
|
return True
|
|
390
421
|
|
|
391
422
|
def _ensure_worker(self) -> None:
|
|
392
|
-
"""Spawn worker subprocess if not running.
|
|
423
|
+
"""Spawn worker subprocess if not running.
|
|
424
|
+
|
|
425
|
+
v3.4.13: Machine-wide singleton — checks PID file before spawning.
|
|
426
|
+
Only ONE embedding_worker can exist at a time on the machine.
|
|
427
|
+
"""
|
|
393
428
|
if self._worker_proc is not None and self._worker_proc.poll() is None:
|
|
394
429
|
return
|
|
395
430
|
self._worker_proc = None
|
|
396
431
|
|
|
432
|
+
# v3.4.13: Check if another worker is already alive (machine-wide)
|
|
433
|
+
if _is_embedding_worker_alive():
|
|
434
|
+
logger.debug("Embedding worker already alive (PID file), skipping spawn")
|
|
435
|
+
self._available = False
|
|
436
|
+
return
|
|
437
|
+
|
|
397
438
|
# V3.3.28: Check memory pressure before spawning
|
|
398
439
|
if not self._check_memory_pressure():
|
|
399
440
|
logger.warning("Skipping embedding worker spawn due to memory pressure")
|
|
@@ -419,8 +460,10 @@ class EmbeddingService:
|
|
|
419
460
|
text=True,
|
|
420
461
|
bufsize=1,
|
|
421
462
|
env=env,
|
|
422
|
-
start_new_session=True,
|
|
463
|
+
start_new_session=True,
|
|
423
464
|
)
|
|
465
|
+
# v3.4.13: Register PID for machine-wide singleton guard
|
|
466
|
+
register_embedding_worker_pid(self._worker_proc.pid)
|
|
424
467
|
logger.info("Embedding worker spawned (PID %d)", self._worker_proc.pid)
|
|
425
468
|
self._worker_ready = True
|
|
426
469
|
except Exception as exc:
|
|
@@ -21,6 +21,8 @@ import subprocess
|
|
|
21
21
|
import sys
|
|
22
22
|
import tempfile
|
|
23
23
|
import time
|
|
24
|
+
import urllib.request
|
|
25
|
+
import urllib.error
|
|
24
26
|
|
|
25
27
|
# ---------------------------------------------------------------------------
|
|
26
28
|
# Cross-platform temp paths
|
|
@@ -34,6 +36,30 @@ _LAST_CONSOLIDATION = os.path.join(
|
|
|
34
36
|
)
|
|
35
37
|
|
|
36
38
|
|
|
39
|
+
_DAEMON_URL = "http://127.0.0.1:8765"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _daemon_post(path: str, body: dict, timeout: float = 3.0) -> bool:
|
|
43
|
+
"""POST to SLM daemon via stdlib urllib. Returns True on success.
|
|
44
|
+
|
|
45
|
+
v3.4.13: Hooks route through daemon HTTP instead of spawning subprocesses.
|
|
46
|
+
This eliminates the memory blast from concurrent worker spawns.
|
|
47
|
+
Uses ONLY stdlib — no httpx, no requests.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
data = json.dumps(body).encode("utf-8")
|
|
51
|
+
req = urllib.request.Request(
|
|
52
|
+
f"{_DAEMON_URL}{path}",
|
|
53
|
+
data=data,
|
|
54
|
+
headers={"Content-Type": "application/json"},
|
|
55
|
+
method="POST",
|
|
56
|
+
)
|
|
57
|
+
urllib.request.urlopen(req, timeout=timeout)
|
|
58
|
+
return True
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
37
63
|
def handle_hook(action: str) -> None:
|
|
38
64
|
"""Dispatch to the appropriate hook handler. Called from main() fast path."""
|
|
39
65
|
handlers = {
|
|
@@ -202,15 +228,9 @@ def _hook_checkpoint() -> None:
|
|
|
202
228
|
if _cooldown_elapsed(lock_file, _OBSERVE_COOLDOWN, now):
|
|
203
229
|
_write_timestamp(lock_file, now)
|
|
204
230
|
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
["slm", "observe", f"File changed: {basename}"],
|
|
209
|
-
stdout=subprocess.DEVNULL,
|
|
210
|
-
stderr=subprocess.DEVNULL,
|
|
211
|
-
)
|
|
212
|
-
except Exception:
|
|
213
|
-
pass
|
|
231
|
+
# v3.4.13: Route through daemon HTTP (not subprocess) to prevent
|
|
232
|
+
# memory blast from concurrent embedding_worker spawns.
|
|
233
|
+
_daemon_post("/observe", {"content": f"File changed: {basename}"})
|
|
214
234
|
|
|
215
235
|
# Log to session activity
|
|
216
236
|
try:
|
|
@@ -286,33 +306,20 @@ def _hook_stop() -> None:
|
|
|
286
306
|
|
|
287
307
|
summary = " | ".join(parts)
|
|
288
308
|
|
|
289
|
-
# --- Save to SLM ---
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
# --- Post-session skill evolution trigger (best-effort) ---
|
|
305
|
-
try:
|
|
306
|
-
session_id = os.environ.get("CLAUDE_SESSION_ID", "")
|
|
307
|
-
if session_id:
|
|
308
|
-
subprocess.Popen(
|
|
309
|
-
["slm", "evolve", "--session", session_id],
|
|
310
|
-
stdout=subprocess.DEVNULL,
|
|
311
|
-
stderr=subprocess.DEVNULL,
|
|
312
|
-
start_new_session=True,
|
|
313
|
-
)
|
|
314
|
-
except Exception:
|
|
315
|
-
pass
|
|
309
|
+
# --- Save to SLM (v3.4.13: daemon HTTP, not subprocess) ---
|
|
310
|
+
if not _daemon_post("/observe", {"content": summary}, timeout=5.0):
|
|
311
|
+
# Fallback: try /remember if observe failed
|
|
312
|
+
_daemon_post("/remember", {"content": summary, "tags": "session-end"}, timeout=5.0)
|
|
313
|
+
|
|
314
|
+
# --- Post-session skill evolution trigger (best-effort, via tool-event) ---
|
|
315
|
+
session_id = os.environ.get("CLAUDE_SESSION_ID", "")
|
|
316
|
+
if session_id:
|
|
317
|
+
_daemon_post("/api/v3/tool-event", {
|
|
318
|
+
"tool_name": "session_end",
|
|
319
|
+
"event_type": "session_end",
|
|
320
|
+
"session_id": session_id,
|
|
321
|
+
"output_summary": summary[:500],
|
|
322
|
+
})
|
|
316
323
|
|
|
317
324
|
# --- Auto-consolidation (if >24h since last run) ---
|
|
318
325
|
_maybe_consolidate()
|
|
@@ -187,6 +187,37 @@ class RetrievalEngine:
|
|
|
187
187
|
except Exception as exc:
|
|
188
188
|
logger.warning("Scene expansion: %s", exc)
|
|
189
189
|
|
|
190
|
+
# V3.4.11: Entity graph signal enhancement (post-RRF boost)
|
|
191
|
+
# Instead of competing as independent channel, entity_graph SCORES
|
|
192
|
+
# the candidates from other channels by graph proximity to query entities.
|
|
193
|
+
# Research: Microsoft GraphRAG DRIFT, Pistis-RAG cascaded architecture.
|
|
194
|
+
if (self._entity is not None
|
|
195
|
+
and "entity_graph" not in set(self._config.disabled_channels)
|
|
196
|
+
and fused):
|
|
197
|
+
try:
|
|
198
|
+
candidate_ids = [fr.fact_id for fr in fused[:100]]
|
|
199
|
+
eg_scores = self._entity.score_candidates(
|
|
200
|
+
query, candidate_ids, profile_id,
|
|
201
|
+
)
|
|
202
|
+
if eg_scores:
|
|
203
|
+
boosted = []
|
|
204
|
+
for fr in fused:
|
|
205
|
+
eg_sc = eg_scores.get(fr.fact_id, 0.0)
|
|
206
|
+
if eg_sc > 0:
|
|
207
|
+
eg_weight = strat.weights.get("entity_graph", 1.0)
|
|
208
|
+
boost = 1.0 + eg_sc * eg_weight * 0.3
|
|
209
|
+
boosted.append(FusionResult(
|
|
210
|
+
fact_id=fr.fact_id,
|
|
211
|
+
fused_score=fr.fused_score * boost,
|
|
212
|
+
channel_ranks=fr.channel_ranks,
|
|
213
|
+
channel_scores={**fr.channel_scores, "entity_graph": eg_sc},
|
|
214
|
+
))
|
|
215
|
+
else:
|
|
216
|
+
boosted.append(fr)
|
|
217
|
+
fused = sorted(boosted, key=lambda r: r.fused_score, reverse=True)
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
logger.warning("Entity graph signal enhancement: %s", exc)
|
|
220
|
+
|
|
190
221
|
# 4. Load facts for rerank pool
|
|
191
222
|
pool = min(len(fused), max(effective_limit * 3, 30))
|
|
192
223
|
top = fused[:pool]
|
|
@@ -448,13 +479,9 @@ class RetrievalEngine:
|
|
|
448
479
|
except Exception as exc:
|
|
449
480
|
logger.warning("BM25 channel: %s", exc)
|
|
450
481
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if r:
|
|
455
|
-
out["entity_graph"] = r
|
|
456
|
-
except Exception as exc:
|
|
457
|
-
logger.warning("Entity channel: %s", exc)
|
|
482
|
+
# V3.4.12: entity_graph is now a signal enhancer (post-RRF boost),
|
|
483
|
+
# not an independent channel. Removed from channel execution to avoid
|
|
484
|
+
# running spreading activation twice. See score_candidates() in engine.recall().
|
|
458
485
|
|
|
459
486
|
if self._temporal is not None and "temporal" not in disabled:
|
|
460
487
|
try:
|
|
@@ -370,6 +370,124 @@ class EntityGraphChannel:
|
|
|
370
370
|
results.sort(key=lambda x: x[1], reverse=True)
|
|
371
371
|
return results[:top_k]
|
|
372
372
|
|
|
373
|
+
def score_candidates(
|
|
374
|
+
self,
|
|
375
|
+
query: str,
|
|
376
|
+
candidate_fact_ids: list[str],
|
|
377
|
+
profile_id: str,
|
|
378
|
+
) -> dict[str, float]:
|
|
379
|
+
"""Score candidate facts by their entity-graph proximity to query entities.
|
|
380
|
+
|
|
381
|
+
V3.4.11 "Signal Enhancer" architecture: instead of returning its own
|
|
382
|
+
independent set of fact_ids (which get outranked by multi-channel facts
|
|
383
|
+
in RRF), this method scores EXISTING candidates from semantic/BM25
|
|
384
|
+
by their graph connectivity to query entities.
|
|
385
|
+
|
|
386
|
+
Research basis: Microsoft GraphRAG DRIFT Search, HippoRAG, Pistis-RAG
|
|
387
|
+
cascaded architecture. Graph signals act as post-retrieval boosters,
|
|
388
|
+
not independent retrievers. Avoids the "weakest link" phenomenon where
|
|
389
|
+
non-overlapping result sets cause rank collapse in RRF fusion.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
query: The user's query string.
|
|
393
|
+
candidate_fact_ids: Fact IDs from semantic/BM25/other channels.
|
|
394
|
+
profile_id: User profile.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Dict mapping fact_id → entity_graph score [0, 1].
|
|
398
|
+
Facts with no entity connection return 0.
|
|
399
|
+
Facts directly linked to query entities score ~1.0.
|
|
400
|
+
Facts 1-hop away score ~0.7 (decay factor).
|
|
401
|
+
"""
|
|
402
|
+
if not candidate_fact_ids:
|
|
403
|
+
return {}
|
|
404
|
+
|
|
405
|
+
raw_entities = extract_query_entities(query)
|
|
406
|
+
if not raw_entities:
|
|
407
|
+
return {}
|
|
408
|
+
|
|
409
|
+
canonical_ids = self._resolve_entities(raw_entities, profile_id)
|
|
410
|
+
if not canonical_ids:
|
|
411
|
+
return {}
|
|
412
|
+
|
|
413
|
+
self._ensure_adjacency(profile_id)
|
|
414
|
+
|
|
415
|
+
# Run full spreading activation (same as search())
|
|
416
|
+
activation: dict[str, float] = defaultdict(float)
|
|
417
|
+
visited_entities: set[str] = set(canonical_ids)
|
|
418
|
+
use_cache = bool(self._entity_to_facts)
|
|
419
|
+
|
|
420
|
+
for eid in canonical_ids:
|
|
421
|
+
if use_cache:
|
|
422
|
+
for fid in self._entity_to_facts.get(eid, ()):
|
|
423
|
+
activation[fid] = max(activation[fid], 1.0)
|
|
424
|
+
else:
|
|
425
|
+
for fact in self._db.get_facts_by_entity(eid, profile_id):
|
|
426
|
+
activation[fact.fact_id] = max(activation[fact.fact_id], 1.0)
|
|
427
|
+
|
|
428
|
+
frontier = set(activation.keys())
|
|
429
|
+
for hop in range(1, self._max_hops):
|
|
430
|
+
hop_decay = self._decay ** hop
|
|
431
|
+
if hop_decay < self._threshold:
|
|
432
|
+
break
|
|
433
|
+
next_frontier: set[str] = set()
|
|
434
|
+
for fid in frontier:
|
|
435
|
+
if use_cache:
|
|
436
|
+
for neighbor, edge_weight in self._adj.get(fid, ()):
|
|
437
|
+
if self._graph_metrics:
|
|
438
|
+
weighted = activation[fid] * self._decay * edge_weight
|
|
439
|
+
if neighbor in self._graph_metrics:
|
|
440
|
+
pr = self._graph_metrics[neighbor].get("pagerank_score", 0.0)
|
|
441
|
+
weighted *= min(1.0 + pr * 2.0, 2.0)
|
|
442
|
+
else:
|
|
443
|
+
weighted = activation[fid] * self._decay
|
|
444
|
+
if weighted >= self._threshold and weighted > activation.get(neighbor, 0.0):
|
|
445
|
+
activation[neighbor] = weighted
|
|
446
|
+
next_frontier.add(neighbor)
|
|
447
|
+
|
|
448
|
+
if use_cache:
|
|
449
|
+
for fid in frontier:
|
|
450
|
+
for eid in self._fact_to_entities.get(fid, ()):
|
|
451
|
+
if eid not in visited_entities:
|
|
452
|
+
visited_entities.add(eid)
|
|
453
|
+
for linked_fid in self._entity_to_facts.get(eid, ()):
|
|
454
|
+
if hop_decay > activation.get(linked_fid, 0.0):
|
|
455
|
+
activation[linked_fid] = hop_decay
|
|
456
|
+
next_frontier.add(linked_fid)
|
|
457
|
+
|
|
458
|
+
frontier = next_frontier
|
|
459
|
+
if not frontier:
|
|
460
|
+
break
|
|
461
|
+
|
|
462
|
+
# Community-aware boosting (same as search)
|
|
463
|
+
if self._graph_metrics and use_cache:
|
|
464
|
+
from collections import Counter as _Counter
|
|
465
|
+
seed_communities: _Counter = _Counter()
|
|
466
|
+
for eid in canonical_ids:
|
|
467
|
+
for fid in self._entity_to_facts.get(eid, ()):
|
|
468
|
+
m = self._graph_metrics.get(fid, {})
|
|
469
|
+
comm = m.get("community_id")
|
|
470
|
+
if comm is not None:
|
|
471
|
+
seed_communities[comm] += 1
|
|
472
|
+
if seed_communities:
|
|
473
|
+
total_seeds = sum(seed_communities.values())
|
|
474
|
+
for fid in list(activation.keys()):
|
|
475
|
+
m = self._graph_metrics.get(fid, {})
|
|
476
|
+
fact_comm = m.get("community_id")
|
|
477
|
+
if fact_comm is not None and fact_comm in seed_communities:
|
|
478
|
+
boost = min(1.0 + 0.15 * (seed_communities[fact_comm] / total_seeds), 1.3)
|
|
479
|
+
activation[fid] *= boost
|
|
480
|
+
|
|
481
|
+
# Extract scores ONLY for the candidate set, normalize to [0, 1]
|
|
482
|
+
candidate_set = set(candidate_fact_ids)
|
|
483
|
+
scored = {fid: activation.get(fid, 0.0) for fid in candidate_set}
|
|
484
|
+
|
|
485
|
+
max_score = max(scored.values()) if scored else 0
|
|
486
|
+
if max_score > 0:
|
|
487
|
+
scored = {fid: sc / max_score for fid, sc in scored.items()}
|
|
488
|
+
|
|
489
|
+
return scored
|
|
490
|
+
|
|
373
491
|
def _suppress_contradictions(
|
|
374
492
|
self, activation: dict[str, float], profile_id: str,
|
|
375
493
|
) -> None:
|
|
@@ -27,8 +27,25 @@ import time
|
|
|
27
27
|
import weakref
|
|
28
28
|
from typing import Any
|
|
29
29
|
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
30
32
|
from superlocalmemory.storage.models import AtomicFact
|
|
31
33
|
|
|
34
|
+
_RERANKER_PID_FILE = Path.home() / ".superlocalmemory" / ".reranker-worker.pid"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_reranker_worker_alive() -> bool:
|
|
38
|
+
"""Check if a reranker worker PID is already alive (machine-wide singleton)."""
|
|
39
|
+
try:
|
|
40
|
+
if not _RERANKER_PID_FILE.exists():
|
|
41
|
+
return False
|
|
42
|
+
pid = int(_RERANKER_PID_FILE.read_text().strip())
|
|
43
|
+
os.kill(pid, 0)
|
|
44
|
+
return True
|
|
45
|
+
except (ValueError, OSError, ProcessLookupError):
|
|
46
|
+
_RERANKER_PID_FILE.unlink(missing_ok=True)
|
|
47
|
+
return False
|
|
48
|
+
|
|
32
49
|
# Track all live reranker instances for atexit cleanup
|
|
33
50
|
_live_rerankers: set[weakref.ref] = set()
|
|
34
51
|
|
|
@@ -148,12 +165,21 @@ class CrossEncoderReranker:
|
|
|
148
165
|
# ------------------------------------------------------------------
|
|
149
166
|
|
|
150
167
|
def _ensure_worker(self) -> None:
|
|
151
|
-
"""Spawn worker subprocess if not running.
|
|
168
|
+
"""Spawn worker subprocess if not running. Machine-wide singleton.
|
|
169
|
+
|
|
170
|
+
v3.4.13: Checks PID file before spawning — only ONE reranker worker
|
|
171
|
+
can exist at a time on the machine.
|
|
172
|
+
"""
|
|
152
173
|
if self._worker_proc is not None and self._worker_proc.poll() is None:
|
|
153
174
|
return
|
|
154
175
|
self._worker_proc = None
|
|
155
176
|
self._worker_ready = False
|
|
156
177
|
|
|
178
|
+
# v3.4.13: Machine-wide singleton guard
|
|
179
|
+
if _is_reranker_worker_alive():
|
|
180
|
+
logger.debug("Reranker worker already alive (PID file), skipping spawn")
|
|
181
|
+
return
|
|
182
|
+
|
|
157
183
|
worker_module = "superlocalmemory.core.reranker_worker"
|
|
158
184
|
try:
|
|
159
185
|
env = {
|
|
@@ -175,6 +201,9 @@ class CrossEncoderReranker:
|
|
|
175
201
|
env=env,
|
|
176
202
|
start_new_session=True,
|
|
177
203
|
)
|
|
204
|
+
# v3.4.13: Register PID for machine-wide singleton
|
|
205
|
+
_RERANKER_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
_RERANKER_PID_FILE.write_text(str(self._worker_proc.pid))
|
|
178
207
|
logger.info(
|
|
179
208
|
"Reranker worker spawned (PID %d)", self._worker_proc.pid,
|
|
180
209
|
)
|
|
@@ -587,6 +587,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
587
587
|
"score": round(r.score, 4),
|
|
588
588
|
"fact_type": getattr(r.fact.fact_type, 'value', str(r.fact.fact_type)),
|
|
589
589
|
"fact_id": r.fact.fact_id,
|
|
590
|
+
"channel_scores": {
|
|
591
|
+
k: round(v, 4) for k, v in r.channel_scores.items()
|
|
592
|
+
} if r.channel_scores else {},
|
|
590
593
|
}
|
|
591
594
|
for r in response.results
|
|
592
595
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: superlocalmemory
|
|
3
|
-
Version: 3.4.
|
|
3
|
+
Version: 3.4.13
|
|
4
4
|
Summary: Information-geometric agent memory with mathematical guarantees
|
|
5
5
|
Author-email: Varun Pratap Bhardwaj <admin@superlocalmemory.com>
|
|
6
6
|
License: AGPL-3.0-or-later
|
|
@@ -83,7 +83,7 @@ Dynamic: license-file
|
|
|
83
83
|
|
|
84
84
|
<h1 align="center">SuperLocalMemory V3.4</h1>
|
|
85
85
|
<p align="center"><strong>Every other AI forgets. Yours won't.</strong><br/><em>Infinite memory for Claude Code, Cursor, Windsurf & 17+ AI tools.</em></p>
|
|
86
|
-
<p align="center"><code>v3.4.
|
|
86
|
+
<p align="center"><code>v3.4.11</code> — Install once. Every session remembers the last. Automatically.</p>
|
|
87
87
|
<p align="center"><strong>Backed by 3 peer-reviewed research papers</strong> · <a href="https://arxiv.org/abs/2603.02240">arXiv:2603.02240</a> · <a href="https://arxiv.org/abs/2603.14588">arXiv:2603.14588</a> · <a href="https://arxiv.org/abs/2604.04514">arXiv:2604.04514</a></p>
|
|
88
88
|
|
|
89
89
|
<p align="center">
|
|
@@ -457,15 +457,20 @@ Auto-capture hooks: `slm hooks install` + `slm observe` + `slm session-context`.
|
|
|
457
457
|
- Auto-learned soft prompts injected into agent context
|
|
458
458
|
- Behavioral pattern detection and outcome tracking
|
|
459
459
|
|
|
460
|
-
### Skill Evolution
|
|
461
|
-
- **Per-skill performance tracking** —
|
|
462
|
-
- **
|
|
463
|
-
- **
|
|
464
|
-
- **
|
|
465
|
-
- **
|
|
466
|
-
- **
|
|
467
|
-
- **ECC
|
|
468
|
-
|
|
460
|
+
### Skill Evolution
|
|
461
|
+
- **Per-skill performance tracking** — tracks which skills succeed and fail across sessions (zero-LLM, always on)
|
|
462
|
+
- **Evolution engine** — 3-trigger system with blind verification. Off by default — enable via `slm config set evolution.enabled true`
|
|
463
|
+
- **MCP tools** — `evolve_skill`, `skill_health`, `skill_lineage` for programmatic access
|
|
464
|
+
- **Lineage DAG** — visual evolution history in the dashboard
|
|
465
|
+
- **CLI config** — `slm config get/set` for all evolution settings
|
|
466
|
+
- **Post-session triggers** — automatic analysis on session end via Stop hook
|
|
467
|
+
- **[ECC](https://github.com/affaan-m/everything-claude-code) integration** — optional enhanced observations via `slm ingest --source ecc`
|
|
468
|
+
|
|
469
|
+
### Tiered Storage & Scaling
|
|
470
|
+
- **4-tier lifecycle** — active, warm, cold, archived with automatic promotion/demotion
|
|
471
|
+
- **Deep recall** — archived facts searchable at reduced weight
|
|
472
|
+
- **Graph pruning** — automatic cleanup of orphan edges, self-loops, duplicates
|
|
473
|
+
- **Fact consolidation** — clusters related facts into consolidated summaries
|
|
469
474
|
|
|
470
475
|
### Trust & Security
|
|
471
476
|
- Bayesian Beta-distribution trust scoring (per-agent, per-fact)
|
|
@@ -476,9 +481,9 @@ Auto-capture hooks: `slm hooks install` + `slm observe` + `slm session-context`.
|
|
|
476
481
|
### Infrastructure
|
|
477
482
|
- 23-tab web dashboard with real-time visualization
|
|
478
483
|
- 17+ IDE integrations (Claude, Cursor, Windsurf, VS Code, JetBrains, Zed, etc.)
|
|
479
|
-
-
|
|
484
|
+
- 38 MCP tools + 7 MCP resources
|
|
480
485
|
- Profile isolation (independent memory spaces)
|
|
481
|
-
-
|
|
486
|
+
- 2,900+ tests, AGPL v3, cross-platform (Mac/Linux/Windows)
|
|
482
487
|
- CPU-only — no GPU required
|
|
483
488
|
- Automatic orphaned process cleanup
|
|
484
489
|
|