superlocalmemory 3.4.32 → 3.4.34

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/CHANGELOG.md CHANGED
@@ -10,6 +10,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ---
12
12
 
13
+ ## [3.4.34] - 2026-04-25
14
+
15
+ Fix: user's mode choice can no longer be silently overwritten.
16
+
17
+ ### Fixed
18
+ - **Mode protection in `SLMConfig.save()`.** Any `save()` call that would
19
+ change the mode in `config.json` is now blocked unless the caller passes
20
+ `mode_change=True`. This prevents accidental mode resets when code creates
21
+ a fresh `SLMConfig()` (defaults to Mode A) and calls `save()` to persist
22
+ an unrelated field change. A warning is logged when a silent mode change
23
+ is blocked.
24
+ - **MCP `set_mode` preserves user settings.** Previously `set_mode` created
25
+ a fresh `SLMConfig.for_mode()` that lost all user customizations (LLM
26
+ provider, API keys, embedding config, active profile). Now carries forward
27
+ all settings from the existing config, matching the dashboard behavior.
28
+ - All intentional mode-change paths (`slm mode`, MCP `set_mode`, dashboard
29
+ PUT `/api/v3/mode`, setup wizard) pass `mode_change=True`.
30
+
31
+ ---
32
+
33
+ ## [3.4.33] - 2026-04-25
34
+
35
+ Fix: daemon leaked SQLite connections to learning.db via bandit threadlocals.
36
+
37
+ ### Fixed
38
+ - **Bandit threadlocal connection leak.** `reward_proxy.settle_stale_plays`
39
+ creates a `ContextualBandit` that opens a threadlocal connection via
40
+ `_conn_for`. When called from `asyncio.to_thread` (bandit_loops.py,
41
+ every 60 s), each thread-pool thread kept its connection open for the
42
+ process lifetime. Over 24 h this accumulated 12+ leaked file descriptors
43
+ and ~100 MB of wasted SQLite page-cache RAM. New
44
+ `bandit.close_threadlocal_conn()` function, called in the
45
+ `settle_stale_plays` finally block, ensures pool threads release their
46
+ connections immediately.
47
+ - **Corrected embedding worker memory comment.** The `~200MB footprint`
48
+ note was written for `all-MiniLM-L6-v2`; the default model
49
+ `nomic-ai/nomic-embed-text-v1.5` uses ~1.1 GB via ONNX.
50
+
51
+ ---
52
+
13
53
  ## [3.4.32] - 2026-04-24
14
54
 
15
55
  Fix: concurrent remembers no longer block recalls on the shared embedder.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.4.32",
3
+ "version": "3.4.34",
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.32"
3
+ version = "3.4.34"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -1,3 +1,3 @@
1
1
  """SuperLocalMemory — information-geometric agent memory."""
2
2
 
3
- __version__ = "3.4.32"
3
+ __version__ = "3.4.34"
@@ -639,7 +639,7 @@ def cmd_mode(args: Namespace) -> None:
639
639
  llm_api_key=config.llm.api_key,
640
640
  llm_api_base=config.llm.api_base,
641
641
  )
