superlocalmemory 3.8.0 → 3.8.2

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 (134) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +2 -2
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +3 -5
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +2 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +3 -5
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +2 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +494 -9
  35. package/src/superlocalmemory/cli/daemon.py +7 -0
  36. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  37. package/src/superlocalmemory/cli/main.py +72 -7
  38. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  39. package/src/superlocalmemory/cli/version_banner.py +17 -3
  40. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  41. package/src/superlocalmemory/core/component_healer.py +144 -0
  42. package/src/superlocalmemory/core/component_registry.py +487 -0
  43. package/src/superlocalmemory/core/config.py +21 -0
  44. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  45. package/src/superlocalmemory/core/embeddings.py +132 -45
  46. package/src/superlocalmemory/core/engine.py +29 -22
  47. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  48. package/src/superlocalmemory/core/ingestion_command.py +154 -25
  49. package/src/superlocalmemory/core/injection.py +12 -7
  50. package/src/superlocalmemory/core/maintenance.py +43 -0
  51. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
  52. package/src/superlocalmemory/core/recall_pipeline.py +42 -4
  53. package/src/superlocalmemory/core/store_pipeline.py +195 -20
  54. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  55. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  56. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  57. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  58. package/src/superlocalmemory/learning/reward.py +50 -0
  59. package/src/superlocalmemory/learning/source_quality.py +523 -1
  60. package/src/superlocalmemory/loops/ledger.py +25 -5
  61. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  62. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  63. package/src/superlocalmemory/mcp/server.py +11 -30
  64. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  65. package/src/superlocalmemory/mcp/tools_core.py +21 -5
  66. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  67. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  68. package/src/superlocalmemory/retrieval/engine.py +53 -21
  69. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  70. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  71. package/src/superlocalmemory/server/config_file.py +90 -0
  72. package/src/superlocalmemory/server/origin.py +50 -0
  73. package/src/superlocalmemory/server/routes/backup.py +293 -70
  74. package/src/superlocalmemory/server/routes/behavioral.py +342 -61
  75. package/src/superlocalmemory/server/routes/brain.py +57 -16
  76. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  77. package/src/superlocalmemory/server/routes/entity.py +100 -23
  78. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  79. package/src/superlocalmemory/server/routes/learning.py +286 -105
  80. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  81. package/src/superlocalmemory/server/routes/memories.py +8 -3
  82. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  83. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  84. package/src/superlocalmemory/server/routes/stats.py +93 -155
  85. package/src/superlocalmemory/server/routes/token.py +3 -13
  86. package/src/superlocalmemory/server/routes/v3_api.py +184 -20
  87. package/src/superlocalmemory/server/unified_daemon.py +732 -41
  88. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  89. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  90. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  92. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  93. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  94. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  95. package/src/superlocalmemory/storage/schema.py +49 -1
  96. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  97. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  98. package/src/superlocalmemory/ui/index.html +6 -8
  99. package/src/superlocalmemory/ui/js/core.js +52 -9
  100. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  101. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  102. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  103. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  104. package/src/superlocalmemory/ui/js/od-entities.js +65 -22
  105. package/src/superlocalmemory/ui/js/od-graph.js +46 -4
  106. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  107. package/src/superlocalmemory/ui/js/od-memories.js +84 -5
  108. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  109. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  110. package/src/superlocalmemory/ui/js/od-settings.js +186 -63
  111. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  112. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  113. package/src/superlocalmemory/ui/js/settings.js +15 -1
  114. package/plugin-src/.mcp.json +0 -12
  115. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  116. package/plugin-src/agents/slm-loop-runner.md +0 -71
  117. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  118. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  119. package/plugin-src/commands/slm-loop.md +0 -31
  120. package/plugin-src/hooks/.gitkeep +0 -0
  121. package/plugin-src/hooks/hooks.json +0 -102
  122. package/plugin-src/manifest.json +0 -30
  123. package/plugin-src/requirements.txt +0 -1
  124. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  125. package/plugin-src/scripts/ensure-venv.bat +0 -122
  126. package/plugin-src/scripts/ensure-venv.sh +0 -105
  127. package/plugin-src/scripts/slm-launch +0 -62
  128. package/plugin-src/scripts/slm-launch.bat +0 -23
  129. package/plugin-src/settings.json +0 -25
  130. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  131. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  132. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  133. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  134. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -0,0 +1,153 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ """Read-only persisted telemetry used by the Living Brain routes."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import sqlite3
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger("superlocalmemory.routes.learning")
12
+
13
+ SOURCE_SCORE_LIMIT = 50
14
+
15
+
16
+ class ReadOnlyRankerStore:
17
+ """Minimal model-cache adapter that never creates or migrates tables."""
18
+
19
+ def __init__(self, db_path: Path) -> None:
20
+ self._db_path = Path(db_path)
21
+
22
+ def count_signals(self, profile_id: str) -> int:
23
+ connection = _readonly_connection(self._db_path)
24
+ try:
25
+ row = connection.execute(
26
+ "SELECT COUNT(*) AS count FROM learning_signals "
27
+ "WHERE profile_id = ?",
28
+ (profile_id,),
29
+ ).fetchone()
30
+ return int(row["count"] if row else 0)
31
+ finally:
32
+ connection.close()
33
+
34
+ def load_active_model(self, profile_id: str) -> dict | None:
35
+ connection = _readonly_connection(self._db_path)
36
+ try:
37
+ row = connection.execute(
38
+ "SELECT state_bytes, bytes_sha256, feature_names, trained_at, "
39
+ "model_version FROM learning_model_state "
40
+ "WHERE profile_id = ? AND is_active = 1 LIMIT 1",
41
+ (profile_id,),
42
+ ).fetchone()
43
+ finally:
44
+ connection.close()
45
+ if row is None:
46
+ return None
47
+ return {
48
+ "state_bytes": bytes(row["state_bytes"]),
49
+ "bytes_sha256": row["bytes_sha256"],
50
+ "feature_names": row["feature_names"],
51
+ "trained_at": row["trained_at"],
52
+ "model_version": row["model_version"],
53
+ }
54
+
55
+
56
+ def _readonly_connection(db_path: Path) -> sqlite3.Connection:
57
+ connection = sqlite3.connect(
58
+ f"file:{db_path}?mode=ro", uri=True, timeout=1.0,
59
+ )
60
+ connection.execute("PRAGMA busy_timeout=1000")
61
+ connection.row_factory = sqlite3.Row
62
+ return connection
63
+
64
+
65
+ def sqlite_status(exc: sqlite3.Error) -> str:
66
+ """Return a stable, non-sensitive dashboard status for SQLite failures."""
67
+ message = str(exc).lower()
68
+ return (
69
+ "database_busy"
70
+ if "locked" in message or "busy" in message
71
+ else "query_error"
72
+ )
73
+
74
+
75
+ def _table_exists(connection: sqlite3.Connection, table: str) -> bool:
76
+ row = connection.execute(
77
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
78
+ (table,),
79
+ ).fetchone()
80
+ return row is not None
81
+
82
+
83
+ def load_source_quality_state(profile_id: str, db_path: Path) -> dict:
84
+ """Read bounded source scores plus the profile's full source count."""
85
+ if not db_path.exists():
86
+ return {
87
+ "scores": {}, "tracked_sources": 0,
88
+ "status": "missing_database",
89
+ }
90
+ try:
91
+ connection = _readonly_connection(db_path)
92
+ try:
93
+ if not _table_exists(connection, "source_quality"):
94
+ return {
95
+ "scores": {}, "tracked_sources": 0,
96
+ "status": "missing_table",
97
+ }
98
+ count_row = connection.execute(
99
+ "SELECT COUNT(DISTINCT source_id) AS count "
100
+ "FROM source_quality WHERE profile_id = ? "
101
+ "AND source_id IS NOT NULL AND source_id != ''",
102
+ (profile_id,),
103
+ ).fetchone()
104
+ rows = connection.execute(
105
+ "SELECT source_id, alpha, beta FROM source_quality "
106
+ "WHERE profile_id = ? "
107
+ "ORDER BY updated_at DESC LIMIT ?",
108
+ (profile_id, SOURCE_SCORE_LIMIT),
109
+ ).fetchall()
110
+ finally:
111
+ connection.close()
112
+ except sqlite3.Error as exc:
113
+ status = sqlite_status(exc)
114
+ logger.warning("source quality telemetry %s: %s", status, exc)
115
+ return {"scores": {}, "tracked_sources": 0, "status": status}
116
+
117
+ scores: dict[str, float] = {}
118
+ for row in rows:
119
+ alpha, beta = float(row["alpha"] or 0), float(row["beta"] or 0)
120
+ source_id = str(row["source_id"] or "")
121
+ if alpha + beta > 0 and source_id and source_id not in scores:
122
+ scores[source_id] = round(alpha / (alpha + beta), 4)
123
+ return {
124
+ "scores": scores,
125
+ "tracked_sources": int(count_row["count"] if count_row else 0),
126
+ "status": "available",
127
+ }
128
+
129
+
130
+ def load_model_state(profile_id: str, db_path: Path) -> dict:
131
+ """Count persisted model artifacts without inferring them from signals."""
132
+ if not db_path.exists():
133
+ return {"models_trained": 0, "status": "missing_database"}
134
+ try:
135
+ connection = _readonly_connection(db_path)
136
+ try:
137
+ if not _table_exists(connection, "learning_model_state"):
138
+ return {"models_trained": 0, "status": "missing_table"}
139
+ row = connection.execute(
140
+ "SELECT COUNT(*) AS count FROM learning_model_state "
141
+ "WHERE profile_id = ?",
142
+ (profile_id,),
143
+ ).fetchone()
144
+ finally:
145
+ connection.close()
146
+ except sqlite3.Error as exc:
147
+ status = sqlite_status(exc)
148
+ logger.warning("model-state telemetry %s: %s", status, exc)
149
+ return {"models_trained": 0, "status": status}
150
+ return {
151
+ "models_trained": int(row["count"] if row else 0),
152
+ "status": "available",
153
+ }
@@ -507,9 +507,14 @@ async def search_memories(request: Request, body: SearchRequest):
507
507
  # a stalled connection and aborts with "signal is aborted without reason"
