superlocalmemory 3.7.8 → 3.8.1

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 (280) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +129 -0
  4. package/README.md +205 -123
  5. package/package.json +12 -3
  6. package/plugin/.claude-plugin/plugin.json +2 -3
  7. package/plugin/CLAUDE.md +8 -8
  8. package/plugin/agents/slm-governance-advisor.md +80 -0
  9. package/plugin/agents/slm-loop-runner.md +71 -0
  10. package/plugin/agents/slm-memory-advisor.md +10 -5
  11. package/plugin/agents/slm-optimize-advisor.md +9 -3
  12. package/plugin/commands/slm-loop.md +31 -0
  13. package/plugin/hooks/hooks.json +79 -0
  14. package/plugin/requirements.txt +1 -1
  15. package/plugin/scripts/slm-launch +46 -7
  16. package/plugin/settings.json +9 -0
  17. package/plugin/skills/slm-cache/SKILL.md +9 -1
  18. package/plugin/skills/slm-compress/SKILL.md +8 -1
  19. package/plugin/skills/slm-governance/SKILL.md +248 -0
  20. package/plugin/skills/slm-graph/SKILL.md +17 -3
  21. package/plugin/skills/slm-loop/SKILL.md +99 -0
  22. package/plugin/skills/slm-mesh/SKILL.md +282 -0
  23. package/plugin/skills/slm-profile/SKILL.md +148 -0
  24. package/plugin/skills/slm-recall/SKILL.md +46 -10
  25. package/plugin/skills/slm-remember/SKILL.md +48 -1
  26. package/plugin/skills/slm-scope/SKILL.md +176 -0
  27. package/plugin/skills/slm-session/SKILL.md +24 -1
  28. package/plugin/skills/slm-status/SKILL.md +18 -1
  29. package/plugin-src/rules/AGENTS.md +57 -18
  30. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  31. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  32. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  33. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  34. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  36. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  37. package/pyproject.toml +2 -1
  38. package/scripts/postinstall/validation.js +2 -0
  39. package/scripts/postinstall-interactive.js +74 -2
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/access/__init__.py +3 -0
  42. package/src/superlocalmemory/access/rbac.py +477 -0
  43. package/src/superlocalmemory/cli/commands.py +228 -17
  44. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  45. package/src/superlocalmemory/cli/daemon.py +7 -0
  46. package/src/superlocalmemory/cli/loop_cmd.py +187 -0
  47. package/src/superlocalmemory/cli/main.py +49 -8
  48. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  49. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  50. package/src/superlocalmemory/cli/pending_store.py +49 -13
  51. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  52. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  53. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  54. package/src/superlocalmemory/cli/version_banner.py +17 -3
  55. package/src/superlocalmemory/compliance/audit.py +6 -0
  56. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  57. package/src/superlocalmemory/compliance/retention.py +176 -45
  58. package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
  59. package/src/superlocalmemory/core/community_summary.py +267 -0
  60. package/src/superlocalmemory/core/config.py +216 -3
  61. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  62. package/src/superlocalmemory/core/context_cache.py +61 -18
  63. package/src/superlocalmemory/core/embedding_worker.py +21 -7
  64. package/src/superlocalmemory/core/embeddings.py +131 -46
  65. package/src/superlocalmemory/core/engine.py +41 -22
  66. package/src/superlocalmemory/core/engine_ingestion.py +359 -43
  67. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  68. package/src/superlocalmemory/core/entity_community.py +178 -0
  69. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  70. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  71. package/src/superlocalmemory/core/ingestion_command.py +134 -25
  72. package/src/superlocalmemory/core/injection.py +12 -7
  73. package/src/superlocalmemory/core/key_expander.py +138 -0
  74. package/src/superlocalmemory/core/maintenance.py +23 -0
  75. package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
  76. package/src/superlocalmemory/core/modes.py +1 -1
  77. package/src/superlocalmemory/core/mutations.py +2 -2
  78. package/src/superlocalmemory/core/pii.py +105 -0
  79. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  80. package/src/superlocalmemory/core/recall_pipeline.py +7 -3
  81. package/src/superlocalmemory/core/recall_worker.py +20 -6
  82. package/src/superlocalmemory/core/scale_engine.py +60 -1
  83. package/src/superlocalmemory/core/security_primitives.py +40 -2
  84. package/src/superlocalmemory/core/store_pipeline.py +186 -29
  85. package/src/superlocalmemory/core/worker_pool.py +21 -6
  86. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  87. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  88. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  89. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  90. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  91. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  92. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  93. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  94. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  95. package/src/superlocalmemory/evolution/triggers.py +22 -13
  96. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  97. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  98. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  99. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  100. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  101. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  102. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  103. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  104. package/src/superlocalmemory/hooks/portable_kit.py +148 -3
  105. package/src/superlocalmemory/infra/backup.py +12 -1
  106. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  107. package/src/superlocalmemory/infra/data_root.py +43 -4
  108. package/src/superlocalmemory/infra/event_bus.py +107 -24
  109. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  110. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  111. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  112. package/src/superlocalmemory/learning/cross_project.py +28 -19
  113. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  114. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  115. package/src/superlocalmemory/learning/reward.py +50 -0
  116. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  117. package/src/superlocalmemory/learning/source_quality.py +523 -1
  118. package/src/superlocalmemory/loops/__init__.py +56 -0
  119. package/src/superlocalmemory/loops/budget.py +58 -0
  120. package/src/superlocalmemory/loops/engine.py +164 -0
  121. package/src/superlocalmemory/loops/ledger.py +263 -0
  122. package/src/superlocalmemory/loops/models.py +152 -0
  123. package/src/superlocalmemory/loops/rules.py +52 -0
  124. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  125. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  126. package/src/superlocalmemory/mcp/profiles.py +103 -0
  127. package/src/superlocalmemory/mcp/server.py +32 -79
  128. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  129. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  130. package/src/superlocalmemory/mcp/tools_core.py +12 -4
  131. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  132. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  133. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  134. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  135. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  136. package/src/superlocalmemory/mesh/broker.py +237 -129
  137. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  138. package/src/superlocalmemory/optimize/NOTICE +1 -6
  139. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  140. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  141. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  142. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  143. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  144. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  145. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  146. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  147. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  148. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  149. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  150. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  151. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  152. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  153. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  154. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  155. package/src/superlocalmemory/retrieval/engine.py +221 -47
  156. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  157. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  158. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  159. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  160. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  161. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  162. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  163. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  164. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  165. package/src/superlocalmemory/server/api.py +4 -4
  166. package/src/superlocalmemory/server/config_file.py +90 -0
  167. package/src/superlocalmemory/server/origin.py +50 -0
  168. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  169. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  170. package/src/superlocalmemory/server/recall_health.py +24 -3
  171. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  172. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  173. package/src/superlocalmemory/server/routes/agents.py +128 -38
  174. package/src/superlocalmemory/server/routes/backup.py +317 -70
  175. package/src/superlocalmemory/server/routes/behavioral.py +349 -71
  176. package/src/superlocalmemory/server/routes/brain.py +69 -12
  177. package/src/superlocalmemory/server/routes/chat.py +10 -5
  178. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  179. package/src/superlocalmemory/server/routes/config_api.py +438 -0
  180. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  181. package/src/superlocalmemory/server/routes/entity.py +108 -26
  182. package/src/superlocalmemory/server/routes/events.py +24 -8
  183. package/src/superlocalmemory/server/routes/evolution.py +189 -68
  184. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  185. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  186. package/src/superlocalmemory/server/routes/insights.py +3 -3
  187. package/src/superlocalmemory/server/routes/learning.py +289 -118
  188. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  189. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  190. package/src/superlocalmemory/server/routes/memories.py +182 -57
  191. package/src/superlocalmemory/server/routes/mesh.py +200 -31
  192. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  193. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  194. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  195. package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
  196. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  197. package/src/superlocalmemory/server/routes/stats.py +103 -158
  198. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  199. package/src/superlocalmemory/server/routes/token.py +3 -13
  200. package/src/superlocalmemory/server/routes/v3_api.py +247 -89
  201. package/src/superlocalmemory/server/routes/ws.py +5 -2
  202. package/src/superlocalmemory/server/security_middleware.py +12 -5
  203. package/src/superlocalmemory/server/ui.py +20 -5
  204. package/src/superlocalmemory/server/unified_daemon.py +827 -72
  205. package/src/superlocalmemory/server/write_identity.py +38 -8
  206. package/src/superlocalmemory/storage/database.py +265 -53
  207. package/src/superlocalmemory/storage/migration_runner.py +132 -1
  208. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  209. package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
  210. package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
  211. package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
  212. package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
  213. package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
  214. package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
  215. package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
  216. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  217. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  218. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  219. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  220. package/src/superlocalmemory/storage/models.py +4 -0
  221. package/src/superlocalmemory/storage/schema.py +136 -1
  222. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  223. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  224. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  225. package/src/superlocalmemory/trust/gate.py +49 -8
  226. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  227. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  228. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  229. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  230. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  231. package/src/superlocalmemory/ui/favicon.svg +35 -4
  232. package/src/superlocalmemory/ui/index.html +303 -173
  233. package/src/superlocalmemory/ui/js/brain.js +5 -20
  234. package/src/superlocalmemory/ui/js/core.js +100 -41
  235. package/src/superlocalmemory/ui/js/dashboard.js +403 -65
  236. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  237. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  238. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  239. package/src/superlocalmemory/ui/js/memories.js +15 -4
  240. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  241. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  242. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  243. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  244. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  245. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  246. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  247. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  248. package/src/superlocalmemory/ui/js/od-backup.js +871 -0
  249. package/src/superlocalmemory/ui/js/od-brain.js +816 -0
  250. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  251. package/src/superlocalmemory/ui/js/od-graph.js +600 -0
  252. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  253. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  254. package/src/superlocalmemory/ui/js/od-memories.js +929 -0
  255. package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
  256. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  257. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  258. package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
  259. package/src/superlocalmemory/ui/js/od-shell.js +809 -0
  260. package/src/superlocalmemory/ui/js/od-skills.js +600 -0
  261. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  262. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  263. package/src/superlocalmemory/ui/js/settings.js +17 -3
  264. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  265. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  266. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  267. package/plugin-src/.mcp.json +0 -12
  268. package/plugin-src/agents/slm-memory-advisor.md +0 -44
  269. package/plugin-src/agents/slm-optimize-advisor.md +0 -38
  270. package/plugin-src/hooks/.gitkeep +0 -0
  271. package/plugin-src/hooks/hooks.json +0 -23
  272. package/plugin-src/manifest.json +0 -25
  273. package/plugin-src/requirements.txt +0 -1
  274. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  275. package/plugin-src/scripts/ensure-venv.bat +0 -122
  276. package/plugin-src/scripts/ensure-venv.sh +0 -105
  277. package/plugin-src/scripts/slm-launch +0 -23
  278. package/plugin-src/scripts/slm-launch.bat +0 -23
  279. package/plugin-src/settings.json +0 -16
  280. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -12,13 +12,17 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
