superlocalmemory 3.4.52 → 3.4.54

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.52",
3
+ "version": "3.4.54",
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.52"
3
+ version = "3.4.54"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -3,7 +3,7 @@
3
3
  import os
4
4
  os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
5
5
 
6
- __version__ = "3.4.52"
6
+ __version__ = "3.4.54"
7
7
 
8
8
  _REQUIRED_VERSIONS = {
9
9
  "sentence_transformers": "5.3.0",
@@ -652,30 +652,11 @@ def cmd_mode(args: Namespace) -> None:
652
652
 
653
653
  config = SLMConfig.load()
654
654
 
655
- def _apply_mode_change(new_value: str) -> tuple[SLMConfig, bool]:
656
- """Mutate-in-place mode switch. Returns (updated_config, llm_was_set).
657
-
658
- Only changes ``config.mode``. If the user has no LLM provider
659
- configured AND is moving to Mode B or C, populates the mode's
660
- default LLM block so the daemon has something to talk to.
661
- Everything else (embedding, retrieval, evolution, forgetting,
662
- math, profile) is preserved byte-for-byte.
663
- """
664
- new_mode = Mode(new_value)
665
- llm_was_set = False
666
- if new_mode != Mode.A and not config.llm.provider:
667
- defaults = SLMConfig.for_mode(new_mode)
668
- config.llm = defaults.llm
669
- llm_was_set = True
670
- config.mode = new_mode
671
- config.save(mode_change=True)
672
- return config, llm_was_set
673
-
674
655
  if getattr(args, 'json', False):
675
656
  from superlocalmemory.cli.json_output import json_print
676
657
  if args.value:
677
658
  old_mode = config.mode.value.upper()
678
- updated, _ = _apply_mode_change(args.value)
659
+ updated = SLMConfig.switch_mode(args.value)
679
660
  json_print("mode", data={
680
661
  "previous_mode": old_mode, "current_mode": args.value.upper(),
681
662
  }, next_actions=[
@@ -690,22 +671,17 @@ def cmd_mode(args: Namespace) -> None:
690
671
  return
691
672
 
692
673
  if args.value:
693
- updated, llm_was_set = _apply_mode_change(args.value)
674
+ updated = SLMConfig.switch_mode(args.value)
694
675
  print(f"Mode set to: {args.value.upper()}")
695
-
696
- # v3.4.43: embedding/retrieval are now preserved, so the old
697
- # "Embedding model changed. Re-indexing will run on next recall."
698
- # warning no longer fires from a CLI mode switch — that was the
699
- # symptom of the bug. The warning is retained ONLY as an
700
- # informational note when LLM defaults were freshly populated.
701
- if llm_was_set:
702
- print(f" ℹ LLM provider populated from mode defaults: "
703
- f"{updated.llm.provider}/{updated.llm.model}. "
704
- f"Run `slm provider set` to customize.")
676
+ print(f" Embedding: {updated.embedding.provider}/{updated.embedding.model_name}")
677
+ if args.value.lower() != "a":
678
+ print(f" LLM: {updated.llm.provider}/{updated.llm.model}")
679
+ print(f" Reranker: ONNX cross-encoder (enabled)")
705
680
 
706
681
  # V3.3.4: Warn if Mode C lacks cloud API key
707
682
  if args.value == "c" and not updated.llm.api_key:
708
683
  print(" ⚠ Mode C requires a cloud API key. Run: slm provider set")
684
+ print(" ℹ Run `slm restart` to apply the new mode.")
709
685
  else:
710
686
  print(f"Current mode: {config.mode.value.upper()}")
711
687
 
@@ -12,9 +12,12 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import logging
15
16
  from dataclasses import dataclass, field
16
17
  from pathlib import Path
17
18
 
19
+ logger = logging.getLogger(__name__)
20
+
18
21
  from superlocalmemory.storage.models import Mode
19
22
 
20
23
 
@@ -25,6 +28,9 @@ from superlocalmemory.storage.models import Mode
25
28
  DEFAULT_BASE_DIR = Path.home() / ".superlocalmemory"
26
29
  DEFAULT_DB_NAME = "memory.db"
27
30
  DEFAULT_PROFILES_FILE = "profiles.json"
31
+ CURRENT_MODE_FILE = "current_mode"
32
+ # Populated lazily in _get_mode_config_path() to avoid circular imports
33
+ _MODE_CONFIG_NAMES: dict | None = None
28
34
 
29
35
 
30
36
  # ---------------------------------------------------------------------------
@@ -950,3 +956,180 @@ class SLMConfig:
950
956
  sheaf_contradiction_threshold=0.65, # Higher for 3072d embeddings
951
957
  ),
952
958
  )
959
+
960
+ # ------------------------------------------------------------------
961
+ # 3-Mode config system (v3.4.54)
962
+ # ------------------------------------------------------------------
963
+
964
+ @staticmethod
965
+ def _mode_config_path(base_dir: Path, mode: "Mode") -> Path:
966
+ """Return the per-mode config file path."""
967
+ from superlocalmemory.storage.models import Mode as _M
968
+ _names = {
969
+ _M.A: "mode_a.json",
970
+ _M.B: "mode_b.json",
971
+ _M.C: "mode_c.json",
972
+ }
973
+ return base_dir / _names.get(mode, "mode_a.json")
974
+
975
+ @staticmethod
976
+ def read_current_mode(base_dir: Path | None = None) -> str:
977
+ """Read the current mode from the ``current_mode`` file.
978
+
979
+ Returns ``\"b\"`` (the default) if the file doesn't exist.
980
+ """
981
+ _base = base_dir or DEFAULT_BASE_DIR
982
+ _f = _base / CURRENT_MODE_FILE
983
+ try:
984
+ return _f.read_text(encoding="utf-8").strip().lower() or "b"
985
+ except (OSError, FileNotFoundError):
986
+ return "b"
987
+
988
+ @staticmethod
989
+ def write_current_mode(mode: str, base_dir: Path | None = None) -> None:
990
+ """Write the current mode letter to ``current_mode``."""
991
+ _base = base_dir or DEFAULT_BASE_DIR
992
+ _base.mkdir(parents=True, exist_ok=True)
993
+ (_base / CURRENT_MODE_FILE).write_text(
994
+ mode.lower().strip(), encoding="utf-8",
995
+ )
996
+
997
+ @classmethod
998
+ def switch_mode(
999
+ cls,
1000
+ new_mode: str,
1001
+ base_dir: Path | None = None,
1002
+ ) -> "SLMConfig":
1003
+ """Switch to a different mode, preserving the old mode's config.
1004
+
1005
+ v3.4.54: Saves the current config as ``mode_{old}.json``, then
1006
+ loads ``mode_{new}.json`` (or creates it from defaults if it
1007
+ doesn't exist). Writes ``current_mode`` and persists the new
1008
+ active config as ``config.json`` for backward compatibility
1009
+ with tooling that reads it directly.
1010
+
1011
+ Returns the new active config.
1012
+ """
1013
+ from superlocalmemory.storage.models import Mode as _M
1014
+ _base = base_dir or DEFAULT_BASE_DIR
1015
+ _base.mkdir(parents=True, exist_ok=True)
1016
+
1017
+ old_config = cls.load(_base / "config.json")
1018
+ old_mode = old_config.mode.value.lower()
1019
+
1020
+ new_mode_val = _M(new_mode.lower())
1021
+
1022
+ # 1. Save current config to its per-mode file (unless already
1023
+ # matched — prevents overwriting user customizations)
1024
+ if old_mode != new_mode.lower():
1025
+ old_path = cls._mode_config_path(_base, old_config.mode)
1026
+ old_config.save(old_path)
1027
+
1028
+ # 2. Try to load the target mode's saved config.
1029
+ new_path = cls._mode_config_path(_base, new_mode_val)
1030
+ need_migration = False
1031
+ if new_path.exists():
1032
+ try:
1033
+ new_config = cls.load(new_path)
1034
+ # Ensure the loaded config actually has the right mode
1035
+ if new_config.mode != new_mode_val:
1036
+ new_config = cls.for_mode(new_mode_val, base_dir=_base)
1037
+ except Exception:
1038
+ new_config = cls.for_mode(new_mode_val, base_dir=_base)
1039
+ else:
1040
+ # First time switching to this mode: try migrating from
1041
+ # legacy config.json if it was already in this mode
1042
+ legacy = _base / "config.json"
1043
+ if legacy.exists():
1044
+ try:
1045
+ import json
1046
+ _data = json.loads(legacy.read_text(encoding="utf-8"))
1047
+ _legacy_mode = _data.get("mode", "").lower()
1048
+ if _legacy_mode == new_mode.lower():
1049
+ # config.json IS this mode — use it directly
1050
+ new_config = cls.load(legacy)
1051
+ need_migration = True
1052
+ else:
1053
+ new_config = cls.for_mode(new_mode_val, base_dir=_base)
1054
+ except Exception:
1055
+ new_config = cls.for_mode(new_mode_val, base_dir=_base)
1056
+ else:
1057
+ new_config = cls.for_mode(new_mode_val, base_dir=_base)
1058
+
1059
+ # 3. Save as active config.json (backward compat)
1060
+ new_config.save(_base / "config.json", mode_change=True)
1061
+
1062
+ # 4. Write current_mode
1063
+ cls.write_current_mode(new_mode, _base)
1064
+
1065
+ # 5. On first migration, also save mode_a.json and mode_c.json
1066
+ # so users have a complete set
1067
+ if need_migration:
1068
+ for _m in (_M.A, _M.B, _M.C):
1069
+ _mp = cls._mode_config_path(_base, _m)
1070
+ if not _mp.exists():
1071
+ try:
1072
+ _mc = cls.for_mode(_m, base_dir=_base)
1073
+ _mc.save(_mp)
1074
+ except Exception:
1075
+ pass
1076
+
1077
+ return new_config
1078
+
1079
+ @classmethod
1080
+ def migrate_to_3mode(cls, base_dir: Path | None = None) -> bool:
1081
+ """One-time migration: config.json → 3-mode system.
1082
+
1083
+ Called on daemon boot. Idempotent — if ``current_mode`` already
1084
+ exists, this is a no-op. Returns True if migration was performed.
1085
+ """
1086
+ _base = base_dir or DEFAULT_BASE_DIR
1087
+ _current = _base / CURRENT_MODE_FILE
1088
+ if _current.exists():
1089
+ return False # already migrated
1090
+
1091
+ legacy = _base / "config.json"
1092
+ if not legacy.exists():
1093
+ # No config at all — write defaults and current_mode
1094
+ from superlocalmemory.storage.models import Mode as _M
1095
+ _def = cls.for_mode(_M.B, base_dir=_base)
1096
+ _def.save(legacy)
1097
+ cls.write_current_mode("b", _base)
1098
+ for _m in (_M.A, _M.B, _M.C):
1099
+ _mp = cls._mode_config_path(_base, _m)
1100
+ if not _mp.exists():
1101
+ try:
1102
+ _mc = cls.for_mode(_m, base_dir=_base)
1103
+ _mc.save(_mp)
1104
+ except Exception:
1105
+ pass
1106
+ return True
1107
+
1108
+ # Migrate existing config.json → mode_{current}.json
1109
+ try:
1110
+ config = cls.load(legacy)
1111
+ current = config.mode.value.lower()
1112
+ cls.write_current_mode(current, _base)
1113
+
1114
+ # Save current config to its mode file
1115
+ mode_path = cls._mode_config_path(_base, config.mode)
1116
+ config.save(mode_path)
1117
+
1118
+ # Generate other mode files from defaults
1119
+ from superlocalmemory.storage.models import Mode as _M
1120
+ for _m in (_M.A, _M.B, _M.C):
1121
+ _mp = cls._mode_config_path(_base, _m)
1122
+ if not _mp.exists():
1123
+ try:
1124
+ _mc = cls.for_mode(_m, base_dir=_base)
1125
+ _mc.save(_mp)
1126
+ except Exception:
1127
+ pass
1128
+
1129
+ logger.info(
1130
+ "3-mode config system migrated: config.json → mode_%s.json. "
1131
+ "All three mode configs generated.", current,
1132
+ )
1133
+ return True
1134
+ except Exception:
1135
+ return False
@@ -451,7 +451,17 @@ class RetrievalEngine:
451
451
  def _run_channels(
452
452
  self, query: str, profile_id: str, strat: QueryStrategy,
453
453
  ) -> dict[str, list[tuple[str, float]]]:
454
- """Run active retrieval channels. Respects disabled_channels config for ablation."""
454
+ """Run active retrieval channels.
455
+
456
+ v3.4.53: channels run in PARALLEL via ThreadPoolExecutor. Industry
457
+ standard (EverMemOS, szl-recall, ContentPilot 2026): all channels
458
+ are independent after embedding; running them serially wastes time
459
+ equal to the sum of all channel latencies. Parallel execution brings
460
+ total channel time from sum(semantic+bm25+entity+temporal+hopfield+sa)
461
+ down to max(semantic,bm25,entity,temporal,hopfield,sa) — roughly a
462
+ 3-5x speedup for the channel phase.
463
+ """
464
+ import concurrent.futures
455
465
  out: dict[str, list[tuple[str, float]]] = {}
456
466
  # Skip channels listed in disabled_channels (ablation support)
457
467
  # V3.4.40: union with per-recall extra_disabled set (e.g. --fast skip)
@@ -475,51 +485,55 @@ class RetrievalEngine:
475
485
  except Exception as exc:
476
486
  logger.warning("Query embedding failed: %s", exc)
477
487
 
478
- if self._semantic is not None and q_emb is not None and "semantic" not in disabled:
479
- try:
480
- r = self._semantic.search(q_emb, profile_id, self._config.semantic_top_k)
481
- if r:
482
- out["semantic"] = r
483
- except Exception as exc:
484
- logger.warning("Semantic channel: %s", exc)
485
-
486
- if self._bm25 is not None and "bm25" not in disabled:
487
- try:
488
- r = self._bm25.search(query, profile_id, self._config.bm25_top_k)
489
- if r:
490
- out["bm25"] = r
491
- except Exception as exc:
492
- logger.warning("BM25 channel: %s", exc)
493
-
494
- # V3.4.12: entity_graph is now a signal enhancer (post-RRF boost),
495
- # not an independent channel. Removed from channel execution to avoid
496
- # running spreading activation twice. See score_candidates() in engine.recall().
497
-
498
- if self._temporal is not None and "temporal" not in disabled:
499
- try:
500
- r = self._temporal.search(query, profile_id, top_k=self._config.bm25_top_k)
501
- if r:
502
- out["temporal"] = r
503
- except Exception as exc:
504
- logger.warning("Temporal channel: %s", exc)
488
+ # v3.4.53: collect channel callables and run in parallel.
489
+ # Each channel is a standalone search — no shared mutable state,
490
+ # no ordering dependencies. SQLite WAL mode permits concurrent reads.
491
+ futures: dict[str, concurrent.futures.Future] = {}
505
492
 
506
- # Phase G: Hopfield channel (6th) — energy-based pattern completion
507
- if self._hopfield is not None and q_emb is not None and "hopfield" not in disabled:
493
+ def _safe_channel(name: str, fn, *args):
494
+ """Run a single channel, returning (name, result_or_None)."""
508
495
  try:
509
- r = self._hopfield.search(q_emb, profile_id, self._config.hopfield_top_k)
510
- if r:
511
- out["hopfield"] = r
496
+ res = fn(*args)
497
+ return (name, res if res else None)
512
498
  except Exception as exc:
513
- logger.warning("Hopfield channel: %s", exc)
499
+ logger.warning("%s channel: %s", name, exc)
500
+ return (name, None)
501
+
502
+ with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
503
+ if self._semantic is not None and q_emb is not None and "semantic" not in disabled:
504
+ futures["semantic"] = executor.submit(
505
+ _safe_channel, "semantic",
506
+ self._semantic.search, q_emb, profile_id, self._config.semantic_top_k,
507
+ )
508
+ if self._bm25 is not None and "bm25" not in disabled:
509
+ futures["bm25"] = executor.submit(
510
+ _safe_channel, "bm25",
511
+ self._bm25.search, query, profile_id, self._config.bm25_top_k,
512
+ )
513
+ if self._temporal is not None and "temporal" not in disabled:
514
+ futures["temporal"] = executor.submit(
515
+ _safe_channel, "temporal",
516
+ self._temporal.search, query, profile_id, self._config.bm25_top_k,
517
+ )
518
+ if self._hopfield is not None and q_emb is not None and "hopfield" not in disabled:
519
+ futures["hopfield"] = executor.submit(
520
+ _safe_channel, "hopfield",
521
+ self._hopfield.search, q_emb, profile_id, self._config.hopfield_top_k,
522
+ )
523
+ if self._spreading_activation is not None and q_emb is not None and "spreading_activation" not in disabled:
524
+ futures["spreading_activation"] = executor.submit(
525
+ _safe_channel, "spreading_activation",
526
+ self._spreading_activation.search, q_emb, profile_id, self._config.bm25_top_k,
527
+ )
514
528
 
515
- # Phase 3: Spreading Activation channel (5th) — graph-based associative recall
516
- if self._spreading_activation is not None and q_emb is not None and "spreading_activation" not in disabled:
517
- try:
518
- r = self._spreading_activation.search(q_emb, profile_id, self._config.bm25_top_k)
519
- if r:
520
- out["spreading_activation"] = r
521
- except Exception as exc:
522
- logger.warning("Spreading activation channel: %s", exc)
529
+ # Collect results as channels complete
530
+ for name, fut in futures.items():
531
+ try:
532
+ ch_name, result = fut.result(timeout=30)
533
+ if result:
534
+ out[ch_name] = result
535
+ except Exception as exc:
536
+ logger.warning("Channel %s timed out or failed: %s", name, exc)
523
537
 
524
538
  # Apply registered post-retrieval filters (forgetting filter, etc.)
525
539
  if hasattr(self, '_registry') and self._registry._filters:
@@ -56,7 +56,11 @@ _IDLE_TIMEOUT_SECONDS = 300 # V3.4.37: 5 min (was 30) — balance cold-start vs
56
56
  # V3.4.19: Bumped from 120 → 1800 in lock-step with the embedding worker.
57
57
  # Set ``SLM_RERANKER_IDLE_TIMEOUT=120`` + ``slm restart`` to revert.
58
58
  _IDLE_TIMEOUT_SECONDS = int(os.environ.get("SLM_RERANKER_IDLE_TIMEOUT", _IDLE_TIMEOUT_SECONDS))
59
- _SUBPROCESS_RESPONSE_TIMEOUT = 180 # V3.3.12: 180s (was 120s) for stressed system respawns
59
+ _SUBPROCESS_RESPONSE_TIMEOUT = 15 # v3.4.52: 15s (was 180s). Long timeout blocked the
60
+ # entire FastAPI event loop — a dead reranker subprocess held ALL
61
+ # endpoints hostage for 3 minutes. 15s is enough for ONNX inference
62
+ # cold start; if the worker can't respond, we fall back to fusion
63
+ # scores without reranking.
60
64
  _WORKER_RECYCLE_AFTER = 500 # Recycle after N requests
61
65
 
62
66
 
@@ -231,16 +235,26 @@ class CrossEncoderReranker:
231
235
  logger.warning("Failed to spawn reranker worker: %s", exc)
232
236
  self._worker_proc = None
233
237
 
234
- def _send_request(self, req: dict, timeout: float | None = None) -> dict | None:
238
+ def _send_request(self, req: dict, timeout: float | None = None,
239
+ block: bool = True) -> dict | None:
235
240
  """Send JSON request to worker, get response. Thread-safe.
236
241
 
237
242
  Uses a short timeout (10s) for rerank requests since the model
238
243
  should already be loaded by the background warmup. Uses the full
239
244
  timeout only for explicit load/ping commands.
245
+
246
+ v3.4.52: when ``block=False``, uses ``try_lock`` instead of
247
+ ``lock.acquire()``. If another thread is already using the
248
+ reranker subprocess, returns ``None`` immediately (the caller
249
+ falls back to fusion scores without reranking). This prevents
250
+ concurrent recall requests from serialising on the lock.
240
251
  """
241
252
  effective_timeout = timeout or _SUBPROCESS_RESPONSE_TIMEOUT
242
253
 
243
- with self._lock:
254
+ acquired = self._lock.acquire(blocking=block)
255
+ if not acquired:
256
+ return None # another request is using the subprocess
257
+ try:
244
258
  if self._request_count >= _WORKER_RECYCLE_AFTER and self._worker_proc is not None:
245
259
  logger.info("Recycling reranker worker after %d requests", self._request_count)
246
260
  self._kill_worker()
@@ -253,31 +267,32 @@ class CrossEncoderReranker:
253
267
  if self._worker_proc is None:
254
268
  return None
255
269
 
256
- try:
257
- msg = json.dumps(req) + "\n"
258
- self._worker_proc.stdin.write(msg)
259
- self._worker_proc.stdin.flush()
270
+ msg = json.dumps(req) + "\n"
271
+ self._worker_proc.stdin.write(msg)
272
+ self._worker_proc.stdin.flush()
260
273
 
261
- resp_line = self._readline_with_timeout(
262
- self._worker_proc.stdout,
263
- effective_timeout,
264
- )
265
- if not resp_line:
266
- logger.warning("Reranker worker timed out after %ds", effective_timeout)
267
- self._kill_worker()
268
- self._model_loaded = False
269
- return None
270
-
271
- resp = json.loads(resp_line)
272
- self._reset_idle_timer()
273
- self._request_count += 1
274
- return resp
275
- except (BrokenPipeError, OSError, json.JSONDecodeError) as exc:
276
- logger.warning("Reranker worker communication failed: %s", exc)
274
+ resp_line = self._readline_with_timeout(
275
+ self._worker_proc.stdout,
276
+ effective_timeout,
277
+ )
278
+ if not resp_line:
279
+ logger.warning("Reranker worker timed out after %ds", effective_timeout)
277
280
  self._kill_worker()
278
281
  self._model_loaded = False
279
282
  return None
280
283
 
284
+ resp = json.loads(resp_line)
285
+ self._reset_idle_timer()
286
+ self._request_count += 1
287
+ return resp
288
+ except (BrokenPipeError, OSError, json.JSONDecodeError) as exc:
289
+ logger.warning("Reranker worker communication failed: %s", exc)
290
+ self._kill_worker()
291
+ self._model_loaded = False
292
+ return None
293
+ finally:
294
+ self._lock.release()
295
+
281
296
  @staticmethod
282
297
  def _readline_with_timeout(stream: Any, timeout_seconds: float) -> str:
283
298
  """Read a line from stream with timeout. Returns '' on timeout."""
@@ -363,14 +378,16 @@ class CrossEncoderReranker:
363
378
 
364
379
  documents = [fact.content for fact, _ in candidates]
365
380
 
366
- # V3.3.16: Timeout 180s ONNX CoreML compilation can take 30-60s on
367
- # first inference even after model load. The warmup_inference in the
368
- # worker should prevent this, but 180s is a safety net.
381
+ # v3.4.53: block=Falseif another recall is using the reranker
382
+ # subprocess, skip reranking and return fusion scores directly.
383
+ # This prevents concurrent recalls from serialising on the lock.
384
+ # 15s timeout (was 180s) — warm ONNX inference takes ~100ms; if
385
+ # the worker can't respond in 15s it's dead and we fall back.
369
386
  resp = self._send_request({
370
387
  "cmd": "rerank",
371
388
  "query": query,
372
389
  "documents": documents,
373
- }, timeout=180.0)
390
+ }, timeout=15.0, block=False)
374
391
 
