superlocalmemory 3.8.2 → 3.8.5

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 (94) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +3 -2
  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/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +139 -91
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +283 -39
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. package/src/superlocalmemory/storage/write_lock.py +88 -0
  94. package/src/superlocalmemory/ui/js/core.js +6 -1
@@ -2,22 +2,261 @@
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
4
 
5
- """Resource-safe FastMCP Streamable-HTTP integration.
5
+ """Resource-safe FastMCP Streamable-HTTP integration — Workstream E additions.
6
6
 
7
7
  MCP SDK 1.27.1 passes an AnyIO ``MemoryObjectReceiveStream`` directly to
8
8
  ``EventSourceResponse`` for every JSON-RPC POST. The response consumes that
9
9
  stream but does not close it after normal iteration, leaving one receive
10
10
  endpoint per request for the garbage collector. The response is the owner of
11
11
  that per-request iterator, so SLM closes it at the response boundary.
12
+
13
+ Workstream E (3.8.4): MCP Connection Resilience
14
+ ------------------------------------------------
15
+ Two root causes of frequent MCP disconnects are addressed here:
16
+
17
+ RC-2: No session idle timeout — zombie sessions accumulate forever.
18
+ Fix: ``SLMFastMCP.streamable_http_app()`` pre-creates the
19
+ ``StreamableHTTPSessionManager`` with a finite ``session_idle_timeout``
20
+ (default 600 s, overridable via ``SLM_MCP_SESSION_IDLE_TIMEOUT_S``).
21
+
22
+ RC-3: No EventStore — every SSE drop requires a full re-initialize.
23
+ Fix: ``SLMInMemoryEventStore`` (bounded, in-memory, async-safe) is
24
+ injected as the session manager's event store. A dropped SSE stream can
25
+ resume via ``Last-Event-ID`` instead of forcing a new ``initialize``
26
+ handshake.
27
+
28
+ KNOWN LIMITATIONS (not fixed here, documented for future work):
29
+ * RC-1 (mcp-remote orphan test-sessions): The mcp-remote v0.1.38 bug
30
+ creates a ``testTransport``/``testClient`` that is never closed. This
31
+ leaks one zombie session per mcp-remote startup. Tracked upstream.
32
+ Session idle-timeout mitigates the accumulation.
33
+ * Client reconnect after daemon restart: ``SLMInMemoryEventStore`` is
34
+ in-memory only and does not survive daemon process restart. After a
35
+ restart, stale ``Last-Event-ID`` values are unknown to the new store;
36
+ ``replay_events_after`` returns ``None`` and the client must
37
+ re-initialize. A SQLite-backed EventStore is planned for v3.9.
38
+ * Claude Code client bug Anthropic #48557: Claude Code may send a stale
39
+ MCP session ID after server restart. This is a client-side defect;
40
+ SLM cannot fix it from the server.
41
+ * Event-loop stall during background maintenance (RC-6): The pruner-lock
42
+ stall fix lives in Workstream A+F (already merged into this branch).
43
+ This module depends on that fix; see fix/3.8.4 merge commit.
12
44
  """
13
45
 
14
46
  from __future__ import annotations
15
47
 
48
+ import logging
49
+ import os
50
+ from collections import OrderedDict, deque
51
+ from typing import Any
52
+
16
53
  from mcp.server.fastmcp import FastMCP
54
+ from mcp.server.streamable_http import (
55
+ EventCallback,
56
+ EventId,
57
+ EventMessage,
58
+ EventStore,
59
+ StreamId,
60
+ )
17
61
  from sse_starlette.sse import EventSourceResponse
18
62
  from starlette.types import Receive, Scope, Send
63
+
19
64
  from superlocalmemory import __version__
20
65
 
66
+ logger = logging.getLogger(__name__)
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Session idle timeout — configurable default
70
+ # ---------------------------------------------------------------------------
71
+
72
+ #: Default session idle timeout in seconds.
73
+ #:
74
+ #: Rationale for 600 s (10 minutes), not the SDK's suggested 1800 s:
75
+ #: * At 1800 s, with mcp-remote leaking 3 zombie sessions per conversation
76
+ #: start and a typical conversation cadence of one every 15 min,
77
+ #: up to (1800/15) × 3 = 360 zombie sessions can accumulate in the worst
78
+ #: case before the first one expires. 600 s bounds this to ~6.
79
+ #: * 600 s is well above the 15 s SSE keepalive interval, the typical
80
+ #: in-conversation idle gap (<5 min), and the mcp-remote reconnect window.
81
+ #: * Active sessions push their idle deadline forward on every tool call,
82
+ #: so a user making any request within 10 min never loses their session.
83
+ #: * A session idle for exactly 10 min (user stepped away) is reaped and
84
+ #: recreated transparently on next tool call via mcp-remote reconnect.
85
+ #:
86
+ #: CRIT — anti-patterns to avoid:
87
+ #: Too aggressive (<60 s): frequent idle-deadline checks waste CPU in
88
+ #: AnyIO's CancelScope machinery; legitimate quiet users are interrupted.
89
+ #: Too lenient (>7200 s = 2 h): zombie sessions accumulate at rates that
90
+ #: can reach 100+ entries, degrading AnyIO task-group scheduling.
91
+ _DEFAULT_SESSION_IDLE_TIMEOUT: float = 600.0
92
+
93
+
94
+ def _slm_session_idle_timeout() -> float:
95
+ """Return the configured MCP session idle timeout in seconds.
96
+
97
+ Reads ``SLM_MCP_SESSION_IDLE_TIMEOUT_S`` from the environment.
98
+ Falls back to :data:`_DEFAULT_SESSION_IDLE_TIMEOUT` on missing or
99
+ non-numeric values. A value ≤ 0 is also treated as the default.
100
+ """
101
+ raw = os.environ.get("SLM_MCP_SESSION_IDLE_TIMEOUT_S", "").strip()
102
+ if raw:
103
+ try:
104
+ val = float(raw)
105
+ if val > 0:
106
+ return val
107
+ except ValueError:
108
+ logger.warning(
109
+ "SLM_MCP_SESSION_IDLE_TIMEOUT_S=%r is not a valid number; "
110
+ "using default %s s",
111
+ raw,
112
+ _DEFAULT_SESSION_IDLE_TIMEOUT,
113
+ )
114
+ return _DEFAULT_SESSION_IDLE_TIMEOUT
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # SLMInMemoryEventStore — bounded, in-memory, async-safe
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ class SLMInMemoryEventStore(EventStore):
123
+ """Bounded in-memory event store for MCP SSE resumability.
124
+
125
+ Enables clients to reconnect to the GET /mcp SSE endpoint and replay
126
+ missed events via the ``Last-Event-ID`` header instead of performing a
127
+ full ``initialize`` handshake (which creates a new session and adds to
128
+ the zombie-session count).
129
+
130
+ Design choices:
131
+ ---------------
132
+ * **Per-stream bounded deque** — each stream_id maps to a
133
+ ``deque(maxlen=max_events_per_stream)``. Oldest events are evicted
134
+ automatically (FIFO) when the cap is hit.
135
+ * **Global stream count cap** — the store tracks at most
136
+ ``max_streams`` distinct stream_ids. When exceeded, the least-recently
137
+ used stream is evicted from the dict. This bounds total memory usage.
138
+ * **Async-safe, lock-free** — all access happens on a single asyncio event
139
+ loop; no ``asyncio.Lock`` is needed.
140
+ * **Priming events (message=None) are stored but skipped during replay** —
141
+ the SDK mints a fresh priming event in the replay path (see
142
+ ``StreamableHTTPServerTransport._replay_events``).
143
+
144
+ Memory bound:
145
+ -------------
146
+ ``max_events_per_stream`` × ~2 KB (avg JSONRPCMessage) × ``max_streams``
147
+ = 200 × 2 KB × 100 = ~40 MB in the absolute worst case.
148
+ At steady state with idle-timeout reaping sessions after 10 min, the
149
+ typical load is 3–6 active streams × 200 events × 2 KB ≈ 1.2–2.4 MB.
150
+
151
+ Known limitation:
152
+ -----------------
153
+ This store is in-memory only. It does not survive a daemon process
154
+ restart. After a restart, any ``Last-Event-ID`` from a previous process
155
+ is unknown; ``replay_events_after`` returns ``None`` and the client must
156
+ re-initialize. A SQLite-backed EventStore is planned for v3.9.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ max_events_per_stream: int = 200,
162
+ max_streams: int = 100,
163
+ ) -> None:
164
+ """Initialise the bounded event store.
165
+
166
+ Args:
167
+ max_events_per_stream: Maximum number of events stored per
168
+ stream before oldest events are dropped. Default 200
169
+ (~400 KB per stream at 2 KB/event).
170
+ max_streams: Maximum number of distinct stream_ids tracked.
171
+ When exceeded, the least-recently used stream is evicted
172
+ (all its events are lost). Default 100.
173
+ """
174
+ self._max_events = max_events_per_stream
175
+ self._max_streams = max_streams
176
+ # OrderedDict maintains insertion/access order for LRU eviction.
177
+ # stream_id → deque[(event_id, message)]
178
+ self._store: OrderedDict[str, deque] = OrderedDict()
179
+ # Monotonically incrementing counter; single event loop → no lock needed.
180
+ self._counter: int = 0
181
+
182
+ # ------------------------------------------------------------------
183
+ # EventStore ABC implementation
184
+ # ------------------------------------------------------------------
185
+
186
+ async def store_event(
187
+ self,
188
+ stream_id: StreamId,
189
+ message: Any, # JSONRPCMessage | None
190
+ ) -> EventId:
191
+ """Store an event for the given stream and return its unique event_id.
192
+
193
+ Args:
194
+ stream_id: The stream (GET /mcp SSE connection) this event belongs to.
195
+ message: The JSON-RPC message, or ``None`` for priming events.
196
+
197
+ Returns:
198
+ A monotonically incrementing string event_id.
199
+ """
200
+ self._counter += 1
201
+ event_id: EventId = str(self._counter)
202
+
203
+ if stream_id not in self._store:
204
+ # Evict oldest stream if at capacity
205
+ if len(self._store) >= self._max_streams:
206
+ oldest_stream, _ = self._store.popitem(last=False)
207
+ logger.debug(
208
+ "SLMInMemoryEventStore: evicted oldest stream %r "
209
+ "(max_streams=%d reached)",
210
+ oldest_stream,
211
+ self._max_streams,
212
+ )
213
+ self._store[stream_id] = deque(maxlen=self._max_events)
214
+ else:
215
+ # Move to "most recently used" end so LRU eviction works correctly
216
+ self._store.move_to_end(stream_id)
217
+
218
+ self._store[stream_id].append((event_id, message))
219
+ return event_id
220
+
221
+ async def replay_events_after(
222
+ self,
223
+ last_event_id: EventId,
224
+ send_callback: EventCallback,
225
+ ) -> StreamId | None:
226
+ """Replay events that occurred after ``last_event_id``.
227
+
228
+ Searches all tracked streams for ``last_event_id`` and forwards every
229
+ subsequent non-None event to ``send_callback``. Priming events
230
+ (``message=None``) are skipped; the SDK mints a fresh priming event
231
+ in the replay path.
232
+
233
+ Args:
234
+ last_event_id: The ID of the last event the client received.
235
+ send_callback: Async callback that receives each missed
236
+ ``EventMessage`` in order.
237
+
238
+ Returns:
239
+ The stream_id that contained ``last_event_id``, or ``None`` if
240
+ the event was not found (client must re-initialize).
241
+ """
242
+ for stream_id, events in self._store.items():
243
+ found = False
244
+ for event_id, message in events:
245
+ if found:
246
+ if message is not None:
247
+ await send_callback(EventMessage(message=message, event_id=event_id))
248
+ elif event_id == last_event_id:
249
+ found = True
250
+ if found:
251
+ return stream_id
252
+ # last_event_id not in any stream (evicted or never seen)
253
+ return None
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # SSE resource guard (unchanged from pre-E)
258
+ # ---------------------------------------------------------------------------
259
+
21
260
 
22
261
  class ClosingEventSourceResponse(EventSourceResponse):
23
262
  """EventSourceResponse that closes the async iterator it consumes."""
@@ -38,8 +277,35 @@ def install_streamable_http_resource_guard() -> None:
38
277
  streamable_http.EventSourceResponse = ClosingEventSourceResponse
39
278
 
40
279
 
280
+ # ---------------------------------------------------------------------------
281
+ # SLMFastMCP — session lifecycle + SSE cleanup
282
+ # ---------------------------------------------------------------------------
283
+
284
+
41
285
  class SLMFastMCP(FastMCP):
42
- """FastMCP with SLM release identity and deterministic SSE cleanup."""
286
+ """FastMCP with SLM release identity, deterministic SSE cleanup, and
287
+ session lifecycle management (Workstream E).
288
+
289
+ Additions over the base FastMCP:
290
+
291
+ 1. **Session idle timeout**: the ``StreamableHTTPSessionManager`` is
292
+ pre-created with a finite ``session_idle_timeout`` so zombie sessions
293
+ (from mcp-remote orphan test-connects, or abandoned conversations) are
294
+ automatically reaped instead of accumulating forever.
295
+
296
+ 2. **Bounded event store**: a ``SLMInMemoryEventStore`` is injected so
297
+ that a dropped SSE stream can resume via ``Last-Event-ID`` without a
298
+ full ``initialize`` round-trip.
299
+
300
+ 3. **Ordering guard**: ``streamable_http_app()`` reads the ``stateless_http``
301
+ flag from ``self.settings`` at call time. If the flag is ``True``
302
+ (set by ``_configure_mcp_transport_settings()`` in unified_daemon.py),
303
+ ``session_idle_timeout`` is suppressed — the MCP SDK raises
304
+ ``RuntimeError`` if both are set simultaneously. If
305
+ ``streamable_http_app()`` is called before settings are configured
306
+ (e.g. in unit tests), the default ``stateless_http=False`` applies
307
+ safely.
308
+ """
43
309
 
44
310
  def __init__(self, *args, product_version: str = __version__, **kwargs) -> None:
45
311
  super().__init__(*args, **kwargs)
@@ -49,6 +315,72 @@ class SLMFastMCP(FastMCP):
49
315
  # (for example) 1.27.1 to every IDE client.
50
316
  self._mcp_server.version = product_version
51
317
 
52
- def streamable_http_app(self):
318
+ def streamable_http_app(self): # type: ignore[override]
319
+ """Return the Streamable-HTTP Starlette app.
320
+
321
+ Pre-creates the ``StreamableHTTPSessionManager`` with SLM-specific
322
+ parameters before delegating to ``super()`` (which skips re-creation
323
+ because the manager is already set).
324
+
325
+ Ordering guarantee
326
+ ------------------
327
+ In production ``unified_daemon.py`` always calls
328
+ ``_configure_mcp_transport_settings(fastmcp)`` *before* this method,
329
+ so ``self.settings.stateless_http`` reflects the correct runtime value.
330
+ If this method is called first (unit tests, embedded hosts), the
331
+ default ``stateless_http=False`` is used — safe because the stateless
332
+ guard only matters when ``stateless_http=True`` (avoid SDK RuntimeError).
333
+
334
+ Dependency note (A+F)
335
+ ---------------------
336
+ This method assumes that ``fix/3.8.4-A+F`` (already merged into this
337
+ branch) has eliminated the pruner-lock stall that caused event-loop
338
+ starvation during background maintenance. Session idle-timeout and
339
+ the event store improve resilience to transient drops, but they cannot
340
+ compensate for a fully stalled event loop.
341
+ """
53
342
  install_streamable_http_resource_guard()
343
+
344
+ if self._session_manager is None:
345
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
346
+
347
+ # Ordering guard: read stateless_http defensively.
348
+ # ``_configure_mcp_transport_settings()`` may not have been called
349
+ # yet; ``getattr`` with a False default ensures we never pass
350
+ # session_idle_timeout=<value> into a stateless manager (SDK raises
351
+ # RuntimeError if both are set simultaneously).
352
+ is_stateless: bool = getattr(self.settings, "stateless_http", False)
353
+
354
+ # Idle timeout: finite for stateful sessions; None for stateless
355
+ # (stateless sessions have no identity — there is nothing to reap).
356
+ idle_timeout: float | None = (
357
+ None if is_stateless else _slm_session_idle_timeout()
358
+ )
359
+
360
+ # Event store: inject SLMInMemoryEventStore for stateful mode.
361
+ # Stateless mode must not receive an event store (SDK limitation).
362
+ # If the caller already configured an event store (via FastMCP
363
+ # constructor argument), respect it — do not replace with ours.
364
+ if is_stateless:
365
+ event_store: EventStore | None = None
366
+ else:
367
+ event_store = self._event_store or SLMInMemoryEventStore()
368
+
369
+ self._session_manager = StreamableHTTPSessionManager(
370
+ app=self._mcp_server,
371
+ event_store=event_store,
372
+ retry_interval=self._retry_interval,
373
+ json_response=self.settings.json_response,
374
+ stateless=is_stateless,
375
+ security_settings=self.settings.transport_security,
376
+ session_idle_timeout=idle_timeout,
377
+ )
378
+ logger.info(
379
+ "SLM MCP session manager created: stateless=%s, "
380
+ "idle_timeout=%ss, event_store=%s",
381
+ is_stateless,
382
+ idle_timeout,
383
+ type(event_store).__name__ if event_store else "None",
384
+ )
385
+
54
386
  return super().streamable_http_app()
@@ -955,7 +955,13 @@ class RetrievalEngine:
955
955
  Blended: alpha * sigmoid(CE_score) + (1 - alpha) * rrf_score.
956
956
  Speaker tags stripped before scoring (Bug 3 fix).
957
957
  """
958
- # Bug 2 fix: score ALL candidates, not just top_k
958
+ # Bug 2 fix: score ALL candidates, not just top_k. v3.8.5: verified on
959
+ # the real DB that bounding the CE to the top-N fusion candidates both
960
+ # (a) gave NO latency win (the cross-encoder batches all pairs in one
961
+ # forward pass, so 60 vs 184 pairs is within noise) and (b) CHANGED the
962
+ # top-5 on 4/8 queries — the CE legitimately promotes items ranked below
963
+ # the fusion top-N into the answer. So exhaustive reranking stays: it is
964
+ # a quality feature, not the latency bottleneck.
959
965
  candidates = [
960
966
  (fact_map[fr.fact_id], fr.fused_score)
961
967
  for fr in fused if fr.fact_id in fact_map
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import json
16
16
  import logging
17
+ import os
17
18
  import re
18
19
  import threading
19
20
  from collections import defaultdict
@@ -31,6 +32,24 @@ if TYPE_CHECKING:
31
32
 
32
33
  logger = logging.getLogger(__name__)
33
34
 
35
+
36
+ def _adj_ttl_seconds() -> float:
37
+ """In-memory adjacency-cache TTL (seconds), env-overridable.
38
+
39
+ v3.8.5: raised from a hard-coded 300s to 3600s. The TTL only exists to
40
+ catch edge-WEIGHT mutations (pruning / MAX-merge) that leave the edge COUNT
41
+ unchanged — new memories already force a reload via the count check. At 300s
42
+ a 208K-edge graph rebuilt on the recall hot path every 5 idle minutes,
43
+ causing a recurring multi-second latency spike. Weight drift is a minor
44
+ ranking refinement, so a longer TTL trades negligible staleness for a big
45
+ latency win. Set SLM_ENTITY_ADJ_TTL_S to tune (0 disables time-based reload;
46
+ the count-based correctness reload always remains).
47
+ """
48
+ try:
49
+ return max(0.0, float(os.environ.get("SLM_ENTITY_ADJ_TTL_S", "3600")))
50
+ except (TypeError, ValueError):
51
+ return 3600.0
52
+
34
53
  _PROPER_NOUN_RE = re.compile(r"\b[A-Z][a-z]{1,}\b")
35
54
 
36
55
  _ENTITY_STOP: frozenset[str] = frozenset({
@@ -156,7 +175,12 @@ class EntityGraphChannel:
156
175
  # count-stable window would otherwise serve a stale adjacency map.
157
176
  import time as _t_ec
158
177
  _now_ec = _t_ec.monotonic()
159
- _fresh = (_now_ec - getattr(self, "_adj_loaded_at", 0.0)) < 300.0
178
+ _ttl = _adj_ttl_seconds()
179
+ # TTL=0 disables the time-based reload entirely (count-based correctness
180
+ # reload still applies); otherwise the cache is fresh within the TTL.
181
+ _fresh = _ttl <= 0.0 or (
182
+ (_now_ec - getattr(self, "_adj_loaded_at", 0.0)) < _ttl
183
+ )
160
184
  if (self._adj_scope_key == scope_key
161
185
  and (self._adj or self._visible_fact_ids)
162
186
  and self._adj_edge_count == current_count
@@ -68,6 +68,21 @@ _SUBPROCESS_RESPONSE_TIMEOUT = 15 # v3.4.52: 15s (was 180s). Long timeout block
68
68
  # scores without reranking.
69
69
  _WORKER_RECYCLE_AFTER = 500 # Recycle after N requests
70
70
 
71
+ # One-time model load is far heavier than a live rerank request: the child
72
+ # process imports torch / sentence-transformers and runs a warmup inference,
73
+ # which measured 9-16s on the reference machine. Sharing the 15s live-request
74
+ # timeout (``_SUBPROCESS_RESPONSE_TIMEOUT``) made the load a coin flip — logs
75
+ # showed ~half of daemon boots hitting "timed out after 15s", killing the
76
+ # worker, and leaving recall on FALLBACK scoring for the entire daemon
77
+ # lifetime (a silent quality regression, not a transient one). The load gets
78
+ # its own generous budget, and the background warmup RETRIES with backoff so a
79
+ # transient slow/failed load self-heals instead of permanently degrading
80
+ # recall quality. Live rerank requests keep the tight 15s cap so a recall
81
+ # never blocks on a sick subprocess.
82
+ _WARMUP_LOAD_TIMEOUT = int(os.environ.get("SLM_RERANKER_WARMUP_TIMEOUT", "90"))
83
+ _WARMUP_MAX_ATTEMPTS = int(os.environ.get("SLM_RERANKER_WARMUP_ATTEMPTS", "5"))
84
+ _WARMUP_RETRY_BACKOFF_S = float(os.environ.get("SLM_RERANKER_WARMUP_BACKOFF", "3"))
85
+
71
86
 
72
87
  class CrossEncoderReranker:
73
88
  """Rerank candidate facts using a local cross-encoder model.
@@ -133,20 +148,72 @@ class CrossEncoderReranker:
133
148
 
134
149
  def _warmup() -> None:
135
150
  try:
136
- self._ensure_worker()
137
- if self._worker_proc is None:
138
- return
139
- resp = self._send_request({
140
- "cmd": "load",
141
- "model_name": self._model_name,
142
- "backend": self._backend,
143
- }, timeout=_SUBPROCESS_RESPONSE_TIMEOUT)
144
- if resp and resp.get("ok"):
145
- self._model_loaded = True
146
- logger.info(
147
- "Reranker worker warm (backend=%s, warmup_inference=%s)",
148
- resp.get("backend", "?"),
149
- resp.get("warmup_inference", False),
151
+ for attempt in range(1, _WARMUP_MAX_ATTEMPTS + 1):
152
+ if self._model_loaded:
153
+ return
154
+ try:
155
+ self._ensure_worker()
156
+ except Exception as exc:
157
+ logger.warning(
158
+ "Reranker warmup attempt %d/%d: worker spawn "
159
+ "raised: %s", attempt, _WARMUP_MAX_ATTEMPTS, exc,
160
+ )
161
+ self._worker_proc = None
162
+
163
+ if self._worker_proc is None:
164
+ # Either the spawn failed, or another process already
165
+ # owns the machine-wide singleton worker. If a sibling
166
+ # worker is alive this instance will use it on demand —
167
+ # stop retrying quietly rather than spinning.
168
+ if _is_reranker_worker_alive():
169
+ logger.debug(
170
+ "Reranker warmup: worker owned by another "
171
+ "process; this instance uses it on demand",
172
+ )
173
+ return
174
+ else:
175
+ # Give the ONE-TIME model load a generous budget — it is
176
+ # far heavier than a live rerank request. On timeout
177
+ # _send_request kills the worker, so the next attempt
178
+ # respawns cleanly (no stale-response race).
179
+ resp = None
180
+ try:
181
+ resp = self._send_request({
182
+ "cmd": "load",
183
+ "model_name": self._model_name,
184
+ "backend": self._backend,
185
+ }, timeout=_WARMUP_LOAD_TIMEOUT)
186
+ except Exception as exc:
187
+ logger.warning(
188
+ "Reranker warmup attempt %d/%d: load request "
189
+ "raised: %s",
190
+ attempt, _WARMUP_MAX_ATTEMPTS, exc,
191
+ )
192
+ if resp and resp.get("ok"):
193
+ self._model_loaded = True
194
+ logger.info(
195
+ "Reranker worker warm (attempt %d/%d, "
196
+ "backend=%s, warmup_inference=%s)",
197
+ attempt, _WARMUP_MAX_ATTEMPTS,
198
+ resp.get("backend", "?"),
199
+ resp.get("warmup_inference", False),
200
+ )
201
+ return
202
+ logger.warning(
203
+ "Reranker warmup attempt %d/%d did not confirm "
204
+ "ready (timeout=%ds); retrying",
205
+ attempt, _WARMUP_MAX_ATTEMPTS, _WARMUP_LOAD_TIMEOUT,
206
+ )
207
+
208
+ if attempt < _WARMUP_MAX_ATTEMPTS and not self._model_loaded:
209
+ time.sleep(min(_WARMUP_RETRY_BACKOFF_S * attempt, 15.0))
210
+
211
+ if not self._model_loaded:
212
+ logger.warning(
213
+ "Reranker warmup exhausted %d attempts; recall uses "
214
+ "fallback scoring until the next rerank triggers a "
215
+ "fresh load. Run 'slm doctor' for diagnostics.",
216
+ _WARMUP_MAX_ATTEMPTS,
150
217
  )
151
218
  except Exception as exc:
152
219
  logger.debug("Background reranker warmup failed: %s", exc)
@@ -337,6 +404,12 @@ class CrossEncoderReranker:
337
404
  # Detach first so re-entrant/finalizer cleanup is idempotent.
338
405
  self._worker_proc = None
339
406
  self._worker_ready = False
407
+ # Invariant: a dead worker has no loaded model. Enforcing this in
408
+ # ONE place (not just the recycle/timeout callers) means the idle
409
+ # timer's kill also clears the flag, so the recall path sees the
410
+ # gap and triggers a background re-warmup instead of sending a
411
+ # rerank to a cold worker and risking a 15s-timeout churn.
412
+ self._model_loaded = False
340
413
  try:
341
414
  proc.stdin.write('{"cmd":"quit"}\n')
342
415
  proc.stdin.flush()
@@ -411,8 +484,18 @@ class CrossEncoderReranker:
411
484
  if not candidates:
412
485
  return [], False, "no_candidates"
413
486
 
414
- # Non-blocking: if model isn't loaded yet, return fallback
487
+ # Non-blocking: if the model isn't loaded, return fallback AND kick a
488
+ # background (re)warmup so the reranker SELF-HEALS. Without this, a
489
+ # worker that was recycled (every 500 reqs), idle-killed (30 min), or
490
+ # crashed left ``_model_loaded`` False with nothing to reload it — every
491
+ # subsequent recall degraded to fallback scoring until the daemon was
492
+ # restarted (a silent, sustained quality regression). The warmup is
493
+ # guarded (no-op if already loading/loaded), retried, and never blocks
494
+ # this recall — it returns fallback now and full quality resumes within
495
+ # seconds once the model is warm again.
415
496
  if not self._model_loaded:
497
+ if not self._worker_loading:
498
+ self._start_background_warmup()
416
499
  sorted_cands = sorted(candidates, key=lambda x: x[1], reverse=True)
417
500
  return sorted_cands[:top_k], False, "fallback_not_ready"
418
501
 
@@ -224,8 +224,13 @@ class SpreadingActivation:
224
224
  if not self._fok_check(activations):
225
225
  return []
226
226
 
227
- # Cache results
228
- self._cache_results(query_hash, profile_id, activations)
227
+ # Cache results — DEFERRED so recall stays READ-ONLY on its hot
228
+ # path. This is a perf cache for FUTURE recalls, not needed to
229
+ # return the current results.
230
+ from superlocalmemory.storage.deferred_writes import submit_background
231
+ submit_background(
232
+ lambda: self._cache_results(query_hash, profile_id, activations)
233
+ )
229
234
 
230
235
  # Return top-K sorted by activation
231
236
  results = sorted(
@@ -545,16 +550,19 @@ class SpreadingActivation:
545
550
  ) -> None:
546
551
  """Store results in activation_cache with 1-hour TTL."""
547
552
  try:
548
- for node_id, value in activations.items():
549
- self._db.execute(
550
- "INSERT OR REPLACE INTO activation_cache "
551
- "(cache_id, profile_id, query_hash, node_id, "
552
- " activation_value, iteration, created_at, expires_at) "
553
- "VALUES (?, ?, ?, ?, ?, ?, datetime('now'), "
554
- "datetime('now', '+1 hour'))",
555
- (_new_id(), profile_id, query_hash, node_id, value,
556
- self._config.max_iterations),
557
- )
553
+ # One transaction => ONE write-lock acquisition for the whole
554
+ # activation cache, instead of N separately-locked writes.
555
+ with self._db.transaction():
556
+ for node_id, value in activations.items():
557
+ self._db.execute(
558
+ "INSERT OR REPLACE INTO activation_cache "
559
+ "(cache_id, profile_id, query_hash, node_id, "
560
+ " activation_value, iteration, created_at, expires_at) "
561
+ "VALUES (?, ?, ?, ?, ?, ?, datetime('now'), "
562
+ "datetime('now', '+1 hour'))",
563
+ (_new_id(), profile_id, query_hash, node_id, value,
564
+ self._config.max_iterations),
565
+ )
558
566
  except Exception as exc:
559
567
  logger.debug("Cache write failed: %s", exc)
560
568