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.
- package/README.md +1 -1
- package/bin/create-workframe.js +2 -2
- package/bin/workframe.js +130 -2
- package/docs/security.md +2 -2
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +1 -0
- package/scripts/verify-public-deploy.sh +65 -3
- package/workframe-api/action_proxy.py +23 -17
- package/workframe-api/activity_feed.py +798 -0
- package/workframe-api/api_meta.py +157 -0
- package/workframe-api/auth_gate.py +566 -0
- package/workframe-api/avatar_registry.py +343 -0
- package/workframe-api/broker_audit.py +164 -0
- package/workframe-api/cell_authority.py +231 -0
- package/workframe-api/chat_bind.py +319 -0
- package/workframe-api/chat_sessions.py +509 -0
- package/workframe-api/chat_stream.py +694 -0
- package/workframe-api/cleanup_dogfood_smoke.py +252 -0
- package/workframe-api/credential_broker.py +162 -0
- package/workframe-api/credential_resolve.py +202 -0
- package/workframe-api/credential_store.py +245 -0
- package/workframe-api/crew_registry.py +250 -0
- package/workframe-api/db_schema.py +755 -0
- package/workframe-api/docker_gateway.py +166 -0
- package/workframe-api/doctor_runtime.py +109 -0
- package/workframe-api/domain/__init__.py +47 -0
- package/workframe-api/domain/entities.py +252 -0
- package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
- package/workframe-api/egress_policy.py +42 -0
- package/workframe-api/handler_modules/__init__.py +17 -0
- package/workframe-api/handler_modules/handler_admin.py +542 -0
- package/workframe-api/handler_modules/handler_auth.py +512 -0
- package/workframe-api/handler_modules/handler_chat.py +641 -0
- package/workframe-api/handler_modules/handler_install.py +178 -0
- package/workframe-api/handler_modules/handler_provider.py +325 -0
- package/workframe-api/handler_modules/handler_workspace.py +1420 -0
- package/workframe-api/health_monitor.py +80 -0
- package/workframe-api/hermes_admin.py +756 -0
- package/workframe-api/hermes_profiles.py +1328 -0
- package/workframe-api/install_api.py +123 -0
- package/workframe-api/kanban_cron.py +473 -0
- package/workframe-api/lane_bindings.py +423 -0
- package/workframe-api/llm_proxy.py +17 -43
- package/workframe-api/mention_helpers.py +180 -0
- package/workframe-api/mention_invoke.py +431 -0
- package/workframe-api/model_surface.py +1139 -0
- package/workframe-api/oauth_pending.py +85 -0
- package/workframe-api/oauth_redirect.py +381 -0
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -0
- package/workframe-api/profile_gateway.py +436 -0
- package/workframe-api/provider_bindings.py +673 -0
- package/workframe-api/provider_bootstrap.py +347 -0
- package/workframe-api/provider_catalog.py +180 -0
- package/workframe-api/rooms.py +1613 -0
- package/workframe-api/route_registry.py +331 -191
- package/workframe-api/run-typecheck.mjs +2 -1
- package/workframe-api/run_authority.py +287 -0
- package/workframe-api/run_ledger.py +470 -0
- package/workframe-api/run_surface_wiring.py +344 -0
- package/workframe-api/runtime_cohort.py +752 -0
- package/workframe-api/runtime_tokens.py +127 -0
- package/workframe-api/server.py +1453 -19513
- package/workframe-api/snapshot_feed.py +49 -0
- package/workframe-api/stack_config.py +33 -1
- package/workframe-api/supervisor_client.py +154 -0
- package/workframe-api/test_api_meta_build_stamp.py +34 -0
- package/workframe-api/test_cell_authority.py +79 -0
- package/workframe-api/test_chat_bind.py +48 -0
- package/workframe-api/test_credential_lease_matrix.py +171 -0
- package/workframe-api/test_credential_resolve.py +51 -0
- package/workframe-api/test_credential_store.py +27 -0
- package/workframe-api/test_dogfood_flows.py +346 -0
- package/workframe-api/test_domain_entities.py +152 -0
- package/workframe-api/test_exception_hygiene.py +12 -0
- package/workframe-api/test_hermes_admin.py +72 -0
- package/workframe-api/test_install_wizard_resume.py +78 -0
- package/workframe-api/test_local_bootstrap.py +67 -0
- package/workframe-api/test_mention_helpers.py +35 -0
- package/workframe-api/test_mention_invoke.py +26 -0
- package/workframe-api/test_model_surface_consistency.py +1 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_route_registry.py +171 -35
- package/workframe-api/test_run_authority.py +112 -0
- package/workframe-api/test_run_ledger.py +157 -0
- package/workframe-api/test_run_surface_wiring.py +130 -0
- package/workframe-api/test_runtime_cohort.py +85 -0
- package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
- package/workframe-api/test_server_reexports.py +99 -0
- package/workframe-api/test_stack_config_install_smtp.py +7 -0
- package/workframe-api/turn_credentials.py +28 -13
- package/workframe-api/turn_overlay.py +715 -0
- package/workframe-api/updates.py +16 -1
- package/workframe-api/user_prefs.py +387 -0
- package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
- package/workframe-api/workspace_bootstrap.py +536 -0
- package/workframe-api/workspace_files.py +344 -0
- package/workframe-api/workspace_messaging.py +206 -0
- package/workframe-api/zk_auth.py +3 -2
- package/workframe-supervisor/server.py +10 -1
- package/workframe-supervisor/test_supervisor_negative.py +75 -0
- package/workframe-ui/public/assets/{arc-B0OFRGmJ.js → arc-BT9iZUSd.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-CaEDd3np.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-DHJhMrIS.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-Cdyz9hIV.js} +1 -1
- package/workframe-ui/public/assets/channel-3M69ekE5.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-BbZx76LX.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DcSxjTIP.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-B7F-kTY1.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-BwwEiRTK.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-DAi_NksX.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-BIaF4XCN.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-DjYSk1dG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-_twnyfgV.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CkNmdy5s.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CjLqd-gl.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-_6oNqFjj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-CeO9Am_D.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-sRM4YeIe.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-CH8tMb21.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-DAI1bsII.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-B2e-5a2G.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-DM2-0q37.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-BnbbewLv.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-C4zmB1Vt.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-n-BJmsiC.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-BzQGQnZi.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-Cx_De-wS.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-CIOI3Lok.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-DkRuPLWY.js} +1 -1
- package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-XhCT1Q-V.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-C3GwhDU1.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-CLWH8z2e.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D4h66Vy3.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-IW5F-Q6c.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-BjNIbmRM.js} +1 -1
- package/workframe-ui/public/assets/index-DL2vJP4L.css +1 -0
- package/workframe-ui/public/assets/index-ElS_D59P.js +130 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CcJspM79.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-DP4xR9jt.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-D4cZb21V.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-DsLuHkIJ.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Wu65nOsR.js} +1 -1
- package/workframe-ui/public/assets/{line-CgQsmU35.js → line-ByBDzRpy.js} +1 -1
- package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-DzXi2vnq.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-CaB-1Ckn.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-D7HTHC17.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BfKsC9aj.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-ClpU98TO.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-KeOyY_-u.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-CUpjnzbs.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-CJdPTszF.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-C9BpandZ.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-CHDZPwry.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-CMJJh91s.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-BsV4GppP.js} +1 -1
- package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-WHks82Up.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-eSX9P8z0.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Dg1UkhGo.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-B7yGsBuj.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-j_G2oyQ_.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-BRrT0jqC.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-DEdUqez2.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-C-bBK9Bb.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-DhH5xuR2.js} +1 -1
- package/workframe-ui/public/index.html +11 -7
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
- package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
- package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
- package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.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}
|