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
@@ -0,0 +1,181 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Time-window parsing + membership for recall (Phase 4, T-window).
6
+
7
+ Pure, dependency-free helpers so recall() can prune candidates to an
8
+ event-time range. Two window forms are accepted:
9
+
10
+ * relative string — ``"1h" | "24h" | "7d" | "30d" | "90d" | "1y"`` etc.
11
+ (``<int><unit>`` where unit is h/d/w/m/y; m≈30d, y≈365d) → ``[now-Δ, now]``.
12
+ * explicit range — ``(start_iso, end_iso)`` two-tuple of timestamps.
13
+
14
+ Timestamps are parsed tolerantly: SQLite ``datetime('now')`` form
15
+ (``"YYYY-MM-DD HH:MM:SS"``), ISO-8601 with ``T`` and/or trailing ``Z``, and
16
+ date-only ``"YYYY-MM-DD"`` are all accepted. Comparing the *strings* would be
17
+ wrong (a space sorts before ``T``), so everything is parsed to tz-aware UTC
18
+ datetimes before comparison — never lexicographic.
19
+
20
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
21
+ License: AGPL-3.0-or-later
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from datetime import datetime, timedelta, timezone
28
+
29
+ __all__ = [
30
+ "parse_timestamp",
31
+ "parse_window",
32
+ "in_window",
33
+ "infer_window_from_query",
34
+ ]
35
+
36
+ _REL = re.compile(r"^\s*(\d+)\s*([hdwmy])\s*$", re.IGNORECASE)
37
+
38
+ # Hours per unit. Month and year are documented approximations.
39
+ _UNIT_HOURS: dict[str, int] = {
40
+ "h": 1,
41
+ "d": 24,
42
+ "w": 24 * 7,
43
+ "m": 24 * 30,
44
+ "y": 24 * 365,
45
+ }
46
+
47
+
48
+ def parse_timestamp(value: str | None) -> datetime | None:
49
+ """Parse a stored timestamp into a tz-aware UTC datetime, or None.
50
+
51
+ Accepts SQLite ``datetime('now')`` (space-separated), ISO-8601 (``T`` and
52
+ optional ``Z``), and date-only strings. Naive values are assumed UTC.
53
+ """
54
+ if not value or not isinstance(value, str):
55
+ return None
56
+ text = value.strip()
57
+ if not text:
58
+ return None
59
+ if text.endswith(("Z", "z")):
60
+ text = text[:-1] + "+00:00"
61
+ dt: datetime | None = None
62
+ try:
63
+ dt = datetime.fromisoformat(text)
64
+ except ValueError:
65
+ # Fall back to a date-only prefix (e.g. "2026-03-15 ...").
66
+ try:
67
+ dt = datetime.fromisoformat(text[:10])
68
+ except ValueError:
69
+ return None
70
+ if dt.tzinfo is None:
71
+ dt = dt.replace(tzinfo=timezone.utc)
72
+ return dt.astimezone(timezone.utc)
73
+
74
+
75
+ def parse_window(
76
+ window: str | tuple[str, str] | list | None,
77
+ now: datetime | None = None,
78
+ ) -> tuple[datetime, datetime] | None:
79
+ """Resolve a window spec to a ``(start, end)`` UTC datetime pair, or None.
80
+
81
+ Returns None when the spec is None or unparseable (caller then applies no
82
+ time filter — additive, fail-open).
83
+ """
84
+ if window is None:
85
+ return None
86
+ _now = now or datetime.now(timezone.utc)
87
+ if _now.tzinfo is None:
88
+ _now = _now.replace(tzinfo=timezone.utc)
89
+
90
+ # Explicit (start, end) range.
91
+ if isinstance(window, (tuple, list)):
92
+ if len(window) != 2:
93
+ return None
94
+ start = parse_timestamp(window[0])
95
+ end = parse_timestamp(window[1])
96
+ if start is None or end is None:
97
+ return None
98
+ return (start, end) if start <= end else (end, start)
99
+
100
+ # String forms: relative "<int><unit>" or an explicit range written as
101
+ # "start..end" / "start,end" (so it survives URL params, JSON, and CLI args
102
+ # as a single value — no tuple serialization needed on the wire).
103
+ if isinstance(window, str):
104
+ m = _REL.match(window)
105
+ if m:
106
+ n = int(m.group(1))
107
+ unit = m.group(2).lower()
108
+ hours = n * _UNIT_HOURS[unit]
109
+ return (_now - timedelta(hours=hours), _now)
110
+ for sep in ("..", ","):
111
+ if sep in window:
112
+ left, _, right = window.partition(sep)
113
+ start = parse_timestamp(left)
114
+ end = parse_timestamp(right)
115
+ if start is None or end is None:
116
+ return None
117
+ return (start, end) if start <= end else (end, start)
118
+
119
+ return None
120
+
121
+
122
+ # Natural-language temporal-scope patterns → relative window spec. Ordered:
123
+ # more specific ("last 3 weeks") is matched before generic ("last week").
124
+ _UNIT_TO_SPEC = {"day": "d", "week": "w", "month": "m", "year": "y"}
125
+ _QUERY_N_UNIT = re.compile(
126
+ r"\b(?:last|past|previous|prior)\s+(\d{1,3})\s+(day|week|month|year)s?\b",
127
+ re.IGNORECASE,
128
+ )
129
+ _QUERY_PHRASES: tuple[tuple[re.Pattern[str], str], ...] = (
130
+ (re.compile(r"\btoday\b", re.IGNORECASE), "1d"),
131
+ (re.compile(r"\byesterday\b", re.IGNORECASE), "2d"),
132
+ (re.compile(r"\b(?:this|last|past|previous)\s+week\b", re.IGNORECASE), "7d"),
133
+ (re.compile(r"\b(?:this|last|past|previous)\s+month\b", re.IGNORECASE), "30d"),
134
+ (re.compile(r"\b(?:this|last|past|previous)\s+year\b", re.IGNORECASE), "1y"),
135
+ (re.compile(r"\b(?:recent|recently|lately)\b", re.IGNORECASE), "30d"),
136
+ )
137
+
138
+
139
+ def infer_window_from_query(query: str | None) -> str | None:
140
+ """Infer a relative time window from natural-language scope in a query.
141
+
142
+ Recognises a small, unambiguous set of temporal-scope phrases ("yesterday",
143
+ "last week", "past 3 months", "recently") and maps them to a relative
144
+ window spec ("2d", "7d", "3m", …) that ``parse_window`` understands. Returns
145
+ None when no clear temporal scope is present, so recall applies no window.
146
+
147
+ Deliberately conservative — only fires on explicit scope words, never on
148
+ bare content — so it augments, never surprises. Callers use it only when the
149
+ user did not pass an explicit window.
150
+ """
151
+ if not query or not isinstance(query, str):
152
+ return None
153
+ m = _QUERY_N_UNIT.search(query)
154
+ if m:
155
+ n = int(m.group(1))
156
+ unit = _UNIT_TO_SPEC.get(m.group(2).lower())
157
+ if unit and n > 0:
158
+ return f"{n}{unit}"
159
+ for pattern, spec in _QUERY_PHRASES:
160
+ if pattern.search(query):
161
+ return spec
162
+ return None
163
+
164
+
165
+ def in_window(
166
+ event_time: str | None,
167
+ bounds: tuple[datetime, datetime] | None,
168
+ ) -> bool:
169
+ """True if ``event_time`` falls within ``bounds`` (inclusive).
170
+
171
+ No bounds → always True (no filtering). An unparseable/missing event time
172
+ is excluded (conservative: a windowed query returns only datable facts in
173
+ range).
174
+ """
175
+ if bounds is None:
176
+ return True
177
+ dt = parse_timestamp(event_time)
178
+ if dt is None:
179
+ return False
180
+ start, end = bounds
181
+ return start <= dt <= end
@@ -48,10 +48,10 @@ logger = logging.getLogger("superlocalmemory.api_server")
48
48
  # V3 paths
