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.
- package/CHANGELOG.md +14 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/pyproject.toml +6 -6
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +169 -9
- package/src/superlocalmemory/cli/setup_wizard.py +18 -1
- package/src/superlocalmemory/infra/auth_middleware.py +5 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
- package/src/superlocalmemory/mcp/server.py +1 -0
- package/src/superlocalmemory/mcp/tools_core.py +178 -20
- package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
- package/src/superlocalmemory/optimize/cache/manager.py +7 -0
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
- package/src/superlocalmemory/server/profile_runtime.py +384 -0
- package/src/superlocalmemory/server/recall_health.py +12 -6
- package/src/superlocalmemory/server/routes/helpers.py +9 -16
- package/src/superlocalmemory/server/routes/profiles.py +24 -14
- package/src/superlocalmemory/server/routes/v3_api.py +97 -20
- package/src/superlocalmemory/server/unified_daemon.py +243 -55
- package/src/superlocalmemory/storage/migration_runner.py +17 -3
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
- package/src/superlocalmemory/ui/index.html +32 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
- 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(
|
|
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
|
-
|
|
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":
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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
|
-
|
|
859
|
-
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
2316
|
-
|
|
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
|
-
|
|
2781
|
+
runtime = _ud._profile_runtime
|
|
2782
|
+
if engine is None or runtime is None:
|
|
2628
2783
|
if not _waiting_logged:
|
|
2629
|
-
logger.info(
|
|
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
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
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 =
|
|
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
|