superlocalmemory 3.8.11 → 3.8.13

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 (50) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +7 -3
  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 +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/cli/commands.py +62 -3
  34. package/src/superlocalmemory/cli/daemon.py +219 -10
  35. package/src/superlocalmemory/cli/setup_wizard.py +45 -1
  36. package/src/superlocalmemory/core/component_registry.py +25 -0
  37. package/src/superlocalmemory/core/config.py +35 -1
  38. package/src/superlocalmemory/core/engine_wiring.py +81 -5
  39. package/src/superlocalmemory/core/recall_pipeline.py +25 -4
  40. package/src/superlocalmemory/core/reranker_worker.py +23 -4
  41. package/src/superlocalmemory/infra/daemon_identity.py +16 -0
  42. package/src/superlocalmemory/infra/process_identity.py +180 -0
  43. package/src/superlocalmemory/infra/version_integrity.py +229 -0
  44. package/src/superlocalmemory/learning/feedback.py +288 -29
  45. package/src/superlocalmemory/learning/legacy_migration.py +45 -4
  46. package/src/superlocalmemory/mcp/_daemon_proxy.py +23 -1
  47. package/src/superlocalmemory/mcp/tools_active.py +109 -58
  48. package/src/superlocalmemory/mcp/tools_core.py +6 -5
  49. package/src/superlocalmemory/retrieval/remote_reranker.py +636 -0
  50. package/src/superlocalmemory/server/unified_daemon.py +27 -0