508
508
  # before the response arrives. Fix: run in a thread-pool executor so the
509
509
  # event loop stays alive to send keepalive frames.
510
- # v3.4.64 (regression fix): fast=True skips spreading_activation + agentic
511
- # LLM rounds (saves ~7s on cold graph traversal). fast=False is the SLOW
512
- # path that enables both; the earlier comment had this inverted.
510
+ # v3.8.2: fast=True the dashboard search BOX is a snappy retrieval
511
+ # list (all six local channels + reranker), never the internal agentic
512
+ # LLM round, which would reintroduce the multi-second hang this endpoint
513
+ # is regression-tested against (test_search_fast_param_and_profile_isolation).
514
+ # The human-facing LLM synthesis lives on separate paths that are NOT the
515
+ # search list: the "ask" memory-chat (/api/v3/chat/stream, Ollama Mode B)
516
+ # and the precomputed knowledge-cluster summaries (core.community_summary,
517
+ # Mode B/C). So search stays fast; synthesis is where the LLM adds value.
513
518
  import asyncio
514
519
  import time as _time
515
520
  engine = _get_engine(request)
@@ -11,15 +11,18 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import asyncio
15
14
  import re
16
- from typing import Optional
15
+ from datetime import datetime, timedelta, timezone
17
16
 
