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
@@ -6,45 +6,117 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from fastapi import APIRouter, HTTPException, Request, Query
9
+ from fastapi import APIRouter, HTTPException, Query, Request
10
10
 
11
- from .helpers import require_engine
11
+ from .helpers import get_active_profile, require_engine
12
12
 
13
13
  router = APIRouter(prefix="/api/entity", tags=["entity"])
14
14
 
15
15
 
16
+ def _list_entities_sql(where_sql: str) -> str:
17
+ """Build the page-first entity query after ``where_sql`` is parameterized."""
18
+ return f"""
19
+ WITH page_entities AS MATERIALIZED (
20
+ SELECT ce.entity_id, ce.profile_id, ce.canonical_name,
21
+ ce.entity_type, ce.fact_count, ce.first_seen, ce.last_seen
22
+ FROM canonical_entities ce
23
+ WHERE {where_sql}
24
+ ORDER BY ce.fact_count DESC, ce.entity_id ASC
25
+ LIMIT ? OFFSET ?
26
+ ),
27
+ ranked_profiles AS MATERIALIZED (
28
+ SELECT ep.*,
29
+ ROW_NUMBER() OVER (
30
+ PARTITION BY ep.entity_id, ep.profile_id
31
+ ORDER BY COALESCE(ep.last_compiled_at, '') DESC,
32
+ ep.project_name COLLATE NOCASE ASC,
33
+ ep.rowid ASC
34
+ ) AS summary_rank
35
+ FROM entity_profiles ep
36
+ JOIN page_entities page
37
+ ON page.entity_id = ep.entity_id
38
+ AND page.profile_id = ep.profile_id
39
+ WHERE ep.profile_id = ?
40
+ )
41
+ SELECT ce.entity_id, ce.canonical_name, ce.entity_type,
42
+ ce.fact_count, ce.first_seen, ce.last_seen,
43
+ ep.knowledge_summary, ep.compiled_truth,
44
+ ep.compilation_confidence, ep.last_compiled_at
45
+ FROM page_entities ce
46
+ LEFT JOIN ranked_profiles ep
47
+ ON ce.entity_id = ep.entity_id
48
+ AND ep.profile_id = ce.profile_id
49
+ AND ep.summary_rank = 1
50
+ ORDER BY ce.fact_count DESC, ce.entity_id ASC
51
+ """
52
+
53
+
54
+ def _require_read(request: Request, profile: str) -> None:
55
+ """Authorize entity metadata access for the explicitly requested profile."""
56
+ from superlocalmemory.access.rbac import Permission
57
+ from superlocalmemory.server.rbac_enforce import require_permission
58
+
59
+ require_permission(request, Permission.READ, profile=profile)
60
+
61
+
62
+ def _require_manage(request: Request, profile: str) -> None:
63
+ """Authorize entity recompilation for the explicitly requested profile."""
64
+ from superlocalmemory.server.rbac_enforce import require_manage
65
+
66
+ require_manage(request, profile=profile)
67
+
68
+
16
69
  @router.get("/list")
