superlocalmemory 3.4.16 → 3.4.17

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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ---
12
12
 
13
+ ## [3.4.17] - 2026-04-17
14
+
15
+ ### Fixed
16
+ - Entity Explorer no longer stuck on "No entities found" after switching operating modes.
17
+ - Engine-backed routes (entity, ingest, recall, remember, list) auto-recover after mode changes — no daemon restart required.
18
+
19
+ ### Added
20
+ - Mode change audit log at `~/.superlocalmemory/logs/mode-audit.log`.
21
+ - Mode C now requires an explicit API key via Settings to prevent accidental cloud-mode writes.
22
+
23
+ ---
24
+
13
25
  ## Author
14
26
 
15
27
  **Varun Pratap Bhardwaj**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.4.16",
3
+ "version": "3.4.17",
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.16"
3
+ version = "3.4.17"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -8,6 +8,8 @@ from __future__ import annotations
8
8
 
9
9
  from fastapi import APIRouter, HTTPException, Request, Query
10
10
 
11
+ from .helpers import require_engine
12
+
11
13
  router = APIRouter(prefix="/api/entity", tags=["entity"])
12
14
 
13
15
 
@@ -19,9 +21,7 @@ async def list_entities(
19
21
  offset: int = Query(default=0, ge=0),
20
22
  ):
21
23
  """List all entities with basic info (canonical name, type, fact count)."""
22
- engine = request.app.state.engine
23
- if engine is None:
24
- raise HTTPException(503, detail="Engine not initialized")
24
+ engine = require_engine(request)
25
25
 
26
26
  import sqlite3
27
27
  import json
@@ -75,9 +75,7 @@ async def get_entity(
75
75
  project: str = Query(default=""),
76
76
  ):
77
77
  """Get compiled truth + timeline for an entity."""
78
- engine = request.app.state.engine
79
- if engine is None:
80
- raise HTTPException(503, detail="Engine not initialized")
78
+ engine = require_engine(request)
81
79
 
82
80
  import sqlite3
83
81
  import json
@@ -121,9 +119,7 @@ async def recompile_entity(
121
119
  project: str = Query(default=""),
122
120
  ):
123
121
  """Force immediate recompilation of an entity."""
124
- engine = request.app.state.engine
125
- if engine is None:
126
- raise HTTPException(503, detail="Engine not initialized")
122
+ engine = require_engine(request)
127
123
 
128
124
  import sqlite3
129
125
  conn = sqlite3.connect(str(engine._config.db_path))
@@ -5,18 +5,26 @@
5
5
  - AGPL-3.0-or-later
6
6
 
7
7
  Shared utilities for all route modules: DB connection, dict factory,
