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.
- package/README.md +1 -1
- package/bin/create-workframe.js +2 -2
- package/bin/workframe.js +130 -2
- package/docs/security.md +85 -0
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +9 -0
- package/scripts/verify-public-deploy.sh +121 -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/openrouter_catalog.py +4 -4
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -0
- package/workframe-api/profile_config_yaml.py +126 -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 +470 -0
- package/workframe-api/run-typecheck.mjs +26 -0
- 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 +1376 -19305
- 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 +117 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_profile_model_yaml.py +99 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_public_routes.py +41 -0
- package/workframe-api/test_route_registry.py +245 -0
- 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/time-bind-steps.py +75 -0
- package/workframe-api/time-perf.py +81 -0
- package/workframe-api/time-warm.py +107 -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-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
- package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
- package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
- package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
- package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
- package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
- package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
- package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
- package/workframe-ui/public/index.html +7 -6
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
- package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
- package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
- package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
"""WF-032 extract: hermes_profiles."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import sqlite3
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from http.server import BaseHTTPRequestHandler
|
|
17
|
+
|
|
18
|
+
import user_prefs
|
|
19
|
+
import rooms
|
|
20
|
+
|
|
21
|
+
_AGENT_PROFILE_UUID_RE = rooms._AGENT_PROFILE_UUID_RE
|
|
22
|
+
_FORBIDDEN_CHILD_SKILLS = frozenset({"botfather", "crew-manager"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _srv():
|
|
26
|
+
import server as srv
|
|
27
|
+
|
|
28
|
+
return srv
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _list_profiles() -> list[str]:
|
|
32
|
+
profiles_dir = _srv().HERMES_DATA / "profiles"
|
|
33
|
+
if not profiles_dir.is_dir():
|
|
34
|
+
return []
|
|
35
|
+
return sorted(
|
|
36
|
+
p.name
|
|
37
|
+
for p in profiles_dir.iterdir()
|
|
38
|
+
if p.is_dir() and ((p / "profile.yaml").exists() or (p / "config.yaml").exists())
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _native_profile_slug() -> str:
|
|
43
|
+
return str(_srv().NATIVE_PROFILE or "workframe-agent").strip() or "workframe-agent"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _native_profile_present() -> bool:
|
|
47
|
+
slug = _native_profile_slug()
|
|
48
|
+
prof_dir = _srv()._profile_dir(slug)
|
|
49
|
+
return (prof_dir / "config.yaml").is_file() or (prof_dir / "profile.yaml").is_file()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _hermes_data_uid_gid() -> tuple[int, int]:
|
|
53
|
+
try:
|
|
54
|
+
st = _srv().HERMES_DATA.stat()
|
|
55
|
+
return int(st.st_uid), int(st.st_gid)
|
|
56
|
+
except OSError:
|
|
57
|
+
return 10000, 10000
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _chown_profile_tree(prof_dir: Path) -> None:
|
|
61
|
+
"""Gateway runs as the Agents volume owner — seeded files must match."""
|
|
62
|
+
uid, gid = _hermes_data_uid_gid()
|
|
63
|
+
prof_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
(prof_dir / "logs").mkdir(parents=True, exist_ok=True)
|
|
65
|
+
for path in [prof_dir, *prof_dir.rglob("*")]:
|
|
66
|
+
try:
|
|
67
|
+
os.chown(path, uid, gid)
|
|
68
|
+
except OSError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _seed_native_profile_on_disk(slug: str) -> bool:
|
|
73
|
+
"""Filesystem fallback when Hermes CLI cannot run (empty / crash-looping gateway)."""
|
|
74
|
+
slug = _srv().safe_profile_slug(slug)
|
|
75
|
+
prof_dir = _srv()._profile_dir(slug)
|
|
76
|
+
prof_dir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
config = prof_dir / "config.yaml"
|
|
78
|
+
if not config.is_file():
|
|
79
|
+
proxy = _srv()._llm_proxy_base_url("openrouter")
|
|
80
|
+
config.write_text(
|
|
81
|
+
"model:\n"
|
|
82
|
+
" default: google/gemini-2.5-flash\n"
|
|
83
|
+
" provider: custom\n"
|
|
84
|
+
f" base_url: {proxy}\n",
|
|
85
|
+
encoding="utf-8",
|
|
86
|
+
)
|
|
87
|
+
soul = prof_dir / "SOUL.md"
|
|
88
|
+
if not soul.is_file():
|
|
89
|
+
soul.write_text(
|
|
90
|
+
f"You are the {_srv().PROJECT_NAME} native agent — orchestrator and workspace admin.\n",
|
|
91
|
+
encoding="utf-8",
|
|
92
|
+
)
|
|
93
|
+
_ensure_profile_toolsets(slug)
|
|
94
|
+
_srv()._ensure_profile_terminal_cwd(slug)
|
|
95
|
+
_register_profile_route(
|
|
96
|
+
slug,
|
|
97
|
+
{
|
|
98
|
+
"display_name": f"{_srv().PROJECT_NAME} Agent",
|
|
99
|
+
"role": f"{_srv().PROJECT_NAME} Manager",
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
routes_path = _srv().ROUTES_JSON
|
|
103
|
+
if routes_path.is_file():
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(routes_path.read_text(encoding="utf-8"))
|
|
106
|
+
if isinstance(data, dict) and not data.get("default_profile"):
|
|
107
|
+
data["default_profile"] = slug
|
|
108
|
+
routes_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
109
|
+
except (OSError, json.JSONDecodeError):
|
|
110
|
+
pass
|
|
111
|
+
_chown_profile_tree(prof_dir)
|
|
112
|
+
return _native_profile_present()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _ensure_native_hermes_profile() -> bool:
|
|
116
|
+
"""Idempotent: native workframe-agent must exist before gateway boot or DM bootstrap."""
|
|
117
|
+
slug = _native_profile_slug()
|
|
118
|
+
if _native_profile_present():
|
|
119
|
+
return True
|
|
120
|
+
shell = (
|
|
121
|
+
"export HERMES_HOME=/opt/data HOME=/opt/data; "
|
|
122
|
+
f"/opt/hermes/bin/hermes profile create {shlex.quote(slug)} --clone "
|
|
123
|
+
f"--description {shlex.quote(f'{_srv().PROJECT_NAME} native agent')}"
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
code, out = _srv()._gateway_container_exec(["sh", "-lc", shell])
|
|
127
|
+
if code == 0 and _native_profile_present():
|
|
128
|
+
_register_profile_route(
|
|
129
|
+
slug,
|
|
130
|
+
{"display_name": f"{_srv().PROJECT_NAME} Agent", "role": f"{_srv().PROJECT_NAME} Manager"},
|
|
131
|
+
)
|
|
132
|
+
_chown_profile_tree(_srv()._profile_dir(slug))
|
|
133
|
+
return True
|
|
134
|
+
if "already exists" in out.lower() and _native_profile_present():
|
|
135
|
+
_register_profile_route(
|
|
136
|
+
slug,
|
|
137
|
+
{"display_name": f"{_srv().PROJECT_NAME} Agent", "role": f"{_srv().PROJECT_NAME} Manager"},
|
|
138
|
+
)
|
|
139
|
+
_chown_profile_tree(_srv()._profile_dir(slug))
|
|
140
|
+
return True
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
return _seed_native_profile_on_disk(slug)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _primary_profile() -> str:
|
|
147
|
+
if _srv().NATIVE_PROFILE and (_srv().HERMES_DATA / "profiles" / _srv().NATIVE_PROFILE).is_dir():
|
|
148
|
+
return _srv().NATIVE_PROFILE
|
|
149
|
+
profiles = _list_profiles()
|
|
150
|
+
return profiles[0] if profiles else ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _profile_dir(profile: str) -> Path:
|
|
154
|
+
return _srv().HERMES_DATA / "profiles" / profile
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _profile_gateway_config_path(profile: str) -> Path | None:
|
|
158
|
+
"""Hermes gateway reads platforms + model chain from config.yaml only."""
|
|
159
|
+
d = _srv()._profile_dir(profile)
|
|
160
|
+
if not d.is_dir():
|
|
161
|
+
return None
|
|
162
|
+
return d / "config.yaml"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _ensure_gateway_config_file(profile: str) -> Path:
|
|
166
|
+
path = _profile_gateway_config_path(profile)
|
|
167
|
+
if path is None:
|
|
168
|
+
raise ValueError(f"profile not found: {profile}")
|
|
169
|
+
if path.is_file():
|
|
170
|
+
return path
|
|
171
|
+
provider, model = "", ""
|
|
172
|
+
meta = _srv()._profile_dir(profile) / "profile.yaml"
|
|
173
|
+
if meta.is_file():
|
|
174
|
+
provider, model = _parse_model_fields_from_yaml(meta.read_text(encoding="utf-8"))
|
|
175
|
+
seed = ""
|
|
176
|
+
if model:
|
|
177
|
+
seed = f"model:\n default: {model}\n"
|
|
178
|
+
if provider:
|
|
179
|
+
seed += f" provider: {provider}\n"
|
|
180
|
+
path.write_text(seed, encoding="utf-8")
|
|
181
|
+
return path
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _fix_invalid_model_header(raw: str) -> str:
|
|
185
|
+
"""Scalar `model: ''` breaks yaml.safe_load when proxy writers add nested keys."""
|
|
186
|
+
lines = raw.splitlines(keepends=True)
|
|
187
|
+
out: list[str] = []
|
|
188
|
+
for line in lines:
|
|
189
|
+
stripped = line.lstrip()
|
|
190
|
+
if stripped.startswith("model:") and not line.startswith((" ", "\t")):
|
|
191
|
+
rest = stripped.split(":", 1)[1].strip().strip("'\"")
|
|
192
|
+
if not rest:
|
|
193
|
+
out.append("model:\n")
|
|
194
|
+
continue
|
|
195
|
+
if not stripped.endswith(":") and rest:
|
|
196
|
+
out.append("model:\n")
|
|
197
|
+
out.append(f" default: {rest}\n")
|
|
198
|
+
continue
|
|
199
|
+
out.append(line)
|
|
200
|
+
return "".join(out)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _scrub_orphan_top_level_yaml_lines(profile: str) -> None:
|
|
204
|
+
"""Drop root-level list items (e.g. `- provider: openrouter`) left by corrupt writers."""
|
|
205
|
+
config_path = _profile_gateway_config_path(profile)
|
|
206
|
+
if not config_path or not config_path.is_file():
|
|
207
|
+
return
|
|
208
|
+
try:
|
|
209
|
+
lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
210
|
+
except OSError:
|
|
211
|
+
return
|
|
212
|
+
out: list[str] = []
|
|
213
|
+
changed = False
|
|
214
|
+
for line in lines:
|
|
215
|
+
stripped = line.lstrip()
|
|
216
|
+
indent = len(line) - len(stripped)
|
|
217
|
+
if indent == 0 and stripped.startswith("- "):
|
|
218
|
+
changed = True
|
|
219
|
+
continue
|
|
220
|
+
out.append(line)
|
|
221
|
+
if changed:
|
|
222
|
+
try:
|
|
223
|
+
config_path.write_text("".join(out), encoding="utf-8")
|
|
224
|
+
except OSError:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _fix_model_child_indent(raw: str) -> str:
|
|
229
|
+
"""Re-indent model block keys to 2 spaces — fixes api_key nested under base_url scalar."""
|
|
230
|
+
lines = raw.splitlines(keepends=True)
|
|
231
|
+
out: list[str] = []
|
|
232
|
+
in_model = False
|
|
233
|
+
for line in lines:
|
|
234
|
+
stripped = line.lstrip()
|
|
235
|
+
if not line.startswith((" ", "\t")) and stripped.startswith("model:"):
|
|
236
|
+
in_model = True
|
|
237
|
+
out.append(line if line.endswith("\n") else f"{line}\n")
|
|
238
|
+
continue
|
|
239
|
+
if in_model:
|
|
240
|
+
if stripped and not line.startswith((" ", "\t", "#")):
|
|
241
|
+
in_model = False
|
|
242
|
+
out.append(line)
|
|
243
|
+
continue
|
|
244
|
+
if stripped.startswith(("default:", "provider:", "base_url:", "api_key:")):
|
|
245
|
+
_, _, val = stripped.partition(":")
|
|
246
|
+
out.append(f" {stripped.split(':', 1)[0]}:{val}\n")
|
|
247
|
+
continue
|
|
248
|
+
out.append(line)
|
|
249
|
+
return "".join(out)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _normalize_profile_config_yaml(profile: str) -> None:
|
|
253
|
+
"""Repair invalid model headers before gateway yaml parsers run (no pyyaml in API image)."""
|
|
254
|
+
config_path = _profile_gateway_config_path(profile)
|
|
255
|
+
if not config_path or not config_path.is_file():
|
|
256
|
+
return
|
|
257
|
+
try:
|
|
258
|
+
raw = config_path.read_text(encoding="utf-8")
|
|
259
|
+
except OSError:
|
|
260
|
+
return
|
|
261
|
+
fixed = _fix_invalid_model_header(raw)
|
|
262
|
+
fixed = _fix_model_child_indent(fixed)
|
|
263
|
+
if fixed != raw:
|
|
264
|
+
try:
|
|
265
|
+
config_path.write_text(fixed, encoding="utf-8")
|
|
266
|
+
except OSError:
|
|
267
|
+
return
|
|
268
|
+
_srv()._coalesce_profile_model_yaml(profile)
|
|
269
|
+
_scrub_orphan_top_level_yaml_lines(profile)
|
|
270
|
+
_srv()._ensure_profile_terminal_cwd(profile)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_model_fields_from_yaml(text: str) -> tuple[str, str]:
|
|
274
|
+
provider = ""
|
|
275
|
+
model_name = ""
|
|
276
|
+
in_model_block = False
|
|
277
|
+
for raw_line in text.splitlines():
|
|
278
|
+
line = raw_line.rstrip()
|
|
279
|
+
if not line or line.lstrip().startswith("#"):
|
|
280
|
+
continue
|
|
281
|
+
if not line[:1].isspace() and line.endswith(":"):
|
|
282
|
+
in_model_block = line[:-1].strip() == "model"
|
|
283
|
+
continue
|
|
284
|
+
if not in_model_block or ":" not in line:
|
|
285
|
+
continue
|
|
286
|
+
key, _, value = line.strip().partition(":")
|
|
287
|
+
value = value.strip().strip('"').strip("'")
|
|
288
|
+
if key == "default":
|
|
289
|
+
model_name = value
|
|
290
|
+
elif key == "provider":
|
|
291
|
+
provider = value
|
|
292
|
+
return provider, model_name
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _profile_config_path(profile: str) -> Path | None:
|
|
296
|
+
"""Readable profile yaml: prefer gateway config.yaml, else legacy profile.yaml metadata."""
|
|
297
|
+
gateway = _profile_gateway_config_path(profile)
|
|
298
|
+
if gateway and gateway.is_file():
|
|
299
|
+
return gateway
|
|
300
|
+
meta = _srv()._profile_dir(profile) / "profile.yaml"
|
|
301
|
+
return meta if meta.is_file() else None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _profile_home(profile: str) -> Path:
|
|
305
|
+
return _srv()._profile_dir(profile)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _render_soul_placeholders(text: str) -> str:
|
|
309
|
+
native_slug = str(_srv().NATIVE_PROFILE or "workframe-agent").strip() or "workframe-agent"
|
|
310
|
+
ctx = {
|
|
311
|
+
"projectName": _srv().PROJECT_NAME,
|
|
312
|
+
"nativeProfileSlug": native_slug,
|
|
313
|
+
"nativeAgentName": _native_display_name(),
|
|
314
|
+
}
|
|
315
|
+
out = str(text or "")
|
|
316
|
+
for key, value in ctx.items():
|
|
317
|
+
out = out.replace(f"{{{key}}}", value)
|
|
318
|
+
return out
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _soul_is_stub(text: str) -> bool:
|
|
322
|
+
"""True when SOUL is missing, trivial, unexpanded, or not Workframe identity."""
|
|
323
|
+
body = str(text or "").strip()
|
|
324
|
+
if len(body) < 80:
|
|
325
|
+
return True
|
|
326
|
+
low = body.lower()
|
|
327
|
+
if any(token in low for token in ("{nativeagentname}", "{projectname}", "{nativeprofileslug}")):
|
|
328
|
+
return True
|
|
329
|
+
if "workframe" not in low and "botfather" not in low and "concierge" not in low:
|
|
330
|
+
return True
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _format_child_template(template: str, **kwargs: str) -> str:
|
|
335
|
+
ctx = {"nativeAgentName": _native_display_name(), **kwargs}
|
|
336
|
+
return _render_soul_placeholders(template.format(**ctx))
|
|
337
|
+
|
|
338
|
+
_CHILD_SOUL_TEMPLATE = """# {display_name}
|
|
339
|
+
|
|
340
|
+
## Identity (highest priority)
|
|
341
|
+
You are **{display_name}** — a Workframe agent. When greeting or identifying yourself, use **{display_name}** — never the underlying model or provider name.
|
|
342
|
+
|
|
343
|
+
Mission
|
|
344
|
+
- {role}
|
|
345
|
+
|
|
346
|
+
## Hermes & Workframe
|
|
347
|
+
- Read `AGENTS.md` in this profile home for tools, skills, memory, and how to upkeep your `SOUL.md`.
|
|
348
|
+
- Read `/workspace/AGENTS.md` for project workspace rules.
|
|
349
|
+
- Load Hermes skills from `skills/` before specialized work.
|
|
350
|
+
- CLI: `/opt/hermes/.venv/bin/hermes -p <your-runtime-slug> …` via **terminal** tool.
|
|
351
|
+
|
|
352
|
+
Restrictions
|
|
353
|
+
- You are a child agent — you cannot create, delete, spawn, or reconfigure other agents.
|
|
354
|
+
- Escalate crew changes to the native Workframe agent ({nativeAgentName}).
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
_CHILD_AGENTS_TEMPLATE = """# AGENTS — {display_name}
|
|
358
|
+
|
|
359
|
+
Operating rules for **{display_name}**. Identity lives in `SOUL.md` in this profile home.
|
|
360
|
+
|
|
361
|
+
## Tools & skills
|
|
362
|
+
|
|
363
|
+
- Load task-appropriate skills from `skills/` before specialized work.
|
|
364
|
+
- CLI: `/opt/hermes/.venv/bin/hermes -p <your-runtime-slug> …` via **terminal**.
|
|
365
|
+
- Read `WORKFRAME_COHORT.md` when present — use **runtime_slug** for kanban/delegate.
|
|
366
|
+
|
|
367
|
+
## Restrictions
|
|
368
|
+
|
|
369
|
+
- Child agent — **not** Botfather. Escalate crew changes to **{nativeAgentName}**.
|
|
370
|
+
- You may refine your own `SOUL.md` and notes when the user asks.
|
|
371
|
+
|
|
372
|
+
## Memory
|
|
373
|
+
|
|
374
|
+
- Outcomes in `/workspace`; evolve notes via Hermes memory when asked.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _soul_is_native_concierge(text: str) -> bool:
|
|
379
|
+
low = str(text or "").lower()
|
|
380
|
+
return "botfather" in low or "concierge" in low
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _strip_forbidden_child_skills(profile: str) -> list[str]:
|
|
384
|
+
"""Remove botfather/crew-manager skills from child profiles after native clone."""
|
|
385
|
+
prof = _srv().safe_profile_slug(profile)
|
|
386
|
+
skills_root = _srv()._profile_dir(prof) / "skills"
|
|
387
|
+
if not skills_root.is_dir():
|
|
388
|
+
return []
|
|
389
|
+
removed: list[str] = []
|
|
390
|
+
|
|
391
|
+
def walk(dir_path: Path) -> None:
|
|
392
|
+
for entry in list(dir_path.iterdir()):
|
|
393
|
+
if entry.is_dir():
|
|
394
|
+
if entry.name in _FORBIDDEN_CHILD_SKILLS:
|
|
395
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
396
|
+
removed.append(entry.name)
|
|
397
|
+
else:
|
|
398
|
+
walk(entry)
|
|
399
|
+
elif entry.name == "SKILL.md":
|
|
400
|
+
skill_name = entry.parent.name
|
|
401
|
+
if skill_name in _FORBIDDEN_CHILD_SKILLS:
|
|
402
|
+
shutil.rmtree(entry.parent, ignore_errors=True)
|
|
403
|
+
removed.append(skill_name)
|
|
404
|
+
|
|
405
|
+
walk(skills_root)
|
|
406
|
+
return sorted(set(removed))
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _seed_native_user_overlay(
|
|
410
|
+
runtime: str,
|
|
411
|
+
template: str,
|
|
412
|
+
*,
|
|
413
|
+
display_name: str = "",
|
|
414
|
+
role: str = "",
|
|
415
|
+
tagline: str = "",
|
|
416
|
+
user_soul: str = "",
|
|
417
|
+
) -> None:
|
|
418
|
+
"""Keep template SOUL/AGENTS on disk; layer user identity in registry overlay."""
|
|
419
|
+
runtime = _srv().safe_profile_slug(runtime)
|
|
420
|
+
template = _srv().safe_profile_slug(template)
|
|
421
|
+
if not runtime:
|
|
422
|
+
return
|
|
423
|
+
_write_profile_soul_if_stub(runtime, template)
|
|
424
|
+
if display_name.strip() or role.strip() or tagline.strip() or user_soul.strip():
|
|
425
|
+
_apply_profile_identity(
|
|
426
|
+
runtime,
|
|
427
|
+
display_name=display_name,
|
|
428
|
+
role=role,
|
|
429
|
+
tagline=tagline,
|
|
430
|
+
user_soul=user_soul,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _apply_profile_identity(
|
|
435
|
+
profile: str,
|
|
436
|
+
*,
|
|
437
|
+
display_name: str = "",
|
|
438
|
+
role: str = "",
|
|
439
|
+
tagline: str = "",
|
|
440
|
+
user_soul: str = "",
|
|
441
|
+
) -> None:
|
|
442
|
+
prof = _srv().safe_profile_slug(profile)
|
|
443
|
+
patch: dict[str, Any] = {}
|
|
444
|
+
if display_name.strip():
|
|
445
|
+
patch["display_name"] = display_name.strip()
|
|
446
|
+
if role.strip():
|
|
447
|
+
patch["role"] = role.strip()
|
|
448
|
+
if tagline.strip():
|
|
449
|
+
patch["tagline"] = tagline.strip()
|
|
450
|
+
if user_soul.strip():
|
|
451
|
+
patch["user_soul"] = user_soul.strip()
|
|
452
|
+
if patch.get("display_name"):
|
|
453
|
+
aid = _srv()._avatar_id_for_display_name(str(patch["display_name"]))
|
|
454
|
+
if aid:
|
|
455
|
+
patch.update(_srv()._normalize_agent_avatar_patch("", aid))
|
|
456
|
+
if patch:
|
|
457
|
+
_srv()._upsert_agent_registry_row(prof, patch)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _profile_identity_overlay(profile: str) -> str:
|
|
461
|
+
prof = _srv().safe_profile_slug(profile)
|
|
462
|
+
if _srv()._is_runtime_profile_slug(prof):
|
|
463
|
+
template = _runtime_template_slug(prof)
|
|
464
|
+
reg = {**_srv()._agent_registry_row(template), **_srv()._agent_registry_row(prof)}
|
|
465
|
+
else:
|
|
466
|
+
reg = _srv()._agent_registry_row(prof)
|
|
467
|
+
blocks: list[str] = []
|
|
468
|
+
identity_lines: list[str] = []
|
|
469
|
+
display = str(reg.get("display_name") or "").strip()
|
|
470
|
+
role = str(reg.get("role") or reg.get("description") or "").strip()
|
|
471
|
+
tagline = str(reg.get("tagline") or "").strip()
|
|
472
|
+
if display:
|
|
473
|
+
identity_lines.append(f"- **Name:** {display}")
|
|
474
|
+
if role:
|
|
475
|
+
identity_lines.append(f"- **Role:** {role}")
|
|
476
|
+
if tagline:
|
|
477
|
+
identity_lines.append(f"- **Tagline:** {tagline}")
|
|
478
|
+
if identity_lines:
|
|
479
|
+
blocks.append("## User identity\n" + "\n".join(identity_lines))
|
|
480
|
+
user_soul = str(reg.get("user_soul") or "").strip()
|
|
481
|
+
if user_soul:
|
|
482
|
+
blocks.append("## Additional instructions\n" + user_soul)
|
|
483
|
+
return "\n\n".join(blocks).strip()
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _install_child_base_artifacts(profile: str, *, display_name: str, role: str) -> bool:
|
|
487
|
+
"""Write child SOUL/AGENTS when profile was cloned from native concierge."""
|
|
488
|
+
prof = _srv().safe_profile_slug(profile)
|
|
489
|
+
if not prof or _is_native_profile(prof):
|
|
490
|
+
return False
|
|
491
|
+
label = display_name.strip() or _profile_display_name(prof)
|
|
492
|
+
mission = role.strip() or f"{label} specialist for {_srv().PROJECT_NAME}."
|
|
493
|
+
raw = _profile_soul_raw(prof)
|
|
494
|
+
wrote = False
|
|
495
|
+
if _soul_is_stub(raw) or _soul_is_native_concierge(raw):
|
|
496
|
+
body = _format_child_template(_CHILD_SOUL_TEMPLATE, display_name=label, role=mission)
|
|
497
|
+
soul_path = _srv()._profile_dir(prof) / "SOUL.md"
|
|
498
|
+
soul_path.parent.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
soul_path.write_text(body if body.endswith("\n") else f"{body}\n", encoding="utf-8")
|
|
500
|
+
wrote = True
|
|
501
|
+
agents_path = _srv()._profile_dir(prof) / "AGENTS.md"
|
|
502
|
+
if not agents_path.is_file():
|
|
503
|
+
agents_body = _format_child_template(_CHILD_AGENTS_TEMPLATE, display_name=label)
|
|
504
|
+
agents_path.write_text(agents_body.strip() + "\n", encoding="utf-8")
|
|
505
|
+
wrote = True
|
|
506
|
+
return wrote
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _profile_base_soul_text(profile: str) -> str:
|
|
510
|
+
prof = _srv().safe_profile_slug(str(profile or "").strip())
|
|
511
|
+
if not prof:
|
|
512
|
+
return ""
|
|
513
|
+
path = _profile_soul_path(prof)
|
|
514
|
+
raw = ""
|
|
515
|
+
try:
|
|
516
|
+
if path.is_file():
|
|
517
|
+
raw = path.read_text(encoding="utf-8-sig", errors="replace").strip()
|
|
518
|
+
except OSError:
|
|
519
|
+
raw = ""
|
|
520
|
+
template = _runtime_template_slug(prof) if _srv()._is_runtime_profile_slug(prof) else prof
|
|
521
|
+
if _soul_is_stub(raw) and template:
|
|
522
|
+
tpl_path = _srv()._profile_dir(template) / "SOUL.md"
|
|
523
|
+
try:
|
|
524
|
+
if tpl_path.is_file() and tpl_path != path:
|
|
525
|
+
raw = tpl_path.read_text(encoding="utf-8-sig", errors="replace").strip()
|
|
526
|
+
except OSError:
|
|
527
|
+
pass
|
|
528
|
+
if not raw:
|
|
529
|
+
return ""
|
|
530
|
+
return _render_soul_placeholders(raw).strip()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _profile_soul_text(profile: str) -> str:
|
|
534
|
+
base = _profile_base_soul_text(profile)
|
|
535
|
+
overlay = _profile_identity_overlay(profile)
|
|
536
|
+
if base and overlay:
|
|
537
|
+
return f"{base.rstrip()}\n\n---\n\n{overlay}"
|
|
538
|
+
return overlay or base
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _write_profile_soul_if_stub(profile: str, template: str = "") -> bool:
|
|
542
|
+
"""Persist rendered template SOUL when runtime file is stub. Returns True if written."""
|
|
543
|
+
prof = _srv().safe_profile_slug(profile)
|
|
544
|
+
tpl = _srv().safe_profile_slug(template or (_runtime_template_slug(prof) if _srv()._is_runtime_profile_slug(prof) else prof))
|
|
545
|
+
if not prof or not tpl:
|
|
546
|
+
return False
|
|
547
|
+
dst = _srv()._profile_dir(prof) / "SOUL.md"
|
|
548
|
+
try:
|
|
549
|
+
current = dst.read_text(encoding="utf-8-sig", errors="replace") if dst.is_file() else ""
|
|
550
|
+
except OSError:
|
|
551
|
+
current = ""
|
|
552
|
+
if not _soul_is_stub(current):
|
|
553
|
+
return False
|
|
554
|
+
src = _srv()._profile_dir(tpl) / "SOUL.md"
|
|
555
|
+
if not src.is_file():
|
|
556
|
+
return False
|
|
557
|
+
try:
|
|
558
|
+
rendered = _render_soul_placeholders(src.read_text(encoding="utf-8-sig", errors="replace")).strip()
|
|
559
|
+
if not rendered or _soul_is_stub(rendered):
|
|
560
|
+
return False
|
|
561
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
562
|
+
dst.write_text(rendered if rendered.endswith("\n") else f"{rendered}\n", encoding="utf-8")
|
|
563
|
+
return True
|
|
564
|
+
except OSError:
|
|
565
|
+
return False
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _profile_soul_path(profile: str) -> Path:
|
|
569
|
+
profile_soul = _profile_home(profile) / "SOUL.md"
|
|
570
|
+
if profile_soul.is_file():
|
|
571
|
+
return profile_soul
|
|
572
|
+
if profile == _primary_profile():
|
|
573
|
+
root_soul = _srv().HERMES_DATA / "SOUL.md"
|
|
574
|
+
if root_soul.is_file():
|
|
575
|
+
return root_soul
|
|
576
|
+
return profile_soul
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _profile_soul_raw(profile: str) -> str:
|
|
580
|
+
path = _profile_soul_path(profile)
|
|
581
|
+
try:
|
|
582
|
+
return path.read_text(encoding="utf-8-sig", errors="replace").strip()
|
|
583
|
+
except OSError:
|
|
584
|
+
return ""
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _file_size_mb(path: Path) -> float:
|
|
588
|
+
try:
|
|
589
|
+
return path.stat().st_size / (1024 * 1024)
|
|
590
|
+
except OSError:
|
|
591
|
+
return 0.0
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def gateway_data(profile: str) -> dict[str, Any]:
|
|
597
|
+
path = _srv()._profile_dir(profile) / "gateway_state.json"
|
|
598
|
+
base: dict[str, Any] = {
|
|
599
|
+
"ok": False,
|
|
600
|
+
"exists": path.is_file(),
|
|
601
|
+
"state": "unknown",
|
|
602
|
+
"platforms": {},
|
|
603
|
+
"active_agents": 0,
|
|
604
|
+
"uptime": None,
|
|
605
|
+
"uptime_seconds": None,
|
|
606
|
+
"updated_at": None,
|
|
607
|
+
}
|
|
608
|
+
if not path.is_file():
|
|
609
|
+
return base
|
|
610
|
+
try:
|
|
611
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
612
|
+
except (OSError, json.JSONDecodeError):
|
|
613
|
+
return base
|
|
614
|
+
start = raw.get("start_time")
|
|
615
|
+
uptime = None
|
|
616
|
+
if isinstance(start, (int, float)) and float(start) > 1_000_000_000:
|
|
617
|
+
uptime = max(0.0, time.time() - float(start))
|
|
618
|
+
platforms = raw.get("platforms") or {}
|
|
619
|
+
for name, info in platforms.items():
|
|
620
|
+
if isinstance(info, dict) and "status" not in info:
|
|
621
|
+
info = {**info, "status": info.get("state", "unknown")}
|
|
622
|
+
platforms[name] = info
|
|
623
|
+
base.update(
|
|
624
|
+
{
|
|
625
|
+
"ok": True,
|
|
626
|
+
"state": raw.get("gateway_state") or raw.get("state") or "unknown",
|
|
627
|
+
"kind": raw.get("kind"),
|
|
628
|
+
"pid": raw.get("pid"),
|
|
629
|
+
"platforms": platforms,
|
|
630
|
+
"active_agents": raw.get("active_agents", 0),
|
|
631
|
+
"updated_at": raw.get("updated_at"),
|
|
632
|
+
"uptime": uptime,
|
|
633
|
+
"uptime_seconds": uptime,
|
|
634
|
+
"raw": raw,
|
|
635
|
+
}
|
|
636
|
+
)
|
|
637
|
+
return base
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def sessions_data(profile: str) -> dict[str, Any]:
|
|
641
|
+
db = _srv()._profile_dir(profile) / "state.db"
|
|
642
|
+
out: dict[str, Any] = {
|
|
643
|
+
"ok": False,
|
|
644
|
+
"count": 0,
|
|
645
|
+
"session_count": 0,
|
|
646
|
+
"message_count": 0,
|
|
647
|
+
"recent": [],
|
|
648
|
+
}
|
|
649
|
+
conn = _srv()._ro_sqlite_live(db)
|
|
650
|
+
if not conn:
|
|
651
|
+
return out
|
|
652
|
+
try:
|
|
653
|
+
row = conn.execute(
|
|
654
|
+
"""
|
|
655
|
+
SELECT COUNT(*) AS sessions,
|
|
656
|
+
COALESCE(SUM(message_count), 0) AS messages
|
|
657
|
+
FROM sessions
|
|
658
|
+
"""
|
|
659
|
+
).fetchone()
|
|
660
|
+
recent = conn.execute(
|
|
661
|
+
"""
|
|
662
|
+
SELECT id, source, title, started_at, ended_at, message_count,
|
|
663
|
+
model, estimated_cost_usd
|
|
664
|
+
FROM sessions
|
|
665
|
+
ORDER BY started_at DESC
|
|
666
|
+
LIMIT 25
|
|
667
|
+
"""
|
|
668
|
+
).fetchall()
|
|
669
|
+
count = int(row["sessions"] or 0)
|
|
670
|
+
messages = int(row["messages"] or 0)
|
|
671
|
+
out.update(
|
|
672
|
+
{
|
|
673
|
+
"ok": True,
|
|
674
|
+
"count": count,
|
|
675
|
+
"session_count": count,
|
|
676
|
+
"message_count": messages,
|
|
677
|
+
"recent": [
|
|
678
|
+
{
|
|
679
|
+
"id": r["id"],
|
|
680
|
+
"source": r["source"],
|
|
681
|
+
"title": r["title"] or "(untitled)",
|
|
682
|
+
"started_at": r["started_at"],
|
|
683
|
+
"started_at_iso": _srv()._iso_from_unix(r["started_at"]),
|
|
684
|
+
"ended_at": r["ended_at"],
|
|
685
|
+
"message_count": r["message_count"] or 0,
|
|
686
|
+
"model": r["model"] or "",
|
|
687
|
+
"cost_usd": r["estimated_cost_usd"],
|
|
688
|
+
}
|
|
689
|
+
for r in recent
|
|
690
|
+
],
|
|
691
|
+
}
|
|
692
|
+
)
|
|
693
|
+
except sqlite3.Error:
|
|
694
|
+
pass
|
|
695
|
+
finally:
|
|
696
|
+
conn.close()
|
|
697
|
+
return out
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _profile_slug(profile: str) -> str:
|
|
701
|
+
if profile.endswith("-agent"):
|
|
702
|
+
return profile[: -len("-agent")]
|
|
703
|
+
return profile
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _agent_label(profile: str) -> str:
|
|
707
|
+
return _profile_slug(profile)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _native_display_name() -> str:
|
|
711
|
+
return f"{_srv().PROJECT_NAME} Agent"
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _is_native_profile(profile: str) -> bool:
|
|
715
|
+
return bool(_srv().NATIVE_PROFILE and profile == _srv().NATIVE_PROFILE)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _agent_db_display_name(template_slug: str, workspace_id: str = "") -> str:
|
|
719
|
+
template_slug = _srv().safe_profile_slug(str(template_slug or "").strip())
|
|
720
|
+
if not template_slug:
|
|
721
|
+
return ""
|
|
722
|
+
conn = _srv()._workframe_db()
|
|
723
|
+
try:
|
|
724
|
+
if workspace_id:
|
|
725
|
+
row = _srv()._lookup_agent_profile(conn, workspace_id, template_slug)
|
|
726
|
+
else:
|
|
727
|
+
row = conn.execute(
|
|
728
|
+
"""
|
|
729
|
+
SELECT display_name FROM agent_profiles
|
|
730
|
+
WHERE slug = ? AND deleted_at IS NULL
|
|
731
|
+
ORDER BY is_native DESC, updated_at DESC
|
|
732
|
+
LIMIT 1
|
|
733
|
+
""",
|
|
734
|
+
(template_slug,),
|
|
735
|
+
).fetchone()
|
|
736
|
+
if row and str(row["display_name"] or "").strip():
|
|
737
|
+
return str(row["display_name"]).strip()
|
|
738
|
+
finally:
|
|
739
|
+
conn.close()
|
|
740
|
+
return ""
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _profile_display_name(profile: str, workspace_id: str = "") -> str:
|
|
744
|
+
template = _runtime_template_slug(profile) if _srv()._is_runtime_profile_slug(profile) else profile
|
|
745
|
+
db_name = _agent_db_display_name(template, workspace_id)
|
|
746
|
+
if db_name:
|
|
747
|
+
return db_name
|
|
748
|
+
reg = _srv()._agent_registry_row(template)
|
|
749
|
+
if reg.get("display_name"):
|
|
750
|
+
return str(reg["display_name"])
|
|
751
|
+
if _is_native_profile(template):
|
|
752
|
+
return _native_display_name()
|
|
753
|
+
slug = _profile_slug(template)
|
|
754
|
+
return slug.replace("-", " ").title()
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _default_session_title(profile: str) -> str:
|
|
758
|
+
display = _profile_display_name(profile).strip() or "Agent"
|
|
759
|
+
return f"Session with {display}"
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _create_profile_session_via_api(profile: str, session_id: str, title: str) -> tuple[int, Any, str]:
|
|
763
|
+
base_title = (title or "").strip() or _default_session_title(profile)
|
|
764
|
+
candidate = base_title
|
|
765
|
+
for attempt in range(1, 25):
|
|
766
|
+
status, data = _srv()._profile_api_request(
|
|
767
|
+
profile,
|
|
768
|
+
"POST",
|
|
769
|
+
"/api/sessions",
|
|
770
|
+
{"session_id": session_id, "title": candidate},
|
|
771
|
+
)
|
|
772
|
+
if status < 300:
|
|
773
|
+
return status, data, candidate
|
|
774
|
+
msg = json.dumps(data).lower() if isinstance(data, dict) else str(data).lower()
|
|
775
|
+
if "invalid_title" not in msg and "already in use" not in msg:
|
|
776
|
+
return status, data, candidate
|
|
777
|
+
if attempt < 12:
|
|
778
|
+
candidate = f"{base_title} ({attempt + 1})"
|
|
779
|
+
else:
|
|
780
|
+
candidate = f"{base_title} {int(time.time())}-{attempt}"
|
|
781
|
+
return status, data, candidate
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _profile_role(profile: str) -> str:
|
|
785
|
+
if _is_native_profile(profile):
|
|
786
|
+
return (
|
|
787
|
+
f"Workframe Manager and orchestrator for {_srv().PROJECT_NAME}. "
|
|
788
|
+
"Routes work to installed specialist profiles."
|
|
789
|
+
)
|
|
790
|
+
slug = _profile_slug(profile)
|
|
791
|
+
return _srv().SPECIALIST_ROLES.get(slug, f"{_profile_display_name(profile)} specialist profile.")
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _profile_code(display_name: str, slug: str) -> str:
|
|
795
|
+
word = (display_name.split()[0] if display_name else slug)[:4]
|
|
796
|
+
return word.upper() or "AGNT"
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def safe_profile_slug(value: str) -> str:
|
|
800
|
+
slug = (value or "").strip()
|
|
801
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", slug):
|
|
802
|
+
raise ValueError("invalid profile")
|
|
803
|
+
return slug
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def profile_exists(profile: str) -> bool:
|
|
807
|
+
return _srv()._profile_dir(profile).is_dir()
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def route_status_for_profile(profile: str) -> str:
|
|
811
|
+
if not profile_exists(profile):
|
|
812
|
+
return "not_installed"
|
|
813
|
+
return "available"
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _register_profile_route(profile: str, meta: dict[str, Any] | None = None) -> bool:
|
|
817
|
+
"""Append an on-disk profile to routes.json when missing (post profile_create)."""
|
|
818
|
+
prof = _srv().safe_profile_slug(profile)
|
|
819
|
+
if not profile_exists(prof):
|
|
820
|
+
return False
|
|
821
|
+
_srv().ROUTES_JSON.parent.mkdir(parents=True, exist_ok=True)
|
|
822
|
+
data: dict[str, Any] = {"version": 1, "routes": []}
|
|
823
|
+
if _srv().ROUTES_JSON.is_file():
|
|
824
|
+
try:
|
|
825
|
+
parsed = json.loads(_srv().ROUTES_JSON.read_text(encoding="utf-8"))
|
|
826
|
+
if isinstance(parsed, dict):
|
|
827
|
+
data = parsed
|
|
828
|
+
except (OSError, json.JSONDecodeError):
|
|
829
|
+
pass
|
|
830
|
+
routes = data.get("routes")
|
|
831
|
+
if not isinstance(routes, list):
|
|
832
|
+
routes = []
|
|
833
|
+
data["routes"] = routes
|
|
834
|
+
if any(isinstance(row, dict) and str(row.get("profile") or "") == prof for row in routes):
|
|
835
|
+
return False
|
|
836
|
+
routes.append(_route_record(prof, meta))
|
|
837
|
+
if not data.get("default_profile"):
|
|
838
|
+
data["default_profile"] = _primary_profile()
|
|
839
|
+
_srv().ROUTES_JSON.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
840
|
+
return True
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _route_record(profile: str, meta: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
844
|
+
meta = meta or {}
|
|
845
|
+
slug = _srv().safe_profile_slug(profile)
|
|
846
|
+
ident = _srv()._agent_identity_fields(slug)
|
|
847
|
+
row = {
|
|
848
|
+
"id": str(meta.get("id") or slug),
|
|
849
|
+
"profile": slug,
|
|
850
|
+
"display_name": str(ident.get("display_name") or meta.get("display_name") or _profile_display_name(slug)),
|
|
851
|
+
"role": str(ident.get("role") or meta.get("role") or _profile_role(slug)),
|
|
852
|
+
"mode": "lane",
|
|
853
|
+
"route_status": route_status_for_profile(slug),
|
|
854
|
+
}
|
|
855
|
+
if ident.get("avatar_url"):
|
|
856
|
+
row["avatar_url"] = str(ident["avatar_url"])
|
|
857
|
+
if ident.get("avatar_id"):
|
|
858
|
+
row["avatar_id"] = str(ident["avatar_id"])
|
|
859
|
+
_srv()._resolve_avatar_fields(row)
|
|
860
|
+
return row
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def load_routes() -> dict[str, Any]:
|
|
864
|
+
default_profile = _primary_profile()
|
|
865
|
+
raw_routes: list[dict[str, Any]] = []
|
|
866
|
+
if _srv().ROUTES_JSON.is_file():
|
|
867
|
+
try:
|
|
868
|
+
data = json.loads(_srv().ROUTES_JSON.read_text(encoding="utf-8"))
|
|
869
|
+
if isinstance(data, dict) and isinstance(data.get("routes"), list):
|
|
870
|
+
raw_routes = data["routes"]
|
|
871
|
+
if data.get("default_profile"):
|
|
872
|
+
default_profile = _srv().safe_profile_slug(str(data["default_profile"]))
|
|
873
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
874
|
+
raw_routes = []
|
|
875
|
+
|
|
876
|
+
routes: list[dict[str, Any]] = []
|
|
877
|
+
seen: set[str] = set()
|
|
878
|
+
for entry in raw_routes:
|
|
879
|
+
if not isinstance(entry, dict):
|
|
880
|
+
continue
|
|
881
|
+
try:
|
|
882
|
+
profile = _srv().safe_profile_slug(str(entry.get("profile") or entry.get("id") or ""))
|
|
883
|
+
except ValueError:
|
|
884
|
+
continue
|
|
885
|
+
if not profile_exists(profile) or profile in seen:
|
|
886
|
+
continue
|
|
887
|
+
seen.add(profile)
|
|
888
|
+
routes.append(_route_record(profile, entry))
|
|
889
|
+
|
|
890
|
+
if not routes:
|
|
891
|
+
for profile in _list_profiles():
|
|
892
|
+
if profile in seen:
|
|
893
|
+
continue
|
|
894
|
+
seen.add(profile)
|
|
895
|
+
routes.append(_route_record(profile, {}))
|
|
896
|
+
|
|
897
|
+
route_profiles = {r["profile"] for r in routes}
|
|
898
|
+
if default_profile and default_profile not in route_profiles and profile_exists(default_profile):
|
|
899
|
+
routes.insert(0, _route_record(default_profile, {}))
|
|
900
|
+
route_profiles.add(default_profile)
|
|
901
|
+
native = _srv().safe_profile_slug(_srv().NATIVE_PROFILE) if _srv().NATIVE_PROFILE else ""
|
|
902
|
+
if native and native not in route_profiles and profile_exists(native):
|
|
903
|
+
routes.insert(0, _route_record(native, {}))
|
|
904
|
+
route_profiles.add(native)
|
|
905
|
+
if default_profile and default_profile not in route_profiles:
|
|
906
|
+
default_profile = routes[0]["profile"] if routes else default_profile
|
|
907
|
+
|
|
908
|
+
return {"ok": True, "default_profile": default_profile, "routes": routes}
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def resolve_validated_profile(profile: str) -> str:
|
|
912
|
+
raw = str(profile or _primary_profile()).strip()
|
|
913
|
+
if _AGENT_PROFILE_UUID_RE.match(raw):
|
|
914
|
+
resolved = _srv()._hermes_slug_from_agent_ref(raw)
|
|
915
|
+
if resolved:
|
|
916
|
+
raw = resolved
|
|
917
|
+
slug = _srv().safe_profile_slug(raw)
|
|
918
|
+
allowed = {r["profile"] for r in load_routes()["routes"]}
|
|
919
|
+
if slug not in allowed:
|
|
920
|
+
raise ValueError(f"unknown profile: {slug}")
|
|
921
|
+
if not profile_exists(slug):
|
|
922
|
+
raise ValueError(f"profile not installed: {slug}")
|
|
923
|
+
return slug
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
_RUNTIME_PROFILE_RE = re.compile(r"^u-[a-z0-9][a-z0-9-]{0,62}$")
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _is_runtime_profile_slug(slug: str) -> bool:
|
|
930
|
+
return bool(_RUNTIME_PROFILE_RE.fullmatch(str(slug or "").strip()))
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _runtime_template_slug(runtime: str) -> str:
|
|
934
|
+
"""Map per-user runtime slug u-{user}-{template} back to the template profile."""
|
|
935
|
+
slug = _srv().safe_profile_slug(str(runtime or "").strip())
|
|
936
|
+
if not _srv()._is_runtime_profile_slug(slug):
|
|
937
|
+
return slug
|
|
938
|
+
rest = slug[2:]
|
|
939
|
+
templates: set[str] = {p for p in _list_profiles() if not _srv()._is_runtime_profile_slug(p)}
|
|
940
|
+
templates.update(_srv().load_agent_registry().keys())
|
|
941
|
+
native = _primary_profile()
|
|
942
|
+
if native:
|
|
943
|
+
templates.add(native)
|
|
944
|
+
for template in sorted(templates, key=len, reverse=True):
|
|
945
|
+
if rest == template or rest.endswith(f"-{template}"):
|
|
946
|
+
return template
|
|
947
|
+
return rest
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
_DEFAULT_CHAT_TOOLSETS = ("hermes-cli", "terminal")
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _chat_toolsets_for_profile(_profile: str = "") -> tuple[str, ...]:
|
|
954
|
+
"""Multi-user safety: runtime RBAC + exec credential guards, not disabled terminal."""
|
|
955
|
+
return _DEFAULT_CHAT_TOOLSETS
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _ensure_profile_toolsets(profile: str, toolsets: tuple[str, ...] | None = None) -> None:
|
|
959
|
+
"""Ensure Hermes toolsets (e.g. terminal) are enabled on a profile config."""
|
|
960
|
+
prof = _srv().safe_profile_slug(str(profile or "").strip())
|
|
961
|
+
cfg_path = _profile_gateway_config_path(prof)
|
|
962
|
+
if not prof or cfg_path is None:
|
|
963
|
+
return
|
|
964
|
+
_normalize_profile_config_yaml(prof)
|
|
965
|
+
want = list(toolsets if toolsets is not None else _chat_toolsets_for_profile(prof))
|
|
966
|
+
if _srv()._profile_toolsets_ready(prof, want):
|
|
967
|
+
return
|
|
968
|
+
try:
|
|
969
|
+
import yaml
|
|
970
|
+
|
|
971
|
+
cfg: dict[str, Any] = {}
|
|
972
|
+
if cfg_path.is_file():
|
|
973
|
+
loaded = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
|
974
|
+
cfg = loaded if isinstance(loaded, dict) else {}
|
|
975
|
+
ts = cfg.setdefault("toolsets", [])
|
|
976
|
+
if not isinstance(ts, list):
|
|
977
|
+
ts = []
|
|
978
|
+
cfg["toolsets"] = ts
|
|
979
|
+
pts = cfg.setdefault("platform_toolsets", {})
|
|
980
|
+
if not isinstance(pts, dict):
|
|
981
|
+
pts = {}
|
|
982
|
+
cfg["platform_toolsets"] = pts
|
|
983
|
+
changed = False
|
|
984
|
+
for name in want:
|
|
985
|
+
if name not in ts:
|
|
986
|
+
ts.append(name)
|
|
987
|
+
changed = True
|
|
988
|
+
for platform in ("api_server", "cli"):
|
|
989
|
+
plat = pts.setdefault(platform, [])
|
|
990
|
+
if not isinstance(plat, list):
|
|
991
|
+
plat = []
|
|
992
|
+
pts[platform] = plat
|
|
993
|
+
for name in want:
|
|
994
|
+
if name not in plat:
|
|
995
|
+
plat.append(name)
|
|
996
|
+
changed = True
|
|
997
|
+
agent = cfg.setdefault("agent", {})
|
|
998
|
+
if not isinstance(agent, dict):
|
|
999
|
+
agent = {}
|
|
1000
|
+
cfg["agent"] = agent
|
|
1001
|
+
disabled = agent.get("disabled_toolsets") if isinstance(agent.get("disabled_toolsets"), list) else []
|
|
1002
|
+
for name in want:
|
|
1003
|
+
if name in disabled:
|
|
1004
|
+
disabled = [x for x in disabled if x != name]
|
|
1005
|
+
changed = True
|
|
1006
|
+
agent["disabled_toolsets"] = disabled
|
|
1007
|
+
if changed:
|
|
1008
|
+
cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
|
|
1009
|
+
except (OSError, ImportError):
|
|
1010
|
+
return
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def resolve_hermes_profile(profile: str) -> str:
|
|
1014
|
+
"""Resolve a Hermes profile dir for API calls; runtime u-* slugs skip route rail."""
|
|
1015
|
+
raw = str(profile or _primary_profile()).strip()
|
|
1016
|
+
if _AGENT_PROFILE_UUID_RE.match(raw):
|
|
1017
|
+
resolved = _srv()._hermes_slug_from_agent_ref(raw)
|
|
1018
|
+
if resolved:
|
|
1019
|
+
raw = resolved
|
|
1020
|
+
slug = _srv().safe_profile_slug(raw)
|
|
1021
|
+
if _srv()._is_runtime_profile_slug(slug):
|
|
1022
|
+
if not profile_exists(slug):
|
|
1023
|
+
raise ValueError(f"profile not installed: {slug}")
|
|
1024
|
+
return slug
|
|
1025
|
+
return _srv().resolve_validated_profile(slug)
|
|
1026
|
+
def profile_create(
|
|
1027
|
+
name: str,
|
|
1028
|
+
model: str = "",
|
|
1029
|
+
description: str = "",
|
|
1030
|
+
clone_from: str = "",
|
|
1031
|
+
soul: str = "",
|
|
1032
|
+
display_name: str = "",
|
|
1033
|
+
role: str = "",
|
|
1034
|
+
tagline: str = "",
|
|
1035
|
+
avatar_url: str = "",
|
|
1036
|
+
avatar_id: str = "",
|
|
1037
|
+
*,
|
|
1038
|
+
user_id: str = "",
|
|
1039
|
+
workspace_id: str = "",
|
|
1040
|
+
bootstrap_dm: bool = True,
|
|
1041
|
+
) -> dict[str, Any]:
|
|
1042
|
+
"""Create a new Hermes profile via docker exec. Returns profile info."""
|
|
1043
|
+
# Validate name: lowercase, alphanumeric, hyphens
|
|
1044
|
+
import re as _re
|
|
1045
|
+
if not _re.match(r'^[a-z][a-z0-9-]{0,63}$', name):
|
|
1046
|
+
raise ValueError("profile name must be lowercase alphanumeric, start with a letter, max 64 chars")
|
|
1047
|
+
|
|
1048
|
+
# Check if profile already exists — register in workspace and return idempotently.
|
|
1049
|
+
pdir = _srv()._profile_dir(name)
|
|
1050
|
+
if pdir.exists():
|
|
1051
|
+
registry_patch: dict[str, Any] = {}
|
|
1052
|
+
if display_name.strip():
|
|
1053
|
+
registry_patch["display_name"] = display_name.strip()
|
|
1054
|
+
if role.strip():
|
|
1055
|
+
registry_patch["role"] = role.strip()
|
|
1056
|
+
elif description.strip():
|
|
1057
|
+
registry_patch["role"] = description.strip()
|
|
1058
|
+
if tagline.strip():
|
|
1059
|
+
registry_patch["tagline"] = tagline.strip()
|
|
1060
|
+
if description.strip():
|
|
1061
|
+
registry_patch["description"] = description.strip()
|
|
1062
|
+
if registry_patch:
|
|
1063
|
+
_srv()._upsert_agent_registry_row(name, registry_patch)
|
|
1064
|
+
_register_profile_route(
|
|
1065
|
+
name,
|
|
1066
|
+
{
|
|
1067
|
+
"display_name": display_name.strip() or name,
|
|
1068
|
+
"role": role.strip() or description.strip(),
|
|
1069
|
+
},
|
|
1070
|
+
)
|
|
1071
|
+
prof = _srv().resolve_validated_profile(name)
|
|
1072
|
+
_srv()._ensure_workspace_agent_profile_row(
|
|
1073
|
+
prof,
|
|
1074
|
+
workspace_id=workspace_id,
|
|
1075
|
+
display_name=display_name.strip() or prof,
|
|
1076
|
+
role=role.strip() or description.strip(),
|
|
1077
|
+
tagline=tagline.strip(),
|
|
1078
|
+
)
|
|
1079
|
+
soul_text = str(soul or "").strip()
|
|
1080
|
+
if soul_text:
|
|
1081
|
+
_apply_profile_identity(name, user_soul=soul_text)
|
|
1082
|
+
results: dict[str, Any] = {
|
|
1083
|
+
"ok": True,
|
|
1084
|
+
"profile": prof,
|
|
1085
|
+
"already_exists": True,
|
|
1086
|
+
"steps": [{"step": "reuse_existing_profile", "ok": True}],
|
|
1087
|
+
}
|
|
1088
|
+
if bootstrap_dm and user_id and workspace_id:
|
|
1089
|
+
try:
|
|
1090
|
+
lane = _srv().bootstrap_agent_dm_lane(
|
|
1091
|
+
user_id,
|
|
1092
|
+
workspace_id,
|
|
1093
|
+
prof,
|
|
1094
|
+
model=model,
|
|
1095
|
+
bind_session=True,
|
|
1096
|
+
room_name=display_name.strip() or prof,
|
|
1097
|
+
created_by=user_id,
|
|
1098
|
+
)
|
|
1099
|
+
results["bootstrap"] = lane
|
|
1100
|
+
results["room_id"] = lane.get("room_id")
|
|
1101
|
+
results["runtime_profile"] = lane.get("runtime")
|
|
1102
|
+
results["session_id"] = lane.get("session_id")
|
|
1103
|
+
if lane.get("room"):
|
|
1104
|
+
results["room"] = lane["room"]
|
|
1105
|
+
results["steps"].extend(lane.get("steps") or [])
|
|
1106
|
+
except Exception as exc:
|
|
1107
|
+
results["steps"].append({"step": "bootstrap_dm_lane", "ok": False, "error": str(exc)})
|
|
1108
|
+
return results
|
|
1109
|
+
|
|
1110
|
+
prof = _srv().safe_profile_slug(name)
|
|
1111
|
+
if not clone_from.strip():
|
|
1112
|
+
try:
|
|
1113
|
+
clone_slug = _srv().resolve_validated_profile(_primary_profile())
|
|
1114
|
+
except ValueError as exc:
|
|
1115
|
+
raise ValueError(f"native agent profile required to spawn children: {exc}") from exc
|
|
1116
|
+
else:
|
|
1117
|
+
clone_slug = _srv().resolve_validated_profile(clone_from)
|
|
1118
|
+
results: dict[str, Any] = {"profile": prof, "steps": []}
|
|
1119
|
+
|
|
1120
|
+
# 1. Create profile via hermes CLI — clone native capabilities minus forbidden skills.
|
|
1121
|
+
cmd = ["profile", "create", "--clone-from", clone_slug]
|
|
1122
|
+
if description:
|
|
1123
|
+
cmd.extend(["--description", description])
|
|
1124
|
+
cmd.append(prof)
|
|
1125
|
+
|
|
1126
|
+
try:
|
|
1127
|
+
code, out = _srv()._gateway_exec(_primary_profile(), cmd)
|
|
1128
|
+
results["steps"].append({"step": "hermes_profile_create", "ok": code == 0, "output": out.strip()})
|
|
1129
|
+
if code != 0:
|
|
1130
|
+
raise ValueError(f"hermes profile create failed: {out.strip()}")
|
|
1131
|
+
except Exception as exc:
|
|
1132
|
+
results["steps"].append({"step": "hermes_profile_create", "ok": False, "error": str(exc)})
|
|
1133
|
+
raise
|
|
1134
|
+
|
|
1135
|
+
removed_skills = _strip_forbidden_child_skills(prof)
|
|
1136
|
+
results["steps"].append({"step": "strip_forbidden_skills", "ok": True, "removed": removed_skills})
|
|
1137
|
+
child_label = display_name.strip() or prof.replace("-", " ").title()
|
|
1138
|
+
child_role = role.strip() or description.strip() or f"{child_label} specialist for {_srv().PROJECT_NAME}."
|
|
1139
|
+
if _install_child_base_artifacts(prof, display_name=child_label, role=child_role):
|
|
1140
|
+
results["steps"].append({"step": "install_child_base", "ok": True})
|
|
1141
|
+
|
|
1142
|
+
# 2. Start the gateway for this profile
|
|
1143
|
+
try:
|
|
1144
|
+
ok, out, port = _srv()._configure_profile_api(prof)
|
|
1145
|
+
results["steps"].append({"step": "configure_api", "ok": ok, "api_port": port, "output": out.strip()})
|
|
1146
|
+
if ok:
|
|
1147
|
+
ok2, out2 = _srv()._patch_profile_gateway_run_script(prof)
|
|
1148
|
+
results["steps"].append({"step": "patch_run_script", "ok": ok2, "output": out2.strip()})
|
|
1149
|
+
except Exception as exc:
|
|
1150
|
+
results["steps"].append({"step": "configure_api", "ok": False, "error": str(exc)})
|
|
1151
|
+
|
|
1152
|
+
# 3–4. Template model/gateway only when not bootstrapping a per-user DM runtime (C1).
|
|
1153
|
+
if not bootstrap_dm:
|
|
1154
|
+
if model:
|
|
1155
|
+
try:
|
|
1156
|
+
code, out = _srv()._gateway_exec(prof, ["model", model])
|
|
1157
|
+
results["steps"].append({"step": "set_model", "ok": code == 0, "model": model, "output": out.strip()})
|
|
1158
|
+
except Exception as exc:
|
|
1159
|
+
results["steps"].append({"step": "set_model", "ok": False, "error": str(exc)})
|
|
1160
|
+
try:
|
|
1161
|
+
state = _srv().profile_gateway_lifecycle(prof, "start")
|
|
1162
|
+
results["steps"].append({"step": "gateway_start", "ok": True, "state": state})
|
|
1163
|
+
except Exception as exc:
|
|
1164
|
+
results["steps"].append({"step": "gateway_start", "ok": False, "error": str(exc)})
|
|
1165
|
+
|
|
1166
|
+
registry_patch: dict[str, Any] = {}
|
|
1167
|
+
if display_name.strip():
|
|
1168
|
+
registry_patch["display_name"] = display_name.strip()
|
|
1169
|
+
if role.strip():
|
|
1170
|
+
registry_patch["role"] = role.strip()
|
|
1171
|
+
elif description.strip():
|
|
1172
|
+
registry_patch["role"] = description.strip()
|
|
1173
|
+
if tagline.strip():
|
|
1174
|
+
registry_patch["tagline"] = tagline.strip()
|
|
1175
|
+
if description.strip():
|
|
1176
|
+
registry_patch["description"] = description.strip()
|
|
1177
|
+
if registry_patch:
|
|
1178
|
+
_srv()._upsert_agent_registry_row(prof, registry_patch)
|
|
1179
|
+
results["registry"] = registry_patch
|
|
1180
|
+
|
|
1181
|
+
try:
|
|
1182
|
+
avatar = _srv()._assign_agent_avatar(
|
|
1183
|
+
prof,
|
|
1184
|
+
display_name=display_name.strip() or child_label,
|
|
1185
|
+
)
|
|
1186
|
+
results["avatar"] = avatar
|
|
1187
|
+
results["steps"].append({"step": "assign_avatar", "ok": True, **avatar})
|
|
1188
|
+
except Exception as exc:
|
|
1189
|
+
results["steps"].append({"step": "assign_avatar", "ok": False, "error": str(exc)})
|
|
1190
|
+
|
|
1191
|
+
explicit_avatar = _srv()._normalize_agent_avatar_patch(avatar_url, avatar_id)
|
|
1192
|
+
if explicit_avatar.get("avatar_url") or explicit_avatar.get("avatar_id"):
|
|
1193
|
+
_srv()._upsert_agent_registry_row(
|
|
1194
|
+
prof,
|
|
1195
|
+
{
|
|
1196
|
+
**explicit_avatar,
|
|
1197
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
1198
|
+
},
|
|
1199
|
+
)
|
|
1200
|
+
results["avatar"] = explicit_avatar
|
|
1201
|
+
|
|
1202
|
+
soul_text = str(soul or "").strip()
|
|
1203
|
+
if soul_text:
|
|
1204
|
+
_apply_profile_identity(prof, user_soul=soul_text)
|
|
1205
|
+
results["steps"].append({"step": "seed_user_soul", "ok": True})
|
|
1206
|
+
|
|
1207
|
+
_register_profile_route(
|
|
1208
|
+
prof,
|
|
1209
|
+
{
|
|
1210
|
+
"display_name": display_name.strip() or prof,
|
|
1211
|
+
"role": role.strip() or description.strip() or description,
|
|
1212
|
+
},
|
|
1213
|
+
)
|
|
1214
|
+
results["steps"].append({"step": "register_route", "ok": True})
|
|
1215
|
+
|
|
1216
|
+
_srv()._ensure_workspace_agent_profile_row(
|
|
1217
|
+
prof,
|
|
1218
|
+
workspace_id=workspace_id,
|
|
1219
|
+
display_name=display_name.strip() or prof,
|
|
1220
|
+
role=role.strip() or description.strip(),
|
|
1221
|
+
tagline=tagline.strip(),
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
if bootstrap_dm and user_id and workspace_id:
|
|
1225
|
+
try:
|
|
1226
|
+
lane = _srv().bootstrap_agent_dm_lane(
|
|
1227
|
+
user_id,
|
|
1228
|
+
workspace_id,
|
|
1229
|
+
prof,
|
|
1230
|
+
model=model,
|
|
1231
|
+
bind_session=True,
|
|
1232
|
+
room_name=display_name.strip() or prof,
|
|
1233
|
+
created_by=user_id,
|
|
1234
|
+
)
|
|
1235
|
+
results["bootstrap"] = lane
|
|
1236
|
+
results["room_id"] = lane.get("room_id")
|
|
1237
|
+
results["runtime_profile"] = lane.get("runtime")
|
|
1238
|
+
results["session_id"] = lane.get("session_id")
|
|
1239
|
+
if lane.get("room"):
|
|
1240
|
+
results["room"] = lane["room"]
|
|
1241
|
+
results["steps"].extend(lane.get("steps") or [])
|
|
1242
|
+
except Exception as exc:
|
|
1243
|
+
results["steps"].append({"step": "bootstrap_dm_lane", "ok": False, "error": str(exc)})
|
|
1244
|
+
|
|
1245
|
+
results["ok"] = True
|
|
1246
|
+
return results
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def profile_delete(profile: str) -> dict[str, Any]:
|
|
1250
|
+
"""Delete a child profile: stop gateway, remove directory, clean registries."""
|
|
1251
|
+
prof = _srv().resolve_validated_profile(profile)
|
|
1252
|
+
if prof == _primary_profile():
|
|
1253
|
+
raise ValueError("cannot delete the native profile")
|
|
1254
|
+
|
|
1255
|
+
results: dict[str, Any] = {"profile": prof, "steps": []}
|
|
1256
|
+
|
|
1257
|
+
# 1. Stop the gateway for this profile
|
|
1258
|
+
try:
|
|
1259
|
+
code, out = _srv()._gateway_exec(prof, ["gateway", "stop"])
|
|
1260
|
+
results["steps"].append({"step": "gateway_stop", "ok": code == 0, "output": out.strip()})
|
|
1261
|
+
except Exception as exc:
|
|
1262
|
+
results["steps"].append({"step": "gateway_stop", "ok": False, "error": str(exc)})
|
|
1263
|
+
|
|
1264
|
+
# 2. Kill any remaining process for this profile
|
|
1265
|
+
try:
|
|
1266
|
+
cmd = ["pkill", "-f", f"hermes.*-p {prof}.*gateway"]
|
|
1267
|
+
code, out = _srv()._docker_exec(_srv().GATEWAY_CONTAINER_NAME, cmd)
|
|
1268
|
+
results["steps"].append({"step": "kill_process", "ok": True, "output": out.strip()})
|
|
1269
|
+
except Exception:
|
|
1270
|
+
results["steps"].append({"step": "kill_process", "ok": True, "output": "no process found"})
|
|
1271
|
+
|
|
1272
|
+
# 3. Remove the profile directory (bypasses Hermes security guard)
|
|
1273
|
+
profile_dir = _srv()._profile_dir(prof)
|
|
1274
|
+
try:
|
|
1275
|
+
if profile_dir.exists():
|
|
1276
|
+
shutil.rmtree(str(profile_dir))
|
|
1277
|
+
results["steps"].append({"step": "remove_directory", "ok": True, "path": str(profile_dir)})
|
|
1278
|
+
else:
|
|
1279
|
+
results["steps"].append({"step": "remove_directory", "ok": True, "output": "directory not found"})
|
|
1280
|
+
except Exception as exc:
|
|
1281
|
+
results["steps"].append({"step": "remove_directory", "ok": False, "error": str(exc)})
|
|
1282
|
+
|
|
1283
|
+
# 4. Remove from agents.json
|
|
1284
|
+
agents_json = _srv().HERMES_DATA / "workframe" / "agents.json"
|
|
1285
|
+
try:
|
|
1286
|
+
if agents_json.exists():
|
|
1287
|
+
data = json.loads(agents_json.read_text(encoding="utf-8"))
|
|
1288
|
+
if prof in data.get("agents", {}):
|
|
1289
|
+
del data["agents"][prof]
|
|
1290
|
+
agents_json.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
1291
|
+
results["steps"].append({"step": "clean_agents_json", "ok": True})
|
|
1292
|
+
else:
|
|
1293
|
+
results["steps"].append({"step": "clean_agents_json", "ok": True, "output": "not in agents.json"})
|
|
1294
|
+
except Exception as exc:
|
|
1295
|
+
results["steps"].append({"step": "clean_agents_json", "ok": False, "error": str(exc)})
|
|
1296
|
+
|
|
1297
|
+
# 5. Remove from avatar-registry.json
|
|
1298
|
+
avatar_json = _srv().HERMES_DATA / "workframe" / "avatar-registry.json"
|
|
1299
|
+
try:
|
|
1300
|
+
if avatar_json.exists():
|
|
1301
|
+
data = json.loads(avatar_json.read_text(encoding="utf-8"))
|
|
1302
|
+
changed = False
|
|
1303
|
+
if prof in data.get("assignments", {}):
|
|
1304
|
+
del data["assignments"][prof]
|
|
1305
|
+
changed = True
|
|
1306
|
+
if changed:
|
|
1307
|
+
avatar_json.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
1308
|
+
results["steps"].append({"step": "clean_avatar_registry", "ok": True})
|
|
1309
|
+
except Exception as exc:
|
|
1310
|
+
results["steps"].append({"step": "clean_avatar_registry", "ok": False, "error": str(exc)})
|
|
1311
|
+
|
|
1312
|
+
# 6. Remove from routes.json
|
|
1313
|
+
routes_json = _srv().HERMES_DATA / "workframe" / "routes.json"
|
|
1314
|
+
try:
|
|
1315
|
+
if routes_json.exists():
|
|
1316
|
+
data = json.loads(routes_json.read_text(encoding="utf-8"))
|
|
1317
|
+
original_len = len(data.get("routes", []))
|
|
1318
|
+
data["routes"] = [r for r in data.get("routes", []) if r.get("profile") != prof]
|
|
1319
|
+
if len(data["routes"]) < original_len:
|
|
1320
|
+
routes_json.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
1321
|
+
results["steps"].append({"step": "clean_routes", "ok": True})
|
|
1322
|
+
else:
|
|
1323
|
+
results["steps"].append({"step": "clean_routes", "ok": True, "output": "not in routes"})
|
|
1324
|
+
except Exception as exc:
|
|
1325
|
+
results["steps"].append({"step": "clean_routes", "ok": False, "error": str(exc)})
|
|
1326
|
+
|
|
1327
|
+
results["ok"] = all(s.get("ok", False) for s in results["steps"])
|
|
1328
|
+
return results
|