12
12
  from __future__ import annotations
13
13
 
14
14
  import re
15
- from typing import Optional
15
+ from datetime import datetime, timedelta, timezone
16
16
 
17
17
  from fastapi import APIRouter, HTTPException, Request
18
18
  from pydantic import BaseModel
19
19
 
20
20
  router = APIRouter(prefix="/mesh", tags=["mesh"])
21
21
 
22
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
23
+ _STALE_AFTER = timedelta(minutes=5)
24
+ _EXPIRE_AFTER = timedelta(minutes=30)
25
+
22
26
 
23
27
  # -- Request models --
24
28
 
@@ -88,6 +92,7 @@ def _get_broker(request: Request):
88
92
  client_host = request.client.host if request.client else ""
89
93
  if client_host not in ("127.0.0.1", "::1", "localhost"):
90
94
  import hmac
95
+
91
96
  from superlocalmemory.core.security_primitives import verify_install_token
92
97
 
93
98
  # Path 1: install token — dashboard/browser callers hold this and
@@ -120,6 +125,19 @@ def _get_broker(request: Request):
120
125
  return broker
121
126
 
122
127
 
128
+ def _active_profile() -> str:
129
+ """Resolve the tenant (profile) for this mesh request.
130
+
131
+ The mesh is a per-tenant coordination bus: peers, messages, shared state,
132
+ and locks are all scoped to the active profile so one tenant never sees
133
+ another's coordination traffic. Resolved from the request ContextVar set by
134
+ ProfileRuntimeMiddleware (falls back to the configured active profile).
135
+ """
136
+ from superlocalmemory.server.routes.helpers import get_active_profile
137
+
138
+ return get_active_profile()
139
+
140
+
123
141
  _SECRET_KEY = re.compile(
124
142
  r"(?:^|[_\-.])(api[_\-.]?key|secret|token|password|credential)(?:$|[_\-.])",
125
143
  re.IGNORECASE,
@@ -140,56 +158,195 @@ def _reject_secret_state(key: str, value: str) -> None:
140
158
  # -- Routes --
141
159
 
142
160
  @router.post("/register")
143
- async def register(req: RegisterRequest, request: Request):
161
+ def register(req: RegisterRequest, request: Request):
144
162
  broker = _get_broker(request)
145
163
  if not req.session_id:
146
164
  raise HTTPException(400, detail="session_id required")
147
165
  return broker.register_peer(
148
166
  req.session_id, req.summary, req.host, req.port,
149
- req.project_path, req.agent_type,
167
+ req.project_path, req.agent_type, profile_id=_active_profile(),
150
168
  )
151
169
 
152
170
 
153
171
  @router.post("/deregister")
154
- async def deregister(req: DeregisterRequest, request: Request):
172
+ def deregister(req: DeregisterRequest, request: Request):
155
173
  broker = _get_broker(request)
156
- result = broker.deregister_peer(req.peer_id)
174
+ result = broker.deregister_peer(req.peer_id, profile_id=_active_profile())
157
175
  if not result.get("ok"):
158
176
  raise HTTPException(404, detail=result.get("error", "peer not found"))
159
177
  return result
160
178
 
161
179
 
180
+ def _peer_activity_counts(request: Request, session_ids: list[str]) -> dict:
181
+ """Per-session {tool_count, memory_count} for the active profile.
182
+
183
+ Each mesh peer IS an agent session, so its activity is the tool events it
184
+ logged and the memories it contributed under the current profile. Batched
185
+ to a single grouped query per table. Fail-soft: returns {} on any error so
186
+ the peer list still renders.
187
+ """
188
+ if not session_ids:
189
+ return {}
190
+ try:
191
+ from superlocalmemory.server.routes.helpers import (
192
+ get_active_profile,
193
+ get_engine_lazy,
194
+ )
195
+ engine = get_engine_lazy(request.app.state)
196
+ if engine is None:
197
+ return {}
198
+ profile = get_active_profile()
199
+ placeholders = ",".join("?" * len(session_ids))
200
+ params = [profile, *session_ids]
201
+ counts: dict[str, dict] = {sid: {"tool_count": 0, "memory_count": 0}
202
+ for sid in session_ids}
203
+ for table, key in (("tool_events", "tool_count"),
204
+ ("memories", "memory_count")):
205
+ try:
206
+ rows = engine._db.execute(
207
+ f"SELECT session_id, COUNT(*) AS n FROM {table} "
208
+ f"WHERE profile_id = ? AND session_id IN ({placeholders}) "
209
+ "GROUP BY session_id",
210
+ tuple(params),
211
+ )
212
+ for r in rows:
213
+ d = dict(r)
214
+ sid = d.get("session_id")
215
+ if sid in counts:
216
+ counts[sid][key] = d.get("n", 0)
217
+ except Exception:
218
+ continue
219
+ return counts
220
+ except Exception:
221
+ return {}
222
+
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
+
162
285
  @router.get("/peers")
163
- async def peers(request: Request):
286
+ def peers(request: Request, view: str = "all"):
164
287
  broker = _get_broker(request)
165
- return {"peers": broker.list_all_peers()}
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
+ )
298
+ session_ids = [p.get("session_id") for p in peer_list if p.get("session_id")]
299
+ counts = _peer_activity_counts(request, session_ids)
300
+ enriched = []
301
+ for p in peer_list:
302
+ c = counts.get(p.get("session_id"), {})
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
+ }
166
315
 
