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 +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +7 -31
- package/src/superlocalmemory/core/config.py +183 -0
- package/src/superlocalmemory/retrieval/engine.py +56 -42
- package/src/superlocalmemory/retrieval/reranker.py +44 -27
- package/src/superlocalmemory/server/unified_daemon.py +26 -1
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.4.
|
|
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
|
@@ -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
|
|
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
|
|
674
|
+
updated = SLMConfig.switch_mode(args.value)
|
|
694
675
|
print(f"Mode set to: {args.value.upper()}")
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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.
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
507
|
-
|
|
493
|
+
def _safe_channel(name: str, fn, *args):
|
|
494
|
+
"""Run a single channel, returning (name, result_or_None)."""
|
|
508
495
|
try:
|
|
509
|
-
|
|
510
|
-
if
|
|
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("
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
#
|
|
367
|
-
#
|
|
368
|
-
#
|
|
381
|
+
# v3.4.53: block=False — if 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=
|
|
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 =
|
|
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")
|