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
@@ -20,6 +20,18 @@ logger = logging.getLogger("superlocalmemory.routes.memories")
20
20
  router = APIRouter()
21
21
 
22
22
 
23
+ def _internal_error(detail: str = "Internal server error") -> HTTPException:
24
+ """SEC-H-02: log the full traceback server-side; return a generic message.
25
+
26
+ Returning ``str(e)`` to HTTP clients leaks DB schema (column/constraint
27
+ names), the data-directory filesystem path, and possibly config internals —
28
+ a GDPR Art. 32 gap. Call this only from inside an ``except`` block so
29
+ ``logger.exception`` captures the active traceback automatically.
30
+ """
31
+ logger.exception("memories route error")
32
+ return HTTPException(status_code=500, detail=detail)
33
+
34
+
23
35
  def _get_engine(request: Request):
24
36
  """Get V3 engine from app state, initializing lazily on first call."""
25
37
  return get_engine_lazy(request.app.state)
@@ -41,6 +53,15 @@ def _authorize_memory_mutation(
41
53
  getattr(request.app.state, "daemon_descriptor", None),
42
54
  actor_kind="dashboard",
43
55
  )
56
+ # RBAC (C3): on top of machine auth, enforce the caller's role on the active
57
+ # profile. delete → DELETE; every other mutation → WRITE. A viewer (or an
58
+ # owner in require_login mode) is rejected here; owner/admin/member pass.
59
+ from superlocalmemory.access.rbac import Permission as _Perm
60
+ from superlocalmemory.server.rbac_enforce import require_permission as _rbac_require
61
+ _rbac_require(
62
+ request,
63
+ _Perm.DELETE if operation == "delete" else _Perm.WRITE,
64
+ )
44
65
  engine = _get_engine(request)
45
66
  if engine is None:
46
67
  raise HTTPException(503, detail="Engine not initialized")
@@ -253,6 +274,14 @@ async def get_memories(
253
274
  None,
254
275
  description="Named filter: 'high_reward' | 'being_forgotten'",
255
276
  ),
277
+ scope: Optional[str] = Query(
278
+ None,
279
+ description=(
280
+ "Scope view (v3 only): 'shared' (shared with this profile), "
281
+ "'global' (global memories), 'all' (this profile + global + shared). "
282
+ "Default None = this profile only (unchanged isolation)."
283
+ ),
284
+ ),
256
285
  ):
257
286
  """List memories with optional filtering and pagination.
258
287
 
@@ -274,15 +303,36 @@ async def get_memories(
274
303
  use_v3 = _has_table(cursor, 'atomic_facts')
275
304
 
276
305
  if use_v3:
277
- query = """
278
- SELECT fact_id as id, memory_id, content, fact_type as category,
279
- confidence as importance, access_count,
280
- created_at, created_at as updated_at,
281
- session_id as project_name
282
- FROM atomic_facts WHERE profile_id = ?
283
- """
284
- params = [active_profile]
285
- count_base = "SELECT COUNT(*) as total FROM atomic_facts WHERE profile_id = ?"
306
+ # Scope view clause. Default (scope=None) is this-profile-only —
307
+ # identical isolation to before. Other values widen the view to
308
+ # global and/or shared-with-this-profile memories.
309
+ _esc = active_profile.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
310
+ _shared_pat = f'%"{_esc}"%'
311
+ if scope == "global":
312
+ scope_where = "scope = 'global'"
313
+ scope_params = []
314
+ elif scope == "shared":
315
+ scope_where = "scope = 'shared' AND shared_with LIKE ? ESCAPE '\\'"
316
+ scope_params = [_shared_pat]
317
+ elif scope == "all":
318
+ scope_where = (
319
+ "(profile_id = ? OR scope = 'global' "
320
+ "OR (scope = 'shared' AND shared_with LIKE ? ESCAPE '\\'))"
321
+ )
322
+ scope_params = [active_profile, _shared_pat]
323
+ else:
324
+ scope_where = "profile_id = ?"
325
+ scope_params = [active_profile]
326
+
327
+ query = (
328
+ "SELECT fact_id as id, memory_id, content, fact_type as category, "
329
+ "confidence as importance, access_count, "
330
+ "created_at, created_at as updated_at, "
331
+ "session_id as project_name, scope, shared_with "
332
+ f"FROM atomic_facts WHERE {scope_where}"
333
+ )
334
+ params = list(scope_params)
335
+ count_base = f"SELECT COUNT(*) as total FROM atomic_facts WHERE {scope_where}"
286
336
  else:
287
337
  query = """