167
316
 
168
317
  @router.post("/heartbeat")
169
- 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."""
170
320
  broker = _get_broker(request)
171
- result = broker.heartbeat(req.peer_id)
321
+ result = broker.heartbeat(req.peer_id, profile_id=_active_profile())
172
322
  if not result.get("ok"):
173
323
  raise HTTPException(404, detail=result.get("error", "peer not found"))
174
324
  return result
175
325
 
176
326
 
177
327
  @router.post("/summary")
178
- async def summary(req: SummaryRequest, request: Request):
328
+ def summary(req: SummaryRequest, request: Request):
179
329
  broker = _get_broker(request)
180
- result = broker.update_summary(req.peer_id, req.summary)
330
+ result = broker.update_summary(req.peer_id, req.summary,
331
+ profile_id=_active_profile())
181
332
  if not result.get("ok"):
182
333
  raise HTTPException(404, detail=result.get("error", "peer not found"))
183
334
  return result
184
335
 
185
336
 
186
337
  @router.post("/send")
187
- async def send(req: SendRequest, request: Request):
338
+ def send(req: SendRequest, request: Request):
188
339
  broker = _get_broker(request)
189
340
  to_target = req.to_peer or req.to # v3.4.6: accept both field names
190
341
  if not to_target:
191
342
  raise HTTPException(400, detail="'to' or 'to_peer' required")
192
- result = broker.send_message(req.from_peer, to_target, req.content, req.type)
343
+ profile = _active_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,
349
+ )
193
350
  if not result.get("ok"):
194
351
  status = 413 if "too large" in result.get("error", "") else 404
195
352
  raise HTTPException(status, detail=result.get("error", ""))
@@ -197,66 +354,78 @@ async def send(req: SendRequest, request: Request):
197
354
 
198
355
 
199
356
  @router.get("/inbox/{peer_id}")
200
- async def inbox(peer_id: str, request: Request, project_path: str = ""):
357
+ def inbox(peer_id: str, request: Request, project_path: str = ""):
201
358
  broker = _get_broker(request)
202
- return {"messages": broker.get_inbox(peer_id, project_path)}
359
+ return {"messages": broker.get_inbox(peer_id, project_path,
360
+ profile_id=_active_profile())}
203
361
 
204
362
 
205
363
  @router.post("/inbox/{peer_id}/read")
206
- async def mark_read(peer_id: str, req: ReadRequest, request: Request):
364
+ def mark_read(peer_id: str, req: ReadRequest, request: Request):
207
365
  broker = _get_broker(request)
208
- return broker.mark_read(peer_id, req.message_ids)
366
+ return broker.mark_read(peer_id, req.message_ids,
367
+ profile_id=_active_profile())
209
368
 
210
369
 
211
370
  @router.get("/pending/{peer_id}")
212
- async def pending(peer_id: str, request: Request, project_path: str = ""):
371
+ def pending(peer_id: str, request: Request, project_path: str = ""):
213
372
  """Get pending broadcast/project messages for this peer."""
214
373
  broker = _get_broker(request)
215
- messages = broker.get_pending(peer_id, project_path)
374
+ messages = broker.get_pending(peer_id, project_path,
375
+ profile_id=_active_profile())
216
376
  return {"messages": messages, "count": len(messages)}
217
377
 
218
378
 
219
379
  @router.get("/state")
220
- async def state_all(request: Request):
380
+ def state_all(request: Request):
221
381
  broker = _get_broker(request)
222
- return {"state": broker.get_state()}
382
+ return {"state": broker.get_state(profile_id=_active_profile())}
223
383
 
224
384
 
225
385
  @router.post("/state")
226
- async def state_set(req: StateSetRequest, request: Request):
386
+ def state_set(req: StateSetRequest, request: Request):
227
387
  broker = _get_broker(request)
228
388
  if not req.key:
229
389
  raise HTTPException(400, detail="key required")
230
390
  _reject_secret_state(req.key, req.value)
231
- return broker.set_state(req.key, req.value, req.set_by)
391
+ return broker.set_state(req.key, req.value, req.set_by,
392
+ profile_id=_active_profile())
232
393
 
233
394
 
234
395
  @router.get("/state/{key}")
235
- async def state_get(key: str, request: Request):
396
+ def state_get(key: str, request: Request):
236
397
  broker = _get_broker(request)
237
- result = broker.get_state_key(key)
398
+ result = broker.get_state_key(key, profile_id=_active_profile())
238
399
  if result is None:
239
400
  raise HTTPException(404, detail="key not found")
240
401
  return result
241
402
 
242
403
 
243
404
  @router.post("/lock")
244
- async def lock(req: LockRequest, request: Request):
405
+ def lock(req: LockRequest, request: Request):
245
406
  broker = _get_broker(request)
246
407
  if not req.file_path or not req.locked_by:
247
408
  raise HTTPException(400, detail="file_path and locked_by required")
248
409
  if req.action not in ("acquire", "release", "query"):
249
410
  raise HTTPException(400, detail="action must be acquire, release, or query")
250
- return broker.lock_action(req.file_path, req.locked_by, req.action)
411
+ return broker.lock_action(req.file_path, req.locked_by, req.action,
412
+ profile_id=_active_profile())
251
413
 
252
414
 
253
415
  @router.get("/events")
254
- async def events(request: Request):
416
+ def events(request: Request):
255
417
  broker = _get_broker(request)
256
- return {"events": broker.get_events()}
418
+ return {"events": broker.get_events(profile_id=_active_profile())}
257
419
 
258
420
 
259
421
  @router.get("/status")
260
- async def status(request: Request):
422
+ def status(request: Request):
261
423
  broker = _get_broker(request)
262
- return broker.get_status()
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
+ }
@@ -71,6 +71,36 @@ async def put_config(body: ConfigUpdateRequest) -> dict[str, Any]:
71
71
  raise HTTPException(status_code=500, detail=str(exc))
72
72
 
73
73
 
74
+ @router.get("/status")
75
+ async def get_status() -> dict[str, Any]:
76
+ """Concise enabled/disabled summary of each optimize sub-feature (M3).
77
+
78
+ REST-convention companion to `slm optimize status` (which reads config
79
+ locally). Returns a per-feature on/off view, the compression mode, and a
80
+ top-level `healthy` flag so external pollers have a stable endpoint.
81
+ """
82
+ try:
83
+ from superlocalmemory.optimize.config import get_shared_store
84
+ cfg = get_shared_store().get()
85
+ return {
86
+ "healthy": True,
87
+ "enabled": cfg.enabled,
88
+ "features": {
89
+ "proxy": cfg.proxy_enabled,
90
+ "cache": cfg.cache_enabled,
91
+ "compression": cfg.compress_enabled,
92
+ "semantic_cache": cfg.semantic_enabled,
93
+ },
94
+ "compress_mode": cfg.compress_mode,
95
+ "config_version": cfg.config_version,
96
+ }
97
+ except Exception as exc:
98
+ logger.warning("GET /api/optimize/status failed: %s", exc)
99
+ # Fail-open: report unhealthy rather than 500 so a poller can
100
+ # distinguish "daemon up but optimize degraded" from "daemon down".
101
+ return {"healthy": False, "error": "internal error"}
102
+
103
+
74
104
  @router.get("/savings")
75
105
  async def get_savings() -> dict[str, Any]:
76
106
  """Return savings estimates from live metrics + CacheDB.
