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.
- package/CHANGELOG.md +57 -0
- package/README.md +3 -2
- 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/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +19 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/engine.py +92 -11
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +160 -31
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/recall_pipeline.py +3 -0
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -1
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +98 -15
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +91 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +6 -12
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/helpers.py +24 -13
- package/src/superlocalmemory/server/routes/memories.py +139 -91
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +42 -30
- package/src/superlocalmemory/server/routes/v3_api.py +67 -77
- package/src/superlocalmemory/server/unified_daemon.py +283 -39
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/database.py +109 -19
- package/src/superlocalmemory/storage/deferred_writes.py +153 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +119 -0
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|