create-workframe 0.1.12 → 0.1.14

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 (188) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +2 -2
  3. package/bin/workframe.js +130 -2
  4. package/docs/security.md +2 -2
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +1 -0
  8. package/scripts/verify-public-deploy.sh +65 -3
  9. package/workframe-api/action_proxy.py +23 -17
  10. package/workframe-api/activity_feed.py +798 -0
  11. package/workframe-api/api_meta.py +157 -0
  12. package/workframe-api/auth_gate.py +566 -0
  13. package/workframe-api/avatar_registry.py +343 -0
  14. package/workframe-api/broker_audit.py +164 -0
  15. package/workframe-api/cell_authority.py +231 -0
  16. package/workframe-api/chat_bind.py +319 -0
  17. package/workframe-api/chat_sessions.py +509 -0
  18. package/workframe-api/chat_stream.py +694 -0
  19. package/workframe-api/cleanup_dogfood_smoke.py +252 -0
  20. package/workframe-api/credential_broker.py +162 -0
  21. package/workframe-api/credential_resolve.py +202 -0
  22. package/workframe-api/credential_store.py +245 -0
  23. package/workframe-api/crew_registry.py +250 -0
  24. package/workframe-api/db_schema.py +755 -0
  25. package/workframe-api/docker_gateway.py +166 -0
  26. package/workframe-api/doctor_runtime.py +109 -0
  27. package/workframe-api/domain/__init__.py +47 -0
  28. package/workframe-api/domain/entities.py +252 -0
  29. package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
  30. package/workframe-api/egress_policy.py +42 -0
  31. package/workframe-api/handler_modules/__init__.py +17 -0
  32. package/workframe-api/handler_modules/handler_admin.py +542 -0
  33. package/workframe-api/handler_modules/handler_auth.py +512 -0
  34. package/workframe-api/handler_modules/handler_chat.py +641 -0
  35. package/workframe-api/handler_modules/handler_install.py +178 -0
  36. package/workframe-api/handler_modules/handler_provider.py +325 -0
  37. package/workframe-api/handler_modules/handler_workspace.py +1420 -0
  38. package/workframe-api/health_monitor.py +80 -0
  39. package/workframe-api/hermes_admin.py +756 -0
  40. package/workframe-api/hermes_profiles.py +1328 -0
  41. package/workframe-api/install_api.py +123 -0
  42. package/workframe-api/kanban_cron.py +473 -0
  43. package/workframe-api/lane_bindings.py +423 -0
  44. package/workframe-api/llm_proxy.py +17 -43
  45. package/workframe-api/mention_helpers.py +180 -0
  46. package/workframe-api/mention_invoke.py +431 -0
  47. package/workframe-api/model_surface.py +1139 -0
  48. package/workframe-api/oauth_pending.py +85 -0
  49. package/workframe-api/oauth_redirect.py +381 -0
  50. package/workframe-api/package.json +2 -2
  51. package/workframe-api/profile_api_lifecycle.py +66 -0
  52. package/workframe-api/profile_gateway.py +436 -0
  53. package/workframe-api/provider_bindings.py +673 -0
  54. package/workframe-api/provider_bootstrap.py +347 -0
  55. package/workframe-api/provider_catalog.py +180 -0
  56. package/workframe-api/rooms.py +1613 -0
  57. package/workframe-api/route_registry.py +331 -191
  58. package/workframe-api/run-typecheck.mjs +2 -1
  59. package/workframe-api/run_authority.py +287 -0
  60. package/workframe-api/run_ledger.py +470 -0
  61. package/workframe-api/run_surface_wiring.py +344 -0
  62. package/workframe-api/runtime_cohort.py +752 -0
  63. package/workframe-api/runtime_tokens.py +127 -0
  64. package/workframe-api/server.py +1453 -19513
  65. package/workframe-api/snapshot_feed.py +49 -0
  66. package/workframe-api/stack_config.py +33 -1
  67. package/workframe-api/supervisor_client.py +154 -0
  68. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  69. package/workframe-api/test_cell_authority.py +79 -0
  70. package/workframe-api/test_chat_bind.py +48 -0
  71. package/workframe-api/test_credential_lease_matrix.py +171 -0
  72. package/workframe-api/test_credential_resolve.py +51 -0
  73. package/workframe-api/test_credential_store.py +27 -0
  74. package/workframe-api/test_dogfood_flows.py +346 -0
  75. package/workframe-api/test_domain_entities.py +152 -0
  76. package/workframe-api/test_exception_hygiene.py +12 -0
  77. package/workframe-api/test_hermes_admin.py +72 -0
  78. package/workframe-api/test_install_wizard_resume.py +78 -0
  79. package/workframe-api/test_local_bootstrap.py +67 -0
  80. package/workframe-api/test_mention_helpers.py +35 -0
  81. package/workframe-api/test_mention_invoke.py +26 -0
  82. package/workframe-api/test_model_surface_consistency.py +1 -0
  83. package/workframe-api/test_oauth_pending.py +41 -0
  84. package/workframe-api/test_provider_bindings.py +52 -0
  85. package/workframe-api/test_provider_catalog.py +28 -0
  86. package/workframe-api/test_route_registry.py +171 -35
  87. package/workframe-api/test_run_authority.py +112 -0
  88. package/workframe-api/test_run_ledger.py +157 -0
  89. package/workframe-api/test_run_surface_wiring.py +130 -0
  90. package/workframe-api/test_runtime_cohort.py +85 -0
  91. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  92. package/workframe-api/test_server_reexports.py +99 -0
  93. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  94. package/workframe-api/turn_credentials.py +28 -13
  95. package/workframe-api/turn_overlay.py +715 -0
  96. package/workframe-api/updates.py +16 -1
  97. package/workframe-api/user_prefs.py +387 -0
  98. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  99. package/workframe-api/workspace_bootstrap.py +536 -0
  100. package/workframe-api/workspace_files.py +344 -0
  101. package/workframe-api/workspace_messaging.py +206 -0
  102. package/workframe-api/zk_auth.py +3 -2
  103. package/workframe-supervisor/server.py +10 -1
  104. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  105. package/workframe-ui/public/assets/{arc-B0OFRGmJ.js → arc-BT9iZUSd.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-CaEDd3np.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-DHJhMrIS.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-Cdyz9hIV.js} +1 -1
  110. package/workframe-ui/public/assets/channel-3M69ekE5.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-BbZx76LX.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DcSxjTIP.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-B7F-kTY1.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-BwwEiRTK.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-DAi_NksX.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-BIaF4XCN.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-DjYSk1dG.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-_twnyfgV.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CkNmdy5s.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CjLqd-gl.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-_6oNqFjj.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-CeO9Am_D.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-sRM4YeIe.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-CH8tMb21.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-DAI1bsII.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-B2e-5a2G.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-DM2-0q37.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-BnbbewLv.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-C4zmB1Vt.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-n-BJmsiC.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-BzQGQnZi.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-Cx_De-wS.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-CIOI3Lok.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-DkRuPLWY.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-XhCT1Q-V.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-C3GwhDU1.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-CLWH8z2e.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D4h66Vy3.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-IW5F-Q6c.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-BjNIbmRM.js} +1 -1
  146. package/workframe-ui/public/assets/index-DL2vJP4L.css +1 -0
  147. package/workframe-ui/public/assets/index-ElS_D59P.js +130 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CcJspM79.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-DP4xR9jt.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-D4cZb21V.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-DsLuHkIJ.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Wu65nOsR.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-ByBDzRpy.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-DzXi2vnq.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-CaB-1Ckn.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-D7HTHC17.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BfKsC9aj.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-ClpU98TO.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-KeOyY_-u.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-CUpjnzbs.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-CJdPTszF.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-C9BpandZ.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-CHDZPwry.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-CMJJh91s.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-BsV4GppP.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-WHks82Up.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-eSX9P8z0.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Dg1UkhGo.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-B7yGsBuj.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-j_G2oyQ_.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-BRrT0jqC.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-DEdUqez2.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-C-bBK9Bb.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-DhH5xuR2.js} +1 -1
  176. package/workframe-ui/public/index.html +11 -7
  177. package/workframe-ui/public/workframe-build.json +5 -0
  178. package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
  179. package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
  180. package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
  181. package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
  182. package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
  183. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
  184. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
  185. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
  186. package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
  187. package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
  188. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +0 -1