18
17
  from fastapi import APIRouter, HTTPException, Request
19
18
  from pydantic import BaseModel
20
19
 
21
20
  router = APIRouter(prefix="/mesh", tags=["mesh"])
22
21
 
22
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
23
+ _STALE_AFTER = timedelta(minutes=5)
24
+ _EXPIRE_AFTER = timedelta(minutes=30)
25
+
23
26
 
24
27
  # -- Request models --
25
28
 
@@ -89,6 +92,7 @@ def _get_broker(request: Request):
89
92
  client_host = request.client.host if request.client else ""
90
93
  if client_host not in ("127.0.0.1", "::1", "localhost"):
91
94
  import hmac
95
+
92
96
  from superlocalmemory.core.security_primitives import verify_install_token
93
97
 
94
98
  # Path 1: install token — dashboard/browser callers hold this and
@@ -154,7 +158,7 @@ def _reject_secret_state(key: str, value: str) -> None:
154
158
  # -- Routes --
155
159
 
156
160
  @router.post("/register")
157
- async def register(req: RegisterRequest, request: Request):
161
+ def register(req: RegisterRequest, request: Request):
158
162
  broker = _get_broker(request)
159
163
  if not req.session_id:
160
164
  raise HTTPException(400, detail="session_id required")
@@ -165,7 +169,7 @@ async def register(req: RegisterRequest, request: Request):
165
169
 
