create-workframe 0.1.11 → 0.1.13

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 (195) 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 +85 -0
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +9 -0
  8. package/scripts/verify-public-deploy.sh +121 -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/openrouter_catalog.py +4 -4
  51. package/workframe-api/package.json +2 -2
  52. package/workframe-api/profile_api_lifecycle.py +66 -0
  53. package/workframe-api/profile_config_yaml.py +126 -0
  54. package/workframe-api/profile_gateway.py +436 -0
  55. package/workframe-api/provider_bindings.py +673 -0
  56. package/workframe-api/provider_bootstrap.py +347 -0
  57. package/workframe-api/provider_catalog.py +180 -0
  58. package/workframe-api/rooms.py +1613 -0
  59. package/workframe-api/route_registry.py +470 -0
  60. package/workframe-api/run-typecheck.mjs +26 -0
  61. package/workframe-api/run_authority.py +287 -0
  62. package/workframe-api/run_ledger.py +470 -0
  63. package/workframe-api/run_surface_wiring.py +344 -0
  64. package/workframe-api/runtime_cohort.py +752 -0
  65. package/workframe-api/runtime_tokens.py +127 -0
  66. package/workframe-api/server.py +1376 -19305
  67. package/workframe-api/snapshot_feed.py +49 -0
  68. package/workframe-api/stack_config.py +33 -1
  69. package/workframe-api/supervisor_client.py +154 -0
  70. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  71. package/workframe-api/test_cell_authority.py +79 -0
  72. package/workframe-api/test_chat_bind.py +48 -0
  73. package/workframe-api/test_credential_lease_matrix.py +171 -0
  74. package/workframe-api/test_credential_resolve.py +51 -0
  75. package/workframe-api/test_credential_store.py +27 -0
  76. package/workframe-api/test_dogfood_flows.py +346 -0
  77. package/workframe-api/test_domain_entities.py +152 -0
  78. package/workframe-api/test_exception_hygiene.py +12 -0
  79. package/workframe-api/test_hermes_admin.py +72 -0
  80. package/workframe-api/test_install_wizard_resume.py +78 -0
  81. package/workframe-api/test_local_bootstrap.py +67 -0
  82. package/workframe-api/test_mention_helpers.py +35 -0
  83. package/workframe-api/test_mention_invoke.py +26 -0
  84. package/workframe-api/test_model_surface_consistency.py +117 -0
  85. package/workframe-api/test_oauth_pending.py +41 -0
  86. package/workframe-api/test_profile_model_yaml.py +99 -0
  87. package/workframe-api/test_provider_bindings.py +52 -0
  88. package/workframe-api/test_provider_catalog.py +28 -0
  89. package/workframe-api/test_public_routes.py +41 -0
  90. package/workframe-api/test_route_registry.py +245 -0
  91. package/workframe-api/test_run_authority.py +112 -0
  92. package/workframe-api/test_run_ledger.py +157 -0
  93. package/workframe-api/test_run_surface_wiring.py +130 -0
  94. package/workframe-api/test_runtime_cohort.py +85 -0
  95. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  96. package/workframe-api/test_server_reexports.py +99 -0
  97. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  98. package/workframe-api/time-bind-steps.py +75 -0
  99. package/workframe-api/time-perf.py +81 -0
  100. package/workframe-api/time-warm.py +107 -0
  101. package/workframe-api/turn_credentials.py +28 -13
  102. package/workframe-api/turn_overlay.py +715 -0
  103. package/workframe-api/updates.py +16 -1
  104. package/workframe-api/user_prefs.py +387 -0
  105. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  106. package/workframe-api/workspace_bootstrap.py +536 -0
  107. package/workframe-api/workspace_files.py +344 -0
  108. package/workframe-api/workspace_messaging.py +206 -0
  109. package/workframe-api/zk_auth.py +3 -2
  110. package/workframe-supervisor/server.py +10 -1
  111. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  112. package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
  113. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  114. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  115. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  116. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  117. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  118. package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  120. package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  123. package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  124. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  127. package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  128. package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  129. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  130. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  131. package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  132. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  133. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  134. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  135. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  136. package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  137. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  138. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  139. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  140. package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  141. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  142. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  143. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  144. package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  145. package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  146. package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
  147. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  148. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  149. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  150. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  151. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  152. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  153. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  154. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  155. package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  156. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  157. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  158. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  159. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  160. package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
  161. package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
  162. package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  163. package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
  164. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  165. package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  166. package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  167. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  168. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  169. package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  170. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  171. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  172. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  173. package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
  174. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  175. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  176. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  177. package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  178. package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  179. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  180. package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  181. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  182. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  183. package/workframe-ui/public/index.html +7 -6
  184. package/workframe-ui/public/workframe-build.json +5 -0
  185. package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
  186. package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
  187. package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
  188. package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
  189. package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
  190. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
  191. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
  192. package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
  193. package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
  194. package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
  195. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
