superlocalmemory 3.8.11 → 3.8.12
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 +55 -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 +15 -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/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
|
@@ -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
|
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
"""Clock-independent identity for one running local process.
|
|
6
|
+
|
|
7
|
+
Why this module exists
|
|
8
|
+
----------------------
|
|
9
|
+
A process's *wall-clock* creation time is not a stable identifier on every
|
|
10
|
+
platform. On Linux -- and therefore inside WSL2 -- psutil derives it as::
|
|
11
|
+
|
|
12
|
+
create_time = /proc/<pid>/stat:starttime / CLOCK_TICKS + /proc/stat:btime
|
|
13
|
+
|
|
14
|
+
``starttime`` is boot-relative and never changes for the life of the process.
|
|
15
|
+
``btime`` is the kernel's *estimate* of the boot instant, recomputed from the
|
|
16
|
+
current wall clock, so any clock step moves ``btime`` and moves every process's
|
|
17
|
+
computed ``create_time`` with it -- retroactively. WSL2 periodically
|
|
18
|
+
resynchronises its VM clock against the Windows host, so a ``create_time``
|
|
19
|
+
recorded when the daemon started stops matching the ``create_time`` computed
|
|
20
|
+
for that very same process minutes later. Issue #104 measured a ~35 second
|
|
21
|
+
divergence after roughly four minutes of uptime.
|
|
22
|
+
|
|
23
|
+
Any *constant* tolerance on that comparison is a delay, not a fix: the
|
|
24
|
+
divergence is unbounded and keeps growing. The correct identifier is one the
|
|
25
|
+
wall clock cannot move at all, which is what this module produces.
|
|
26
|
+
|
|
27
|
+
What a start token is
|
|
28
|
+
---------------------
|
|
29
|
+
``process_start_token_for(pid)`` returns an opaque string identifying one
|
|
30
|
+
process *instance*, derived without reference to wall-clock time, or ``None``
|
|
31
|
+
when the platform cannot supply one. Tokens are only comparable when they use
|
|
32
|
+
the same scheme, so :func:`compare_start_tokens` is deliberately tri-state --
|
|
33
|
+
callers fall back to a weaker signal instead of guessing.
|
|
34
|
+
|
|
35
|
+
Schemes
|
|
36
|
+
-------
|
|
37
|
+
``lx1``
|
|
38
|
+
Linux/WSL2: ``lx1:<boot_id>:<starttime_ticks>``. Both halves come straight
|
|
39
|
+
from procfs and are an opaque UUID and an integer tick count rather than
|
|
40
|
+
timestamps, so a clock adjustment cannot rewrite either. ``boot_id``
|
|
41
|
+
differs across reboots, so a post-reboot PID collision can never look like
|
|
42
|
+
a match.
|
|
43
|
+
``mn1``
|
|
44
|
+
Platforms where psutil exposes a monotonic creation time (macOS, NetBSD,
|
|
45
|
+
and Linux if procfs is unreadable): ``mn1:<value>``. psutil builds its own
|
|
46
|
+
PID-reuse identity from exactly this value for exactly this reason.
|
|
47
|
+
|
|
48
|
+
Windows has no monotonic variant, and needs none: its creation time comes from
|
|
49
|
+
``GetProcessTimes``, a kernel timestamp that a clock adjustment does not
|
|
50
|
+
rewrite. ``process_start_token_for`` returns ``None`` there and the caller's
|
|
51
|
+
creation-time comparison stays correct.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import logging
|
|
57
|
+
import sys
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
LINUX_SCHEME = "lx1"
|
|
63
|
+
MONOTONIC_SCHEME = "mn1"
|
|
64
|
+
|
|
65
|
+
_PROCFS = Path("/proc")
|
|
66
|
+
# "man proc" numbers /proc/<pid>/stat fields from 1 and starttime is field 22.
|
|
67
|
+
# The comm field can contain spaces and parentheses, so parsing starts after
|
|
68
|
+
# the last ')', which drops fields 1 and 2 -- hence 22 - 3 == 19.
|
|
69
|
+
_STARTTIME_INDEX = 19
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _boot_id() -> str | None:
|
|
73
|
+
"""Return this boot's opaque kernel identifier, or None when unavailable.
|
|
74
|
+
|
|
75
|
+
Deliberately strict: without a boot id, two processes from different boots
|
|
76
|
+
could share a PID *and* a tick count, so the token would be unsound. A
|
|
77
|
+
missing boot id therefore means "no token" rather than a weaker token.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
value = (_PROCFS / "sys" / "kernel" / "random" / "boot_id").read_text(
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
).strip()
|
|
83
|
+
except OSError:
|
|
84
|
+
return None
|
|
85
|
+
return value or None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _linux_start_ticks(pid: int) -> int | None:
|
|
89
|
+
"""Return boot-relative start ticks for a PID from procfs, or None."""
|
|
90
|
+
try:
|
|
91
|
+
data = (_PROCFS / str(pid) / "stat").read_bytes()
|
|
92
|
+
except OSError:
|
|
93
|
+
return None
|
|
94
|
+
closing = data.rfind(b")")
|
|
95
|
+
if closing < 0:
|
|
96
|
+
return None
|
|
97
|
+
fields = data[closing + 2:].split()
|
|
98
|
+
if len(fields) <= _STARTTIME_INDEX:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
return int(fields[_STARTTIME_INDEX])
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _linux_start_token(pid: int) -> str | None:
|
|
107
|
+
boot_id = _boot_id()
|
|
108
|
+
if boot_id is None:
|
|
109
|
+
return None
|
|
110
|
+
ticks = _linux_start_ticks(pid)
|
|
111
|
+
if ticks is None:
|
|
112
|
+
return None
|
|
113
|
+
return f"{LINUX_SCHEME}:{boot_id}:{ticks}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _monotonic_start_token(pid: int) -> str | None:
|
|
117
|
+
"""Return psutil's monotonic creation time as a token, or None.
|
|
118
|
+
|
|
119
|
+
``Process._proc.create_time(monotonic=True)`` is the same private accessor
|
|
120
|
+
psutil uses internally to build ``Process._ident``. It is guarded on every
|
|
121
|
+
axis -- missing psutil, missing attribute, platforms whose implementation
|
|
122
|
+
takes no ``monotonic`` keyword (Windows) -- and degrades to ``None``.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
import psutil
|
|
126
|
+
|
|
127
|
+
platform_process = getattr(psutil.Process(pid), "_proc", None)
|
|
128
|
+
except Exception: # noqa: BLE001 - identity probing must never raise
|
|
129
|
+
return None
|
|
130
|
+
create_time = getattr(platform_process, "create_time", None)
|
|
131
|
+
if create_time is None:
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
raw = create_time(monotonic=True)
|
|
135
|
+
except TypeError:
|
|
136
|
+
# No monotonic variant on this platform (Windows).
|
|
137
|
+
return None
|
|
138
|
+
except Exception: # noqa: BLE001
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
return f"{MONOTONIC_SCHEME}:{float(raw)!r}"
|
|
142
|
+
except (TypeError, ValueError):
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def process_start_token_for(pid: int) -> str | None:
|
|
147
|
+
"""Return a clock-independent identity token for ``pid``, or None.
|
|
148
|
+
|
|
149
|
+
``None`` is a normal answer, not an error: it means "this platform cannot
|
|
150
|
+
prove process identity without the wall clock", and the caller should fall
|
|
151
|
+
back to comparing creation times.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
pid = int(pid)
|
|
155
|
+
except (TypeError, ValueError):
|
|
156
|
+
return None
|
|
157
|
+
if pid <= 0:
|
|
158
|
+
return None
|
|
159
|
+
if sys.platform.startswith("linux"):
|
|
160
|
+
token = _linux_start_token(pid)
|
|
161
|
+
if token is not None:
|
|
162
|
+
return token
|
|
163
|
+
return _monotonic_start_token(pid)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def compare_start_tokens(recorded: str | None, observed: str | None) -> bool | None:
|
|
167
|
+
"""Tri-state comparison of two start tokens.
|
|
168
|
+
|
|
169
|
+
``True`` -- same scheme, identical value: proven the same process instance.
|
|
170
|
+
``False`` -- same scheme, different value: proven a different instance.
|
|
171
|
+
``None`` -- not comparable (either side missing, or different schemes);
|
|
172
|
+
the caller must fall back rather than assume either way.
|
|
173
|
+
"""
|
|
174
|
+
if not recorded or not observed:
|
|
175
|
+
return None
|
|
176
|
+
if not isinstance(recorded, str) or not isinstance(observed, str):
|
|
177
|
+
return None
|
|
178
|
+
if recorded.split(":", 1)[0] != observed.split(":", 1)[0]:
|
|
179
|
+
return None
|
|
180
|
+
return recorded == observed
|