17
- async def list_entities(
70
+ def list_entities(
18
71
  request: Request,
19
- profile: str = Query(default="default"),
72
+ profile: str | None = Query(default=None),
73
+ entity_type: str | None = Query(default=None, alias="type", max_length=80),
74
+ search: str | None = Query(default=None, max_length=200),
20
75
  limit: int = Query(default=100, ge=1, le=1000),
21
76
  offset: int = Query(default=0, ge=0),
22
77
  ):
23
- """List all entities with basic info (canonical name, type, fact count)."""
78
+ """List a profile's entities, filtering before count and pagination."""
24
79
  engine = require_engine(request)
80
+ # Default to the ACTIVE profile (request runtime truth), never the literal
81
+ # "default" — otherwise every profile sees the default profile's entities.
82
+ profile = profile or get_active_profile()
83
+ _require_read(request, profile)
25
84
 
26
85
  import sqlite3
27
- import json
28
86
  conn = sqlite3.connect(str(engine._config.db_path))
29
87
  conn.row_factory = sqlite3.Row
30
88
  try:
89
+ where = ["ce.profile_id = ?"]
90
+ params: list[object] = [profile]
91
+ if entity_type and entity_type.lower() != "all":
92
+ where.append("ce.entity_type = ? COLLATE NOCASE")
93
+ params.append(entity_type.strip().lower())
94
+ if search and search.strip():
95
+ escaped = (search.strip().lower().replace("\\", "\\\\")
96
+ .replace("%", "\\%").replace("_", "\\_"))
97
+ where.append(
98
+ "(LOWER(ce.canonical_name) LIKE ? ESCAPE '\\' "
99
+ "OR LOWER(COALESCE(ce.entity_type, 'unknown')) LIKE ? ESCAPE '\\' "
100
+ "OR EXISTS ("
101
+ "SELECT 1 FROM entity_profiles eps "
102
+ "WHERE eps.entity_id = ce.entity_id "
103
+ "AND eps.profile_id = ce.profile_id "
104
+ "AND LOWER(COALESCE(eps.knowledge_summary, '')) "
105
+ "LIKE ? ESCAPE '\\'))"
106
+ )
107
+ params.extend([f"%{escaped}%"] * 3)
108
+ where_sql = " AND ".join(where)
109
+
31
110
  total = conn.execute(
32
- "SELECT COUNT(*) FROM canonical_entities WHERE profile_id = ?",
33
- (profile,),
111
+ "SELECT COUNT(*) FROM canonical_entities ce "
112
+ f"WHERE {where_sql}",
113
+ params,
34
114
  ).fetchone()[0]
35
115
 
36
- rows = conn.execute("""
37
- SELECT ce.entity_id, ce.canonical_name, ce.entity_type,
38
- ce.fact_count, ce.first_seen, ce.last_seen,
39
- ep.knowledge_summary, ep.compiled_truth,
40
- ep.compilation_confidence, ep.last_compiled_at
41
- FROM canonical_entities ce
42
- LEFT JOIN entity_profiles ep
43
- ON ce.entity_id = ep.entity_id AND ep.profile_id = ce.profile_id
44
- WHERE ce.profile_id = ?
45
- ORDER BY ce.fact_count DESC
46
- LIMIT ? OFFSET ?
47
- """, (profile, limit, offset)).fetchall()
116
+ rows = conn.execute(
117
+ _list_entities_sql(where_sql),
118
+ [*params, limit, offset, profile],
119
+ ).fetchall()
48
120
 
49
121
  entities = []
50
122
  for r in rows:
@@ -62,23 +134,31 @@ async def list_entities(
62
134
  "last_compiled_at": r["last_compiled_at"],
63
135
  })
64
136
 
65
- return {"entities": entities, "total": total, "limit": limit, "offset": offset}
137
+ return {
138
+ "entities": entities,
139
+ "total": total,
140
+ "limit": limit,
141
+ "offset": offset,
142
+ "has_more": offset + limit < total,
143
+ }
66
144
  finally:
67
145
  conn.close()
68
146
 
69
147
 
70
148
  @router.get("/{entity_name}")