@@ -0,0 +1,509 @@
1
+ """WF-032 extract: Hermes session info, chat bootstrap, and message turns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ import time
9
+ import urllib.parse
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import api_meta
14
+ import provider_catalog
15
+
16
+
17
+ def _srv():
18
+ import server as srv
19
+
20
+ return srv
21
+
22
+
23
+ _session_info_cache: dict[str, tuple[dict[str, Any], float]] = {}
24
+ _SESSION_INFO_TTL_SEC = 5.0
25
+
26
+
27
+ def _invalidate_session_info_cache(profile: str = "", session_id: str = "") -> None:
28
+ if profile and session_id:
29
+ _session_info_cache.pop(f"{_srv().safe_profile_slug(profile)}:{session_id}", None)
30
+ elif profile:
31
+ prefix = f"{_srv().safe_profile_slug(profile)}:"
32
+ for key in list(_session_info_cache):
33
+ if key.startswith(prefix):
34
+ _session_info_cache.pop(key, None)
35
+ else:
36
+ _session_info_cache.clear()
37
+
38
+ _USER_IMAGE_RE = re.compile(
39
+ r"\[User attached image:\s*([^\]]+)\]|📎\s*Attached image:\s*(\S+)",
40
+ re.IGNORECASE,
41
+ )
42
+
43
+
44
+ def _parse_multimodal_content(content: str) -> list[dict[str, Any]]:
45
+ text = (content or "").strip()
46
+ if not text.startswith("["):
47
+ return []
48
+ try:
49
+ data = json.loads(text)
50
+ except json.JSONDecodeError:
51
+ return []
52
+ if not isinstance(data, list):
53
+ return []
54
+ segments: list[dict[str, Any]] = []
55
+ for part in data:
56
+ if not isinstance(part, dict):
57
+ continue
58
+ kind = str(part.get("type") or "")
59
+ if kind in {"image_url", "input_image", "image"}:
60
+ url = ""
61
+ if isinstance(part.get("image_url"), dict):
62
+ url = str(part["image_url"].get("url") or "")
63
+ elif isinstance(part.get("image_url"), str):
64
+ url = part["image_url"]
65
+ path = str(part.get("path") or url or "image")
66
+ segments.append({"kind": "image", "path": path, "name": Path(path).name})
67
+ elif kind in {"text", "input_text", "output_text"}:
68
+ body = str(part.get("text") or part.get("content") or "").strip()
69
+ if body:
70
+ segments.append({"kind": "text", "text": body})
71
+ return segments
72
+
73
+
74
+ def _parse_user_content_segments(content: str) -> list[dict[str, Any]]:
75
+ text = (content or "").strip()
76
+ if not text:
77
+ return []
78
+ multimodal = _parse_multimodal_content(text)
79
+ if multimodal:
80
+ return multimodal
81
+ segments: list[dict[str, Any]] = []
82
+ remainder = text
83
+ for match in _USER_IMAGE_RE.finditer(text):
84
+ name = (match.group(1) or match.group(2) or "").strip()
85
+ if name:
86
+ segments.append({"kind": "image", "path": name, "name": Path(name).name})
87
+ remainder = remainder.replace(match.group(0), " ").strip()
88
+ if remainder:
89
+ segments.append({"kind": "text", "text": remainder})
90
+ elif not segments:
91
+ segments.append({"kind": "text", "text": text})
92
+ return segments
93
+
94
+
95
+ def _parse_tool_content(content: str) -> dict[str, Any]:
96
+ """Parse Hermes tool row JSON when present."""
97
+ text = (content or "").strip()
98
+ if not text:
99
+ return {"output": "", "exit_code": None, "error": None}
100
+ try:
101
+ data = json.loads(text)
102
+ if isinstance(data, dict):
103
+ output = data.get("output")
104
+ if output is None:
105
+ output = data.get("result")
106
+ if output is None and "files" in data:
107
+ output = json.dumps(data, ensure_ascii=False)
108
+ elif output is None:
109
+ output = text
110
+ return {
111
+ "output": str(output or ""),
112
+ "exit_code": data.get("exit_code"),
113
+ "error": data.get("error"),
114
+ }
115
+ except json.JSONDecodeError:
116
+ pass
117
+ return {"output": text, "exit_code": None, "error": None}
118
+
119
+
120
+ def _message_row_segments(m: sqlite3.Row) -> list[dict[str, Any]]:
121
+ """Map one state.db messages row to structured UI segments."""
122
+ role = str(m["role"] or "")
123
+ if role == "session_meta":
124
+ return []
125
+
126
+ segments: list[dict[str, Any]] = []
127
+ reasoning = (m["reasoning_content"] or "").strip()
128
+ content = (m["content"] or "").strip()
129
+ tool_name = (m["tool_name"] or "").strip()
130
+
131
+ if reasoning:
132
+ segments.append({"kind": "thinking", "text": reasoning})
133
+
134
+ if role == "tool":
135
+ parsed = _parse_tool_content(content)
136
+ exit_code = parsed.get("exit_code")
137
+ status = "error" if parsed.get("error") or (exit_code not in (None, 0)) else "done"
138
+ segments.append(
139
+ {
140
+ "kind": "tool",
141
+ "name": tool_name or "tool",
142
+ "status": status,
143
+ "output": parsed.get("output") or "",
144
+ }
145
+ )
146
+ elif role == "user":
147
+ segments.extend(_parse_user_content_segments(content))
148
+ elif content:
149
+ segments.append({"kind": "text", "text": content})
150
+
151
+ return segments
152
+
153
+
154
+ def _profile_model(profile: str) -> str:
155
+ """Read configured model from Hermes profile yaml, else latest session model."""
156
+ block = _srv()._read_model_block(profile)
157
+ configured = str(block.get("default") or "").strip()
158
+ if configured:
159
+ return configured
160
+ for name in ("config.yaml", "profile.yaml"):
161
+ path = _srv()._profile_dir(profile) / name
162
+ if not path.is_file():
163
+ continue
164
+ try:
165
+ text = path.read_text(encoding="utf-8", errors="replace")
166
+ except OSError:
167
+ continue
168
+ flat = re.search(r"^[ \t]*model:[ \t]*['\"]?([^'\"#\n]+)['\"]?[ \t]*(?:#.*)?$", text, re.MULTILINE)
169
+ if flat:
170
+ value = flat.group(1).strip()
171
+ if value and not value.endswith(":"):
172
+ return value
173
+
174
+ db = _srv()._profile_dir(profile) / "state.db"
175
+ conn = _srv()._ro_sqlite_live(db)
176
+ if not conn:
177
+ return ""
178
+ try:
179
+ row = conn.execute(
180
+ "SELECT model FROM sessions WHERE model IS NOT NULL AND model != '' ORDER BY started_at DESC LIMIT 1"
181
+ ).fetchone()
182
+ if not row:
183
+ return ""
184
+ model = str(row["model"] or "").strip()
185
+ if model.startswith("default:"):
186
+ model = model.split(":", 1)[1].strip()
187
+ return model
188
+ except sqlite3.Error:
189
+ return ""
190
+ finally:
191
+ conn.close()
192
+
193
+
194
+ def _llm_attribution_for_profile(profile: str, session_model: str = "") -> tuple[str, str]:
195
+ """Resolve model id + billing provider for chat turn footers."""
196
+ _ = session_model # ponytail: Hermes sessions.model is profile slug, not LLM id
197
+ try:
198
+ prof = _srv().resolve_hermes_profile(str(profile or "").strip())
199
+ except ValueError:
200
+ return "", ""
201
+ block = _srv()._read_model_block(prof)
202
+ model = str(block.get("default") or "").strip()
203
+ billing = _srv()._billing_provider_id_from_hermes_config(str(block.get("provider") or ""))
204
+ if not billing:
205
+ billing = _srv()._llm_billing_provider(prof)
206
+ if not billing and model:
207
+ infer_llm = {
208
+ str(spec["id"]).lower()
209
+ for spec in provider_catalog.PROVIDER_CONNECT_CATALOG
210
+ if str(spec.get("category") or "") == "llm"
211
+ }
212
+ billing = _srv()._resolve_billing_provider_for_model(model, infer_llm)
213
+ return model, str(billing or "").strip()
214
+
215
+
216
+ def _latest_session_id(profile: str) -> str:
217
+ """Most recent session — prefer active (ended_at IS NULL), then by started_at."""
218
+ db = _srv()._profile_dir(profile) / "state.db"
219
+ conn = _srv()._ro_sqlite_live(db)
220
+ if not conn:
221
+ return ""
222
+ try:
223
+ row = conn.execute(
224
+ """
225
+ SELECT id FROM sessions
226
+ ORDER BY (ended_at IS NULL) DESC, started_at DESC, id DESC
227
+ LIMIT 1
228
+ """
229
+ ).fetchone()
230
+ return str(row["id"]) if row else ""
231
+ except sqlite3.Error:
232
+ return ""
233
+ finally:
234
+ conn.close()
235
+
236
+
237
+ def _session_info(profile: str, session_id: str) -> dict[str, Any]:
238
+ sid = str(session_id or "").strip()
239
+ prof = _srv().safe_profile_slug(str(profile or "").strip())
240
+ cache_key = f"{prof}:{sid}"
241
+ now = time.monotonic()
242
+ cached = _session_info_cache.get(cache_key)
243
+ if cached and now - cached[1] < _SESSION_INFO_TTL_SEC:
244
+ return dict(cached[0])
245
+ empty: dict[str, Any] = {
246
+ "session_id": session_id,
247
+ "title": "",
248
+ "active": False,
249
+ "message_count": 0,
250
+ }
251
+ db = _srv()._profile_dir(prof) / "state.db"
252
+ conn = _srv()._ro_sqlite_live(db)
253
+ if not conn:
254
+ return empty
255
+ try:
256
+ row = conn.execute(
257
+ """
258
+ SELECT id, title, started_at, ended_at, message_count
259
+ FROM sessions WHERE id = ?
260
+ """,
261
+ (session_id,),
262
+ ).fetchone()
263
+ if not row:
264
+ _session_info_cache[cache_key] = (empty, now)
265
+ return empty
266
+ result = {
267
+ "session_id": str(row["id"]),
268
+ "title": (row["title"] or "").strip(),
269
+ "active": row["ended_at"] is None,
270
+ "message_count": int(row["message_count"] or 0),
271
+ "started_at": _srv()._iso_from_unix(row["started_at"]),
272
+ }
273
+ _session_info_cache[cache_key] = (result, now)
274
+ return result
275
+ except sqlite3.Error:
276
+ _session_info_cache[cache_key] = (empty, now)
277
+ return empty
278
+ finally:
279
+ conn.close()
280
+
281
+
282
+ def _is_blank_session_title(title: str) -> bool:
283
+ t = str(title or "").strip()
284
+ return not t or t == "(untitled)"
285
+
286
+
287
+ def _resolved_session_title(hermes_prof: str, session_id: str, fallback: str = "") -> str:
288
+ """Hermes state.db title, else room_sessions / default — not the (untitled) sentinel."""
289
+ info = _session_info(hermes_prof, session_id)
290
+ title = str(info.get("title") or "").strip()
291
+ if not _is_blank_session_title(title):
292
+ return title
293
+ fb = str(fallback or "").strip()
294
+ if fb and not _is_blank_session_title(fb):
295
+ return fb
296
+ template = (
297
+ _srv()._runtime_template_slug(hermes_prof)
298
+ if _srv()._is_runtime_profile_slug(hermes_prof)
299
+ else hermes_prof
300
+ )
301
+ return _srv()._default_session_title(template)
302
+
303
+
304
+ def _ensure_hermes_session_title(profile: str, session_id: str, title: str) -> None:
305
+ """Backfill empty Hermes session titles on bind via profile API (state.db is often gateway-locked)."""
306
+ sid = str(session_id or "").strip()
307
+ want = str(title or "").strip()
308
+ if not sid or _is_blank_session_title(want):
309
+ return
310
+ if not _is_blank_session_title(str(_session_info(profile, sid).get("title") or "")):
311
+ return
312
+ try:
313
+ path = f"/api/sessions/{urllib.parse.quote(sid, safe='')}"
314
+ status, _ = _srv()._profile_api_request(profile, "PATCH", path, {"title": want})
315
+ if status < 300:
316
+ return
317
+ except Exception: # noqa: BLE001
318
+ pass
319
+
320
+
321
+ def _session_info_display(profile: str, session_id: str, fallback: str = "") -> dict[str, Any]:
322
+ """Like _session_info but title is never blank — for API responses that show humans a label."""
323
+ info = _session_info(profile, session_id)
324
+ title = _resolved_session_title(profile, session_id, fallback)
325
+ info["title"] = title if not _is_blank_session_title(title) else "(untitled)"
326
+ return info
327
+
328
+
329
+ def _latest_api_session_id(profile: str) -> str:
330
+ """Most recent api_server session — prefer active, then by started_at."""
331
+ db = _srv()._profile_dir(profile) / "state.db"
332
+ conn = _srv()._ro_sqlite_live(db)
333
+ if not conn:
334
+ return ""
335
+ try:
336
+ row = conn.execute(
337
+ """
338
+ SELECT id FROM sessions
339
+ WHERE source = 'api_server'
340
+ ORDER BY (ended_at IS NULL) DESC, started_at DESC, id DESC
341
+ LIMIT 1
342
+ """
343
+ ).fetchone()
344
+ return str(row["id"]) if row else ""
345
+ except sqlite3.Error:
346
+ return ""
347
+ finally:
348
+ conn.close()
349
+
350
+
351
+ def _latest_active_run_id(profile: str) -> str:
352
+ """Most recent active (ended_at IS NULL) api_server run session (run_ prefixed)."""
353
+ db = _srv()._profile_dir(profile) / "state.db"
354
+ conn = _srv()._ro_sqlite_live(db)
355
+ if not conn:
356
+ return ""
357
+ try:
358
+ row = conn.execute(
359
+ """
360
+ SELECT id FROM sessions
361
+ WHERE source = 'api_server' AND ended_at IS NULL
362
+ AND id LIKE 'run%'
363
+ ORDER BY started_at DESC, id DESC
364
+ LIMIT 1
365
+ """
366
+ ).fetchone()
367
+ return str(row["id"]) if row else ""
368
+ except sqlite3.Error:
369
+ return ""
370
+ finally:
371
+ conn.close()
372
+
373
+ def _session_exists(profile: str, session_id: str) -> bool:
374
+ sid = (session_id or "").strip()
375
+ if not sid:
376
+ return False
377
+ db = _srv()._profile_dir(profile) / "state.db"
378
+ conn = _srv()._ro_sqlite_live(db)
379
+ if not conn:
380
+ return False
381
+ try:
382
+ row = conn.execute("SELECT 1 FROM sessions WHERE id = ?", (sid,)).fetchone()
383
+ return row is not None
384
+ except sqlite3.Error:
385
+ return False
386
+ finally:
387
+ conn.close()
388
+
389
+
390
+ def chat_session(profile: str, session_id: str = "", source_id: str = "ui") -> dict[str, Any]:
391
+ sid = (session_id or "").strip() or _latest_session_id(profile)
392
+ if not sid:
393
+ return {"ok": True, "session_id": "", "title": "", "active": False, "message_count": 0}
394
+ info = _session_info(profile, sid)
395
+ return {"ok": True, **info}
396
+
397
+
398
+ def chat_bootstrap(profile: str, persistent: str = "", source_id: str = "ui") -> dict[str, Any]:
399
+ """Resolve native profile + latest/persistent Hermes session ids + dashboard WS bootstrap."""
400
+ prof = (profile or "").strip() or _srv()._primary_profile()
401
+ latest_id = _latest_session_id(prof)
402
+ persistent_id = (persistent or "").strip()
403
+ persistent_valid = persistent_id and _session_exists(prof, persistent_id)
404
+
405
+ active_id = persistent_id if persistent_valid else ""
406
+ latest_info = _session_info(prof, latest_id) if latest_id else {}
407
+ persistent_info = _session_info(prof, persistent_id) if persistent_valid else {}
408
+ active_info = _session_info(prof, active_id) if active_id else {}
409
+
410
+ try:
411
+ hermes = api_meta.hermes_bootstrap(prof)
412
+ except ValueError as exc:
413
+ hermes = {"ok": False, "error": str(exc)}
414
+
415
+ display = _srv()._profile_display_name(prof) if prof else _srv()._native_display_name()
416
+ return {
417
+ "ok": True,
418
+ "profile": prof,
419
+ "native_agent_name": display,
420
+ "latest_session_id": latest_id,
421
+ "latest_session": latest_info,
422
+ "persistent_session_id": persistent_id if persistent_valid else "",
423
+ "persistent_session": persistent_info,
424
+ "active_session_id": active_id,
425
+ "active_session": active_info,
426
+ "hermes": hermes,
427
+ }
428
+
429
+
430
+ def chat_messages(profile: str, session_id: str = "", source_id: str = "ui") -> dict[str, Any]:
431
+ """Recent chat turns for one Hermes session — grouped agent turns with segments."""
432
+ sid = (session_id or "").strip() or _latest_session_id(profile)
433
+ session = _session_info(profile, sid) if sid else {}
434
+ db = _srv()._profile_dir(profile) / "state.db"
435
+ conn = _srv()._ro_sqlite_live(db)
436
+ if not conn or not sid:
437
+ return {"ok": True, "session_id": sid, "session": session, "messages": []}
438
+ turns: list[dict[str, Any]] = []
439
+ current: dict[str, Any] | None = None
440
+ author_profile = (
441
+ _srv()._runtime_template_slug(profile) if _srv()._is_runtime_profile_slug(profile) else profile
442
+ )
443
+ display = _srv()._profile_display_name(profile)
444
+ attr_model, attr_provider = _llm_attribution_for_profile(profile)
445
+ try:
446
+ raw = conn.execute(
447
+ """
448
+ SELECT m.id, m.role, m.content, m.reasoning_content, m.tool_name,
449
+ m.timestamp, m.token_count, s.model AS session_model
450
+ FROM messages m
451
+ LEFT JOIN sessions s ON s.id = m.session_id
452
+ WHERE m.role IN ('user', 'assistant', 'tool') AND m.session_id = ?
453
+ ORDER BY m.timestamp ASC, m.id ASC
454
+ LIMIT 200
455
+ """,
456
+ (sid,),
457
+ ).fetchall()
458
+ for m in raw:
459
+ role = str(m["role"] or "")
460
+ segments = _message_row_segments(m)
461
+ if not segments:
462
+ continue
463
+ tokens = int(m["token_count"] or 0)
464
+ ts = _srv()._iso_from_unix(m["timestamp"])
465
+
466
+ if role == "user":
467
+ if current:
468
+ turns.append(current)
469
+ current = None
470
+ turns.append(
471
+ {
472
+ "id": f"msg-{m['id']}",
473
+ "authorId": "user",
474
+ "authorName": "You",
475
+ "role": "user",
476
+ "segments": segments,
477
+ "timestamp": ts,
478
+ "tokens": tokens,
479
+ }
480
+ )
481
+ continue
482
+
483
+ if current and current["role"] == "agent":
484
+ current["segments"].extend(segments)
485
+ current["tokens"] += tokens
486
+ if attr_model:
487
+ current["model"] = attr_model
488
+ current["llm_provider"] = attr_provider
489
+ else:
490
+ if current:
491
+ turns.append(current)
492
+ current = {
493
+ "id": f"turn-{m['id']}",
494
+ "authorId": author_profile,
495
+ "authorName": display,
496
+ "role": "agent",
497
+ "segments": list(segments),
498
+ "timestamp": ts,
499
+ "tokens": tokens,
500
+ "model": attr_model,
501
+ "llm_provider": attr_provider,
502
+ }
503
+ if current:
504
+ turns.append(current)
505
+ except sqlite3.Error:
506
+ return {"ok": True, "session_id": sid, "session": session, "messages": []}
507
+ finally:
508
+ conn.close()
509
+ return {"ok": True, "session_id": sid, "session": session, "messages": turns}