superlocalmemory 3.8.3 → 3.8.6

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 (125) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -68,6 +68,21 @@ _SUBPROCESS_RESPONSE_TIMEOUT = 15 # v3.4.52: 15s (was 180s). Long timeout block
68
68
  # scores without reranking.
69
69
  _WORKER_RECYCLE_AFTER = 500 # Recycle after N requests
70
70
 
71
+ # One-time model load is far heavier than a live rerank request: the child
72
+ # process imports torch / sentence-transformers and runs a warmup inference,
73
+ # which measured 9-16s on the reference machine. Sharing the 15s live-request
74
+ # timeout (``_SUBPROCESS_RESPONSE_TIMEOUT``) made the load a coin flip — logs
75
+ # showed ~half of daemon boots hitting "timed out after 15s", killing the
76
+ # worker, and leaving recall on FALLBACK scoring for the entire daemon
77
+ # lifetime (a silent quality regression, not a transient one). The load gets
78
+ # its own generous budget, and the background warmup RETRIES with backoff so a
79
+ # transient slow/failed load self-heals instead of permanently degrading
80
+ # recall quality. Live rerank requests keep the tight 15s cap so a recall
81
+ # never blocks on a sick subprocess.
82
+ _WARMUP_LOAD_TIMEOUT = int(os.environ.get("SLM_RERANKER_WARMUP_TIMEOUT", "90"))
83
+ _WARMUP_MAX_ATTEMPTS = int(os.environ.get("SLM_RERANKER_WARMUP_ATTEMPTS", "5"))
84
+ _WARMUP_RETRY_BACKOFF_S = float(os.environ.get("SLM_RERANKER_WARMUP_BACKOFF", "3"))
85
+
71
86
 
72
87
  class CrossEncoderReranker:
73
88
  """Rerank candidate facts using a local cross-encoder model.
@@ -96,6 +111,8 @@ class CrossEncoderReranker:
96
111
  self._model_loaded = False # True once worker confirms model is ready
97
112
  self._worker_loading = False # True while background warmup in progress
98
113
  self._lock = threading.Lock()
114
+ self._shutdown_event = threading.Event()
115
+ self._warmup_thread: threading.Thread | None = None
99
116
  self._idle_timer: threading.Timer | None = None
100
117
  self._request_count: int = 0
101
118
 
@@ -111,7 +128,7 @@ class CrossEncoderReranker:
111
128
  def __del__(self) -> None:
112
129
  """Kill worker subprocess when reranker is garbage-collected."""
113
130
  try:
114
- self._kill_worker()
131
+ self.shutdown(timeout=0.1)
115
132
  except Exception:
116
133
  pass
117
134
 
@@ -127,26 +144,81 @@ class CrossEncoderReranker:
127
144
  lock, creating a race where the warmup's readline thread could
128
145
  steal responses meant for _send_request → deadlock → timeout.
129
146
  """
130
- if self._worker_loading or self._model_loaded:
147
+ if self._shutdown_event.is_set() or self._worker_loading or self._model_loaded:
131
148
  return
132
149
  self._worker_loading = True
133
150
 
134
151
  def _warmup() -> None:
135
152
  try:
136
- self._ensure_worker()
137
- if self._worker_proc is None:
138
- return
139
- resp = self._send_request({
140
- "cmd": "load",
141
- "model_name": self._model_name,
142
- "backend": self._backend,
143
- }, timeout=_SUBPROCESS_RESPONSE_TIMEOUT)
144
- if resp and resp.get("ok"):
145
- self._model_loaded = True
146
- logger.info(
147
- "Reranker worker warm (backend=%s, warmup_inference=%s)",
148
- resp.get("backend", "?"),
149
- resp.get("warmup_inference", False),
153
+ for attempt in range(1, _WARMUP_MAX_ATTEMPTS + 1):
154
+ if self._shutdown_event.is_set() or self._model_loaded:
155
+ return
156
+ try:
157
+ self._ensure_worker()
158
+ except Exception as exc:
159
+ logger.warning(
160
+ "Reranker warmup attempt %d/%d: worker spawn "
161
+ "raised: %s", attempt, _WARMUP_MAX_ATTEMPTS, exc,
162
+ )
163
+ self._worker_proc = None
164
+
165
+ if self._worker_proc is None:
166
+ # Either the spawn failed, or another process already
167
+ # owns the machine-wide singleton worker. If a sibling
168
+ # worker is alive this instance will use it on demand —
169
+ # stop retrying quietly rather than spinning.
170
+ if _is_reranker_worker_alive():
171
+ logger.debug(
172
+ "Reranker warmup: worker owned by another "
173
+ "process; this instance uses it on demand",
174
+ )
175
+ return
176
+ else:
177
+ # Give the ONE-TIME model load a generous budget — it is
178
+ # far heavier than a live rerank request. On timeout
179
+ # _send_request kills the worker, so the next attempt
180
+ # respawns cleanly (no stale-response race).
181
+ resp = None
182
+ try:
183
+ resp = self._send_request({
184
+ "cmd": "load",
185
+ "model_name": self._model_name,
186
+ "backend": self._backend,
187
+ }, timeout=_WARMUP_LOAD_TIMEOUT)
188
+ except Exception as exc:
189
+ logger.warning(
190
+ "Reranker warmup attempt %d/%d: load request "
191
+ "raised: %s",
192
+ attempt, _WARMUP_MAX_ATTEMPTS, exc,
193
+ )
194
+ if resp and resp.get("ok"):
195
+ self._model_loaded = True
196
+ logger.info(
197
+ "Reranker worker warm (attempt %d/%d, "
198
+ "backend=%s, warmup_inference=%s)",
199
+ attempt, _WARMUP_MAX_ATTEMPTS,
200
+ resp.get("backend", "?"),
201
+ resp.get("warmup_inference", False),
202
+ )
203
+ return
204
+ logger.warning(
205
+ "Reranker warmup attempt %d/%d did not confirm "
206
+ "ready (timeout=%ds); retrying",
207
+ attempt, _WARMUP_MAX_ATTEMPTS, _WARMUP_LOAD_TIMEOUT,
208
+ )
209
+
210
+ if attempt < _WARMUP_MAX_ATTEMPTS and not self._model_loaded:
211
+ if self._shutdown_event.wait(
212
+ min(_WARMUP_RETRY_BACKOFF_S * attempt, 15.0),
213
+ ):
214
+ return
215
+
216
+ if not self._model_loaded:
217
+ logger.warning(
218
+ "Reranker warmup exhausted %d attempts; recall uses "
219
+ "fallback scoring until the next rerank triggers a "
220
+ "fresh load. Run 'slm doctor' for diagnostics.",
221
+ _WARMUP_MAX_ATTEMPTS,
150
222
  )
151
223
  except Exception as exc:
152
224
  logger.debug("Background reranker warmup failed: %s", exc)
@@ -164,7 +236,11 @@ class CrossEncoderReranker:
164
236
  """
165
237
  if self._model_loaded:
166
238
  return True
167
- if not self._worker_loading and not self._model_loaded:
239
+ if (
240
+ not self._shutdown_event.is_set()
241
+ and not self._worker_loading
242
+ and not self._model_loaded
243
+ ):
168
244
  self._start_background_warmup()
169
245
  t = getattr(self, '_warmup_thread', None)
170
246
  if t is not None:
@@ -181,6 +257,8 @@ class CrossEncoderReranker:
181
257
  v3.4.13: Checks PID file before spawning — only ONE reranker worker
182
258
  can exist at a time on the machine.
183
259
  """
260
+ if self._shutdown_event.is_set():
261
+ return
184
262
  if self._worker_proc is not None and self._worker_proc.poll() is None:
185
263
  return
186
264
  self._worker_proc = None
@@ -260,6 +338,8 @@ class CrossEncoderReranker:
260
338
  falls back to fusion scores without reranking). This prevents
261
339
  concurrent recall requests from serialising on the lock.
