superlocalmemory 3.6.8 → 3.6.10
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 +83 -0
- package/README.md +8 -4
- package/package.json +1 -1
- package/pyproject.toml +6 -1
- package/src/superlocalmemory/__init__.py +6 -2
- package/src/superlocalmemory/cli/compress_cmd.py +32 -70
- package/src/superlocalmemory/cli/daemon.py +25 -3
- package/src/superlocalmemory/cli/optimize_cmd.py +1 -3
- package/src/superlocalmemory/cli/setup_wizard.py +49 -0
- package/src/superlocalmemory/core/config.py +28 -0
- package/src/superlocalmemory/core/engine.py +15 -4
- package/src/superlocalmemory/core/health_monitor.py +32 -9
- package/src/superlocalmemory/mcp/agent_context.py +111 -0
- package/src/superlocalmemory/mcp/tools_active.py +47 -12
- package/src/superlocalmemory/mcp/tools_core.py +22 -2
- package/src/superlocalmemory/mcp/tools_mesh.py +37 -38
- package/src/superlocalmemory/optimize/cache/boundary_store.py +23 -9
- package/src/superlocalmemory/optimize/cache/exact.py +7 -4
- package/src/superlocalmemory/optimize/cache/key_builder.py +13 -0
- package/src/superlocalmemory/optimize/cache/manager.py +70 -8
- package/src/superlocalmemory/optimize/cache/semantic.py +10 -5
- package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +1 -7
- package/src/superlocalmemory/optimize/compress/router.py +82 -87
- package/src/superlocalmemory/optimize/config/__init__.py +16 -0
- package/src/superlocalmemory/optimize/config/defaults.py +1 -6
- package/src/superlocalmemory/optimize/config/schema.py +2 -19
- package/src/superlocalmemory/optimize/config/store.py +15 -1
- package/src/superlocalmemory/optimize/metrics/counters.py +15 -7
- package/src/superlocalmemory/optimize/proxy/_helpers.py +100 -2
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +12 -0
- package/src/superlocalmemory/optimize/proxy/capture.py +243 -0
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +31 -0
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +12 -0
- package/src/superlocalmemory/optimize/proxy/server.py +29 -0
- package/src/superlocalmemory/optimize/storage/db.py +78 -11
- package/src/superlocalmemory/optimize/storage/schema.py +11 -0
- package/src/superlocalmemory/retrieval/spreading_activation.py +8 -3
- package/src/superlocalmemory/server/routes/optimize.py +6 -8
- package/src/superlocalmemory/server/unified_daemon.py +68 -11
- package/src/superlocalmemory/ui/index.html +18 -14
- package/src/superlocalmemory/ui/js/auto-settings.js +3 -1
- package/src/superlocalmemory/ui/js/ng-shell.js +3 -0
- package/src/superlocalmemory/ui/js/optimize.js +9 -9
- package/src/superlocalmemory.egg-info/PKG-INFO +10 -5
- package/src/superlocalmemory.egg-info/SOURCES.txt +2 -2
- package/src/superlocalmemory.egg-info/requires.txt +1 -0
- package/src/superlocalmemory/optimize/compress/extractive_code.py +0 -311
- package/src/superlocalmemory/optimize/compress/extractive_json.py +0 -72
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Per-HTTP-request agent ID resolution — ContextVar home.
|
|
2
|
+
|
|
3
|
+
Kept in a standalone module so both tools_core and tools_active can import
|
|
4
|
+
it without creating circular dependencies (server.py → tools_core → here,
|
|
5
|
+
and unified_daemon.py → here independently).
|
|
6
|
+
|
|
7
|
+
Priority chain (HTTP-first, stdio-fallback):
|
|
8
|
+
1. ContextVar set by _AgentIDExtractorASGI middleware from /mcp/{agent_id} URL path.
|
|
9
|
+
2. SLM_AGENT_ID environment variable (stdio transport legacy).
|
|
10
|
+
3. Hard-coded "mcp_client" sentinel.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextvars
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
_current_agent_id: contextvars.ContextVar[str] = contextvars.ContextVar(
|
|
19
|
+
"slm_agent_id", default="mcp_client"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Agent ids arrive from an untrusted URL path segment. They are ATTRIBUTION
|
|
23
|
+
# metadata, never an authenticated principal — but they reach loggers, the
|
|
24
|
+
# agent registry, and SQL-bound attribution columns, so we hard-restrict the
|
|
25
|
+
# charset at the single extraction chokepoint. This neutralises log-injection
|
|
26
|
+
# (CRLF / ANSI), oversized ids, and any path-ish characters in one place.
|
|
27
|
+
_AGENT_ID_SANITIZE = re.compile(r"[^A-Za-z0-9._-]")
|
|
28
|
+
_AGENT_ID_MAX_LEN = 64
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sanitize_agent_id(raw: str) -> str:
|
|
32
|
+
"""Coerce an untrusted agent-id segment to a safe, bounded token."""
|
|
33
|
+
return _AGENT_ID_SANITIZE.sub("_", raw)[:_AGENT_ID_MAX_LEN]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_current_agent_id(env_fallback: bool = True) -> str:
|
|
37
|
+
"""Return the agent_id for the current asyncio task.
|
|
38
|
+
|
|
39
|
+
For HTTP transport the ASGI wrapper sets the ContextVar from the URL path
|
|
40
|
+
before the request reaches any MCP tool, so this returns the URL-derived id.
|
|
41
|
+
For stdio transport the ContextVar holds its default ("mcp_client") and we
|
|
42
|
+
fall through to the SLM_AGENT_ID env var instead.
|
|
43
|
+
"""
|
|
44
|
+
ctx_id = _current_agent_id.get()
|
|
45
|
+
if ctx_id != "mcp_client":
|
|
46
|
+
return ctx_id
|
|
47
|
+
if env_fallback:
|
|
48
|
+
return os.environ.get("SLM_AGENT_ID", "mcp_client")
|
|
49
|
+
return "mcp_client"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AgentIDExtractorASGI:
|
|
53
|
+
"""ASGI wrapper that maps ``/mcp/{agent_id}`` → the agent-id ContextVar.
|
|
54
|
+
|
|
55
|
+
Mounted at ``/mcp`` in unified_daemon. IMPORTANT: Starlette's ``Mount``
|
|
56
|
+
(≥0.35 / 1.x) does NOT strip the mount prefix from ``scope["path"]`` — it
|
|
57
|
+
records the prefix in ``scope["root_path"]`` and leaves ``path`` as the full
|
|
58
|
+
request path (e.g. ``/mcp/claude`` with ``root_path == "/mcp"``). So we
|
|
59
|
+
compute the mount-relative sub-path ourselves as ``path[len(root_path):]``.
|
|
60
|
+
|
|
61
|
+
Flow for ``POST /mcp/claude``:
|
|
62
|
+
sub-path ``/claude`` → agent id ``claude`` → set ContextVar → rewrite the
|
|
63
|
+
scope path to ``{root_path}/`` so the inner FastMCP app (Starlette, route
|
|
64
|
+
``/``) sees the same mount-relative ``/`` it sees for a bare ``/mcp/``.
|
|
65
|
+
|
|
66
|
+
Backward compatible: bare ``/mcp/`` has sub-path ``/`` → no agent segment →
|
|
67
|
+
the request passes through untouched and the ContextVar keeps its
|
|
68
|
+
``"mcp_client"`` default.
|
|
69
|
+
|
|
70
|
+
Per-request isolation is guaranteed by ContextVar + ``reset(token)`` in a
|
|
71
|
+
``finally``, so concurrent HTTP sessions never see each other's agent id.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
__slots__ = ("_app",)
|
|
75
|
+
|
|
76
|
+
def __init__(self, inner) -> None:
|
|
77
|
+
self._app = inner
|
|
78
|
+
|
|
79
|
+
async def __call__(self, scope, receive, send):
|
|
80
|
+
if scope.get("type") == "http":
|
|
81
|
+
root_path: str = scope.get("root_path", "")
|
|
82
|
+
full_path: str = scope.get("path", "/")
|
|
83
|
+
# Mount-relative sub-path (what comes AFTER /mcp). When a root_path
|
|
84
|
+
# is present the request path MUST start with it (Starlette Mount
|
|
85
|
+
# guarantees this); if it somehow does not, treat it as no-agent and
|
|
86
|
+
# pass through untouched rather than mis-parsing the full path.
|
|
87
|
+
if root_path:
|
|
88
|
+
if not full_path.startswith(root_path):
|
|
89
|
+
await self._app(scope, receive, send)
|
|
90
|
+
return
|
|
91
|
+
subpath = full_path[len(root_path):]
|
|
92
|
+
else:
|
|
93
|
+
subpath = full_path
|
|
94
|
+
first = subpath.lstrip("/").split("/")[0]
|
|
95
|
+
if first:
|
|
96
|
+
first = sanitize_agent_id(first)
|
|
97
|
+
token = _current_agent_id.set(first)
|
|
98
|
+
# Rewrite the path so the inner app sees the bare mount root,
|
|
99
|
+
# exactly as it would for a no-agent /mcp/ request.
|
|
100
|
+
new_full = (root_path + "/") if root_path else "/"
|
|
101
|
+
new_scope = {
|
|
102
|
+
**scope,
|
|
103
|
+
"path": new_full,
|
|
104
|
+
"raw_path": new_full.encode(),
|
|
105
|
+
}
|
|
106
|
+
try:
|
|
107
|
+
await self._app(new_scope, receive, send)
|
|
108
|
+
finally:
|
|
109
|
+
_current_agent_id.reset(token)
|
|
110
|
+
return
|
|
111
|
+
await self._app(scope, receive, send)
|
|
@@ -16,9 +16,12 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import asyncio
|
|
20
|
+
import datetime
|
|
19
21
|
import logging
|
|
20
22
|
import os
|
|
21
23
|
import sqlite3
|
|
24
|
+
import uuid
|
|
22
25
|
from pathlib import Path
|
|
23
26
|
from typing import Callable
|
|
24
27
|
|
|
@@ -106,15 +109,14 @@ def _sqlite_emergency_recall(
|
|
|
106
109
|
def _get_agent_id(default: str = "mcp_client") -> str:
|
|
107
110
|
"""Resolve the calling agent's ID for attribution.
|
|
108
111
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
v3.4.39+: enables proper per-agent attribution in ``session_init``,
|
|
115
|
-
``observe``, and event emissions.
|
|
112
|
+
Priority chain (v3.6.10+):
|
|
113
|
+
1. ContextVar set by HTTP URL path (/mcp/{agent_id}) — HTTP transport.
|
|
114
|
+
2. SLM_AGENT_ID env var — stdio transport per-process identity.
|
|
115
|
+
3. Provided default (legacy "mcp_client").
|
|
116
116
|
"""
|
|
117
|
-
|
|
117
|
+
from superlocalmemory.mcp.agent_context import get_current_agent_id
|
|
118
|
+
resolved = get_current_agent_id(env_fallback=True)
|
|
119
|
+
return resolved if resolved != "mcp_client" else default
|
|
118
120
|
|
|
119
121
|
|
|
120
122
|
def _emit_event(event_type: str, payload: dict | None = None,
|
|
@@ -213,7 +215,13 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
213
215
|
from superlocalmemory.mcp._pool_adapter import PoolError
|
|
214
216
|
degraded_mode = False
|
|
215
217
|
try:
|
|
216
|
-
|
|
218
|
+
# v3.6.9-audit: pool_recall uses blocking urllib under the hood
|
|
219
|
+
# (DaemonPoolProxy.recall → urllib.urlopen). Must run in a
|
|
220
|
+
# thread so the async MCP event loop is not stalled — same
|
|
221
|
+
# fix class as #34 mesh tools deadlock.
|
|
222
|
+
response = await asyncio.to_thread(
|
|
223
|
+
pool_recall, search_query, limit=max_results, fast=False,
|
|
224
|
+
)
|
|
217
225
|
except (PoolError, Exception) as exc:
|
|
218
226
|
logger.warning(
|
|
219
227
|
"session_init: daemon recall failed (%s) — using FTS5 emergency fallback. "
|
|
@@ -349,6 +357,13 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
349
357
|
"session_init feedback_count read failed: %s", exc,
|
|
350
358
|
)
|
|
351
359
|
|
|
360
|
+
# v3.6.9 (#35): generate a stable session_id so clients can pass it
|
|
361
|
+
# to remember() and close_session() for proper session aggregation.
|
|
362
|
+
session_id = (
|
|
363
|
+
f"slm-{datetime.datetime.now(datetime.timezone.utc):%Y%m%d}"
|
|
364
|
+
f"-{uuid.uuid4().hex[:8]}"
|
|
365
|
+
)
|
|
366
|
+
|
|
352
367
|
# Register agent + emit event (v3.4.39: SLM_AGENT_ID env support)
|
|
353
368
|
agent_id = _get_agent_id()
|
|
354
369
|
_register_agent(agent_id, pid)
|
|
@@ -360,6 +375,7 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
360
375
|
|
|
361
376
|
return {
|
|
362
377
|
"success": True,
|
|
378
|
+
"session_id": session_id,
|
|
363
379
|
"context": context,
|
|
364
380
|
"memories": memories[:max_results],
|
|
365
381
|
"memory_count": len(memories),
|
|
@@ -430,8 +446,11 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
430
446
|
"confidence": round(decision.confidence, 3),
|
|
431
447
|
}
|
|
432
448
|
|
|
433
|
-
# Auto-store via engine
|
|
434
|
-
|
|
449
|
+
# Auto-store via engine.
|
|
450
|
+
# pool_store uses blocking urllib (DaemonPoolProxy) — run in
|
|
451
|
+
# thread so the MCP event loop stays unblocked (#34 class).
|
|
452
|
+
stored = await asyncio.to_thread(
|
|
453
|
+
auto.capture,
|
|
435
454
|
content,
|
|
436
455
|
category=decision.category,
|
|
437
456
|
metadata={"agent_id": agent_id, "source": "auto-observe"},
|
|
@@ -525,8 +544,24 @@ def register_active_tools(server, get_engine: Callable) -> None:
|
|
|
525
544
|
try:
|
|
526
545
|
engine = get_engine()
|
|
527
546
|
sid = session_id or getattr(engine, '_last_session_id', '')
|
|
547
|
+
# v3.6.9 (#35): _last_session_id was never assigned — fall back to
|
|
548
|
+
# querying the DB for the most recent session_id instead of silently
|
|
549
|
+
# returning summary_events_created: 0.
|
|
550
|
+
if not sid:
|
|
551
|
+
try:
|
|
552
|
+
db = getattr(engine, '_db', None) or getattr(engine, 'db', None)
|
|
553
|
+
if db and hasattr(db, 'execute'):
|
|
554
|
+
rows = db.execute(
|
|
555
|
+
"SELECT session_id FROM memories "
|
|
556
|
+
"WHERE session_id != '' ORDER BY created_at DESC LIMIT 1",
|
|
557
|
+
()
|
|
558
|
+
)
|
|
559
|
+
if rows:
|
|
560
|
+
sid = str(rows[0][0])
|
|
561
|
+
except Exception:
|
|
562
|
+
pass
|
|
528
563
|
if not sid:
|
|
529
|
-
return {"success": False, "error": "No session_id provided"}
|
|
564
|
+
return {"success": False, "error": "No session_id provided or found"}
|
|
530
565
|
count = engine.close_session(sid)
|
|
531
566
|
return {
|
|
532
567
|
"success": True,
|
|
@@ -110,6 +110,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
110
110
|
Extracts atomic facts, resolves entities, builds graph edges,
|
|
111
111
|
and indexes for 4-channel retrieval.
|
|
112
112
|
"""
|
|
113
|
+
# v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
|
|
114
|
+
if agent_id == "mcp_client":
|
|
115
|
+
from superlocalmemory.mcp.agent_context import get_current_agent_id
|
|
116
|
+
agent_id = get_current_agent_id()
|
|
113
117
|
meta = {
|
|
114
118
|
"project": project,
|
|
115
119
|
"importance": importance,
|
|
@@ -122,9 +126,13 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
122
126
|
# recall window so a parallel/next agent finds memories saved seconds ago.
|
|
123
127
|
# Falls back to pending.db only if the daemon is unreachable.
|
|
124
128
|
try:
|
|
129
|
+
import asyncio as _asyncio
|
|
125
130
|
from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
# is_daemon_running() and daemon_request() both use blocking urllib
|
|
132
|
+
# against the same uvicorn server — run in threads so the MCP
|
|
133
|
+
# event loop stays unblocked (#34 class bug).
|
|
134
|
+
if await _asyncio.to_thread(is_daemon_running):
|
|
135
|
+
resp = await _asyncio.to_thread(daemon_request, "POST", "/remember", {
|
|
128
136
|
"content": content, "tags": tags, "metadata": meta,
|
|
129
137
|
})
|
|
130
138
|
if resp and (resp.get("fact_ids") is not None or resp.get("ok")):
|
|
@@ -167,6 +175,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
167
175
|
``CLAUDE_SESSION_ID``. Omitting it degrades to "no closed-loop
|
|
168
176
|
learning for this recall" — the recall itself always works.
|
|
169
177
|
"""
|
|
178
|
+
# v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
|
|
179
|
+
if agent_id == "mcp_client":
|
|
180
|
+
from superlocalmemory.mcp.agent_context import get_current_agent_id
|
|
181
|
+
agent_id = get_current_agent_id()
|
|
170
182
|
import asyncio
|
|
171
183
|
try:
|
|
172
184
|
from superlocalmemory.mcp._daemon_proxy import choose_pool
|
|
@@ -481,6 +493,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
481
493
|
fact_id: Exact fact ID to delete (from recall or list_recent results).
|
|
482
494
|
agent_id: Identifier of the calling agent (logged for audit).
|
|
483
495
|
"""
|
|
496
|
+
# v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
|
|
497
|
+
if agent_id == "mcp_client":
|
|
498
|
+
from superlocalmemory.mcp.agent_context import get_current_agent_id
|
|
499
|
+
agent_id = get_current_agent_id()
|
|
484
500
|
try:
|
|
485
501
|
from superlocalmemory.core.worker_pool import WorkerPool
|
|
486
502
|
pool = WorkerPool.shared()
|
|
@@ -515,6 +531,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
515
531
|
content: New content for the memory (cannot be empty).
|
|
516
532
|
agent_id: Identifier of the calling agent (logged for audit).
|
|
517
533
|
"""
|
|
534
|
+
# v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
|
|
535
|
+
if agent_id == "mcp_client":
|
|
536
|
+
from superlocalmemory.mcp.agent_context import get_current_agent_id
|
|
537
|
+
agent_id = get_current_agent_id()
|
|
518
538
|
try:
|
|
519
539
|
if not content or not content.strip():
|
|
520
540
|
return {"success": False, "error": "content cannot be empty"}
|
|
@@ -16,6 +16,7 @@ Auto-heartbeat keeps the session alive as long as the MCP server is running.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import asyncio
|
|
19
20
|
import json
|
|
20
21
|
import logging
|
|
21
22
|
import os
|
|
@@ -141,20 +142,20 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
141
142
|
global _SESSION_SUMMARY
|
|
142
143
|
_SESSION_SUMMARY = summary or "Active session"
|
|
143
144
|
|
|
144
|
-
_ensure_registered
|
|
145
|
+
await asyncio.to_thread(_ensure_registered)
|
|
145
146
|
|
|
146
147
|
# Update summary
|
|
147
|
-
result =
|
|
148
|
-
"
|
|
149
|
-
"summary": _SESSION_SUMMARY,
|
|
150
|
-
|
|
148
|
+
result = await asyncio.to_thread(
|
|
149
|
+
_mesh_request, "POST", "/summary",
|
|
150
|
+
{"peer_id": _PEER_ID, "summary": _SESSION_SUMMARY},
|
|
151
|
+
)
|
|
151
152
|
|
|
152
153
|
return {
|
|
153
154
|
"peer_id": _PEER_ID,
|
|
154
155
|
"summary": _SESSION_SUMMARY,
|
|
155
156
|
"project_path": _PROJECT_PATH,
|
|
156
|
-
"registered":
|
|
157
|
-
"heartbeat_active": _HEARTBEAT_THREAD is not None,
|
|
157
|
+
"registered": _REGISTERED,
|
|
158
|
+
"heartbeat_active": _HEARTBEAT_THREAD is not None and _HEARTBEAT_THREAD.is_alive(),
|
|
158
159
|
"broker_response": result,
|
|
159
160
|
}
|
|
160
161
|
|
|
@@ -165,8 +166,8 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
165
166
|
Shows other Claude Code, Cursor, or AI agent sessions that are
|
|
166
167
|
connected to the same SLM mesh network.
|
|
167
168
|
"""
|
|
168
|
-
_ensure_registered
|
|
169
|
-
result = _mesh_request
|
|
169
|
+
await asyncio.to_thread(_ensure_registered)
|
|
170
|
+
result = await asyncio.to_thread(_mesh_request, "GET", "/peers")
|
|
170
171
|
peers = (result or {}).get("peers", [])
|
|
171
172
|
return {
|
|
172
173
|
"peers": peers,
|
|
@@ -185,12 +186,11 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
185
186
|
- "project:/path/to/dir" (all sessions in that project directory)
|
|
186
187
|
message: The message content (max 4KB — use file paths for large data)
|
|
187
188
|
"""
|
|
188
|
-
_ensure_registered
|
|
189
|
-
result =
|
|
190
|
-
"
|
|
191
|
-
"to_peer": to,
|
|
192
|
-
|
|
193
|
-
})
|
|
189
|
+
await asyncio.to_thread(_ensure_registered)
|
|
190
|
+
result = await asyncio.to_thread(
|
|
191
|
+
_mesh_request, "POST", "/send",
|
|
192
|
+
{"from_peer": _PEER_ID, "to_peer": to, "content": message},
|
|
193
|
+
)
|
|
194
194
|
return result or {"error": "Failed to send message"}
|
|
195
195
|
|
|
196
196
|
@server.tool()
|
|
@@ -201,18 +201,19 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
201
201
|
Broadcast/project messages are delivered to ALL matching sessions.
|
|
202
202
|
Messages auto-expire after 48 hours.
|
|
203
203
|
"""
|
|
204
|
-
_ensure_registered
|
|
204
|
+
await asyncio.to_thread(_ensure_registered)
|
|
205
205
|
project = _PROJECT_PATH or _detect_project_path()
|
|
206
|
-
messages =
|
|
207
|
-
"GET", f"/inbox/{_PEER_ID}?project_path={project}",
|
|
206
|
+
messages = await asyncio.to_thread(
|
|
207
|
+
_mesh_request, "GET", f"/inbox/{_PEER_ID}?project_path={project}",
|
|
208
208
|
)
|
|
209
209
|
msg_list = (messages or {}).get("messages", [])
|
|
210
210
|
# Auto-mark unread messages as read
|
|
211
211
|
unread_ids = [m["id"] for m in msg_list if not m.get("read")]
|
|
212
212
|
if unread_ids:
|
|
213
|
-
|
|
214
|
-
"
|
|
215
|
-
|
|
213
|
+
await asyncio.to_thread(
|
|
214
|
+
_mesh_request, "POST", f"/inbox/{_PEER_ID}/read",
|
|
215
|
+
{"message_ids": unread_ids},
|
|
216
|
+
)
|
|
216
217
|
return {
|
|
217
218
|
"messages": msg_list,
|
|
218
219
|
"count": len(msg_list),
|
|
@@ -231,21 +232,20 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
231
232
|
value: Value to set (only for action="set")
|
|
232
233
|
action: "get" (read all or one key), "set" (write a key)
|
|
233
234
|
"""
|
|
234
|
-
_ensure_registered
|
|
235
|
+
await asyncio.to_thread(_ensure_registered)
|
|
235
236
|
|
|
236
237
|
if action == "set" and key:
|
|
237
|
-
result =
|
|
238
|
-
"
|
|
239
|
-
"value": value,
|
|
240
|
-
|
|
241
|
-
})
|
|
238
|
+
result = await asyncio.to_thread(
|
|
239
|
+
_mesh_request, "POST", "/state",
|
|
240
|
+
{"key": key, "value": value, "set_by": _PEER_ID},
|
|
241
|
+
)
|
|
242
242
|
return result or {"error": "Failed to set state"}
|
|
243
243
|
|
|
244
244
|
if key:
|
|
245
|
-
result = _mesh_request
|
|
245
|
+
result = await asyncio.to_thread(_mesh_request, "GET", f"/state/{key}")
|
|
246
246
|
return result or {"key": key, "value": None}
|
|
247
247
|
|
|
248
|
-
result = _mesh_request
|
|
248
|
+
result = await asyncio.to_thread(_mesh_request, "GET", "/state")
|
|
249
249
|
return result or {"state": {}}
|
|
250
250
|
|
|
251
251
|
@server.tool()
|
|
@@ -261,12 +261,11 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
261
261
|
file_path: Path to the file
|
|
262
262
|
action: "query" (check lock), "acquire" (lock file), "release" (unlock)
|
|
263
263
|
"""
|
|
264
|
-
_ensure_registered
|
|
265
|
-
result =
|
|
266
|
-
"
|
|
267
|
-
"action": action,
|
|
268
|
-
|
|
269
|
-
})
|
|
264
|
+
await asyncio.to_thread(_ensure_registered)
|
|
265
|
+
result = await asyncio.to_thread(
|
|
266
|
+
_mesh_request, "POST", "/lock",
|
|
267
|
+
{"file_path": file_path, "action": action, "locked_by": _PEER_ID},
|
|
268
|
+
)
|
|
270
269
|
return result or {"error": "Lock operation failed"}
|
|
271
270
|
|
|
272
271
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
@@ -275,7 +274,7 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
275
274
|
|
|
276
275
|
Shows the activity log of the mesh network.
|
|
277
276
|
"""
|
|
278
|
-
result = _mesh_request
|
|
277
|
+
result = await asyncio.to_thread(_mesh_request, "GET", "/events")
|
|
279
278
|
return result or {"events": []}
|
|
280
279
|
|
|
281
280
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
@@ -284,10 +283,10 @@ def register_mesh_tools(server, get_engine: Callable) -> None:
|
|
|
284
283
|
|
|
285
284
|
Shows broker uptime, peer count, and connection status.
|
|
286
285
|
"""
|
|
287
|
-
result = _mesh_request
|
|
286
|
+
result = await asyncio.to_thread(_mesh_request, "GET", "/status")
|
|
288
287
|
if result:
|
|
289
288
|
result["my_peer_id"] = _PEER_ID
|
|
290
|
-
result["heartbeat_active"] = _HEARTBEAT_THREAD is not None
|
|
289
|
+
result["heartbeat_active"] = _HEARTBEAT_THREAD is not None and _HEARTBEAT_THREAD.is_alive()
|
|
291
290
|
return result or {
|
|
292
291
|
"broker_up": False,
|
|
293
292
|
"error": "Cannot reach mesh broker. Is the daemon running? (slm serve start)",
|
|
@@ -93,19 +93,23 @@ class PerItemBoundaryRecord:
|
|
|
93
93
|
query_sim: float,
|
|
94
94
|
delta: float = 0.05,
|
|
95
95
|
epsilon_grid: tuple[float, ...] = (0.01, 0.02, 0.05, 0.10),
|
|
96
|
+
return_threshold: float = 1.0,
|
|
96
97
|
) -> float:
|
|
97
98
|
"""Compute τ̂ — the vCache exploration probability (Eq. 11).
|
|
98
99
|
|
|
99
100
|
Args:
|
|
100
|
-
query_sim:
|
|
101
|
-
delta:
|
|
102
|
-
|
|
103
|
-
epsilon_grid:
|
|
104
|
-
|
|
101
|
+
query_sim: Cosine similarity s(x) ∈ [0, 1] for the incoming query.
|
|
102
|
+
delta: δ — user-defined maximum error rate.
|
|
103
|
+
Theorem 4.1 guarantee: Pr(correct) ≥ 1 - δ.
|
|
104
|
+
epsilon_grid: ε values for the Eq. 11 min sweep.
|
|
105
|
+
Distinct from δ; controls CI conservativeness.
|
|
106
|
+
return_threshold: Semantic return threshold from config (semantic_return_threshold).
|
|
107
|
+
C-03 fix: during cold start, exploit directly when
|
|
108
|
+
query_sim >= return_threshold instead of always exploring.
|
|
105
109
|
|
|
106
110
|
Returns:
|
|
107
111
|
τ̂ ∈ [0.0, 1.0]. Lower = more exploitation.
|
|
108
|
-
Cold start (n < 3):
|
|
112
|
+
Cold start (n < 3): 0.0 if query_sim >= return_threshold, else 1.0.
|
|
109
113
|
|
|
110
114
|
Eq. 11 derivation (from the paper):
|
|
111
115
|
1. I_tt = Σ γ̂² · p_i(1 - p_i) [Fisher info diagonal]
|
|
@@ -117,7 +121,12 @@ class PerItemBoundaryRecord:
|
|
|
117
121
|
"""
|
|
118
122
|
n = len(self.samples)
|
|
119
123
|
if n < 3:
|
|
120
|
-
|
|
124
|
+
# ARCH-02 note: this function serves dual purpose — (a) warm-phase vCache
|
|
125
|
+
# Eq. 11 tau computation and (b) cold-start similarity gate. The cold-start
|
|
126
|
+
# branch (n < 3) is intentionally simple: if the query is already above the
|
|
127
|
+
# return threshold, serve it (tau=0.0 → exploit); otherwise explore (tau=1.0).
|
|
128
|
+
# C-03: honor return_threshold — avoids 100% miss until 3 samples are accumulated.
|
|
129
|
+
return 0.0 if query_sim >= return_threshold else 1.0
|
|
121
130
|
|
|
122
131
|
# Step 1: Fisher-information SE
|
|
123
132
|
i_tt = 0.0
|
|
@@ -144,12 +153,17 @@ class PerItemBoundaryRecord:
|
|
|
144
153
|
|
|
145
154
|
return best_tau
|
|
146
155
|
|
|
147
|
-
def should_explore(
|
|
156
|
+
def should_explore(
|
|
157
|
+
self,
|
|
158
|
+
query_sim: float,
|
|
159
|
+
delta: float = 0.05,
|
|
160
|
+
return_threshold: float = 1.0,
|
|
161
|
+
) -> bool:
|
|
148
162
|
"""Return True (explore = LLM call) or False (exploit = serve cache).
|
|
149
163
|
|
|
150
164
|
Source: vCache Algorithm 2: draw u ~ Uniform(0, 1); explore iff u ≤ τ̂.
|
|
151
165
|
"""
|
|
152
|
-
tau = self.compute_tau(query_sim, delta=delta)
|
|
166
|
+
tau = self.compute_tau(query_sim, delta=delta, return_threshold=return_threshold)
|
|
153
167
|
return _RNG.random() <= tau
|
|
154
168
|
|
|
155
169
|
def add_sample(
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import logging
|
|
6
7
|
import time
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
9
10
|
from superlocalmemory.optimize.cache.key_builder import CacheConfig
|
|
10
11
|
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
11
13
|
|
|
12
14
|
_NON_CACHEABLE_FINISH_REASONS: frozenset[str] = frozenset({
|
|
13
15
|
"tool_use",
|
|
@@ -20,17 +22,21 @@ _NON_CACHEABLE_FINISH_REASONS: frozenset[str] = frozenset({
|
|
|
20
22
|
def _is_cacheable_response(response: dict) -> bool:
|
|
21
23
|
finish = response.get("stop_reason") or response.get("finish_reason") or ""
|
|
22
24
|
if finish in _NON_CACHEABLE_FINISH_REASONS:
|
|
25
|
+
logger.debug("exact: skip cache (finish_reason=%r)", finish)
|
|
23
26
|
return False
|
|
24
27
|
choices = response.get("choices") or []
|
|
25
28
|
for choice in choices:
|
|
26
29
|
fr = (choice.get("finish_reason") or "")
|
|
27
30
|
if fr in _NON_CACHEABLE_FINISH_REASONS:
|
|
31
|
+
logger.debug("exact: skip cache (choice.finish_reason=%r)", fr)
|
|
28
32
|
return False
|
|
29
33
|
msg = choice.get("message") or {}
|
|
30
34
|
if msg.get("tool_calls"):
|
|
35
|
+
logger.debug("exact: skip cache (choice.message.tool_calls present)")
|
|
31
36
|
return False
|
|
32
37
|
for block in response.get("content") or []:
|
|
33
38
|
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
39
|
+
logger.debug("exact: skip cache (tool_use content block)")
|
|
34
40
|
return False
|
|
35
41
|
return True
|
|
36
42
|
|
|
@@ -46,9 +52,6 @@ class ExactCache:
|
|
|
46
52
|
row = self._db.get(key, tenant_id)
|
|
47
53
|
if row is None:
|
|
48
54
|
return None
|
|
49
|
-
if row.ttl_expires is not None and row.ttl_expires < time.time():
|
|
50
|
-
self._db.delete(key, tenant_id)
|
|
51
|
-
return None
|
|
52
55
|
return json.loads(row.value.decode("utf-8"))
|
|
53
56
|
|
|
54
57
|
def set(
|
|
@@ -74,7 +77,7 @@ class ExactCache:
|
|
|
74
77
|
value=encoded,
|
|
75
78
|
model=model,
|
|
76
79
|
ttl_expires=expires_at,
|
|
77
|
-
tags=
|
|
80
|
+
tags=tags,
|
|
78
81
|
)
|
|
79
82
|
return True
|
|
80
83
|
|
|
@@ -4,10 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
|
+
import logging
|
|
7
8
|
import re
|
|
8
9
|
from dataclasses import dataclass, field
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
11
14
|
DETERMINISTIC_PARAMS: frozenset[str] = frozenset({
|
|
12
15
|
"max_tokens", "stop", "stop_sequences", "top_p", "top_k",
|
|
13
16
|
"response_format", "tools", "tool_choice",
|
|
@@ -62,6 +65,16 @@ class KeyBuilder:
|
|
|
62
65
|
temperature = 0.0
|
|
63
66
|
|
|
64
67
|
if temperature != 0 and not self._config.allow_nonzero_temperature_cache:
|
|
68
|
+
logger.debug(
|
|
69
|
+
"cache skip: temperature=%.2f allow_nonzero=%s",
|
|
70
|
+
temperature,
|
|
71
|
+
self._config.allow_nonzero_temperature_cache,
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
from superlocalmemory.optimize.metrics.counters import MetricsCollector
|
|
75
|
+
MetricsCollector.get_instance().increment_skipped_temperature()
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
65
78
|
return None
|
|
66
79
|
|
|
67
80
|
deterministic_params: dict[str, Any] = {}
|