49
49
  MEMORY_DIR = DynamicStatePath()
50
50
  DB_PATH = DynamicStatePath("memory.db")
51
- # V3.3.21: UI shipped inside the package for pip/npm installs.
52
- _PKG_UI = Path(__file__).resolve().parent.parent / "ui"
53
- _REPO_UI = Path(__file__).resolve().parent.parent.parent.parent / "ui"
54
- UI_DIR = _PKG_UI if (_PKG_UI / "index.html").exists() else _REPO_UI
51
+ # V3.3.21+: the dashboard ships inside the package (superlocalmemory/ui) for
52
+ # every install path. The legacy repo-root ui/ dev fallback was retired in
53
+ # v3.8.0 when that copy was deleted; the packaged directory is authoritative.
54
+ UI_DIR = Path(__file__).resolve().parent.parent / "ui"
55
55
 
56
56
 
57
57
  # ============================================================================
@@ -0,0 +1,90 @@
1
+ """Process-safe, durable read/modify/write access to ``config.json``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ import threading
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from typing import Callable, Iterator
12
+
13
+ _PROCESS_LOCK = threading.RLock()
14
+
15
+
16
+ @contextmanager
17
+ def _file_lock(path: Path) -> Iterator[None]:
18
+ """Serialize config access across daemon, CLI, and worker processes."""
19
+ lock_path = path.with_name(f".{path.name}.lock")
20
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
21
+ with _PROCESS_LOCK, lock_path.open("a+b") as handle:
22
+ if os.name == "nt":
23
+ import msvcrt
24
+
25
+ handle.seek(0)
26
+ handle.write(b"\0")
27
+ handle.flush()
28
+ handle.seek(0)
29
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
30
+ else:
31
+ import fcntl
32
+
33
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
34
+ try:
35
+ yield
36
+ finally:
37
+ if os.name == "nt":
38
+ handle.seek(0)
39
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
40
+ else:
41
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
42
+
43
+
44
+ def _read_unlocked(path: Path) -> dict:
45
+ if not path.exists():
46
+ return {}
47
+ value = json.loads(path.read_text(encoding="utf-8"))
48
+ if not isinstance(value, dict):
49
+ raise ValueError("config root must be a JSON object")
50
+ return value
51
+
52
+
53
+ def read_config(path: Path) -> dict:
54
+ """Return one coherent config snapshot under the interprocess lock."""
55
+ with _file_lock(path):
56
+ return _read_unlocked(path)
57
+
58
+
59
+ def update_config(path: Path, update: Callable[[dict], None]) -> dict:
60
+ """Atomically update and durably replace a config JSON object."""
61
+ with _file_lock(path):
62
+ data = _read_unlocked(path)
63
+ update(data)
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ descriptor, temp_name = tempfile.mkstemp(
66
+ prefix=f".{path.name}.",
67
+ suffix=".tmp",
68
+ dir=path.parent,
69
+ )
70
+ temp_path = Path(temp_name)
71
+ try:
72
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
73
+ json.dump(data, stream, indent=2)
74
+ stream.write("\n")
75
+ stream.flush()
76
+ os.fsync(stream.fileno())
77
+ os.chmod(temp_path, 0o600)
78
+ os.replace(temp_path, path)
79
+ if hasattr(os, "O_DIRECTORY"):
80
+ directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
81
+ try:
82
+ os.fsync(directory_fd)
83
+ finally:
84
+ os.close(directory_fd)
85
+ finally:
86
+ temp_path.unlink(missing_ok=True)
87
+ return data
88
+
89
+
90
+ __all__ = ("read_config", "update_config")
@@ -0,0 +1,50 @@
1
+ """Exact browser-origin validation for the local dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlsplit
6
+
7
+
8
+ _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
9
+
10
+
11
+ def origin_is_loopback(origin: str) -> bool:
12
+ """Return whether an Origin is absent or an exact HTTP(S) loopback URL."""
13
+ if not origin:
14
+ return True
15
+ try:
16
+ parsed = urlsplit(origin)
17
+ # Accessing port validates malformed/non-numeric port values.
18
+ _ = parsed.port
19
+ except (TypeError, ValueError):
20
+ return False
21
+ return (
22
+ parsed.scheme in {"http", "https"}
23
+ and parsed.hostname is not None
24
+ and parsed.hostname.lower() in _LOOPBACK_HOSTS
25
+ and parsed.username is None
26
+ and parsed.password is None
27
+ and parsed.path in {"", "/"}
28
+ and not parsed.query
29
+ and not parsed.fragment
30
+ )
31
+
32
+
33
+ def origin_is_daemon(origin: str, *, port: int) -> bool:
34
+ """Return whether ``origin`` is one of this daemon's loopback aliases.
35
+
36
+ A loopback host alone is not a browser trust boundary: another local web
37
+ server can run on a different port. Credentialless dashboard writes are
38
+ therefore limited to the port owned by this daemon. Authenticated local
39
+ integrations are handled separately by the write-identity boundary.
40
+ """
41
+ if not origin_is_loopback(origin):
42
+ return False
43
+ try:
44
+ parsed = urlsplit(origin)
45
+ return parsed.port == port
46
+ except (TypeError, ValueError):
47
+ return False
48
+
49
+
50
+ __all__ = ("origin_is_daemon", "origin_is_loopback")
@@ -6,6 +6,7 @@ import json
6
6
  import os