262
340
  """
341
+ if self._shutdown_event.is_set():
342
+ return None
263
343
  effective_timeout = timeout or _SUBPROCESS_RESPONSE_TIMEOUT
264
344
 
265
345
  acquired = self._lock.acquire(blocking=block)
@@ -326,7 +406,7 @@ class CrossEncoderReranker:
326
406
  raise error_container[0]
327
407
  return result_container[0] if result_container else ""
328
408
 
329
- def _kill_worker(self) -> None:
409
+ def _kill_worker(self, timeout: float = 3.0) -> None:
330
410
  """Terminate the worker and close every owned pipe exactly once."""
331
411
  if self._idle_timer is not None:
332
412
  self._idle_timer.cancel()
@@ -337,10 +417,16 @@ class CrossEncoderReranker:
337
417
  # Detach first so re-entrant/finalizer cleanup is idempotent.
338
418
  self._worker_proc = None
339
419
  self._worker_ready = False
420
+ # Invariant: a dead worker has no loaded model. Enforcing this in
421
+ # ONE place (not just the recycle/timeout callers) means the idle
422
+ # timer's kill also clears the flag, so the recall path sees the
423
+ # gap and triggers a background re-warmup instead of sending a
424
+ # rerank to a cold worker and risking a 15s-timeout churn.
425
+ self._model_loaded = False
340
426
  try:
341
427
  proc.stdin.write('{"cmd":"quit"}\n')
342
428
  proc.stdin.flush()
343
- proc.wait(timeout=3)
429
+ proc.wait(timeout=max(0.0, timeout))
344
430
  except Exception:
345
431
  try:
346
432
  returncode = proc.poll()
@@ -349,7 +435,7 @@ class CrossEncoderReranker:
349
435
  if returncode is None or not isinstance(returncode, int):
350
436
  try:
351
437
  proc.kill()
352
- proc.wait(timeout=3)
438
+ proc.wait(timeout=max(0.0, timeout))
353
439
  except Exception:
354
440
  pass
355
441
  finally:
@@ -365,6 +451,8 @@ class CrossEncoderReranker:
365
451
 
366
452
  def _reset_idle_timer(self) -> None:
367
453
  """Reset idle timer — kills worker after 2 min inactivity."""
454
+ if self._shutdown_event.is_set():
455
+ return
368
456
  if self._idle_timer is not None:
369
457
  self._idle_timer.cancel()
370
458
  self._idle_timer = threading.Timer(
@@ -379,6 +467,16 @@ class CrossEncoderReranker:
379
467
  self._kill_worker()
380
468
  logger.info("CrossEncoderReranker: worker killed (idle timeout)")
381
469
 
470
+ def shutdown(self, timeout: float = 3.0) -> None:
471
+ """Cancel warmup, terminate the child, and join owned background work."""
472
+ shutdown_event = getattr(self, "_shutdown_event", None)
473
+ if shutdown_event is not None:
474
+ shutdown_event.set()
475
+ self._kill_worker(timeout=min(max(0.0, timeout), 1.0))
476
+ warmup_thread = getattr(self, "_warmup_thread", None)
477
+ if warmup_thread is not None and warmup_thread is not threading.current_thread():
478
+ warmup_thread.join(timeout=timeout)
479
+
382
480
  # ------------------------------------------------------------------
383
481
  # Public API
384
482
  # ------------------------------------------------------------------
@@ -411,8 +509,18 @@ class CrossEncoderReranker:
411
509
  if not candidates:
412
510
  return [], False, "no_candidates"
413
511
 
414
- # Non-blocking: if model isn't loaded yet, return fallback
512
+ # Non-blocking: if the model isn't loaded, return fallback AND kick a
513
+ # background (re)warmup so the reranker SELF-HEALS. Without this, a
514
+ # worker that was recycled (every 500 reqs), idle-killed (30 min), or
515
+ # crashed left ``_model_loaded`` False with nothing to reload it — every
516
+ # subsequent recall degraded to fallback scoring until the daemon was
517
+ # restarted (a silent, sustained quality regression). The warmup is
518
+ # guarded (no-op if already loading/loaded), retried, and never blocks
519
+ # this recall — it returns fallback now and full quality resumes within
520
+ # seconds once the model is warm again.
415
521
  if not self._model_loaded:
522
+ if not self._shutdown_event.is_set() and not self._worker_loading:
523
+ self._start_background_warmup()
416
524
  sorted_cands = sorted(candidates, key=lambda x: x[1], reverse=True)
417
525
  return sorted_cands[:top_k], False, "fallback_not_ready"
418
526
 
@@ -479,7 +587,7 @@ def _cleanup_all_rerankers() -> None:
479
587
  reranker = ref()
480
588
  if reranker is not None:
481
589
  try:
482
- reranker._kill_worker()
590
+ reranker.shutdown()
483
591
  except Exception:
484
592
  pass
485
593
  _live_rerankers.clear()
@@ -224,8 +224,13 @@ class SpreadingActivation:
224
224
  if not self._fok_check(activations):
225
225
  return []
226
226
 
227
- # Cache results
228
- self._cache_results(query_hash, profile_id, activations)
227
+ # Cache results — DEFERRED so recall stays READ-ONLY on its hot
228
+ # path. This is a perf cache for FUTURE recalls, not needed to
229
+ # return the current results.
230
+ from superlocalmemory.storage.deferred_writes import submit_background
231
+ submit_background(
232
+ lambda: self._cache_results(query_hash, profile_id, activations)
233
+ )
229
234
 
230
235
  # Return top-K sorted by activation
231
236
  results = sorted(
@@ -545,16 +550,19 @@ class SpreadingActivation:
545
550
  ) -> None:
546
551
  """Store results in activation_cache with 1-hour TTL."""
547
552
  try:
548
- for node_id, value in activations.items():
549
- self._db.execute(
550
- "INSERT OR REPLACE INTO activation_cache "
551
- "(cache_id, profile_id, query_hash, node_id, "
552
- " activation_value, iteration, created_at, expires_at) "
553
- "VALUES (?, ?, ?, ?, ?, ?, datetime('now'), "
554
- "datetime('now', '+1 hour'))",
555
- (_new_id(), profile_id, query_hash, node_id, value,
556
- self._config.max_iterations),
557
- )
553
+ # One transaction => ONE write-lock acquisition for the whole
554
+ # activation cache, instead of N separately-locked writes.
555
+ with self._db.transaction():
556
+ for node_id, value in activations.items():
557
+ self._db.execute(
558
+ "INSERT OR REPLACE INTO activation_cache "
559
+ "(cache_id, profile_id, query_hash, node_id, "
560
+ " activation_value, iteration, created_at, expires_at) "
561
+ "VALUES (?, ?, ?, ?, ?, ?, datetime('now'), "
562
+ "datetime('now', '+1 hour'))",
563
+ (_new_id(), profile_id, query_hash, node_id, value,
564
+ self._config.max_iterations),
565
+ )
558
566
  except Exception as exc:
559
567
  logger.debug("Cache write failed: %s", exc)
560
568
 
@@ -23,6 +23,8 @@ from typing import Any
23
23
 
24
24
  import numpy as np
25
25
 
26
+ from superlocalmemory.storage.write_lock import get_write_lock
27
+
26
28
  logger = logging.getLogger(__name__)
27
29
 
28
30
 
@@ -182,51 +184,62 @@ class VectorStore:
182
184
  )
183
185
  return False
184
186
 
187
+ # Embedding bytes computed OUTSIDE the lock — serialisation must not
188
+ # cover slow computation, only the sqlite3 write transaction itself.
185
189
  vec_bytes = self._serialize_f32(embedding)
186
190
 
187
- with self._lock:
188
- try:
189
- conn = self._connect()
190
- # Check if fact_id already exists in metadata
191
- row = conn.execute(
192
- "SELECT vec_rowid FROM embedding_metadata "
193
- "WHERE fact_id = ?",
194
- (fact_id,),
195
- ).fetchone()
196
-
197
- if row is not None:
198
- # UPDATE existing
199
- rowid = row["vec_rowid"]
200
- conn.execute(
201
- "UPDATE fact_embeddings SET embedding = ? "
202
- "WHERE rowid = ?",
203
- (vec_bytes, rowid),
204
- )
205
- else:
206
- # INSERT new
207
- conn.execute(
208
- "INSERT INTO fact_embeddings(profile_id, embedding) "
209
- "VALUES (?, ?)",
210
- (profile_id, vec_bytes),
211
- )
212
- rowid = conn.execute(
213
- "SELECT last_insert_rowid()"
214
- ).fetchone()[0]
215
- conn.execute(
216
- "INSERT INTO embedding_metadata "
217
- "(vec_rowid, fact_id, profile_id, model_name, dimension) "
218
- "VALUES (?, ?, ?, ?, ?)",
219
- (rowid, fact_id, profile_id,
220
- model_name or self._config.model_name,
221
- self._config.dimension),
222
- )
223
-
224
- conn.commit()
225
- conn.close()
226
- return True
227
- except Exception as exc:
228
- logger.debug("upsert failed for fact_id=%s: %s", fact_id, exc)
229
- return False
191
+ # Acquire the process-level write lock for this db file BEFORE opening
192
+ # the sqlite3 connection. This is the OUTERMOST lock (see write_lock.py
193
+ # ordering rule). self._lock is INNER and acquired inside. The write
194
+ # lock is the same RLock that DatabaseManager._lock references for this
195
+ # db_path, so the self-heal backfill pattern
196
+ # with db._lock: vs.upsert()
197
+ # simply re-enters the RLock (same thread — always safe).
198
+ _wl = get_write_lock(self._db_path)
199
+ with _wl: # OUTER: serialises all memory.db writers
200
+ with self._lock: # INNER: VectorStore per-instance state
201
+ try:
202
+ conn = self._connect()
203
+ # Check if fact_id already exists in metadata
204
+ row = conn.execute(
205
+ "SELECT vec_rowid FROM embedding_metadata "
206
+ "WHERE fact_id = ?",
207
+ (fact_id,),
208
+ ).fetchone()
209
+
210
+ if row is not None:
211
+ # UPDATE existing
212
+ rowid = row["vec_rowid"]
213
+ conn.execute(
214
+ "UPDATE fact_embeddings SET embedding = ? "
215
+ "WHERE rowid = ?",
216
+ (vec_bytes, rowid),
217
+ )
218
+ else:
219
+ # INSERT new
220
+ conn.execute(
221
+ "INSERT INTO fact_embeddings(profile_id, embedding) "
222
+ "VALUES (?, ?)",
223
+ (profile_id, vec_bytes),
224
+ )
225
+ rowid = conn.execute(
226
+ "SELECT last_insert_rowid()"
227
+ ).fetchone()[0]
228
+ conn.execute(
229
+ "INSERT INTO embedding_metadata "
230
+ "(vec_rowid, fact_id, profile_id, model_name, dimension) "
231
+ "VALUES (?, ?, ?, ?, ?)",
232
+ (rowid, fact_id, profile_id,
233
+ model_name or self._config.model_name,
234
+ self._config.dimension),
235
+ )
236
+
237
+ conn.commit()
238
+ conn.close()
239
+ return True
240
+ except Exception as exc:
241
+ logger.debug("upsert failed for fact_id=%s: %s", fact_id, exc)
242
+ return False
230
243
 
231
244
  def search(
232
245
  self,
@@ -302,41 +315,43 @@ class VectorStore:
302
315
  def delete(self, fact_id: str) -> bool:
303
316
  """Remove a vector from vec0 and metadata.
