create-workframe 0.1.18 → 0.1.21

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 (136) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +73 -9
  3. package/package.json +1 -1
  4. package/scripts/bundle-workframe-ui.mjs +11 -1
  5. package/workframe-api/action_proxy.py +8 -0
  6. package/workframe-api/chat_sessions.py +37 -6
  7. package/workframe-api/chat_stream.py +16 -0
  8. package/workframe-api/db_schema.py +29 -0
  9. package/workframe-api/handler_modules/handler_admin.py +32 -0
  10. package/workframe-api/handler_modules/handler_auth.py +6 -4
  11. package/workframe-api/handler_modules/handler_chat.py +17 -26
  12. package/workframe-api/handler_modules/handler_provider.py +13 -14
  13. package/workframe-api/handler_modules/handler_workspace.py +60 -2
  14. package/workframe-api/hermes_profiles.py +31 -9
  15. package/workframe-api/llm_proxy.py +18 -0
  16. package/workframe-api/mention_invoke.py +29 -24
  17. package/workframe-api/message_reactions.py +139 -0
  18. package/workframe-api/model_surface.py +329 -113
  19. package/workframe-api/package.json +4 -4
  20. package/workframe-api/profile_gateway.py +14 -6
  21. package/workframe-api/provider_bindings.py +118 -17
  22. package/workframe-api/provider_bootstrap.py +74 -38
  23. package/workframe-api/provider_model_catalog.py +261 -0
  24. package/workframe-api/rooms.py +4 -3
  25. package/workframe-api/route_registry.py +4 -0
  26. package/workframe-api/runtime_cohort.py +118 -23
  27. package/workframe-api/scripts/run-python.mjs +28 -0
  28. package/workframe-api/server.py +44 -12
  29. package/workframe-api/test_agent_identity_authority.py +96 -0
  30. package/workframe-api/test_chat_attribution_history.py +59 -0
  31. package/workframe-api/test_enrich_room_messages.py +43 -2
  32. package/workframe-api/test_llm_proxy_encoding.py +33 -0
  33. package/workframe-api/test_message_reactions.py +151 -0
  34. package/workframe-api/test_model_routing_reconcile.py +122 -0
  35. package/workframe-api/test_model_surface_consistency.py +45 -5
  36. package/workframe-api/test_oauth_llm.py +1 -1
  37. package/workframe-api/test_profile_gateway_health.py +37 -0
  38. package/workframe-api/test_provider_bindings.py +115 -1
  39. package/workframe-api/test_provider_model_catalog.py +27 -0
  40. package/workframe-api/test_provider_model_surface.py +2 -2
  41. package/workframe-api/test_runtime_lease_lifecycle.py +93 -0
  42. package/workframe-api/test_server_reexports.py +2 -0
  43. package/workframe-api/test_user_llm_prefs.py +27 -0
  44. package/workframe-api/test_user_theme_prefs.py +65 -0
  45. package/workframe-api/test_workspace_file_actions.py +119 -0
  46. package/workframe-api/turn_credentials.py +41 -0
  47. package/workframe-api/turn_overlay.py +95 -34
  48. package/workframe-api/user_prefs.py +22 -1
  49. package/workframe-api/workspace_bootstrap.py +1 -0
  50. package/workframe-api/workspace_files.py +124 -0
  51. package/workframe-api/zk_auth.py +7 -1
  52. package/workframe-ui/public/assets/{arc-CSddOtMa.js → arc-D_gXvZHy.js} +1 -1
  53. package/workframe-ui/public/assets/architecture-7EHR7CIX-B8ZI_Iqb.js +1 -0
  54. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-BSiUei3D.js → architectureDiagram-3BPJPVTR-2F65XJkk.js} +1 -1
  55. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BhKTULPo.js → blockDiagram-GPEHLZMM-BoURuzr5.js} +1 -1
  56. package/workframe-ui/public/assets/{brave-B885JYnH.svg → brave-BoYMbZi1.svg} +0 -1
  57. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Bv_xZi-a.js → c4Diagram-AAUBKEIU-BBtveYSm.js} +1 -1
  58. package/workframe-ui/public/assets/channel-Ca1FvZ7w.js +1 -0
  59. package/workframe-ui/public/assets/{chunk-2J33WTMH-Bh0WiqFE.js → chunk-2J33WTMH-BCuhFg2u.js} +1 -1
  60. package/workframe-ui/public/assets/{chunk-3OPIFGDE-qN09pRuK.js → chunk-3OPIFGDE-gM6Q-rjJ.js} +1 -1
  61. package/workframe-ui/public/assets/{chunk-4BX2VUAB-Dz6FVuMa.js → chunk-4BX2VUAB-D11CJMfF.js} +1 -1
  62. package/workframe-ui/public/assets/{chunk-55IACEB6-DWtPHVfR.js → chunk-55IACEB6-tOvPBzSf.js} +1 -1
  63. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-CyOUeDAo.js → chunk-5ZQYHXKU-Bb1uF7QP.js} +1 -1
  64. package/workframe-ui/public/assets/{chunk-727SXJPM-BrICvWwx.js → chunk-727SXJPM-BvzzLkGB.js} +1 -1
  65. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CQu2SmLM.js → chunk-AQP2D5EJ-tr5A2PUD.js} +1 -1
  66. package/workframe-ui/public/assets/{chunk-BSJP7CBP-uJbYXNNG.js → chunk-BSJP7CBP-BSFEsidk.js} +1 -1
  67. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-CCS_FtVZ.js → chunk-CSCIHK7Q-Bgf-IHHq.js} +1 -1
  68. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CoLUtPiK.js → chunk-FMBD7UC4-rD27Gf3U.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-KSCS5N6A-DLW4gvEW.js → chunk-KSCS5N6A-C1MobXen.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CMt0LJAd.js → chunk-L5ZTLDWV-CD2wS3bn.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Bn-maIlt.js → chunk-LZXEDZCA-DQxuSIdE.js} +2 -2
  72. package/workframe-ui/public/assets/{chunk-ND2GUHAM-CG7yoApR.js → chunk-ND2GUHAM-DHlBi932.js} +1 -1
  73. package/workframe-ui/public/assets/{chunk-NZK2D7GU-B4dIvbii.js → chunk-NZK2D7GU-MB8bO5o8.js} +1 -1
  74. package/workframe-ui/public/assets/{chunk-O5CBEL6O-B5spDyXy.js → chunk-O5CBEL6O-BmPwoKAr.js} +1 -1
  75. package/workframe-ui/public/assets/chunk-QZHKN3VN-BsuC0swk.js +1 -0
  76. package/workframe-ui/public/assets/chunk-WU5MYG2G-CyfRhD17.js +1 -0
  77. package/workframe-ui/public/assets/{chunk-XPW4576I-D5c0TMX6.js → chunk-XPW4576I-BUCJoags.js} +1 -1
  78. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DZ_KubnM.js +1 -0
  79. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DZ_KubnM.js +1 -0
  80. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-DFRM3DZv.js → cose-bilkent-S5V4N54A-w9hpFlQU.js} +1 -1
  81. package/workframe-ui/public/assets/{dagre-BM42HDAG-CnUdKeJa.js → dagre-BM42HDAG-DEnBMVrR.js} +1 -1
  82. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ds3-e-hW.js → diagram-2AECGRRQ-CMoYqkVC.js} +1 -1
  83. package/workframe-ui/public/assets/{diagram-5GNKFQAL-B41zx8FF.js → diagram-5GNKFQAL-rS0QQe2R.js} +1 -1
  84. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D83tI9I8.js → diagram-KO2AKTUF-Do63Vbzi.js} +1 -1
  85. package/workframe-ui/public/assets/{diagram-LMA3HP47-h_EuFyLI.js → diagram-LMA3HP47-Bq3e2FDx.js} +1 -1
  86. package/workframe-ui/public/assets/{diagram-OG6HWLK6-CpU-d3RZ.js → diagram-OG6HWLK6-DTESfGaG.js} +1 -1
  87. package/workframe-ui/public/assets/{dist-_igeSJ9r.js → dist-BiIfSARy.js} +1 -1
  88. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-jLFoncRU.js → erDiagram-TEJ5UH35-C7RA0P76.js} +1 -1
  89. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DChy3wGd.js +1 -0
  90. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-C9IaNgCy.js → flowDiagram-I6XJVG4X-e47AwJAm.js} +1 -1
  91. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-A9FhfFc5.js → ganttDiagram-6RSMTGT7-BVytj000.js} +1 -1
  92. package/workframe-ui/public/assets/{gemini-Cq-0-rmr.svg → gemini-DqnZlvMo.svg} +0 -1
  93. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BqK_IjZb.js → gitGraph-WXDBUCRP-QdT2gCGh.js} +1 -1
  94. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CDVqxhyW.js → gitGraphDiagram-PVQCEYII-COEavQVV.js} +1 -1
  95. package/workframe-ui/public/assets/index-03WsLxfn.js +133 -0
  96. package/workframe-ui/public/assets/index-BIKn2D6J.css +1 -0
  97. package/workframe-ui/public/assets/{info-J43DQDTF-C2EKRglL.js → info-J43DQDTF-CEyVZ-9w.js} +1 -1
  98. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-azQtFzpU.js → infoDiagram-5YYISTIA-CV3ftI3x.js} +1 -1
  99. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BuDOLv1t.js → ishikawaDiagram-YF4QCWOH-M8CoaInm.js} +1 -1
  100. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-ypTHFoT0.js → journeyDiagram-JHISSGLW-BtoaC0oj.js} +1 -1
  101. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Cgr78J_x.js → kanban-definition-UN3LZRKU-Dke-ozKB.js} +1 -1
  102. package/workframe-ui/public/assets/{line-CcwgaMdv.js → line-CDg_Ms_2.js} +1 -1
  103. package/workframe-ui/public/assets/{linear-CswIlWaW.js → linear-D3OWfPXS.js} +1 -1
  104. package/workframe-ui/public/assets/{mermaid-parser.core-CQYhP37X.js → mermaid-parser.core-CGDFs4AZ.js} +2 -2
  105. package/workframe-ui/public/assets/{mermaid.core-Cz1XNBaK.js → mermaid.core-C9-q_XHF.js} +3 -3
  106. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BJLu0829.js → mindmap-definition-RKZ34NQL-BvEiBAWz.js} +1 -1
  107. package/workframe-ui/public/assets/{packet-YPE3B663-AB1qh96V.js → packet-YPE3B663-FF5nKMbE.js} +1 -1
  108. package/workframe-ui/public/assets/{pie-LRSECV5Y-CokIXLdT.js → pie-LRSECV5Y-CZ9ijSae.js} +1 -1
  109. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyrwQn5F.js → pieDiagram-4H26LBE5-kD9o509O.js} +1 -1
  110. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-C_Fdg3F6.js → quadrantDiagram-W4KKPZXB-d_eA7_-T.js} +1 -1
  111. package/workframe-ui/public/assets/{radar-GUYGQ44K-C21Xfrrg.js → radar-GUYGQ44K-ViyzC1rg.js} +1 -1
  112. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ChhaWgaF.js → requirementDiagram-4Y6WPE33-Sq3D_b4Z.js} +1 -1
  113. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-C5T4sDTt.js → sankeyDiagram-5OEKKPKP-ZZnl3ZwZ.js} +1 -1
  114. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DRks8i3A.js → sequenceDiagram-3UESZ5HK-Yaz2MgMq.js} +1 -1
  115. package/workframe-ui/public/assets/{src-BQu5waHd.js → src-DBh8rT2Z.js} +1 -1
  116. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BI-AKYta.js → stateDiagram-AJRCARHV-DVwi_P3W.js} +1 -1
  117. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DRSxEx1_.js +1 -0
  118. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DnAIWzEc.js → timeline-definition-PNZ67QCA-C88RoMRK.js} +1 -1
  119. package/workframe-ui/public/assets/{treeView-BLDUP644-BEF_sjx7.js → treeView-BLDUP644-2xabSpow.js} +1 -1
  120. package/workframe-ui/public/assets/{treemap-LRROVOQU-BcbL7g5c.js → treemap-LRROVOQU-BA_oRW4e.js} +1 -1
  121. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-DAoIbCkz.js → vennDiagram-CIIHVFJN-DvyVA588.js} +1 -1
  122. package/workframe-ui/public/assets/{wardley-L42UT6IY-3SOmFBFw.js → wardley-L42UT6IY-BR0suGqb.js} +1 -1
  123. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DlQV_-LH.js → wardleyDiagram-YWT4CUSO-CXMvBEO9.js} +1 -1
  124. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-CVoTTRHF.js → xychartDiagram-2RQKCTM6-wM7UrTHx.js} +1 -1
  125. package/workframe-ui/public/index.html +23 -12
  126. package/workframe-ui/public/workframe-build.json +4 -3
  127. package/workframe-ui/public/assets/architecture-7EHR7CIX-CDDNM1ni.js +0 -1
  128. package/workframe-ui/public/assets/channel-R52LrLO3.js +0 -1
  129. package/workframe-ui/public/assets/chunk-QZHKN3VN-C_9MbT10.js +0 -1
  130. package/workframe-ui/public/assets/chunk-WU5MYG2G-VJadzJmf.js +0 -1
  131. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CpBjaI3g.js +0 -1
  132. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CpBjaI3g.js +0 -1
  133. package/workframe-ui/public/assets/eventmodeling-FCH6USID-kzOxPvtj.js +0 -1
  134. package/workframe-ui/public/assets/index-Bco9IDMA.css +0 -1
  135. package/workframe-ui/public/assets/index-Do34PZS2.js +0 -132
  136. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-vJyhFZbx.js +0 -1
