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.
Files changed (48) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +8 -4
  3. package/package.json +1 -1
  4. package/pyproject.toml +6 -1
  5. package/src/superlocalmemory/__init__.py +6 -2
  6. package/src/superlocalmemory/cli/compress_cmd.py +32 -70
  7. package/src/superlocalmemory/cli/daemon.py +25 -3
  8. package/src/superlocalmemory/cli/optimize_cmd.py +1 -3
  9. package/src/superlocalmemory/cli/setup_wizard.py +49 -0
  10. package/src/superlocalmemory/core/config.py +28 -0
  11. package/src/superlocalmemory/core/engine.py +15 -4
  12. package/src/superlocalmemory/core/health_monitor.py +32 -9
  13. package/src/superlocalmemory/mcp/agent_context.py +111 -0
  14. package/src/superlocalmemory/mcp/tools_active.py +47 -12
  15. package/src/superlocalmemory/mcp/tools_core.py +22 -2
  16. package/src/superlocalmemory/mcp/tools_mesh.py +37 -38
  17. package/src/superlocalmemory/optimize/cache/boundary_store.py +23 -9
  18. package/src/superlocalmemory/optimize/cache/exact.py +7 -4
  19. package/src/superlocalmemory/optimize/cache/key_builder.py +13 -0
  20. package/src/superlocalmemory/optimize/cache/manager.py +70 -8
  21. package/src/superlocalmemory/optimize/cache/semantic.py +10 -5
  22. package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +1 -7
  23. package/src/superlocalmemory/optimize/compress/router.py +82 -87
  24. package/src/superlocalmemory/optimize/config/__init__.py +16 -0
  25. package/src/superlocalmemory/optimize/config/defaults.py +1 -6
  26. package/src/superlocalmemory/optimize/config/schema.py +2 -19
  27. package/src/superlocalmemory/optimize/config/store.py +15 -1
  28. package/src/superlocalmemory/optimize/metrics/counters.py +15 -7
  29. package/src/superlocalmemory/optimize/proxy/_helpers.py +100 -2
  30. package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +12 -0
  31. package/src/superlocalmemory/optimize/proxy/capture.py +243 -0
  32. package/src/superlocalmemory/optimize/proxy/gemini_surface.py +31 -0
  33. package/src/superlocalmemory/optimize/proxy/openai_surface.py +12 -0
  34. package/src/superlocalmemory/optimize/proxy/server.py +29 -0
  35. package/src/superlocalmemory/optimize/storage/db.py +78 -11
  36. package/src/superlocalmemory/optimize/storage/schema.py +11 -0
  37. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -3
  38. package/src/superlocalmemory/server/routes/optimize.py +6 -8
  39. package/src/superlocalmemory/server/unified_daemon.py +68 -11
  40. package/src/superlocalmemory/ui/index.html +18 -14
  41. package/src/superlocalmemory/ui/js/auto-settings.js +3 -1
  42. package/src/superlocalmemory/ui/js/ng-shell.js +3 -0
  43. package/src/superlocalmemory/ui/js/optimize.js +9 -9
  44. package/src/superlocalmemory.egg-info/PKG-INFO +10 -5
  45. package/src/superlocalmemory.egg-info/SOURCES.txt +2 -2
  46. package/src/superlocalmemory.egg-info/requires.txt +1 -0
  47. package/src/superlocalmemory/optimize/compress/extractive_code.py +0 -311
  48. 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
- Each MCP client (Claude Code, Codex, Gemini CLI, Kimi, etc.) can set
110
- the ``SLM_AGENT_ID`` env var in its MCP server config so that memories,
111
- observations, and registry entries are tagged with the actual source
112
- agent not the legacy ``"mcp_client"`` default.
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
- return os.environ.get("SLM_AGENT_ID", default)
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
- response = pool_recall(search_query, limit=max_results, fast=False)
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
- stored = auto.capture(
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
- if is_daemon_running():
127
- resp = daemon_request("POST", "/remember", {
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 = _mesh_request("POST", "/summary", {
148
- "peer_id": _PEER_ID,
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": True,
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("GET", "/peers")
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 = _mesh_request("POST", "/send", {
190
- "from_peer": _PEER_ID,
191
- "to_peer": to,
192
- "content": message,
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 = _mesh_request(
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
- _mesh_request("POST", f"/inbox/{_PEER_ID}/read", {
214
- "message_ids": unread_ids,
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 = _mesh_request("POST", "/state", {
238
- "key": key,
239
- "value": value,
240
- "set_by": _PEER_ID,
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("GET", f"/state/{key}")
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("GET", "/state")
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 = _mesh_request("POST", "/lock", {
266
- "file_path": file_path,
267
- "action": action,
268
- "locked_by": _PEER_ID,
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("GET", "/events")
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("GET", "/status")
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: Cosine similarity s(x) ∈ [0, 1] for the incoming query.
101
- delta: δ — user-defined maximum error rate.
102
- Theorem 4.1 guarantee: Pr(correct) ≥ 1 - δ.
103
- epsilon_grid: ε values for the Eq. 11 min sweep.
104
- Distinct from δ; controls CI conservativeness.
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): returns 1.0 (always explore).
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
- return 1.0 # cold startalways explore
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(self, query_sim: float, delta: float = 0.05) -> bool:
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] = {}