288
338
  SELECT id, content, summary, category, project_name, project_path,
@@ -293,7 +343,8 @@ async def get_memories(
293
343
  params = [active_profile]
294
344
  count_base = "SELECT COUNT(*) as total FROM memories WHERE profile = ?"
295
345
 
296
- count_params = [active_profile]
346
+ # count params must mirror the base WHERE params (scope-aware for v3).
347
+ count_params = list(params)
297
348
 
298
349
  if category:
299
350
  if use_v3:
@@ -396,8 +447,8 @@ async def get_memories(
396
447
  "has_more": (offset + limit) < total,
397
448
  }
398
449
 
399
- except Exception as e:
400
- raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
450
+ except Exception:
451
+ raise _internal_error("Database error")
401
452
 
402
453
 
403
454
  @router.get("/api/graph")
@@ -430,8 +481,8 @@ async def get_graph(
430
481
  },
431
482
  }
432
483
 
433
- except Exception as e:
434
- raise HTTPException(status_code=500, detail=f"Graph error: {str(e)}")
484
+ except Exception:
485
+ raise _internal_error("Graph error")
435
486
 
436
487
 
437
488
  @router.post("/api/search")
@@ -455,17 +506,27 @@ async def search_memories(request: Request, body: SearchRequest):
455
506
  # directly in an async route blocks the ASGI event loop — Chrome detects
456
507
  # a stalled connection and aborts with "signal is aborted without reason"
457
508
  # before the response arrives. Fix: run in a thread-pool executor so the
458
- # event loop stays alive to send keepalive frames. Also fast=False skips
459
- # spreading_activation + Hopfield (saves ~7s on cold graph traversal).
509
+ # event loop stays alive to send keepalive frames.
510
+ # v3.4.64 (regression fix): fast=True skips spreading_activation + agentic
511
+ # LLM rounds (saves ~7s on cold graph traversal). fast=False is the SLOW
512
+ # path that enables both; the earlier comment had this inverted.
460
513
  import asyncio
461
514
  import time as _time
462
515
  engine = _get_engine(request)
463
516
  if engine is not None:
464
- loop = asyncio.get_event_loop()
517
+ loop = asyncio.get_running_loop()
465
518
  t0 = _time.monotonic()
519
+ # T-window: prefer an explicit ``window`` spec; otherwise derive a
520
+ # range from the legacy date_from/date_to pair when both are set.
521
+ _window = getattr(body, "window", None) or ""
522
+ if not _window and getattr(body, "date_from", None) and getattr(body, "date_to", None):
523
+ _window = f"{body.date_from}..{body.date_to}"
466
524
  response = await loop.run_in_executor(
467
525
  None,
468
- lambda: engine.recall(body.query, limit=body.limit, fast=False),
526
+ lambda: engine.recall(
527
+ body.query, limit=body.limit, fast=True,
528
+ window=_window or None,
529
+ ),
469
530
  )
470
531
  elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
471
532
  from superlocalmemory.server.recall_serializer import (
@@ -525,8 +586,8 @@ async def search_memories(request: Request, body: SearchRequest):
525
586
  "no_confident_match": False,
526
587
  }
527
588
 