7
7
  import tempfile
8
8
  import threading
9
+ import time as _time
9
10
  from contextlib import contextmanager
10
11
  from contextvars import ContextVar
11
12
  from dataclasses import dataclass
@@ -20,6 +21,20 @@ _REQUEST_PROFILE: ContextVar[str | None] = ContextVar(
20
21
  "slm_request_profile", default=None,
21
22
  )
22
23
 
24
+ # Maximum seconds to wait for in-flight operations to drain before a
25
+ # profile switch or engine reconfigure is aborted with HTTP 503.
26
+ # Tests can monkeypatch this module-level value to keep runs fast.
27
+ _DRAIN_TIMEOUT_SECS: float = 5.0
28
+
29
+
30
+ class TransitionDrainTimeout(Exception):
31
+ """Raised when in-flight operations do not drain within _DRAIN_TIMEOUT_SECS.
32
+
33
+ The caller (HTTP switch route) maps this to HTTP 503 so the client
34
+ receives a clear error instead of a silent hang. After the timeout,
35
+ `_transitioning` is always reset to False so the daemon stays responsive.
36
+ """
37
+
23
38
 
24
39
  def current_request_profile() -> str | None:
25
40
  """Return the immutable profile snapshot admitted for this request."""
@@ -61,6 +76,18 @@ class ProfileRuntime:
61
76
  self._active_operations += 1
