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.
Files changed (44) hide show
  1. package/CHANGELOG.md +27 -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/skills/slm-recall/SKILL.md +4 -3
  7. package/plugin-src/manifest.json +1 -1
  8. package/plugin-src/requirements.txt +1 -1
  9. package/plugin-src/skills/slm-recall/SKILL.md +4 -3
  10. package/pyproject.toml +6 -6
  11. package/src/superlocalmemory/__init__.py +1 -1
  12. package/src/superlocalmemory/cli/commands.py +169 -9
  13. package/src/superlocalmemory/cli/setup_wizard.py +53 -2
  14. package/src/superlocalmemory/core/backend_orchestrator.py +6 -1
  15. package/src/superlocalmemory/core/config.py +1 -1
  16. package/src/superlocalmemory/core/engine.py +3 -2
  17. package/src/superlocalmemory/core/engine_wiring.py +3 -1
  18. package/src/superlocalmemory/core/scale_engine.py +9 -1
  19. package/src/superlocalmemory/core/store_pipeline.py +1 -1
  20. package/src/superlocalmemory/hooks/before_web_hook.py +1 -1
  21. package/src/superlocalmemory/hooks/claude_code_hooks.py +1 -1
  22. package/src/superlocalmemory/infra/auth_middleware.py +5 -5
  23. package/src/superlocalmemory/mcp/_daemon_proxy.py +8 -10
  24. package/src/superlocalmemory/mcp/server.py +1 -0
  25. package/src/superlocalmemory/mcp/tools_active.py +11 -7
  26. package/src/superlocalmemory/mcp/tools_core.py +178 -20
  27. package/src/superlocalmemory/optimize/cache/centroid_store.py +21 -3
  28. package/src/superlocalmemory/optimize/cache/manager.py +7 -0
  29. package/src/superlocalmemory/optimize/cache/semantic.py +27 -10
  30. package/src/superlocalmemory/retrieval/engine.py +16 -8
  31. package/src/superlocalmemory/server/profile_runtime.py +384 -0
  32. package/src/superlocalmemory/server/recall_health.py +13 -7
  33. package/src/superlocalmemory/server/routes/chat.py +2 -2
  34. package/src/superlocalmemory/server/routes/helpers.py +9 -16
  35. package/src/superlocalmemory/server/routes/profiles.py +24 -14
  36. package/src/superlocalmemory/server/routes/v3_api.py +97 -20
  37. package/src/superlocalmemory/server/unified_daemon.py +261 -60
  38. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  39. package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +32 -3
  40. package/src/superlocalmemory/ui/index.html +32 -1
  41. package/src/superlocalmemory/ui/js/auto-settings.js +48 -0
  42. package/src/superlocalmemory/ui/js/memory-chat.js +2 -2
  43. package/src/superlocalmemory/ui/js/profiles.js +11 -2
  44. 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 × 6-channel threads → Ollama serialises, reranker
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
- 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
@@ -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 6-channel recall takes 7-10s on
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 6-channel recall after embedding warms up.
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 6 channels.
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(
@@ -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
- if not check_api_key(headers, is_write=is_write):
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
- fact_count = engine.fact_count if engine else 0
2303
- 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"
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
- if engine is None:
2781
+ runtime = _ud._profile_runtime
2782
+ if engine is None or runtime is None:
2615
2783
  if not _waiting_logged:
2616
- logger.info("Materializer: waiting for engine to init...")
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
- durable_complete, durable_failed = _materialize_ingestion_one_pass(
2625
- engine,
2626
- 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
+ ),
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 = _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")
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
- """Return True if the rebuilt model_state schema is in place."""
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
- return _REQUIRED_COLS <= cols
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
- <div id="settings-emb-info" class="small text-muted mt-1">
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">