superlocalmemory 3.4.45 → 3.4.46
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 +28 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +4 -1
- package/src/superlocalmemory/core/recall_worker.py +5 -3
- package/src/superlocalmemory/core/worker_pool.py +2 -1
- package/src/superlocalmemory/mcp/_daemon_proxy.py +7 -3
- package/src/superlocalmemory/mcp/_pool_adapter.py +7 -2
- package/src/superlocalmemory/mcp/server.py +17 -3
- package/src/superlocalmemory/mcp/tools_active.py +31 -18
- package/src/superlocalmemory/mcp/tools_core.py +5 -6
- package/src/superlocalmemory/mcp/tools_v3.py +2 -2
- package/src/superlocalmemory/server/unified_daemon.py +2 -2
- package/src/superlocalmemory/storage/database.py +1 -1
- package/src/superlocalmemory/storage/schema.py +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## [3.4.46] - 2026-05-18
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **`SLM_MCP_TOOLS` env var** — Fine-grained MCP tool allowlist. Users can now
|
|
16
|
+
set `SLM_MCP_TOOLS=remember,recall,search,session_init` to expose exactly
|
|
17
|
+
the tools they need, reducing MCP context budget. Falls back to 25-tool
|
|
18
|
+
essential set when unset; `SLM_MCP_ALL_TOOLS=1` still wins for power users.
|
|
19
|
+
- **`KMP_DUPLICATE_LIB_OK=TRUE`** — Set at package init to prevent OpenMP
|
|
20
|
+
multi-library crashes when PyTorch, ONNX Runtime, and NumPy-MKL all load
|
|
21
|
+
their own runtimes simultaneously.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **WAL busy_timeout ordering** (PR #24, @kenyonxu) — `_enable_wal()` now
|
|
25
|
+
sets `busy_timeout` before `journal_mode=WAL`, ensuring the 10s configured
|
|
26
|
+
timeout is used instead of SQLite's default 5s during WAL initialization.
|
|
27
|
+
- **Engine init traceback logging** (PR #25, @kenyonxu) — `logger.exception()`
|
|
28
|
+
replaces `logger.warning()` on daemon engine init failure, capturing the
|
|
29
|
+
full traceback for root-cause diagnosis.
|
|
30
|
+
- **MCP `fast` recall wiring** (PR #22, @VikingOwl91) — `fast=True` recall
|
|
31
|
+
parameter now threads through the full MCP→daemon→worker stack.
|
|
32
|
+
`session_init` performs one `pool_recall(fast=True)` instead of two
|
|
33
|
+
redundant recalls. Tools switch from `WorkerPool.shared()` to `choose_pool()`
|
|
34
|
+
for daemon-first routing (avoids N×1.6 GB ONNX duplication across IDEs).
|
|
35
|
+
- **FTS trigger idempotency** — `CREATE TRIGGER IF NOT EXISTS` prevents race
|
|
36
|
+
crashes on repeated schema init.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
12
40
|
## [3.4.43] - 2026-05-12
|
|
13
41
|
|
|
14
42
|
Smart-hook architecture release. Replaces the time-based 15-minute recall
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.46",
|
|
4
4
|
"description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-memory",
|
package/pyproject.toml
CHANGED
|
@@ -59,10 +59,12 @@ def _get_engine():
|
|
|
59
59
|
return _engine
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
def _handle_recall(
|
|
62
|
+
def _handle_recall(
|
|
63
|
+
query: str, limit: int, session_id: str = "", fast: bool = False,
|
|
64
|
+
) -> dict:
|
|
63
65
|
engine = _get_engine()
|
|
64
66
|
response = engine.recall(
|
|
65
|
-
query, limit=limit, session_id=session_id or None,
|
|
67
|
+
query, limit=limit, session_id=session_id or None, fast=bool(fast),
|
|
66
68
|
)
|
|
67
69
|
|
|
68
70
|
# Batch-fetch original memory text for all results
|
|
@@ -290,7 +292,7 @@ def _worker_main() -> None:
|
|
|
290
292
|
if cmd == "recall":
|
|
291
293
|
result = _handle_recall(
|
|
292
294
|
req.get("query", ""), req.get("limit", 10),
|
|
293
|
-
req.get("session_id", ""),
|
|
295
|
+
req.get("session_id", ""), bool(req.get("fast", False)),
|
|
294
296
|
)
|
|
295
297
|
_respond(result)
|
|
296
298
|
elif cmd == "store":
|
|
@@ -67,6 +67,7 @@ class WorkerPool:
|
|
|
67
67
|
|
|
68
68
|
def recall(
|
|
69
69
|
self, query: str, limit: int = 10, session_id: str = "",
|
|
70
|
+
fast: bool = False,
|
|
70
71
|
) -> dict:
|
|
71
72
|
"""Run recall in worker subprocess. Returns result dict.
|
|
72
73
|
|
|
@@ -77,7 +78,7 @@ class WorkerPool:
|
|
|
77
78
|
"""
|
|
78
79
|
return self._send({
|
|
79
80
|
"cmd": "recall", "query": query, "limit": limit,
|
|
80
|
-
"session_id": session_id or "",
|
|
81
|
+
"session_id": session_id or "", "fast": bool(fast),
|
|
81
82
|
})
|
|
82
83
|
|
|
83
84
|
def store(self, content: str, metadata: dict | None = None) -> dict:
|
|
@@ -45,10 +45,14 @@ class DaemonPoolProxy:
|
|
|
45
45
|
|
|
46
46
|
def recall(
|
|
47
47
|
self, query: str, limit: int = 10, session_id: str = "",
|
|
48
|
+
fast: bool = False,
|
|
48
49
|
) -> dict[str, Any]:
|
|
49
|
-
params = urllib.parse.urlencode(
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
params = urllib.parse.urlencode({
|
|
51
|
+
"q": query,
|
|
52
|
+
"limit": limit,
|
|
53
|
+
"session_id": session_id or "",
|
|
54
|
+
"fast": "true" if fast else "false",
|
|
55
|
+
})
|
|
52
56
|
try:
|
|
53
57
|
with urllib.request.urlopen(
|
|
54
58
|
self._url(f"/recall?{params}"), timeout=self._timeout,
|
|
@@ -74,12 +74,17 @@ def _unwrap_error(raw: Any, op: str) -> None:
|
|
|
74
74
|
raise PoolError(f"pool.{op} failed: {reason}")
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
def pool_recall(query: str, limit: int = 10, **
|
|
77
|
+
def pool_recall(query: str, limit: int = 10, **kwargs: Any) -> PoolRecallResponse:
|
|
78
78
|
"""Call pool.recall and reshape its dict into a typed response.
|
|
79
79
|
|
|
80
80
|
Raises :class:`PoolError` on worker death or any non-ok envelope.
|
|
81
81
|
"""
|
|
82
|
-
raw = _pool().recall(
|
|
82
|
+
raw = _pool().recall(
|
|
83
|
+
query=query,
|
|
84
|
+
limit=limit,
|
|
85
|
+
session_id=str(kwargs.get("session_id") or ""),
|
|
86
|
+
fast=bool(kwargs.get("fast", False)),
|
|
87
|
+
)
|
|
83
88
|
_unwrap_error(raw, "recall")
|
|
84
89
|
items = raw.get("results", []) if isinstance(raw, dict) else []
|
|
85
90
|
results = [
|
|
@@ -122,13 +122,21 @@ _ESSENTIAL_TOOLS = frozenset(_ESSENTIAL_TOOLS)
|
|
|
122
122
|
|
|
123
123
|
_all_tools = _os_reg.environ.get("SLM_MCP_ALL_TOOLS") == "1"
|
|
124
124
|
|
|
125
|
+
# v3.4.45: Minimal mode — explicit user allowlist via SLM_MCP_TOOLS env var.
|
|
126
|
+
# Format: comma-separated tool names, e.g. "remember,recall,session_init,search"
|
|
127
|
+
# Use case: Claude Code consumer plans with tight context budgets where the
|
|
128
|
+
# 25-tool essential set is still too many. Power users override to expose
|
|
129
|
+
# exactly the tools they invoke. Falls back to _ESSENTIAL_TOOLS when unset.
|
|
130
|
+
_user_allowlist_str = _os_reg.environ.get("SLM_MCP_TOOLS", "").strip()
|
|
131
|
+
|
|
125
132
|
|
|
126
133
|
class _FilteredServer:
|
|
127
134
|
"""Wraps FastMCP to only register essential tools.
|
|
128
135
|
|
|
129
136
|
Non-essential tools are silently skipped (not registered on the MCP
|
|
130
137
|
server). They remain available via CLI. When SLM_MCP_ALL_TOOLS=1,
|
|
131
|
-
all tools are registered (bypass filter).
|
|
138
|
+
all tools are registered (bypass filter). When SLM_MCP_TOOLS is set,
|
|
139
|
+
that user allowlist is used instead of _ESSENTIAL_TOOLS.
|
|
132
140
|
"""
|
|
133
141
|
__slots__ = ("_server", "_allowed")
|
|
134
142
|
|
|
@@ -147,8 +155,14 @@ class _FilteredServer:
|
|
|
147
155
|
return getattr(self._server, name)
|
|
148
156
|
|
|
149
157
|
|
|
150
|
-
# Choose
|
|
151
|
-
|
|
158
|
+
# Choose registration target (precedence: ALL > user allowlist > essential)
|
|
159
|
+
if _all_tools:
|
|
160
|
+
_target = server
|
|
161
|
+
elif _user_allowlist_str:
|
|
162
|
+
_user_allowlist = frozenset(t.strip() for t in _user_allowlist_str.split(",") if t.strip())
|
|
163
|
+
_target = _FilteredServer(server, _user_allowlist)
|
|
164
|
+
else:
|
|
165
|
+
_target = _FilteredServer(server, _ESSENTIAL_TOOLS)
|
|
152
166
|
|
|
153
167
|
from superlocalmemory.mcp.tools_core import register_core_tools
|
|
154
168
|
from superlocalmemory.mcp.tools_v28 import register_v28_tools
|
|
@@ -93,7 +93,6 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
93
93
|
The AI should call this automatically before any other work.
|
|
94
94
|
"""
|
|
95
95
|
try:
|
|
96
|
-
from superlocalmemory.hooks.auto_recall import AutoRecall
|
|
97
96
|
from superlocalmemory.hooks.rules_engine import RulesEngine
|
|
98
97
|
from superlocalmemory.mcp._pool_adapter import pool_recall
|
|
99
98
|
|
|
@@ -104,21 +103,37 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
104
103
|
return {"success": True, "context": "", "memories": [], "message": "Auto-recall disabled"}
|
|
105
104
|
|
|
106
105
|
recall_config = rules.get_recall_config()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
106
|
+
relevance_threshold = recall_config.get("relevance_threshold", 0.3)
|
|
107
|
+
if query:
|
|
108
|
+
search_query = query
|
|
109
|
+
elif project_path:
|
|
110
|
+
search_query = f"project context {project_path}"
|
|
111
|
+
else:
|
|
112
|
+
search_query = "recent important decisions"
|
|
113
|
+
|
|
114
|
+
response = pool_recall(search_query, limit=max_results, fast=True)
|
|
115
|
+
relevant = [
|
|
116
|
+
r for r in response.results
|
|
117
|
+
if r.score >= relevance_threshold
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
# Build both return shapes from one recall. Calling recall twice
|
|
121
|
+
# doubles session startup latency and can return duplicate snippets.
|
|
122
|
+
context = ""
|
|
123
|
+
if relevant:
|
|
124
|
+
lines = ["# Relevant Memory Context", ""]
|
|
125
|
+
for r in relevant[:max_results]:
|
|
126
|
+
lines.append(f"- {r.fact.content[:200]}")
|
|
127
|
+
context = "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
memories = [
|
|
130
|
+
{
|
|
131
|
+
"fact_id": r.fact.fact_id,
|
|
132
|
+
"content": r.fact.content[:300],
|
|
133
|
+
"score": round(r.score, 3),
|
|
134
|
+
}
|
|
135
|
+
for r in relevant[:max_results]
|
|
136
|
+
]
|
|
122
137
|
|
|
123
138
|
# Get learning status
|
|
124
139
|
pid = engine.profile_id
|
|
@@ -184,7 +199,6 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
184
199
|
from superlocalmemory.hooks.rules_engine import RulesEngine
|
|
185
200
|
from superlocalmemory.mcp._pool_adapter import pool_store
|
|
186
201
|
|
|
187
|
-
engine = get_engine()
|
|
188
202
|
rules = RulesEngine()
|
|
189
203
|
|
|
190
204
|
auto = AutoCapture(
|
|
@@ -305,7 +319,6 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
305
319
|
"""
|
|
306
320
|
try:
|
|
307
321
|
engine = get_engine()
|
|
308
|
-
pid = engine.profile_id
|
|
309
322
|
sid = session_id or getattr(engine, '_last_session_id', '')
|
|
310
323
|
if not sid:
|
|
311
324
|
return {"success": False, "error": "No session_id provided"}
|
|
@@ -13,10 +13,9 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
-
import json
|
|
17
16
|
import logging
|
|
18
17
|
from pathlib import Path
|
|
19
|
-
from typing import
|
|
18
|
+
from typing import Callable
|
|
20
19
|
|
|
21
20
|
from mcp.types import ToolAnnotations
|
|
22
21
|
|
|
@@ -111,7 +110,6 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
111
110
|
Extracts atomic facts, resolves entities, builds graph edges,
|
|
112
111
|
and indexes for 4-channel retrieval.
|
|
113
112
|
"""
|
|
114
|
-
import asyncio
|
|
115
113
|
try:
|
|
116
114
|
# v3.4.32: Store-first pattern. Write to pending.db and return
|
|
117
115
|
# immediately. The daemon's pending-materializer thread drains
|
|
@@ -141,7 +139,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
141
139
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
142
140
|
async def recall(
|
|
143
141
|
query: str, limit: int = 10, agent_id: str = "mcp_client",
|
|
144
|
-
session_id: str = "",
|
|
142
|
+
session_id: str = "", fast: bool = False,
|
|
145
143
|
) -> dict:
|
|
146
144
|
"""Search memories by semantic query with 4-channel retrieval, RRF fusion, and reranking.
|
|
147
145
|
|
|
@@ -153,8 +151,8 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
153
151
|
"""
|
|
154
152
|
import asyncio
|
|
155
153
|
try:
|
|
156
|
-
from superlocalmemory.
|
|
157
|
-
pool =
|
|
154
|
+
from superlocalmemory.mcp._daemon_proxy import choose_pool
|
|
155
|
+
pool = choose_pool()
|
|
158
156
|
# S9-DASH-10: priority for session_id, so engagement
|
|
159
157
|
# signals land on the right pending_outcome:
|
|
160
158
|
# 1. Explicit ``session_id`` tool-call argument.
|
|
@@ -199,6 +197,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
199
197
|
# block behind a single threading.Lock. See worker_pool.py.
|
|
200
198
|
result = await asyncio.to_thread(
|
|
201
199
|
pool.recall, query, limit=limit, session_id=effective_sid,
|
|
200
|
+
fast=bool(fast),
|
|
202
201
|
)
|
|
203
202
|
if result.get("ok"):
|
|
204
203
|
# Record implicit feedback: every returned result is a recall_hit
|
|
@@ -285,8 +285,8 @@ def register_v3_tools(server, get_engine: Callable) -> None:
|
|
|
285
285
|
limit: Maximum results (default 10).
|
|
286
286
|
"""
|
|
287
287
|
try:
|
|
288
|
-
from superlocalmemory.
|
|
289
|
-
raw =
|
|
288
|
+
from superlocalmemory.mcp._daemon_proxy import choose_pool
|
|
289
|
+
raw = choose_pool().recall(query=query, limit=limit)
|
|
290
290
|
items = raw.get("results", []) if isinstance(raw, dict) else []
|
|
291
291
|
results = []
|
|
292
292
|
for item in items[:limit]:
|
|
@@ -535,8 +535,8 @@ async def lifespan(application: FastAPI):
|
|
|
535
535
|
application.state.queue_consumer = None
|
|
536
536
|
application.state.recall_queue = None
|
|
537
537
|
|
|
538
|
-
except Exception
|
|
539
|
-
logger.
|
|
538
|
+
except Exception:
|
|
539
|
+
logger.exception("Engine init failed") # auto-includes traceback
|
|
540
540
|
application.state.engine = None
|
|
541
541
|
application.state.config = None
|
|
542
542
|
|
|
@@ -66,8 +66,8 @@ class DatabaseManager:
|
|
|
66
66
|
def _enable_wal(self) -> None:
|
|
67
67
|
conn = sqlite3.connect(str(self.db_path))
|
|
68
68
|
try:
|
|
69
|
+
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") # FIRST — so WAL pragma below uses configured timeout
|
|
69
70
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
70
|
-
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
|
71
71
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
72
72
|
conn.commit()
|
|
73
73
|
finally:
|
|
@@ -252,7 +252,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS atomic_facts_fts
|
|
|
252
252
|
-- left by V2 migration.
|
|
253
253
|
|
|
254
254
|
-- INSERT trigger
|
|
255
|
-
CREATE TRIGGER atomic_facts_fts_insert
|
|
255
|
+
CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_insert
|
|
256
256
|
AFTER INSERT ON atomic_facts
|
|
257
257
|
BEGIN
|
|
258
258
|
INSERT INTO atomic_facts_fts (rowid, fact_id, content)
|
|
@@ -260,7 +260,7 @@ BEGIN
|
|
|
260
260
|
END;
|
|
261
261
|
|
|
262
262
|
-- DELETE trigger
|
|
263
|
-
CREATE TRIGGER atomic_facts_fts_delete
|
|
263
|
+
CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_delete
|
|
264
264
|
AFTER DELETE ON atomic_facts
|
|
265
265
|
BEGIN
|
|
266
266
|
INSERT INTO atomic_facts_fts (atomic_facts_fts, rowid, fact_id, content)
|
|
@@ -268,7 +268,7 @@ BEGIN
|
|
|
268
268
|
END;
|
|
269
269
|
|
|
270
270
|
-- UPDATE trigger
|
|
271
|
-
CREATE TRIGGER atomic_facts_fts_update
|
|
271
|
+
CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_update
|
|
272
272
|
AFTER UPDATE OF content ON atomic_facts
|
|
273
273
|
BEGIN
|
|
274
274
|
INSERT INTO atomic_facts_fts (atomic_facts_fts, rowid, fact_id, content)
|