superlocalmemory 3.7.6 → 3.7.7

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 (29) hide show
  1. package/CHANGELOG.md +14 -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 -6
  9. package/src/superlocalmemory/__init__.py +1 -1
  10. package/src/superlocalmemory/cli/commands.py +169 -9
  11. package/src/superlocalmemory/cli/setup_wizard.py +18 -1
  12. package/src/superlocalmemory/infra/auth_middleware.py +5 -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 +178 -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/profile_runtime.py +384 -0
  20. package/src/superlocalmemory/server/recall_health.py +12 -6
  21. package/src/superlocalmemory/server/routes/helpers.py +9 -16
  22. package/src/superlocalmemory/server/routes/profiles.py +24 -14
  23. package/src/superlocalmemory/server/routes/v3_api.py +97 -20
  24. package/src/superlocalmemory/server/unified_daemon.py +243 -55
  25. package/src/superlocalmemory/storage/migration_runner.py +17 -3
  26. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
  27. package/src/superlocalmemory/ui/index.html +32 -1
  28. package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
  29. package/src/superlocalmemory/ui/js/profiles.js +11 -2
@@ -19,6 +19,27 @@ logger = logging.getLogger(__name__)
19
19
  router = APIRouter(prefix="/api/v3", tags=["v3"])
20
20
 
21
21
 
22
+ async def _apply_runtime_config(request: Request, config, *, mode_change: bool) -> None:
23
+ """Persist and hot-swap config only after the daemon transition succeeds."""
24
+ import asyncio
25
+
26
+ authorization = authorize_route_mutation(
27
+ request,
28
+ operation="update",
29
+ source_agent_id="dashboard-config",
30
+ profile_id=getattr(config, "active_profile", "default"),
31
+ )
32
+ from superlocalmemory.server.profile_runtime import reconfigure_daemon_engine
33
+
34
+ await asyncio.to_thread(
35
+ reconfigure_daemon_engine,
36
+ request.app.state,
37
+ config,
38
+ mode_change=mode_change,
39
+ )
40
+ authorization.complete()
41
+
42
+
22
43
  # ── Dashboard ────────────────────────────────────────────────
23
44
 
24
45
  @router.get("/dashboard")
@@ -26,7 +47,10 @@ async def dashboard(request: Request):
26
47
  """Dashboard summary: mode, memory count, health score, recent activity."""
27
48
  try:
28
49
  from superlocalmemory.core.config import SLMConfig
29
- config = SLMConfig.load()
50
+ config = getattr(request.app.state, "config", None) or SLMConfig.load()
51
+ from superlocalmemory.server.profile_runtime import get_profile_runtime
52
+
53
+ active_profile = get_profile_runtime(request.app.state).snapshot.profile_id
30
54
 
31
55
  # Read stats directly from SQLite (dashboard doesn't load engine)
32
56
  import sqlite3
@@ -38,12 +62,24 @@ async def dashboard(request: Request):
38
62
  conn = sqlite3.connect(str(db_path))
39
63
  cursor = conn.cursor()
40
64
  try:
41
- cursor.execute("SELECT COUNT(*) FROM atomic_facts")
65
+ cursor.execute(
66
+ "SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
67
+ (active_profile,),
68
+ )
42
69
  fact_count = cursor.fetchone()[0]
43
70
  except Exception:
44
71
  pass
45
72
  try:
46
- cursor.execute("SELECT COUNT(*) FROM memories")
73
+ try:
74
+ cursor.execute(
75
+ "SELECT COUNT(*) FROM memories WHERE profile_id = ?",
76
+ (active_profile,),
77
+ )
78
+ except Exception:
79
+ cursor.execute(
80
+ "SELECT COUNT(*) FROM memories WHERE profile = ?",
81
+ (active_profile,),
82
+ )
47
83
  memory_count = cursor.fetchone()[0]
48
84
  except Exception:
49
85
  pass
@@ -58,7 +94,7 @@ async def dashboard(request: Request):
58
94
  "model": config.llm.model or "",
59
95
  "memory_count": memory_count,
60
96
  "fact_count": fact_count,
61
- "profile": config.active_profile,
97
+ "profile": active_profile,
62
98
  "base_dir": str(config.base_dir),
