superlocalmemory 3.4.24 → 3.4.30
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 +43 -0
- package/README.md +8 -1
- package/package.json +1 -1
- package/pyproject.toml +3 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +3 -0
- package/src/superlocalmemory/cli/daemon.py +90 -16
- package/src/superlocalmemory/cli/doctor_cmd.py +152 -0
- package/src/superlocalmemory/cli/main.py +28 -0
- package/src/superlocalmemory/cli/post_install.py +15 -0
- package/src/superlocalmemory/cli/setup_wizard.py +20 -0
- package/src/superlocalmemory/cli/version_banner.py +183 -0
- package/src/superlocalmemory/cli/wizard_v3426_options.py +129 -0
- package/src/superlocalmemory/core/clock_monitor.py +45 -0
- package/src/superlocalmemory/core/db_pool.py +80 -0
- package/src/superlocalmemory/core/engine.py +75 -30
- package/src/superlocalmemory/core/engine_capabilities.py +24 -0
- package/src/superlocalmemory/core/engine_lock.py +75 -0
- package/src/superlocalmemory/core/error_catalog.py +113 -0
- package/src/superlocalmemory/core/error_envelope.py +60 -0
- package/src/superlocalmemory/core/file_lock.py +92 -0
- package/src/superlocalmemory/core/loop_watchdog.py +56 -0
- package/src/superlocalmemory/core/priority_queue.py +61 -0
- package/src/superlocalmemory/core/queue_dispatcher.py +73 -0
- package/src/superlocalmemory/core/rate_limit.py +151 -0
- package/src/superlocalmemory/core/recall_pipeline.py +2 -0
- package/src/superlocalmemory/core/recall_queue.py +370 -0
- package/src/superlocalmemory/core/recall_worker.py +10 -0
- package/src/superlocalmemory/core/safe_fs.py +108 -0
- package/src/superlocalmemory/hooks/auto_capture.py +34 -12
- package/src/superlocalmemory/hooks/auto_recall.py +36 -9
- package/src/superlocalmemory/mcp/_daemon_proxy.py +107 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +121 -0
- package/src/superlocalmemory/mcp/resources.py +8 -5
- package/src/superlocalmemory/mcp/server.py +38 -9
- package/src/superlocalmemory/mcp/tools_active.py +21 -9
- package/src/superlocalmemory/mcp/tools_core.py +13 -9
- package/src/superlocalmemory/mcp/tools_evolution.py +4 -2
- package/src/superlocalmemory/mcp/tools_learning.py +5 -3
- package/src/superlocalmemory/mcp/tools_mesh.py +5 -3
- package/src/superlocalmemory/mcp/tools_v3.py +18 -22
- package/src/superlocalmemory/mcp/tools_v33.py +65 -2
- package/src/superlocalmemory/migrations/__init__.py +5 -0
- package/src/superlocalmemory/migrations/v3_4_25_to_v3_4_26.py +144 -0
- package/src/superlocalmemory/server/routes/events.py +1 -1
- package/src/superlocalmemory/server/routes/v3_api.py +0 -1
- package/src/superlocalmemory/server/unified_daemon.py +128 -12
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import logging
|
|
10
10
|
import re
|
|
11
11
|
from dataclasses import dataclass
|
|
12
|
-
from typing import Any
|
|
12
|
+
from typing import Any, Callable
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
@@ -41,10 +41,25 @@ class CaptureDecision:
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
class AutoCapture:
|
|
44
|
-
"""Detect and classify content for automatic storage.
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
"""Detect and classify content for automatic storage.
|
|
45
|
+
|
|
46
|
+
Two ways to wire the store side:
|
|
47
|
+
|
|
48
|
+
- Pass ``engine=<MemoryEngine>`` (CLI/daemon path — historical shape).
|
|
49
|
+
- Pass ``store_fn=<callable>`` (MCP/LIGHT path — the callable should
|
|
50
|
+
match ``MemoryEngine.store(content, metadata=...)`` and return a
|
|
51
|
+
list of fact ids). When both are supplied, ``store_fn`` wins.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
engine=None,
|
|
57
|
+
config: dict | None = None,
|
|
58
|
+
*,
|
|
59
|
+
store_fn: Callable[..., Any] | None = None,
|
|
60
|
+
):
|
|
47
61
|
self._engine = engine
|
|
62
|
+
self._store_fn = store_fn
|
|
48
63
|
self._config = config or {}
|
|
49
64
|
self._enabled = self._config.get("enabled", True)
|
|
50
65
|
self._min_confidence = self._config.get("min_confidence", 0.5)
|
|
@@ -85,18 +100,25 @@ class AutoCapture:
|
|
|
85
100
|
return CaptureDecision(False, 0.0, "none", "no patterns matched")
|
|
86
101
|
|
|
87
102
|
def capture(self, content: str, category: str = "", metadata: dict | None = None) -> bool:
|
|
88
|
-
"""Store content via engine if auto-capture decides to.
|
|
89
|
-
|
|
103
|
+
"""Store content via engine or store_fn if auto-capture decides to.
|
|
104
|
+
|
|
105
|
+
Never mutates the caller's metadata dict — we copy before adding
|
|
106
|
+
our auto-capture bookkeeping keys. This matters because
|
|
107
|
+
``pool_store`` ships the dict cross-process and callers often
|
|
108
|
+
reuse the same dict across captures.
|
|
109
|
+
"""
|
|
110
|
+
if self._store_fn is None and self._engine is None:
|
|
90
111
|
return False
|
|
91
112
|
|
|
92
113
|
try:
|
|
93
|
-
meta = metadata or {}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
meta = {**(metadata or {}), "source": "auto-capture", "category": category}
|
|
115
|
+
if self._store_fn is not None:
|
|
116
|
+
fact_ids = self._store_fn(content, metadata=meta)
|
|
117
|
+
else:
|
|
118
|
+
fact_ids = self._engine.store(content, metadata=meta)
|
|
119
|
+
return bool(fact_ids) and len(fact_ids) > 0
|
|
98
120
|
except Exception as exc:
|
|
99
|
-
logger.
|
|
121
|
+
logger.warning("Auto-capture store failed: %s", exc)
|
|
100
122
|
return False
|
|
101
123
|
|
|
102
124
|
def _match_patterns(self, content: str, patterns: list[str]) -> float:
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import logging
|
|
10
|
-
from typing import Any
|
|
10
|
+
from typing import Any, Callable
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger(__name__)
|
|
13
13
|
|
|
@@ -17,30 +17,53 @@ class AutoRecall:
|
|
|
17
17
|
|
|
18
18
|
Called at session start or before each prompt to inject
|
|
19
19
|
relevant memories without user intervention.
|
|
20
|
+
|
|
21
|
+
Two ways to wire the recall side:
|
|
22
|
+
|
|
23
|
+
- Pass ``engine=<MemoryEngine>`` (CLI/daemon path — historical shape).
|
|
24
|
+
- Pass ``recall_fn=<callable>`` (MCP/LIGHT path — the callable adapts
|
|
25
|
+
a worker-pool call to the ``RecallResponse``-shaped return value).
|
|
26
|
+
When both are supplied, ``recall_fn`` wins.
|
|
20
27
|
"""
|
|
21
28
|
|
|
22
|
-
def __init__(
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
engine=None,
|
|
32
|
+
config: dict | None = None,
|
|
33
|
+
*,
|
|
34
|
+
recall_fn: Callable[..., Any] | None = None,
|
|
35
|
+
):
|
|
23
36
|
self._engine = engine
|
|
37
|
+
self._recall_fn = recall_fn
|
|
24
38
|
self._config = config or {}
|
|
25
39
|
self._enabled = self._config.get("enabled", True)
|
|
26
40
|
self._max_memories = self._config.get("max_memories_injected", 10)
|
|
27
41
|
self._threshold = self._config.get("relevance_threshold", 0.3)
|
|
28
42
|
|
|
43
|
+
def _recall(self, query: str, limit: int):
|
|
44
|
+
if self._recall_fn is not None:
|
|
45
|
+
return self._recall_fn(query, limit=limit)
|
|
46
|
+
if self._engine is not None:
|
|
47
|
+
return self._engine.recall(query, limit=limit)
|
|
48
|
+
return None
|
|
49
|
+
|
|
29
50
|
def get_session_context(self, project_path: str = "", query: str = "") -> str:
|
|
30
51
|
"""Get relevant context for a session or query.
|
|
31
52
|
|
|
32
53
|
Returns a formatted string of relevant memories suitable
|
|
33
54
|
for injection into an AI's system prompt.
|
|
34
55
|
"""
|
|
35
|
-
if not self._enabled
|
|
56
|
+
if not self._enabled:
|
|
57
|
+
return ""
|
|
58
|
+
if self._recall_fn is None and self._engine is None:
|
|
36
59
|
return ""
|
|
37
60
|
|
|
38
61
|
try:
|
|
39
62
|
# Build query from project path or explicit query
|
|
40
63
|
search_query = query or f"project context {project_path}"
|
|
41
|
-
response = self.
|
|
64
|
+
response = self._recall(search_query, self._max_memories)
|
|
42
65
|
|
|
43
|
-
if not response.results:
|
|
66
|
+
if response is None or not response.results:
|
|
44
67
|
return ""
|
|
45
68
|
|
|
46
69
|
# Filter by relevance threshold
|
|
@@ -56,7 +79,7 @@ class AutoRecall:
|
|
|
56
79
|
|
|
57
80
|
return "\n".join(lines)
|
|
58
81
|
except Exception as exc:
|
|
59
|
-
logger.
|
|
82
|
+
logger.warning("Auto-recall failed: %s", exc)
|
|
60
83
|
return ""
|
|
61
84
|
|
|
62
85
|
def get_query_context(self, query: str) -> list[dict]:
|
|
@@ -64,11 +87,15 @@ class AutoRecall:
|
|
|
64
87
|
|
|
65
88
|
Returns structured data (not formatted string) for MCP tools.
|
|
66
89
|
"""
|
|
67
|
-
if not self._enabled
|
|
90
|
+
if not self._enabled:
|
|
91
|
+
return []
|
|
92
|
+
if self._recall_fn is None and self._engine is None:
|
|
68
93
|
return []
|
|
69
94
|
|
|
70
95
|
try:
|
|
71
|
-
response = self.
|
|
96
|
+
response = self._recall(query, self._max_memories)
|
|
97
|
+
if response is None:
|
|
98
|
+
return []
|
|
72
99
|
results = []
|
|
73
100
|
for r in response.results:
|
|
74
101
|
if r.score >= self._threshold:
|
|
@@ -79,7 +106,7 @@ class AutoRecall:
|
|
|
79
106
|
})
|
|
80
107
|
return results
|
|
81
108
|
except Exception as exc:
|
|
82
|
-
logger.
|
|
109
|
+
logger.warning("Auto-recall query failed: %s", exc)
|
|
83
110
|
return []
|
|
84
111
|
|
|
85
112
|
@property
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""HTTP proxy that lets MCP processes use the daemon as their worker.
|
|
6
|
+
|
|
7
|
+
Without this, every MCP process (one per IDE) would spawn its own
|
|
8
|
+
``recall_worker`` subprocess through ``WorkerPool.shared()`` and load
|
|
9
|
+
the ONNX embedder into that subprocess. With N IDEs open the total
|
|
10
|
+
RSS was approximately N x 1.6 GB — the exact failure Path B was built
|
|
11
|
+
to avoid.
|
|
12
|
+
|
|
13
|
+
With this proxy, the MCP process opens an HTTP connection to the
|
|
14
|
+
single long-lived daemon (already running for dashboard / mesh /
|
|
15
|
+
health) and forwards ``recall`` and ``store`` calls there. Heavy
|
|
16
|
+
engine state exists in exactly one process: the daemon.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DaemonPoolProxy:
|
|
30
|
+
""":class:`WorkerPool`-shaped facade that talks to the daemon over HTTP.
|
|
31
|
+
|
|
32
|
+
The shape matches ``WorkerPool.recall`` / ``WorkerPool.store`` so that
|
|
33
|
+
the existing pool adapter in ``mcp/_pool_adapter.py`` can swap between
|
|
34
|
+
a local subprocess pool and the daemon proxy without any adapter
|
|
35
|
+
change. Errors are returned as ``{"ok": False, "error": "..."}``
|
|
36
|
+
envelopes — the adapter is responsible for surfacing those.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, port: int, *, timeout_s: float = 30.0) -> None:
|
|
40
|
+
self._port = port
|
|
41
|
+
self._timeout = timeout_s
|
|
42
|
+
|
|
43
|
+
def _url(self, path: str) -> str:
|
|
44
|
+
return f"http://127.0.0.1:{self._port}{path}"
|
|
45
|
+
|
|
46
|
+
def recall(
|
|
47
|
+
self, query: str, limit: int = 10, session_id: str = "",
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
params = urllib.parse.urlencode(
|
|
50
|
+
{"q": query, "limit": limit, "session_id": session_id or ""}
|
|
51
|
+
)
|
|
52
|
+
try:
|
|
53
|
+
with urllib.request.urlopen(
|
|
54
|
+
self._url(f"/recall?{params}"), timeout=self._timeout,
|
|
55
|
+
) as resp:
|
|
56
|
+
data = json.loads(resp.read().decode() or "{}")
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
logger.warning("daemon /recall failed: %s", exc)
|
|
59
|
+
return {"ok": False, "error": str(exc)}
|
|
60
|
+
if not isinstance(data, dict):
|
|
61
|
+
return {"ok": False, "error": "non-dict response"}
|
|
62
|
+
data.setdefault("ok", True)
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
def store(
|
|
66
|
+
self, content: str, metadata: dict | None = None,
|
|
67
|
+
) -> dict[str, Any]:
|
|
68
|
+
body = json.dumps({
|
|
69
|
+
"content": content,
|
|
70
|
+
"tags": (metadata or {}).get("tags", ""),
|
|
71
|
+
"metadata": metadata or {},
|
|
72
|
+
}).encode()
|
|
73
|
+
req = urllib.request.Request(
|
|
74
|
+
self._url("/remember"),
|
|
75
|
+
data=body,
|
|
76
|
+
headers={"Content-Type": "application/json"},
|
|
77
|
+
method="POST",
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
81
|
+
data = json.loads(resp.read().decode() or "{}")
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
logger.warning("daemon /remember failed: %s", exc)
|
|
84
|
+
return {"ok": False, "error": str(exc)}
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
return {"ok": False, "error": "non-dict response"}
|
|
87
|
+
data.setdefault("ok", True)
|
|
88
|
+
return data
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def choose_pool() -> Any:
|
|
92
|
+
"""Return the best available pool for this MCP process.
|
|
93
|
+
|
|
94
|
+
Preference order:
|
|
95
|
+
1. Running daemon — use HTTP proxy (keeps ONNX in ONE process)
|
|
96
|
+
2. No daemon — fall back to ``WorkerPool.shared()`` (spawns a
|
|
97
|
+
local subprocess with a FULL engine). This keeps single-user
|
|
98
|
+
/ first-launch scenarios working.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
from superlocalmemory.cli.daemon import _get_port, is_daemon_running
|
|
102
|
+
if is_daemon_running():
|
|
103
|
+
return DaemonPoolProxy(port=_get_port())
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
logger.warning("daemon probe failed — falling back to subprocess pool: %s", exc)
|
|
106
|
+
from superlocalmemory.core.worker_pool import WorkerPool
|
|
107
|
+
return WorkerPool.shared()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""MCP-side adapters onto the pool (daemon HTTP or local subprocess).
|
|
6
|
+
|
|
7
|
+
The pool returns plain dicts with an ``ok`` flag. Hooks
|
|
8
|
+
(``AutoRecall`` / ``AutoCapture``) expect a ``RecallResponse``-shaped
|
|
9
|
+
object and a list of fact ids. These adapters bridge the two.
|
|
10
|
+
|
|
11
|
+
On ``{"ok": False, "error": "..."}`` the adapters raise
|
|
12
|
+
:class:`PoolError` instead of silently returning empty results — worker
|
|
13
|
+
death must be distinguishable from "no memories" on the user side.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class PoolFact:
|
|
26
|
+
fact_id: str = ""
|
|
27
|
+
content: str = ""
|
|
28
|
+
memory_id: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class PoolRecallItem:
|
|
33
|
+
fact: PoolFact = field(default_factory=PoolFact)
|
|
34
|
+
score: float = 0.0
|
|
35
|
+
confidence: float = 0.0
|
|
36
|
+
trust_score: float = 0.0
|
|
37
|
+
channel_scores: dict[str, float] = field(default_factory=dict)
|
|
38
|
+
evidence_chain: list[Any] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class PoolRecallResponse:
|
|
43
|
+
results: list[PoolRecallItem] = field(default_factory=list)
|
|
44
|
+
query_type: str = ""
|
|
45
|
+
retrieval_time_ms: float = 0.0
|
|
46
|
+
channel_weights: dict[str, float] = field(default_factory=dict)
|
|
47
|
+
total_candidates: int = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PoolError(RuntimeError):
|
|
51
|
+
"""Raised when the pool returns an error envelope.
|
|
52
|
+
|
|
53
|
+
Callers that want the old silent-empty behaviour (e.g. hook paths
|
|
54
|
+
that must never break the user's session) catch this and log at
|
|
55
|
+
WARNING. Callers that want to surface the failure (e.g. dashboard
|
|
56
|
+
resources) let it propagate.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pool():
|
|
61
|
+
"""Lazy pool factory — prefers the daemon HTTP proxy.
|
|
62
|
+
|
|
63
|
+
Split out so tests can monkey-patch the factory without touching
|
|
64
|
+
the real ``WorkerPool.shared()`` singleton.
|
|
65
|
+
"""
|
|
66
|
+
from superlocalmemory.mcp._daemon_proxy import choose_pool
|
|
67
|
+
return choose_pool()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _unwrap_error(raw: Any, op: str) -> None:
|
|
71
|
+
"""Raise PoolError if the pool returned an ``{"ok": False}`` envelope."""
|
|
72
|
+
if isinstance(raw, dict) and raw.get("ok") is False:
|
|
73
|
+
reason = raw.get("error") or "pool returned ok=False"
|
|
74
|
+
raise PoolError(f"pool.{op} failed: {reason}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pool_recall(query: str, limit: int = 10, **_: Any) -> PoolRecallResponse:
|
|
78
|
+
"""Call pool.recall and reshape its dict into a typed response.
|
|
79
|
+
|
|
80
|
+
Raises :class:`PoolError` on worker death or any non-ok envelope.
|
|
81
|
+
"""
|
|
82
|
+
raw = _pool().recall(query=query, limit=limit)
|
|
83
|
+
_unwrap_error(raw, "recall")
|
|
84
|
+
items = raw.get("results", []) if isinstance(raw, dict) else []
|
|
85
|
+
results = [
|
|
86
|
+
PoolRecallItem(
|
|
87
|
+
fact=PoolFact(
|
|
88
|
+
fact_id=item.get("fact_id", ""),
|
|
89
|
+
content=item.get("content", ""),
|
|
90
|
+
memory_id=item.get("memory_id", ""),
|
|
91
|
+
),
|
|
92
|
+
score=float(item.get("score", 0.0)),
|
|
93
|
+
confidence=float(item.get("confidence", 0.0)),
|
|
94
|
+
trust_score=float(item.get("trust_score", 0.0)),
|
|
95
|
+
channel_scores=item.get("channel_scores", {}) or {},
|
|
96
|
+
evidence_chain=list(item.get("evidence_chain", []) or []),
|
|
97
|
+
)
|
|
98
|
+
for item in items
|
|
99
|
+
]
|
|
100
|
+
return PoolRecallResponse(
|
|
101
|
+
results=results,
|
|
102
|
+
query_type=raw.get("query_type", "") if isinstance(raw, dict) else "",
|
|
103
|
+
retrieval_time_ms=float(raw.get("retrieval_time_ms", 0.0))
|
|
104
|
+
if isinstance(raw, dict) else 0.0,
|
|
105
|
+
channel_weights=raw.get("channel_weights", {})
|
|
106
|
+
if isinstance(raw, dict) else {},
|
|
107
|
+
total_candidates=int(raw.get("total_candidates", 0))
|
|
108
|
+
if isinstance(raw, dict) else 0,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def pool_store(content: str, metadata: dict | None = None) -> list[str]:
|
|
113
|
+
"""Call pool.store and return the fact id list.
|
|
114
|
+
|
|
115
|
+
Raises :class:`PoolError` on worker death or any non-ok envelope.
|
|
116
|
+
"""
|
|
117
|
+
raw = _pool().store(content=content, metadata=metadata or {})
|
|
118
|
+
_unwrap_error(raw, "store")
|
|
119
|
+
if isinstance(raw, dict):
|
|
120
|
+
return list(raw.get("fact_ids", []))
|
|
121
|
+
return []
|
|
@@ -35,9 +35,9 @@ def register_resources(server, get_engine: Callable) -> None:
|
|
|
35
35
|
"""
|
|
36
36
|
try:
|
|
37
37
|
from superlocalmemory.hooks.auto_recall import AutoRecall
|
|
38
|
-
|
|
38
|
+
from superlocalmemory.mcp._pool_adapter import pool_recall
|
|
39
39
|
auto = AutoRecall(
|
|
40
|
-
|
|
40
|
+
recall_fn=pool_recall,
|
|
41
41
|
config={"enabled": True, "max_memories_injected": 10, "relevance_threshold": 0.3},
|
|
42
42
|
)
|
|
43
43
|
context = auto.get_session_context(query="recent decisions and important context")
|
|
@@ -200,7 +200,8 @@ def register_resources(server, get_engine: Callable) -> None:
|
|
|
200
200
|
from superlocalmemory.learning.behavioral import BehavioralPatternStore
|
|
201
201
|
store = BehavioralPatternStore(engine._db.db_path)
|
|
202
202
|
summary = store.get_summary(pid)
|
|
203
|
-
except Exception:
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
logger.warning("learning_status behavioral summary failed: %s", exc)
|
|
204
205
|
summary = {}
|
|
205
206
|
|
|
206
207
|
# Outcome stats
|
|
@@ -211,7 +212,8 @@ def register_resources(server, get_engine: Callable) -> None:
|
|
|
211
212
|
(pid,),
|
|
212
213
|
)
|
|
213
214
|
outcomes = {dict(r)["outcome"]: dict(r)["c"] for r in outcome_rows}
|
|
214
|
-
except Exception:
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
logger.warning("learning_status outcome query failed: %s", exc)
|
|
215
217
|
outcomes = {}
|
|
216
218
|
|
|
217
219
|
lines = [
|
|
@@ -245,7 +247,8 @@ def register_resources(server, get_engine: Callable) -> None:
|
|
|
245
247
|
(pid,),
|
|
246
248
|
)
|
|
247
249
|
activity = {dict(r)["action"]: dict(r)["c"] for r in audit_rows}
|
|
248
|
-
except Exception:
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
logger.warning("engagement audit-trail query failed: %s", exc)
|
|
249
252
|
activity = {}
|
|
250
253
|
|
|
251
254
|
# Top-accessed facts
|
|
@@ -31,26 +31,42 @@ server = FastMCP("SuperLocalMemory V3")
|
|
|
31
31
|
|
|
32
32
|
# Lazy engine singleton -------------------------------------------------------
|
|
33
33
|
|
|
34
|
+
import threading as _threading
|
|
34
35
|
_engine = None
|
|
36
|
+
_engine_lock = _threading.Lock()
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
def get_engine():
|
|
38
|
-
"""Return (or create) the singleton MemoryEngine.
|
|
40
|
+
"""Return (or create) the singleton LIGHT MemoryEngine.
|
|
41
|
+
|
|
42
|
+
FastMCP may call tools concurrently from multiple threads. The
|
|
43
|
+
double-checked lock keeps construction single-shot even if two
|
|
44
|
+
tool invocations race on a cold process — without it we would
|
|
45
|
+
double-run the schema migrations and build two ``AdaptiveLearner``
|
|
46
|
+
instances over the same DB file.
|
|
47
|
+
"""
|
|
39
48
|
global _engine
|
|
40
|
-
if _engine is None:
|
|
49
|
+
if _engine is not None:
|
|
50
|
+
return _engine
|
|
51
|
+
with _engine_lock:
|
|
52
|
+
if _engine is not None:
|
|
53
|
+
return _engine
|
|
41
54
|
from superlocalmemory.core.config import SLMConfig
|
|
42
55
|
from superlocalmemory.core.engine import MemoryEngine
|
|
56
|
+
from superlocalmemory.core.engine_capabilities import Capabilities
|
|
43
57
|
|
|
44
58
|
config = SLMConfig.load()
|
|
45
|
-
|
|
46
|
-
|
|
59
|
+
new_engine = MemoryEngine(config, capabilities=Capabilities.LIGHT)
|
|
60
|
+
new_engine.initialize()
|
|
61
|
+
_engine = new_engine
|
|
47
62
|
return _engine
|
|
48
63
|
|
|
49
64
|
|
|
50
65
|
def reset_engine():
|
|
51
66
|
"""Reset engine singleton (for testing or mode switch)."""
|
|
52
67
|
global _engine
|
|
53
|
-
|
|
68
|
+
with _engine_lock:
|
|
69
|
+
_engine = None
|
|
54
70
|
|
|
55
71
|
|
|
56
72
|
# Register tools and resources -------------------------------------------------
|
|
@@ -71,6 +87,9 @@ _ESSENTIAL_TOOLS: set[str] = {
|
|
|
71
87
|
"list_recent", "delete_memory", "update_memory", "get_status",
|
|
72
88
|
# Session lifecycle (3)
|
|
73
89
|
"session_init", "observe", "close_session",
|
|
90
|
+
# Feedback / learning signals — reachable Dash-Core path for
|
|
91
|
+
# thumbs-up / pin / drift signals.
|
|
92
|
+
"report_feedback",
|
|
74
93
|
# Memory management (2)
|
|
75
94
|
"forget", "run_maintenance",
|
|
76
95
|
# Infinite memory + learning (4)
|
|
@@ -161,14 +180,24 @@ register_evolution_tools(_target, get_engine) # v3.4.11: Skill evolution tools
|
|
|
161
180
|
# the first tool call arrives (1-2s later), the engine is already warm.
|
|
162
181
|
# This applies to ALL IDEs: Claude Code, Cursor, Antigravity, Gemini CLI, etc.
|
|
163
182
|
def _eager_warmup() -> None:
|
|
164
|
-
"""Pre-warm engine + ensure daemon is running + auto-register mesh
|
|
183
|
+
"""Pre-warm LIGHT engine + ensure daemon is running + auto-register mesh.
|
|
184
|
+
|
|
185
|
+
LIGHT engine init is cheap (DB only, ~100 ms). The real reason this
|
|
186
|
+
stays in a background thread is the follow-on side effects
|
|
187
|
+
(``ensure_daemon``, ``auto_register_mesh``) which do I/O.
|
|
188
|
+
"""
|
|
165
189
|
import logging
|
|
166
190
|
_logger = logging.getLogger(__name__)
|
|
167
191
|
try:
|
|
168
192
|
get_engine()
|
|
169
193
|
_logger.info("MCP engine pre-warmed successfully")
|
|
170
194
|
except Exception as exc:
|
|
171
|
-
_logger.
|
|
195
|
+
_logger.warning("MCP engine pre-warmup failed: %s", exc)
|
|
196
|
+
|
|
197
|
+
# Measurement / test harnesses set this to skip daemon-start and
|
|
198
|
+
# mesh-register. The LIGHT engine init above still runs.
|
|
199
|
+
if _os.environ.get("SLM_DISABLE_WARMUP_SIDE_EFFECTS") == "1":
|
|
200
|
+
return
|
|
172
201
|
|
|
173
202
|
# V3.4.4: Also ensure daemon is running for dashboard/mesh/health features.
|
|
174
203
|
# This runs in background — doesn't block MCP tool registration.
|
|
@@ -177,7 +206,7 @@ def _eager_warmup() -> None:
|
|
|
177
206
|
if ensure_daemon():
|
|
178
207
|
_logger.info("Daemon auto-started by MCP server")
|
|
179
208
|
except Exception as exc:
|
|
180
|
-
_logger.
|
|
209
|
+
_logger.warning("Daemon auto-start failed: %s", exc)
|
|
181
210
|
|
|
182
211
|
# V3.4.6: Auto-register this MCP session as a mesh peer immediately.
|
|
183
212
|
# Previously, registration was lazy (only on first mesh tool call).
|
|
@@ -187,7 +216,7 @@ def _eager_warmup() -> None:
|
|
|
187
216
|
auto_register_mesh()
|
|
188
217
|
_logger.info("Mesh peer auto-registered at startup")
|
|
189
218
|
except Exception as exc:
|
|
190
|
-
_logger.
|
|
219
|
+
_logger.warning("Mesh auto-register failed: %s", exc)
|
|
191
220
|
|
|
192
221
|
import threading
|
|
193
222
|
_warmup_thread = threading.Thread(target=_eager_warmup, daemon=True, name="mcp-warmup")
|
|
@@ -28,14 +28,18 @@ DB_PATH = MEMORY_DIR / "memory.db"
|
|
|
28
28
|
|
|
29
29
|
def _emit_event(event_type: str, payload: dict | None = None,
|
|
30
30
|
source_agent: str = "mcp_client") -> None: # V3.3.12: see also mcp/shared.py
|
|
31
|
-
"""Emit an event to the EventBus (best-effort, never raises).
|
|
31
|
+
"""Emit an event to the EventBus (best-effort, never raises).
|
|
32
|
+
|
|
33
|
+
Dashboard visibility is load-bearing per the v3.4.26 user contract,
|
|
34
|
+
so we log on failure rather than silently dropping the signal.
|
|
35
|
+
"""
|
|
32
36
|
try:
|
|
33
37
|
from superlocalmemory.infra.event_bus import EventBus
|
|
34
38
|
bus = EventBus.get_instance(str(DB_PATH))
|
|
35
39
|
bus.emit(event_type, payload=payload, source_agent=source_agent,
|
|
36
40
|
source_protocol="mcp")
|
|
37
|
-
except Exception:
|
|
38
|
-
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
logger.warning("event emit failed: type=%s err=%s", event_type, exc)
|
|
39
43
|
|
|
40
44
|
|
|
41
45
|
def _register_agent(agent_id: str, profile_id: str) -> None:
|
|
@@ -45,8 +49,10 @@ def _register_agent(agent_id: str, profile_id: str) -> None:
|
|
|
45
49
|
registry_path = MEMORY_DIR / "agents.json"
|
|
46
50
|
registry = AgentRegistry(persist_path=registry_path)
|
|
47
51
|
registry.register_agent(agent_id, profile_id)
|
|
48
|
-
except Exception:
|
|
49
|
-
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
logger.warning(
|
|
54
|
+
"agent registry write failed: agent=%s err=%s", agent_id, exc,
|
|
55
|
+
)
|
|
50
56
|
|
|
51
57
|
|
|
52
58
|
def register_active_tools(server, get_engine: Callable) -> None:
|
|
@@ -73,6 +79,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
73
79
|
try:
|
|
74
80
|
from superlocalmemory.hooks.auto_recall import AutoRecall
|
|
75
81
|
from superlocalmemory.hooks.rules_engine import RulesEngine
|
|
82
|
+
from superlocalmemory.mcp._pool_adapter import pool_recall
|
|
76
83
|
|
|
77
84
|
engine = get_engine()
|
|
78
85
|
rules = RulesEngine()
|
|
@@ -82,7 +89,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
82
89
|
|
|
83
90
|
recall_config = rules.get_recall_config()
|
|
84
91
|
auto = AutoRecall(
|
|
85
|
-
|
|
92
|
+
recall_fn=pool_recall,
|
|
86
93
|
config={
|
|
87
94
|
"enabled": True,
|
|
88
95
|
"max_memories_injected": max_results,
|
|
@@ -102,8 +109,12 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
102
109
|
feedback_count = 0
|
|
103
110
|
try:
|
|
104
111
|
feedback_count = engine._adaptive_learner.get_feedback_count(pid)
|
|
105
|
-
except Exception:
|
|
106
|
-
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
# Feedback count is a Dash-Core signal; a silent zero
|
|
114
|
+
# masks wiring bugs. Log so operators see the failure.
|
|
115
|
+
logger.warning(
|
|
116
|
+
"session_init feedback_count read failed: %s", exc,
|
|
117
|
+
)
|
|
107
118
|
|
|
108
119
|
# Register agent + emit event
|
|
109
120
|
_register_agent("mcp_client", pid)
|
|
@@ -148,12 +159,13 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
148
159
|
try:
|
|
149
160
|
from superlocalmemory.hooks.auto_capture import AutoCapture
|
|
150
161
|
from superlocalmemory.hooks.rules_engine import RulesEngine
|
|
162
|
+
from superlocalmemory.mcp._pool_adapter import pool_store
|
|
151
163
|
|
|
152
164
|
engine = get_engine()
|
|
153
165
|
rules = RulesEngine()
|
|
154
166
|
|
|
155
167
|
auto = AutoCapture(
|
|
156
|
-
|
|
168
|
+
store_fn=pool_store,
|
|
157
169
|
config=rules.get_capture_config(),
|
|
158
170
|
)
|
|
159
171
|
|