@@ -91,7 +121,9 @@ async def get_savings() -> dict[str, Any]:
91
121
  )
92
122
 
93
123
  # F-03 fix: use module-level singleton instead of per-request instantiation
94
- active_model = getattr(cfg, "active_model", "anthropic")
124
+ # DASH-V6 fix: attribute may EXIST but be None; getattr default only
125
+ # covers the missing case, so coerce None -> default to avoid .lower() crash.
126
+ active_model = getattr(cfg, "active_model", None) or "anthropic"
95
127
  # Determine provider from model
96
128
  provider = "anthropic"
97
129
  for key in ("anthropic", "openai", "gemini"):
@@ -154,6 +154,7 @@ def _upsert_cache(
154
154
  content: str, fact_ids: list[str],
155
155
  ) -> None:
156
156
  from superlocalmemory.core.context_cache import CacheEntry, ContextCache
157
+ from superlocalmemory.server.routes.helpers import get_active_profile
157
158
  cache = ContextCache()
158
159
  try:
159
160
  cache.upsert(CacheEntry(
@@ -163,6 +164,7 @@ def _upsert_cache(
163
164
  fact_ids=tuple(fact_ids),
164
165
  provenance="prewarm_post_tool",
165
166
  computed_at=int(time.time()),
167
+ profile_id=get_active_profile(), # so non-default profiles hit
166
168
  ))
167
169
  finally:
168
170
  cache.close()
@@ -28,11 +28,18 @@ from .helpers import (
28
28
  from superlocalmemory.server.profile_runtime import (
29
29
  commit_daemon_profile_switch,
30
30
  get_profile_runtime,
31
+ TransitionDrainTimeout,
31
32
  )
32
33
 
33
34
  logger = logging.getLogger("superlocalmemory.routes.profiles")
34
35
  router = APIRouter()
35
36
 
37
+
38
+ def _internal_error(detail: str = "Internal server error") -> HTTPException:
39
+ """SEC-H-02: log full traceback server-side; return a generic message to the client."""
40
+ logger.exception("profiles route error")
41
+ return HTTPException(status_code=500, detail=detail)
42
+
36
43
  # WebSocket manager reference (set by ui_server.py at startup)
37
44
  ws_manager = None
38
45
 
@@ -85,8 +92,8 @@ async def list_profiles(request: Request):
85
92
  "total_profiles": len(profiles),
86
93
  }
87
94
 
88
- except Exception as e:
89
- raise HTTPException(status_code=500, detail=f"Profile list error: {str(e)}")
95
+ except Exception:
96
+ raise _internal_error("Profile list error")
90
97
 
91
98
 
92
99
  @router.post("/api/profiles/{name}/switch")
@@ -112,17 +119,36 @@ async def switch_profile(name: str, request: Request):
112
119
  source_agent_id="http-profile-switch",
113
120
  profile_id=name,
114
121
  )
