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.
- package/CHANGELOG.md +85 -0
- package/README.md +7 -3
- 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 +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +62 -3
- package/src/superlocalmemory/cli/daemon.py +219 -10
- package/src/superlocalmemory/cli/setup_wizard.py +45 -1
- package/src/superlocalmemory/core/component_registry.py +25 -0
- package/src/superlocalmemory/core/config.py +35 -1
- package/src/superlocalmemory/core/engine_wiring.py +81 -5
- package/src/superlocalmemory/core/recall_pipeline.py +25 -4
- package/src/superlocalmemory/core/reranker_worker.py +23 -4
- package/src/superlocalmemory/infra/daemon_identity.py +16 -0
- package/src/superlocalmemory/infra/process_identity.py +180 -0
- package/src/superlocalmemory/infra/version_integrity.py +229 -0
- package/src/superlocalmemory/learning/feedback.py +288 -29
- package/src/superlocalmemory/learning/legacy_migration.py +45 -4
- package/src/superlocalmemory/mcp/_daemon_proxy.py +23 -1
- package/src/superlocalmemory/mcp/tools_active.py +109 -58
- package/src/superlocalmemory/mcp/tools_core.py +6 -5
- package/src/superlocalmemory/retrieval/remote_reranker.py +636 -0
- 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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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).
|
|
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
|
-
|
|
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"
|
|
260
|
-
f"
|
|
261
|
-
f"
|
|
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
|
|