superlocalmemory 3.7.7 → 3.8.0

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 (262) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/ATTRIBUTION.md +1 -3
  3. package/CHANGELOG.md +85 -0
  4. package/README.md +199 -29
  5. package/package.json +4 -2
  6. package/plugin/.claude-plugin/plugin.json +2 -2
  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/agents/slm-governance-advisor.md +80 -0
  30. package/plugin-src/agents/slm-loop-runner.md +71 -0
  31. package/plugin-src/agents/slm-memory-advisor.md +10 -5
  32. package/plugin-src/agents/slm-optimize-advisor.md +9 -3
  33. package/plugin-src/commands/slm-loop.md +31 -0
  34. package/plugin-src/hooks/hooks.json +79 -0
  35. package/plugin-src/manifest.json +7 -2
  36. package/plugin-src/requirements.txt +1 -1
  37. package/plugin-src/rules/AGENTS.md +57 -18
  38. package/plugin-src/rules/CLAUDE.md.fragment +8 -8
  39. package/plugin-src/scripts/slm-launch +46 -7
  40. package/plugin-src/settings.json +9 -0
  41. package/plugin-src/skills/slm-cache/SKILL.md +9 -1
  42. package/plugin-src/skills/slm-compress/SKILL.md +8 -1
  43. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  44. package/plugin-src/skills/slm-graph/SKILL.md +17 -3
  45. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  46. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  47. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  48. package/plugin-src/skills/slm-recall/SKILL.md +46 -10
  49. package/plugin-src/skills/slm-remember/SKILL.md +48 -1
  50. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  51. package/plugin-src/skills/slm-session/SKILL.md +24 -1
  52. package/plugin-src/skills/slm-status/SKILL.md +18 -1
  53. package/pyproject.toml +1 -2
  54. package/scripts/postinstall/validation.js +2 -0
  55. package/scripts/postinstall-interactive.js +74 -2
  56. package/src/superlocalmemory/__init__.py +1 -1
  57. package/src/superlocalmemory/access/__init__.py +3 -0
  58. package/src/superlocalmemory/access/rbac.py +477 -0
  59. package/src/superlocalmemory/cli/commands.py +96 -12
  60. package/src/superlocalmemory/cli/compress_cmd.py +17 -7
  61. package/src/superlocalmemory/cli/loop_cmd.py +192 -0
  62. package/src/superlocalmemory/cli/main.py +39 -4
  63. package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
  64. package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
  65. package/src/superlocalmemory/cli/pending_store.py +49 -13
  66. package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
  67. package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
  68. package/src/superlocalmemory/cli/setup_wizard.py +22 -13
  69. package/src/superlocalmemory/compliance/audit.py +6 -0
  70. package/src/superlocalmemory/compliance/gdpr.py +128 -138
  71. package/src/superlocalmemory/compliance/retention.py +176 -45
  72. package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
  73. package/src/superlocalmemory/core/community_summary.py +267 -0
  74. package/src/superlocalmemory/core/config.py +216 -3
  75. package/src/superlocalmemory/core/consolidation_engine.py +95 -22
  76. package/src/superlocalmemory/core/context_cache.py +61 -18
  77. package/src/superlocalmemory/core/embedding_worker.py +17 -2
  78. package/src/superlocalmemory/core/embeddings.py +12 -1
  79. package/src/superlocalmemory/core/engine.py +17 -1
  80. package/src/superlocalmemory/core/engine_ingestion.py +29 -0
  81. package/src/superlocalmemory/core/engine_wiring.py +13 -0
  82. package/src/superlocalmemory/core/entity_community.py +178 -0
  83. package/src/superlocalmemory/core/graph_analyzer.py +39 -2
  84. package/src/superlocalmemory/core/graph_pruner.py +13 -8
  85. package/src/superlocalmemory/core/key_expander.py +138 -0
  86. package/src/superlocalmemory/core/maintenance.py +23 -0
  87. package/src/superlocalmemory/core/modes.py +1 -1
  88. package/src/superlocalmemory/core/mutations.py +2 -2
  89. package/src/superlocalmemory/core/pii.py +105 -0
  90. package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
  91. package/src/superlocalmemory/core/recall_pipeline.py +2 -0
  92. package/src/superlocalmemory/core/recall_worker.py +20 -6
  93. package/src/superlocalmemory/core/scale_engine.py +60 -1
  94. package/src/superlocalmemory/core/security_primitives.py +40 -2
  95. package/src/superlocalmemory/core/store_pipeline.py +35 -11
  96. package/src/superlocalmemory/core/worker_pool.py +21 -6
  97. package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
  98. package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
  99. package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
  100. package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
  101. package/src/superlocalmemory/evolution/evolution_store.py +122 -45
  102. package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
  103. package/src/superlocalmemory/evolution/model_selection.py +160 -0
  104. package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
  105. package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
  106. package/src/superlocalmemory/evolution/triggers.py +22 -13
  107. package/src/superlocalmemory/graph/cozo_backend.py +43 -20
  108. package/src/superlocalmemory/hooks/adapter_base.py +5 -1
  109. package/src/superlocalmemory/hooks/auto_recall.py +13 -1
  110. package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
  111. package/src/superlocalmemory/hooks/codex_assets.py +64 -5
  112. package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
  113. package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
  114. package/src/superlocalmemory/hooks/portable_kit.py +114 -1
  115. package/src/superlocalmemory/infra/auth_middleware.py +28 -0
  116. package/src/superlocalmemory/infra/backup.py +12 -1
  117. package/src/superlocalmemory/infra/daemon_identity.py +40 -4
  118. package/src/superlocalmemory/infra/data_root.py +43 -4
  119. package/src/superlocalmemory/infra/event_bus.py +107 -24
  120. package/src/superlocalmemory/infra/rate_limiter.py +93 -0
  121. package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
  122. package/src/superlocalmemory/ingestion/credentials.py +1 -1
  123. package/src/superlocalmemory/learning/cross_project.py +28 -19
  124. package/src/superlocalmemory/learning/reward_proxy.py +42 -9
  125. package/src/superlocalmemory/loops/__init__.py +56 -0
  126. package/src/superlocalmemory/loops/budget.py +58 -0
  127. package/src/superlocalmemory/loops/engine.py +164 -0
  128. package/src/superlocalmemory/loops/ledger.py +243 -0
  129. package/src/superlocalmemory/loops/models.py +152 -0
  130. package/src/superlocalmemory/loops/rules.py +52 -0
  131. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  132. package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
  133. package/src/superlocalmemory/mcp/profiles.py +103 -0
  134. package/src/superlocalmemory/mcp/server.py +21 -49
  135. package/src/superlocalmemory/mcp/tools_active.py +4 -7
  136. package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
  137. package/src/superlocalmemory/mcp/tools_core.py +50 -5
  138. package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
  139. package/src/superlocalmemory/mcp/tools_loops.py +300 -0
  140. package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
  141. package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
  142. package/src/superlocalmemory/mesh/broker.py +237 -129
  143. package/src/superlocalmemory/mesh/remote_sync.py +50 -8
  144. package/src/superlocalmemory/optimize/NOTICE +1 -6
  145. package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
  146. package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
  147. package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
  148. package/src/superlocalmemory/optimize/compress/align.py +32 -26
  149. package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
  150. package/src/superlocalmemory/optimize/compress/router.py +105 -22
  151. package/src/superlocalmemory/optimize/config/defaults.py +1 -1
  152. package/src/superlocalmemory/optimize/config/schema.py +87 -4
  153. package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
  154. package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
  155. package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
  156. package/src/superlocalmemory/optimize/storage/db.py +38 -9
  157. package/src/superlocalmemory/optimize/storage/schema.py +10 -0
  158. package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
  159. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  160. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
  161. package/src/superlocalmemory/retrieval/engine.py +168 -26
  162. package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
  163. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
  164. package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
  165. package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
  166. package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
  167. package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
  168. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
  169. package/src/superlocalmemory/retrieval/time_window.py +181 -0
  170. package/src/superlocalmemory/server/api.py +21 -4
  171. package/src/superlocalmemory/server/profile_runtime.py +125 -8
  172. package/src/superlocalmemory/server/rbac_enforce.py +142 -0
  173. package/src/superlocalmemory/server/recall_health.py +24 -3
  174. package/src/superlocalmemory/server/recall_serializer.py +19 -1
  175. package/src/superlocalmemory/server/routes/abstraction.py +115 -0
  176. package/src/superlocalmemory/server/routes/agents.py +128 -38
  177. package/src/superlocalmemory/server/routes/backup.py +34 -10
  178. package/src/superlocalmemory/server/routes/behavioral.py +13 -12
  179. package/src/superlocalmemory/server/routes/brain.py +21 -5
  180. package/src/superlocalmemory/server/routes/chat.py +72 -16
  181. package/src/superlocalmemory/server/routes/compliance.py +171 -21
  182. package/src/superlocalmemory/server/routes/config_api.py +436 -0
  183. package/src/superlocalmemory/server/routes/data_io.py +30 -8
  184. package/src/superlocalmemory/server/routes/entity.py +9 -4
  185. package/src/superlocalmemory/server/routes/events.py +24 -8
  186. package/src/superlocalmemory/server/routes/evolution.py +135 -17
  187. package/src/superlocalmemory/server/routes/helpers.py +16 -1
  188. package/src/superlocalmemory/server/routes/ingest.py +7 -4
  189. package/src/superlocalmemory/server/routes/insights.py +3 -3
  190. package/src/superlocalmemory/server/routes/learning.py +14 -14
  191. package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
  192. package/src/superlocalmemory/server/routes/memories.py +221 -49
  193. package/src/superlocalmemory/server/routes/mesh.py +95 -15
  194. package/src/superlocalmemory/server/routes/optimize.py +33 -1
  195. package/src/superlocalmemory/server/routes/prewarm.py +2 -0
  196. package/src/superlocalmemory/server/routes/profiles.py +63 -17
  197. package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
  198. package/src/superlocalmemory/server/routes/rbac.py +367 -0
  199. package/src/superlocalmemory/server/routes/stats.py +13 -6
  200. package/src/superlocalmemory/server/routes/tiers.py +11 -9
  201. package/src/superlocalmemory/server/routes/v3_api.py +194 -81
  202. package/src/superlocalmemory/server/routes/ws.py +5 -2
  203. package/src/superlocalmemory/server/security_middleware.py +12 -5
  204. package/src/superlocalmemory/server/ui.py +30 -5
  205. package/src/superlocalmemory/server/unified_daemon.py +431 -75
  206. package/src/superlocalmemory/server/write_identity.py +38 -8
  207. package/src/superlocalmemory/storage/database.py +265 -53
  208. package/src/superlocalmemory/storage/migration_runner.py +53 -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/models.py +4 -0
  217. package/src/superlocalmemory/storage/schema.py +87 -0
  218. package/src/superlocalmemory/storage/schema_v32.py +0 -9
  219. package/src/superlocalmemory/storage/schema_v343.py +24 -12
  220. package/src/superlocalmemory/trust/gate.py +49 -8
  221. package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
  222. package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
  223. package/src/superlocalmemory/ui/css/design-system.css +621 -0
  224. package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
  225. package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
  226. package/src/superlocalmemory/ui/favicon.svg +35 -4
  227. package/src/superlocalmemory/ui/index.html +306 -173
  228. package/src/superlocalmemory/ui/js/brain.js +5 -20
  229. package/src/superlocalmemory/ui/js/core.js +47 -31
  230. package/src/superlocalmemory/ui/js/dashboard.js +314 -63
  231. package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
  232. package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
  233. package/src/superlocalmemory/ui/js/math-health.js +1 -1
  234. package/src/superlocalmemory/ui/js/memories.js +15 -4
  235. package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
  236. package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
  237. package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
  238. package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
  239. package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
  240. package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
  241. package/src/superlocalmemory/ui/js/od-agents.js +544 -0
  242. package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
  243. package/src/superlocalmemory/ui/js/od-backup.js +780 -0
  244. package/src/superlocalmemory/ui/js/od-brain.js +779 -0
  245. package/src/superlocalmemory/ui/js/od-entities.js +579 -0
  246. package/src/superlocalmemory/ui/js/od-graph.js +593 -0
  247. package/src/superlocalmemory/ui/js/od-health.js +539 -0
  248. package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
  249. package/src/superlocalmemory/ui/js/od-memories.js +887 -0
  250. package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
  251. package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
  252. package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
  253. package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
  254. package/src/superlocalmemory/ui/js/od-shell.js +593 -0
  255. package/src/superlocalmemory/ui/js/od-skills.js +573 -0
  256. package/src/superlocalmemory/ui/js/od-team.js +258 -0
  257. package/src/superlocalmemory/ui/js/profiles.js +159 -46
  258. package/src/superlocalmemory/ui/js/settings.js +2 -2
  259. package/src/superlocalmemory/ui/js/timeline.js +34 -5
  260. package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
  261. package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
  262. package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
