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.
- package/CHANGELOG.md +76 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- 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.
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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=
|
|
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=
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
conn.execute(
|
|
201
|
-
"
|
|
202
|
-
"WHERE
|
|
203
|
-
(
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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")
|