63
99
  "version": SLM_VERSION,
64
100
  }
@@ -69,11 +105,11 @@ async def dashboard(request: Request):
69
105
  # ── Mode ─────────────────────────────────────────────────────
70
106
 
71
107
  @router.get("/mode")
72
- async def get_mode():
108
+ async def get_mode(request: Request):
73
109
  """Get current mode, provider, model — single source of truth for UI."""
74
110
  try:
75
111
  from superlocalmemory.core.config import SLMConfig
76
- config = SLMConfig.load()
112
+ config = getattr(request.app.state, "config", None) or SLMConfig.load()
77
113
  current = config.mode.value
78
114
  return {
79
115
  "mode": current,
@@ -132,9 +168,10 @@ async def set_mode(request: Request):
132
168
  old_config.retrieval = _template.retrieval
133
169
  old_config.math = _template.math
134
170
  old_config.channel_weights = _template.channel_weights
135
- old_config.save(mode_change=True)
136
171
  new_config = old_config
137
172
 
173
+ await _apply_runtime_config(request, new_config, mode_change=True)
174
+
138
175
  # Audit the change before we lose context — proves who/when/what.
139
176
  # Captures the phantom-write case where `for_mode(C)` auto-defaults
140
177
  # the model to "anthropic/claude-sonnet-4" (see core/config.py).
@@ -151,10 +188,6 @@ async def set_mode(request: Request):
151
188
  or old_config.embedding.model_name != new_config.embedding.model_name
152
189
  )
153
190
 