122
+ # RBAC (C4): a logged-in user may only activate a workspace they belong
123
+ # to — this is the read boundary. Switching sets the active tenant for
124
+ # subsequent reads, so a non-member must not be able to enter it. The
125
+ # machine owner (no user session) switches freely.
126
+ from superlocalmemory.server.rbac_enforce import resolve_principal
127
+ _principal = resolve_principal(request)
128
+ if _principal.get("kind") == "user":
129
+ _rbac = getattr(request.app.state, "rbac", None)
130
+ if _rbac is not None and _rbac.get_role(_principal["user_id"], name) is None:
131
+ raise HTTPException(
132
+ status_code=403,
133
+ detail="You are not a member of this workspace.",
134
+ )
115
135
  runtime = get_profile_runtime(request.app.state)
116
136
  previous = runtime.snapshot.profile_id
117
- snapshot = await asyncio.to_thread(
118
- runtime.transition,
119
- name,
120
- lambda prior, target: commit_daemon_profile_switch(
121
- request.app.state,
122
- prior,
123
- target,
124
- ),
125
- )
137
+ try:
138
+ snapshot = await asyncio.to_thread(
139
+ runtime.transition,
140
+ name,
141
+ lambda prior, target: commit_daemon_profile_switch(
142
+ request.app.state,
143
+ prior,
144
+ target,
145
+ ),
146
+ )
147
+ except TransitionDrainTimeout as exc:
148
+ raise HTTPException(
149
+ status_code=503,
150
+ detail=str(exc),
151
+ )
126
152
 
