superlocalmemory 3.7.6 → 3.7.8

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/requirements.txt +1 -1
  6. package/plugin-src/manifest.json +1 -1
  7. package/plugin-src/requirements.txt +1 -1
  8. package/pyproject.toml +6 -7
  9. package/src/superlocalmemory/__init__.py +1 -1
  10. package/src/superlocalmemory/cli/commands.py +171 -11
  11. package/src/superlocalmemory/cli/setup_wizard.py +18 -1
  12. package/src/superlocalmemory/infra/auth_middleware.py +33 -5
  13. package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
  14. package/src/superlocalmemory/mcp/server.py +1 -0
  15. package/src/superlocalmemory/mcp/tools_core.py +216 -20
  16. package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
  17. package/src/superlocalmemory/optimize/cache/manager.py +7 -0
  18. package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
  19. package/src/superlocalmemory/server/api.py +17 -0
  20. package/src/superlocalmemory/server/profile_runtime.py +384 -0
  21. package/src/superlocalmemory/server/recall_health.py +12 -6
  22. package/src/superlocalmemory/server/routes/chat.py +63 -12
  23. package/src/superlocalmemory/server/routes/helpers.py +9 -16
  24. package/src/superlocalmemory/server/routes/memories.py +58 -11
  25. package/src/superlocalmemory/server/routes/profiles.py +24 -14
  26. package/src/superlocalmemory/server/routes/v3_api.py +128 -52
  27. package/src/superlocalmemory/server/ui.py +10 -0
  28. package/src/superlocalmemory/server/unified_daemon.py +290 -74
  29. package/src/superlocalmemory/storage/migration_runner.py +17 -3
  30. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
  31. package/src/superlocalmemory/storage/schema_v32.py +0 -9
  32. package/src/superlocalmemory/ui/index.html +32 -1
  33. package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
  34. package/src/superlocalmemory/ui/js/profiles.js +11 -2