642
- updated.save()
642
+ updated.save(mode_change=True)
643
643
  json_print("mode", data={
644
644
  "previous_mode": old_mode, "current_mode": args.value.upper(),
645
645
  }, next_actions=[
@@ -661,7 +661,7 @@ def cmd_mode(args: Namespace) -> None:
661
661
  llm_api_key=config.llm.api_key,
662
662
  llm_api_base=config.llm.api_base,
663
663
  )
664
- updated.save()
664
+ updated.save(mode_change=True)
665
665
  print(f"Mode set to: {args.value.upper()}")
666
666
 
667
667
  # V3.3: Check if embedding model changed — inform about re-indexing
@@ -335,7 +335,7 @@ def run_wizard(auto: bool = False) -> None:
335
335
  if choice == "c" and interactive:
336
336
  configure_provider(config)
337
337
  else:
338
- config.save()
338
+ config.save(mode_change=True)
339
339
 
340
340
  mode_names = {"a": "Local Guardian", "b": "Smart Local", "c": "Full Power"}
341
341
  print(f"\n ✓ Mode {choice.upper()} ({mode_names[choice]}) configured")
@@ -421,7 +421,7 @@ def run_wizard(auto: bool = False) -> None:
421
421
  config.daemon_idle_timeout = 0
422
422
  print("\n ✓ 24/7 Always-On mode")
423
423
 
424
- config.save()
424
+ config.save(mode_change=True)
425
425
 
426
426
  # -- Step 6: Mesh Communication (v3.4.3) --
427
427
  print()
@@ -441,7 +441,7 @@ def run_wizard(auto: bool = False) -> None:
441
441
  print(" Auto-enabling Mesh (non-interactive)")
442
442
 
443
443
  config.mesh_enabled = mesh_choice in ("", "y", "yes")
444
- config.save()
444
+ config.save(mode_change=True)
445
445
  print(f"\n ✓ Mesh {'enabled' if config.mesh_enabled else 'disabled'}")
446
446
 
447
447
  # -- Step 7: Ingestion Adapters (v3.4.3) --
@@ -502,7 +502,7 @@ def run_wizard(auto: bool = False) -> None:
502
502
  print(" Auto-enabling entity compilation (non-interactive)")
503
503
 
504
504
  config.entity_compilation_enabled = ec_choice in ("", "y", "yes")
505
- config.save()
505
+ config.save(mode_change=True)
506
506
  print(f"\n ✓ Entity compilation {'enabled' if config.entity_compilation_enabled else 'disabled'}")
507
507
 
508
508
  # -- Step 9: Skill Evolution (v3.4.11) --
@@ -661,7 +661,7 @@ def check_first_use(command: str) -> None:
661
661
  from superlocalmemory.core.config import SLMConfig
662
662
  from superlocalmemory.storage.models import Mode
663
663
  config = SLMConfig.for_mode(Mode.A)
664
- config.save()
664
+ config.save(mode_change=True)
665
665
  _mark_complete()
666
666
  except Exception:
667
667
  pass
@@ -749,6 +749,6 @@ def configure_provider(config: object) -> None:
749
749
  llm_api_key=api_key,
750
750
  llm_api_base=preset["base_url"],
751
751
  )
752
- updated.save()
752
+ updated.save(mode_change=True)
753
753
  print(f" Provider: {provider_name}")
754
754
  print(f" Model: {preset['model']}")
@@ -697,8 +697,25 @@ class SLMConfig:
697
697
 
698
698
  return config
699
699
 
700
- def save(self, config_path: Path | None = None) -> None:
701
- """Save config to JSON file."""
700
+ def save(
701
+ self,
702
+ config_path: Path | None = None,
703
+ *,
704
+ mode_change: bool = False,
705
+ ) -> None:
706
+ """Save config to JSON file.
707
+
708
+ v3.4.34: mode protection. If the existing config.json has a mode
709
+ that differs from ``self.mode`` and the caller did NOT pass
710
+ ``mode_change=True``, the EXISTING mode is preserved. This
711
+ prevents accidental mode resets when code creates a fresh
712
+ ``SLMConfig()`` (defaults to Mode A) and calls ``save()`` to
713
+ persist an unrelated field change.
714
+
715
+ Callers that intentionally switch mode (``slm mode b``, the MCP
716
+ ``set_mode`` tool, the dashboard PUT ``/api/v3/mode``) MUST pass
717
+ ``mode_change=True``.
718
+ """
702
719
  import json
703
720
  path = config_path or (self.base_dir / "config.json")
704
721
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -710,8 +727,19 @@ class SLMConfig:
710
727
  except (json.JSONDecodeError, OSError):
711
728
  pass
712
729
 
