@@ -54,6 +54,7 @@ def register(ctx):
5454import sys
5555import time
5656from dataclasses import dataclass
57+ from enum import Enum
5758from pathlib import Path
5859from typing import Any , Callable
5960
@@ -63,6 +64,12 @@ def register(ctx):
6364 "plugin_skill" ,
6465 "register_plugin" ,
6566 "invoke_host_tool" ,
67+ "deliver_media" ,
68+ "resolve_delivery_target" ,
69+ "MediaType" ,
70+ "MediaPayload" ,
71+ "ResolvedDeliveryTarget" ,
72+ "MediaDeliveryResult" ,
6673 "PluginSkill" ,
6774 "RegistrationSummary" ,
6875 "register_all" ,
@@ -115,6 +122,81 @@ class RegistrationSummary:
115122 skipped_optional_skills : tuple [str , ...] = ()
116123
117124
125+ class MediaType (str , Enum ):
126+ """Hermes media delivery modes with explicit platform semantics."""
127+
128+ AUTO = "auto"
129+ VOICE = "voice"
130+ DOCUMENT = "document"
131+
132+
133+ @dataclass (frozen = True )
134+ class MediaPayload :
135+ """A validated local attachment and its intended Hermes delivery mode."""
136+
137+ path : Path | str
138+ media_type : MediaType | str = MediaType .AUTO
139+ caption : str = ""
140+
141+ def __post_init__ (self ) -> None :
142+ path = Path (self .path ).expanduser ()
143+ if not path .is_absolute ():
144+ raise ValueError ("media path must be absolute" )
145+ try :
146+ media_type = (
147+ self .media_type
148+ if isinstance (self .media_type , MediaType )
149+ else MediaType (self .media_type )
150+ )
151+ except ValueError as exc :
152+ raise ValueError ("media_type must be auto, voice, or document" ) from exc
153+ if media_type is MediaType .VOICE and path .suffix .lower () not in {".ogg" , ".opus" }:
154+ raise ValueError ("voice media must use an ogg or opus container" )
155+ object .__setattr__ (self , "path" , path )
156+ object .__setattr__ (self , "media_type" , media_type )
157+ object .__setattr__ (self , "caption" , str (self .caption or "" ).strip ())
158+
159+ def to_message (self ) -> str :
160+ directive = ""
161+ if self .media_type is MediaType .VOICE :
162+ directive = "[[audio_as_voice]]\n "
163+ elif self .media_type is MediaType .DOCUMENT :
164+ directive = "[[as_document]]\n "
165+ caption = f"{ self .caption } \n " if self .caption else ""
166+ return f"{ caption } { directive } MEDIA:{ self .path } "
167+
168+
169+ @dataclass (frozen = True )
170+ class ResolvedDeliveryTarget :
171+ """Requested route plus the host-only route and privacy-safe display form."""
172+
173+ requested : str
174+ host_target : str
175+ display : str
176+
177+
178+ @dataclass (frozen = True )
179+ class MediaDeliveryResult :
180+ """Typed result from Hermes host media delivery."""
181+
182+ success : bool
183+ requested_target : str
184+ display_target : str
185+ media_type : MediaType
186+ path : Path
187+ host_result : dict [str , Any ]
188+
189+ def as_dict (self ) -> dict [str , Any ]:
190+ return {
191+ "success" : self .success ,
192+ "requested_target" : self .requested_target ,
193+ "display_target" : self .display_target ,
194+ "media_type" : self .media_type .value ,
195+ "path" : str (self .path ),
196+ "host_result" : self .host_result ,
197+ }
198+
199+
118200# ---------------------------------------------------------------------------
119201# Tool naming
120202# ---------------------------------------------------------------------------
@@ -498,6 +580,145 @@ def invoke_host_tool(name: str, args: dict | None = None, **context: Any) -> str
498580 return result
499581
500582
583+ def _display_delivery_identifier (value : str ) -> str :
584+ raw = str (value or "" )
585+ sign = "-" if raw .startswith ("-" ) else ""
586+ digits = raw .lstrip ("-" )
587+ if digits .isdigit () and len (digits ) > 4 :
588+ return f"{ sign } …{ digits [- 4 :]} "
589+ return raw
590+
591+
592+ def _display_delivery_target (target : str ) -> str :
593+ parts = str (target or "" ).split (":" )
594+ if len (parts ) >= 2 :
595+ parts [1 ] = _display_delivery_identifier (parts [1 ])
596+ return ":" .join (parts )
597+
598+
599+ def resolve_delivery_target (target : str ) -> ResolvedDeliveryTarget :
600+ """Resolve ``origin`` through Hermes task-local context.
601+
602+ Consumers never need to import ``gateway.session_context``. Explicit host
603+ targets pass through unchanged; ``origin`` binds to the current platform,
604+ chat, and optional thread without exposing the raw route to the model.
605+ """
606+ requested = str (target or "" ).strip ()
607+ if not requested :
608+ raise ValueError ("delivery target is required" )
609+ if requested != "origin" :
610+ return ResolvedDeliveryTarget (
611+ requested = requested ,
612+ host_target = requested ,
613+ display = _display_delivery_target (requested ),
614+ )
615+ try :
616+ from gateway .session_context import get_session_env
617+
618+ platform = get_session_env ("HERMES_SESSION_PLATFORM" , "" ).strip ().lower ()
619+ chat_id = get_session_env ("HERMES_SESSION_CHAT_ID" , "" ).strip ()
620+ thread_id = get_session_env ("HERMES_SESSION_THREAD_ID" , "" ).strip ()
621+ except Exception as exc :
622+ raise RuntimeError (
623+ f"current Hermes delivery origin is unavailable: { type (exc ).__name__ } "
624+ ) from exc
625+ if not platform or not chat_id :
626+ raise RuntimeError ("current Hermes delivery origin has no platform/chat route" )
627+ host_target = f"{ platform } :{ chat_id } "
628+ if thread_id :
629+ host_target = f"{ host_target } :{ thread_id } "
630+ return ResolvedDeliveryTarget (
631+ requested = requested ,
632+ host_target = host_target ,
633+ display = _display_delivery_target (host_target ),
634+ )
635+
636+
637+ def _safe_media_host_result (
638+ payload : Any ,
639+ resolved : ResolvedDeliveryTarget ,
640+ ) -> dict [str , Any ]:
641+ parts = resolved .host_target .split (":" )
642+ raw_chat_id = parts [1 ] if len (parts ) >= 2 else ""
643+ display_chat_id = _display_delivery_identifier (raw_chat_id )
644+
645+ def redact (value : Any ) -> Any :
646+ if isinstance (value , dict ):
647+ return {key : redact (item ) for key , item in value .items ()}
648+ if isinstance (value , list ):
649+ return [redact (item ) for item in value ]
650+ if isinstance (value , tuple ):
651+ return [redact (item ) for item in value ]
652+ if isinstance (value , str ):
653+ safe = value .replace (resolved .host_target , resolved .display )
654+ if raw_chat_id and raw_chat_id != display_chat_id :
655+ safe = safe .replace (raw_chat_id , display_chat_id )
656+ return safe
657+ return value
658+
659+ safe = redact (payload )
660+ return safe if isinstance (safe , dict ) else {"result" : safe }
661+
662+
663+ def deliver_media (
664+ media : MediaPayload ,
665+ * ,
666+ target : str | ResolvedDeliveryTarget = "origin" ,
667+ ** context : Any ,
668+ ) -> MediaDeliveryResult :
669+ """Deliver typed local media through Hermes' guarded host messaging seam."""
670+ if not isinstance (media , MediaPayload ):
671+ raise TypeError ("media must be a MediaPayload" )
672+ if not media .path .is_file () or media .path .stat ().st_size <= 0 :
673+ raise ValueError (f"media file is missing or empty: { media .path } " )
674+ resolved = (
675+ target
676+ if isinstance (target , ResolvedDeliveryTarget )
677+ else resolve_delivery_target (target )
678+ )
679+ log = logging .getLogger ("hermes_plugin_kit" )
680+ log .info (
681+ "event=media_delivery_started target=%s media_type=%s path=%s" ,
682+ resolved .display ,
683+ media .media_type .value ,
684+ media .path ,
685+ )
686+ raw = invoke_host_tool (
687+ "send_message" ,
688+ {
689+ "action" : "send" ,
690+ "target" : resolved .host_target ,
691+ "message" : media .to_message (),
692+ },
693+ ** context ,
694+ )
695+ try :
696+ host_payload = json .loads (raw )
697+ except (TypeError , json .JSONDecodeError ):
698+ host_payload = {"error" : "send_message returned invalid JSON" }
699+ success = _host_result_fields (raw )[0 ] == "success"
700+ safe_result = _safe_media_host_result (host_payload , resolved )
701+ log_method = log .info if success else log .warning
702+ log_method (
703+ "event=media_delivery_completed target=%s media_type=%s success=%s" ,
704+ resolved .display ,
705+ media .media_type .value ,
706+ success ,
707+ )
708+ return MediaDeliveryResult (
709+ success = success ,
710+ requested_target = (
711+ resolved .requested
712+ if resolved .requested == "origin"
713+ else _display_delivery_target (resolved .requested )
714+ ),
715+ display_target = resolved .display ,
716+ media_type = media .media_type ,
717+ path = media .path ,
718+ host_result = safe_result ,
719+ )
720+
721+
501722def tool (
502723 * ,
503724 toolset : str ,
0 commit comments