166
170
 
167
171
  @router.post("/deregister")
168
- async def deregister(req: DeregisterRequest, request: Request):
172
+ def deregister(req: DeregisterRequest, request: Request):
169
173
  broker = _get_broker(request)
170
174
  result = broker.deregister_peer(req.peer_id, profile_id=_active_profile())
171
175
  if not result.get("ok"):
@@ -185,7 +189,8 @@ def _peer_activity_counts(request: Request, session_ids: list[str]) -> dict:
185
189
  return {}
186
190
  try:
187
191
  from superlocalmemory.server.routes.helpers import (
188
- get_engine_lazy, get_active_profile,
192
+ get_active_profile,
193
+ get_engine_lazy,
189
194
  )
190
195
  engine = get_engine_lazy(request.app.state)
191
196
  if engine is None:
@@ -216,21 +221,102 @@ def _peer_activity_counts(request: Request, session_ids: list[str]) -> dict:
216
221
  return {}
217
222
 
218
223
 
224
+ def _parse_heartbeat(value: object) -> datetime | None:
225
+ """Parse a stored heartbeat as UTC without trusting malformed values."""
226
+ if not isinstance(value, str) or not value:
227
+ return None
228
+ try:
229
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
230
+ except ValueError:
231
+ return None
232
+ if parsed.tzinfo is None:
233
+ return parsed.replace(tzinfo=timezone.utc)
234
+ return parsed.astimezone(timezone.utc)
235
+
236
+
237
+ def _mesh_read_model(records: list[dict]) -> tuple[list[dict], list[dict]]:
238
+ """Return bounded remote peers and local sessions with read-time liveness.
239
+
240
+ The broker persists local agent sessions in ``mesh_peers`` for messaging.
241
+ That storage detail must not make them look like remote mesh neighbours in
242
+ the dashboard. Classification is read-only so an ordinary dashboard GET
243
+ neither mutates a live database nor waits for the five-minute cleanup loop.
244
+ """
245
+ now = datetime.now(timezone.utc)
246
+ remote: list[dict] = []
247
+ local: list[dict] = []
248
+ for record in records:
249
+ heartbeat = _parse_heartbeat(record.get("last_heartbeat"))
250
+ if heartbeat is None:
251
+ continue
252
+ age = now - heartbeat
253
+ if age >= _EXPIRE_AFTER:
254
+ continue
255
+ stale_at = heartbeat + _STALE_AFTER
256
+ expires_at = heartbeat + _EXPIRE_AFTER
257
+ status = "active" if age < _STALE_AFTER else "stale"
258
+ normalized = {
259
+ **record,
260
+ "status": status,
261
+ "stale_at": stale_at.isoformat(),
262
+ "expires_at": expires_at.isoformat(),
263
+ }
264
+ if str(record.get("host") or "").lower() in _LOOPBACK_HOSTS:
265
+ local.append(normalized)
266
+ else:
267
+ remote.append(normalized)
268
+ return remote, local
269
+
270
+
271
+ def _mesh_counts(remote: list[dict], local: list[dict]) -> dict:
272
+ """Expose active counts separately while retaining the legacy total key."""
273
+ active_remote = sum(peer["status"] == "active" for peer in remote)
274
+ active_local = sum(session["status"] == "active" for session in local)
275
+ return {
276
+ "peer_count": active_remote + active_local,
277
+ "active_peer_count": active_remote + active_local,
278
+ "remote_peer_count": active_remote,
279
+ "local_session_count": active_local,
280
+ "stale_peer_count": sum(peer["status"] == "stale" for peer in remote),
281
+ "stale_local_session_count": sum(session["status"] == "stale" for session in local),
282
+ }
283
+
284
+
219
285
  @router.get("/peers")
