superlocalmemory 3.7.5 → 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 +27 -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/skills/slm-recall/SKILL.md +4 -3
- package/plugin-src/manifest.json +1 -1
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +4 -3
- 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 +53 -2
- package/src/superlocalmemory/core/backend_orchestrator.py +6 -1
- package/src/superlocalmemory/core/config.py +1 -1
- package/src/superlocalmemory/core/engine.py +3 -2
- package/src/superlocalmemory/core/engine_wiring.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +9 -1
- package/src/superlocalmemory/core/store_pipeline.py +1 -1
- package/src/superlocalmemory/hooks/before_web_hook.py +1 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +1 -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_active.py +11 -7
- 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/retrieval/engine.py +16 -8
- package/src/superlocalmemory/server/profile_runtime.py +384 -0
- package/src/superlocalmemory/server/recall_health.py +13 -7
- package/src/superlocalmemory/server/routes/chat.py +2 -2
- 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 +261 -60
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- 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/memory-chat.js +2 -2
- package/src/superlocalmemory/ui/js/profiles.js +11 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +42 -6
|
@@ -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(
|
|
@@ -277,7 +372,7 @@ def _emit_event(
|
|
|
277
372
|
|
|
278
373
|
|
|
279
374
|
# v3.4.53: Limit concurrent full (non-fast) recalls. Without this, N parallel
|
|
280
|
-
# /recall calls spawn N ×
|
|
375
|
+
# /recall calls spawn N × full-recall threads → Ollama serialises, reranker
|
|
281
376
|
# lock queues, and total wall time is N × single-recall-time. 3 concurrent
|
|
282
377
|
# full recalls gives parallelism benefit without resource oversaturation.
|
|
283
378
|
import asyncio as _asyncio
|
|
@@ -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
|
|
@@ -775,7 +840,7 @@ async def lifespan(application: FastAPI):
|
|
|
775
840
|
# v3.4.52: Ensure covering indexes for SpreadingActivation queries.
|
|
776
841
|
# SQLite 3.45+ streaming merge (UNION ALL + ORDER BY + LIMIT) uses
|
|
777
842
|
# these to seek directly to top-K rows per subquery, avoiding a
|
|
778
|
-
# full sort. Without them full
|
|
843
|
+
# full sort. Without them full recall takes 7-10s on
|
|
779
844
|
# >1M edges (the SpreadingActivation 4-UNION query disk-sorts every
|
|
780
845
|
# node's neighbor list on each call). With them: sub-second.
|
|
781
846
|
try:
|
|
@@ -835,7 +900,7 @@ async def lifespan(application: FastAPI):
|
|
|
835
900
|
logger.warning("Embedding warmup failed: %s", exc)
|
|
836
901
|
|
|
837
902
|
def _warmup_recall():
|
|
838
|
-
"""v3.4.62: Fire a full
|
|
903
|
+
"""v3.4.62: Fire a full recall after embedding warms up.
|
|
839
904
|
|
|
840
905
|
Loads the graph_edges table (347K rows, ~100 MB) into the SQLite
|
|
841
906
|
page cache. Without this, the first user query takes 15-24s because
|
|
@@ -853,10 +918,11 @@ async def lifespan(application: FastAPI):
|
|
|
853
918
|
try:
|
|
854
919
|
t0 = _t.monotonic()
|
|
855
920
|
# Fire 2 warmup queries: one to load the graph page cache,
|
|
856
|
-
# second to warm the reranker subprocess + all
|
|
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(
|
|
@@ -1710,7 +1801,20 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1710
1801
|
content={"error": str(_identity_exc.detail)},
|
|
1711
1802
|
)
|
|
1712
1803
|
raise
|
|
1713
|
-
|
|
1804
|
+
# v3.7.6 (#71/#73/#74): require_http_mutation_actor above is the
|
|
1805
|
+
# authoritative write-auth boundary — it accepts the daemon
|
|
1806
|
+
# capability, the dashboard install token, a matching X-SLM-API-Key,
|
|
1807
|
+
# or an uncredentialed loopback caller, and fails closed for everyone
|
|
1808
|
+
# else. The legacy check_api_key gate only understands X-SLM-API-Key,
|
|
1809
|
+
# so running it as a second gate 401'd write paths that stage 1 had
|
|
1810
|
+
# already authorized: capability-authenticated daemon write-throughs
|
|
1811
|
+
# (MCP `remember`, #71) and install-token dashboard writes / config
|
|
1812
|
+
# tests (#73/#74) whenever an api_key file exists. Only fall back to
|
|
1813
|
+
# check_api_key when the mutation-actor gate did not run — i.e. for
|
|
1814
|
+
# non-write, non-recall requests, where it is a no-op for reads.
|
|
1815
|
+
if not requires_mutation_actor and not check_api_key(
|
|
1816
|
+
headers, is_write=is_write
|
|
1817
|
+
):
|
|
1714
1818
|
from fastapi.responses import JSONResponse
|
|
1715
1819
|
return JSONResponse(
|
|
1716
1820
|
status_code=401,
|
|
@@ -1955,6 +2059,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1955
2059
|
except Exception:
|
|
1956
2060
|
_recall_health = {"recall_healthy": None}
|
|
1957
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
|
|
1958
2065
|
return {
|
|
1959
2066
|
"status": "ok",
|
|
1960
2067
|
"ready": fully_ready,
|
|
@@ -1972,6 +2079,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1972
2079
|
# Runtime readiness is more precise than descriptor lifecycle.
|
|
1973
2080
|
# A process can be alive and identity-valid while retrieval warms.
|
|
1974
2081
|
"state": runtime_state,
|
|
2082
|
+
"active_profile": profile_snapshot.profile_id,
|
|
2083
|
+
"profile_generation": profile_snapshot.generation,
|
|
1975
2084
|
}
|
|
1976
2085
|
|
|
1977
2086
|
@application.get("/recall")
|
|
@@ -2063,8 +2172,13 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2063
2172
|
)
|
|
2064
2173
|
for _r in results:
|
|
2065
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
|
|
2066
2178
|
return {
|
|
2067
2179
|
"ok": True,
|
|
2180
|
+
"profile": profile_snapshot.profile_id,
|
|
2181
|
+
"profile_generation": profile_snapshot.generation,
|
|
2068
2182
|
"query": search_query,
|
|
2069
2183
|
"query_type": response.query_type,
|
|
2070
2184
|
"result_count": len(results),
|
|
@@ -2299,17 +2413,54 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2299
2413
|
_update_activity()
|
|
2300
2414
|
# Non-blocking peek — status must never force a re-init.
|
|
2301
2415
|
engine = getattr(application.state, "engine", None)
|
|
2302
|
-
|
|
2303
|
-
|
|
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"
|
|
2304
2447
|
return {
|
|
2305
2448
|
"status": "running",
|
|
2306
2449
|
"pid": os.getpid(),
|
|
2307
2450
|
"uptime_s": round(time.monotonic() - (_start_time or time.monotonic())),
|
|
2308
2451
|
"mode": mode,
|
|
2452
|
+
"provider": provider,
|
|
2309
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,
|
|
2310
2459
|
"idle_s": round(time.monotonic() - _last_activity),
|
|
2311
2460
|
"port": application.state.daemon_descriptor.port,
|
|
2312
2461
|
"legacy_port": _LEGACY_PORT,
|
|
2462
|
+
"profile": profile_snapshot.profile_id,
|
|
2463
|
+
"profile_generation": profile_snapshot.generation,
|
|
2313
2464
|
}
|
|
2314
2465
|
|
|
2315
2466
|
@application.get("/list")
|
|
@@ -2495,6 +2646,18 @@ def _materializer_actor_id() -> str:
|
|
|
2495
2646
|
return f"daemon-capability:{descriptor.capability_fingerprint}"
|
|
2496
2647
|
|
|
2497
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
|
+
|
|
2498
2661
|
def _materialize_ingestion_one_pass(
|
|
2499
2662
|
engine,
|
|
2500
2663
|
*,
|
|
@@ -2596,6 +2759,10 @@ def _start_pending_materializer() -> None:
|
|
|
2596
2759
|
"""Drain M018 operations and backfill the legacy pending.db queue."""
|
|
2597
2760
|
global _materializer_thread
|
|
2598
2761
|
|
|
2762
|
+
if _materializer_thread is not None and _materializer_thread.is_alive():
|
|
2763
|
+
return
|
|
2764
|
+
_materializer_stop.clear()
|
|
2765
|
+
|
|
2599
2766
|
def _loop():
|
|
2600
2767
|
from superlocalmemory.cli.pending_store import (
|
|
2601
2768
|
get_pending, mark_done, mark_failed,
|
|
@@ -2611,9 +2778,12 @@ def _start_pending_materializer() -> None:
|
|
|
2611
2778
|
# not a stale local reference.
|
|
2612
2779
|
import superlocalmemory.server.unified_daemon as _ud
|
|
2613
2780
|
engine = _ud._engine
|
|
2614
|
-
|
|
2781
|
+
runtime = _ud._profile_runtime
|
|
2782
|
+
if engine is None or runtime is None:
|
|
2615
2783
|
if not _waiting_logged:
|
|
2616
|
-
logger.info(
|
|
2784
|
+
logger.info(
|
|
2785
|
+
"Materializer: waiting for engine/runtime to init..."
|
|
2786
|
+
)
|
|
2617
2787
|
_waiting_logged = True
|
|
2618
2788
|
time.sleep(0.5)
|
|
2619
2789
|
continue
|
|
@@ -2621,10 +2791,18 @@ def _start_pending_materializer() -> None:
|
|
|
2621
2791
|
logger.info("Materializer: engine acquired, starting drain loop")
|
|
2622
2792
|
_engine_logged = True
|
|
2623
2793
|
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
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
|
+
),
|
|
2627
2804
|
)
|
|
2805
|
+
durable_complete, durable_failed = cycle_result or (0, 0)
|
|
2628
2806
|
pending = get_pending(limit=50)
|
|
2629
2807
|
if not pending and not durable_complete and not durable_failed:
|
|
2630
2808
|
time.sleep(1.0)
|
|
@@ -2642,7 +2820,15 @@ def _start_pending_materializer() -> None:
|
|
|
2642
2820
|
time.sleep(0.5)
|
|
2643
2821
|
waits += 1
|
|
2644
2822
|
try:
|
|
2645
|
-
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")
|
|
2646
2832
|
mark_done(item["id"])
|
|
2647
2833
|
_emit_event(
|
|
2648
2834
|
"memory.stored",
|
|
@@ -2670,6 +2856,21 @@ def _start_pending_materializer() -> None:
|
|
|
2670
2856
|
logger.info("Pending materializer started (recall-priority)")
|
|
2671
2857
|
|
|
2672
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
|
+
|
|
2673
2874
|
def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
2674
2875
|
"""Start the unified daemon. Blocks until stopped."""
|
|
2675
2876
|
global _start_time
|
|
@@ -120,6 +120,17 @@ _MODULES = {
|
|
|
120
120
|
|
|
121
121
|
logger = logging.getLogger(__name__)
|
|
122
122
|
|
|
123
|
+
# Exact historical DDL fingerprints whose resulting schema is intentionally
|
|
124
|
+
# accepted by the current migration. Unknown hashes are never reconciled.
|
|
125
|
+
_KNOWN_EQUIVALENT_DDL_HASHES: dict[str, frozenset[str]] = {
|
|
126
|
+
_M002.NAME: frozenset({
|
|
127
|
+
# v3.4.21 hardened copy-forward variant.
|
|
128
|
+
"347eeb2ec8aac89f7cbf373da49ac9446be9ed150e6105c382c656cd22426d4b",
|
|
129
|
+
# v3.4.22 model_version-default variant shipped through 3.6.x.
|
|
130
|
+
"d28666fa1dfa66e6514efd288e6748363513da2255a4cee95d80f233e6728ae7",
|
|
131
|
+
}),
|
|
132
|
+
}
|
|
133
|
+
|
|
123
134
|
|
|
124
135
|
@dataclass(frozen=True, slots=True)
|
|
125
136
|
class Migration:
|
|
@@ -298,6 +309,39 @@ def _apply_single(
|
|
|
298
309
|
_, _, logged_hash, _, status = existing
|
|
299
310
|
if status == "complete":
|
|
300
311
|
if logged_hash != ddl_hash:
|
|
312
|
+
# v3.7.6 (#70): a complete migration whose logged DDL hash no
|
|
313
|
+
# longer matches the current text is only a real failure if the
|
|
314
|
+
# schema it guarantees is actually absent. Historically-benign
|
|
315
|
+
# DDL edits (e.g. M002's V3.4.21 <-> S9-W1 variants that build the
|
|
316
|
+
# identical end-state) would otherwise brick readiness forever on
|
|
317
|
+
# upgrade. Consult the migration's own verify(); if the schema is
|
|
318
|
+
# in place, reconcile the log to the current hash and treat as
|
|
319
|
+
# already-applied instead of failing the daemon into permanent
|
|
320
|
+
# not_ready. Absent/failing verify keeps the hard failure.
|
|
321
|
+
allowed_hashes = _KNOWN_EQUIVALENT_DDL_HASHES.get(
|
|
322
|
+
migration.name, frozenset(),
|
|
323
|
+
)
|
|
324
|
+
mod = _MODULES.get(migration.name)
|
|
325
|
+
verify_fn = (
|
|
326
|
+
getattr(mod, "verify", None) if mod is not None else None
|
|
327
|
+
)
|
|
328
|
+
if logged_hash in allowed_hashes and verify_fn is not None:
|
|
329
|
+
try:
|
|
330
|
+
if verify_fn(conn):
|
|
331
|
+
if not dry_run:
|
|
332
|
+
try:
|
|
333
|
+
_upsert_log(
|
|
334
|
+
conn, migration.name, ddl_hash, "complete"
|
|
335
|
+
)
|
|
336
|
+
except sqlite3.Error: # pragma: no cover
|
|
337
|
+
pass
|
|
338
|
+
return (
|
|
339
|
+
"skipped",
|
|
340
|
+
"allowlisted historical DDL reconciled after "
|
|
341
|
+
"full schema verification",
|
|
342
|
+
)
|
|
343
|
+
except sqlite3.Error: # pragma: no cover
|
|
344
|
+
pass
|
|
301
345
|
detail = (
|
|
302
346
|
f"DDL drift detected for {migration.name}: "
|
|
303
347
|
f"logged={logged_hash[:8]}... current={ddl_hash[:8]}..."
|
|
@@ -29,14 +29,43 @@ _REQUIRED_COLS = frozenset({
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def verify(conn: sqlite3.Connection) -> bool:
|
|
32
|
-
"""
|
|
32
|
+
"""Verify columns plus both indexes promised by this migration."""
|
|
33
33
|
try:
|
|
34
|
-
cols = {r[1] for r in conn.execute(
|
|
34
|
+
cols = {r[1]: r for r in conn.execute(
|
|
35
35
|
"PRAGMA table_info(learning_model_state)"
|
|
36
36
|
).fetchall()}
|
|
37
|
+
index_rows = conn.execute(
|
|
38
|
+
"PRAGMA index_list(learning_model_state)"
|
|
39
|
+
).fetchall()
|
|
37
40
|
except sqlite3.Error:
|
|
38
41
|
return False
|
|
39
|
-
|
|
42
|
+
if not _REQUIRED_COLS <= set(cols):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
indexes = {row[1]: row for row in index_rows}
|
|
46
|
+
active = indexes.get("idx_model_active")
|
|
47
|
+
profile_time = indexes.get("idx_model_profile_time")
|
|
48
|
+
if active is None or profile_time is None:
|
|
49
|
+
return False
|
|
50
|
+
# idx_model_active must remain a UNIQUE partial index.
|
|
51
|
+
if int(active[2]) != 1 or int(active[4]) != 1:
|
|
52
|
+
return False
|
|
53
|
+
active_cols = [row[2] for row in conn.execute(
|
|
54
|
+
"PRAGMA index_info(idx_model_active)"
|
|
55
|
+
).fetchall()]
|
|
56
|
+
time_cols = [row[2] for row in conn.execute(
|
|
57
|
+
"PRAGMA index_info(idx_model_profile_time)"
|
|
58
|
+
).fetchall()]
|
|
59
|
+
if active_cols != ["profile_id"]:
|
|
60
|
+
return False
|
|
61
|
+
if time_cols != ["profile_id", "trained_at"]:
|
|
62
|
+
return False
|
|
63
|
+
sql_row = conn.execute(
|
|
64
|
+
"SELECT sql FROM sqlite_master WHERE type='index' AND name=?",
|
|
65
|
+
("idx_model_active",),
|
|
66
|
+
).fetchone()
|
|
67
|
+
normalized = " ".join(str(sql_row[0] if sql_row else "").lower().split())
|
|
68
|
+
return "where is_active = 1" in normalized
|
|
40
69
|
|
|
41
70
|
|
|
42
71
|
# IMPORTANT: this DDL shipped in V3.4.21. Migration hashes are immutable
|
|
@@ -1058,11 +1058,42 @@
|
|
|
1058
1058
|
</button>
|
|
1059
1059
|
<span id="settings-emb-test-result" class="ms-2 small"></span>
|
|
1060
1060
|
</div>
|
|
1061
|
-
|
|
1061
|
+
<div id="settings-emb-info" class="small text-muted mt-1">
|
|
1062
1062
|
Using local <strong>nomic-embed-text-v1.5</strong> (768d)
|
|
1063
1063
|
</div>
|
|
1064
1064
|
</div>
|
|
1065
1065
|
|
|
1066
|
+
<!-- Step 4: Scope defaults (shared/global remain opt-in) -->
|
|
1067
|
+
<div class="mt-3 pt-3 border-top" id="settings-scope-panel">
|
|
1068
|
+
<h6 class="text-muted"><i class="bi bi-shield-lock"></i> Step 4: Memory Visibility</h6>
|
|
1069
|
+
<p class="small text-muted mb-2">
|
|
1070
|
+
Personal-only is the privacy-safe default. Enable broader visibility deliberately.
|
|
1071
|
+
</p>
|
|
1072
|
+
<div class="row g-2 align-items-end">
|
|
1073
|
+
<div class="col-md-4">
|
|
1074
|
+
<label class="form-label small" for="settings-default-scope">Default write scope</label>
|
|
1075
|
+
<select class="form-select form-select-sm" id="settings-default-scope">
|
|
1076
|
+
<option value="personal">Personal (recommended)</option>
|
|
1077
|
+
<option value="shared">Shared</option>
|
|
1078
|
+
<option value="global">Global</option>
|
|
1079
|
+
</select>
|
|
1080
|
+
</div>
|
|
1081
|
+
<div class="col-md-4">
|
|
1082
|
+
<div class="form-check form-switch">
|
|
1083
|
+
<input class="form-check-input" type="checkbox" id="settings-recall-shared">
|
|
1084
|
+
<label class="form-check-label small" for="settings-recall-shared">Include shared memories by default</label>
|
|
1085
|
+
</div>
|
|
1086
|
+
</div>
|
|
1087
|
+
<div class="col-md-4">
|
|
1088
|
+
<div class="form-check form-switch">
|
|
1089
|
+
<input class="form-check-input" type="checkbox" id="settings-recall-global">
|
|
1090
|
+
<label class="form-check-label small" for="settings-recall-global">Include global memories by default</label>
|
|
1091
|
+
</div>
|
|
1092
|
+
</div>
|
|
1093
|
+
</div>
|
|
1094
|
+
<div id="settings-scope-status" class="small text-muted mt-2"></div>
|
|
1095
|
+
</div>
|
|
1096
|
+
|
|
1066
1097
|
<!-- Save button -->
|
|
1067
1098
|
<div class="mt-3">
|
|
1068
1099
|
<button class="btn btn-primary" id="settings-save-all">
|