superlocalmemory 3.8.10 → 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.
Files changed (54) hide show
  1. package/CHANGELOG.md +91 -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 +28 -6
  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 +78 -17
  41. package/src/superlocalmemory/infra/daemon_identity.py +16 -0
  42. package/src/superlocalmemory/infra/process_identity.py +180 -0
  43. package/src/superlocalmemory/learning/feedback.py +328 -27
  44. package/src/superlocalmemory/learning/legacy_migration.py +45 -4
  45. package/src/superlocalmemory/learning/pattern_miner.py +31 -11
  46. package/src/superlocalmemory/mcp/_daemon_proxy.py +23 -1
  47. package/src/superlocalmemory/mcp/tools_active.py +179 -17
  48. package/src/superlocalmemory/mcp/tools_core.py +6 -5
  49. package/src/superlocalmemory/retrieval/remote_reranker.py +636 -0
  50. package/src/superlocalmemory/retrieval/reranker.py +52 -5
  51. package/src/superlocalmemory/server/unified_daemon.py +4 -0
  52. package/src/superlocalmemory/storage/migration_runner.py +9 -0
  53. package/src/superlocalmemory/storage/migrations/M033_learning_feedback_channel.py +77 -0
  54. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
@@ -119,7 +119,9 @@ def _worker_main() -> None:
119
119
  if cmd == "load":
120
120
  name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
121
121
  backend = req.get("backend", "onnx")
122
- model, active_backend, model_name = _load_model(name, backend)
122
+ model, active_backend, model_name, load_error = _load_model(
123
+ name, backend,
124
+ )
123
125
  # V3.3.16: Run real inference to trigger ONNX CoreML JIT compilation.
124
126
  # Without this, first real rerank call triggers 30-60s compilation
125
127
  # that exceeds the caller's timeout, killing the worker.
@@ -147,6 +149,9 @@ def _worker_main() -> None:
147
149
  "backend": active_backend,
148
150
  "model": model_name,
149
151
  "warmup_inference": warmup_ok,
152
+ # Carries the real reason to the parent so the warmup log can
153
+ # print it instead of a generic timeout message (issue #103).
154
+ "error": load_error,
150
155
  })
151
156
  continue
152
157
 
@@ -160,9 +165,14 @@ def _worker_main() -> None:
160
165
  # Auto-load with defaults
161
166
  name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
162
167
  backend = req.get("backend", "onnx")
163
- model, active_backend, model_name = _load_model(name, backend)
168
+ model, active_backend, model_name, load_error = _load_model(
169
+ name, backend,
170
+ )
164
171
  if model is None:
165
- _respond({"ok": False, "error": "Model load failed"})
172
+ _respond({
173
+ "ok": False,
174
+ "error": load_error or "Model load failed",
175
+ })
166
176
  continue
167
177
  try:
168
178
  pairs = [(query, doc) for doc in documents]
@@ -195,9 +205,14 @@ def _worker_main() -> None:
195
205
  if model is None:
196
206
  name = req.get("model_name", "cross-encoder/ms-marco-MiniLM-L-12-v2")
197
207
  backend = req.get("backend", "onnx")
198
- model, active_backend, model_name = _load_model(name, backend)
208
+ model, active_backend, model_name, load_error = _load_model(
209
+ name, backend,
210
+ )
199
211
  if model is None:
200
- _respond({"ok": False, "error": "Model load failed"})
212
+ _respond({
213
+ "ok": False,
214
+ "error": load_error or "Model load failed",
215
+ })
201
216
  continue
202
217
  try:
203
218
  try:
@@ -214,10 +229,18 @@ def _worker_main() -> None:
214
229
  _respond({"ok": False, "error": f"Unknown command: {cmd}"})
215
230
 
216
231
 
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")
238
+
239
+
217
240
  def _load_model(
218
241
  name: str, backend: str,
219
242
  ) -> tuple:
220
- """Load cross-encoder model. Returns (model, backend_name, model_name).
243
+ """Load cross-encoder model. Returns (model, backend_name, model_name, error).
221
244
 
222
245
  V3.3.13: sentence-transformers 5.x+ supports backend='onnx' for
223
246
  CrossEncoder. We use a 3-tier fallback chain:
@@ -231,6 +254,33 @@ def _load_model(
231
254
  x86_64 → model_quint8_avx2.onnx
232
255
  Fallback → model.onnx (generic)
233
256
  """
257
+ # v3.8.11 (issue #103): an unrecognised backend used to fall through to
258
+ # the PyTorch tier and fail there with a confusing model-load error. A
259
+ # user who set backend="openai" expecting a remote reranker got five
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
+ )
275
+ if backend not in _KNOWN_BACKENDS:
276
+ return None, "", "", (
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."
281
+ )
282
+
283
+ tier_errors: list[str] = []
234
284
  try:
235
285
  from sentence_transformers import CrossEncoder
236
286
 
@@ -242,24 +292,35 @@ def _load_model(
242
292
  name, backend="onnx",
243
293
  model_kwargs={"file_name": onnx_file},
244
294
  )
245
- return m, f"onnx-quantized({onnx_file})", name
246
- except Exception:
247
- pass
295
+ return m, f"onnx-quantized({onnx_file})", name, ""
296
+ except Exception as exc:
297
+ tier_errors.append(f"onnx-quantized: {exc}")
248
298
 
249
299
  # Tier 2: Generic ONNX (auto-exported by optimum)
250
300
  try:
251
301
  m = CrossEncoder(name, backend="onnx")
252
- return m, "onnx", name
253
- except Exception:
254
- pass
302
+ return m, "onnx", name, ""
303
+ except Exception as exc:
304
+ tier_errors.append(f"onnx: {exc}")
255
305
 
256
306
  # Tier 3: PyTorch (always works, no ONNX dependency needed)
257
307
  m = CrossEncoder(name)
258
- return m, "pytorch", name
259
- except ImportError:
260
- return None, "", ""
261
- except Exception:
262
- return None, "", ""
308
+ return m, "pytorch", name, ""
309
+ except ImportError as exc:
310
+ # Previously indistinguishable from a bad model name.
311
+ return None, "", "", (
312
+ f"sentence-transformers is not installed ({exc}); "
313
+ f"install it or set retrieval.use_cross_encoder=false"
314
+ )
315
+ except Exception as exc:
316
+ tier_errors.append(f"pytorch: {exc}")
317
+ # Every tier's real error, propagated instead of discarded. Before
318
+ # 3.8.11 this returned (None, "", "") and the operator saw only a
319
+ # generic "did not confirm ready" line from the parent process.
320
+ return None, "", "", (
321
+ f"could not load cross-encoder model {name!r} "
322
+ f"(backend={backend or 'pytorch'}): " + "; ".join(tier_errors)
323
+ )
263
324
 
264
325
 
265
326
  def _respond(data: dict) -> None:
@@ -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