@@ -0,0 +1,798 @@
1
+ """WF-032 extract: activity feed aggregation (Hermes + run ledger)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sqlite3
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+ import run_ledger
10
+
11
+
12
+ ACTIVITY_ROOM_LIMIT = 250
13
+ ACTIVITY_WORKSPACE_LIMIT = 1000
14
+
15
+
16
+ def _srv():
17
+ import server as srv
18
+
19
+ return srv
20
+
21
+
22
+ def _crew_lookup(crew: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
23
+ lookup: dict[str, dict[str, Any]] = {}
24
+ for member in crew:
25
+ for alias in (
26
+ member.get("profile"),
27
+ member.get("key"),
28
+ member.get("display_name"),
29
+ str(member.get("display_name", "")).lower(),
30
+ ):
31
+ if alias:
32
+ lookup[str(alias).lower()] = member
33
+ return lookup
34
+
35
+
36
+ def _resolve_agent(member: dict[str, Any] | None, fallback: str) -> dict[str, str]:
37
+ if member:
38
+ return {
39
+ "agent_name": member["display_name"],
40
+ "profile": member["profile"],
41
+ "key": member["key"],
42
+ }
43
+ slug = fallback.lower()
44
+ return {"agent_name": fallback, "profile": fallback, "key": slug}
45
+
46
+
47
+ def _message_activity(profile: str, crew_lookup: dict[str, Any], limit: int = 80) -> list[dict[str, Any]]:
48
+ """Extract individual tool calls from messages table as atomic activity entries."""
49
+ db = _srv()._profile_dir(profile) / "state.db"
50
+ conn = _srv()._ro_sqlite_live(db)
51
+ if not conn:
52
+ return []
53
+ entries: list[dict[str, Any]] = []
54
+ member = crew_lookup.get(profile.lower()) or crew_lookup.get(_srv()._profile_slug(profile).lower())
55
+ agent = _resolve_agent(member, _srv()._profile_display_name(profile))
56
+
57
+ try:
58
+ open_sessions = {
59
+ str(row["id"])
60
+ for row in conn.execute("SELECT id FROM sessions WHERE ended_at IS NULL").fetchall()
61
+ }
62
+ completed_tools = {
63
+ str(row["tool_call_id"])
64
+ for row in conn.execute(
65
+ """
66
+ SELECT tool_call_id FROM messages
67
+ WHERE role = 'tool' AND tool_call_id IS NOT NULL AND tool_call_id != ''
68
+ """,
69
+ ).fetchall()
70
+ }
71
+
72
+ rows = conn.execute(
73
+ """
74
+ SELECT m.id, m.session_id, m.timestamp, m.token_count, m.content, m.tool_calls,
75
+ m.tool_name, s.model
76
+ FROM messages m
77
+ LEFT JOIN sessions s ON s.id = m.session_id
78
+ WHERE m.role = 'assistant'
79
+ AND m.tool_calls IS NOT NULL
80
+ AND m.tool_calls != '[]'
81
+ ORDER BY m.timestamp DESC
82
+ LIMIT ?
83
+ """,
84
+ (limit * 3,),
85
+ ).fetchall()
86
+
87
+ for r in rows:
88
+ timestamp = r["timestamp"]
89
+ tool_calls_raw = r["tool_calls"]
90
+ session_id = str(r["session_id"] or "")
91
+
92
+ parsed_calls: list[dict[str, Any]] = []
93
+ try:
94
+ parsed_calls = json.loads(tool_calls_raw) if tool_calls_raw else []
95
+ except (json.JSONDecodeError, TypeError):
96
+ continue
97
+
98
+ if not parsed_calls:
99
+ continue
100
+
101
+ model = r["model"] or ""
102
+
103
+ for tc in parsed_calls:
104
+ fn = tc.get("function") or {}
105
+ tool_name = fn.get("name") or tc.get("tool_name") or "unknown"
106
+ raw_args = fn.get("arguments") or tc.get("arguments") or "{}"
107
+
108
+ args: dict[str, Any] = {}
109
+ try:
110
+ args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
111
+ except (json.JSONDecodeError, TypeError):
112
+ pass
113
+
114
+ label = _tool_call_label(tool_name, args)
115
+ call_id = str(tc.get("id") or tc.get("call_id") or "")
116
+ has_result = call_id in completed_tools
117
+ session_open = session_id in open_sessions
118
+ if has_result:
119
+ status = "completed"
120
+ elif session_open:
121
+ status = "active"
122
+ else:
123
+ status = "idle"
124
+ summary = f"running tool: {tool_name}" if status == "active" else f"tool: {tool_name}"
125
+
126
+ entries.append({
127
+ "id": f"tool:{profile}:{call_id or r['id']}:{tool_name}",
128
+ "kind": "tool_call",
129
+ "agent_name": agent["agent_name"],
130
+ "profile": agent["profile"],
131
+ "key": agent["key"],
132
+ "task_description": summary,
133
+ "status": status,
134
+ "model_used": model,
135
+ "created_at": _srv()._iso_from_unix(timestamp),
136
+ "source": "message",
137
+ "tool_call_id": call_id,
138
+ "message_id": r["id"],
139
+ "session_id": session_id,
140
+ "token_count": r["token_count"] or 0,
141
+ "tool_name": tool_name,
142
+ })
143
+
144
+ if len(entries) >= limit:
145
+ break
146
+
147
+ if len(entries) >= limit:
148
+ break
149
+
150
+ except sqlite3.Error:
151
+ pass
152
+ finally:
153
+ conn.close()
154
+ return entries
155
+
156
+
157
+ def _tool_call_label(tool_name: str, args: dict[str, Any]) -> str:
158
+ """Build a short, human-readable label for a tool call."""
159
+ name_lower = tool_name.lower()
160
+
161
+ if name_lower == "read_file":
162
+ path = args.get("path", "") or args.get("file_path", "")
163
+ if path:
164
+ short = str(path).split("/")[-1]
165
+ return f"read {short}"
166
+ return "read_file"
167
+
168
+ if name_lower == "write_file":
169
+ path = args.get("path", "") or args.get("file_path", "")
170
+ if path:
171
+ short = str(path).split("/")[-1]
172
+ return f"write {short}"
173
+ return "write_file"
174
+
175
+ if name_lower == "terminal":
176
+ cmd = args.get("command", "") or args.get("cmd", "")
177
+ if cmd:
178
+ cmd_str = str(cmd).strip()
179
+ if len(cmd_str) > 40:
180
+ return f"terminal: {cmd_str[:37]}…"
181
+ return f"terminal: {cmd_str}"
182
+ return "terminal"
183
+
184
+ if name_lower in ("web_search", "search"):
185
+ query = args.get("query", "")
186
+ if query:
187
+ q = str(query).strip()
188
+ if len(q) > 35:
189
+ return f"search: {q[:32]}…"
190
+ return f"search: {q}"
191
+ return "search"
192
+
193
+ if name_lower == "skill_view":
194
+ skill = args.get("name", "")
195
+ if skill:
196
+ return f"load skill: {skill}"
197
+ return "skill_view"
198
+
199
+ if name_lower == "browser_navigate":
200
+ url = args.get("url", "")
201
+ if url:
202
+ parsed = urlparse(str(url))
203
+ host = parsed.netloc or str(url)
204
+ if len(host) > 30:
205
+ return f"browser: {host[:27]}…"
206
+ return f"browser: {host}"
207
+ return "browser"
208
+
209
+ if name_lower in ("delegate_task", "subagent"):
210
+ goal = args.get("goal", "") or args.get("task", "")
211
+ if goal:
212
+ g = str(goal).strip()
213
+ if len(g) > 35:
214
+ return f"delegate: {g[:32]}…"
215
+ return f"delegate: {g}"
216
+ return "delegate"
217
+
218
+ if name_lower == "memory":
219
+ action = args.get("action", "")
220
+ if action:
221
+ return f"memory: {action}"
222
+ return "memory"
223
+
224
+ # Generic fallback: tool_name + first arg value
225
+ if args:
226
+ first_key = next(iter(args))
227
+ first_val = str(args[first_key])
228
+ if len(first_val) > 30:
229
+ first_val = first_val[:27] + "…"
230
+ return f"{tool_name}: {first_val}"
231
+
232
+ return tool_name
233
+
234
+
235
+ def _activity_default_peer_handle() -> str:
236
+ """Best-effort @handle for activity session subtitles."""
237
+ try:
238
+ conn = sqlite3.connect(str(_srv()._workframe_db_path()), timeout=2.0)
239
+ conn.row_factory = sqlite3.Row
240
+ row = conn.execute(
241
+ "SELECT display_name, email FROM users WHERE deleted_at IS NULL ORDER BY created_at ASC LIMIT 1",
242
+ ).fetchone()
243
+ conn.close()
244
+ if row:
245
+ handle = _srv()._mention_handle(str(row["display_name"] or ""), str(row["email"] or ""))
246
+ if handle:
247
+ return handle
248
+ except (sqlite3.Error, OSError):
249
+ pass
250
+ return "user"
251
+
252
+
253
+ def _session_activity_label(
254
+ conn: sqlite3.Connection,
255
+ profile: str,
256
+ session_id: str,
257
+ title: str,
258
+ msg_count: int,
259
+ model: str,
260
+ ) -> str:
261
+ del conn, profile, session_id, title, msg_count, model # ponytail: uniform label, not message dumps
262
+ return f"chatting with @{_activity_default_peer_handle()}"
263
+
264
+
265
+ def _session_activity(profile: str, crew_lookup: dict[str, dict[str, Any]], limit: int = 50) -> list[dict[str, Any]]:
266
+ db = _srv()._profile_dir(profile) / "state.db"
267
+ conn = _srv()._ro_sqlite_live(db)
268
+ if not conn:
269
+ return []
270
+ entries: list[dict[str, Any]] = []
271
+ member = crew_lookup.get(profile.lower()) or crew_lookup.get(_srv()._profile_slug(profile).lower())
272
+ agent = _resolve_agent(member, _srv()._profile_display_name(profile))
273
+ try:
274
+ rows = conn.execute(
275
+ """
276
+ SELECT id, source, title, started_at, ended_at, message_count, model
277
+ FROM sessions
278
+ ORDER BY started_at DESC
279
+ LIMIT ?
280
+ """,
281
+ (limit,),
282
+ ).fetchall()
283
+ for r in rows:
284
+ session_id = str(r["id"] or "")
285
+ ended = r["ended_at"]
286
+ msg_count = int(r["message_count"] or 0)
287
+ model = str(r["model"] or "").strip()
288
+ title = (r["title"] or "").strip()
289
+ desc = _session_activity_label(conn, profile, session_id, title, msg_count, model)
290
+ if len(desc) > 140:
291
+ desc = desc[:137] + "..."
292
+ status = "completed" if ended else "idle"
293
+ entries.append(
294
+ {
295
+ "id": f"session:{profile}:{session_id}",
296
+ "kind": "session_start",
297
+ "agent_name": agent["agent_name"],
298
+ "profile": agent["profile"],
299
+ "key": agent["key"],
300
+ "task_description": desc,
301
+ "status": status,
302
+ "model_used": model,
303
+ "created_at": _srv()._iso_from_unix(r["started_at"]),
304
+ "source": "session",
305
+ "session_id": session_id,
306
+ "message_count": msg_count,
307
+ }
308
+ )
309
+ except sqlite3.Error:
310
+ pass
311
+ finally:
312
+ conn.close()
313
+ return entries
314
+
315
+
316
+ def _kanban_activity(
317
+ crew_lookup: dict[str, dict[str, Any]],
318
+ limit: int = 50,
319
+ board_slug: str = "default",
320
+ ) -> list[dict[str, Any]]:
321
+ db = _srv()._hermes_kanban_db_path(board_slug)
322
+ conn = _srv()._ro_sqlite_live(db)
323
+ if not conn:
324
+ return []
325
+ entries: list[dict[str, Any]] = []
326
+ try:
327
+ rows = conn.execute(
328
+ """
329
+ SELECT e.kind, e.created_at, t.title, t.assignee, t.status
330
+ FROM task_events e
331
+ JOIN tasks t ON t.id = e.task_id
332
+ ORDER BY e.created_at DESC
333
+ LIMIT ?
334
+ """,
335
+ (limit,),
336
+ ).fetchall()
337
+ for r in rows:
338
+ assignee = str(r["assignee"] or "kanban")
339
+ member = crew_lookup.get(assignee.lower()) or crew_lookup.get(_srv()._profile_slug(assignee).lower())
340
+ agent = _resolve_agent(member, _srv()._profile_display_name(assignee) if assignee != "kanban" else "Kanban")
341
+ desc = f"{r['title'] or 'task'} · {r['kind']}"
342
+ if len(desc) > 140:
343
+ desc = desc[:137] + "..."
344
+ st = str(r["status"] or r["kind"] or "event").lower()
345
+ if st in ("done", "completed"):
346
+ status = "completed"
347
+ elif "fail" in st or st == "blocked":
348
+ status = "failed"
349
+ else:
350
+ status = st
351
+ entries.append(
352
+ {
353
+ "id": f"kanban:{assignee}:{r['created_at']}:{r['kind']}",
354
+ "kind": "kanban_task",
355
+ "agent_name": agent["agent_name"],
356
+ "profile": agent["profile"],
357
+ "key": agent["key"],
358
+ "task_description": desc,
359
+ "status": status,
360
+ "model_used": "",
361
+ "created_at": _srv()._iso_from_unix(r["created_at"]),
362
+ "source": "kanban",
363
+ }
364
+ )
365
+ except sqlite3.Error:
366
+ pass
367
+ finally:
368
+ conn.close()
369
+ return entries
370
+
371
+
372
+ def activity_data(profiles: list[str], crew: list[dict[str, Any]]) -> dict[str, Any]:
373
+ crew_lookup = _crew_lookup(crew)
374
+ merged: list[dict[str, Any]] = []
375
+ for p in profiles:
376
+ merged.extend(_session_activity(p, crew_lookup, 40))
377
+ merged.extend(_message_activity(p, crew_lookup, 60))
378
+ merged.extend(_kanban_activity(crew_lookup, 40))
379
+ merged.sort(key=lambda e: e.get("created_at") or "", reverse=True)
380
+ merged = merged[:80]
381
+
382
+ agents: dict[str, dict[str, Any]] = {}
383
+ for e in merged:
384
+ key = str(e.get("key") or e.get("agent_name") or "unknown").lower()
385
+ member = crew_lookup.get(key)
386
+ a = agents.setdefault(
387
+ key,
388
+ {
389
+ "name": e.get("agent_name") or key,
390
+ "display_name": e.get("agent_name") or key,
391
+ "profile": e.get("profile") or key,
392
+ "key": key,
393
+ "total": 0,
394
+ "completed": 0,
395
+ "failed": 0,
396
+ "last_task": "",
397
+ "last_status": "idle",
398
+ "last_seen": "",
399
+ "model": e.get("model_used") or "",
400
+ "role": member.get("role") if member else "",
401
+ "platform": member.get("platform") if member else "Hermes",
402
+ "color": member.get("color") if member else "",
403
+ },
404
+ )
405
+ a["total"] += 1
406
+ st = str(e.get("status") or "").lower()
407
+ if "fail" in st or st == "blocked":
408
+ a["failed"] += 1
409
+ elif st in ("completed", "done", "active"):
410
+ a["completed"] += 1
411
+ if not a["last_task"]:
412
+ a["last_task"] = e.get("task_description") or ""
413
+ a["last_status"] = e.get("status") or ""
414
+ a["last_seen"] = e.get("created_at") or ""
415
+ if e.get("model_used"):
416
+ a["model"] = e["model_used"]
417
+
418
+ for member in crew:
419
+ key = member["key"]
420
+ agents.setdefault(
421
+ key,
422
+ {
423
+ "name": member["display_name"],
424
+ "display_name": member["display_name"],
425
+ "profile": member["profile"],
426
+ "key": key,
427
+ "total": 0,
428
+ "completed": 0,
429
+ "failed": 0,
430
+ "last_task": "No activity yet",
431
+ "last_status": "idle",
432
+ "last_seen": "",
433
+ "model": "",
434
+ "role": member.get("role") or "",
435
+ "platform": member.get("platform") or "Hermes",
436
+ "color": member.get("color") or "",
437
+ },
438
+ )
439
+
440
+ by_day: dict[str, dict[str, Any]] = {}
441
+ for e in merged:
442
+ day = str(e.get("created_at") or "")[:10]
443
+ if not day:
444
+ continue
445
+ slot = by_day.setdefault(day, {"day": day, "total": 0, "agents": {}})
446
+ slot["total"] += 1
447
+ an = str(e.get("key") or e.get("agent_name") or "unknown").lower()
448
+ slot["agents"][an] = slot["agents"].get(an, 0) + 1
449
+
450
+ total = len(merged)
451
+ completed = sum(
452
+ 1 for e in merged if str(e.get("status", "")).lower() in ("completed", "done", "active")
453
+ )
454
+ failed = sum(
455
+ 1
456
+ for e in merged
457
+ if "fail" in str(e.get("status", "")).lower() or str(e.get("status", "")).lower() == "blocked"
458
+ )
459
+
460
+ return {
461
+ "entries": merged,
462
+ "agents": list(agents.values()),
463
+ "activity_by_day": [by_day[d] for d in sorted(by_day.keys())][-7:],
464
+ "stats": {"total": total, "completed": completed, "failed": failed},
465
+ }
466
+
467
+
468
+ def activity_detail(profile: str, tool_call_id: str, session_id: str, message_id: str) -> dict[str, Any]:
469
+ """Return the full tool call request + result pair for a single activity entry."""
470
+ db = _srv()._profile_dir(profile) / "state.db"
471
+ conn = _srv()._ro_sqlite_live(db)
472
+ if not conn:
473
+ return {"ok": False, "error": "state.db not found"}
474
+
475
+ result: dict[str, Any] = {
476
+ "ok": True,
477
+ "profile": profile,
478
+ "tool_call_id": tool_call_id,
479
+ "session_id": session_id,
480
+ "request": None,
481
+ "response": None,
482
+ "metadata": {},
483
+ }
484
+
485
+ try:
486
+ # 1. Find the assistant message with this tool_call_id in its tool_calls JSON
487
+ assistant_params: list[Any] = [session_id] if session_id else []
488
+ assistant_sql = """
489
+ SELECT m.id, m.timestamp, m.token_count, m.content, m.tool_calls, s.model
490
+ FROM messages m
491
+ LEFT JOIN sessions s ON s.id = m.session_id
492
+ WHERE m.role = 'assistant'
493
+ AND m.tool_calls IS NOT NULL
494
+ AND m.tool_calls != '[]'
495
+ """
496
+ if session_id:
497
+ assistant_sql += " AND m.session_id = ?"
498
+ if tool_call_id:
499
+ assistant_sql += " AND m.tool_calls LIKE ?"
500
+ assistant_params.append(f"%{tool_call_id}%")
501
+ assistant_sql += " ORDER BY m.timestamp DESC LIMIT 100"
502
+ assistant_rows = conn.execute(assistant_sql, tuple(assistant_params)).fetchall()
503
+
504
+ tool_request: dict[str, Any] | None = None
505
+ assistant_msg_id = ""
506
+ assistant_ts = 0.0
507
+ model = ""
508
+
509
+ for r in assistant_rows:
510
+ try:
511
+ calls = json.loads(r["tool_calls"]) if r["tool_calls"] else []
512
+ except (json.JSONDecodeError, TypeError):
513
+ continue
514
+ for tc in calls:
515
+ tc_id = tc.get("id") or tc.get("call_id") or ""
516
+ if tc_id == tool_call_id:
517
+ tool_request = tc
518
+ assistant_msg_id = r["id"]
519
+ assistant_ts = r["timestamp"] or 0.0
520
+ model = r["model"] or ""
521
+ break
522
+ if tool_request:
523
+ break
524
+
525
+ if not tool_request:
526
+ return {"ok": False, "error": "tool_call_id not found in session"}
527
+
528
+ fn = tool_request.get("function") or {}
529
+ tool_name = fn.get("name") or "unknown"
530
+ raw_args = fn.get("arguments") or "{}"
531
+ try:
532
+ args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
533
+ except (json.JSONDecodeError, TypeError):
534
+ args = {}
535
+
536
+ result["request"] = {
537
+ "tool_name": tool_name,
538
+ "arguments": args,
539
+ "message_id": assistant_msg_id,
540
+ "timestamp": _srv()._iso_from_unix(assistant_ts),
541
+ "model": model,
542
+ }
543
+
544
+ # 2. Find the tool result message (role=tool, tool_call_id matches)
545
+ tool_row = conn.execute(
546
+ """
547
+ SELECT id, timestamp, content, token_count
548
+ FROM messages
549
+ WHERE session_id = ?
550
+ AND role = 'tool'
551
+ AND tool_call_id = ?
552
+ LIMIT 1
553
+ """,
554
+ (session_id, tool_call_id),
555
+ ).fetchone()
556
+
557
+ if tool_row:
558
+ raw_content = tool_row["content"] or ""
559
+ # Try to parse as JSON for structured display
560
+ try:
561
+ parsed_content = json.loads(raw_content) if isinstance(raw_content, str) else raw_content
562
+ except (json.JSONDecodeError, TypeError):
563
+ parsed_content = None
564
+
565
+ result["response"] = {
566
+ "content": raw_content,
567
+ "parsed": parsed_content,
568
+ "message_id": tool_row["id"],
569
+ "timestamp": _srv()._iso_from_unix(tool_row["timestamp"]),
570
+ "token_count": tool_row["token_count"] or 0,
571
+ }
572
+ else:
573
+ result["response"] = None
574
+
575
+ # 3. Session metadata
576
+ session_row = conn.execute(
577
+ "SELECT title, model, message_count, started_at, ended_at, tool_call_count, input_tokens, output_tokens FROM sessions WHERE id = ?",
578
+ (session_id,),
579
+ ).fetchone()
580
+
581
+ if session_row:
582
+ result["metadata"] = {
583
+ "session_title": session_row["title"] or "",
584
+ "model": session_row["model"] or model,
585
+ "message_count": session_row["message_count"] or 0,
586
+ "started_at": _srv()._iso_from_unix(session_row["started_at"]),
587
+ "ended_at": _srv()._iso_from_unix(session_row["ended_at"]) if session_row["ended_at"] else None,
588
+ "tool_call_count": session_row["tool_call_count"] or 0,
589
+ "input_tokens": session_row["input_tokens"] or 0,
590
+ "output_tokens": session_row["output_tokens"] or 0,
591
+ }
592
+
593
+ # 4. Run duration (response.timestamp - request.timestamp) — the time the
594
+ # tool call took. Falls back to None if either timestamp is missing.
595
+ result["run_duration_seconds"] = None
596
+ req_ts = result.get("request", {}).get("timestamp") if result.get("request") else None
597
+ resp_ts = result.get("response", {}).get("timestamp") if result.get("response") else None
598
+ if req_ts and resp_ts:
599
+ try:
600
+ from datetime import datetime as _dt
601
+ req_unix = _dt.fromisoformat(req_ts).timestamp()
602
+ resp_unix = _dt.fromisoformat(resp_ts).timestamp()
603
+ result["run_duration_seconds"] = round(max(0.0, resp_unix - req_unix), 3)
604
+ except (TypeError, ValueError):
605
+ pass
606
+
607
+ # 5. Model/provider from the profile's config.yaml. Hermes stores the
608
+ # profile slug in sessions.model, so the BFF prefers the config value
609
+ # when available. Falls back to sessions.model if config is missing.
610
+ provider, config_model = _srv()._read_model_from_config(profile)
611
+ result["model_name"] = config_model or (session_row["model"] if session_row else "") or ""
612
+ result["provider"] = provider
613
+
614
+ except sqlite3.Error as e:
615
+ return {"ok": False, "error": str(e)}
616
+ finally:
617
+ conn.close()
618
+
619
+ return result
620
+
621
+ def _message_activity_for_sessions(
622
+ profile: str,
623
+ crew_lookup: dict[str, Any],
624
+ session_ids: set[str],
625
+ limit: int = 80,
626
+ ) -> list[dict[str, Any]]:
627
+ if not session_ids:
628
+ return []
629
+ entries = _message_activity(profile, crew_lookup, limit * 3)
630
+ return [entry for entry in entries if str(entry.get("session_id") or "") in session_ids][:limit]
631
+
632
+
633
+ def _room_session_activity_label(
634
+ room_name: str,
635
+ title: str,
636
+ msg_count: int,
637
+ status: str,
638
+ ) -> str:
639
+ base = str(title or room_name or "Session").strip()
640
+ if msg_count > 0:
641
+ return f"{base} · {msg_count} message{'s' if msg_count != 1 else ''}"
642
+ if status == "active":
643
+ return f"{base} · active"
644
+ return base
645
+
646
+
647
+ def room_activity_data(
648
+ room_id: str,
649
+ user_id: str,
650
+ *,
651
+ profile_user_id: str | None = None,
652
+ ) -> list[dict[str, Any]]:
653
+ room_id = str(room_id or "").strip()
654
+ user_id = str(user_id or "").strip()
655
+ if not room_id or not user_id:
656
+ return []
657
+ conn = _srv()._workframe_db()
658
+ try:
659
+ room = conn.execute(
660
+ "SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
661
+ (room_id,),
662
+ ).fetchone()
663
+ if not room or not _srv()._user_can_access_room(conn, room_id, user_id):
664
+ return []
665
+ workspace_id = str(room["workspace_id"])
666
+ room_name = str(room["name"] or "Room")
667
+ rows = _srv()._room_session_rows(conn, room_id)
668
+ finally:
669
+ conn.close()
670
+
671
+ crew = _srv().workframe_agents().get("crew") or []
672
+ if not isinstance(crew, list):
673
+ crew = []
674
+ crew_lookup = _crew_lookup(crew)
675
+ merged: list[dict[str, Any]] = []
676
+ session_ids_by_profile: dict[str, set[str]] = {}
677
+ prof_uid = str(profile_user_id or user_id).strip() or user_id
678
+
679
+ for row in rows:
680
+ template_slug = str(row["agent_slug"] or "").strip()
681
+ if not template_slug:
682
+ continue
683
+ sid = str(row["session_id"] or "").strip()
684
+ hermes_prof = _srv()._resolve_chat_hermes_profile(template_slug, prof_uid, room_id, workspace_id)
685
+ if not sid or not _srv()._session_exists(hermes_prof, sid):
686
+ continue
687
+ info = _srv()._session_info(hermes_prof, sid)
688
+ msg_count = int(info.get("message_count") or 0)
689
+ ended = info.get("ended_at")
690
+ row_status = str(row["status"] or "active")
691
+ if row_status == "active" and not ended:
692
+ status = "active"
693
+ elif ended:
694
+ status = "completed"
695
+ else:
696
+ status = "idle"
697
+ member = crew_lookup.get(template_slug.lower()) or crew_lookup.get(_srv()._profile_slug(template_slug).lower())
698
+ agent = _resolve_agent(member, str(row["agent_display_name"] or _srv()._profile_display_name(template_slug)))
699
+ title = _srv()._resolved_session_title(hermes_prof, sid, str(row["title"] or ""))
700
+ desc = _room_session_activity_label(room_name, title, msg_count, status)
701
+ merged.append(
702
+ {
703
+ "id": f"session:{hermes_prof}:{sid}",
704
+ "kind": "session_start",
705
+ "agent_name": agent["agent_name"],
706
+ "profile": hermes_prof,
707
+ "key": agent["key"],
708
+ "task_description": desc,
709
+ "status": status,
710
+ "model_used": str(info.get("model") or ""),
711
+ "created_at": _srv()._iso_from_unix(row["updated_at"] or row["created_at"]),
712
+ "source": "session",
713
+ "session_id": sid,
714
+ "room_session_id": str(row["id"]),
715
+ "message_count": msg_count,
716
+ }
717
+ )
718
+ session_ids_by_profile.setdefault(hermes_prof, set()).add(sid)
719
+
720
+ for prof, sids in session_ids_by_profile.items():
721
+ merged.extend(_message_activity_for_sessions(prof, crew_lookup, sids, ACTIVITY_ROOM_LIMIT))
722
+
723
+ try:
724
+ merged.extend(run_ledger.list_run_events_for_room(room_id, limit=ACTIVITY_ROOM_LIMIT))
725
+ except Exception: # noqa: BLE001
726
+ pass
727
+
728
+ merged.sort(key=lambda e: e.get("created_at") or "", reverse=True)
729
+ return merged[:ACTIVITY_ROOM_LIMIT]
730
+
731
+
732
+ def _room_agent_dm_owner_user_id(conn: sqlite3.Connection, room_id: str) -> str | None:
733
+ row = conn.execute(
734
+ """
735
+ SELECT rm.user_id FROM room_memberships rm
736
+ WHERE rm.room_id = ? AND rm.deleted_at IS NULL AND rm.user_id IS NOT NULL
737
+ ORDER BY rm.joined_at ASC
738
+ LIMIT 1
739
+ """,
740
+ (room_id,),
741
+ ).fetchone()
742
+ if not row or not row["user_id"]:
743
+ return None
744
+ return str(row["user_id"]).strip() or None
745
+
746
+
747
+ def workspace_activity_data(workspace_id: str, user_id: str) -> list[dict[str, Any]]:
748
+ workspace_id = str(workspace_id or "").strip()
749
+ user_id = str(user_id or "").strip()
750
+ if not _srv()._user_is_workspace_member(user_id, workspace_id):
751
+ return []
752
+ conn = _srv()._workframe_db()
753
+ try:
754
+ rooms = conn.execute(
755
+ """
756
+ SELECT id, room_type, agent_profile_id, name
757
+ FROM rooms
758
+ WHERE workspace_id = ? AND deleted_at IS NULL
759
+ """,
760
+ (workspace_id,),
761
+ ).fetchall()
762
+ finally:
763
+ conn.close()
764
+ merged: list[dict[str, Any]] = []
765
+ seen: set[str] = set()
766
+ for room in rooms:
767
+ room_id = str(room["id"])
768
+ profile_user_id = user_id
769
+ if str(room["room_type"] or "") == "direct" and room["agent_profile_id"]:
770
+ conn = _srv()._workframe_db()
771
+ try:
772
+ owner = _room_agent_dm_owner_user_id(conn, room_id)
773
+ finally:
774
+ conn.close()
775
+ if owner:
776
+ profile_user_id = owner
777
+ for entry in room_activity_data(room_id, user_id, profile_user_id=profile_user_id):
778
+ eid = str(entry.get("id") or "")
779
+ if eid and eid in seen:
780
+ continue
781
+ if eid:
782
+ seen.add(eid)
783
+ merged.append(entry)
784
+ board_row = _srv()._workspace_kanban_board_row(workspace_id)
785
+ board_slug = str(board_row["hermes_board_slug"]) if board_row else "default"
786
+ crew = _srv().workframe_agents().get("crew") or []
787
+ if not isinstance(crew, list):
788
+ crew = []
789
+ crew_lookup = _crew_lookup(crew)
790
+ for entry in _kanban_activity(crew_lookup, 40, board_slug=board_slug):
791
+ eid = str(entry.get("id") or "")
792
+ if eid and eid in seen:
793
+ continue
794
+ if eid:
795
+ seen.add(eid)
796
+ merged.append(entry)
797
+ merged.sort(key=lambda e: e.get("created_at") or "", reverse=True)
798
+ return merged[:ACTIVITY_WORKSPACE_LIMIT]