154
- # Invalidate engine; next engine-backed request lazy-inits with new config.
155
- if hasattr(request.app.state, "engine"):
156
- request.app.state.engine = None
157
-
158
191
  return {
159
192
  "success": True,
160
193
  "mode": new_mode,
@@ -227,7 +260,7 @@ async def set_full_config(request: Request):
227
260
 
228
261
  # v3.6.12 (settings-1): mode_change=True is required to persist the new
229
262
  # mode — save() without it hits a guard that preserves the old mode.
230
- config.save(mode_change=True)
263
+ await _apply_runtime_config(request, config, mode_change=True)
231
264
 
232
265
  log_mode_change(
233
266
  old_mode, new_mode,
@@ -236,16 +269,14 @@ async def set_full_config(request: Request):
236
269
  source="POST /api/v3/mode/set",
237
270
  )
238
271
 
239
- # Kill existing worker so next request uses new config
272
+ # Recycle only out-of-process fallbacks; the resident daemon engine was
273
+ # already acknowledged and hot-swapped by _apply_runtime_config().
240
274
  try:
241
275
  from superlocalmemory.core.worker_pool import WorkerPool
242
276
  WorkerPool.shared().shutdown()
243
277
  except Exception:
244
278
  pass
245
279
 
246
- if hasattr(request.app.state, "engine"):
247
- request.app.state.engine = None
248
-
249
280
  return {
250
281
  "success": True,
251
282
  "mode": new_mode,
@@ -309,7 +340,7 @@ async def set_embedding_config(request: Request):
309
340
  api_version=old_emb.api_version,
310
341
  deployment_name=old_emb.deployment_name,
311
342
  )
312
- config.save()
343
+ await _apply_runtime_config(request, config, mode_change=False)
313
344
 
314
345
  needs_reindex = (
315
346
  old_emb.provider != new_provider
@@ -323,9 +354,6 @@ async def set_embedding_config(request: Request):
323
354
  WorkerPool.shared().shutdown()
324
355
  except Exception:
325
356
  pass
326
- if hasattr(request.app.state, "engine"):
327
- request.app.state.engine = None
328
-
329
357
  return {
330
358
  "success": True,
331
359
  "provider": new_provider,
@@ -337,6 +365,55 @@ async def set_embedding_config(request: Request):
337
365
  return JSONResponse({"error": str(e)}, status_code=500)
338
366
 
339
367
 
368
+ @router.get("/scope/config")
369
+ async def get_scope_config(request: Request):
370
+ """Return runtime multi-scope defaults used by daemon writes and recalls."""
371
+ try:
372
+ from superlocalmemory.core.config import SLMConfig
373
+
374
+ config = getattr(request.app.state, "config", None) or SLMConfig.load()
375
+ return {"success": True, **config.scope.as_dict()}
376
+ except Exception as exc:
377
+ return JSONResponse({"error": str(exc)}, status_code=500)
378
+
379
+
380
+ @router.put("/scope/config")
381
+ async def set_scope_config(request: Request):
382
+ """Validate, persist, and hot-apply explicit multi-scope defaults."""
383
+ try:
384
+ body = await request.json()
385
+ from superlocalmemory.core.config import SLMConfig, ScopeConfig
386
+
387
+ config = SLMConfig.load()
388
+ current = config.scope
389
+ default_scope = body.get("default_scope", current.default_scope)
390
+ if default_scope not in {"personal", "shared", "global"}:
391
+ return JSONResponse(
392
+ {"error": "default_scope must be personal, shared, or global"},
393
+ status_code=400,
394
+ )
395
+ include_global = body.get(
396
+ "recall_include_global", current.recall_include_global,
397
+ )
398
+ include_shared = body.get(
399
+ "recall_include_shared", current.recall_include_shared,
400
+ )
401
+ if not isinstance(include_global, bool) or not isinstance(include_shared, bool):
402
+ return JSONResponse(
403
+ {"error": "recall scope flags must be booleans"},
404
+ status_code=400,
405
+ )
406
+ config.scope = ScopeConfig(
407
+ default_scope=default_scope,
408
+ recall_include_global=include_global,
409
+ recall_include_shared=include_shared,
410
+ )
411
+ await _apply_runtime_config(request, config, mode_change=False)
412
+ return {"success": True, **config.scope.as_dict()}
413
+ except Exception as exc:
414
+ return JSONResponse({"error": str(exc)}, status_code=500)
415
+
416
+
340
417
  @router.post("/embedding/test")
341
418
  async def test_embedding_endpoint(request: Request):
342
419
  """Test connectivity to a custom embedding endpoint."""
@@ -182,10 +182,26 @@ class EngineRecallAdapter:
182
182
  loaded a SECOND MemoryEngine. This adapter eliminates that duplication.
183
183
  """
184
184
 
185
- def __init__(self, engine) -> None:
185
+ def __init__(self, engine, profile_runtime=None) -> None:
186
+ self._engine = engine
187
+ self._profile_runtime = profile_runtime
188
+
189
+ def set_engine(self, engine) -> None:
190
+ """Replace the engine while the profile runtime is exclusive."""
186
191
  self._engine = engine
187
192
 
188
193
  def recall(self, query: str, limit: int = 10, session_id: str = "") -> dict:
194
+ from contextlib import nullcontext
195
+
196
+ lease = (
197
+ self._profile_runtime.operation()
198
+ if self._profile_runtime is not None
199
+ else nullcontext()
200
+ )
201
+ with lease:
202
+ return self._recall(query, limit=limit, session_id=session_id)
203
+
204
+ def _recall(self, query: str, limit: int = 10, session_id: str = "") -> dict:
189
205
  response = self._engine.recall(
190
206
  query, limit=limit, session_id=session_id or None,
191
207
  )
@@ -229,6 +245,84 @@ class EngineRecallAdapter:
229
245
  }
230
246
 
231
247
 
248
+ def _configure_scale_backends(engine, config) -> None:
249
+ """Attach optional graph/vector backends to one initialized engine."""
250
+ try:
251
+ from superlocalmemory.core.backend_orchestrator import (
252
+ BackendOrchestrator,
253
+ set_orchestrator,
254
+ )
255
+
256
+ orchestrator = BackendOrchestrator(config=config, db=engine._db)
257
+ orchestrator.on_daemon_start()
258
+ set_orchestrator(orchestrator)
259
+ cozo_backend = orchestrator.get_graph_backend()
260
+ lancedb_backend = orchestrator.get_vector_backend()
261
+ retrieval = getattr(engine, "_retrieval_engine", None)
262
+ if retrieval is not None:
263
+ entity_graph = getattr(retrieval, "_entity", None)
264
+ if (
265
+ entity_graph is not None
266
+ and cozo_backend is not None
267
+ and orchestrator.graph_retrieval_ready()
268
+ ):
269
+ entity_graph._cozo = cozo_backend
270
+ semantic = getattr(retrieval, "_semantic", None)
271
+ if semantic is not None and lancedb_backend is not None:
272
+ semantic.set_scale_vector_backend(lancedb_backend)
273
+ logger.info(
274
+ "BackendOrchestrator: ready (cozo=%s, lancedb=%s)",
275
+ "active" if cozo_backend else "off",
276
+ "active" if lancedb_backend else "off",
277
+ )
278
+ except Exception as exc:
279
+ logger.warning("BackendOrchestrator init failed (non-fatal): %s", exc)
280
+
281
+
282
+ def _hot_reconfigure_engine(application, new_config, *, mode_change: bool) -> None:
283
+ """Build and publish one coherent daemon engine for a saved config."""
284
+ from superlocalmemory.core.engine import MemoryEngine
285
+
286
+ old_engine = getattr(application.state, "engine", None)
287
+ new_engine = MemoryEngine(new_config)
288
+ try:
289
+ new_engine.initialize()
290
+ new_config.save(mode_change=mode_change)
291
+ except BaseException:
292
+ new_engine.close()
293
+ raise
294
+
295
+ # The profile transition barrier is exclusive here. Publish every
296
+ # long-lived reference before closing the former engine.
297
+ application.state.engine = new_engine
298
+ application.state.config = new_config
299
+ global _engine
300
+ _engine = new_engine
301
+ _observe_buffer.set_engine(new_engine)
302
+ adapter = getattr(application.state, "engine_recall_adapter", None)
303
+ if adapter is not None:
304
+ adapter.set_engine(new_engine)
305
+ _configure_scale_backends(new_engine, new_config)
306
+
307
+ old_health_stop = getattr(application.state, "recall_health_stop", None)
308
+ if old_health_stop is not None:
309
+ old_health_stop.set()
310
+ try:
311
+ from superlocalmemory.server.recall_health import start_recall_health_monitor
312
+
313
+ runtime = getattr(application.state, "profile_runtime", None)
314
+ _thread, health_stop, _state = start_recall_health_monitor(
315
+ new_engine, runtime=runtime,
316
+ )
317
+ application.state.recall_health_stop = health_stop
318
+ except Exception as exc:
319
+ application.state.recall_health_stop = None
320
+ logger.warning("recall-health restart failed (non-fatal): %s", exc)
321
+
322
+ if old_engine is not None and old_engine is not new_engine:
323
+ old_engine.close()
324
+
325
+
232
326
  # ---------------------------------------------------------------------------
233
327
  # v3.4.32: Recall-priority gate for the pending materializer.
234
328
  # All /remember writes go to pending.db and return fast; a background
@@ -248,6 +342,7 @@ from superlocalmemory.core.recall_gate import (
248
342
  # of pending memories — they accumulated forever, only being processed at
249
343
  # daemon startup via engine._process_pending_memories().
250
344
  _engine = None
345
+ _profile_runtime = None
251
346
 
252
347
 
253
348
  def _emit_event(
@@ -679,54 +774,24 @@ async def lifespan(application: FastAPI):
679
774
  except Exception:
680
775
  pass
681
776
 
682
- application.state.engine = engine
683
- application.state.config = config
777
+ from superlocalmemory.server.profile_runtime import bind_profile_runtime
778
+
779
+ profile_runtime = bind_profile_runtime(application.state, engine, config)
780
+ application.state.reconfigure_engine = (
781
+ lambda new_config, mode_change=False: _hot_reconfigure_engine(
782
+ application, new_config, mode_change=mode_change,
783
+ )
784
+ )
684
785
  # v3.4.38: Wire module-level _engine for the pending materializer.
685
- global _engine
786
+ global _engine, _profile_runtime
787
+ _profile_runtime = profile_runtime
686
788
  _engine = engine
687
789
  logger.info("Unified daemon: MemoryEngine initialized (mode=%s)", config.mode.value)
688
790
 
689
791
  # v3.5.0: Backend Orchestrator — CozoDB (graph) + LanceDB (vector) backends.
690
792
  # Initialise AFTER engine so the retrieval channels exist to receive backends.
691
793
  # Migrates edges/embeddings automatically; fail-soft (non-blocking).
692
- _cozo_backend = None
693
- _lancedb_backend = None
694
- try:
695
- from superlocalmemory.core.backend_orchestrator import (
696
- BackendOrchestrator, set_orchestrator,
697
- )
698
- orch = BackendOrchestrator(config=config, db=engine._db)
699
- orch.on_daemon_start()
700
- set_orchestrator(orch)
701
- _cozo_backend = orch.get_graph_backend()
702
- _lancedb_backend = orch.get_vector_backend()
703
- # Cozo storage may be active before its canonical-entity retrieval
704
- # projection is parity-proven. Never route mismatched ID spaces.
705
- re = getattr(engine, '_retrieval_engine', None)
706
- if re is not None:
707
- eg = getattr(re, '_entity', None)
708
- if (
709
- eg is not None
710
- and _cozo_backend is not None
711
- and orch.graph_retrieval_ready()
712
- ):
713
- try:
714
- eg._cozo = _cozo_backend
715
- logger.info("CozoDB backend wired into entity_graph channel")
716
- except Exception as exc:
717
- logger.warning("CozoDB channel injection failed: %s", exc)
718
- semantic = getattr(re, '_semantic', None)
719
- if semantic is not None and _lancedb_backend is not None:
720
- try:
721
- semantic.set_scale_vector_backend(_lancedb_backend)
722
- logger.info("LanceDB backend wired into semantic channel with SQLite shadow")
723
- except Exception as exc:
724
- logger.warning("LanceDB channel injection failed: %s", exc)
725
- logger.info("BackendOrchestrator: ready (cozo=%s, lancedb=%s)",
726
- "active" if _cozo_backend else "off",
727
- "active" if _lancedb_backend else "off")
728
- except Exception as exc:
729
- logger.warning("BackendOrchestrator init failed (non-fatal): %s", exc)
794
+ _configure_scale_backends(engine, config)
730
795
 
731
796
  # LLD-07 §4 — deferred migrations (e.g. M006 reward column) need to
732
797
  # run AFTER MemoryEngine.initialize() has bootstrapped runtime tables
@@ -855,8 +920,9 @@ async def lifespan(application: FastAPI):
855
920
  # Fire 2 warmup queries: one to load the graph page cache,
856
921
  # second to warm the reranker subprocess + all producers.
857
922
  # Without this, dashboard POST /api/search hits 11s cold.
858
- for wq in ("memory recall performance", "context injection retrieval"):
859
- engine.recall(wq, limit=5)
923
+ with profile_runtime.operation():
924
+ for wq in ("memory recall performance", "context injection retrieval"):
925
+ engine.recall(wq, limit=5)
860
926
  elapsed = round((_t.monotonic() - t0) * 1000)
861
927
  logger.info(
862
928
  "Recall engine pre-warmed in %dms", elapsed,
@@ -928,7 +994,9 @@ async def lifespan(application: FastAPI):
928
994
  from superlocalmemory.server.recall_health import (
929
995
  start_recall_health_monitor,
930
996
  )
931
- _rh_thread, _rh_stop, _ = start_recall_health_monitor(engine)
997
+ _rh_thread, _rh_stop, _ = start_recall_health_monitor(
998
+ engine, runtime=profile_runtime,
999
+ )
932
1000
  application.state.recall_health_stop = _rh_stop
933
1001
  except Exception as _rh_exc:
934
1002
  logger.warning(
@@ -944,12 +1012,14 @@ async def lifespan(application: FastAPI):
944
1012
  from superlocalmemory.core.recall_queue import RecallQueue
945
1013
  _queue_db = state_path("recall_queue.db")
946
1014
  _recall_queue = RecallQueue(_queue_db)
1015
+ _engine_recall_adapter = EngineRecallAdapter(engine, profile_runtime)
947
1016
  _queue_consumer = QueueConsumer(
948
1017
  queue=_recall_queue,
949
- pool=EngineRecallAdapter(engine),
1018
+ pool=_engine_recall_adapter,
950
1019
  )
951
1020
  _queue_consumer.start()
952
1021
  application.state.queue_consumer = _queue_consumer
1022
+ application.state.engine_recall_adapter = _engine_recall_adapter
953
1023
  application.state.recall_queue = _recall_queue
954
1024
  logger.info("QueueConsumer started (recall_queue.db)")
955
1025
 
@@ -966,6 +1036,7 @@ async def lifespan(application: FastAPI):
966
1036
  except Exception as _qc_exc:
967
1037
  logger.warning("QueueConsumer start failed (non-fatal): %s", _qc_exc)
968
1038
  application.state.queue_consumer = None
1039
+ application.state.engine_recall_adapter = None
969
1040
  application.state.recall_queue = None
970
1041
 
971
1042
  except Exception:
@@ -1315,11 +1386,21 @@ async def lifespan(application: FastAPI):
1315
1386
  except Exception as exc: # pragma: no cover — defensive
1316
1387
  logger.warning("perf_log flush failed: %s", exc)
1317
1388
 
1318
- if engine is not None:
1389
+ materializer_stopped = _stop_pending_materializer()
1390
+ _profile_runtime = None
1391
+ _engine = None
1392
+ if engine is not None and materializer_stopped:
1319
1393
  try:
1320
1394
  engine.close()
1321
1395
  except Exception:
1322
1396
  pass
1397
+ elif engine is not None:
1398
+ # The process is already shutting down. Do not close a database/model
1399
+ # object still owned by an admitted background operation; OS process
1400
+ # teardown is safer than racing that writer with engine.close().
1401
+ logger.warning(
1402
+ "Engine close deferred because pending materializer is still active"
1403
+ )
1323
1404
  _cleanup_process_descriptor(
1324
1405
  getattr(application.state, "daemon_descriptor", None),
1325
1406
  )
@@ -1362,9 +1443,19 @@ def create_app() -> FastAPI:
1362
1443
  application.state.daemon_descriptor = _process_descriptor(
1363
1444
  identity_port, SLM_VERSION, "starting",
1364
1445
  )
1446
+ application.state.reconfigure_engine = (
1447
+ lambda new_config, mode_change=False: _hot_reconfigure_engine(
1448
+ application, new_config, mode_change=mode_change,
1449
+ )
1450
+ )
1365
1451
 
1366
1452
  # -- Middleware --
1453
+ from superlocalmemory.server.profile_runtime import ProfileRuntimeMiddleware
1367
1454
  from superlocalmemory.server.security_middleware import SecurityHeadersMiddleware
1455
+ application.add_middleware(
1456
+ ProfileRuntimeMiddleware,
1457
+ app_state=application.state,
1458
+ )
1368
1459
  application.add_middleware(SecurityHeadersMiddleware)
1369
1460
  application.add_middleware(GZipMiddleware, minimum_size=1000)
1370
1461
  application.add_middleware(
@@ -1968,6 +2059,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
1968
2059
  except Exception:
1969
2060
  _recall_health = {"recall_healthy": None}
1970
2061
  identity = getattr(application.state, "daemon_descriptor", None)
2062
+ from superlocalmemory.server.profile_runtime import get_profile_runtime
2063
+
2064
+ profile_snapshot = get_profile_runtime(application.state).snapshot
1971
2065
  return {
1972
2066
  "status": "ok",
1973
2067
  "ready": fully_ready,
@@ -1985,6 +2079,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
1985
2079
  # Runtime readiness is more precise than descriptor lifecycle.
1986
2080
  # A process can be alive and identity-valid while retrieval warms.
1987
2081
  "state": runtime_state,
2082
+ "active_profile": profile_snapshot.profile_id,
2083
+ "profile_generation": profile_snapshot.generation,
1988
2084
  }
1989
2085
 
1990
2086
  @application.get("/recall")
@@ -2076,8 +2172,13 @@ def _register_daemon_routes(application: FastAPI) -> None:
2076
2172
  )
2077
2173
  for _r in results:
2078
2174
  _r["content"] = _sanitize_json_text(_r.get("content", ""))
2175
+ from superlocalmemory.server.profile_runtime import get_profile_runtime
2176
+
2177
+ profile_snapshot = get_profile_runtime(application.state).snapshot
2079
2178
  return {
2080
2179
  "ok": True,
2180
+ "profile": profile_snapshot.profile_id,
2181
+ "profile_generation": profile_snapshot.generation,
2081
2182
  "query": search_query,
2082
2183
  "query_type": response.query_type,
2083
2184
  "result_count": len(results),
@@ -2312,17 +2413,54 @@ def _register_daemon_routes(application: FastAPI) -> None:
2312
2413
  _update_activity()
2313
2414
  # Non-blocking peek — status must never force a re-init.
2314
2415
  engine = getattr(application.state, "engine", None)
2315
- fact_count = engine.fact_count if engine else 0
2316
- mode = engine._config.mode.value if engine and hasattr(engine, '_config') else "unknown"
2416
+ from superlocalmemory.server.profile_runtime import get_profile_runtime
2417
+
2418
+ profile_snapshot = get_profile_runtime(application.state).snapshot
2419
+ config = getattr(application.state, "config", None)
2420
+ fact_count = 0
2421
+ entity_count = 0
2422
+ edge_count = 0
2423
+ if engine is not None:
2424
+ try:
2425
+ fact_count = engine._db.get_fact_count(profile_snapshot.profile_id)
2426
+ entities = engine._db.execute(
2427
+ "SELECT COUNT(*) AS c FROM canonical_entities "
2428
+ "WHERE profile_id = ?",
2429
+ (profile_snapshot.profile_id,),
2430
+ )
2431
+ entity_count = int(dict(entities[0])["c"]) if entities else 0
2432
+ edges = engine._db.execute(
2433
+ "SELECT COUNT(*) AS c FROM graph_edges WHERE profile_id = ?",
2434
+ (profile_snapshot.profile_id,),
2435
+ )
2436
+ edge_count = int(dict(edges[0])["c"]) if edges else 0
2437
+ except Exception:
2438
+ logger.debug("daemon status count query failed", exc_info=True)
2439
+ db_path = getattr(config, "db_path", None)
2440
+ db_size_mb = (
2441
+ round(db_path.stat().st_size / 1024 / 1024, 2)
2442
+ if db_path is not None and db_path.exists()
2443
+ else 0.0
2444
+ )
2445
+ mode = getattr(getattr(config, "mode", None), "value", "unknown")
2446
+ provider = getattr(getattr(config, "llm", None), "provider", "") or "none"
2317
2447
  return {
2318
2448
  "status": "running",
2319
2449
  "pid": os.getpid(),
2320
2450
  "uptime_s": round(time.monotonic() - (_start_time or time.monotonic())),
2321
2451
  "mode": mode,
2452
+ "provider": provider,
2322
2453
  "fact_count": fact_count,
2454
+ "entity_count": entity_count,
2455
+ "edge_count": edge_count,
2456
+ "base_dir": str(getattr(config, "base_dir", "")),
2457
+ "db_path": str(db_path or ""),
2458
+ "db_size_mb": db_size_mb,
2323
2459
  "idle_s": round(time.monotonic() - _last_activity),
2324
2460
  "port": application.state.daemon_descriptor.port,
2325
2461
  "legacy_port": _LEGACY_PORT,
2462
+ "profile": profile_snapshot.profile_id,
2463
+ "profile_generation": profile_snapshot.generation,
2326
2464
  }
2327
2465
 
2328
2466
  @application.get("/list")
@@ -2508,6 +2646,18 @@ def _materializer_actor_id() -> str:
2508
2646
  return f"daemon-capability:{descriptor.capability_fingerprint}"
2509
2647
 
2510
2648
 
2649
+ def _run_materializer_operation(runtime, engine_supplier, operation):
2650
+ """Run one bounded background unit against an admitted engine snapshot."""
2651
+ with runtime.operation():
2652
+ # Resolve the engine only after admission. A concurrent mode/provider
2653
+ # reconfiguration may have replaced the module-level engine while this
2654
+ # worker was waiting at the transition barrier.
2655
+ engine = engine_supplier()
2656
+ if engine is None:
2657
+ return None
2658
+ return operation(engine)
2659
+
2660
+
2511
2661
  def _materialize_ingestion_one_pass(
2512
2662
  engine,
2513
2663
  *,
@@ -2609,6 +2759,10 @@ def _start_pending_materializer() -> None:
2609
2759
  """Drain M018 operations and backfill the legacy pending.db queue."""
2610
2760
  global _materializer_thread
2611
2761
 
2762
+ if _materializer_thread is not None and _materializer_thread.is_alive():
2763
+ return
2764
+ _materializer_stop.clear()
2765
+
2612
2766
  def _loop():
2613
2767
  from superlocalmemory.cli.pending_store import (
2614
2768
  get_pending, mark_done, mark_failed,
@@ -2624,9 +2778,12 @@ def _start_pending_materializer() -> None:
2624
2778
  # not a stale local reference.
2625
2779
  import superlocalmemory.server.unified_daemon as _ud
2626
2780
  engine = _ud._engine
2627
- if engine is None:
2781
+ runtime = _ud._profile_runtime
2782
+ if engine is None or runtime is None:
2628
2783
  if not _waiting_logged:
2629
- logger.info("Materializer: waiting for engine to init...")
2784
+ logger.info(
2785
+ "Materializer: waiting for engine/runtime to init..."
2786
+ )
2630
2787
  _waiting_logged = True
2631
2788
  time.sleep(0.5)
2632
2789
  continue
@@ -2634,10 +2791,18 @@ def _start_pending_materializer() -> None:
2634
2791
  logger.info("Materializer: engine acquired, starting drain loop")
2635
2792
  _engine_logged = True
2636
2793
 
2637
- durable_complete, durable_failed = _materialize_ingestion_one_pass(
2638
- engine,
2639
- limit=50,
2794
+ cycle_result = _run_materializer_operation(
2795
+ runtime,
2796
+ lambda: _ud._engine,
2797
+ lambda admitted_engine: _materialize_ingestion_one_pass(
2798
+ admitted_engine,
2799
+ # One operation per lease bounds profile-switch wait
2800
+ # time without allowing engine components to rebind
2801
+ # halfway through an enrichment pipeline.
2802
+ limit=1,
2803
+ ),
2640
2804
  )
2805
+ durable_complete, durable_failed = cycle_result or (0, 0)
2641
2806
  pending = get_pending(limit=50)
2642
2807
  if not pending and not durable_complete and not durable_failed:
2643
2808
  time.sleep(1.0)
@@ -2655,7 +2820,15 @@ def _start_pending_materializer() -> None:
2655
2820
  time.sleep(0.5)
2656
2821
  waits += 1
2657
2822
  try:
2658
- operation_id = _materialize_legacy_pending_item(engine, item)
2823
+ operation_id = _run_materializer_operation(
2824
+ runtime,
2825
+ lambda: _ud._engine,
2826
+ lambda admitted_engine: _materialize_legacy_pending_item(
2827
+ admitted_engine, item,
2828
+ ),
2829
+ )
2830
+ if operation_id is None:
2831
+ raise RuntimeError("resident engine became unavailable")
2659
2832
  mark_done(item["id"])
2660
2833
  _emit_event(
2661
2834
  "memory.stored",
@@ -2683,6 +2856,21 @@ def _start_pending_materializer() -> None:
2683
2856
  logger.info("Pending materializer started (recall-priority)")
2684
2857
 
2685
2858
 
2859
+ def _stop_pending_materializer(timeout: float = 5.0) -> bool:
2860
+ """Stop and join the background writer before closing its engine."""
2861
+ global _materializer_thread
2862
+
2863
+ _materializer_stop.set()
2864
+ thread = _materializer_thread
2865
+ if thread is not None and thread.is_alive():
2866
+ thread.join(timeout=timeout)
2867
+ if thread.is_alive():
2868
+ logger.warning("Pending materializer did not stop within %.1fs", timeout)
2869
+ return False
2870
+ _materializer_thread = None
2871
+ return True
2872
+
2873
+
2686
2874
  def start_server(port: int = _DEFAULT_PORT) -> None:
2687
2875
  """Start the unified daemon. Blocks until stopped."""
2688
2876
  global _start_time