8
- profile helper, validation, Pydantic models, config paths.
8
+ profile helper, validation, Pydantic models, config paths, and the
9
+ shared lazy engine accessor used by every engine-dependent route.
9
10
  """
11
+ import logging
10
12
  import re
11
13
  import json
12
14
  import sqlite3
15
+ import threading
16
+ import time
17
+ from datetime import datetime, timezone
13
18
  from pathlib import Path
14
19
  from typing import Optional
15
20
 
16
- from fastapi import HTTPException
21
+ from fastapi import HTTPException, Request
17
22
  from pydantic import BaseModel, Field
18
23
 
19
24
 
25
+ _engine_logger = logging.getLogger("superlocalmemory.engine")
26
+
27
+
20
28
  # ---------------------------------------------------------------------------
21
29
  # Version detection (shared — avoids circular import between ui.py ↔ v3_api.py)
22
30
  # ---------------------------------------------------------------------------
@@ -65,25 +73,122 @@ UI_DIR = Path(__file__).parent.parent / "ui"
65
73
  PROFILES_DIR = MEMORY_DIR / "profiles"
66
74
 
67
75
 
76
+ # ---------------------------------------------------------------------------
77
+ # Engine lifecycle — lazy, thread-safe, recoverable after mode changes.
78
+ # ---------------------------------------------------------------------------
79
+
80
+ _engine_init_lock = threading.Lock()
81
+ _last_engine_failure: float = 0.0
82
+ _ENGINE_FAILURE_COOLDOWN_S: float = 5.0
83
+
84
+
68
85
  def get_engine_lazy(app_state):
69
- """Get or lazily initialize the V3 engine. Returns engine or None."""
86
+ """Return ``app_state.engine``, initialising it if None.
87
+
88
+ Why this exists
89
+ ---------------
90
+ Mode-change endpoints (``PUT`` / ``POST /api/v3/mode[/set]``) set
91
+ ``app.state.engine = None`` so the next request picks up the new config.
92
+ Before this helper, no code path re-created the engine until the daemon
93
+ restarted, breaking every engine-backed route (entity, ingest, tiers,
94
+ recall, remember, list, status).
95
+
96
+ Contract
97
+ --------
98
+ * Returns the cached engine if already initialised.
99
+ * Otherwise acquires a process-wide lock and builds a fresh
100
+ ``MemoryEngine`` from the latest ``SLMConfig.load()``.
101
+ * Returns ``None`` if init fails. A brief cooldown prevents hammering
102
+ init when the underlying cause (e.g., corrupt DB) is persistent.
103
+ * Never uses a sticky "already attempted" flag — recovery must be
104
+ automatic after a transient failure.
105
+ """
106
+ global _last_engine_failure
107
+
70
108
  engine = getattr(app_state, "engine", None)
71
109
  if engine is not None:
72
110
  return engine
73
- if getattr(app_state, "_engine_init_attempted", False):
111
+
112
+ now = time.monotonic()
113
+ if _last_engine_failure and (now - _last_engine_failure) < _ENGINE_FAILURE_COOLDOWN_S:
74
114
  return None
115
+
116
+ with _engine_init_lock:
117
+ # Double-checked: another thread may have initialised while we waited.
118
+ engine = getattr(app_state, "engine", None)
119
+ if engine is not None:
120
+ return engine
121
+ try:
122
+ from superlocalmemory.core.config import SLMConfig
123
+ from superlocalmemory.core.engine import MemoryEngine
124
+ config = SLMConfig.load()
125
+ new_engine = MemoryEngine(config)
126
+ new_engine.initialize()
127
+ app_state.engine = new_engine
128
+ app_state.config = config
129
+ _engine_logger.info(
130
+ "Engine lazy-initialised (mode=%s, profile=%s)",
131
+ getattr(getattr(config, "mode", None), "value", "?"),
132
+ getattr(config, "active_profile", "?"),
133
+ )
134
+ _last_engine_failure = 0.0
135
+ return new_engine
136
+ except Exception as exc:
137
+ _engine_logger.warning("Engine lazy init failed: %s", exc)
138
+ _last_engine_failure = time.monotonic()
139
+ return None
140
+
141
+
142
+ def require_engine(request: Request):
143
+ """Return the engine or raise ``HTTPException(503)``.
144
+
145
+ Use this in every route that touches ``app.state.engine``. Replaces the
146
+ old ``engine = request.app.state.engine; if engine is None: raise ...``
147
+ pattern with a single call that also lazily re-initialises after a mode
148
+ change.
149
+ """
150
+ engine = get_engine_lazy(request.app.state)
151
+ if engine is None:
152
+ raise HTTPException(
153
+ status_code=503,
154
+ detail="Engine not initialized. Retry in a few seconds — it's warming up.",
155
+ )
156
+ return engine
157
+
158
+
159
+ def log_mode_change(
160
+ old_mode: str,
161
+ new_mode: str,
162
+ *,
163
+ provider: str = "",
164
+ model: str = "",
165
+ source: str = "api",
166
+ ) -> None:
167
+ """Append an audit entry for every mode change.
168
+
169
+ Lets us trace phantom writes (e.g., a stray dashboard-card button click
170
+ silently flipping the system to Mode C with ``anthropic/claude-sonnet-4``
171
+ as the auto-defaulted model).
172
+
173
+ Audit file: ``<base_dir>/logs/mode-audit.log`` (tab-separated).
174
+ """
175
+ audit_path = MEMORY_DIR / "logs" / "mode-audit.log"
75
176
  try:
76
- from superlocalmemory.core.config import SLMConfig
77
- from superlocalmemory.core.engine import MemoryEngine
78
- config = SLMConfig.load()
79
- engine = MemoryEngine(config)
80
- engine.initialize()
81
- app_state.engine = engine
82
- app_state._engine_init_attempted = True
83
- return engine
84
- except Exception:
85
- app_state._engine_init_attempted = True
86
- return None
177
+ audit_path.parent.mkdir(parents=True, exist_ok=True)
178
+ ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
179
+ line = (
180
+ f"{ts}\told={old_mode}\tnew={new_mode}"
181
+ f"\tprovider={provider}\tmodel={model}\tsource={source}\n"
182
+ )
183
+ with open(audit_path, "a", encoding="utf-8") as handle:
184
+ handle.write(line)
185
+ except Exception as exc: # Logging must never break the mode-change path.
186
+ _engine_logger.warning("mode audit write failed: %s", exc)
187
+
188
+ _engine_logger.info(
189
+ "Mode change: %s→%s provider=%s model=%s (%s)",
190
+ old_mode, new_mode, provider, model, source,
191
+ )
87
192
 
88
193
 
89
194
  def get_db_connection() -> sqlite3.Connection:
@@ -44,9 +44,8 @@ async def ingest(req: IngestRequest, request: Request):
44
44
  """
