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
@@ -41,6 +41,10 @@ from superlocalmemory.infra.data_root import (
41
41
  assert_no_durable_root_conflict,
42
42
  state_path,
43
43
  )
44
+ from superlocalmemory.infra.process_identity import (
45
+ compare_start_tokens,
46
+ process_start_token_for,
47
+ )
44
48
 
45
49
  logger = logging.getLogger(__name__)
46
50
 
@@ -72,25 +76,91 @@ def _is_pid_alive(pid: int) -> bool:
72
76
  return False
73
77
 
74
78
 
75
- def _descriptor_process_is_alive(descriptor) -> bool:
76
- """Reject stale descriptors when a PID has been reused by another process."""
77
- if not _is_pid_alive(descriptor.pid):
79
+ _CREATE_TIME_TOLERANCE_SECONDS = 1.0
80
+
81
+
82
+ def _health_proves_descriptor_ownership(descriptor) -> bool:
83
+ """Return whether the live health endpoint proves this exact daemon.
84
+
85
+ This is a *stronger* ownership proof than any process-table comparison. To
86
+ pass, a process listening on the descriptor's port must echo the random
87
+ 128-bit ``instance_id`` and the SHA-256 fingerprint of the 256-bit
88
+ capability token -- both of which exist only inside the mode-0600
89
+ ``daemon.json`` -- alongside its own PID, namespace, owner and port. A
90
+ process that merely inherited a recycled PID cannot produce any of that.
91
+ """
92
+ health = _fetch_health(descriptor.port)
93
+ if health is None:
78
94
  return False
95
+ return descriptor_matches_health(descriptor, health)
96
+
97
+
98
+ def _resolve_descriptor_liveness(descriptor) -> tuple[bool, str]:
99
+ """Return ``(is_alive, evidence)`` for the descriptor's recorded process.
100
+
101
+ Ownership is decided by the strongest available evidence, never by the
102
+ wall clock alone:
103
+
104
+ 1. The PID must exist and must not be a zombie.
105
+ 2. A clock-independent start token settles it exactly, with no tolerance.
106
+ This is the path that fixes issue #104: under WSL2 the boot time behind
107
+ ``psutil.create_time`` drifts against the wall clock during a session,
108
+ so a recorded creation time stops matching the *same* live process
109
+ (~35s after ~4 minutes). A start token cannot drift, so no tolerance
110
+ constant is needed and none can silently expire.
111
+ 3. Otherwise fall back to comparing creation times, for descriptors written
112
+ by an older release and for platforms with no token (Windows, where the
113
+ kernel creation time is already immune to clock adjustment).
114
+ 4. A creation-time mismatch is *not* proof of PID reuse -- it is exactly
115
+ what a stepped clock looks like -- so before condemning a running
116
+ daemon, ask the daemon to prove its identity over loopback. Only if that
117
+ cryptographic proof also fails is the process declared foreign.
118
+ """
119
+ if not _is_pid_alive(descriptor.pid):
120
+ return False, "process_exited"
79
121
  try:
80
122
  import psutil
81
-
123
+ except ImportError:
124
+ # Without psutil, PID existence is the only signal there is.
125
+ return True, "pid_exists_without_psutil"
126
+ try:
82
127
  process = psutil.Process(descriptor.pid)
83
128
  # A terminated daemon can remain in the process table briefly as a
84
129
  # zombie while its parent reaps it. PID existence is therefore not
85
130
  # liveness and must not block a namespace-owned restart.
86
131
  if not process.is_running() or process.status() == psutil.STATUS_ZOMBIE:
87
- return False
88
- actual = float(process.create_time())
89
- except ImportError:
90
- return True
132
+ return False, "process_zombie"
133
+ actual_create_time = float(process.create_time())
91
134
  except Exception:
92
- return False
93
- return abs(actual - float(descriptor.process_create_time)) <= 1.0
135
+ return False, "process_unreadable"
136
+
137
+ recorded_token = getattr(descriptor, "process_start_token", None)
138
+ if recorded_token:
139
+ verdict = compare_start_tokens(
140
+ recorded_token, process_start_token_for(descriptor.pid),
141
+ )
142
+ if verdict is True:
143
+ return True, "start_token_match"
144
+ if verdict is False:
145
+ return False, "start_token_mismatch"
146
+
147
+ drift = abs(actual_create_time - float(descriptor.process_create_time))
148
+ if drift <= _CREATE_TIME_TOLERANCE_SECONDS:
149
+ return True, "create_time_match"
150
+
151
+ if _health_proves_descriptor_ownership(descriptor):
152
+ logger.debug(
153
+ "descriptor creation time drifted by %.3fs for pid %s; owned "
154
+ "daemon confirmed by health identity instead",
155
+ drift, descriptor.pid,
156
+ )
157
+ return True, "health_identity_match"
158
+ return False, "identity_mismatch"
159
+
160
+
161
+ def _descriptor_process_is_alive(descriptor) -> bool:
162
+ """Reject stale descriptors when a PID has been reused by another process."""
163
+ return _resolve_descriptor_liveness(descriptor)[0]
94
164
 
95
165
 
96
166
  def _is_port_available(port: int) -> bool:
@@ -420,6 +490,7 @@ def _start_daemon_subprocess() -> bool:
420
490
  bootstrap_descriptor,
421
491
  pid=proc.pid,
422
492
  process_create_time=process_create_time_for(proc.pid),
493
+ process_start_token=process_start_token_for(proc.pid),
423
494
  )
424
495
  current = read_descriptor()