375
392
  if resp is None or not resp.get("ok"):
376
393
  # Fallback: return by existing score
@@ -156,6 +156,13 @@ from superlocalmemory.core.recall_gate import (
156
156
  # daemon startup via engine._process_pending_memories().
157
157
  _engine = None
158
158
 
159
+ # v3.4.53: Limit concurrent full (non-fast) recalls. Without this, N parallel
160
+ # /recall calls spawn N × 6-channel threads → Ollama serialises, reranker
161
+ # lock queues, and total wall time is N × single-recall-time. 3 concurrent
162
+ # full recalls gives parallelism benefit without resource oversaturation.
163
+ import asyncio as _asyncio
164
+ _recall_semaphore = _asyncio.Semaphore(3)
165
+
159
166
  # v3.4.52: Embedding model warm state. Set to True by the async pre-warm
160
167
  # thread once Ollama has loaded the embedding model. /health reports this
161
168
  # so MCP clients can wait for warm state before issuing recall calls.
@@ -439,6 +446,9 @@ async def lifespan(application: FastAPI):
439
446
  from superlocalmemory.core.config import SLMConfig
440
447
  from superlocalmemory.core.engine import MemoryEngine
441
448
 
449
+ # v3.4.54: one-time migration config.json → 3-mode system
450
+ SLMConfig.migrate_to_3mode()
451
+
442
452
  config = SLMConfig.load()
443
453
  engine = MemoryEngine(config)
444
454
  engine.initialize()
@@ -1167,9 +1177,22 @@ def _register_daemon_routes(application: FastAPI) -> None:
1167
1177
  import time as _t
1168
1178
  effective_sid = f"http:{int(_t.time() * 1000)}"
1169
1179
  # v3.4.32: mark recall in-flight so the pending materializer pauses
1180
+ # v3.4.52: run engine.recall() in a thread-pool executor so the
1181
+ # FastAPI event loop stays responsive for /health, /remember, and
1182
+ # concurrent /recall requests. Without this, a single slow full
1183
+ # recall (reranker timeout, cold embedder) blocks ALL endpoints.
1184
+ import asyncio
1170
1185
  _begin_recall()
1186
+ # v3.4.53: Full (non-fast) recalls are gated by a semaphore to
1187
+ # prevent resource oversaturation. Ollama serialises concurrent
1188
+ # embedding calls and the reranker subprocess has a single lock —
1189
+ # queuing more than ~3 concurrent full recalls just adds latency.
1190
+ # Fast recalls (SQLite/BM25 only) skip the semaphore.
1191
+ if not fast:
1192
+ await _recall_semaphore.acquire()
1171
1193
  try:
1172
- response = engine.recall(
1194
+ response = await asyncio.to_thread(
1195
+ engine.recall,
1173
1196
  search_query, limit=limit, session_id=effective_sid,
1174
1197
  fast=fast,
1175
1198
  )
@@ -1228,6 +1251,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
1228
1251
  except Exception as exc:
1229
1252
  raise HTTPException(500, detail=str(exc))
1230
1253
  finally:
1254
+ if not fast:
1255
+ _recall_semaphore.release()
1231
1256
  _end_recall()
1232
1257
 
1233
1258
  @application.post("/remember")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.4.52
3
+ Version: 3.4.54
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