@@ -0,0 +1,384 @@
1
+ """Linearizable active-profile runtime state for the unified daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ import threading
9
+ from contextlib import contextmanager
10
+ from contextvars import ContextVar
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Callable, Iterator
15
+
16
+ from superlocalmemory.infra.data_root import state_path
17
+
18
+ _RUNTIME_BIND_LOCK = threading.Lock()
19
+ _REQUEST_PROFILE: ContextVar[str | None] = ContextVar(
20
+ "slm_request_profile", default=None,
21
+ )
22
+
23
+
24
+ def current_request_profile() -> str | None:
25
+ """Return the immutable profile snapshot admitted for this request."""
26
+ return _REQUEST_PROFILE.get()
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ProfileSnapshot:
31
+ """One immutable active-profile generation."""
32
+
33
+ profile_id: str
34
+ generation: int
35
+
36
+
37
+ class ProfileRuntime:
38
+ """Coordinate profile-sensitive operations and exclusive transitions."""
39
+
40
+ def __init__(self, profile_id: str, *, generation: int = 0) -> None:
41
+ self._condition = threading.Condition(threading.Lock())
42
+ self._snapshot = ProfileSnapshot(profile_id, generation)
43
+ self._active_operations = 0
44
+ self._transitioning = False
45
+
46
+ @property
47
+ def snapshot(self) -> ProfileSnapshot:
48
+ with self._condition:
49
+ return self._snapshot
50
+
51
+ @property
52
+ def transitioning(self) -> bool:
53
+ with self._condition:
54
+ return self._transitioning
55
+
56
+ def acquire_operation(self) -> ProfileSnapshot:
57
+ """Admit an operation only when no profile transition is active."""
58
+ with self._condition:
59
+ while self._transitioning:
60
+ self._condition.wait()
61
+ self._active_operations += 1
62
+ return self._snapshot
63
+
64
+ def release_operation(self) -> None:
65
+ with self._condition:
66
+ if self._active_operations <= 0:
67
+ raise RuntimeError("profile runtime operation lease underflow")
68
+ self._active_operations -= 1
69
+ if self._active_operations == 0:
70
+ self._condition.notify_all()
71
+
72
+ @contextmanager
73
+ def operation(self) -> Iterator[ProfileSnapshot]:
74
+ snapshot = self.acquire_operation()
75
+ try:
76
+ yield snapshot
77
+ finally:
78
+ self.release_operation()
79
+
80
+ def transition(
81
+ self,
82
+ target_profile: str,
83
+ commit: Callable[[ProfileSnapshot, str], None],
84
+ ) -> ProfileSnapshot:
85
+ """Drain admitted operations, commit, then publish a new generation."""
86
+ with self._condition:
87
+ while self._transitioning:
88
+ self._condition.wait()
89
+ if target_profile == self._snapshot.profile_id:
90
+ return self._snapshot
91
+ self._transitioning = True
92
+ while self._active_operations:
93
+ self._condition.wait()
94
+ previous = self._snapshot
95
+
96
+ try:
97
+ commit(previous, target_profile)
98
+ except BaseException:
99
+ with self._condition:
100
+ self._transitioning = False
101
+ self._condition.notify_all()
102
+ raise
103
+
104
+ with self._condition:
105
+ self._snapshot = ProfileSnapshot(
106
+ profile_id=target_profile,
107
+ generation=previous.generation + 1,
108
+ )
109
+ self._transitioning = False
110
+ self._condition.notify_all()
111
+ return self._snapshot
112
+
113
+ def reconfigure(self, commit: Callable[[ProfileSnapshot], None]) -> ProfileSnapshot:
114
+ """Run a same-profile engine transition behind the operation barrier."""
115
+ with self._condition:
116
+ while self._transitioning:
117
+ self._condition.wait()
118
+ self._transitioning = True
119
+ while self._active_operations:
120
+ self._condition.wait()
121
+ snapshot = self._snapshot
122
+
123
+ try:
124
+ commit(snapshot)
125
+ except BaseException:
126
+ with self._condition:
127
+ self._transitioning = False
128
+ self._condition.notify_all()
129
+ raise
130
+
131
+ with self._condition:
132
+ self._transitioning = False
133
+ self._condition.notify_all()
134
+ return self._snapshot
135
+
136
+
137
+ @dataclass(slots=True)
138
+ class ActiveProfilePersistence:
139
+ """Rollback handle for the two compatibility configuration stores."""
140
+
141
+ previous: dict[Path, bytes | None]
142
+ _rolled_back: bool = False
143
+
144
+ def rollback(self) -> None:
145
+ if self._rolled_back:
146
+ return
147
+ for path, content in self.previous.items():
148
+ if content is None:
149
+ path.unlink(missing_ok=True)
150
+ else:
151
+ _atomic_write_bytes(path, content)
152
+ self._rolled_back = True
153
+
154
+
155
+ def _load_json_object(path: Path, *, default: dict) -> dict:
156
+ if not path.exists():
157
+ return dict(default)
158
+ value = json.loads(path.read_text(encoding="utf-8"))
159
+ if not isinstance(value, dict):
160
+ raise ValueError(f"{path.name} must contain a JSON object")
161
+ return value
162
+
163
+
164
+ def _atomic_write_bytes(path: Path, content: bytes) -> None:
165
+ path.parent.mkdir(parents=True, exist_ok=True)
166
+ fd, temporary = tempfile.mkstemp(
167
+ dir=str(path.parent),
168
+ prefix=f".{path.name}.",
169
+ suffix=".tmp",
170
+ )
171
+ temporary_path = Path(temporary)
172
+ try:
173
+ with os.fdopen(fd, "wb") as handle:
174
+ handle.write(content)
175
+ handle.flush()
176
+ os.fsync(handle.fileno())
177
+ os.replace(temporary_path, path)
178
+ except BaseException:
179
+ temporary_path.unlink(missing_ok=True)
180
+ raise
181
+
182
+
183
+ def _json_bytes(payload: dict) -> bytes:
184
+ return (json.dumps(payload, indent=2) + "\n").encode("utf-8")
185
+
186
+
187
+ def persist_active_profile(profile_id: str) -> ActiveProfilePersistence:
188
+ """Atomically update config plus the legacy profiles cache, with rollback."""
189
+ config_path = Path(state_path("config.json"))
190
+ profiles_path = Path(state_path("profiles.json"))
191
+ paths = (config_path, profiles_path)
192
+ previous = {
193
+ path: path.read_bytes() if path.exists() else None
194
+ for path in paths
195
+ }
196
+ config = _load_json_object(config_path, default={})
197
+ profiles = _load_json_object(
198
+ profiles_path,
199
+ default={
200
+ "profiles": {
201
+ "default": {
202
+ "name": "default",
203
+ "description": "Default memory profile",
204
+ },
205
+ },
206
+ },
207
+ )
208
+ config["active_profile"] = profile_id
209
+ profiles["active_profile"] = profile_id
210
+ profile_catalog = profiles.get("profiles")
211
+ if isinstance(profile_catalog, dict):
212
+ selected = profile_catalog.get(profile_id)
213
+ if isinstance(selected, dict):
214
+ selected["last_used"] = datetime.now(timezone.utc).isoformat()
215
+ rollback = ActiveProfilePersistence(previous)
216
+ try:
217
+ _atomic_write_bytes(config_path, _json_bytes(config))
218
+ _atomic_write_bytes(profiles_path, _json_bytes(profiles))
219
+ except BaseException:
220
+ rollback.rollback()
221
+ raise
222
+ return rollback
223
+
224
+
225
+ def get_profile_runtime(app_state) -> ProfileRuntime:
226
+ """Return the daemon runtime, lazily deriving its initial profile."""
227
+ runtime = getattr(app_state, "profile_runtime", None)
228
+ if runtime is not None:
229
+ return runtime
230
+ with _RUNTIME_BIND_LOCK:
231
+ runtime = getattr(app_state, "profile_runtime", None)
232
+ if runtime is not None:
233
+ return runtime
234
+ engine = getattr(app_state, "engine", None)
235
+ config = getattr(app_state, "config", None)
236
+ profile_id = (
237
+ getattr(engine, "profile_id", "")
238
+ or getattr(config, "active_profile", "")
239
+ or "default"
240
+ )
241
+ runtime = ProfileRuntime(str(profile_id))
242
+ app_state.profile_runtime = runtime
243
+ return runtime
244
+
245
+
246
+ def bind_profile_runtime(app_state, engine, config) -> ProfileRuntime:
247
+ """Attach the authoritative runtime after daemon engine initialization."""
248
+ runtime = ProfileRuntime(str(engine.profile_id))
249
+ app_state.profile_runtime = runtime
250
+ app_state.engine = engine
251
+ app_state.config = config
252
+ return runtime
253
+
254
+
255
+ def commit_daemon_profile_switch(
256
+ app_state,
257
+ previous: ProfileSnapshot,
258
+ target_profile: str,
259
+ ) -> None:
260
+ """Persist and rebind a quiescent resident engine, rolling back on error."""
261
+ engine = getattr(app_state, "engine", None)
262
+ if engine is None:
263
+ raise RuntimeError("resident engine is unavailable")
264
+ rows = engine._db.execute(
265
+ "SELECT 1 FROM profiles WHERE profile_id = ?",
266
+ (target_profile,),
267
+ )
268
+ if not rows:
269
+ raise RuntimeError(
270
+ f"profile '{target_profile}' no longer exists at commit time"
271
+ )
272
+ app_config = getattr(app_state, "config", None)
273
+ engine_config = getattr(engine, "_config", None)
274
+ persistence = None
275
+ try:
276
+ # Requests are drained and runtime generation is not yet published.
277
+ # Rebind the in-memory engine first, then make compatibility files the
278
+ # final commit step so they can never lead daemon runtime truth.
279
+ engine.profile_id = target_profile
280
+ if app_config is not None:
281
+ app_config.active_profile = target_profile
282
+ if engine_config is not None:
283
+ engine_config.active_profile = target_profile
284
+ persistence = persist_active_profile(target_profile)
285
+ except BaseException:
286
+ engine.profile_id = previous.profile_id
287
+ if app_config is not None:
288
+ app_config.active_profile = previous.profile_id
289
+ if engine_config is not None:
290
+ engine_config.active_profile = previous.profile_id
291
+ if persistence is not None:
292
+ persistence.rollback()
293
+ raise
294
+
295
+
296
+ def reconfigure_daemon_engine(app_state, new_config, *, mode_change: bool) -> None:
297
+ """Exclusively rebuild every daemon engine reference for a new config."""
298
+ callback = getattr(app_state, "reconfigure_engine", None)
299
+ if not callable(callback):
300
+ # Direct route/unit usage without a resident daemon retains the
301
+ # established persistence-only behavior.
302
+ new_config.save(mode_change=mode_change)
303
+ return
304
+ runtime = get_profile_runtime(app_state)
305
+
306
+ def _commit(snapshot: ProfileSnapshot) -> None:
307
+ # The candidate may have been loaded before a concurrent profile
308
+ # switch. Runtime truth always wins over that stale config snapshot.
309
+ new_config.active_profile = snapshot.profile_id
310
+ callback(new_config, mode_change=mode_change)
311
+
312
+ runtime.reconfigure(_commit)
313
+
314
+
315
+ class ProfileRuntimeMiddleware:
316
+ """Hold an operation lease for each daemon HTTP request."""
317
+
318
+ def __init__(self, app, *, app_state) -> None:
319
+ self._app = app
320
+ self._app_state = app_state
321
+
322
+ @staticmethod
323
+ def _is_transition_request(path: str, method: str) -> bool:
324
+ profile_switch = (
325
+ path.startswith("/api/profiles/")
326
+ and path.endswith("/switch")
327
+ )
328
+ config_transition = method in {"POST", "PUT", "PATCH"} and path in {
329
+ "/api/v3/mode",
330
+ "/api/v3/mode/set",
331
+ "/api/v3/embedding/config",
332
+ "/api/v3/scope/config",
333
+ }
334
+ return profile_switch or config_transition
335
+
336
+ async def __call__(self, scope, receive, send) -> None:
337
+ if scope.get("type") != "http":
338
+ await self._app(scope, receive, send)
339
+ return
340
+ path = str(scope.get("path", ""))
341
+ method = str(scope.get("method", "GET")).upper()
342
+ # Mounted MCP tools proxy profile-sensitive work back through the
343
+ # canonical daemon routes. Leasing the outer MCP request would make an
344
+ # embedded switch wait on itself.
345
+ if path.startswith("/mcp") or self._is_transition_request(path, method):
346
+ await self._app(scope, receive, send)
347
+ return
348
+
349
+ import asyncio
350
+
351
+ runtime = get_profile_runtime(self._app_state)
352
+ acquire_task = asyncio.create_task(
353
+ asyncio.to_thread(runtime.acquire_operation)
354
+ )
355
+ try:
356
+ # Shield the worker so cancellation cannot strand a lease that the
357
+ # thread acquires after this coroutine has already unwound.
358
+ snapshot = await asyncio.shield(acquire_task)
359
+ except asyncio.CancelledError:
360
+ await acquire_task
361
+ runtime.release_operation()
362
+ raise
363
+ scope.setdefault("state", {})["profile_snapshot"] = snapshot
364
+ token = _REQUEST_PROFILE.set(snapshot.profile_id)
365
+ try:
366
+ await self._app(scope, receive, send)
367
+ finally:
368
+ _REQUEST_PROFILE.reset(token)
369
+ # Release is lock-only and must not itself be cancellation-prone.
370
+ runtime.release_operation()
371
+
372
+
373
+ __all__ = [
374
+ "ActiveProfilePersistence",
375
+ "ProfileRuntime",
376
+ "ProfileRuntimeMiddleware",
377
+ "ProfileSnapshot",
378
+ "bind_profile_runtime",
379
+ "commit_daemon_profile_switch",
380
+ "current_request_profile",
381
+ "get_profile_runtime",
382
+ "persist_active_profile",
383
+ "reconfigure_daemon_engine",
384
+ ]
@@ -39,6 +39,7 @@ from __future__ import annotations
39
39
 
40
40
  import logging
41
41
  import threading
42
+ from contextlib import nullcontext
42
43
  from dataclasses import dataclass
43
44
 
44
45
  logger = logging.getLogger(__name__)
@@ -118,7 +119,7 @@ def _heal_embedder(engine, *, log) -> bool:
118
119
 
119
120
 
120
121
  def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
121
- log=logger) -> RecallHealth:
122
+ log=logger, runtime=None) -> RecallHealth:
122
123
  """One monitor tick: re-warm (Tier 1), probe (Tier 2), self-heal (Tier 3).