528
- except Exception as e:
529
- raise HTTPException(status_code=500, detail=f"Search error: {str(e)}")
589
+ except Exception:
590
+ raise _internal_error("Search error")
530
591
  finally:
531
592
  end_recall()
532
593
 
@@ -611,8 +672,8 @@ async def get_clusters(request: Request):
611
672
 
612
673
  conn.close()
613
674
  return {"clusters": clusters, "total_clusters": len(clusters), "unclustered_count": unclustered}
614
- except Exception as e:
615
- raise HTTPException(status_code=500, detail=f"Cluster error: {str(e)}")
675
+ except Exception:
676
+ raise _internal_error("Cluster error")
616
677
 
617
678
 
618
679
  @router.get("/api/clusters/{cluster_id}")
@@ -662,14 +723,30 @@ async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Que
662
723
  if not members:
663
724
  raise HTTPException(status_code=404, detail="Cluster not found")
664
725
  # Generate cluster summary
726
+ # v3.7.8: previously routed through WorkerPool.shared(), a subprocess
727
+ # cache never recycled on a profile switch (CRITICAL cross-profile
728
+ # leak — up to 120s stale). Use the daemon's own resident,
729
+ # lease-protected engine's config instead; the summarizer only
730
+ # consumes already profile-filtered `texts` above, so this closes
731
+ # the stale-engine window without changing behavior.
665
732
  summary = ""
666
733
  try:
667
- from superlocalmemory.core.worker_pool import WorkerPool
668
- pool = WorkerPool.shared()
669
734
  texts = [m.get("content", "")[:200] for m in members[:10] if m.get("content")]
670
735
  if texts:
671
- result = pool.summarize(texts)
672
- summary = result.get("summary", "") if result.get("ok") else ""
736
+ from superlocalmemory.core.summarizer import Summarizer
737
+ from superlocalmemory.server.routes.helpers import get_engine_lazy
738
+
739
+ # v3.4.64: Do NOT call runtime.operation() synchronously in an
740
+ # async route — if a transition is pending, acquire_operation()
741
+ # blocks the event loop thread, deadlocking the drain. The
742
+ # middleware already holds an operation lease for this request;
743
+ # the engine is stable for the full duration of the handler.
744
+ engine = get_engine_lazy(request.app.state)
745
+ if engine is not None:
746
+ summarizer = Summarizer(engine._config)
747
+ summary = summarizer.summarize_cluster(
748
+ [{"content": t} for t in texts]
749
+ ) or ""
673
750
  except Exception:
674
751
  pass
675
752
 
@@ -681,24 +758,60 @@ async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Que
681
758
  }
682
759
  except HTTPException:
683
760
  raise
684
- except Exception as e:
685
- raise HTTPException(status_code=500, detail=f"Cluster detail error: {str(e)}")
761
+ except Exception:
762
+ raise _internal_error("Cluster detail error")
686
763
 
687
764
 
688
765
  @router.get("/api/memories/{memory_id}/facts")
689
766
  async def get_memory_facts(request: Request, memory_id: str):
690
- """Get original memory text with all its child atomic facts."""
767
+ """Get original memory text with all its child atomic facts.
768
+
769
+ v3.7.8: previously routed through WorkerPool.shared(), a subprocess
770
+ engine cached at process init and never recycled on a profile switch
771
+ (CRITICAL cross-profile leak — served the OLD profile's facts for up to
772
+ 120s after a switch). Uses the daemon's own resident, lease-protected
773
+ engine instead, exactly like the /recall route.
774
+ """
691
775
  try:
692
- from superlocalmemory.core.worker_pool import WorkerPool
693
- pool = WorkerPool.shared()
694
- result = pool.get_memory_facts(memory_id)
695
- if result.get("ok"):
696
- return result
697
- raise HTTPException(status_code=404, detail="Memory not found")
776
+ from superlocalmemory.server.routes.helpers import get_engine_lazy
777
+
778
+ # v3.4.64: Do NOT call runtime.operation() synchronously in an async
779
+ # route — blocks the event loop when a transition is pending. The
780
+ # middleware already holds an operation lease; the engine and its
781
+ # profile_id are stable for the full duration of this request.
782
+ engine = get_engine_lazy(request.app.state)
783
+ if engine is None:
784
+ raise HTTPException(status_code=503, detail="Engine not initialized")
785
+ active_profile = engine.profile_id
786
+ mem_map = engine._db.get_memory_content_batch(
787
+ [memory_id], active_profile, include_global=True, include_shared=True,
788
+ )
789
+ original = mem_map.get(memory_id, "")
790
+ facts = engine._db.get_facts_by_memory_id(memory_id, active_profile)
791
+ fact_list = [
792
+ {
793
+ "fact_id": f.fact_id,
794
+ "content": f.content,
795
+ "fact_type": (
796
+ f.fact_type.value if hasattr(f.fact_type, "value")
797
+ else str(f.fact_type)
798
+ ),
799
+ "confidence": round(f.confidence, 3),
800
+ "created_at": f.created_at,
801
+ }
802
+ for f in facts
803
+ ]
804
+ return {
805
+ "ok": True,
806
+ "memory_id": memory_id,
807
+ "original_content": original,
808
+ "facts": fact_list,
809
+ "fact_count": len(fact_list),
810
+ }
698
811
  except HTTPException:
699
812
  raise
700
- except Exception as e:
701
- raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
813
+ except Exception:
814
+ raise _internal_error()
702
815
 
703
816
 
704
817
  @router.get("/api/memories/{memory_id}/detail")
@@ -748,8 +861,8 @@ async def get_memory_detail(request: Request, memory_id: str):
748
861
  }
749
862
  except HTTPException:
750
863
  raise
751
- except Exception as e:
752
- raise HTTPException(status_code=500, detail=f"Detail error: {str(e)}")
864
+ except Exception:
865
+ raise _internal_error("Detail error")
753
866
 
754
867
 
755
868
  @router.get("/api/facts/{fact_id}")
@@ -788,8 +901,8 @@ async def get_fact_detail(request: Request, fact_id: str):
788
901
  return row
789
902
  except HTTPException:
790
903
  raise
791
- except Exception as e:
792
- raise HTTPException(status_code=500, detail=f"Fact detail error: {str(e)}")
904
+ except Exception:
905
+ raise _internal_error("Fact detail error")
793
906
 
794
907
 
795
908
  @router.delete("/api/memories/{fact_id}")
@@ -812,8 +925,8 @@ async def delete_memory(request: Request, fact_id: str):
812
925
  return {"success": True, "deleted": fact_id}
813
926
  except HTTPException:
814
927
  raise
815
- except Exception as e:
816
- raise HTTPException(status_code=500, detail=f"Delete error: {str(e)}")
928
+ except Exception:
929
+ raise _internal_error("Delete error")
817
930
 
818
931
 
819
932
  @router.post("/api/memories/{fact_id}/forget")
@@ -873,8 +986,8 @@ async def forget_memory(request: Request, fact_id: str):
873
986
  return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
874
987
  except HTTPException:
875
988
  raise
876
- except Exception as e:
877
- raise HTTPException(status_code=500, detail=f"Forget error: {str(e)}")
989
+ except Exception:
990
+ raise _internal_error("Forget error")
878
991
 
879
992
 
880
993
  @router.post("/api/memories/{fact_id}/merge")
@@ -942,8 +1055,8 @@ async def merge_memory(request: Request, fact_id: str):
942
1055
  }
943
1056
  except HTTPException:
944
1057
  raise
945
- except Exception as e:
946
- raise HTTPException(status_code=500, detail=f"Merge error: {str(e)}")
1058
+ except Exception:
1059
+ raise _internal_error("Merge error")
947
1060
 
948
1061
 
949
1062
  @router.patch("/api/memories/{fact_id}")