@@ -0,0 +1,636 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SuperLocalMemory V3 — Remote (OpenAI-compatible) cross-encoder reranker.
6
+
7
+ v3.8.12 (issue #105). The mirror image of the remote EMBEDDING endpoint that
8
+ shipped in v3.4.24 (issue #16): when ``retrieval.cross_encoder_backend`` is
9
+ ``"openai"`` (or ``"remote"``) and ``retrieval.cross_encoder_endpoint`` is set,
10
+ reranking is an HTTP POST to that endpoint instead of a local subprocess.
11
+
12
+ WHY THIS EXISTS
13
+ The bundled cross-encoder, ``cross-encoder/ms-marco-MiniLM-L-12-v2``, is
14
+ English-only. A Chinese, Japanese, or Arabic corpus was being scored by a
15
+ model that cannot read it — a silent relevance regression with no error to
16
+ look at. Bringing your own multilingual reranker (bge-reranker-v2-m3, a
17
+ Qwen reranker, …) is the same escape hatch embeddings already had.
18
+
19
+ WHY IT LIVES IN THE PARENT PROCESS
20
+ ``CrossEncoderReranker`` spawns a subprocess to keep torch/ONNX out of the
21
+ parent. The remote path imports neither, so a subprocess would buy nothing
22
+ and cost a fork, a PID-file singleton, a warmup handshake, and the JSON
23
+ pipe. Issue #103's reporter hit exactly that: a machine-wide worker
24
+ singleton blocking a reranker that was never local to begin with. The
25
+ remote path never spawns, never touches the PID file, and never warms up a
26
+ model. This also mirrors the embedding side, where the OpenAI-compatible
27
+ call lives in ``core/embeddings.py`` (parent), not ``embedding_worker.py``.
28
+
29
+ WIRE PROTOCOL (Cohere-shaped ``/v1/rerank``; llama-server, TEI, Infinity, …)
30
+ Request : {"model": "...", "query": "...", "documents": ["...", ...]}
31
+ Response: {"results": [{"index": 0, "relevance_score": -5.94}, ...]}
32
+
33
+ Bare-list responses (``[{"index": 0, "score": 0.9}, ...]``) are accepted
34
+ too. Anything else is REJECTED with a precise error rather than coerced
35
+ into plausible-looking scores — issue #103 was a lesson in what silent
36
+ degradation costs.
37
+
38
+ FAILURE POLICY
39
+ An unreachable, slow, or malformed endpoint degrades to fusion-score
40
+ ordering and logs an error. It does NOT fall back to the local
41
+ cross-encoder: a user who configured a multilingual reranker asked for it
42
+ precisely because the local English model is wrong for their corpus, and
43
+ quietly substituting it would recreate the bug this feature fixes.
44
+
45
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import json
51
+ import logging
52
+ import math
53
+ import os
54
+ import threading
55
+ import time
56
+ from typing import Any
57
+ from urllib.parse import urlparse, urlunparse
58
+
59
+ from superlocalmemory.storage.models import AtomicFact
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # Backend tokens that select the remote path. "openai" is what issue #105
64
+ # asked for and matches ``embedding.provider == "openai"``, the established
65
+ # repo token for "any OpenAI-compatible HTTP endpoint". It is a slight misnomer
66
+ # — OpenAI has no rerank API and these endpoints are usually llama-server or
67
+ # TEI — so "remote" is accepted as a truthful alias.
68
+ REMOTE_CROSS_ENCODER_BACKENDS = ("openai", "remote")
69
+
70
+ # Environment override for the bearer token. Preferred over the config field:
71
+ # ``config.json`` is world-readable in many installs and is copied around.
72
+ CROSS_ENCODER_API_KEY_ENV = "SLM_CROSS_ENCODER_API_KEY"
73
+
74
+ _CONNECT_TIMEOUT_S = 5.0
75
+ _DEFAULT_READ_TIMEOUT_S = 15.0
76
+
77
+ # A rerank response is a small array of floats. Anything past this is either a
78
+ # misconfigured URL pointing at something that is not a reranker, or a hostile
79
+ # endpoint trying to exhaust memory. Bounded read, hard stop.
80
+ _MAX_RESPONSE_BYTES = 8 * 1024 * 1024
81
+
82
+ # Candidate pools are 50-200 in practice (semantic_top_k/bm25_top_k are 50).
83
+ # This cap only guards against a pathological pool inflating one HTTP body.
84
+ _MAX_DOCUMENTS = 512
85
+
86
+ # Only transport faults and 5xx are retried, and only once: recall is
87
+ # interactive, so a second failure must surface fast rather than pay a
88
+ # backoff sleep on the user's latency budget.
89
+ _MAX_ATTEMPTS = 2
90
+
91
+ # Consecutive failures re-log at most this often. The first failure always
92
+ # logs; the operator must never have to guess whether reranking is running.
93
+ _FAILURE_RELOG_INTERVAL_S = 60.0
94
+
95
+ _ERROR_BODY_SNIPPET_CHARS = 200
96
+
97
+
98
+ class RemoteRerankerError(RuntimeError):
99
+ """A remote rerank request failed (transport, status, or schema)."""
100
+
101
+
102
+ class RemoteRerankerConfigError(ValueError):
103
+ """The remote reranker configuration is unusable as written."""
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Configuration (pure functions — no I/O, directly testable)
108
+ # ---------------------------------------------------------------------------
109
+
110
+ def is_remote_cross_encoder_backend(backend: str) -> bool:
111
+ """True when ``backend`` selects the remote reranker."""
112
+ return (backend or "").strip().lower() in REMOTE_CROSS_ENCODER_BACKENDS
113
+
114
+
115
+ def validate_remote_reranker_config(backend: str, endpoint: str) -> str | None:
116
+ """Return an actionable error string, or None when the pair is coherent.
117
+
118
+ Covers the issue-#103 leftover directly: an endpoint configured against a
119
+ LOCAL backend used to be dropped on the floor by ``SLMConfig.load``. It now
120
+ produces a named error naming both keys and the exact edit to make.
121
+ """
122
+ backend = (backend or "").strip()
123
+ endpoint = (endpoint or "").strip()
124
+ remote = is_remote_cross_encoder_backend(backend)
125
+
126
+ if remote and not endpoint:
127
+ return (
128
+ f"retrieval.cross_encoder_backend={backend!r} selects the remote "
129
+ f"reranker but retrieval.cross_encoder_endpoint is empty. Set the "
130
+ f"endpoint (e.g. \"http://127.0.0.1:8041/v1/rerank\"), or set "
131
+ f"cross_encoder_backend to \"\" (PyTorch) / \"onnx\" to rerank "
132
+ f"locally."
133
+ )
134
+ if endpoint and not remote:
135
+ return (
136
+ f"retrieval.cross_encoder_endpoint is set to {endpoint!r} but "
137
+ f"retrieval.cross_encoder_backend={backend!r} is a LOCAL backend, "
138
+ f"so the endpoint would be ignored. Set cross_encoder_backend to "
139
+ f"\"openai\" to use the endpoint, or remove cross_encoder_endpoint "
140
+ f"to rerank locally."
141
+ )
142
+ if not remote:
143
+ return None
144
+ return _validate_endpoint_url(endpoint)
145
+
146
+
147
+ def _validate_endpoint_url(endpoint: str) -> str | None:
148
+ """Scheme/host allow-listing for the operator-supplied rerank URL."""
149
+ try:
150
+ parsed = urlparse(endpoint)
151
+ except ValueError as exc:
152
+ return f"retrieval.cross_encoder_endpoint is not a valid URL: {exc}"
153
+ if parsed.scheme not in ("http", "https"):
154
+ return (
155
+ f"retrieval.cross_encoder_endpoint must use http or https, got "
156
+ f"{parsed.scheme or '(none)'!r}. SuperLocalMemory will not open "
157
+ f"file/ftp/other schemes for reranking."
158
+ )
159
+ if not parsed.hostname:
160
+ return (
161
+ "retrieval.cross_encoder_endpoint has no host; expected something "
162
+ "like \"http://127.0.0.1:8041/v1/rerank\"."
163
+ )
164
+ if parsed.username or parsed.password:
165
+ # httpx logs "HTTP Request: POST <url>" at INFO using str(url), which
166
+ # renders an embedded password in full. This module never logs the raw
167
+ # URL, but it does not own the httpx logger — so credentials are
168
+ # refused at the door instead of being trusted to stay redacted.
169
+ return (
170
+ "retrieval.cross_encoder_endpoint must not embed credentials "
171
+ "(user:password@host) — the HTTP client logs request URLs in "
172
+ "full. Put the token in SLM_CROSS_ENCODER_API_KEY (preferred) or "
173
+ "retrieval.cross_encoder_api_key; it is sent as a Bearer header "
174
+ "and never logged."
175
+ )
176
+ return None
177
+
178
+
179
+ def normalize_rerank_endpoint(endpoint: str) -> str:
180
+ """Append ``/rerank`` when the URL stops at the API root.
181
+
182
+ Mirrors the embedding path's ``/embeddings`` suffixing so a user can paste
183
+ either ``http://host:8041/v1`` or ``http://host:8041/v1/rerank``.
184
+ """
185
+ url = (endpoint or "").strip().rstrip("/")
186
+ parsed = urlparse(url)
187
+ if parsed.path.endswith("/rerank"):
188
+ return url
189
+ return f"{url}/rerank"
190
+
191
+
192
+ def redact_endpoint(endpoint: str) -> str:
193
+ """Drop any ``user:password@`` userinfo before an endpoint reaches a log.
194
+
195
+ Defence in depth. ``_validate_endpoint_url`` already refuses credentialed
196
+ URLs, so this should never have anything to strip in a configured install
197
+ — it exists so that any future caller constructing a reranker directly
198
+ still cannot put a password in the log.
199
+ """
200
+ try:
201
+ parsed = urlparse(endpoint)
202
+ except ValueError:
203
+ return "<unparseable endpoint>"
204
+ if not parsed.hostname:
205
+ return endpoint
206
+ netloc = parsed.hostname
207
+ if parsed.port:
208
+ netloc = f"{netloc}:{parsed.port}"
209
+ if parsed.username or parsed.password:
210
+ netloc = f"***@{netloc}"
211
+ return urlunparse(parsed._replace(netloc=netloc))
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Response parsing (pure — the schema gate)
216
+ # ---------------------------------------------------------------------------
217
+
218
+ def parse_rerank_response(payload: Any, expected: int) -> list[float]:
219
+ """Validate a ``/v1/rerank`` payload and return scores in document order.
220
+
221
+ Raises ``RemoteRerankerError`` on ANY deviation. A rerank response that is
222
+ not understood must abort reranking, never yield partly-invented scores:
223
+ a wrong score silently reorders a user's memory, and nothing downstream
224
+ can tell that apart from a good one.
225
+ """
226
+ results = _extract_results_array(payload)
227
+ if len(results) != expected:
228
+ raise RemoteRerankerError(
229
+ f"rerank endpoint returned {len(results)} results for "
230
+ f"{expected} documents; refusing to guess the missing scores"
231
+ )
232
+
233
+ scores: list[float | None] = [None] * expected
234
+ for position, item in enumerate(results):
235
+ if not isinstance(item, dict):
236
+ raise RemoteRerankerError(
237
+ f"rerank result #{position} is {type(item).__name__}, "
238
+ f"expected an object with 'index' and 'relevance_score'"
239
+ )
240
+ index = _coerce_index(item, position, expected)
241
+ if scores[index] is not None:
242
+ raise RemoteRerankerError(
243
+ f"rerank endpoint returned index {index} more than once"
244
+ )
245
+ scores[index] = _coerce_score(item, index)
246
+
247
+ missing = [i for i, s in enumerate(scores) if s is None]
248
+ if missing:
249
+ raise RemoteRerankerError(
250
+ f"rerank endpoint returned no score for document index(es) "
251
+ f"{missing[:5]}{'…' if len(missing) > 5 else ''}"
252
+ )
253
+ return [float(s) for s in scores] # type: ignore[arg-type]
254
+
255
+
256
+ def _extract_results_array(payload: Any) -> list[Any]:
257
+ if isinstance(payload, list):
258
+ return payload # text-embeddings-inference style bare array
259
+ if not isinstance(payload, dict):
260
+ raise RemoteRerankerError(
261
+ f"rerank endpoint returned {type(payload).__name__}, expected a "
262
+ f"JSON object with a 'results' array"
263
+ )
264
+ results = payload.get("results")
265
+ if results is None:
266
+ raise RemoteRerankerError(
267
+ f"rerank response has no 'results' array (keys: "
268
+ f"{sorted(payload)[:8]}). Is cross_encoder_endpoint pointing at a "
269
+ f"rerank route and not, say, /v1/embeddings?"
270
+ )
271
+ if not isinstance(results, list):
272
+ raise RemoteRerankerError(
273
+ f"rerank response 'results' is {type(results).__name__}, "
274
+ f"expected an array"
275
+ )
276
+ return results
277
+
278
+
279
+ def _coerce_index(item: dict, position: int, expected: int) -> int:
280
+ raw = item.get("index", position)
281
+ if isinstance(raw, bool) or not isinstance(raw, int):
282
+ raise RemoteRerankerError(
283
+ f"rerank result #{position} has non-integer index {raw!r}"
284
+ )
285
+ if not 0 <= raw < expected:
286
+ raise RemoteRerankerError(
287
+ f"rerank result #{position} has out-of-range index {raw} "
288
+ f"(sent {expected} documents)"
289
+ )
290
+ return raw
291
+
292
+
293
+ def _coerce_score(item: dict, index: int) -> float:
294
+ for key in ("relevance_score", "score"):
295
+ if key in item:
296
+ raw = item[key]
297
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
298
+ raise RemoteRerankerError(
299
+ f"rerank result for index {index} has non-numeric "
300
+ f"{key}={raw!r}"
301
+ )
302
+ value = float(raw)
303
+ if not math.isfinite(value):
304
+ raise RemoteRerankerError(
305
+ f"rerank result for index {index} has non-finite "
306
+ f"{key}={raw!r}"
307
+ )
308
+ return value
309
+ raise RemoteRerankerError(
310
+ f"rerank result for index {index} has neither 'relevance_score' nor "
311
+ f"'score' (keys: {sorted(item)[:8]})"
312
+ )
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # The reranker
317
+ # ---------------------------------------------------------------------------
318
+
319
+ class RemoteReranker:
320
+ """Rerank candidates via an OpenAI-compatible ``/v1/rerank`` endpoint.
321
+
322
+ Public surface is interchangeable with ``CrossEncoderReranker`` so
323
+ ``RetrievalEngine`` never learns which one it holds.
324
+
325
+ Args:
326
+ model_name: Model identifier passed to the endpoint (llama-server
327
+ wants the served path, e.g. ``/root/model/reranker.gguf``).
328
+ endpoint: Base or full rerank URL. ``/rerank`` is appended when absent.
329
+ api_key: Optional bearer token. ``SLM_CROSS_ENCODER_API_KEY`` wins.
330
+ backend: The configured backend token, for validation + logs.
331
+ timeout_seconds: Per-request read budget.
332
+
333
+ Raises:
334
+ RemoteRerankerConfigError: the backend/endpoint pair is unusable.
335
+ """
336
+
337
+ def __init__(
338
+ self,
339
+ model_name: str,
340
+ endpoint: str,
341
+ *,
342
+ api_key: str = "",
343
+ backend: str = "openai",
344
+ timeout_seconds: float = _DEFAULT_READ_TIMEOUT_S,
345
+ ) -> None:
346
+ error = validate_remote_reranker_config(backend, endpoint)
347
+ if error:
348
+ raise RemoteRerankerConfigError(error)
349
+
350
+ self._model_name = model_name
351
+ self._backend = backend
352
+ self._endpoint = normalize_rerank_endpoint(endpoint)
353
+ self.safe_endpoint = redact_endpoint(self._endpoint)
354
+ self._api_key = os.environ.get(CROSS_ENCODER_API_KEY_ENV, "") or api_key
355
+ try:
356
+ self._read_timeout = max(1.0, float(timeout_seconds))
357
+ except (TypeError, ValueError):
358
+ self._read_timeout = _DEFAULT_READ_TIMEOUT_S
359
+
360
+ self._client: Any = None
361
+ self._client_lock = threading.Lock()
362
+ self._shutdown = threading.Event()
363
+ self._consecutive_failures = 0
364
+ self._last_failure_log = 0.0
365
+ self._probe_ok = False
366
+
367
+ # -- lifecycle ---------------------------------------------------------
368
+
369
+ def warmup_sync(self, timeout: float = _DEFAULT_READ_TIMEOUT_S) -> bool:
370
+ """Probe the endpoint once so startup states reachability out loud.
371
+
372
+ Diagnostic only: a failed probe never disables reranking, because an
373
+ endpoint that is still booting will serve the next real recall fine.
374
+ """
375
+ if self._shutdown.is_set():
376
+ return False
377
+ try:
378
+ self._request_scores("ping", ["SuperLocalMemory reranker probe"])
379
+ except RemoteRerankerError as exc:
380
+ self._probe_ok = False
381
+ logger.error(
382
+ "Remote reranker probe failed for %s (model=%s): %s. Recall "
383
+ "will run WITHOUT reranking until the endpoint answers. "
384
+ "Verify retrieval.cross_encoder_endpoint and that the service "
385
+ "is up.",
386
+ self.safe_endpoint, self._model_name, exc,
387
+ )
388
+ return False
389
+ self._probe_ok = True
390
+ logger.info(
391
+ "Remote reranker ready: %s (model=%s, backend=%s)",
392
+ self.safe_endpoint, self._model_name, self._backend,
393
+ )
394
+ return True
395
+
396
+ def unload(self) -> None:
397
+ """Release the pooled HTTP connections; the object stays usable."""
398
+ self._close_client()
399
+
400
+ def shutdown(self, timeout: float = 3.0) -> None: # noqa: ARG002 - parity
401
+ """Stop serving and close the HTTP client."""
402
+ self._shutdown.set()
403
+ self._close_client()
404
+
405
+ def __del__(self) -> None:
406
+ try:
407
+ self._close_client()
408
+ except Exception:
409
+ pass
410
+
411
+ @property
412
+ def is_available(self) -> bool:
413
+ """Whether the endpoint answers a probe right now."""
414
+ if self._shutdown.is_set():
415
+ return False
416
+ try:
417
+ self._request_scores("ping", ["SuperLocalMemory reranker probe"])
418
+ except RemoteRerankerError:
419
+ return False
420
+ return True
421
+
422
+ # -- public reranking --------------------------------------------------
423
+
424
+ def rerank(
425
+ self,
426
+ query: str,
427
+ candidates: list[tuple[AtomicFact, float]],
428
+ top_k: int = 10,
429
+ ) -> list[tuple[AtomicFact, float]]:
430
+ """Rerank ``candidates``; fusion order is returned when the endpoint fails."""
431
+ results, _, _ = self.rerank_with_status(query, candidates, top_k=top_k)
432
+ return results
433
+
434
+ def rerank_with_status(
435
+ self,
436
+ query: str,
437
+ candidates: list[tuple[AtomicFact, float]],
438
+ top_k: int = 10,
439
+ ) -> tuple[list[tuple[AtomicFact, float]], bool, str]:
440
+ """Return results plus whether remote reranking actually ran."""
441
+ if not candidates:
442
+ return [], False, "no_candidates"
443
+ if self._shutdown.is_set():
444
+ return self._fusion_order(candidates)[:top_k], False, "shutdown"
445
+
446
+ ranked = self._fusion_order(candidates)
447
+ if len(ranked) > _MAX_DOCUMENTS:
448
+ # Unreachable with stock config (semantic_top_k/bm25_top_k are 50).
449
+ # RetrievalEngine keeps every fused result and assigns the batch
450
+ # minimum to any fact absent from the rerank map, so the excluded
451
+ # tail — already the lowest-fusion candidates — is demoted, not
452
+ # lost.
453
+ logger.warning(
454
+ "Remote reranker: %d candidates exceeds the %d-document "
455
+ "request cap; reranking the top %d by fusion score and "
456
+ "dropping the rest",
457
+ len(ranked), _MAX_DOCUMENTS, _MAX_DOCUMENTS,
458
+ )
459
+ ranked = ranked[:_MAX_DOCUMENTS]
460
+
461
+ try:
462
+ scores = self._request_scores(
463
+ query, [fact.content for fact, _ in ranked],
464
+ )
465
+ except RemoteRerankerError as exc:
466
+ self._note_failure(exc)
467
+ return ranked[:top_k], False, "remote_unavailable"
468
+
469
+ self._note_success()
470
+ scored = [
471
+ (fact, float(score))
472
+ for (fact, _), score in zip(ranked, scores)
473
+ ]
474
+ scored.sort(key=lambda pair: pair[1], reverse=True)
475
+ return scored[:top_k], True, "applied"
476
+
477
+ def score_pair(self, query: str, document: str) -> float:
478
+ """Score one (query, document) pair; 0.0 when the endpoint fails."""
479
+ if self._shutdown.is_set():
480
+ return 0.0
481
+ try:
482
+ return self._request_scores(query, [document])[0]
483
+ except RemoteRerankerError as exc:
484
+ self._note_failure(exc)
485
+ return 0.0
486
+
487
+ # -- HTTP --------------------------------------------------------------
488
+
489
+ def _request_scores(self, query: str, documents: list[str]) -> list[float]:
490
+ """POST one rerank request, retrying only genuinely transient faults."""
491
+ headers = {"Content-Type": "application/json"}
492
+ if self._api_key:
493
+ # Never logged: no error path in this module formats `headers`.
494
+ headers["Authorization"] = f"Bearer {self._api_key}"
495
+ body = {
496
+ "model": self._model_name,
497
+ "query": query,
498
+ "documents": documents,
499
+ }
500
+
501
+ last_error: RemoteRerankerError | None = None
502
+ for attempt in range(_MAX_ATTEMPTS):
503
+ try:
504
+ payload = self._post(headers, body)
505
+ except _RetryableRemoteError as exc:
506
+ last_error = RemoteRerankerError(str(exc))
507
+ if attempt < _MAX_ATTEMPTS - 1:
508
+ continue
509
+ break
510
+ return parse_rerank_response(payload, len(documents))
511
+ raise RemoteRerankerError(
512
+ f"remote reranker at {self.safe_endpoint} failed after "
513
+ f"{_MAX_ATTEMPTS} attempts: {last_error}"
514
+ )
515
+
516
+ def _post(self, headers: dict[str, str], body: dict[str, Any]) -> Any:
517
+ """Send the request and return parsed JSON, with a bounded body read."""
518
+ import httpx
519
+
520
+ client = self._get_client()
521
+ try:
522
+ with client.stream(
523
+ "POST", self._endpoint, headers=headers, json=body,
524
+ ) as resp:
525
+ raw = _read_bounded(resp)
526
+ if 300 <= resp.status_code < 400:
527
+ # Redirects are not followed: a rerank endpoint that
528
+ # bounces us elsewhere is either misconfigured or an
529
+ # attempt to pivot this outbound request at a host the
530
+ # operator never approved.
531
+ raise RemoteRerankerError(
532
+ f"rerank endpoint {self.safe_endpoint} replied HTTP "
533
+ f"{resp.status_code} (redirect). Redirects are not "
534
+ f"followed — configure the final URL directly."
535
+ )
536
+ if resp.status_code >= 400:
537
+ snippet = raw[:_ERROR_BODY_SNIPPET_CHARS].decode(
538
+ "utf-8", "replace",
539
+ )
540
+ message = (
541
+ f"HTTP {resp.status_code} from {self.safe_endpoint}: "
542
+ f"{snippet}"
543
+ )
544
+ if resp.status_code >= 500:
545
+ raise _RetryableRemoteError(message)
546
+ raise RemoteRerankerError(message)
547
+ except httpx.TransportError as exc:
548
+ raise _RetryableRemoteError(
549
+ f"cannot reach {self.safe_endpoint}: "
550
+ f"{type(exc).__name__}: {exc}"
551
+ ) from exc
552
+ try:
553
+ return json.loads(raw)
554
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
555
+ raise RemoteRerankerError(
556
+ f"rerank endpoint {self.safe_endpoint} returned non-JSON "
557
+ f"({exc})"
558
+ ) from exc
559
+
560
+ def _get_client(self) -> Any:
561
+ import httpx
562
+
563
+ with self._client_lock:
564
+ if self._client is None:
565
+ self._client = httpx.Client(
566
+ timeout=httpx.Timeout(
567
+ connect=_CONNECT_TIMEOUT_S,
568
+ read=self._read_timeout,
569
+ write=10.0,
570
+ pool=5.0,
571
+ ),
572
+ follow_redirects=False,
573
+ )
574
+ return self._client
575
+
576
+ def _close_client(self) -> None:
577
+ with self._client_lock:
578
+ client, self._client = self._client, None
579
+ if client is not None:
580
+ try:
581
+ client.close()
582
+ except Exception:
583
+ pass
584
+
585
+ # -- failure visibility ------------------------------------------------
586
+
587
+ def _note_failure(self, exc: Exception) -> None:
588
+ """Make a degraded reranker impossible to miss, without log flooding."""
589
+ self._consecutive_failures += 1
590
+ now = time.time()
591
+ if (
592
+ self._consecutive_failures == 1
593
+ or now - self._last_failure_log >= _FAILURE_RELOG_INTERVAL_S
594
+ ):
595
+ logger.error(
596
+ "Remote reranker unavailable (%d consecutive failures): %s. "
597
+ "Recall is returning fusion-ranked results with NO reranking. "
598
+ "SuperLocalMemory will not silently substitute the local "
599
+ "English cross-encoder for your configured model.",
600
+ self._consecutive_failures, exc,
601
+ )
602
+ self._last_failure_log = now
603
+
604
+ def _note_success(self) -> None:
605
+ if self._consecutive_failures:
606
+ logger.info(
607
+ "Remote reranker recovered after %d consecutive failures (%s)",
608
+ self._consecutive_failures, self.safe_endpoint,
609
+ )
610
+ self._consecutive_failures = 0
611
+
612
+ @staticmethod
613
+ def _fusion_order(
614
+ candidates: list[tuple[AtomicFact, float]],
615
+ ) -> list[tuple[AtomicFact, float]]:
616
+ return sorted(candidates, key=lambda pair: pair[1], reverse=True)
617
+
618
+
619
+ class _RetryableRemoteError(RemoteRerankerError):
620
+ """Internal marker: this failure is worth exactly one more attempt."""
621
+
622
+
623
+ def _read_bounded(resp: Any) -> bytes:
624
+ """Read a streaming response body, refusing to buffer past the cap."""
625
+ chunks: list[bytes] = []
626
+ total = 0
627
+ for chunk in resp.iter_bytes():
628
+ total += len(chunk)
629
+ if total > _MAX_RESPONSE_BYTES:
630
+ raise RemoteRerankerError(
631
+ f"rerank response exceeded {_MAX_RESPONSE_BYTES} bytes; "
632
+ f"aborting the read. Is cross_encoder_endpoint pointing at a "
633
+ f"rerank route?"
634
+ )
635
+ chunks.append(chunk)
636
+ return b"".join(chunks)
@@ -3272,6 +3272,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
3272
3272
  "runtime_state": runtime_state,
3273
3273
  "active_profile": profile_snapshot.profile_id,
3274
3274
  "profile_generation": profile_snapshot.generation,
3275
+ # issue #107: does this daemon's *imported* code still match the
3276
+ # installed distribution? ``version`` above reports what this
3277
+ # process loaded, which is self-consistent and therefore cannot
3278
+ # reveal staleness on its own. Loopback-only, alongside the other
3279
+ # operational metadata.
3280
+ "version_integrity": _version_integrity_payload(),
3275
3281
  }
3276
3282
 
3277
3283
  @application.get("/recall")
@@ -4016,6 +4022,27 @@ class _PendingProfileMismatchError(RuntimeError):
4016
4022
  """A legacy pending row no longer matches the admitted profile lease."""
4017
4023
 
4018
4024
 
4025
+ def _version_integrity_payload() -> dict:
4026
+ """Report whether this daemon's imported code matches what is installed.
4027
+
4028
+ Issue #107. ``/health``'s ``version`` field reports the version this
4029
+ process loaded at import, so a stale daemon reports its *own* stale version
4030
+ perfectly happily -- self-consistent and useless as a staleness signal.
4031
+ This compares that against the distribution metadata on disk.
4032
+
4033
+ Fail-open by construction: any error degrades to a ``state`` of
4034
+ ``"unknown"`` rather than raising, because ``/health`` is what clients poll
4035
+ to decide whether the daemon is usable and must not start returning 500s
4036
+ over a diagnostic.
4037
+ """
4038
+ try:
4039
+ from superlocalmemory.infra.version_integrity import check_version_integrity
4040
+
4041
+ return check_version_integrity().as_dict()
4042
+ except Exception as exc: # noqa: BLE001 - health must never fail on this
4043
+ return {"state": "unknown", "detail": f"version check failed: {exc}"}
4044
+
4045
+
4019
4046
  def _materializer_actor_id() -> str:
4020
4047
  """Return the process-owned actor identity used by background writes."""
4021
4048
  descriptor = _ACTIVE_DAEMON_DESCRIPTOR