71
- async def get_entity(
149
+ def get_entity(
72
150
  entity_name: str,
73
151
  request: Request,
74
- profile: str = Query(default="default"),
152
+ profile: str | None = Query(default=None),
75
153
  project: str = Query(default=""),
76
154
  ):
77
155
  """Get compiled truth + timeline for an entity."""
78
156
  engine = require_engine(request)
157
+ profile = profile or get_active_profile()
158
+ _require_read(request, profile)
79
159
 
80
- import sqlite3
81
160
  import json
161
+ import sqlite3
82
162
  conn = sqlite3.connect(str(engine._config.db_path))
83
163
  conn.row_factory = sqlite3.Row
84
164
  try:
@@ -112,14 +192,16 @@ async def get_entity(
112
192
 
113
193
 
114
194
  @router.post("/{entity_name}/recompile")
115
- async def recompile_entity(
195
+ def recompile_entity(
116
196
  entity_name: str,
117
197
  request: Request,
118
- profile: str = Query(default="default"),
198
+ profile: str | None = Query(default=None),
119
199
  project: str = Query(default=""),
120
200
  ):
121
201
  """Force immediate recompilation of an entity."""
122
202
  engine = require_engine(request)
203
+ profile = profile or get_active_profile()
204
+ _require_manage(request, profile)
123
205
 
124
206
  import sqlite3
125
207
  conn = sqlite3.connect(str(engine._config.db_path))
@@ -17,7 +17,7 @@ from datetime import datetime, timezone
17
17
  from fastapi import APIRouter, HTTPException, Query
18
18
  from fastapi.responses import StreamingResponse
19
19
 
20
- from .helpers import DB_PATH
20
+ from .helpers import DB_PATH, get_active_profile
21
21
 
22
22
  logger = logging.getLogger("superlocalmemory.routes.events")
23
23
  router = APIRouter()
@@ -71,6 +71,11 @@ async def event_stream(
71
71
 
72
72
  import asyncio
73
73
 
74
+ # Scope this stream to the profile active at connect time. The dashboard
75
+ # opens a fresh stream after a profile switch, so a connection only ever
76
+ # surfaces ONE profile's events — never another profile's (GDPR).
77
+ active_profile = get_active_profile()
78
+
74
79
  client_queue = _queue.Queue(maxsize=100)
75
80
  with _sse_queues_lock:
76
81
  _sse_queues.add(client_queue)
@@ -86,6 +91,7 @@ async def event_stream(
86
91
  bus = EventBus.get_instance(DB_PATH)
87
92
  missed = bus.get_recent_events(
88
93
  since_id=last_event_id, limit=50, event_type=event_type,
94
+ profile_id=active_profile,
89
95
  )
90
96
  for evt in missed:
91
97
  data = json.dumps(evt)
@@ -96,7 +102,9 @@ async def event_stream(
96
102
  else:
97
103
  try:
98
104
  bus = EventBus.get_instance(DB_PATH)
99
- recent = bus.get_recent_events(limit=1)
105
+ recent = bus.get_recent_events(
106
+ limit=1, profile_id=active_profile,
107
+ )
100
108
  if recent:
101
109
  last_db_id = recent[-1].get('id', 0)
102
110
  except Exception:
@@ -110,6 +118,9 @@ async def event_stream(
110
118
  event = client_queue.get_nowait()
111
119
  if event_type and event.get("event_type") != event_type:
112
120
  continue
121
+ # Profile isolation: never forward another profile's event.
122
+ if event.get("profile_id", "default") != active_profile:
123
+ continue
113
124
  data = json.dumps(event)
114
125
  event_id = event.get("id", event.get("seq", ""))
115
126
  yield f"id: {event_id}\nevent: {event['event_type']}\ndata: {data}\n\n"
@@ -126,6 +137,7 @@ async def event_stream(
126
137
  bus = EventBus.get_instance(DB_PATH)
127
138
  new_events = bus.get_recent_events(
128
139
  since_id=last_db_id, limit=10, event_type=event_type,
140
+ profile_id=active_profile,
129
141
  )
130
142
  for evt in new_events:
131
143
  data = json.dumps(evt)
@@ -162,13 +174,16 @@ async def get_events(
162
174
  return {"events": [], "count": 0, "message": "Event Bus not available"}
163
175
  try:
164
176
  bus = EventBus.get_instance(DB_PATH)
177
+ active_profile = get_active_profile()
165
178
  events = bus.get_recent_events(
166
179
  since_id=since_id, limit=limit, event_type=event_type,
180
+ profile_id=active_profile,
167
181
  )
168
- stats = bus.get_event_stats()
182
+ stats = bus.get_event_stats(profile_id=active_profile)
169
183
  return {"events": events, "count": len(events), "stats": stats}
170
- except Exception as e:
171
- raise HTTPException(status_code=500, detail=f"Event retrieval error: {str(e)}")
184
+ except Exception:
185
+ logger.exception("events route error")
186
+ raise HTTPException(status_code=500, detail="Event retrieval error")
172
187
 
173
188
 
174
189
  @router.get("/api/events/stats")
@@ -178,6 +193,7 @@ async def get_event_stats():
178
193
  return {"total_events": 0, "message": "Event Bus not available"}
179
194
  try:
180
195
  bus = EventBus.get_instance(DB_PATH)
181
- return bus.get_event_stats()
182
- except Exception as e:
183
- raise HTTPException(status_code=500, detail=f"Event stats error: {str(e)}")
196
+ return bus.get_event_stats(profile_id=get_active_profile())
197
+ except Exception:
198
+ logger.exception("events route error")
199
+ raise HTTPException(status_code=500, detail="Event stats error")
@@ -8,46 +8,89 @@ Routes: /api/evolution/status, /api/evolution/enable, /api/evolution/run
8
8
  """
9
9
 
10
10
  import logging
11
- from pathlib import Path
11
+ from types import SimpleNamespace
12
+ from typing import Optional
12
13
 
13
- from fastapi import APIRouter
14
+ from fastapi import APIRouter, Request
15
+ from pydantic import BaseModel
14
16
 
15
- from .helpers import get_active_profile, MEMORY_DIR
17
+ from superlocalmemory.server.config_file import read_config, update_config
18
+
19
+ from .helpers import MEMORY_DIR, get_active_profile
16
20
 
17
21
  logger = logging.getLogger("superlocalmemory.routes.evolution")
18
22
  router = APIRouter()
19
23
 
20
24
 
25
+ def _require_read(request: Request) -> None:
26
+ """Guard evolution telemetry with READ on the active profile."""
27
+ from superlocalmemory.access.rbac import Permission
28
+ from superlocalmemory.server.rbac_enforce import require_permission
29
+
30
+ require_permission(request, Permission.READ, profile=get_active_profile())
31
+
32
+
33
+ def _require_manage(request: Request) -> None:
34
+ """Guard evolution mutations with the same RBAC boundary as v3 settings."""
35
+ from superlocalmemory.server.rbac_enforce import require_manage
36
+
37
+ require_manage(request)
38
+
39
+
40
+ def _read_evolution_config() -> dict:
41
+ """Read one process-safe evolution config snapshot."""
42
+ return dict(read_config(MEMORY_DIR / "config.json").get("evolution", {}))
43
+
44
+
45
+ def _update_evolution_config(update) -> dict:
46
+ """Atomically update evolution without losing other config sections."""
47
+ config_path = MEMORY_DIR / "config.json"
48
+
49
+ def mutate(cfg: dict) -> None:
50
+ evolution = cfg.setdefault("evolution", {})
51
+ update(evolution)
52
+
53
+ cfg = update_config(config_path, mutate)
54
+ return dict(cfg.get("evolution", {}))
55
+
56
+
57
+ def _enable_evolution(config: dict) -> None:
58
+ config["enabled"] = True
59
+ config.setdefault("backend", "auto")
60
+
61
+
21
62
  @router.get("/api/evolution/status")
22
- async def evolution_status():
63
+ def evolution_status(request: Request):
23
64
  """Get evolution engine status, backend, and recent history."""
65
+ _require_read(request)
24
66
  try:
25
- import json as _json
26
- from superlocalmemory.evolution.skill_evolver import detect_backend
27
67
  from superlocalmemory.evolution.evolution_store import EvolutionStore
68
+ from superlocalmemory.evolution.skill_evolver import detect_backend
28
69
 
29
- # Read config directly from config.json (SLMConfig.load doesn't serialize evolution)
30
- config_path = MEMORY_DIR / "config.json"
31
- evo_cfg = {}
32
- if config_path.exists():
33
- with open(config_path) as f:
34
- cfg = _json.load(f)
35
- evo_cfg = cfg.get("evolution", {})
36
-
70
+ evo_cfg = _read_evolution_config()
37
71
  enabled = evo_cfg.get("enabled", False)
38
- backend = detect_backend() if enabled else "none"
72
+ backend_setting = evo_cfg.get("backend", "auto")
73
+ backend = (
74
+ detect_backend() if enabled and backend_setting == "auto"
75
+ else backend_setting if enabled
76
+ else "none"
77
+ )
39
78
  db_path = str(MEMORY_DIR / "memory.db")
40
79
 
80
+ profile_id = get_active_profile()
41
81
  store = EvolutionStore(db_path)
42
- stats = store.get_stats()
43
- recent = store.get_recent(limit=10)
82
+ stats = store.get_stats(profile_id)
83
+ recent = store.get_recent(profile_id, limit=10)
44
84
 
45
85
  return {
46
86
  "enabled": enabled,
47
87
  "backend": backend,
48
88
  "config": {
49
- "backend_setting": evo_cfg.get("backend", "auto"),
89
+ "backend_setting": backend_setting,
50
90
  "max_per_cycle": evo_cfg.get("max_evolutions_per_cycle", 3),
91
+ "mutation_model": evo_cfg.get("mutation_model", ""),
92
+ "verify_model": evo_cfg.get("verify_model", ""),
93
+ "confirm_model": evo_cfg.get("confirm_model", ""),
51
94
  },
52
95
  "stats": {
53
96
  "total": stats.get("total", 0),
@@ -70,83 +113,158 @@ async def evolution_status():
70
113
  for r in recent
71
114
  ],
72
115
  }
73
- except Exception as e:
74
- logger.debug("evolution_status error: %s", e)
75
- return {"enabled": False, "backend": "none", "error": str(e)}
116
+ except Exception:
117
+ logger.exception("evolution_status error")
118
+ return {"enabled": False, "backend": "none", "error": "Internal server error"}
76
119
 
77
120
 
78
121
  @router.post("/api/evolution/enable")
79
- async def evolution_enable():
80
- """Enable skill evolution engine. Writes directly to config.json."""
122
+ def evolution_enable(request: Request):
123
+ """Enable evolution without replacing the user's selected backend."""
124
+ _require_manage(request)
81
125
  try:
82
- import json as _json
83
-
84
- config_path = MEMORY_DIR / "config.json"
85
- cfg = {}
86
- if config_path.exists():
87
- with open(config_path) as f:
88
- cfg = _json.load(f)
126
+ evolution = _update_evolution_config(_enable_evolution)
127
+ return {
128
+ "ok": True,
129
+ "message": f"Evolution enabled with {evolution['backend']} backend.",
130
+ }
131
+ except Exception:
132
+ logger.exception("evolution_enable error")
133
+ return {"ok": False, "error": "Internal server error"}
89
134
 
90
- if "evolution" not in cfg:
91
- cfg["evolution"] = {}
92
- cfg["evolution"]["enabled"] = True
93
- cfg["evolution"]["backend"] = "auto"
94
135
 
95
- with open(config_path, "w") as f:
96
- _json.dump(cfg, f, indent=2)
136
+ @router.post("/api/evolution/disable")
137
+ def evolution_disable(request: Request):
138
+ """Disable skill evolution engine. Mirrors /api/evolution/enable."""
139
+ _require_manage(request)
140
+ try:
141
+ _update_evolution_config(lambda cfg: cfg.update({"enabled": False}))
97
142
 
98
- return {"ok": True, "message": "Evolution enabled. Will use auto-detected backend."}
99
- except Exception as e:
100
- logger.error("evolution_enable error: %s", e)
101
- return {"ok": False, "error": str(e)}
143
+ return {"ok": True, "message": "Evolution disabled."}
144
+ except Exception:
145
+ logger.exception("evolution_disable error")
146
+ return {"ok": False, "error": "Internal server error"}
102
147
 
103
148
 
104
149
  @router.post("/api/evolution/run")
105
- async def evolution_run():
150
+ def evolution_run(request: Request):
106
151
  """Manually trigger an evolution cycle."""
152
+ _require_manage(request)
107
153
  try:
108
- import json as _json
109
154
  from superlocalmemory.evolution.skill_evolver import SkillEvolver
110
155
 
111
- config_path = MEMORY_DIR / "config.json"
112
- evo_cfg = {}
113
- if config_path.exists():
114
- with open(config_path) as f:
115
- evo_cfg = _json.load(f).get("evolution", {})
116
-
156
+ evo_cfg = _read_evolution_config()
117
157
  if not evo_cfg.get("enabled", False):
118
158
  return {"ok": False, "error": "Evolution is disabled. Enable first."}
119
159
 
120
160
  profile = get_active_profile()
121
161
  db_path = str(MEMORY_DIR / "memory.db")
122
162
 
123
- # Build a minimal config object for the evolver
124
- class _EvoCfg:
125
- enabled = True
126
- backend = evo_cfg.get("backend", "auto")
127
- max_evolutions_per_cycle = evo_cfg.get("max_evolutions_per_cycle", 3)
128
- class _Cfg:
129
- evolution = _EvoCfg()
130
-
131
- evolver = SkillEvolver(db_path, _Cfg())
163
+ # Build a minimal config object for the evolver. Must carry the
164
+ # per-step model fields (v3.7.9) or a dashboard-triggered run would
165
+ # silently ignore the user's configured models and fall back to the
166
+ # cheapest defaults.
167
+ evolution_config = SimpleNamespace(
168
+ enabled=True,
169
+ backend=evo_cfg.get("backend", "auto"),
170
+ max_evolutions_per_cycle=evo_cfg.get("max_evolutions_per_cycle", 3),
171
+ mutation_model=evo_cfg.get("mutation_model", ""),
172
+ verify_model=evo_cfg.get("verify_model", ""),
173
+ confirm_model=evo_cfg.get("confirm_model", ""),
174
+ )
175
+ evolver = SkillEvolver(
176
+ db_path, SimpleNamespace(evolution=evolution_config)
177
+ )
132
178
  result = evolver.run_consolidation_cycle(profile)
133
179
 
134
180
  return {"ok": True, **result}
135
- except Exception as e:
136
- logger.error("evolution_run error: %s", e)
137
- return {"ok": False, "error": str(e)}
181
+ except Exception:
182
+ logger.exception("evolution_run error")
183
+ return {"ok": False, "error": "Internal server error"}
184
+
185
+
186
+ class EvolutionConfigUpdate(BaseModel):
187
+ enabled: Optional[bool] = None
188
+ backend: Optional[str] = None
189
+ max_evolutions_per_cycle: Optional[int] = None
190
+ mutation_model: Optional[str] = None
191
+ verify_model: Optional[str] = None
192
+ confirm_model: Optional[str] = None
193
+
194
+
195
+ @router.post("/api/evolution/config")
196
+ def evolution_config(request: Request, body: EvolutionConfigUpdate):
197
+ """Update evolution config from the dashboard (v3.7.9).
198
+
199
+ Validates model + backend values against the same allow-list the CLI uses
200
+ (``slm config set evolution.*``) and persists to config.json atomically.
201
+ Only fields provided in the body are changed.
202
+ """
203
+ _require_manage(request)
204
+ try:
205
+ from superlocalmemory.evolution.model_selection import _MODEL_ALIASES
206
+
207
+ accepted_models = set(_MODEL_ALIASES) | {"", "auto"}
208
+ accepted_backends = {"auto", "claude", "ollama", "anthropic", "openai"}
209
+
210
+ for field in ("mutation_model", "verify_model", "confirm_model"):
211
+ val = getattr(body, field)
212
+ if val is not None and val not in accepted_models:
213
+ allowed = ", ".join(["auto", *sorted(_MODEL_ALIASES)])
214
+ return {"ok": False, "error": f"{field} must be one of: {allowed}"}
215
+ if body.backend is not None and body.backend not in accepted_backends:
216
+ return {
217
+ "ok": False,
218
+ "error": f"backend must be one of: {', '.join(sorted(accepted_backends))}",
219
+ }
220
+ if (body.max_evolutions_per_cycle is not None
221
+ and not 0 < body.max_evolutions_per_cycle <= 50):
222
+ return {"ok": False, "error": "max_evolutions_per_cycle must be 1..50"}
223
+
224
+ def _apply(evo: dict) -> None:
225
+ for field in (
226
+ "enabled",
227
+ "backend",
228
+ "max_evolutions_per_cycle",
229
+ "mutation_model",
230
+ "verify_model",
231
+ "confirm_model",
232
+ ):
233
+ value = getattr(body, field)
234
+ if value is None:
235
+ continue
236
+ if field.endswith("_model") and value == "auto":
237
+ value = ""
238
+ evo[field] = value
239
+
240
+ evo = _update_evolution_config(_apply)
241
+
242
+ return {"ok": True, "config": {
243
+ "enabled": evo.get("enabled", False),
244
+ "backend": evo.get("backend", "auto"),
245
+ "max_evolutions_per_cycle": evo.get("max_evolutions_per_cycle", 3),
246
+ "mutation_model": evo.get("mutation_model", ""),
247
+ "verify_model": evo.get("verify_model", ""),
248
+ "confirm_model": evo.get("confirm_model", ""),
249
+ }}
250
+ except Exception:
251
+ logger.exception("evolution_config error")
252
+ return {"ok": False, "error": "Internal server error"}
138
253
 
139
254
 
140
255
  @router.get("/api/evolution/lineage")
141
- async def evolution_lineage(skill_name: str = ""):
256
+ def evolution_lineage(request: Request, skill_name: str = ""):
142
257
  """Get evolution lineage for a skill or all skills.
143
258
 
144
259
  Returns lineage records and a tree structure grouped by root skill.
145
260
  """
261
+ _require_read(request)
262
+ conn = None
146
263
  try:
147
264
  import sqlite3 as _sqlite3
148
265
 
149
266
  db_path = str(MEMORY_DIR / "memory.db")
267
+ profile_id = get_active_profile()
150
268
  conn = _sqlite3.connect(db_path, timeout=10)
151
269
  conn.row_factory = _sqlite3.Row
152
270
 
@@ -156,9 +274,9 @@ async def evolution_lineage(skill_name: str = ""):
156
274
  "trigger_type, generation, status, mutation_summary, "
157
275
  "blind_verified, created_at, completed_at "
158
276
  "FROM skill_evolution_log "
159
- "WHERE skill_name = ? OR parent_skill_id = ? "
277
+ "WHERE profile_id = ? AND (skill_name = ? OR parent_skill_id = ?) "
160
278
  "ORDER BY created_at ASC",
161
- (skill_name, skill_name),
279
+ (profile_id, skill_name, skill_name),
162
280
  ).fetchall()
163
281
  else:
164
282
  rows = conn.execute(
@@ -166,11 +284,11 @@ async def evolution_lineage(skill_name: str = ""):
166
284
  "trigger_type, generation, status, mutation_summary, "
167
285
  "blind_verified, created_at, completed_at "
168
286
  "FROM skill_evolution_log "
287
+ "WHERE profile_id = ? "
169
288
  "ORDER BY created_at DESC LIMIT 100",
289
+ (profile_id,),
170
290
  ).fetchall()
171
291
 
172
- conn.close()
173
-
174
292
  lineage = [
175
293
  {
176
294
  "id": dict(r)["id"],
@@ -208,6 +326,9 @@ async def evolution_lineage(skill_name: str = ""):
208
326
  "lineage_count": len(lineage),
209
327
  "tree": tree,
210
328
  }
211
- except Exception as e:
212
- logger.debug("evolution_lineage error: %s", e)
213
- return {"lineage": [], "lineage_count": 0, "tree": {}, "error": str(e)}
329
+ except Exception:
330
+ logger.exception("evolution_lineage error")
331
+ return {"lineage": [], "lineage_count": 0, "tree": {}, "error": "Internal server error"}
332
+ finally:
333
+ if conn is not None:
334
+ conn.close()
@@ -342,12 +342,23 @@ def set_active_profile_everywhere(name: str) -> None:
342
342
 
343
343
 
344
344
  def delete_profile_from_db(name: str) -> None:
345
- """Delete a profile row from SQLite. ON DELETE CASCADE handles child rows."""
345
+ """Delete a profile row from SQLite.
346
+
347
+ rbac_memberships has no FK to profiles, so CASCADE does not remove role
348
+ grants — they would otherwise survive deletion and silently re-activate if
349
+ a profile of the same name is later recreated. Remove them explicitly.
350
+ """
346
351
  if not DB_PATH.exists():
347
352
  return
348
353
  conn = sqlite3.connect(str(DB_PATH))
349
354
  try:
350
355
  conn.execute("PRAGMA foreign_keys=ON")
356
+ # Purge role grants for this workspace (no FK CASCADE covers these).
357
+ for tbl in ("rbac_memberships",):
358
+ try:
359
+ conn.execute(f"DELETE FROM {tbl} WHERE profile_id = ?", (name,))
360
+ except sqlite3.OperationalError:
361
+ pass # table may not exist on older installs
351
362
  conn.execute("DELETE FROM profiles WHERE profile_id = ?", (name,))
352
363
  conn.commit()
353
364
  finally:
@@ -420,6 +431,10 @@ class SearchRequest(BaseModel):
420
431
  cluster_id: Optional[int] = None
421
432
  date_from: Optional[str] = None
422
433
  date_to: Optional[str] = None
434
+ # T-window: relative span ("7d", "30d", "1y") or explicit range
435
+ # ("2026-07-01..2026-07-31"). When empty, date_from/date_to (if both set)
436
+ # are used as the range. Empty + no dates = no time filter.
437
+ window: Optional[str] = None
423
438
 
424
439
 
425
440
  class ProfileSwitch(BaseModel):