425
496
  if not (
@@ -545,6 +616,144 @@ def _wait_for_daemon(timeout: int = 60) -> bool:
545
616
  return False
546
617
 
547
618
 
619
+ _GENERIC_UNAVAILABLE = {
620
+ "reason": "unknown",
621
+ "message": "Owned daemon is unavailable; retry later.",
622
+ "hint": "Run `slm doctor`, then `slm restart` if it stays down.",
623
+ }
624
+
625
+ _LIVENESS_DIAGNOSIS = {
626
+ "process_exited": (
627
+ "daemon_process_exited",
628
+ "the recorded daemon process (pid {pid}) is no longer running",
629
+ "Start it again with `slm start`.",
630
+ ),
631
+ "process_zombie": (
632
+ "daemon_process_exited",
633
+ "the recorded daemon process (pid {pid}) has exited and is awaiting "
634
+ "reaping by its parent",
635
+ "Start it again with `slm start`.",
636
+ ),
637
+ "process_unreadable": (
638
+ "daemon_process_unreadable",
639
+ "the recorded daemon process (pid {pid}) could not be inspected; it "
640
+ "may belong to another user",
641
+ "Run `slm restart` to publish a fresh descriptor.",
642
+ ),
643
+ "start_token_mismatch": (
644
+ "pid_reused_by_another_process",
645
+ "pid {pid} is alive but is a different process than the daemon that "
646
+ "wrote {path}; the daemon exited and its pid was recycled",
647
+ "Run `slm restart` to publish a fresh descriptor.",
648
+ ),
649
+ "identity_mismatch": (
650
+ "daemon_identity_mismatch",
651
+ "pid {pid} did not match the process identity recorded in {path} and "
652
+ "the process on port {port} did not prove it owns that identity; the "
653
+ "recorded creation time can also diverge on its own if this machine's "
654
+ "clock is stepped (common under WSL2)",
655
+ "Run `slm restart` to publish a fresh descriptor.",
656
+ ),
657
+ }
658
+
659
+
660
+ def describe_daemon_unavailability() -> dict[str, str]:
661
+ """Explain *why* the owned daemon cannot be used, in actionable terms.
662
+
663
+ "Owned daemon is unavailable" is true of a stopped daemon, a recycled PID,
664
+ an unreachable port and an identity mismatch alike, which left issue #104's
665
+ reporter with nothing to act on. This names the specific evidence instead.
666
+ Diagnosis is best-effort and never raises: a broken diagnosis must not
667
+ replace the caller's real error.
668
+ """
669
+ try:
670
+ return _describe_daemon_unavailability()
671
+ except Exception: # noqa: BLE001 - diagnosis is advisory only
672
+ return dict(_GENERIC_UNAVAILABLE)
673
+
674
+
675
+ def _describe_daemon_unavailability() -> dict[str, str]:
676
+ path = descriptor_path()
677
+ descriptor = read_descriptor()
678
+ if descriptor is None:
679
+ if path.exists():
680
+ return {
681
+ "reason": "descriptor_unusable",
682
+ "message": (
683
+ f"{path} is unreadable, malformed, or belongs to another "
684
+ f"data root or user."
685
+ ),
686
+ "hint": "Run `slm restart` to publish a fresh descriptor.",
687
+ }
688
+ if _verified_legacy_health() is not None:
689
+ return {
690
+ "reason": "legacy_daemon_request_failed",
691
+ "message": (
692
+ "a pre-descriptor daemon answered health but rejected or "
693
+ "dropped the request."
694
+ ),
695
+ "hint": "Run `slm restart` to upgrade it to an owned daemon.",
696
+ }
697
+ return {
698
+ "reason": "no_daemon",
699
+ "message": f"no daemon is registered for this data root ({path} is absent).",
700
+ "hint": "Run `slm start`.",
701
+ }
702
+
703
+ alive, evidence = _resolve_descriptor_liveness(descriptor)
704
+ if not alive:
705
+ reason, template, hint = _LIVENESS_DIAGNOSIS.get(
706
+ evidence,
707
+ (
708
+ "daemon_identity_mismatch",
709
+ "pid {pid} did not match the identity recorded in {path}",
710
+ "Run `slm restart` to publish a fresh descriptor.",
711
+ ),
712
+ )
713
+ return {
714
+ "reason": reason,
715
+ "message": template.format(
716
+ pid=descriptor.pid, port=descriptor.port, path=path,
717
+ ) + ".",
718
+ "hint": hint,
719
+ }
720
+
721
+ health = _fetch_health(descriptor.port)
722
+ if health is None:
723
+ return {
724
+ "reason": "daemon_unreachable",
725
+ "message": (
726
+ f"the owned daemon (pid {descriptor.pid}) is running but did "
727
+ f"not answer http://127.0.0.1:{descriptor.port}/health within "
728
+ f"2s."
729
+ ),
730
+ "hint": (
731
+ "Check `slm logs` for a stalled request, or `slm restart` if "
732
+ "it stays unresponsive."
733
+ ),
734
+ }
735
+ if not descriptor_matches_health(descriptor, health):
736
+ return {
737
+ "reason": "port_owned_by_another_daemon",
738
+ "message": (
739
+ f"port {descriptor.port} answered health but with a different "
740
+ f"daemon identity than {path} records."
741
+ ),
742
+ "hint": (
743
+ "Another SuperLocalMemory instance holds that port. Stop it, "
744
+ "or set SLM_DAEMON_PORT to a free port."
745
+ ),
746
+ }
747
+ return {
748
+ "reason": "request_rejected",
749
+ "message": (
750
+ f"the owned daemon (pid {descriptor.pid}) is healthy but rejected "
751
+ f"or dropped this request."
752
+ ),
753
+ "hint": "Check `slm logs` for the failing request.",
754
+ }
755
+
756
+
548
757
  def stop_daemon() -> bool:
549
758
  """Stop only the daemon proven to belong to this data namespace.
550
759
 
@@ -274,6 +274,35 @@ def _embedding_is_remote(config: Any) -> bool:
274
274
  return provider in ("openai", "openai-compatible", "remote") or bool(endpoint)
275
275
 
276
276
 
277
+ def _reranker_is_remote(config: Any) -> bool:
278
+ """True when reranking runs against a remote endpoint (v3.8.12, #105).
279
+
280
+ In that case the local ~130MB English cross-encoder download is pointless
281
+ — and worse, misleading, since it is not the model that will score recall.
282
+ Unlike embeddings, the endpoint alone is NOT sufficient: a stray endpoint
283
+ against a local backend is a misconfiguration the engine reports, not a
284
+ remote setup.
285
+ """
286
+ rt = getattr(config, "retrieval", None)
287
+ if rt is None:
288
+ return False
289
+ backend = (getattr(rt, "cross_encoder_backend", "") or "").strip().lower()
290
+ endpoint = getattr(rt, "cross_encoder_endpoint", "") or ""
291
+ return backend in ("openai", "remote") and bool(endpoint)
292
+
293
+
294
+ # Remote-reranker keys the wizard never asks about. ``retrieval`` is otherwise
295
+ # a mode-owned block that the wizard resets from the template, which would
296
+ # silently delete a working remote endpoint on every re-run (#105).
297
+ _USER_OWNED_RETRIEVAL_KEYS = (
298
+ "cross_encoder_backend",
299
+ "cross_encoder_endpoint",
300
+ "cross_encoder_api_key",
301
+ "cross_encoder_model",
302
+ "cross_encoder_timeout_seconds",
303
+ )
304
+
305
+
277
306
  def _build_wizard_config(mode):
278
307
  """Apply mode-owned presets without erasing existing user-owned blocks."""
279
308
  from superlocalmemory.core.config import SLMConfig
@@ -285,7 +314,14 @@ def _build_wizard_config(mode):
285
314
  existing = SLMConfig.load()
286
315
  existing.mode = mode
287
316
  existing.llm = template.llm
317
+ preserved = {
318
+ key: getattr(existing.retrieval, key)
319
+ for key in _USER_OWNED_RETRIEVAL_KEYS
320
+ if hasattr(existing.retrieval, key)
321
+ }
288
322
  existing.retrieval = template.retrieval
323
+ for key, value in preserved.items():
324
+ setattr(existing.retrieval, key, value)
289
325
  existing.math = template.math
290
326
  existing.channel_weights = template.channel_weights
291
327
  return existing
@@ -566,7 +602,15 @@ def run_wizard(auto: bool = False) -> None:
566
602
  print()
567
603
  print("─── Step 4b/10: Download Reranker Model ───")
568
604
 
569
- if not st_ok:
605
+ if _reranker_is_remote(config):
606
+ # v3.8.12 (#105): a remote /v1/rerank endpoint scores the results, so
607
+ # the local English cross-encoder would be dead weight on disk.
608
+ rt = config.retrieval
609
+ print(" ✓ Skipped — remote rerank endpoint configured")
610
+ print(f" backend={getattr(rt, 'cross_encoder_backend', '?')}, "
611
+ f"model={getattr(rt, 'cross_encoder_model', '?')}")
612
+ print(" No local reranker model needed.")
613
+ elif not st_ok:
570
614
  print(" ⚠ Skipped (sentence-transformers not installed)")
571
615
  else:
572
616
  _download_reranker(_RERANKER_MODEL)
@@ -164,6 +164,20 @@ def _embedding_is_remote(config: Any) -> bool:
164
164
  return False
165
165
 
166
166
 
167
+ def _reranker_is_remote(config: Any) -> bool:
168
+ """True when reranking is served by a remote endpoint (v3.8.12, #105).
169
+
170
+ Same shape as ``_embedding_is_remote``: conservative False on any
171
+ import/attribute error so a local reranker is still probed.
172
+ """
173
+ try:
174
+ from superlocalmemory.cli.setup_wizard import _reranker_is_remote as _r
175
+
176
+ return bool(_r(config))
177
+ except Exception:
178
+ return False
179
+
180
+
167
181
  # --------------------------------------------------------------------------
168
182
  # Individual probes — each returns a Component (never raises)
169
183
  # --------------------------------------------------------------------------
@@ -259,6 +273,17 @@ def probe_reranker_model(config: Any = None) -> Component:
259
273
  if config is not None else True
260
274
  except Exception:
261
275
  enabled = True
276
+ if enabled and config is not None and _reranker_is_remote(config):
277
+ # v3.8.12 (#105): a remote /v1/rerank endpoint supplies the scores, so
278
+ # the local 130MB English cross-encoder is neither downloaded nor used.
279
+ # Reporting it MISSING would push the operator to "fix" a component the
280
+ # configured runtime never touches.
281
+ return Component(
282
+ key="reranker_model", label="Reranker model",
283
+ category=CATEGORY_RECOMMENDED, status=STATUS_OK,
284
+ detail="remote rerank endpoint (no local model required)",
285
+ last_checked=time.time(),
286
+ )
262
287
  comp = _probe_hf_model(
263
288
  "reranker_model", "Reranker model", _RERANKER_MODEL,
264
289
  category=CATEGORY_RECOMMENDED if enabled else CATEGORY_OPTIONAL,
@@ -267,7 +267,41 @@ class RetrievalConfig:
267
267
  # relevant facts before reranking. See bench-v342-locomo.md.
268
268
  use_cross_encoder: bool = True
269
269
  cross_encoder_model: str = "cross-encoder/ms-marco-MiniLM-L-12-v2"
270
- cross_encoder_backend: str = "" # "" = PyTorch (~500MB stable), "onnx" = ONNX (leaks on ARM64 CoreML)
270
+ # "" = PyTorch (~500MB stable), "onnx" = ONNX (leaks on ARM64 CoreML),
271
+ # "openai"/"remote" = v3.8.12 (issue #105) OpenAI-compatible /v1/rerank
272
+ # endpoint (llama-server, TEI, Infinity, vLLM, Cohere-shaped services).
273
+ cross_encoder_backend: str = ""
274
+
275
+ # v3.8.12 (issue #105): remote reranker endpoint. The bundled default
276
+ # cross-encoder (ms-marco-MiniLM-L-12-v2) is ENGLISH-ONLY, so non-English
277
+ # deployments scored their own language with a model that cannot read it.
278
+ # Pointing this at a multilingual reranker (bge-reranker-v2-m3, a Qwen
279
+ # reranker, …) is the same escape hatch remote embeddings got in v3.4.24.
280
+ #
281
+ # This key was accepted-and-ignored before 3.8.12 (issue #103): it was not
282
+ # a dataclass field, so ``SLMConfig.load`` filtered it out without a word.
283
+ # It is now read, validated, and — when it disagrees with the backend —
284
+ # reported as a loud configuration error instead of nothing at all.
285
+ cross_encoder_endpoint: str = ""
286
+ # Optional bearer token. Prefer the SLM_CROSS_ENCODER_API_KEY environment
287
+ # variable — it takes precedence and keeps the secret out of config.json.
288
+ cross_encoder_api_key: str = ""
289
+ # Per-request read budget for the remote reranker. Recall is interactive,
290
+ # so this stays tight: a slow reranker degrades to fusion scores rather
291
+ # than holding the recall open.
292
+ cross_encoder_timeout_seconds: float = 15.0
293
+
294
+ @property
295
+ def is_remote_cross_encoder(self) -> bool:
296
+ """True when reranking is served by a remote HTTP endpoint."""
297
+ from superlocalmemory.retrieval.remote_reranker import (
298
+ is_remote_cross_encoder_backend,
299
+ )
300
+
301
+ return (
302
+ is_remote_cross_encoder_backend(self.cross_encoder_backend)
303
+ and bool(self.cross_encoder_endpoint)
304
+ )
271
305
 
272
306
  # Agentic (Mode C only)
273
307
  agentic_max_rounds: int = 3
@@ -22,8 +22,88 @@ if TYPE_CHECKING:
22
22
  logger = logging.getLogger(__name__)
23
23
 
24
24
 
25
+ def init_reranker(retrieval_config: Any) -> Any:
26
+ """Build the reranker the config asks for — remote endpoint or local worker.
27
+
28
+ v3.8.12 (issue #105). The remote branch is decided HERE, in the parent
29
+ process, before any subprocess exists. Spawning a worker whose only job
30
+ would be to forward an HTTP POST costs a fork, a machine-wide PID
31
+ singleton, and a warmup handshake for nothing — and issue #103 showed that
32
+ singleton blocking a reranker that was never local in the first place.
33
+
34
+ Misconfiguration is reported, never absorbed:
35
+ * remote backend with no/invalid endpoint -> reranking is DISABLED with
36
+ an error naming the fix. Quietly loading the English local model
37
+ instead would recreate the exact silent degradation #105 is about.
38
+ * endpoint set against a LOCAL backend -> error naming both keys, then
39
+ the local reranker runs as the backend actually requested. Before
40
+ 3.8.12 this combination was dropped in silence (issue #103).
41
+
42
+ Returns the reranker, or None when reranking must stay off.
43
+ """
44
+ from superlocalmemory.retrieval.remote_reranker import (
45
+ RemoteReranker,
46
+ RemoteRerankerConfigError,
47
+ is_remote_cross_encoder_backend,
48
+ validate_remote_reranker_config,
49
+ )
50
+ from superlocalmemory.retrieval.reranker import CrossEncoderReranker
51
+
52
+ backend = getattr(retrieval_config, "cross_encoder_backend", "") or ""
53
+ endpoint = getattr(retrieval_config, "cross_encoder_endpoint", "") or ""
54
+ model = getattr(
55
+ retrieval_config, "cross_encoder_model",
56
+ "cross-encoder/ms-marco-MiniLM-L-12-v2",
57
+ )
58
+ remote_requested = is_remote_cross_encoder_backend(backend)
59
+
60
+ error = validate_remote_reranker_config(backend, endpoint)
61
+ if error and remote_requested:
62
+ logger.error(
63
+ "Remote reranker not started — %s Reranking is DISABLED; recall "
64
+ "returns fusion-ranked results.", error,
65
+ )
66
+ return None
67
+ if error:
68
+ logger.error(
69
+ "Reranker configuration conflict — %s Continuing with the local "
70
+ "cross-encoder as configured.", error,
71
+ )
72
+ elif remote_requested:
73
+ try:
74
+ return RemoteReranker(
75
+ model,
76
+ endpoint,
77
+ api_key=getattr(retrieval_config, "cross_encoder_api_key", ""),
78
+ backend=backend,
79
+ timeout_seconds=getattr(
80
+ retrieval_config, "cross_encoder_timeout_seconds", 15.0,
81
+ ),
82
+ )
83
+ except RemoteRerankerConfigError as exc:
84
+ logger.error(
85
+ "Remote reranker not started — %s Reranking is DISABLED.", exc,
86
+ )
87
+ return None
88
+
89
+ return CrossEncoderReranker(model, backend=backend)
90
+
91
+
25
92
  def _log_reranker_warmup_status(reranker: Any) -> None:
26
93
  """Record non-blocking reranker warmup state without alarming first-run users."""
94
+ from superlocalmemory.retrieval.remote_reranker import RemoteReranker
95
+
96
+ # isinstance, not a duck-typed attribute probe: MagicMock fabricates any
97
+ # attribute on demand, so ``getattr(reranker, "is_remote", False)`` would
98
+ # route every mocked reranker in the suite down the remote branch. Same
99
+ # hazard RetrievalEngine guards against when it checks the TYPE for
100
+ # ``rerank_with_status``.
101
+ if isinstance(reranker, RemoteReranker):
102
+ # The remote reranker logs its own probe outcome (endpoint, model, and
103
+ # the precise transport error). A second generic line about a local
104
+ # worker singleton would be noise at best and misleading at worst.
105
+ reranker.warmup_sync()
106
+ return
27
107
  ready = reranker.warmup_sync(timeout=180)
28
108
  if ready:
29
109
  logger.info("Cross-encoder reranker warm and ready")
@@ -508,7 +588,6 @@ def init_retrieval(
508
588
  from superlocalmemory.retrieval.bm25_channel import BM25Channel
509
589
  from superlocalmemory.retrieval.entity_channel import EntityGraphChannel
510
590
  from superlocalmemory.retrieval.temporal_channel import TemporalChannel
511
- from superlocalmemory.retrieval.reranker import CrossEncoderReranker
512
591
  from superlocalmemory.retrieval.profile_channel import ProfileChannel
513
592
  from superlocalmemory.retrieval.bridge_discovery import BridgeDiscovery
514
593
 
@@ -541,10 +620,7 @@ def init_retrieval(
541
620
 
542
621
  reranker = None
543
622
  if config.retrieval.use_cross_encoder:
544
- reranker = CrossEncoderReranker(
545
- config.retrieval.cross_encoder_model,
546
- backend=config.retrieval.cross_encoder_backend,
547
- )
623
+ reranker = init_reranker(config.retrieval)
548
624
 
549
625
  profile_ch = ProfileChannel(db)
550
626
  bridge = BridgeDiscovery(db)
@@ -283,7 +283,14 @@ class _ReadOnlyLearningView:
283
283
  connection.close()
284
284
 
285
285
  def count_feedback(self, profile_id: str) -> int:
286
- """Count legacy feedback without running schema initialization."""
286
+ """Count legacy feedback without running schema initialization.
287
+
288
+ Reports the raw ``learning_feedback`` table, which the dashboard
289
+ surfaces as ``legacy_feedback_rows`` alongside a pending-migration
290
+ card. Do NOT gate a ranking phase on this — use ``count_signals``,
291
+ which is the counter every other surface resolves its phase from
292
+ (issue #106).
293
+ """
287
294
  connection = self._connection()
288
295
  try:
289
296
  row = connection.execute(
@@ -408,17 +415,31 @@ def apply_adaptive_ranking(
408
415
  if not learning_db.exists():
409
416
  return response
410
417
 
418
+ # issue #106: count the CANONICAL store, not the legacy one. The
419
+ # dashboard's Living Brain panel and ranker-phase card both resolve their
420
+ # phase from ``learning_signals``; this gate read ``learning_feedback``,
421
+ # so the phase a user was shown and the phase that actually ranked their
422
+ # results were computed from different tables and could disagree without
423
+ # limit. ``learning_feedback`` rows reach this counter through
424
+ # ``legacy_migration``, which copies them forward.
411
425
  try:
412
- signal_count = _ReadOnlyLearningView(learning_db).count_feedback(pid)
426
+ signal_count = _ReadOnlyLearningView(learning_db).count_signals(pid)
413
427
  except sqlite3.Error:
414
428
  # A pre-learning database may not have this optional table yet.
415
429
  # Recall remains a query and cannot create it on demand.
416
430
  return response
417
431
 
418
- if signal_count < 50:
432
+ from superlocalmemory.learning.ranker import (
433
+ PHASE_2_THRESHOLD,
434
+ AdaptiveRanker,
435
+ )
436
+
437
+ # Thresholds come from ``learning.ranker`` — the same constants the
438
+ # dashboard gates on. Duplicating the literals here is how the two
439
+ # surfaces drifted apart in the first place.
440
+ if signal_count < PHASE_2_THRESHOLD:
419
441
  return response # Phase 1: no change
420
442
 
421
- from superlocalmemory.learning.ranker import AdaptiveRanker
422
443
  ranker = AdaptiveRanker(signal_count=signal_count)
423
444
 
424
445
  from datetime import UTC
@@ -230,6 +230,11 @@ def _worker_main() -> None:
230
230
 
231
231
 
232
232
  _KNOWN_BACKENDS = ("onnx", "", "pytorch", "torch")
233
+ # Backends this worker can never serve — they are handled over HTTP by
234
+ # superlocalmemory.retrieval.remote_reranker in the parent process (#105).
235
+ # Duplicated as a literal on purpose: this module runs as a bare subprocess
236
+ # and must not import the retrieval package (or, transitively, httpx).
237
+ _REMOTE_BACKENDS = ("openai", "remote")
233
238
 
234
239
 
235
240
  def _load_model(
@@ -253,12 +258,26 @@ def _load_model(
253
258
  # the PyTorch tier and fail there with a confusing model-load error. A
254
259
  # user who set backend="openai" expecting a remote reranker got five
255
260
  # silent failures and no hint that the value meant nothing. Name it.
261
+ #
262
+ # v3.8.12 (issue #105): remote reranking now EXISTS, but it is served in
263
+ # the parent process — this worker holds torch/ONNX and cannot forward an
264
+ # HTTP request. Reaching here with a remote backend means the parent
265
+ # routed wrong (or a caller drove the worker directly), so the message
266
+ # points at the config keys that select the remote path.
267
+ if backend in _REMOTE_BACKENDS:
268
+ return None, "", "", (
269
+ f"unknown backend {backend!r} for the LOCAL reranker worker. "
270
+ f"{backend!r} selects the remote reranker, which runs in the "
271
+ f"parent process — set retrieval.cross_encoder_endpoint (e.g. "
272
+ f"\"http://127.0.0.1:8041/v1/rerank\") so SuperLocalMemory routes "
273
+ f"reranking over HTTP instead of spawning this worker."
274
+ )
256
275
  if backend not in _KNOWN_BACKENDS:
257
276
  return None, "", "", (
258
- f"unknown backend {backend!r}; supported values are "
259
- f"'onnx' or '' (PyTorch). SuperLocalMemory has no remote/"
260
- f"OpenAI-compatible reranker backend the cross-encoder always "
261
- f"runs locally, so 'cross_encoder_endpoint' has no effect."
277
+ f"unknown backend {backend!r}; supported values are 'onnx' or ''"
278
+ f" (PyTorch) for local reranking, or 'openai'/'remote' with "
279
+ f"retrieval.cross_encoder_endpoint set for a remote "
280
+ f"OpenAI-compatible /v1/rerank endpoint."
262
281
  )
263
282
 
264
283
  tier_errors: list[str] = []
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
  from typing import Any, Mapping
27
27
 
28
28
  from superlocalmemory.infra.data_root import canonical_data_root
29
+ from superlocalmemory.infra.process_identity import process_start_token_for
29
30
 
30
31
  DAEMON_DESCRIPTOR_SCHEMA = 1
31
32
  DAEMON_PROTOCOL = 1
@@ -88,6 +89,11 @@ class DaemonDescriptor:
88
89
  state: str
89
90
  version: str
90
91
  started_at: float
92
+ # Clock-independent process identity. Optional and defaulted so a
93
+ # descriptor written by an older release still parses; platforms without a
94
+ # boot-relative start time (Windows) legitimately store None and fall back
95
+ # to the creation-time comparison. See infra/process_identity.py.
96
+ process_start_token: str | None = None
91
97
 
92
98
  def public_health_fields(self) -> dict[str, Any]:
93
99
  """Identity fields safe to expose on the loopback health endpoint."""
@@ -112,6 +118,7 @@ def build_descriptor(
112
118
  version: str,
113
119
  pid: int | None = None,
114
120
  process_create_time: float | None = None,
121
+ process_start_token: str | None = None,
115
122
  instance_id: str | None = None,
116
123
  capability: str | None = None,
117
124
  state: str = "starting",
@@ -141,6 +148,11 @@ def build_descriptor(
141
148
  state=state,
142
149
  version=version,
143
150
  started_at=float(started_at if started_at is not None else time.time()),
151
+ process_start_token=(
152
+ process_start_token
153
+ if process_start_token is not None
154
+ else process_start_token_for(actual_pid)
155
+ ),
144
156
  )
145
157
 
146
158
 
@@ -238,6 +250,10 @@ def read_descriptor(
238
250
  return None
239
251
  if not (1 <= descriptor.port <= 65535) or descriptor.pid <= 0:
240
252
  return None
253
+ if descriptor.process_start_token is not None and not isinstance(
254
+ descriptor.process_start_token, str
255
+ ):
256
+ return None
241
257
  return descriptor
242
258
 
243
259