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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.4.11",
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.4.11"
3
+ version = "3.4.13"
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()
@@ -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
- if self._entity is not None and "entity_graph" not in disabled:
452
- try:
453
- r = self._entity.search(query, profile_id, top_k=self._config.bm25_top_k)
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. 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
  )
@@ -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.11
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.4 "Neural Glass"</code> — Install once. Every session remembers the last. Automatically.</p>
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 (NEW in v3.4.10)
461
- - **Per-skill performance tracking** — automatically tracks which skills succeed and which fail, across sessions (zero-LLM, always on)
462
- - **Execution trace analysis** — mines tool usage patterns around skill invocations to determine effectiveness
463
- - **Skill entities in Entity Explorer** — each skill becomes a browsable entity with performance facts and evolution history
464
- - **Dedicated Skill Evolution dashboard tab** — overview cards, performance assertions, skill correlations
465
- - **Behavioral assertions for skill routing** — soft prompts recommend high-performing skills in future sessions
466
- - **LLM-powered skill evolution** — 3-trigger system (post-session + degradation + health check) with blind verification. **Off by default** — opt-in via `slm config set evolution.enabled true`. Supports Ollama (free, local), Anthropic API, and OpenAI API backends.
467
- - **ECC integration** — enhanced observation support with [Everything Claude Code](https://github.com/affaan-m/everything-claude-code) via `slm ingest --source ecc`
468
- - **IDE compatibility:** Skill tracking currently works with Claude Code. The `/api/v3/tool-event` endpoint accepts events from any IDE client — adapters for other IDEs in future releases.
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
- - 35 MCP tools + 7 MCP resources
484
+ - 38 MCP tools + 7 MCP resources
480
485
  - Profile isolation (independent memory spaces)
481
- - 1400+ tests, AGPL v3, cross-platform (Mac/Linux/Windows)
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