@@ -0,0 +1,261 @@
1
+ """Account-scoped live model discovery for Workframe's LLM providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import concurrent.futures
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import time
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from typing import Any
14
+
15
+ _TTL_SEC = int(os.environ.get("WORKFRAME_MODEL_CATALOG_TTL", "900"))
16
+ _ERROR_TTL_SEC = int(os.environ.get("WORKFRAME_MODEL_CATALOG_ERROR_TTL", "30"))
17
+ _CACHE: dict[tuple[str, str], dict[str, Any]] = {}
18
+
19
+
20
+ def _request_json(url: str, *, headers: dict[str, str] | None = None, timeout: float = 8) -> dict[str, Any]:
21
+ request = urllib.request.Request(url, headers=headers or {}, method="GET")
22
+ with urllib.request.urlopen(request, timeout=timeout) as response:
23
+ payload = json.loads(response.read())
24
+ return payload if isinstance(payload, dict) else {}
25
+
26
+
27
+ def _row(provider: str, model: str, label: str = "", description: str = "") -> dict[str, str]:
28
+ model_id = str(model or "").strip()
29
+ return {
30
+ "provider": provider,
31
+ "billing_provider": provider,
32
+ "model": model_id,
33
+ "label": str(label or model_id).strip() or model_id,
34
+ "description": str(description or model_id).strip() or model_id,
35
+ "catalog_source": "live",
36
+ }
37
+
38
+
39
+ def _openrouter_models(api_key: str, timeout: float) -> list[dict[str, str]]:
40
+ payload = _request_json(
41
+ "https://openrouter.ai/api/v1/models?output_modalities=text",
42
+ headers={
43
+ "Authorization": f"Bearer {api_key}",
44
+ "HTTP-Referer": "https://workfra.me",
45
+ "X-Title": "Workframe",
46
+ },
47
+ timeout=timeout,
48
+ )
49
+ rows: list[dict[str, str]] = []
50
+ for item in payload.get("data") or []:
51
+ if not isinstance(item, dict):
52
+ continue
53
+ model_id = str(item.get("id") or "").strip()
54
+ if not model_id:
55
+ continue
56
+ name = str(item.get("name") or model_id.rsplit("/", 1)[-1]).strip()
57
+ rows.append(_row("openrouter", model_id, name, model_id))
58
+ return sorted(rows, key=lambda item: item["label"].lower())
59
+
60
+
61
+ def _openai_models(api_key: str, timeout: float) -> list[dict[str, str]]:
62
+ payload = _request_json(
63
+ "https://api.openai.com/v1/models",
64
+ headers={"Authorization": f"Bearer {api_key}"},
65
+ timeout=timeout,
66
+ )
67
+ excluded = ("audio", "embedding", "image", "moderation", "realtime", "search", "transcribe", "tts", "whisper")
68
+ rows: list[dict[str, str]] = []
69
+ for item in payload.get("data") or []:
70
+ if not isinstance(item, dict):
71
+ continue
72
+ model_id = str(item.get("id") or "").strip()
73
+ lower = model_id.lower()
74
+ if not model_id or any(token in lower for token in excluded):
75
+ continue
76
+ if not lower.startswith(("gpt-", "chatgpt-", "o1", "o3", "o4")):
77
+ continue
78
+ rows.append(_row("openai", model_id))
79
+ return sorted(rows, key=lambda item: item["model"].lower())
80
+
81
+
82
+ def _anthropic_models(api_key: str, timeout: float) -> list[dict[str, str]]:
83
+ payload = _request_json(
84
+ "https://api.anthropic.com/v1/models?limit=1000",
85
+ headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"},
86
+ timeout=timeout,
87
+ )
88
+ rows: list[dict[str, str]] = []
89
+ for item in payload.get("data") or []:
90
+ if not isinstance(item, dict):
91
+ continue
92
+ model_id = str(item.get("id") or "").strip()
93
+ if model_id:
94
+ rows.append(_row("anthropic", model_id, str(item.get("display_name") or model_id)))
95
+ return rows
96
+
97
+
98
+ def _google_models(api_key: str, timeout: float) -> list[dict[str, str]]:
99
+ query = urllib.parse.urlencode({"key": api_key, "pageSize": "1000"})
100
+ payload = _request_json(f"https://generativelanguage.googleapis.com/v1beta/models?{query}", timeout=timeout)
101
+ rows: list[dict[str, str]] = []
102
+ for item in payload.get("models") or []:
103
+ if not isinstance(item, dict) or "generateContent" not in (item.get("supportedGenerationMethods") or []):
104
+ continue
105
+ model_id = str(item.get("name") or "").removeprefix("models/").strip()
106
+ if model_id:
107
+ rows.append(_row(
108
+ "google", model_id, str(item.get("displayName") or model_id),
109
+ str(item.get("description") or model_id),
110
+ ))
111
+ return rows
112
+
113
+
114
+ def _deepseek_models(api_key: str, timeout: float) -> list[dict[str, str]]:
115
+ payload = _request_json(
116
+ "https://api.deepseek.com/models",
117
+ headers={"Authorization": f"Bearer {api_key}"},
118
+ timeout=timeout,
119
+ )
120
+ return [
121
+ _row("deepseek", str(item.get("id") or ""))
122
+ for item in payload.get("data") or []
123
+ if isinstance(item, dict) and str(item.get("id") or "").strip()
124
+ ]
125
+
126
+
127
+ def _codex_models(access_token: str, timeout: float) -> list[dict[str, str]]:
128
+ payload = _request_json(
129
+ "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
130
+ headers={"Authorization": f"Bearer {access_token}"},
131
+ timeout=timeout,
132
+ )
133
+ sortable: list[tuple[int, dict[str, str]]] = []
134
+ for item in payload.get("models") or []:
135
+ if not isinstance(item, dict):
136
+ continue
137
+ model_id = str(item.get("slug") or "").strip()
138
+ visibility = str(item.get("visibility") or "").strip().lower()
139
+ if not model_id or visibility in {"hide", "hidden"}:
140
+ continue
141
+ priority = item.get("priority")
142
+ rank = int(priority) if isinstance(priority, (int, float)) else 10_000
143
+ display = str(item.get("display_name") or item.get("displayName") or model_id).strip()
144
+ sortable.append((rank, _row("codex", model_id, display)))
145
+ sortable.sort(key=lambda item: (item[0], item[1]["model"]))
146
+ return [item[1] for item in sortable]
147
+
148
+
149
+ _FETCHERS = {
150
+ "openrouter": _openrouter_models,
151
+ "openai": _openai_models,
152
+ "anthropic": _anthropic_models,
153
+ "google": _google_models,
154
+ "deepseek": _deepseek_models,
155
+ "codex": _codex_models,
156
+ }
157
+
158
+
159
+ def _cache_key(provider: str, credential: str) -> tuple[str, str]:
160
+ digest = hashlib.sha256(credential.encode("utf-8")).hexdigest()[:20] if credential else ""
161
+ return provider, digest
162
+
163
+
164
+ def discover(provider: str, credential: str, *, timeout: float = 8) -> tuple[list[dict[str, str]], dict[str, str]]:
165
+ provider_id = str(provider or "").strip().lower()
166
+ secret = str(credential or "").strip()
167
+ if provider_id not in _FETCHERS:
168
+ return [], {"status": "unsupported", "message": "Use a custom model id for this provider."}
169
+ if not secret:
170
+ return [], {"status": "unauthenticated", "message": "Reconnect this provider to refresh its models."}
171
+ key = _cache_key(provider_id, secret)
172
+ now = time.monotonic()
173
+ cached = _CACHE.get(key)
174
+ if cached:
175
+ ttl = _TTL_SEC if cached.get("status") == "live" else _ERROR_TTL_SEC
176
+ if now - float(cached.get("at") or 0) < ttl:
177
+ return list(cached.get("rows") or []), dict(cached.get("meta") or {})
178
+ try:
179
+ rows = _FETCHERS[provider_id](secret, timeout)
180
+ meta = {
181
+ "status": "live" if rows else "empty",
182
+ "message": f"{len(rows)} models available now." if rows else "The provider returned no chat models.",
183
+ }
184
+ except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, OSError, ValueError):
185
+ rows = []
186
+ meta = {"status": "error", "message": "Live catalog unavailable. Retry or enter a custom model id."}
187
+ _CACHE[key] = {"at": now, "status": meta["status"], "rows": rows, "meta": meta}
188
+ return list(rows), dict(meta)
189
+
190
+
191
+ def discover_many(credentials: dict[str, str], *, timeout: float = 8) -> tuple[list[dict[str, str]], dict[str, dict[str, str]]]:
192
+ rows: list[dict[str, str]] = []
193
+ statuses: dict[str, dict[str, str]] = {}
194
+ if not credentials:
195
+ return rows, statuses
196
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(5, len(credentials))) as pool:
197
+ pending = {
198
+ pool.submit(discover, provider, credential, timeout=timeout): provider
199
+ for provider, credential in credentials.items()
200
+ }
201
+ for future in concurrent.futures.as_completed(pending):
202
+ provider = pending[future]
203
+ try:
204
+ provider_rows, meta = future.result()
205
+ except Exception:
206
+ provider_rows, meta = [], {
207
+ "status": "error",
208
+ "message": "Live catalog unavailable. Retry or enter a custom model id.",
209
+ }
210
+ rows.extend(provider_rows)
211
+ statuses[provider] = meta
212
+ return rows, statuses
213
+
214
+
215
+ def oauth_access_token(auth: dict[str, Any] | None, hermes_auth_id: str) -> str:
216
+ """Read an OAuth access token from Hermes provider or credential-pool layouts."""
217
+ if not isinstance(auth, dict):
218
+ return ""
219
+ raw = str(hermes_auth_id or "").strip().lower()
220
+ keys = {raw, raw.replace("-", ""), raw.replace("-", "_")}
221
+ if "-" in raw:
222
+ keys.add(raw.split("-")[-1])
223
+
224
+ def token_from(entry: Any) -> str:
225
+ if not isinstance(entry, dict):
226
+ return ""
227
+ tokens = entry.get("tokens") if isinstance(entry.get("tokens"), dict) else {}
228
+ for source in (tokens, entry):
229
+ for field in ("access_token", "accessToken", "token"):
230
+ value = str(source.get(field) or "").strip()
231
+ if value:
232
+ return value
233
+ return ""
234
+
235
+ providers = auth.get("providers")
236
+ if isinstance(providers, dict):
237
+ for key, entry in providers.items():
238
+ if str(key).lower() in keys:
239
+ token = token_from(entry)
240
+ if token:
241
+ return token
242
+ credentials = auth.get("credentials")
243
+ if isinstance(credentials, list):
244
+ for entry in credentials:
245
+ if not isinstance(entry, dict):
246
+ continue
247
+ provider = str(entry.get("provider") or entry.get("id") or "").lower()
248
+ if provider in keys:
249
+ token = token_from(entry)
250
+ if token:
251
+ return token
252
+ pool = auth.get("credential_pool")
253
+ if isinstance(pool, dict):
254
+ for key, entries in pool.items():
255
+ if str(key).lower() not in keys or not isinstance(entries, list):
256
+ continue
257
+ for entry in entries:
258
+ token = token_from(entry)
259
+ if token:
260
+ return token
261
+ return ""
@@ -650,6 +650,7 @@ def _ensure_workspace_agent_profile_row(
650
650
  role: str = "",
651
651
  tagline: str = "",
652
652
  is_native: bool = False,
653
+ preserve_existing_identity: bool = False,
653
654
  ) -> str | None:
654
655
  """Ensure agent_profiles row exists for a workspace (default workspace when id omitted)."""
655
656
  slug = str(slug or "").strip()
@@ -686,11 +687,11 @@ def _ensure_workspace_agent_profile_row(
686
687
  if existing:
687
688
  agent_id = str(existing["id"])
688
689
  patches: dict[str, Any] = {}
689
- if display_name.strip():
690
+ if display_name.strip() and not preserve_existing_identity:
690
691
  patches["display_name"] = display_name.strip()
691
- if tagline.strip():
692
+ if tagline.strip() and not preserve_existing_identity:
692
693
  patches["tagline"] = tagline.strip()
693
- if role.strip():
694
+ if role.strip() and not preserve_existing_identity:
694
695
  patches["role"] = role.strip()
695
696
  if patches:
696
697
  sets = [f"{col} = ?" for col in patches]
@@ -81,6 +81,7 @@ ROUTES: tuple[Route, ...] = (
81
81
  Route("GET", "/api/me/credentials", AuthLevel.SESSION, "_route_get_me_credentials"),
82
82
  Route("GET", "/api/me/providers", AuthLevel.SESSION, "_route_get_me_providers"),
83
83
  Route("GET", "/api/me/cohort", AuthLevel.SESSION, "_route_get_me_cohort"),
84
+ Route("GET", "/api/message-reactions", AuthLevel.SESSION, "_route_get_message_reactions"),
84
85
  Route("GET", "/api/user/credentials", AuthLevel.SESSION, "_route_get_user_credentials"),
85
86
  Route("GET", "/api/hermes/profiles/status", AuthLevel.SESSION, "_route_get_hermes_profiles_status"),
86
87
  Route("GET", "/api/oauth/github/callback", AuthLevel.SESSION, "_route_get_oauth_github_callback"),
@@ -128,7 +129,10 @@ ROUTES: tuple[Route, ...] = (
128
129
  Route("POST", "/api/content/delete", AuthLevel.SESSION, "_route_post_content_delete"),
129
130
  Route("POST", "/api/files/write", AuthLevel.SESSION, "_route_post_files_write"),
130
131
  Route("POST", "/api/files/upload", AuthLevel.SESSION, "_route_post_files_upload"),
132
+ Route("POST", "/api/files/archive", AuthLevel.SESSION, "_route_post_files_archive"),
133
+ Route("POST", "/api/files/delete", AuthLevel.SESSION, "_route_post_files_delete"),
131
134
  Route("POST", "/api/chat/dispatch", AuthLevel.SESSION, "_route_post_chat_dispatch"),
135
+ Route("POST", "/api/message-reactions/toggle", AuthLevel.SESSION, "_route_post_message_reactions_toggle"),
132
136
  Route("POST", "/api/hermes/profiles/create", AuthLevel.SESSION, "_route_post_hermes_profiles_create"),
133
137
  Route("POST", "/api/hermes/profiles/start", AuthLevel.SESSION, "_route_post_hermes_profiles_start"),
134
138
  Route("POST", "/api/hermes/profiles/stop", AuthLevel.SESSION, "_route_post_hermes_profiles_stop"),
@@ -38,6 +38,8 @@ def _prepare_runtime_profile_credentials(
38
38
  runtime: str,
39
39
  user_id: str,
40
40
  workspace_id: str = "",
41
+ *,
42
+ wait_healthy: bool = True,
41
43
  ) -> bool:
42
44
  """Strip stale profile secrets, then overlay the runtime profile owner's keys.