62
77
  return self._snapshot
63
78
 
79
+ def _try_acquire_operation_nowait(self) -> ProfileSnapshot | None:
80
+ """Non-blocking acquire: returns None immediately when a transition is pending.
81
+
82
+ Used by cooperative background maintenance tasks so they never block the
83
+ drain window of a pending profile switch.
84
+ """
85
+ with self._condition:
86
+ if self._transitioning:
87
+ return None
88
+ self._active_operations += 1
89
+ return self._snapshot
90
+
64
91
  def release_operation(self) -> None:
65
92
  with self._condition:
66
93
  if self._active_operations <= 0:
@@ -77,20 +104,64 @@ class ProfileRuntime:
77
104
  finally:
78
105
  self.release_operation()
79
106
 
107
+ @contextmanager
108
+ def operation_nowait(self) -> Iterator[ProfileSnapshot | None]:
109
+ """Cooperatively skip when a profile transition is pending.
110
+
111
+ Background maintenance tasks that do NOT mutate profile-scoped engine
112
+ state (cache warmup, health probes) MUST use this instead of
113
+ ``operation()`` so they never hold the drain window hostage.
114
+
115
+ Yields ``None`` (preempted — skip this work cycle) when a switch is
116
+ already in progress. Yields a :class:`ProfileSnapshot` and holds the
117
+ lease normally when no transition is pending.
118
+
119
+ The caller is responsible for checking ``if snap is None: return``.
120
+
121
+ Unlike ``operation()``, this context manager never blocks — it either
122
+ admits immediately or preempts immediately.
123
+ """
124
+ snapshot = self._try_acquire_operation_nowait()
125
+ acquired = snapshot is not None
126
+ try:
127
+ yield snapshot
128
+ finally:
129
+ if acquired:
130
+ self.release_operation()
131
+
80
132
  def transition(
81
133
  self,
82
134
  target_profile: str,
83
135
  commit: Callable[[ProfileSnapshot, str], None],
84
136
  ) -> ProfileSnapshot:
85
- """Drain admitted operations, commit, then publish a new generation."""
137
+ """Drain admitted operations, commit, then publish a new generation.
138
+
139
+ Raises TransitionDrainTimeout if in-flight operations do not drain
140
+ within _DRAIN_TIMEOUT_SECS. The flag is always reset on timeout so
141
+ the daemon remains responsive (no permanent _transitioning=True wedge).
142
+ """
143
+ deadline = _time.monotonic() + _DRAIN_TIMEOUT_SECS
86
144
  with self._condition:
87
145
  while self._transitioning:
88
146
  self._condition.wait()
89
147
  if target_profile == self._snapshot.profile_id:
90
148
  return self._snapshot
91
149
  self._transitioning = True
92
- while self._active_operations:
93
- self._condition.wait()
150
+ while self._active_operations > 0:
151
+ remaining = deadline - _time.monotonic()
152
+ if remaining <= 0:
153
+ # Reset before raising — never leave the daemon wedged.
154
+ self._transitioning = False
155
+ self._condition.notify_all()
156
+ raise TransitionDrainTimeout(
157
+ f"Profile switch to '{target_profile}' timed out after "
158
+ f"{_DRAIN_TIMEOUT_SECS:.0f}s: {self._active_operations} "
159
+ "in-flight operation(s) did not drain. "
160
+ "Try again when no active requests are in progress."
161
+ )
162
+ # Use short sleep slices so we react quickly to notifications
163
+ # and to timeout expiry without busy-spinning.
164
+ self._condition.wait(timeout=min(remaining, 0.25))
94
165
  previous = self._snapshot
95
166
 
96
167
  try:
@@ -111,13 +182,26 @@ class ProfileRuntime:
111
182
  return self._snapshot
112
183
 
113
184
  def reconfigure(self, commit: Callable[[ProfileSnapshot], None]) -> ProfileSnapshot:
114
- """Run a same-profile engine transition behind the operation barrier."""
185
+ """Run a same-profile engine transition behind the operation barrier.
186
+
187
+ Raises TransitionDrainTimeout if in-flight operations do not drain
188
+ within _DRAIN_TIMEOUT_SECS (same semantics as transition()).
189
+ """
190
+ deadline = _time.monotonic() + _DRAIN_TIMEOUT_SECS
115
191
  with self._condition:
116
192
  while self._transitioning:
117
193
  self._condition.wait()
118
194
  self._transitioning = True
119
- while self._active_operations:
120
- self._condition.wait()
195
+ while self._active_operations > 0:
196
+ remaining = deadline - _time.monotonic()
197
+ if remaining <= 0:
198
+ self._transitioning = False
199
+ self._condition.notify_all()
200
+ raise TransitionDrainTimeout(
201
+ f"Engine reconfigure timed out after {_DRAIN_TIMEOUT_SECS:.0f}s: "
202
+ f"{self._active_operations} in-flight operation(s) did not drain."
203
+ )
204
+ self._condition.wait(timeout=min(remaining, 0.25))
121
205
  snapshot = self._snapshot
122
206
 
123
207
  try:
@@ -362,12 +446,44 @@ class ProfileRuntimeMiddleware:
362
446
  raise