220
- async def peers(request: Request):
286
+ def peers(request: Request, view: str = "all"):
221
287
  broker = _get_broker(request)
222
- peer_list = broker.list_all_peers(_active_profile())
288
+ if view not in {"all", "remote", "local"}:
289
+ raise HTTPException(422, detail="view must be all, remote, or local")
290
+ remote_peers, local_sessions = _mesh_read_model(
291
+ broker.list_all_peers(_active_profile()),
292
+ )
293
+ peer_list = (
294
+ remote_peers if view == "remote" else
295
+ local_sessions if view == "local" else
296
+ [*remote_peers, *local_sessions]
297
+ )
223
298
  session_ids = [p.get("session_id") for p in peer_list if p.get("session_id")]
224
299
  counts = _peer_activity_counts(request, session_ids)
300
+ enriched = []
225
301
  for p in peer_list:
226
302
  c = counts.get(p.get("session_id"), {})
227
- p["tool_count"] = c.get("tool_count", 0)
228
- p["memory_count"] = c.get("memory_count", 0)
229
- return {"peers": peer_list}
303
+ enriched.append({
304
+ **p,
305
+ "tool_count": c.get("tool_count", 0),
306
+ "memory_count": c.get("memory_count", 0),
307
+ })
308
+ return {
309
+ "peers": enriched,
310
+ "remote_peers": remote_peers,
311
+ "local_sessions": local_sessions,
312
+ "view": view,
313
+ **_mesh_counts(remote_peers, local_sessions),
314
+ }
230
315
 
231
316
 
232
317
  @router.post("/heartbeat")
233
- async def heartbeat(req: HeartbeatRequest, request: Request):
318
+ def heartbeat(req: HeartbeatRequest, request: Request):
319
+ """Update peer liveness without blocking the daemon's async event loop."""
234
320
  broker = _get_broker(request)
235
321
  result = broker.heartbeat(req.peer_id, profile_id=_active_profile())
236
322
  if not result.get("ok"):
@@ -239,7 +325,7 @@ async def heartbeat(req: HeartbeatRequest, request: Request):
239
325
 
240
326
 
241
327
  @router.post("/summary")
242
- async def summary(req: SummaryRequest, request: Request):
328
+ def summary(req: SummaryRequest, request: Request):
243
329
  broker = _get_broker(request)
244
330
  result = broker.update_summary(req.peer_id, req.summary,
245
331
  profile_id=_active_profile())
@@ -249,21 +335,17 @@ async def summary(req: SummaryRequest, request: Request):
249
335
 
250
336
 
251
337
  @router.post("/send")
252
- async def send(req: SendRequest, request: Request):
338
+ def send(req: SendRequest, request: Request):
253
339
  broker = _get_broker(request)
254
340
  to_target = req.to_peer or req.to # v3.4.6: accept both field names
255
341
  if not to_target:
256
342
  raise HTTPException(400, detail="'to' or 'to_peer' required")
257
- # Resolve the tenant on the event loop (the request ContextVar is set here);
258
- # a worker thread would not inherit it.
259
343
  profile = _active_profile()