43
45
 
@@ -57,7 +59,86 @@ def _prepare_runtime_profile_credentials(
57
59
  _srv()._reconcile_profile_llm_for_user(runtime, user, workspace_id)
58
60
  prov = _srv()._llm_billing_provider(runtime, user_id=user, workspace_id=workspace_id)
59
61
  _srv()._ensure_profile_llm_proxy(runtime, prov)
60
- return _srv()._user_can_use_llm(user, workspace_id, prov)
62
+ ready = _srv()._user_can_use_llm(user, workspace_id, prov)
63
+ if ready:
64
+ # Pre-bind the opaque runtime credential while provisioning/opening the
65
+ # agent. The first chat turn must never be responsible for restarting a
66
+ # credential-less Hermes gateway.
67
+ _srv()._try_overlay_turn_provider_env(
68
+ runtime,
69
+ user,
70
+ workspace_id,
71
+ prov,
72
+ f"runtime-bootstrap:{runtime}",
73
+ wait_healthy=wait_healthy,
74
+ )
75
+ return ready
76
+
77
+
78
+ def _refresh_user_runtime_credentials(
79
+ user_id: str,
80
+ workspace_id: str = "",
81
+ *,
82
+ wait_healthy: bool = False,
83
+ ) -> int:
84
+ """Refresh all of a user's agent runtimes after a credential change."""
85
+ user = str(user_id or "").strip()
86
+ if not user:
87
+ return 0
88
+ user_part = re.sub(
89
+ r"[^a-z0-9]+",
90
+ "-",
91
+ _srv()._user_hermes_dir_slug(user).lower(),
92
+ ).strip("-")[:20] or "user"
93
+ prefix = f"u-{user_part}-"
94
+ profiles_dir = _srv().HERMES_DATA / "profiles"
95
+ if not profiles_dir.is_dir():
96
+ return 0
97
+ refreshed = 0
98
+ for path in profiles_dir.iterdir():
99
+ if not path.is_dir() or not path.name.startswith(prefix):
100
+ continue
101
+ try:
102
+ if _prepare_runtime_profile_credentials(
103
+ path.name,
104
+ user,
105
+ workspace_id,
106
+ wait_healthy=wait_healthy,
107
+ ):
108
+ refreshed += 1
109
+ except (OSError, RuntimeError, ValueError):
110
+ continue
111
+ return refreshed
112
+
113
+
114
+ def _refresh_workspace_runtime_credentials(
115
+ workspace_id: str,
116
+ *,
117
+ wait_healthy: bool = False,
118
+ ) -> int:
119
+ """Refresh active member runtimes after a company credential changes."""
120
+ workspace = str(workspace_id or "").strip()
121
+ if not workspace:
122
+ return 0
123
+ conn = _srv()._workframe_db()
124
+ try:
125
+ rows = conn.execute(
126
+ """
127
+ SELECT DISTINCT user_id FROM workspace_memberships
128
+ WHERE workspace_id = ? AND status = 'active' AND deleted_at IS NULL
129
+ """,
130
+ (workspace,),
131
+ ).fetchall()
132
+ finally:
133
+ conn.close()
134
+ return sum(
135
+ _refresh_user_runtime_credentials(
136
+ str(row["user_id"]),
137
+ workspace,
138
+ wait_healthy=wait_healthy,
139
+ )
140
+ for row in rows
141
+ )
61
142
 
62
143
 
63
144
  def _resolve_runtime_owner(runtime: str) -> tuple[str, str] | None:
@@ -111,12 +192,9 @@ def _user_owner_name(user_id: str) -> str:
111
192
 
112
193
  def _runtime_display_label(user_id: str, template_slug: str, workspace_id: str = "") -> str:
113
194
  template = _srv().safe_profile_slug(str(template_slug or "").strip())
114
- user_id = str(user_id or "").strip()
115
- if user_id:
116
- runtime = _runtime_profile_slug(user_id, template)
117
- reg_runtime = _srv()._agent_registry_row(runtime)
118
- if str(reg_runtime.get("display_name") or "").strip():
119
- return str(reg_runtime["display_name"]).strip()
195
+ # Runtime profiles are credential/session proxies. Their registry rows may
196
+ # contain stale metadata from older builds, but can never rename the shared
197
+ # workspace agent.
120
198
  custom = _srv()._agent_db_display_name(template, workspace_id)
121
199
  if custom:
122
200
  return custom
@@ -124,11 +202,11 @@ def _runtime_display_label(user_id: str, template_slug: str, workspace_id: str =
124
202
  if reg.get("display_name"):
125
203
  return str(reg["display_name"])
126
204
  if _srv()._is_native_profile(template) or template.endswith("-agent"):
127
- return f"{_user_owner_name(user_id)}'s {_srv()._native_display_name().replace(' Agent', '')} Agent"
205
+ return _srv()._native_display_name()
128
206
  role = _srv()._agent_db_display_name(template, workspace_id) or _srv()._agent_registry_row(template).get("display_name")
129
207
  if not role:
130
208
  role = template.replace("-", " ").title()
131
- return f"{_user_owner_name(user_id)}'s {role}"
209
+ return str(role)
132
210
 
133
211
 
134
212
  def _cohort_alias(user_id: str, template_slug: str) -> str:
@@ -465,6 +543,8 @@ def _write_workframe_cohort_manifest(user_id: str, workspace_id: str, cohort: li
465
543
  path = _srv()._profile_dir(runtime) / "WORKFRAME_COHORT.md"
466
544
  try:
467
545
  path.parent.mkdir(parents=True, exist_ok=True)
546
+ if path.is_file() and path.read_text(encoding="utf-8") == body:
547
+ continue
468
548
  path.write_text(body, encoding="utf-8")
469
549
  except OSError:
470
550
  pass
@@ -616,6 +696,11 @@ def _copy_text_without_bom(src: Path, dst: Path) -> None:
616
696
  if raw.startswith(b"\xef\xbb\xbf"):
617
697
  raw = raw[3:]
618
698
  dst.parent.mkdir(parents=True, exist_ok=True)
699
+ try:
700
+ if dst.is_file() and dst.read_bytes() == raw:
701
+ return
702
+ except OSError:
703
+ pass
619
704
  dst.write_bytes(raw)
620
705
 
621
706
 
@@ -648,10 +733,7 @@ def _backfill_runtime_identity(runtime: str, template: str) -> None:
648
733
  for name in ("SOUL.md", "AGENTS.md", "SETUP.md"):
649
734
  src = tdir / name
650
735
  dst = rdir / name
651
- if name == "SOUL.md":
652
- if _srv()._write_profile_soul_if_stub(runtime, template):
653
- continue
654
- if src.is_file() and not dst.is_file():
736
+ if src.is_file():
655
737
  try:
656
738
  _copy_text_without_bom(src, dst)
657
739
  except OSError:
@@ -680,13 +762,15 @@ def _profile_toolsets_ready(profile: str, want: tuple[str, ...] | list[str]) ->
680
762
  if not names:
681
763
  return True
682
764
  ts = cfg.get("toolsets") if isinstance(cfg.get("toolsets"), list) else []
683
- if not all(name in ts for name in names):
684
- return False
765
+ global_ready = all(name in ts for name in names)
685
766
  pts = cfg.get("platform_toolsets") if isinstance(cfg.get("platform_toolsets"), dict) else {}
767
+ platform_ready = True
686
768
  for platform in ("api_server", "cli"):
687
769
  plat = pts.get(platform) if isinstance(pts.get(platform), list) else []
688
770
  if not all(name in plat for name in names):
689
- return False
771
+ platform_ready = False
772
+ if not global_ready and not platform_ready:
773
+ return False
690
774
  agent = cfg.get("agent") if isinstance(cfg.get("agent"), dict) else {}
691
775
  disabled = agent.get("disabled_toolsets") if isinstance(agent.get("disabled_toolsets"), list) else []
692
776
  return not any(name in disabled for name in names)
@@ -724,16 +808,20 @@ def ensure_runtime_profile(
724
808
  ok, out, _port = _srv()._configure_profile_api(runtime)
725
809
  if not ok:
726
810
  raise ValueError(f"runtime profile api config failed: {out}")
727
- skills_dir = _srv()._profile_dir(runtime) / "skills"
728
- if not skills_dir.is_dir() or not any(skills_dir.iterdir()):
729
- _backfill_runtime_identity(runtime, template)
811
+ _backfill_runtime_identity(runtime, template)
730
812
  if not _profile_toolsets_ready(runtime, _srv()._chat_toolsets_for_profile(runtime)):
731
813
  _srv()._ensure_profile_toolsets(runtime)
732
814
  _ensure_profile_terminal_cwd(runtime)
733
- # Existing profiles still need invariant repair. This deliberately does
734
- # not copy template model preferences; reconciliation keeps the agent's
735
- # model when the acting user can use it and falls back to that user's
736
- # connected providers otherwise.
815
+ # A runtime isolates credentials/gateway state, not agent preferences.
816
+ # Repair model drift before credential preparation so DM, room mention,
817
+ # kanban, and cron all execute the model owned by the agent template.
818
+ ok_model, model_error = _srv()._sync_runtime_model_from_template(
819
+ runtime,
820
+ template,
821
+ user_id=user_id,
822
+ )
823
+ if not ok_model:
824
+ raise ValueError(f"runtime model sync failed: {model_error}")
737
825
  _prepare_runtime_profile_credentials(runtime, user_id, workspace_id)
738
826
  _srv()._ensure_profile_proxy_headers(runtime)
739
827
  return
@@ -759,4 +847,11 @@ def ensure_runtime_profile(
759
847
  raise ValueError(f"runtime profile api config failed: {out}")
760
848
  _srv()._ensure_profile_toolsets(runtime)
761
849
  _ensure_profile_terminal_cwd(runtime)
850
+ ok_model, model_error = _srv()._sync_runtime_model_from_template(
851
+ runtime,
852
+ template,
853
+ user_id=user_id,
854
+ )
855
+ if not ok_model:
856
+ raise ValueError(f"runtime model sync failed: {model_error}")
762
857
  _prepare_runtime_profile_credentials(runtime, user_id, workspace_id)
@@ -0,0 +1,28 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ const requestedArgs = process.argv.slice(2)
4
+ const candidates = process.platform === 'win32'
5
+ ? [
6
+ { command: 'python', prefix: [] },
7
+ { command: 'py', prefix: ['-3'] },
8
+ { command: 'python3', prefix: [] },
9
+ ]
10
+ : [
11
+ { command: 'python3', prefix: [] },
12
+ { command: 'python', prefix: [] },
13
+ ]
14
+
15
+ for (const candidate of candidates) {
16
+ const result = spawnSync(candidate.command, [...candidate.prefix, ...requestedArgs], {
17
+ stdio: 'inherit',
18
+ })
19
+ if (result.error?.code === 'ENOENT') continue
20
+ if (result.error) {
21
+ console.error(`Could not run ${candidate.command}: ${result.error.message}`)
22
+ process.exit(1)
23
+ }
24
+ process.exit(result.status ?? 1)
25
+ }
26
+
27
+ console.error('Python 3 was not found. Install Python 3 and ensure py, python, or python3 is available.')
28
+ process.exit(1)
@@ -109,6 +109,8 @@ file_read = workspace_files.file_read
109
109
  file_write = workspace_files.file_write
110
110
  file_upload_binary = workspace_files.file_upload_binary
111
111
  file_raw = workspace_files.file_raw
112
+ files_archive = workspace_files.files_archive
113
+ files_delete = workspace_files.files_delete
112
114
 
113
115
  import activity_feed
114
116
 
@@ -162,6 +164,8 @@ _assign_agent_avatar = avatar_registry._assign_agent_avatar
162
164
  # WF-032: runtime_cohort re-exports
163
165
  _runtime_profile_slug = runtime_cohort._runtime_profile_slug
164
166
  _prepare_runtime_profile_credentials = runtime_cohort._prepare_runtime_profile_credentials
167
+ _refresh_user_runtime_credentials = runtime_cohort._refresh_user_runtime_credentials
168
+ _refresh_workspace_runtime_credentials = runtime_cohort._refresh_workspace_runtime_credentials
165
169
  _resolve_runtime_owner = runtime_cohort._resolve_runtime_owner
166
170
  _user_handle = runtime_cohort._user_handle
167
171
  _user_owner_name = runtime_cohort._user_owner_name
@@ -269,6 +273,7 @@ _resolve_models_profile = model_surface._resolve_models_profile
269
273
  _model_suggestion_row = model_surface._model_suggestion_row
270
274
  _augment_model_suggestions = model_surface._augment_model_suggestions
271
275
  _billing_provider_from_block = model_surface._billing_provider_from_block
276
+ _normalized_profile_fallback_chain = model_surface._normalized_profile_fallback_chain
272
277
  _llm_billing_provider = model_surface._llm_billing_provider
273
278
  _profile_llm_proxy_matches_billing = model_surface._profile_llm_proxy_matches_billing
274
279
  _profile_routing_matches_billing = model_surface._profile_routing_matches_billing
@@ -278,6 +283,7 @@ _catalog_row_for_billing_provider = model_surface._catalog_row_for_billing_provi
278
283
  _provider_display_label = model_surface._provider_display_label
279
284
  _model_catalog_rows_for_provider = model_surface._model_catalog_rows_for_provider
280
285
  _suggestions_for_connected_llm_providers = model_surface._suggestions_for_connected_llm_providers
286
+ _live_suggestions_for_connected_llm_providers = model_surface._live_suggestions_for_connected_llm_providers
281
287
  _model_id_vendor_and_bare = model_surface._model_id_vendor_and_bare
282
288
  _persisted_model_id = model_surface._persisted_model_id
283
289
  _apply_model_persisted = model_surface._apply_model_persisted
@@ -1382,6 +1388,7 @@ _user_action_env_specs = turn_overlay._user_action_env_specs
1382
1388
  _overlay_turn_user_env = turn_overlay._overlay_turn_user_env
1383
1389
  _apply_action_credential_lease = turn_overlay._apply_action_credential_lease
1384
1390
  _revoke_turn_credential_lease = turn_overlay._revoke_turn_credential_lease
1391
+ _revoke_runtime_llm_leases = turn_overlay._revoke_runtime_llm_leases
1385
1392
  _require_user_provider = turn_overlay._require_user_provider
1386
1393
  _require_runtime_owner_provider = turn_overlay._require_runtime_owner_provider
1387
1394
  _strip_profile_llm_env = turn_overlay._strip_profile_llm_env
@@ -1467,11 +1474,32 @@ def _enrich_room_messages(conn: sqlite3.Connection, messages: list[Any]) -> list
1467
1474
  enriched: list[dict[str, Any]] = []
1468
1475
  for row in messages:
1469
1476
  item = dict(row)
1477
+ # Provider/model attribution is turn evidence, not mutable agent state.
1478
+ # New agent messages persist it in messages.metadata. Legacy messages
1479
+ # without that evidence intentionally render without a model badge rather
1480
+ # than being relabelled whenever the agent's current model changes.
1481
+ metadata: dict[str, Any] = {}
1482
+ raw_metadata = item.get("metadata")
1483
+ if isinstance(raw_metadata, dict):
1484
+ metadata = raw_metadata
1485
+ elif isinstance(raw_metadata, str) and raw_metadata.strip():
1486
+ try:
1487
+ parsed = json.loads(raw_metadata)
1488
+ if isinstance(parsed, dict):
1489
+ metadata = parsed
1490
+ except (TypeError, ValueError, json.JSONDecodeError):
1491
+ metadata = {}
1492
+ turn_model = str(metadata.get("model") or "").strip()
1493
+ turn_provider = str(metadata.get("llm_provider") or "").strip()
1494
+ if turn_model:
1495
+ item["model"] = turn_model
1496
+ if turn_provider:
1497
+ item["llm_provider"] = turn_provider
1470
1498
  agent_id = str(item.get("sender_agent_id") or "").strip()
1471
1499
  if agent_id:
1472
1500
  agent = conn.execute(
1473
1501
  """
1474
- SELECT slug, display_name, model_name, model_provider
1502
+ SELECT slug, display_name
1475
1503
  FROM agent_profiles WHERE id = ? AND deleted_at IS NULL
1476
1504
  """,
1477
1505
  (agent_id,),
@@ -1479,17 +1507,6 @@ def _enrich_room_messages(conn: sqlite3.Connection, messages: list[Any]) -> list
1479
1507
  if agent:
1480
1508
  item["sender_agent_slug"] = str(agent["slug"])
1481
1509
  item["sender_agent_name"] = str(agent["display_name"] or agent["slug"])
1482
- model_name = str(agent["model_name"] or "").strip()
1483
- model_provider = str(agent["model_provider"] or "").strip()
1484
- slug = str(agent["slug"] or "").strip()
1485
- if not model_name and slug:
1486
- model_name = str(_read_model_block(slug).get("default") or "").strip()
1487
- if not model_provider and slug:
1488
- model_provider = _llm_billing_provider(slug)
1489
- if model_name:
1490
- item["model"] = model_name
1491
- if model_provider:
1492
- item["llm_provider"] = model_provider
1493
1510
  enriched.append(item)
1494
1511
  return enriched
1495
1512
 
@@ -1542,10 +1559,13 @@ def _sync_agent_profile_db(profile: str, fields: dict[str, Any]) -> None:
1542
1559
  "tagline": "tagline",
1543
1560
  "role": "role",
1544
1561
  "avatar_url": "avatar_url",
1562
+ "model_provider": "model_provider",
1563
+ "model_name": "model_name",
1545
1564
  }
1546
1565
  updates = {column: fields[key] for key, column in column_map.items() if key in fields}
1547
1566
  if not updates:
1548
1567
  return
1568
+ _ensure_workframe_db_schema()
1549
1569
  conn = _workframe_db()
1550
1570
  try:
1551
1571
  now_ts = str(int(time.time()))
@@ -1555,6 +1575,18 @@ def _sync_agent_profile_db(profile: str, fields: dict[str, Any]) -> None:
1555
1575
  f"UPDATE agent_profiles SET {', '.join(sets)}, updated_at = ? WHERE slug = ? AND deleted_at IS NULL",
1556
1576
  vals,
1557
1577
  )
1578
+ if "display_name" in updates and str(updates["display_name"] or "").strip():
1579
+ conn.execute(
1580
+ """
1581
+ UPDATE rooms
1582
+ SET name = ?, updated_at = ?
1583
+ WHERE room_type = 'direct' AND deleted_at IS NULL
1584
+ AND agent_profile_id IN (
1585
+ SELECT id FROM agent_profiles WHERE slug = ? AND deleted_at IS NULL
1586
+ )
1587
+ """,
1588
+ (str(updates["display_name"]).strip(), now_ts, slug),
1589
+ )
1558
1590
  conn.commit()
1559
1591
  finally:
1560
1592
  conn.close()