45
45
  global _active_count
46
46
 
47
- engine = request.app.state.engine
48
- if engine is None:
49
- raise HTTPException(503, detail="Engine not initialized")
47
+ from .helpers import require_engine
48
+ engine = require_engine(request)
50
49
 
51
50
  if not req.content:
52
51
  raise HTTPException(400, detail="content required")
@@ -101,7 +101,28 @@ async def set_mode(request: Request):
101
101
 
102
102
  from superlocalmemory.core.config import SLMConfig
103
103
  from superlocalmemory.storage.models import Mode
104
+ from superlocalmemory.server.routes.helpers import log_mode_change
104
105
  old_config = SLMConfig.load()
106
+ old_mode = old_config.mode.value
107
+
108
+ # Safety: a bare ``{mode:"c"}`` body (e.g., a stray dashboard button
109
+ # click) used to silently auto-default the model to
110
+ # ``anthropic/claude-sonnet-4`` with no API key, writing phantom state
111
+ # into config.json. Refuse that path — Mode C requires explicit
112
+ # provider+key via POST /api/v3/mode/set.
113
+ if new_mode == "c" and not old_config.llm.api_key:
114
+ return JSONResponse(
115
+ {
116
+ "error": (
117
+ "Mode C requires a cloud API key. "
118
+ "Configure provider + key in Settings → Step 2 "
119
+ "(uses POST /api/v3/mode/set)."
120
+ ),
121
+ "code": "mode_c_requires_api_key",
122
+ },
123
+ status_code=400,
124
+ )
125
+
105
126
  new_config = SLMConfig.for_mode(
106
127
  Mode(new_mode),
107
128
  llm_provider=old_config.llm.provider,
@@ -112,13 +133,23 @@ async def set_mode(request: Request):
112
133
  new_config.active_profile = old_config.active_profile
113
134
  new_config.save()
114
135
 
136
+ # Audit the change before we lose context — proves who/when/what.
137
+ # Captures the phantom-write case where `for_mode(C)` auto-defaults
138
+ # the model to "anthropic/claude-sonnet-4" (see core/config.py).
139
+ log_mode_change(
140
+ old_mode, new_mode,
141
+ provider=new_config.llm.provider,
142
+ model=new_config.llm.model,
143
+ source="PUT /api/v3/mode",
144
+ )
145
+
115
146
  # V3.3: Check if embedding model changed — flag for re-indexing
116
147
  needs_reindex = (
117
148
  old_config.embedding.provider != new_config.embedding.provider
118
149
  or old_config.embedding.model_name != new_config.embedding.model_name
119
150
  )
120
151
 
121
- # Reset engine to pick up new config
152
+ # Invalidate engine; next engine-backed request lazy-inits with new config.
122
153
  if hasattr(request.app.state, "engine"):
123
154
  request.app.state.engine = None
124
155
 
@@ -147,6 +178,9 @@ async def set_full_config(request: Request):
147
178
 
148
179
  from superlocalmemory.core.config import SLMConfig
149
180
  from superlocalmemory.storage.models import Mode
181
+ from superlocalmemory.server.routes.helpers import log_mode_change
182
+ old = SLMConfig.load()
183
+ old_mode = old.mode.value
150
184
  config = SLMConfig.for_mode(
151
185
  Mode(new_mode),
152
186
  llm_provider=provider if provider != "none" else "",
@@ -154,10 +188,16 @@ async def set_full_config(request: Request):
154
188
  llm_api_key=api_key,
155
189
  llm_api_base="http://localhost:11434" if provider == "ollama" else "",
156
190
  )
157
- old = SLMConfig.load()
158
191
  config.active_profile = old.active_profile
159
192
  config.save()
160
193
 
194
+ log_mode_change(
195
+ old_mode, new_mode,
196
+ provider=config.llm.provider,
197
+ model=config.llm.model,
198
+ source="POST /api/v3/mode/set",
199
+ )
200
+
161
201
  # Kill existing worker so next request uses new config
162
202
  try:
163
203
  from superlocalmemory.core.worker_pool import WorkerPool
@@ -559,10 +559,25 @@ def _register_daemon_routes(application: FastAPI) -> None:
559
559
  """Add daemon-specific routes for CLI integration."""
560
560
  global _last_activity
561
561
 
562
+ from superlocalmemory.server.routes.helpers import get_engine_lazy
563
+
564
+ def _get_engine_or_503():
565
+ """Lazy-init engine; raise 503 if init fails.
566
+
567
+ Shared by every daemon route so a mode switch that nulled
568
+ ``application.state.engine`` never leaves the daemon stuck in
569
+ 503 until restart.
570
+ """
571
+ engine = get_engine_lazy(application.state)
572
+ if engine is None:
573
+ raise HTTPException(503, detail="Engine not initialized")
574
+ return engine
575
+
562
576
  @application.get("/health")
563
577
  async def health():
564
578
  _update_activity()
565
- engine = application.state.engine
579
+ # Non-blocking peek: report status without forcing a re-init.
580
+ engine = getattr(application.state, "engine", None)
566
581
  return {
567
582
  "status": "ok",
568
583
  "pid": os.getpid(),
@@ -574,9 +589,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
574
589
  async def recall(q: str = "", query: str = "", limit: int = 20):
575
590
  _update_activity()
576
591
  search_query = q or query # Accept both ?q= and ?query= for compatibility
577
- engine = application.state.engine
578
- if engine is None:
579
- raise HTTPException(503, detail="Engine not initialized")
592
+ engine = _get_engine_or_503()
580
593
  if not search_query:
581
594
  return {"results": [], "count": 0, "query_type": "none", "retrieval_time_ms": 0}
582
595
  try:
@@ -605,9 +618,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
605
618
  @application.post("/remember")
606
619
  async def remember(req: RememberRequest):
607
620
  _update_activity()
608
- engine = application.state.engine
609
- if engine is None:
610
- raise HTTPException(503, detail="Engine not initialized")
621
+ engine = _get_engine_or_503()
611
622
  try:
612
623
  metadata = {"tags": req.tags} if req.tags else {}
613
624
  fact_ids = engine.store(req.content, metadata=metadata)
@@ -624,7 +635,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
624
635
  @application.get("/status")
625
636
  async def status():
626
637
  _update_activity()
627
- engine = application.state.engine
638
+ # Non-blocking peek — status must never force a re-init.
639
+ engine = getattr(application.state, "engine", None)
628
640
  fact_count = engine.fact_count if engine else 0
629
641
  mode = engine._config.mode.value if engine and hasattr(engine, '_config') else "unknown"
630
642
  return {
@@ -641,9 +653,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
641
653
  @application.get("/list")
642
654
  async def list_facts(limit: int = 50):
643
655
  _update_activity()
644
- engine = application.state.engine
645
- if engine is None:
646
- raise HTTPException(503, detail="Engine not initialized")
656
+ engine = _get_engine_or_503()
647
657
  try:
648
658
  facts = engine.list_facts(limit=limit)
649
659
  items = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.4.14
3
+ Version: 3.4.17
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
@@ -634,7 +634,7 @@ Qualixar is building the open-source infrastructure for AI agent reliability eng
634
634
  | **[SLM Mesh](https://github.com/qualixar/slm-mesh)** | P2P coordination across AI agent sessions | `npm i slm-mesh` | — |
635
635
  | **[SLM MCP Hub](https://github.com/qualixar/slm-mcp-hub)** | Federate 430+ MCP tools through one gateway | `pip install slm-mcp-hub` | — |
636
636
  | **[AgentAssay](https://github.com/qualixar/agentassay)** | Token-efficient AI agent testing | `pip install agentassay` | [arXiv:2603.02601](https://arxiv.org/abs/2603.02601) |
637
- | **[AgentAssert](https://github.com/qualixar/agentassert-abc)** | Behavioral contracts + drift detection | `pip install agentassert` | [arXiv:2602.22302](https://arxiv.org/abs/2602.22302) |
637
+ | **[AgentAssert](https://github.com/qualixar/agentassert-abc)** | Behavioral contracts + drift detection | `pip install agentassert-abc` | [arXiv:2602.22302](https://arxiv.org/abs/2602.22302) |
638
638
  | **[SkillFortify](https://github.com/qualixar/skillfortify)** | Formal verification for AI agent skills | `pip install skillfortify` | [arXiv:2603.00195](https://arxiv.org/abs/2603.00195) |
639
639
 
640
640
  **Zero cloud dependency. Local-first. EU AI Act compliant.**