260
- # send_message may make a blocking httpx call (up to 10s) when delivering
261
- # to a remote peer. Offload to a worker thread so a slow/dead peer network
262
- # never stalls the daemon event loop for all other users. The broker opens
263
- # a fresh SQLite connection per call, so this is thread-safe.
264
- result = await asyncio.to_thread(
265
- broker.send_message, req.from_peer, to_target, req.content,
266
- req.type, "", profile,
344
+ # This sync FastAPI route already runs in the worker thread pool, so the
345
+ # broker's SQLite retries and optional remote HTTP delivery cannot block
346
+ # the daemon event loop.
347
+ result = broker.send_message(
348
+ req.from_peer, to_target, req.content, req.type, "", profile,
267
349
  )
268
350
  if not result.get("ok"):
269
351
  status = 413 if "too large" in result.get("error", "") else 404
@@ -272,21 +354,21 @@ async def send(req: SendRequest, request: Request):
272
354
 
273
355
 
274
356
  @router.get("/inbox/{peer_id}")
275
- async def inbox(peer_id: str, request: Request, project_path: str = ""):
357
+ def inbox(peer_id: str, request: Request, project_path: str = ""):
276
358
  broker = _get_broker(request)
277
359
  return {"messages": broker.get_inbox(peer_id, project_path,
278
360
  profile_id=_active_profile())}
279
361
 
280
362
 
281
363
  @router.post("/inbox/{peer_id}/read")
282
- async def mark_read(peer_id: str, req: ReadRequest, request: Request):
364
+ def mark_read(peer_id: str, req: ReadRequest, request: Request):
283
365
  broker = _get_broker(request)
284
366
  return broker.mark_read(peer_id, req.message_ids,
285
367
  profile_id=_active_profile())
286
368
 
287
369
 
288
370
  @router.get("/pending/{peer_id}")
289
- async def pending(peer_id: str, request: Request, project_path: str = ""):
371
+ def pending(peer_id: str, request: Request, project_path: str = ""):
290
372
  """Get pending broadcast/project messages for this peer."""
291
373
  broker = _get_broker(request)
292
374
  messages = broker.get_pending(peer_id, project_path,
@@ -295,13 +377,13 @@ async def pending(peer_id: str, request: Request, project_path: str = ""):
295
377
 
296
378
 
297
379
  @router.get("/state")
298
- async def state_all(request: Request):
380
+ def state_all(request: Request):
299
381
  broker = _get_broker(request)
300
382
  return {"state": broker.get_state(profile_id=_active_profile())}
301
383
 
302
384
 
303
385
  @router.post("/state")
304
- async def state_set(req: StateSetRequest, request: Request):
386
+ def state_set(req: StateSetRequest, request: Request):
305
387
  broker = _get_broker(request)
306
388
  if not req.key:
307
389
  raise HTTPException(400, detail="key required")
@@ -311,7 +393,7 @@ async def state_set(req: StateSetRequest, request: Request):
311
393
 
312
394
 
313
395
  @router.get("/state/{key}")
314
- async def state_get(key: str, request: Request):
396
+ def state_get(key: str, request: Request):
315
397
  broker = _get_broker(request)
316
398
  result = broker.get_state_key(key, profile_id=_active_profile())
317
399
  if result is None:
@@ -320,7 +402,7 @@ async def state_get(key: str, request: Request):
320
402
 
321
403
 
322
404
  @router.post("/lock")
323
- async def lock(req: LockRequest, request: Request):
405
+ def lock(req: LockRequest, request: Request):
324
406
  broker = _get_broker(request)
325
407
  if not req.file_path or not req.locked_by:
326
408
  raise HTTPException(400, detail="file_path and locked_by required")
@@ -331,12 +413,19 @@ async def lock(req: LockRequest, request: Request):
331
413
 
332
414
 
333
415
  @router.get("/events")
334
- async def events(request: Request):
416
+ def events(request: Request):
335
417
  broker = _get_broker(request)
336
418
  return {"events": broker.get_events(profile_id=_active_profile())}
337
419
 
338
420
 
339
421
  @router.get("/status")
340
- async def status(request: Request):
422
+ def status(request: Request):
341
423
  broker = _get_broker(request)
342
- return broker.get_status(profile_id=_active_profile())
424
+ broker_status = broker.get_status(profile_id=_active_profile())
425
+ remote_peers, local_sessions = _mesh_read_model(
426
+ broker.list_all_peers(_active_profile()),
427
+ )
428
+ return {
429
+ **broker_status,
430
+ **_mesh_counts(remote_peers, local_sessions),
431
+ }
@@ -33,9 +33,9 @@ from superlocalmemory.infra.rate_limiter import (
33
33
  set_limits,
34
34
  )
35
35
  from superlocalmemory.server.routes.config_api import (
36
- _atomic_write,
37
36
  _read_config,
38
37
  _require_admin,
38
+ _update_config,
39
39
  )
40
40
 
41
41
  logger = logging.getLogger(__name__)
@@ -81,22 +81,26 @@ def load_persisted_limits() -> None:
81
81
  logger.debug("load_persisted_limits skipped: %s", exc)
82
82
 
83
83
 
84
+ def _require_read(request: Request) -> None:
85
+ from superlocalmemory.access.rbac import Permission
86
+ from superlocalmemory.server.rbac_enforce import require_permission
87
+ from superlocalmemory.server.routes.helpers import get_active_profile
88
+
89
+ require_permission(request, Permission.READ, profile=get_active_profile())
90
+
91
+
84
92
  @router.get("/ratelimit")
85
- def get_ratelimit() -> JSONResponse:
93
+ def get_ratelimit(request: Request) -> JSONResponse:
94
+ _require_read(request)
86
95
  return JSONResponse(_effective())
87
96
 
88
97
 
89
98
  @router.put("/ratelimit")
90
- async def put_ratelimit(request: Request) -> JSONResponse:
99
+ def put_ratelimit(
100
+ request: Request,
101
+ payload: RateLimitUpdate,
102
+ ) -> JSONResponse:
91
103
  _require_admin(request)
92
- try:
93
- raw = await request.json()
94
- except Exception:
95
- raw = {}
96
- try:
97
- payload = RateLimitUpdate(**(raw or {}))
98
- except Exception as exc:
99
- return JSONResponse(status_code=422, content={"error": str(exc)})
100
104
 
101
105
  if payload.write is None and payload.read is None and payload.window is None:
102
106
  return JSONResponse(
@@ -104,21 +108,25 @@ async def put_ratelimit(request: Request) -> JSONResponse:
104
108
  content={"error": "Provide at least one of write, read, window."},
105
109
  )
106
110
 
107
- # Runtime apply (reconfigures every live limiter — no restart).
108
- effective = set_limits(
109
- write=payload.write, read=payload.read, window=payload.window,
110
- )
111
-
112
- # Persist so the override survives restart.
111
+ current = get_limits()
112
+ requested = {
113
+ "write": payload.write if payload.write is not None else current["write"],
114
+ "read": payload.read if payload.read is not None else current["read"],
115
+ "window": payload.window if payload.window is not None else current["window"],
116
+ }
117
+ # Persist first. A response must never say success for a runtime-only
118
+ # setting that silently disappears after restart.
113
119
  try:
114
- cfg = _read_config()
115
- cfg[_CONFIG_KEY] = {
116
- "write": effective["write"],
117
- "read": effective["read"],
118
- "window": effective["window"],
119
- }
120
- _atomic_write(cfg)
120
+ _update_config(
121
+ lambda cfg: cfg.update({_CONFIG_KEY: dict(requested)}),
122
+ )
121
123
  except Exception as exc:
122
- logger.warning("rate-limit persist failed (applied at runtime): %s", exc)
124
+ logger.warning("rate-limit persist failed: %s", exc)
125
+ return JSONResponse(
126
+ status_code=500,
127
+ content={"error": "Rate-limit configuration was not persisted."},
128
+ )
123
129
 
130
+ # Runtime apply reconfigures every live limiter; no restart is required.
131
+ set_limits(**requested)
124
132
  return JSONResponse({"success": True, **_effective()})