304
317
 
305
- Thread-safe: acquires self._lock.
318
+ Thread-safe: acquires the process-level write lock then self._lock.
306
319
  Returns True if deleted, False if not found or error.
307
320
  """
308
321
  if not self._available:
309
322
  return False
310
323
 
311
- with self._lock:
312
- try:
313
- conn = self._connect()
314
- row = conn.execute(
315
- "SELECT vec_rowid FROM embedding_metadata "
316
- "WHERE fact_id = ?",
317
- (fact_id,),
318
- ).fetchone()
324
+ _wl = get_write_lock(self._db_path)
325
+ with _wl: # OUTER: process-level write serialisation
326
+ with self._lock: # INNER: VectorStore per-instance state
327
+ try:
328
+ conn = self._connect()
329
+ row = conn.execute(
330
+ "SELECT vec_rowid FROM embedding_metadata "
331
+ "WHERE fact_id = ?",
332
+ (fact_id,),
333
+ ).fetchone()
334
+
335
+ if row is None:
336
+ conn.close()
337
+ return False
319
338
 
320
- if row is None:
339
+ rowid = row["vec_rowid"]
340
+ conn.execute(
341
+ "DELETE FROM fact_embeddings WHERE rowid = ?",
342
+ (rowid,),
343
+ )
344
+ conn.execute(
345
+ "DELETE FROM embedding_metadata WHERE vec_rowid = ?",
346
+ (rowid,),
347
+ )
348
+ conn.commit()
321
349
  conn.close()
350
+ return True
351
+ except Exception as exc:
352
+ logger.debug("delete failed for fact_id=%s: %s", fact_id, exc)
322
353
  return False
323
354
 
324
- rowid = row["vec_rowid"]
325
- conn.execute(
326
- "DELETE FROM fact_embeddings WHERE rowid = ?",
327
- (rowid,),
328
- )
329
- conn.execute(
330
- "DELETE FROM embedding_metadata WHERE vec_rowid = ?",
331
- (rowid,),
332
- )
333
- conn.commit()
334
- conn.close()
335
- return True
336
- except Exception as exc:
337
- logger.debug("delete failed for fact_id=%s: %s", fact_id, exc)
338
- return False
339
-
340
355
  def count(self, profile_id: str | None = None) -> int:
341
356
  """Count vectors in the store.