@@ -975,5 +1088,64 @@ async def edit_memory(request: Request, fact_id: str):
975
1088
  return {"success": True, "fact_id": fact_id, "content": new_content}
976
1089
  except HTTPException:
977
1090
  raise
978
- except Exception as e:
979
- raise HTTPException(status_code=500, detail=f"Edit error: {str(e)}")
1091
+ except Exception:
1092
+ raise _internal_error("Edit error")
1093
+
1094
+
1095
+ _VALID_SCOPES = ("personal", "shared", "global")
1096
+
1097
+
1098
+ @router.patch("/api/memories/{fact_id}/scope")
1099
+ async def set_memory_scope(request: Request, fact_id: str):
1100
+ """Set a memory's scope (personal | shared | global) + shared_with.
1101
+
1102
+ Body: {"scope": "shared", "shared_with": "alice,bob"}. shared_with accepts a
1103
+ comma-separated string or a list; it is stored as a JSON array so the
1104
+ _scope_where LIKE match works. Mutation-authorized, and the fact must
1105
+ belong to the active profile (a caller cannot re-scope another profile's
1106
+ fact). This is the write side of multi-scope sharing from the dashboard.
1107
+ """
1108
+ import json as _json
1109
+ try:
1110
+ body = await request.json()
1111
+ scope = (body.get("scope") or "").strip().lower()
1112
+ if scope not in _VALID_SCOPES:
1113
+ raise HTTPException(400, detail=f"scope must be one of {_VALID_SCOPES}")
1114
+
1115
+ raw_shared = body.get("shared_with", [])
1116
+ if isinstance(raw_shared, str):
1117
+ shared_list = [s.strip() for s in raw_shared.split(",") if s.strip()]
1118
+ elif isinstance(raw_shared, list):
1119
+ shared_list = [str(s).strip() for s in raw_shared if str(s).strip()]
1120
+ else:
1121
+ shared_list = []
1122
+ if scope == "shared" and not shared_list:
1123
+ raise HTTPException(
1124
+ 400, detail="shared scope requires at least one profile in shared_with")
1125
+ # global/personal never carry a shared_with list.
1126
+ if scope != "shared":
1127
+ shared_list = []
1128
+
1129
+ engine, active_profile, _ctx = _authorize_memory_mutation(
1130
+ request, "update", fact_id, run_pre_hook=False,
1131
+ )
1132
+ # Ownership check: the fact must belong to the active profile.
1133
+ rows = engine._db.execute(
1134
+ "SELECT 1 FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
1135
+ (fact_id, active_profile),
1136
+ )
1137
+ if not rows:
1138
+ raise HTTPException(404, detail="Memory not found in this profile")
1139
+
1140
+ engine._db.update_fact(fact_id, {
1141
+ "scope": scope,
1142
+ "shared_with": _json.dumps(shared_list),
1143
+ }, profile_id=active_profile)
1144
+ return {
1145
+ "success": True, "fact_id": fact_id, "scope": scope,
1146
+ "shared_with": shared_list, "active_profile": active_profile,
1147
+ }
1148
+ except HTTPException:
1149
+ raise
1150
+ except Exception:
1151
+ raise _internal_error("Scope update error")
@@ -11,6 +11,7 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import asyncio
14
15
  import re
15
16
  from typing import Optional
16
17
 
@@ -120,6 +121,19 @@ def _get_broker(request: Request):
120
121
  return broker
121
122
 
122
123
 