127
153
  count = _get_memory_count(name)
128
154
 
@@ -143,8 +169,8 @@ async def switch_profile(name: str, request: Request):
143
169
 
144
170
  except HTTPException:
145
171
  raise
146
- except Exception as e:
147
- raise HTTPException(status_code=500, detail=f"Profile switch error: {str(e)}")
172
+ except Exception:
173
+ raise _internal_error("Profile switch error")
148
174
 
149
175
 
150
176
  @router.post("/api/profiles/create")
@@ -166,18 +192,34 @@ async def create_profile(body: ProfileSwitch, request: Request):
166
192
  operation="update",
167
193
  source_agent_id="http-profile-create",
168
194
  )
195
+ # RBAC (C3): creating a tenant is an administrative action.
196
+ from superlocalmemory.access.rbac import Permission as _Perm
197
+ from superlocalmemory.server.rbac_enforce import require_manage as _rbac_manage
198
+ _principal = _rbac_manage(request)
169
199
  # Write to BOTH stores atomically
170
200
  desc = f'Memory profile: {name}'
171
201
  ensure_profile_in_db(name, desc)
172
202
  ensure_profile_in_json(name, desc)
173
203
 
204
+ # A logged-in user who creates a workspace becomes its admin so they
205
+ # can manage it immediately (profile_id == name here). The machine
206
+ # owner needs no membership row (implicit root).
207
+ if _principal.get("kind") == "user":
208
+ rbac = getattr(request.app.state, "rbac", None)
209
+ if rbac is not None:
210
+ try:
211
+ rbac.set_membership(name, _principal["user_id"], "admin",
212
+ added_by=_principal["username"])
213
+ except Exception:
214
+ pass
215
+
174
216
  authorization.complete()
