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.
- package/CHANGELOG.md +112 -0
- package/README.md +32 -120
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +1 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +2 -2
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +3 -5
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +2 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +3 -5
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +2 -1
- package/scripts/postinstall.js +7 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +494 -9
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +2 -7
- package/src/superlocalmemory/cli/main.py +72 -7
- package/src/superlocalmemory/cli/setup_wizard.py +142 -16
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
- package/src/superlocalmemory/core/component_healer.py +144 -0
- package/src/superlocalmemory/core/component_registry.py +487 -0
- package/src/superlocalmemory/core/config.py +21 -0
- package/src/superlocalmemory/core/embedding_worker.py +4 -5
- package/src/superlocalmemory/core/embeddings.py +132 -45
- package/src/superlocalmemory/core/engine.py +29 -22
- package/src/superlocalmemory/core/engine_ingestion.py +332 -45
- package/src/superlocalmemory/core/ingestion_command.py +154 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/maintenance.py +43 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
- package/src/superlocalmemory/core/recall_pipeline.py +42 -4
- package/src/superlocalmemory/core/store_pipeline.py +195 -20
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/portable_kit.py +34 -2
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/ledger.py +25 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
- package/src/superlocalmemory/mcp/server.py +11 -30
- package/src/superlocalmemory/mcp/tools_active.py +1 -1
- package/src/superlocalmemory/mcp/tools_core.py +21 -5
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
- package/src/superlocalmemory/retrieval/engine.py +53 -21
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/routes/backup.py +293 -70
- package/src/superlocalmemory/server/routes/behavioral.py +342 -61
- package/src/superlocalmemory/server/routes/brain.py +57 -16
- package/src/superlocalmemory/server/routes/config_api.py +84 -82
- package/src/superlocalmemory/server/routes/entity.py +100 -23
- package/src/superlocalmemory/server/routes/evolution.py +103 -100
- package/src/superlocalmemory/server/routes/learning.py +286 -105
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/memories.py +8 -3
- package/src/superlocalmemory/server/routes/mesh.py +121 -32
- package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
- package/src/superlocalmemory/server/routes/stats.py +93 -155
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +184 -20
- package/src/superlocalmemory/server/unified_daemon.py +732 -41
- package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
- package/src/superlocalmemory/storage/migration_runner.py +79 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/schema.py +49 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/ui/index.html +6 -8
- package/src/superlocalmemory/ui/js/core.js +52 -9
- package/src/superlocalmemory/ui/js/dashboard.js +169 -82
- package/src/superlocalmemory/ui/js/od-backup.js +156 -65
- package/src/superlocalmemory/ui/js/od-brain.js +88 -51
- package/src/superlocalmemory/ui/js/od-components.js +147 -0
- package/src/superlocalmemory/ui/js/od-entities.js +65 -22
- package/src/superlocalmemory/ui/js/od-graph.js +46 -4
- package/src/superlocalmemory/ui/js/od-health.js +18 -0
- package/src/superlocalmemory/ui/js/od-memories.js +84 -5
- package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
- package/src/superlocalmemory/ui/js/od-operations.js +36 -0
- package/src/superlocalmemory/ui/js/od-settings.js +186 -63
- package/src/superlocalmemory/ui/js/od-shell.js +249 -33
- package/src/superlocalmemory/ui/js/od-skills.js +44 -17
- package/src/superlocalmemory/ui/js/settings.js +15 -1
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-governance-advisor.md +0 -80
- package/plugin-src/agents/slm-loop-runner.md +0 -71
- package/plugin-src/agents/slm-memory-advisor.md +0 -49
- package/plugin-src/agents/slm-optimize-advisor.md +0 -44
- package/plugin-src/commands/slm-loop.md +0 -31
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -102
- package/plugin-src/manifest.json +0 -30
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -62
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -25
- package/plugin-src/skills/slm-governance/SKILL.md +0 -248
- package/plugin-src/skills/slm-loop/SKILL.md +0 -99
- package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
- package/plugin-src/skills/slm-profile/SKILL.md +0 -148
- 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.
|
|
511
|
-
#
|
|
512
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
+
def peers(request: Request, view: str = "all"):
|
|
221
287
|
broker = _get_broker(request)
|
|
222
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
261
|
-
#
|
|
262
|
-
#
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
+
def status(request: Request):
|
|
341
423
|
broker = _get_broker(request)
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
write
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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()})
|