@@ -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
@@ -124,15 +124,36 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
124
124
 
125
125
  Mutates and returns ``state``. Never raises — a timed-out / failing recall
126
126
  marks the path unhealthy instead of propagating.
127
+
128
+ Uses ``operation_nowait()`` when a runtime is supplied so that a pending
129
+ profile switch is not blocked by the health-probe recall. If a transition
130
+ is in progress the tick is skipped entirely — the next scheduled tick will
131
+ re-warm once the switch has committed.
127
132
  """
128
133
  state.checks += 1
129
134
 
130
135
  # Tier 1: re-warm. A real full-fusion recall keeps the graph page cache hot
131
136
  # and the embedder resident.
137
+ # Cooperative preemption: use operation_nowait() so a pending profile switch
138
+ # is not held hostage by a slow full-fusion recall (2–10s with fast=False).
132
139
  try:
133
- lease = runtime.operation() if runtime is not None else nullcontext()
134
- with lease:
135
- resp = engine.recall(probe, limit=3, fast=False)
140
+ if runtime is not None:
141
+ lease = runtime.operation_nowait()
142
+ else:
143
+ lease = nullcontext()
144
+ with lease as _snap:
145
+ if runtime is not None and _snap is None:
146
+ # A profile transition is in progress — skip this tick so we
147
+ # do not hold the drain window. Health state is unchanged;
148
+ # the next tick fires after the switch commits.
149
+ log.debug(
150
+ "recall-health: tick skipped — profile transition in progress"
151
+ )
152
+ return state
153
+ # fast=True: a health probe must release its operation lease well
154
+ # within the 5s profile-switch drain window (fast=False is 2-10s and
155
+ # would make every profile switch time out while a tick is in flight).
156
+ resp = engine.recall(probe, limit=3, fast=True)
136
157
  except Exception as exc:
137
158
  state.healthy = False
138
159
  state.consecutive_failures += 1
@@ -24,6 +24,7 @@ import re
24
24
  from typing import Any
25
25
 
26
26
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
27
+ from superlocalmemory.retrieval.temporal_frame import relative_age, temporal_frame
27
28
 
28
29
 
29
30
  # ---------------------------------------------------------------------------
@@ -190,9 +191,13 @@ def serialize_recall_response(
190
191
  is the evidence-floor signal lifted from the response (additive).
191
192
  """