123
124
 
124
125
  Mutates and returns ``state``. Never raises — a timed-out / failing recall
@@ -129,7 +130,9 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
129
130
  # Tier 1: re-warm. A real full-fusion recall keeps the graph page cache hot
130
131
  # and the embedder resident.
131
132
  try:
132
- resp = engine.recall(probe, limit=3, fast=False)
133
+ lease = runtime.operation() if runtime is not None else nullcontext()
134
+ with lease:
135
+ resp = engine.recall(probe, limit=3, fast=False)
133
136
  except Exception as exc:
134
137
  state.healthy = False
135
138
  state.consecutive_failures += 1
@@ -183,7 +186,7 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
183
186
 
184
187
  def health_monitor_loop(engine, *, interval_s: int, stop_event: threading.Event,
185
188
  state: RecallHealth, probe: str = DEFAULT_PROBE,
186
- log=logger) -> None:
189
+ log=logger, runtime=None) -> None:
187
190
  """Background loop. Sleeps ``interval_s`` between ticks; exits promptly when
188
191
  ``stop_event`` is set. An initial short delay avoids racing boot warmup."""
189
192
  # Initial delay (bounded) so we don't pile onto the boot warmup threads.
@@ -191,7 +194,9 @@ def health_monitor_loop(engine, *, interval_s: int, stop_event: threading.Event,
191
194
  return
192
195
  while not stop_event.is_set():
193
196
  try:
194
- run_health_tick(engine, state, probe=probe, log=log)
197
+ run_health_tick(
198
+ engine, state, probe=probe, log=log, runtime=runtime,
199
+ )
195
200
  except Exception as exc: # pragma: no cover - belt & suspenders
196
201
  log.warning("recall-health: tick crashed (non-fatal): %s", exc)
197
202
  if stop_event.wait(interval_s):
@@ -204,7 +209,8 @@ _GLOBAL_STATE = RecallHealth()
204
209
 
205
210
 
206
211
  def start_recall_health_monitor(engine, *, interval_s: int = DEFAULT_INTERVAL_S,
207
- probe: str = DEFAULT_PROBE, log=None):
212
+ probe: str = DEFAULT_PROBE, log=None,
213
+ runtime=None):
208
214
  """Start the monitor as a daemon thread. Returns ``(thread, stop_event,
209
215
  state)``. The state is the shared module-level state read by
210
216
  :func:`get_recall_health`."""