363
447
  scope.setdefault("state", {})["profile_snapshot"] = snapshot
364
448
  token = _REQUEST_PROFILE.set(snapshot.profile_id)
449
+
450
+ # The operation lease guards the SYNCHRONOUS route work that produces
451
+ # the response — not the streaming of its body. A long-lived SSE body
452
+ # would otherwise hold the lease for its whole lifetime and make every
453
+ # profile switch time out during drain (HTTP 503):
454
+ # * /events/stream runs `while True: await sleep(1)` FOREVER while a
455
+ # dashboard tab is open;
456
+ # * /api/v3/chat/stream streams LLM tokens for up to 120s.
457
+ # Release the lease the instant the response starts. By then the route
458
+ # handler has finished its engine work and returned its Response
459
+ # (FastAPI buffers normal responses before sending response.start).
460
+ # Streaming generators that still need the engine (chat recall) already
461
+ # re-acquire their own short-lived lease internally, and /events/stream
462
+ # only reads the EventBus DB — neither depends on this outer lease.
463
+ released = False
464
+
465
+ def _release_lease_once() -> None:
466
+ # Single-task coroutine chain — a plain flag is sufficient; the
467
+ # underlying release_operation() is lock-guarded and idempotent-safe
468
+ # only via this guard, so never call it twice.
469
+ nonlocal released
470
+ if not released:
471
+ released = True
472
+ runtime.release_operation()
473
+
474
+ async def _send(message) -> None:
475
+ if message.get("type") == "http.response.start":
476
+ _release_lease_once()
477
+ await send(message)
478
+
365
479
  try:
366
- await self._app(scope, receive, send)
480
+ await self._app(scope, receive, _send)
367
481
  finally:
368
482
  _REQUEST_PROFILE.reset(token)
483
+ # Safety net: a request that errors before emitting response.start
484
+ # (or an ASGI app that never sends one) must still release its lease.
369
485
  # Release is lock-only and must not itself be cancellation-prone.
370
- runtime.release_operation()
486
+ _release_lease_once()
371
487
 
372
488
 