730
+ # v3.4.34: mode protection — preserve user's mode unless explicitly changing
731
+ effective_mode = self.mode.value
732
+ existing_mode = existing.get("mode")
733
+ if existing_mode and existing_mode != effective_mode and not mode_change:
734
+ logger.warning(
735
+ "SLMConfig.save(): mode change blocked (%s → %s). "
736
+ "Pass mode_change=True to override. Preserving '%s'.",
737
+ existing_mode, effective_mode, existing_mode,
738
+ )
739
+ effective_mode = existing_mode
740
+
713
741
  data = {
714
- "mode": self.mode.value,
742
+ "mode": effective_mode,
715
743
  "active_profile": self.active_profile,
716
744
  "llm": {
717
745
  "provider": self.llm.provider,
@@ -63,7 +63,7 @@ def _load_embedding_model(name: str) -> tuple:
63
63
  """
64
64
  from sentence_transformers import SentenceTransformer
65
65
 
66
- # Tier 1: ONNX (stable memory, ~200MB footprint)
66
+ # Tier 1: ONNX (stable memory; ~1.1 GB for nomic-embed-text-v1.5)
67
67
  try:
68
68
  m = SentenceTransformer(name, backend="onnx", trust_remote_code=True)
69
69
  return m, "onnx"
@@ -176,6 +176,23 @@ def _conn_for(db_path: Path) -> sqlite3.Connection:
176
176
  return conn
177
177
 
178
178
 
179
+ def close_threadlocal_conn() -> None:
180
+ """Close the threadlocal bandit connection on the calling thread.
181
+
182
+ v3.4.33: background callers (asyncio.to_thread pool threads) MUST call
183
+ this after finishing bandit work. Without it, each pool thread keeps a
184
+ leaked connection to learning.db for the process lifetime — observed as
185
+ 12+ open file descriptors and ~100 MB wasted page-cache RAM.
186
+ """
187
+ if _holder.conn is not None:
188
+ try:
189
+ _holder.conn.close()
190
+ except sqlite3.Error: # pragma: no cover
191
+ pass
192
+ _holder.conn = None
193
+ _holder.path = None
194
+
195
+
179
196
  def _now_iso() -> str:
180
197
  return datetime.now(timezone.utc).isoformat(timespec="seconds")
181
198
 
@@ -520,6 +537,7 @@ def retention_sweep(
520
537
  __all__ = (
521
538
  "BanditChoice",
522
539
  "ContextualBandit",
540
+ "close_threadlocal_conn",
523
541
  "compute_stratum",
524
542
  "current_time_bucket",
525
543
  "retention_sweep",
@@ -317,8 +317,13 @@ def settle_stale_plays(
317
317
  memory_conn.close()
318
318
  except sqlite3.Error: # pragma: no cover
319
319
  pass
320
- # Don't close a caller-owned bandit instance.
321
- _ = owns_bandit
320
+ # v3.4.33: close the threadlocal bandit connection so pool threads
321
+ # from asyncio.to_thread don't leak file descriptors to learning.db.
322
+ try:
323
+ from superlocalmemory.learning.bandit import close_threadlocal_conn
324
+ close_threadlocal_conn()
325
+ except Exception: # pragma: no cover — defensive
326
+ pass
322
327
 
323
328
  return settled
324
329
 
@@ -70,8 +70,20 @@ def register_v3_tools(server, get_engine: Callable) -> None:
70
70
 
71
71
  mode_enum = Mode(mode_lower)
72
72
  old_config = SLMConfig.load()
73
- config = SLMConfig.for_mode(mode_enum)
74
- config.save()
73
+ config = SLMConfig.for_mode(
74
+ mode_enum,
75
+ llm_provider=old_config.llm.provider,
76
+ llm_model=old_config.llm.model,
77
+ llm_api_key=old_config.llm.api_key,
78
+ llm_api_base=old_config.llm.api_base,
79
+ embedding_provider=old_config.embedding.provider,
80
+ embedding_endpoint=old_config.embedding.api_endpoint,
81
+ embedding_key=old_config.embedding.api_key,
82
+ embedding_model_name=old_config.embedding.model_name,
83
+ embedding_dimension=old_config.embedding.dimension,
84
+ )
85
+ config.active_profile = old_config.active_profile
86
+ config.save(mode_change=True)
75
87
 
76
88
  # V3.3: Check if embedding model changed — flag for re-indexing
77
89
  needs_reindex = (
@@ -136,7 +136,7 @@ async def set_mode(request: Request):
136
136
  embedding_dimension=old_config.embedding.dimension,
137
137
  )
138
138
  new_config.active_profile = old_config.active_profile
139
- new_config.save()
139
+ new_config.save(mode_change=True)
140
140
 
141
141
  # Audit the change before we lose context — proves who/when/what.
142
142
  # Captures the phantom-write case where `for_mode(C)` auto-defaults