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.
- package/CHANGELOG.md +76 -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 +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- 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/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- 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 +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- 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/graph/cozo_backend.py +5 -5
- 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/bandit.py +50 -1
- 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/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- 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 +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -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/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- 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__(
|
|
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
|
|
97
|
+
return self._unavailable_response()
|
|
79
98
|
if not isinstance(data, dict):
|
|
80
|
-
return
|
|
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
|
|
125
|
+
return self._unavailable_response()
|
|
105
126
|
if not isinstance(data, dict):
|
|
106
|
-
return
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
122
|
-
|
|
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
|
|
126
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|