373
489
  __all__ = [
@@ -375,6 +491,7 @@ __all__ = [
375
491
  "ProfileRuntime",
376
492
  "ProfileRuntimeMiddleware",
377
493
  "ProfileSnapshot",
494
+ "TransitionDrainTimeout",
378
495
  "bind_profile_runtime",
379
496
  "commit_daemon_profile_switch",
380
497
  "current_request_profile",
@@ -0,0 +1,142 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 — RBAC / teams (C3)
4
+
5
+ """RBAC enforcement boundary for HTTP routes.
6
+
7
+ This is the single place that turns "who is calling" + "what are they trying to
8
+ do" into an allow/deny decision, on top of the existing machine-auth layer
9
+ (write_identity). It is deliberately small so every mutation route calls the
10
+ same code path — the research warning was explicit: an RBAC layer that is
11
+ defined but not consistently called is worse than none.
12
+
13
+ Principal model
14
+ ---------------
15
+ * **user** — a logged-in dashboard user (valid session token). Always enforced
16
+ against their role on the active profile.
17
+ * **owner** — the machine operator (already proved machine auth via
18
+ write_identity; no user session). In personal mode the owner bypasses RBAC
19
+ (all permissions). When the org turns on *require_login* (company mode) the
20
+ owner bypass is disabled and a user session is mandatory.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from fastapi import HTTPException, Request
28
+
29
+ from superlocalmemory.access.rbac import Permission, Role, permissions_for_role
30
+
31
+ _SESSION_HEADER = "X-SLM-User-Session"
32
+ _SESSION_COOKIE = "slm_session"
33
+
34
+ OWNER_PRINCIPAL = {
35
+ "kind": "owner",
36
+ "user_id": "owner",
37
+ "username": "owner",
38
+ "display_name": "Machine Owner",
39
+ }
40
+
41
+
42
+ def get_rbac_engine(app_state: Any) -> Any | None:
43
+ return getattr(app_state, "rbac", None)
44
+
45
+
46
+ def _session_token(request: Request) -> str:
47
+ tok = request.headers.get(_SESSION_HEADER, "")
48
+ if tok:
49
+ return tok
50
+ try:
51
+ return request.cookies.get(_SESSION_COOKIE, "") or ""
52
+ except Exception:
53
+ return ""
54
+
55
+
56
+ def resolve_principal(request: Request) -> dict:
57
+ """Resolve the caller to a user (valid session) or the machine owner."""
58
+ rbac = get_rbac_engine(request.app.state)
59
+ token = _session_token(request)
60
+ if rbac is not None and token:
61
+ user = rbac.resolve_session(token)
62
+ if user:
63
+ return {"kind": "user", **user}
64
+ return dict(OWNER_PRINCIPAL)
65
+
66
+
67
+ def _active_profile() -> str:
68
+ from superlocalmemory.server.routes.helpers import get_active_profile
69
+
70
+ return get_active_profile()
71
+
72
+
73
+ def require_permission(
74
+ request: Request,
75
+ permission: Permission,
76
+ *,
77
+ profile: str | None = None,
78
+ ) -> dict:
79
+ """Authorize ``permission`` on ``profile`` (default: active profile).
80
+
81
+ Returns the principal on success. Raises 401 when a login is required but
82
+ absent, or 403 when the user's role does not grant the permission.
83
+ """
84
+ rbac = get_rbac_engine(request.app.state)
85
+ principal = resolve_principal(request)
86
+ require_login = bool(rbac is not None and rbac.require_login())
87
+ prof = profile or _active_profile()
88
+
89
+ if principal["kind"] == "owner":
90
+ # The machine operator is root — they always retain MANAGE (they have
91
+ # shell access to the box regardless), so company mode can never lock
92
+ # administration out of the dashboard. require_login only gates the
93
+ # owner's DATA operations, forcing per-user login for read/write/etc.
94
+ if require_login and permission != Permission.MANAGE:
95
+ raise HTTPException(
96
+ 401,
97
+ detail="Login required: this workspace enforces per-user access.",
98
+ )
99
+ return principal # personal mode — operator is owner
100
+
101
+ # Logged-in user: always enforced against their role.
102
+ if rbac is not None and rbac.has_permission(principal["user_id"], prof, permission):
103
+ return principal
104
+ raise HTTPException(
105
+ 403,
106
+ detail=(
107
+ f"Your role does not allow '{permission.value}' on this workspace."
108
+ ),
109
+ )
110
+
111
+
112
+ def require_manage(request: Request, *, profile: str | None = None) -> dict:
113
+ """Guard for user/role administration (MANAGE permission)."""
114
+ return require_permission(request, Permission.MANAGE, profile=profile)
115
+
116
+
117
+ def principal_info(request: Request) -> dict:
118
+ """Rich identity for /whoami: principal + role + effective permissions on
119
+ the active profile. Never raises — used by the dashboard to render UI."""
120
+ rbac = get_rbac_engine(request.app.state)
121
+ principal = resolve_principal(request)
122
+ prof = _active_profile()
123
+ info = {
124
+ "kind": principal["kind"],
125
+ "user_id": principal["user_id"],
126
+ "username": principal["username"],
127
+ "display_name": principal.get("display_name", principal["username"]),
128
+ "profile": prof,
129
+ "rbac_active": bool(rbac is not None and rbac.user_count() > 0),
130
+ "require_login": bool(rbac is not None and rbac.require_login()),
131
+ }
132
+ if principal["kind"] == "owner":
133
+ # Owner has every permission (personal mode) unless login is required.
134
+ info["role"] = "owner"
135
+ info["permissions"] = [p.value for p in Permission]
136
+ return info
137
+ role = rbac.get_role(principal["user_id"], prof) if rbac is not None else None
138
+ info["role"] = role.value if role else None
139
+ info["permissions"] = (
140
+ [p.value for p in permissions_for_role(role)] if role else []
141
+ )
142
+ return info