342
357
 
@@ -0,0 +1,85 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later
3
+
4
+ """Centralized loopback-address predicate for the SLM server.
5
+
6
+ Replaces every ``frozenset({"127.0.0.1", "::1", "localhost"})`` auth check
7
+ with a semantic helper that correctly handles IPv4-mapped IPv6 addresses
8
+ (``::ffff:127.0.0.1``) — the root cause of issue #90.
9
+
10
+ Issue #90 root cause
11
+ --------------------
12
+ When the daemon is started with ``SLM_DAEMON_HOST=0.0.0.0`` on a dual-stack
13
+ Linux host (common in LXC/Docker containers), the OS creates an IPv6 socket
14
+ that accepts IPv4 connections via the IPv4-mapped IPv6 address mechanism
15
+ (RFC 4291 §2.5.5.2). A client connecting to ``localhost`` on such a host
16
+ may have its peer address reported as ``::ffff:127.0.0.1`` by
17
+ uvicorn/Starlette. The literal set ``("127.0.0.1", "::1", "localhost")``
18
+ does not include this form, causing a spurious 403 for install-token and
19
+ uncredentialed-loopback callers.
20
+
21
+ ``ipaddress.ip_address("::ffff:127.0.0.1").is_loopback`` already returns
22
+ ``True`` in CPython. This module uses that fact.
23
+
24
+ SECURITY INVARIANTS (non-negotiable):
25
+ - Empty/None host → False. SEC-L-02: a missing peer is never trusted.
26
+ - No proxy header trust. Callers MUST pass ``request.client.host`` only,
27
+ never X-Forwarded-For or any other spoofable header.
28
+ - All 127.0.0.0/8 is loopback per RFC 5735 (includes 127.0.0.2, etc.).
29
+ - ``"localhost"`` is accepted as a hostname alias. Callers that need the
30
+ stricter no-hostname check (``prewarm_auth``) keep their own predicate.
31
+ - ``"testclient"`` is NOT in the loopback set — that bypass must be wired
32
+ explicitly alongside ``_TEST_ISOLATION_ALLOWED`` so it cannot appear in
33
+ production paths.
34
+ - ``"0.0.0.0"`` is NOT loopback — it is a bind address, not a peer address.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import ipaddress as _ipa
40
+
41
+
42
+ def is_loopback(host: str) -> bool:
43
+ """Return ``True`` iff ``host`` is a loopback address in any standard form.
44
+
45
+ Handles:
46
+ * ``"127.0.0.1"`` and the full 127.0.0.0/8 range (RFC 5735).
47
+ * ``"::1"`` (IPv6 loopback).
48
+ * ``"::ffff:127.0.0.1"`` and all ``::ffff:127.x.x.x`` (IPv4-mapped IPv6,
49
+ fixes issue #90).
50
+ * ``"localhost"`` (hostname alias, case-insensitive).
51
+
52
+ Returns ``False`` for:
53
+ * Empty string or ``None`` (SEC-L-02: missing peer is never trusted).
54
+ * Any non-loopback IP (192.168.x.x, 10.x.x.x, public IPs, etc.).
55
+ * ``"::ffff:192.168.x.x"`` and other IPv4-mapped non-loopback addresses.
56
+ * ``"testclient"`` — callers that need the test-client exemption must
57
+ wire it explicitly alongside ``_TEST_ISOLATION_ALLOWED``.
58
+ * ``"0.0.0.0"`` — bind address, not a peer address.
59
+
60
+ Args:
61
+ host: The ``request.client.host`` string from an incoming HTTP
62
+ request. Must be the TCP-observed peer address only.
63
+
64
+ Returns:
65
+ ``True`` if the host is any recognised loopback form; ``False``
66
+ otherwise.
67
+ """
68
+ if not isinstance(host, str) or not host:
69
+ return False # SEC-L-02
70
+ if host.lower() == "localhost":
71
+ return True
72
+ try:
73
+ ip = _ipa.ip_address(host)
74
+ except ValueError:
75
+ return False
76
+ if ip.is_loopback:
77
+ return True
78
+ # CPython's handling of IPv4-mapped IPv6 changed across supported
79
+ # runtimes. Normalize through the embedded IPv4 address so a dual-stack
80
+ # socket reporting ::ffff:127.x.x.x retains the equivalent IPv4 decision.
81
+ mapped_ipv4 = getattr(ip, "ipv4_mapped", None)
82
+ return bool(mapped_ipv4 is not None and mapped_ipv4.is_loopback)
83
+
84
+
85
+ __all__ = ("is_loopback",)
@@ -4,12 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  from urllib.parse import urlsplit
6
6
 
7
-
8
- _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
7
+ from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
9
8
 
10
9
 
11
10
  def origin_is_loopback(origin: str) -> bool:
12
- """Return whether an Origin is absent or an exact HTTP(S) loopback URL."""
11
+ """Return whether an Origin is absent or an exact HTTP(S) loopback URL.
12
+
13
+ Uses the centralized is_loopback helper so that IPv4-mapped loopback
14
+ addresses (::ffff:127.x.x.x) are accepted correctly (issue #90).
15
+ Note: browsers normalize Origin hostnames; the ::ffff: form would only
16
+ appear in synthetic requests. The helper is used for defense-in-depth.
17
+ """
13
18
  if not origin:
14
19
  return True
15
20
  try:
@@ -21,7 +26,7 @@ def origin_is_loopback(origin: str) -> bool:
21
26
  return (
22
27
  parsed.scheme in {"http", "https"}
23
28
  and parsed.hostname is not None
24
- and parsed.hostname.lower() in _LOOPBACK_HOSTS
29
+ and _is_loopback_host(parsed.hostname.lower())
25
30
  and parsed.username is None
26
31
  and parsed.password is None
27
32
  and parsed.path in {"", "/"}
@@ -361,6 +361,13 @@ def commit_daemon_profile_switch(
361
361
  # Rebind the in-memory engine first, then make compatibility files the
362
362
  # final commit step so they can never lead daemon runtime truth.
363
363
  engine.profile_id = target_profile
364
+ canonical_remember = getattr(
365
+ app_state,
366
+ "canonical_remember_runtime",
367
+ None,
368
+ )
369
+ if canonical_remember is not None:
370
+ canonical_remember.rebind_engine(engine)
364
371
  if app_config is not None:
365
372
  app_config.active_profile = target_profile
366
373
  if engine_config is not None:
@@ -368,6 +375,13 @@ def commit_daemon_profile_switch(
368
375
  persistence = persist_active_profile(target_profile)
369
376
  except BaseException:
370
377
  engine.profile_id = previous.profile_id
378
+ canonical_remember = getattr(
379
+ app_state,
380
+ "canonical_remember_runtime",
381
+ None,
382
+ )
383
+ if canonical_remember is not None:
384
+ canonical_remember.rebind_engine(engine)
371
385
  if app_config is not None:
372
386
  app_config.active_profile = previous.profile_id
373
387
  if engine_config is not None:
@@ -24,7 +24,7 @@ from typing import Any
24
24
  from fastapi import APIRouter, Query
25
25
  from fastapi.responses import JSONResponse
26
26
 
27
- from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
27
+ from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile, get_read_connection
28
28
 
29
29
  logger = logging.getLogger(__name__)
30
30
 
@@ -45,9 +45,7 @@ class _ReadDB:
45
45
  def _conn() -> sqlite3.Connection | None:
46
46
  if not DB_PATH.exists():
47
47
  return None
48
- conn = sqlite3.connect(str(DB_PATH))
49
- conn.row_factory = sqlite3.Row
50
- return conn
48
+ return get_read_connection(DB_PATH)
51
49
 
52
50
 
53
51
  @router.get("/persona")