175
217
  return {"success": True, "profile": name, "message": f"Profile '{name}' created"}
176
218
 
177
219
  except HTTPException:
178
220
  raise
179
- except Exception as e:
180
- raise HTTPException(status_code=500, detail=f"Profile create error: {str(e)}")
221
+ except Exception:
222
+ raise _internal_error("Profile create error")
181
223
 
182
224
 
183
225
  @router.delete("/api/profiles/{name}")
@@ -204,6 +246,10 @@ async def delete_profile(name: str, request: Request):
204
246
  source_agent_id="http-profile-delete",
205
247
  profile_id=name,
206
248
  )
249
+ # RBAC (C3): deleting a tenant is administrative. Check MANAGE on the
250
+ # profile being deleted (not the active one).
251
+ from superlocalmemory.server.rbac_enforce import require_manage as _rbac_manage
252
+ _rbac_manage(request, profile=name)
207
253
  # Move data to default before deleting (bypasses CASCADE)
208
254
  conn = get_db_connection()
209
255
  cursor = conn.cursor()
@@ -243,5 +289,5 @@ async def delete_profile(name: str, request: Request):
243
289
 
244
290
  except HTTPException:
245
291
  raise
246
- except Exception as e:
247
- raise HTTPException(status_code=500, detail=f"Profile delete error: {str(e)}")
292
+ except Exception:
293
+ raise _internal_error("Profile delete error")