superlocalmemory 3.4.12 → 3.4.14

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/README.md CHANGED
@@ -533,3 +533,33 @@ Part of [Qualixar](https://qualixar.com) · Author: [Varun Pratap Bhardwaj](http
533
533
  <p align="center">
534
534
  <sub>Built with mathematical rigor. Not in the race — here to help everyone build better AI memory systems.</sub>
535
535
  </p>
536
+
537
+ ---
538
+
539
+ ## ⭐ Support This Project
540
+
541
+ If this project solves a real problem for you, **please star the repo** — it helps other developers discover Qualixar and signals that the AI agent reliability community is growing. Every star matters.
542
+
543
+ [![Star History Chart](https://api.star-history.com/svg?repos=qualixar/superlocalmemory&type=Date)](https://star-history.com/#qualixar/superlocalmemory&Date)
544
+
545
+ ---
546
+
547
+ ## Part of the Qualixar AI Agent Reliability Platform
548
+
549
+ Qualixar is building the open-source infrastructure for AI agent reliability engineering. Seven products, seven peer-reviewed papers, one coherent platform. Each tool solves one reliability pillar:
550
+
551
+ | Product | Purpose | Install | Paper |
552
+ |---------|---------|---------|-------|
553
+ | **[SuperLocalMemory](https://github.com/qualixar/superlocalmemory)** | Persistent memory + learning for AI agents | `npx superlocalmemory` | [arXiv:2604.04514](https://arxiv.org/abs/2604.04514) |
554
+ | **[Qualixar OS](https://github.com/qualixar/qualixar-os)** | Universal agent runtime (13 execution topologies) | `npx qualixar-os` | [arXiv:2604.06392](https://arxiv.org/abs/2604.06392) |
555
+ | **[SLM Mesh](https://github.com/qualixar/slm-mesh)** | P2P coordination across AI agent sessions | `npm i slm-mesh` | — |
556
+ | **[SLM MCP Hub](https://github.com/qualixar/slm-mcp-hub)** | Federate 430+ MCP tools through one gateway | `pip install slm-mcp-hub` | — |
557
+ | **[AgentAssay](https://github.com/qualixar/agentassay)** | Token-efficient AI agent testing | `pip install agentassay` | [arXiv:2603.02601](https://arxiv.org/abs/2603.02601) |
558
+ | **[AgentAssert](https://github.com/qualixar/agentassert-abc)** | Behavioral contracts + drift detection | `pip install agentassert` | [arXiv:2602.22302](https://arxiv.org/abs/2602.22302) |
559
+ | **[SkillFortify](https://github.com/qualixar/skillfortify)** | Formal verification for AI agent skills | `pip install skillfortify` | [arXiv:2603.00195](https://arxiv.org/abs/2603.00195) |
560
+
561
+ **Zero cloud dependency. Local-first. EU AI Act compliant.**
562
+
563
+ Start here → **[qualixar.com](https://qualixar.com)** · [All papers on Qualixar HuggingFace](https://huggingface.co/Qualixar)
564
+
565
+ ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.4.12",
3
+ "version": "3.4.14",
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",
@@ -89,4 +89,4 @@
89
89
  "dependencies": {
90
90
  "docx": "^9.5.1"
91
91
  }
92
- }
92
+ }
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.4.12"
3
+ version = "3.4.14"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -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", "daemon.lock"):
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
- # Fallback: store-first pattern (Option C zero data loss)
754
- import subprocess
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
- _MAX_CONCURRENT_WORKERS = int(os.environ.get("SLM_MAX_EMBEDDING_WORKERS", 2))
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
- Uses fcntl.flock on Unix. On Windows, falls back to allowing (no lock).
73
- Returns True if lock acquired, False if timed out (another worker active).
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 — daemon routing is primary defense
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, # Prevent terminal signals bleeding to worker
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
- # 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
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
- 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
- # --- 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()
@@ -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. Non-blocking."""
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
  )