124
+ def _active_profile() -> str:
125
+ """Resolve the tenant (profile) for this mesh request.
126
+
127
+ The mesh is a per-tenant coordination bus: peers, messages, shared state,
128
+ and locks are all scoped to the active profile so one tenant never sees
129
+ another's coordination traffic. Resolved from the request ContextVar set by
130
+ ProfileRuntimeMiddleware (falls back to the configured active profile).
131
+ """
132
+ from superlocalmemory.server.routes.helpers import get_active_profile
133
+
134
+ return get_active_profile()
135
+
136
+
123
137
  _SECRET_KEY = re.compile(
124
138
  r"(?:^|[_\-.])(api[_\-.]?key|secret|token|password|credential)(?:$|[_\-.])",
125
139
  re.IGNORECASE,
@@ -146,29 +160,79 @@ async def register(req: RegisterRequest, request: Request):
146
160
  raise HTTPException(400, detail="session_id required")
147
161
  return broker.register_peer(
148
162
  req.session_id, req.summary, req.host, req.port,
149
- req.project_path, req.agent_type,
163
+ req.project_path, req.agent_type, profile_id=_active_profile(),
150
164
  )
151
165
 
152
166
 
153
167
  @router.post("/deregister")
154
168
  async def deregister(req: DeregisterRequest, request: Request):
155
169
  broker = _get_broker(request)
156
- result = broker.deregister_peer(req.peer_id)
170
+ result = broker.deregister_peer(req.peer_id, profile_id=_active_profile())
157
171
  if not result.get("ok"):
158
172
  raise HTTPException(404, detail=result.get("error", "peer not found"))
159
173
  return result
160
174
 
161
175
 
176
+ def _peer_activity_counts(request: Request, session_ids: list[str]) -> dict:
177
+ """Per-session {tool_count, memory_count} for the active profile.
178
+
179
+ Each mesh peer IS an agent session, so its activity is the tool events it
180
+ logged and the memories it contributed under the current profile. Batched
181
+ to a single grouped query per table. Fail-soft: returns {} on any error so
182
+ the peer list still renders.
183
+ """
184
+ if not session_ids:
185
+ return {}
186
+ try:
187
+ from superlocalmemory.server.routes.helpers import (
188
+ get_engine_lazy, get_active_profile,
189
+ )
190
+ engine = get_engine_lazy(request.app.state)
191
+ if engine is None:
192
+ return {}
193
+ profile = get_active_profile()
194
+ placeholders = ",".join("?" * len(session_ids))
195
+ params = [profile, *session_ids]
196
+ counts: dict[str, dict] = {sid: {"tool_count": 0, "memory_count": 0}
197
+ for sid in session_ids}
198
+ for table, key in (("tool_events", "tool_count"),
199
+ ("memories", "memory_count")):
200
+ try:
201
+ rows = engine._db.execute(
202
+ f"SELECT session_id, COUNT(*) AS n FROM {table} "
203
+ f"WHERE profile_id = ? AND session_id IN ({placeholders}) "
204
+ "GROUP BY session_id",
205
+ tuple(params),
206
+ )
207
+ for r in rows:
208
+ d = dict(r)
209
+ sid = d.get("session_id")
210
+ if sid in counts:
211
+ counts[sid][key] = d.get("n", 0)
212
+ except Exception:
213
+ continue
214
+ return counts
215
+ except Exception:
216
+ return {}
217
+
218
+
162
219
  @router.get("/peers")
163
220
  async def peers(request: Request):
164
221
  broker = _get_broker(request)
165
- return {"peers": broker.list_all_peers()}
222
+ peer_list = broker.list_all_peers(_active_profile())
223
+ session_ids = [p.get("session_id") for p in peer_list if p.get("session_id")]
224
+ counts = _peer_activity_counts(request, session_ids)
225
+ for p in peer_list:
226
+ c = counts.get(p.get("session_id"), {})
227
+ p["tool_count"] = c.get("tool_count", 0)
228
+ p["memory_count"] = c.get("memory_count", 0)
229
+ return {"peers": peer_list}
166
230
 
167
231
 
168
232
  @router.post("/heartbeat")
169
233
  async def heartbeat(req: HeartbeatRequest, request: Request):
170
234
  broker = _get_broker(request)
171
- result = broker.heartbeat(req.peer_id)
235
+ result = broker.heartbeat(req.peer_id, profile_id=_active_profile())
172
236
  if not result.get("ok"):
173
237
  raise HTTPException(404, detail=result.get("error", "peer not found"))
174
238
  return result
@@ -177,7 +241,8 @@ async def heartbeat(req: HeartbeatRequest, request: Request):
177
241
  @router.post("/summary")
178
242
  async def summary(req: SummaryRequest, request: Request):
179
243
  broker = _get_broker(request)
180
- result = broker.update_summary(req.peer_id, req.summary)
244
+ result = broker.update_summary(req.peer_id, req.summary,
245
+ profile_id=_active_profile())
181
246
  if not result.get("ok"):
182
247
  raise HTTPException(404, detail=result.get("error", "peer not found"))
183
248
  return result
@@ -189,7 +254,17 @@ async def send(req: SendRequest, request: Request):
189
254
  to_target = req.to_peer or req.to # v3.4.6: accept both field names
190
255
  if not to_target:
191
256
  raise HTTPException(400, detail="'to' or 'to_peer' required")
192
- result = broker.send_message(req.from_peer, to_target, req.content, req.type)
257
+ # Resolve the tenant on the event loop (the request ContextVar is set here);
258
+ # a worker thread would not inherit it.
259
+ profile = _active_profile()
260
+ # send_message may make a blocking httpx call (up to 10s) when delivering
261
+ # to a remote peer. Offload to a worker thread so a slow/dead peer network
262
+ # never stalls the daemon event loop for all other users. The broker opens
263
+ # a fresh SQLite connection per call, so this is thread-safe.
264
+ result = await asyncio.to_thread(
265
+ broker.send_message, req.from_peer, to_target, req.content,
266
+ req.type, "", profile,
267
+ )
193
268
  if not result.get("ok"):
194
269
  status = 413 if "too large" in result.get("error", "") else 404
195
270
  raise HTTPException(status, detail=result.get("error", ""))
@@ -199,27 +274,30 @@ async def send(req: SendRequest, request: Request):
199
274
  @router.get("/inbox/{peer_id}")
200
275
  async def inbox(peer_id: str, request: Request, project_path: str = ""):
201
276
  broker = _get_broker(request)
202
- return {"messages": broker.get_inbox(peer_id, project_path)}
277
+ return {"messages": broker.get_inbox(peer_id, project_path,
278
+ profile_id=_active_profile())}
203
279
 
204
280
 
205
281
  @router.post("/inbox/{peer_id}/read")
206
282
  async def mark_read(peer_id: str, req: ReadRequest, request: Request):
207
283
  broker = _get_broker(request)
208
- return broker.mark_read(peer_id, req.message_ids)
284
+ return broker.mark_read(peer_id, req.message_ids,
285
+ profile_id=_active_profile())
209
286
 
210
287
 
211
288
  @router.get("/pending/{peer_id}")
212
289
  async def pending(peer_id: str, request: Request, project_path: str = ""):
213
290
  """Get pending broadcast/project messages for this peer."""
214
291
  broker = _get_broker(request)
215
- messages = broker.get_pending(peer_id, project_path)
292
+ messages = broker.get_pending(peer_id, project_path,
293
+ profile_id=_active_profile())
216
294
  return {"messages": messages, "count": len(messages)}
217
295
 
218
296
 
219
297
  @router.get("/state")
220
298
  async def state_all(request: Request):
221
299
  broker = _get_broker(request)
222
- return {"state": broker.get_state()}
300
+ return {"state": broker.get_state(profile_id=_active_profile())}
223
301
 
224
302
 
225
303
  @router.post("/state")
@@ -228,13 +306,14 @@ async def state_set(req: StateSetRequest, request: Request):
228
306
  if not req.key:
229
307
  raise HTTPException(400, detail="key required")
230
308
  _reject_secret_state(req.key, req.value)
231
- return broker.set_state(req.key, req.value, req.set_by)
309
+ return broker.set_state(req.key, req.value, req.set_by,
310
+ profile_id=_active_profile())
232
311
 
233
312
 
234
313
  @router.get("/state/{key}")
235
314
  async def state_get(key: str, request: Request):
236
315
  broker = _get_broker(request)
237
- result = broker.get_state_key(key)
316
+ result = broker.get_state_key(key, profile_id=_active_profile())
238
317
  if result is None:
239
318
  raise HTTPException(404, detail="key not found")
240
319
  return result
@@ -247,16 +326,17 @@ async def lock(req: LockRequest, request: Request):
247
326
  raise HTTPException(400, detail="file_path and locked_by required")
248
327
  if req.action not in ("acquire", "release", "query"):
249
328
  raise HTTPException(400, detail="action must be acquire, release, or query")
250
- return broker.lock_action(req.file_path, req.locked_by, req.action)
329
+ return broker.lock_action(req.file_path, req.locked_by, req.action,
330
+ profile_id=_active_profile())
251
331
 
252
332
 
253
333
  @router.get("/events")
254
334
  async def events(request: Request):
255
335
  broker = _get_broker(request)
256
- return {"events": broker.get_events()}
336
+ return {"events": broker.get_events(profile_id=_active_profile())}
257
337
 
258
338
 
259
339
  @router.get("/status")
260
340
  async def status(request: Request):
261
341
  broker = _get_broker(request)
262
- return broker.get_status()
342
+ return broker.get_status(profile_id=_active_profile())
@@ -71,6 +71,36 @@ async def put_config(body: ConfigUpdateRequest) -> dict[str, Any]:
71
71
  raise HTTPException(status_code=500, detail=str(exc))
72
72
 
73
73
 
74
+ @router.get("/status")
75
+ async def get_status() -> dict[str, Any]:
76
+ """Concise enabled/disabled summary of each optimize sub-feature (M3).
77
+
78
+ REST-convention companion to `slm optimize status` (which reads config
79
+ locally). Returns a per-feature on/off view, the compression mode, and a
80
+ top-level `healthy` flag so external pollers have a stable endpoint.
81
+ """
82
+ try:
83
+ from superlocalmemory.optimize.config import get_shared_store
84
+ cfg = get_shared_store().get()
85
+ return {
86
+ "healthy": True,
87
+ "enabled": cfg.enabled,
88
+ "features": {
89
+ "proxy": cfg.proxy_enabled,
90
+ "cache": cfg.cache_enabled,
91
+ "compression": cfg.compress_enabled,
92
+ "semantic_cache": cfg.semantic_enabled,
93
+ },
94
+ "compress_mode": cfg.compress_mode,
95
+ "config_version": cfg.config_version,
96
+ }
97
+ except Exception as exc:
98
+ logger.warning("GET /api/optimize/status failed: %s", exc)
99
+ # Fail-open: report unhealthy rather than 500 so a poller can
100
+ # distinguish "daemon up but optimize degraded" from "daemon down".
101
+ return {"healthy": False, "error": "internal error"}
102
+
103
+
74
104
  @router.get("/savings")
75
105
  async def get_savings() -> dict[str, Any]:
76
106
  """Return savings estimates from live metrics + CacheDB.
@@ -91,7 +121,9 @@ async def get_savings() -> dict[str, Any]:
91
121
  )
92
122
 
93
123
  # F-03 fix: use module-level singleton instead of per-request instantiation
94
- active_model = getattr(cfg, "active_model", "anthropic")
124
+ # DASH-V6 fix: attribute may EXIST but be None; getattr default only
125
+ # covers the missing case, so coerce None -> default to avoid .lower() crash.
126
+ active_model = getattr(cfg, "active_model", None) or "anthropic"
95
127
  # Determine provider from model
96
128
  provider = "anthropic"
97
129
  for key in ("anthropic", "openai", "gemini"):