192
193
  memory_map = memory_map or {}
194
+ # T-inject: one shared "now" so every result's age label is consistent.
195
+ from datetime import datetime as _dt, timezone as _tz
196
+ _now = _dt.now(_tz.utc)
193
197
  raw: list[dict] = []
194
198
  for r in (response.results or [])[:limit]:
195
199
  fact = r.fact
200
+ _created = getattr(fact, "created_at", "") or ""
196
201
  fact_type = getattr(fact, "fact_type", None)
197
202
  lifecycle = getattr(fact, "lifecycle", None)
198
203
  raw.append({
@@ -225,7 +230,10 @@ def serialize_recall_response(
225
230
  if lifecycle is not None and hasattr(lifecycle, "value")
226
231
  else (lifecycle or ""),
227
232
  "access_count": getattr(fact, "access_count", 0),
228
- "created_at": getattr(fact, "created_at", "") or "",
233
+ "created_at": _created,
234
+ # T-inject: human-relative age so consumers (and the LLM) can
235
+ # weigh recency without doing date math. "" when undated.
236
+ "age_label": relative_age(_created, _now),
229
237
  "evidence_chain": list(getattr(r, "evidence_chain", []) or []),
230
238
  })
231
239
 
@@ -241,6 +249,12 @@ def serialize_recall_response(
241
249
 
242
250
  def recall_response_metadata(response: Any) -> dict:
243
251
  """Return Score Contract v2 response metadata for transport envelopes."""
252
+ # T-inject: a one-line temporal frame anchoring the result set to "now"
253
+ # and its age span, so time-blind LLMs get an explicit recency signal.
254
+ _timestamps = [
255
+ getattr(getattr(r, "fact", None), "created_at", "") or ""
256
+ for r in (getattr(response, "results", None) or [])
257
+ ]
244
258
  return {
245
259
  "score_contract_version": getattr(response, "score_contract_version", "2"),
246
260
  "calibration_status": getattr(response, "calibration_status", "uncalibrated"),
@@ -248,4 +262,8 @@ def recall_response_metadata(response: Any) -> dict:
248
262
  "answer_confidence": getattr(response, "answer_confidence", None),
249
263
  "abstained": bool(getattr(response, "abstained", False)),
250
264
  "abstention_reason": getattr(response, "abstention_reason", None),
265
+ "temporal_frame": temporal_frame(_timestamps),
266
+ # Q2b: thematic community summary (pure pass-through; computed upstream
267
+ # in the engine where DB access is available). None on most recalls.
268
+ "thematic_context": getattr(response, "community_context", None),
251
269
  }
@@ -0,0 +1,115 @@
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
4
+
5
+ """Progressive-abstraction read API (Wave Q3).
6
+
7
+ Exposes the abstraction hierarchy so the dashboard can browse it and drill
8
+ down to source atoms:
9
+
10
+ GET /api/v3/abstraction/persona — the per-profile persona roll-up
11
+ GET /api/v3/abstraction/communities — community summaries (Q2)
12
+ GET /api/v3/abstraction/sources — drill-down (node -> source atoms)
13
+
14
+ Read-only, profile-scoped (Rule 01), direct sqlite3 (Rule 06). All handlers
15
+ fail-soft: a missing DB or table returns an empty payload, never a 500.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import sqlite3
22
+ from typing import Any
23
+
24
+ from fastapi import APIRouter, Query
25
+ from fastapi.responses import JSONResponse
26
+
27
+ from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ router = APIRouter(prefix="/api/v3/abstraction", tags=["abstraction"])
32
+
33
+
34
+ class _ReadDB:
35
+ """Adapt a raw sqlite3 connection to the .execute(...) -> list contract
36
+ the read-only builder methods expect (matches DatabaseManager.execute)."""
37
+
38
+ def __init__(self, conn: sqlite3.Connection) -> None:
39
+ self._conn = conn
40
+
41
+ def execute(self, sql: str, params: tuple = ()) -> list:
42
+ return self._conn.execute(sql, params).fetchall()
43
+
44
+
45
+ def _conn() -> sqlite3.Connection | None:
46
+ if not DB_PATH.exists():
47
+ return None
48
+ conn = sqlite3.connect(str(DB_PATH))
49
+ conn.row_factory = sqlite3.Row
50
+ return conn
51
+
52
+
53
+ @router.get("/persona")
54
+ def get_persona(profile: str = Query("")) -> JSONResponse:
55
+ pid = profile or get_active_profile()
56
+ conn = _conn()
57
+ if conn is None:
58
+ return JSONResponse({"profile": pid, "persona": None})
59
+ try:
60
+ from superlocalmemory.core.progressive_abstraction import ProgressiveAbstraction
61
+
62
+ persona = ProgressiveAbstraction(_ReadDB(conn)).get_persona(pid)
63
+ return JSONResponse({"profile": pid, "persona": persona})
64
+ except Exception as exc: # pragma: no cover - defensive
65
+ logger.debug("persona read failed: %s", exc)
66
+ return JSONResponse({"profile": pid, "persona": None})
67
+ finally:
68
+ conn.close()
69
+
70
+
71
+ @router.get("/communities")
72
+ def get_communities(profile: str = Query("")) -> JSONResponse:
73
+ pid = profile or get_active_profile()
74
+ conn = _conn()
75
+ if conn is None:
76
+ return JSONResponse({"profile": pid, "communities": []})
77
+ try:
78
+ from superlocalmemory.core.community_summary import CommunitySummaryBuilder
79
+
80
+ summaries = CommunitySummaryBuilder(_ReadDB(conn)).get_summaries(pid)
81
+ return JSONResponse({"profile": pid, "communities": summaries})
82
+ except Exception as exc: # pragma: no cover - defensive
83
+ logger.debug("communities read failed: %s", exc)
84
+ return JSONResponse({"profile": pid, "communities": []})
85
+ finally:
86
+ conn.close()
87
+
88
+
89
+ @router.get("/sources")
90
+ def get_sources(
91
+ profile: str = Query(""), node: str = Query("persona"),
92
+ ) -> JSONResponse:
93
+ pid = profile or get_active_profile()
94
+ conn = _conn()
95
+ empty: dict[str, Any] = {
96
+ "node_id": node, "node_type": "unknown", "communities": [], "fact_ids": [],
97
+ }
98
+ if conn is None:
99
+ return JSONResponse({"profile": pid, "sources": empty})
100
+ try:
101
+ from superlocalmemory.core.progressive_abstraction import ProgressiveAbstraction
102
+
103
+ node_val: Any = node
104
+ if node != "persona":
105
+ try:
106
+ node_val = int(node)
107
+ except (ValueError, TypeError):
108
+ node_val = node
109
+ sources = ProgressiveAbstraction(_ReadDB(conn)).get_sources(pid, node_val)
110
+ return JSONResponse({"profile": pid, "sources": sources})
111
+ except Exception as exc: # pragma: no cover - defensive
112
+ logger.debug("sources read failed: %s", exc)
113
+ return JSONResponse({"profile": pid, "sources": empty})
114
+ finally:
115
+ conn.close()