@@ -215,7 +221,7 @@ def start_recall_health_monitor(engine, *, interval_s: int = DEFAULT_INTERVAL_S,
215
221
  target=health_monitor_loop,
216
222
  kwargs=dict(
217
223
  engine=engine, interval_s=interval_s, stop_event=stop,
218
- state=state, probe=probe, log=log,
224
+ state=state, probe=probe, log=log, runtime=runtime,
219
225
  ),
220
226
  daemon=True,
221
227
  name="recall-health",
@@ -76,7 +76,7 @@ async def chat_stream(request: Request):
76
76
  limit = min(body.get("limit", 10), 20)
77
77
 
78
78
  return StreamingResponse(
79
- _stream_chat(query, mode, limit),
79
+ _stream_chat(request.app.state, query, mode, limit),
80
80
  media_type="text/event-stream",
81
81
  headers={
82
82
  "Cache-Control": "no-cache",
@@ -89,15 +89,18 @@ async def chat_stream(request: Request):
89
89
  # ── Core Chat Logic ──────────────────────────────────────────────
90
90
 
91
91
  async def _stream_chat(
92
- query: str, mode: str, limit: int,
92
+ app_state, query: str, mode: str, limit: int,
93
93
  ) -> AsyncGenerator[str, None]:
94
94
  """Retrieve memories, then stream LLM response with citations."""
95
95
 
96
- # Step 1: Retrieve memories via WorkerPool (run in executor to avoid blocking)
96
+ # Step 1: Retrieve memories via the daemon's resident engine (run in
97
+ # executor to avoid blocking the event loop).
97
98
  memories = []
98
99
  try:
99
100
  loop = asyncio.get_event_loop()
100
- memories = await loop.run_in_executor(None, _recall_memories, query, limit)
101
+ memories = await loop.run_in_executor(
102
+ None, _recall_memories, app_state, query, limit,
103
+ )
101
104
  except Exception as exc:
102
105
  yield _sse_event("error", json.dumps({"message": f"Retrieval failed: {exc}"}))
103
106
  yield _sse_event("done", "")
@@ -314,14 +317,62 @@ async def _stream_openai_compat(
314
317
 
315
318
  # ── Retrieval Helper ─────────────────────────────────────────────
316
319
 
317
- def _recall_memories(query: str, limit: int) -> list:
318
- """Run full recall via WorkerPool (synchronous, runs in executor)."""
319
- from superlocalmemory.core.worker_pool import WorkerPool
320
- pool = WorkerPool.shared()
321
- result = pool.recall(query, limit=limit)
322
- if result.get("ok"):
323
- return result.get("results", [])
324
- return []
320
+ def _recall_memories(app_state, query: str, limit: int) -> list:
321
+ """Run full recall via the daemon's resident, lease-protected engine.
322
+
323
+ v3.7.8 CRITICAL fix: this used to go through ``WorkerPool.shared()``, a
324
+ long-lived subprocess that caches its OWN ``MemoryEngine`` + profile_id
325
+ at process init and is never recycled on a profile switch. That meant a
326
+ switch could serve the OLD profile's memories to this chat endpoint for
327
+ up to 120s — a cross-profile data leak. ``application.state.engine`` is
328
+ rebound synchronously by ``commit_daemon_profile_switch``, so reading it
329
+ (via the queue-consumer's engine adapter, which already holds the same
330
+ profile-runtime lease the daemon's own ``/recall`` route uses) always
331
+ reflects the current profile.
332
+ """
333
+ adapter = getattr(app_state, "engine_recall_adapter", None)
334
+ if adapter is not None:
335
+ result = adapter.recall(query, limit=limit)
336
+ if result.get("ok"):
337
+ return result.get("results", [])
338
+ return []
339
+ # Fallback (adapter unavailable, e.g. queue-consumer failed to start):
340
+ # recall directly against the resident engine under the same profile
341
+ # lease the HTTP /recall route uses. Never fall back to WorkerPool —
342
+ # that reintroduces the stale-profile leak this fix closes.
343
+ return _recall_via_resident_engine(app_state, query, limit)
344
+
345
+
346
+ def _recall_via_resident_engine(app_state, query: str, limit: int) -> list:
347
+ """Lease-protected direct-engine recall, mirroring the /recall route."""
348
+ from superlocalmemory.server.profile_runtime import get_profile_runtime
349
+ from superlocalmemory.server.routes.helpers import get_engine_lazy
350
+
351
+ runtime = get_profile_runtime(app_state)
352
+ with runtime.operation():
353
+ engine = get_engine_lazy(app_state)
354
+ if engine is None:
355
+ return []
356
+ response = engine.recall(query, limit=limit)
357
+ memory_ids = list({
358
+ r.fact.memory_id for r in response.results[:limit]
359
+ if r.fact.memory_id
360
+ })
361
+ memory_map = (
362
+ engine._db.get_memory_content_batch(memory_ids) if memory_ids else {}
363
+ )
364
+ from superlocalmemory.server.recall_serializer import (
365
+ serialize_recall_response,
366
+ )
367
+ _rc = getattr(engine._config, "retrieval", None)
368
+ results, _no_confident_match = serialize_recall_response(
369
+ response,
370
+ limit=limit,
371
+ memory_map=memory_map,
372
+ per_fact_max=getattr(_rc, "recall_per_fact_max_chars", 2400),
373
+ total_max=getattr(_rc, "recall_total_max_chars", 12000),
374
+ )
375
+ return results
325
376
 
326
377
 
327
378
  # ── SSE Formatting ───────────────────────────────────────────────
@@ -233,7 +233,12 @@ def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
233
233
 
234
234
 
235
235
  def get_active_profile() -> str:
236
- """Read the active profile from profiles.json. Falls back to 'default'."""
236
+ """Read request runtime truth, falling back to the compatibility cache."""
237
+ from superlocalmemory.server.profile_runtime import current_request_profile
238
+
239
+ runtime_profile = current_request_profile()
240
+ if runtime_profile:
241
+ return runtime_profile
237
242
  config_file = MEMORY_DIR / "profiles.json"
238
243
  if config_file.exists():
239
244
  try:
@@ -330,22 +335,10 @@ def sync_profiles() -> list[dict]:
330
335
 
331
336
 
332
337
  def set_active_profile_everywhere(name: str) -> None:
333
- """Persist the active profile to BOTH profiles.json and config.json."""
334
- # profiles.json
335
- config = _load_profiles_json()
336
- config['active_profile'] = name
337
- _save_profiles_json(config)
338
+ """Persist active profile through the crash-safe compatibility writer."""
339
+ from superlocalmemory.server.profile_runtime import persist_active_profile
338
340
 
339
- # config.json (read by Engine/MCP on startup)
340
- config_path = MEMORY_DIR / "config.json"
341
- cfg = {}
342
- if config_path.exists():
343
- try:
344
- cfg = json.loads(config_path.read_text())
345
- except (json.JSONDecodeError, IOError):
346
- pass
347
- cfg['active_profile'] = name
348
- config_path.write_text(json.dumps(cfg, indent=2))
341
+ persist_active_profile(name)
349
342
 
350
343
 
351
344
  def delete_profile_from_db(name: str) -> None:
@@ -662,14 +662,28 @@ async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Que
662
662
  if not members:
663
663
  raise HTTPException(status_code=404, detail="Cluster not found")
664
664
  # Generate cluster summary
665
+ # v3.7.8: previously routed through WorkerPool.shared(), a subprocess
666
+ # cache never recycled on a profile switch (CRITICAL cross-profile
667
+ # leak — up to 120s stale). Use the daemon's own resident,
668
+ # lease-protected engine's config instead; the summarizer only
669
+ # consumes already profile-filtered `texts` above, so this closes
670
+ # the stale-engine window without changing behavior.
665
671
  summary = ""
666
672
  try:
667
- from superlocalmemory.core.worker_pool import WorkerPool
668
- pool = WorkerPool.shared()
669
673
  texts = [m.get("content", "")[:200] for m in members[:10] if m.get("content")]
670
674
  if texts:
671
- result = pool.summarize(texts)
672
- summary = result.get("summary", "") if result.get("ok") else ""
675
+ from superlocalmemory.core.summarizer import Summarizer
676
+ from superlocalmemory.server.profile_runtime import get_profile_runtime
677
+ from superlocalmemory.server.routes.helpers import get_engine_lazy
678
+
679
+ runtime = get_profile_runtime(request.app.state)
680
+ with runtime.operation():
681
+ engine = get_engine_lazy(request.app.state)
682
+ if engine is not None:
683
+ summarizer = Summarizer(engine._config)
684
+ summary = summarizer.summarize_cluster(
685
+ [{"content": t} for t in texts]
686
+ ) or ""
673
687
  except Exception:
674
688
  pass
675
689
 
@@ -687,14 +701,47 @@ async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Que
687
701
 
688
702
  @router.get("/api/memories/{memory_id}/facts")
689
703
  async def get_memory_facts(request: Request, memory_id: str):
690
- """Get original memory text with all its child atomic facts."""
704
+ """Get original memory text with all its child atomic facts.
705
+
706
+ v3.7.8: previously routed through WorkerPool.shared(), a subprocess
707
+ engine cached at process init and never recycled on a profile switch
708
+ (CRITICAL cross-profile leak — served the OLD profile's facts for up to
709
+ 120s after a switch). Uses the daemon's own resident, lease-protected
710
+ engine instead, exactly like the /recall route.
711
+ """
691
712
  try:
692
- from superlocalmemory.core.worker_pool import WorkerPool
693
- pool = WorkerPool.shared()
694
- result = pool.get_memory_facts(memory_id)
695
- if result.get("ok"):
696
- return result
697
- raise HTTPException(status_code=404, detail="Memory not found")
713
+ from superlocalmemory.server.profile_runtime import get_profile_runtime
714
+ from superlocalmemory.server.routes.helpers import get_engine_lazy
715
+
716
+ runtime = get_profile_runtime(request.app.state)
717
+ with runtime.operation():
718
+ engine = get_engine_lazy(request.app.state)
719
+ if engine is None:
720
+ raise HTTPException(status_code=503, detail="Engine not initialized")
721
+ active_profile = engine.profile_id
722
+ mem_map = engine._db.get_memory_content_batch([memory_id])
723
+ original = mem_map.get(memory_id, "")
724
+ facts = engine._db.get_facts_by_memory_id(memory_id, active_profile)
725
+ fact_list = [
726
+ {
727
+ "fact_id": f.fact_id,
728
+ "content": f.content,
729
+ "fact_type": (
730
+ f.fact_type.value if hasattr(f.fact_type, "value")
731
+ else str(f.fact_type)
732
+ ),
733
+ "confidence": round(f.confidence, 3),
734
+ "created_at": f.created_at,
735
+ }
736
+ for f in facts
737
+ ]
738
+ return {
739
+ "ok": True,
740
+ "memory_id": memory_id,
741
+ "original_content": original,
742
+ "facts": fact_list,
743
+ "fact_count": len(fact_list),
744
+ }
698
745
  except HTTPException:
699
746
  raise
700
747
  except Exception as e: