superlocalmemory 3.8.3 → 3.8.6

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 (125) hide show
  1. package/CHANGELOG.md +76 -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 +9 -4
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +158 -404
  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/component_registry.py +4 -2
  40. package/src/superlocalmemory/core/config.py +78 -0
  41. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  42. package/src/superlocalmemory/core/embeddings.py +33 -6
  43. package/src/superlocalmemory/core/engine.py +186 -60
  44. package/src/superlocalmemory/core/engine_ingestion.py +150 -63
  45. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  46. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  47. package/src/superlocalmemory/core/ingestion_command.py +273 -32
  48. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  49. package/src/superlocalmemory/core/mutations.py +32 -10
  50. package/src/superlocalmemory/core/recall_pipeline.py +111 -74
  51. package/src/superlocalmemory/core/registry.py +5 -1
  52. package/src/superlocalmemory/core/remember_admission.py +152 -0
  53. package/src/superlocalmemory/core/remember_runtime.py +712 -0
  54. package/src/superlocalmemory/core/remote_mode.py +3 -1
  55. package/src/superlocalmemory/core/scale_engine.py +41 -18
  56. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  57. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  58. package/src/superlocalmemory/graph/cozo_backend.py +5 -5
  59. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  60. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  61. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  62. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  63. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  64. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  65. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  66. package/src/superlocalmemory/infra/event_bus.py +250 -88
  67. package/src/superlocalmemory/learning/bandit.py +50 -1
  68. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  69. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  70. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  71. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  72. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  73. package/src/superlocalmemory/learning/source_quality.py +38 -35
  74. package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
  75. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  76. package/src/superlocalmemory/mcp/tools_active.py +4 -41
  77. package/src/superlocalmemory/mcp/tools_core.py +26 -87
  78. package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
  79. package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
  80. package/src/superlocalmemory/retrieval/engine.py +15 -4
  81. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  82. package/src/superlocalmemory/retrieval/reranker.py +130 -22
  83. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  84. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  85. package/src/superlocalmemory/server/loopback.py +85 -0
  86. package/src/superlocalmemory/server/origin.py +9 -4
  87. package/src/superlocalmemory/server/profile_runtime.py +14 -0
  88. package/src/superlocalmemory/server/routes/abstraction.py +2 -4
  89. package/src/superlocalmemory/server/routes/agents.py +3 -5
  90. package/src/superlocalmemory/server/routes/backup.py +6 -2
  91. package/src/superlocalmemory/server/routes/behavioral.py +11 -25
  92. package/src/superlocalmemory/server/routes/brain.py +6 -9
  93. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  94. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  95. package/src/superlocalmemory/server/routes/entity.py +3 -7
  96. package/src/superlocalmemory/server/routes/evolution.py +3 -5
  97. package/src/superlocalmemory/server/routes/helpers.py +57 -25
  98. package/src/superlocalmemory/server/routes/insights.py +2 -4
  99. package/src/superlocalmemory/server/routes/learning.py +2 -5
  100. package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
  101. package/src/superlocalmemory/server/routes/memories.py +119 -98
  102. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  103. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  104. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  105. package/src/superlocalmemory/server/routes/tiers.py +28 -35
  106. package/src/superlocalmemory/server/routes/timeline.py +2 -4
  107. package/src/superlocalmemory/server/routes/v3_api.py +85 -93
  108. package/src/superlocalmemory/server/unified_daemon.py +400 -140
  109. package/src/superlocalmemory/server/write_identity.py +22 -4
  110. package/src/superlocalmemory/storage/admission_codec.py +119 -0
  111. package/src/superlocalmemory/storage/admission_journal.py +728 -0
  112. package/src/superlocalmemory/storage/database.py +168 -19
  113. package/src/superlocalmemory/storage/deferred_writes.py +209 -0
  114. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  115. package/src/superlocalmemory/storage/memory_write.py +115 -0
  116. package/src/superlocalmemory/storage/migration_runner.py +44 -0
  117. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  118. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  119. package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
  120. package/src/superlocalmemory/storage/read_connection.py +115 -0
  121. package/src/superlocalmemory/storage/write_coordinator.py +756 -0
  122. package/src/superlocalmemory/storage/write_lock.py +88 -0
  123. package/src/superlocalmemory/ui/index.html +1 -1
  124. package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
  125. package/src/superlocalmemory/ui/js/od-settings.js +9 -3
@@ -34,9 +34,26 @@ class DaemonPoolProxy:
34
34
  envelopes — the adapter is responsible for surfacing those.
35
35
  """
36
36
 
37
- def __init__(self, port: int, *, timeout_s: float = 30.0) -> None: # v3.4.59: 8s→30s — observed recall takes 13.4s on dense graph (2.1M edges); 8s always timed out → degraded mode
37
+ def __init__(
38
+ self,
39
+ port: int | None,
40
+ *,
41
+ timeout_s: float = 30.0,
42
+ unavailable: bool = False,
43
+ ) -> None:
44
+ # v3.4.59: 8s→30s — dense graph recall can exceed the old timeout.
38
45
  self._port = port
39
46
  self._timeout = timeout_s
47
+ self._unavailable = unavailable
48
+
49
+ @staticmethod
50
+ def _unavailable_response() -> dict[str, Any]:
51
+ return {
52
+ "ok": False,
53
+ "code": "DAEMON_UNAVAILABLE",
54
+ "retryable": True,
55
+ "error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
56
+ }
40
57
 
41
58
  def recall(
42
59
  self, query: str, limit: int = 10, session_id: str = "",
@@ -45,6 +62,8 @@ class DaemonPoolProxy:
45
62
  include_shared: bool | None = None,
46
63
  window: str | None = None,
47
64
  ) -> dict[str, Any]:
65
+ if self._unavailable:
66
+ return self._unavailable_response()
48
67
  _params: dict[str, Any] = {
49
68
  "q": query,
50
69
  "limit": limit,
@@ -75,15 +94,17 @@ class DaemonPoolProxy:
75
94
  )
76
95
  except Exception as exc:
77
96
  logger.warning("daemon /recall failed: %s", exc)
78
- return {"ok": False, "error": str(exc)}
97
+ return self._unavailable_response()
79
98
  if not isinstance(data, dict):
80
- return {"ok": False, "error": "owned daemon unavailable"}
99
+ return self._unavailable_response()
81
100
  data.setdefault("ok", True)
82
101
  return data
83
102
 
84
103
  def store(
85
104
  self, content: str, metadata: dict | None = None,
86
105
  ) -> dict[str, Any]:
106
+ if self._unavailable:
107
+ return self._unavailable_response()
87
108
  body = {
88
109
  "content": content,
89
110
  "tags": (metadata or {}).get("tags", ""),
@@ -101,9 +122,9 @@ class DaemonPoolProxy:
101
122
  data = daemon_request("POST", "/remember", body)
102
123
  except Exception as exc:
103
124
  logger.warning("daemon /remember failed: %s", exc)
104
- return {"ok": False, "error": str(exc)}
125
+ return self._unavailable_response()
105
126
  if not isinstance(data, dict):
106
- return {"ok": False, "error": "owned daemon unavailable"}
127
+ return self._unavailable_response()
107
128
  data.setdefault("ok", True)
108
129
  return data
109
130
 
@@ -111,17 +132,19 @@ class DaemonPoolProxy:
111
132
  def choose_pool() -> Any:
112
133
  """Return the best available pool for this MCP process.
113
134
 
114
- Preference order:
115
- 1. Running daemon use HTTP proxy (keeps ONNX in ONE process)
116
- 2. No daemon fall back to ``WorkerPool.shared()`` (spawns a
117
- local subprocess with a FULL engine). This keeps single-user
118
- / first-launch scenarios working.
135
+ The daemon is the sole canonical writer. A bounded daemon auto-start is
136
+ attempted for first use; if it cannot become healthy, return a facade that
137
+ reports a retryable ``DAEMON_UNAVAILABLE`` envelope. Never construct a
138
+ process-local ``WorkerPool`` from an MCP client.
119
139
  """
120
140
  try:
121
- from superlocalmemory.cli.daemon import _get_port, is_daemon_running
122
- if is_daemon_running():
141
+ from superlocalmemory.cli.daemon import (
142
+ _get_port,
143
+ ensure_daemon,
144
+ is_daemon_running,
145
+ )
146
+ if is_daemon_running() or ensure_daemon():
123
147
  return DaemonPoolProxy(port=_get_port())
124
148
  except Exception as exc:
125
- logger.warning("daemon probe failed falling back to subprocess pool: %s", exc)
126
- from superlocalmemory.core.worker_pool import WorkerPool
127
- return WorkerPool.shared()
149
+ logger.warning("daemon probe or bounded start failed: %s", exc)
150
+ return DaemonPoolProxy(port=None, unavailable=True)
@@ -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()
@@ -19,12 +19,12 @@ from __future__ import annotations
19
19
  import asyncio
20
20
  import datetime
21
21
  import logging
22
- import sqlite3
23
22
  import uuid
24
23
  from typing import TYPE_CHECKING, Callable
25
24
 
26
- from superlocalmemory.infra.data_root import canonical_data_root, state_path
25
+ from superlocalmemory.infra.data_root import state_path
27
26
  from superlocalmemory.mcp.shared import authorize_mcp_mutation
27
+ from superlocalmemory.storage.read_connection import ReadConnectionFactory
28
28
 
29
29
  if TYPE_CHECKING:
30
30
  from superlocalmemory.mcp._pool_adapter import PoolRecallResponse
@@ -67,7 +67,8 @@ def _sqlite_emergency_recall(
67
67
  f"AND f.created_at >= datetime('now', '-{int(max_age_days)} days') "
68
68
  if max_age_days > 0 else ""
69
69
  )
70
- conn = sqlite3.connect(str(state_path("memory.db")), timeout=5.0)
70
+ memory_db = state_path("memory.db").resolve()
71
+ conn = ReadConnectionFactory(memory_db, timeout_ms=250).open()
71
72
  try:
72
73
  rows = conn.execute(
73
74
  f"""SELECT f.fact_id, f.content, f.memory_id, f.created_at,
@@ -139,21 +140,6 @@ def _emit_event(event_type: str, payload: dict | None = None,
139
140
  logger.warning("event emit failed: type=%s err=%s", event_type, exc)
140
141
 
141
142
 
142
- def _register_agent(agent_id: str, profile_id: str) -> bool:
143
- """Register an agent in the AgentRegistry (best-effort)."""
144
- try:
145
- from superlocalmemory.core.registry import AgentRegistry
146
- registry_path = canonical_data_root() / "agents.json"
147
- registry = AgentRegistry(persist_path=registry_path)
148
- registry.register_agent(agent_id, profile_id)
149
- return True
150
- except Exception as exc:
151
- logger.warning(
152
- "agent registry write failed: agent=%s err=%s", agent_id, exc,
153
- )
154
- return False
155
-
156
-
157
143
  def register_active_tools(server, get_engine: Callable) -> None:
158
144
  """Register 3 active memory tools on *server*."""
159
145
 
@@ -401,29 +387,6 @@ def register_active_tools(server, get_engine: Callable) -> None:
401
387
  f"-{uuid.uuid4().hex[:8]}"
402
388
  )
403
389
 
404
- # Register agent + emit event (v3.4.39: SLM_AGENT_ID env support)
405
- agent_id = _get_agent_id()
406
- if hasattr(engine, "_hooks"):
407
- registration_auth = authorize_mcp_mutation(
408
- engine,
409
- "update",
410
- mutation_source="mcp-agent-registration",
411
- profile_id=pid,
412
- )
413
- if _register_agent(agent_id, pid):
414
- registration_auth.complete()
415
- else:
416
- # A LIGHT client without the policy registry is read-capable,
417
- # but must not fall back to an unauthorised registry write.
418
- logger.info(
419
- "agent registration skipped: policy hooks unavailable"
420
- )
421
- _emit_event("agent.connected", {
422
- "agent_id": agent_id,
423
- "project_path": project_path,
424
- "memory_count": len(memories),
425
- })
426
-
427
390
  return {
428
391
  "success": True,
429
392
  "session_id": session_id,