@@ -5515,6 +5515,8 @@ def process_command(self, command: str) -> bool:
55155515 _cprint (f" Queued for the next turn: { payload [:80 ]} { '...' if len (payload ) > 80 else '' } " )
55165516 else :
55175517 _cprint (f" Queued: { payload [:80 ]} { '...' if len (payload ) > 80 else '' } " )
5518+ elif canonical == "goal" :
5519+ self ._handle_goal_command (cmd_original )
55185520 elif canonical == "skin" :
55195521 self ._handle_skin_command (cmd_original )
55205522 elif canonical == "voice" :
@@ -7031,6 +7033,166 @@ def _voice_speak_response(self, text: str):
70317033 finally :
70327034 self ._voice_tts_done .set ()
70337035
7036+ # ────────────────────────────────────────────────────────────────
7037+ # /goal — persistent cross-turn goals (Ralph-style loop)
7038+ # ────────────────────────────────────────────────────────────────
7039+ def _get_goal_manager (self ):
7040+ """Return the GoalManager bound to the current session_id.
7041+
7042+ Cached on ``self._goal_manager`` and rebound lazily when
7043+ ``session_id`` changes (e.g. after /new or a compression-driven
7044+ session split).
7045+ """
7046+ try :
7047+ from hermes_cli .goals import GoalManager
7048+ from hermes_cli .config import load_config
7049+ except Exception as exc :
7050+ logging .debug ("goal manager unavailable: %s" , exc )
7051+ return None
7052+
7053+ sid = getattr (self , "session_id" , None ) or ""
7054+ if not sid :
7055+ return None
7056+
7057+ existing = getattr (self , "_goal_manager" , None )
7058+ if existing is not None and getattr (existing , "session_id" , None ) == sid :
7059+ return existing
7060+
7061+ try :
7062+ cfg = load_config () or {}
7063+ goals_cfg = cfg .get ("goals" ) or {}
7064+ max_turns = int (goals_cfg .get ("max_turns" , 20 ) or 20 )
7065+ except Exception :
7066+ max_turns = 20
7067+
7068+ mgr = GoalManager (session_id = sid , default_max_turns = max_turns )
7069+ self ._goal_manager = mgr
7070+ return mgr
7071+
7072+ def _handle_goal_command (self , cmd : str ) -> None :
7073+ """Dispatch /goal subcommands: set / status / pause / resume / clear."""
7074+ parts = (cmd or "" ).strip ().split (None , 1 )
7075+ arg = parts [1 ].strip () if len (parts ) > 1 else ""
7076+
7077+ mgr = self ._get_goal_manager ()
7078+ if mgr is None :
7079+ _cprint (f" { _DIM } Goals unavailable (no active session).{ _RST } " )
7080+ return
7081+
7082+ lower = arg .lower ()
7083+
7084+ # Bare /goal or /goal status → show current state
7085+ if not arg or lower == "status" :
7086+ _cprint (f" { mgr .status_line ()} " )
7087+ return
7088+
7089+ if lower == "pause" :
7090+ state = mgr .pause (reason = "user-paused" )
7091+ if state is None :
7092+ _cprint (f" { _DIM } No goal set.{ _RST } " )
7093+ else :
7094+ _cprint (f" ⏸ Goal paused: { state .goal } " )
7095+ return
7096+
7097+ if lower == "resume" :
7098+ state = mgr .resume ()
7099+ if state is None :
7100+ _cprint (f" { _DIM } No goal to resume.{ _RST } " )
7101+ else :
7102+ _cprint (f" ▶ Goal resumed: { state .goal } " )
7103+ _cprint (
7104+ f" { _DIM } Send any message (or press Enter on an empty prompt "
7105+ f"is a no-op; type 'continue' to kick it off).{ _RST } "
7106+ )
7107+ return
7108+
7109+ if lower in ("clear" , "stop" , "done" ):
7110+ had = mgr .has_goal ()
7111+ mgr .clear ()
7112+ if had :
7113+ _cprint (" ✓ Goal cleared." )
7114+ else :
7115+ _cprint (f" { _DIM } No active goal.{ _RST } " )
7116+ return
7117+
7118+ # Otherwise treat the arg as the goal text.
7119+ try :
7120+ state = mgr .set (arg )
7121+ except ValueError as exc :
7122+ _cprint (f" Invalid goal: { exc } " )
7123+ return
7124+
7125+ _cprint (f" ⊙ Goal set ({ state .max_turns } -turn budget): { state .goal } " )
7126+ _cprint (
7127+ f" { _DIM } After each turn, a judge model will check if the goal is done. "
7128+ f"Hermes keeps working until it is, you pause/clear it, or the budget is "
7129+ f"exhausted. Use /goal status, /goal pause, /goal resume, /goal clear.{ _RST } "
7130+ )
7131+ # Kick the loop off immediately so the user doesn't have to send a
7132+ # separate message after setting the goal.
7133+ try :
7134+ self ._pending_input .put (state .goal )
7135+ except Exception :
7136+ pass
7137+
7138+ def _maybe_continue_goal_after_turn (self ) -> None :
7139+ """Hook run after every CLI turn. Judges + maybe re-queues.
7140+
7141+ Safe to call when no goal is set — returns quickly.
7142+
7143+ Preemption is automatic: if a real user message is already in
7144+ ``_pending_input`` we skip judging (the user's new input takes
7145+ priority and we'll re-judge after that turn). If judge says done,
7146+ mark it done and tell the user. If judge says continue and we're
7147+ under budget, push the continuation prompt onto the queue.
7148+ """
7149+ mgr = self ._get_goal_manager ()
7150+ if mgr is None or not mgr .is_active ():
7151+ return
7152+
7153+ # If a real user message is already queued, don't inject a
7154+ # continuation prompt on top — let the user's turn go first.
7155+ try :
7156+ if getattr (self , "_pending_input" , None ) is not None \
7157+ and not self ._pending_input .empty ():
7158+ return
7159+ except Exception :
7160+ pass
7161+
7162+ # Extract the agent's final response for this turn.
7163+ last_response = ""
7164+ try :
7165+ hist = self .conversation_history or []
7166+ for msg in reversed (hist ):
7167+ if msg .get ("role" ) == "assistant" :
7168+ content = msg .get ("content" , "" )
7169+ if isinstance (content , list ):
7170+ # Multimodal content — flatten text parts.
7171+ parts = [
7172+ p .get ("text" , "" )
7173+ for p in content
7174+ if isinstance (p , dict ) and p .get ("type" ) in ("text" , "output_text" )
7175+ ]
7176+ last_response = "\n " .join (t for t in parts if t )
7177+ else :
7178+ last_response = str (content or "" )
7179+ break
7180+ except Exception :
7181+ last_response = ""
7182+
7183+ decision = mgr .evaluate_after_turn (last_response , user_initiated = True )
7184+ msg = decision .get ("message" ) or ""
7185+ if msg :
7186+ _cprint (f" { msg } " )
7187+
7188+ if decision .get ("should_continue" ):
7189+ prompt = decision .get ("continuation_prompt" )
7190+ if prompt :
7191+ try :
7192+ self ._pending_input .put (prompt )
7193+ except Exception as exc :
7194+ logging .debug ("goal continuation enqueue failed: %s" , exc )
7195+
70347196 def _handle_voice_command (self , command : str ):
70357197 """Handle /voice [on|off|tts|status] command."""
70367198 parts = command .strip ().split (maxsplit = 1 )
@@ -9611,6 +9773,17 @@ def _expand_ref(m):
96119773
96129774 app .invalidate () # Refresh status line
96139775
9776+ # Goal continuation: if a standing goal is active, ask
9777+ # the judge whether the turn satisfied it. If not, and
9778+ # there's no real user message already queued, push the
9779+ # continuation prompt back into _pending_input so the
9780+ # next loop iteration picks it up naturally (and any
9781+ # user input that arrives in between still preempts).
9782+ try :
9783+ self ._maybe_continue_goal_after_turn ()
9784+ except Exception as _goal_exc :
9785+ logging .debug ("goal continuation hook failed: %s" , _goal_exc )
9786+
96149787 # Continuous voice: auto-restart recording after agent responds.
96159788 # Dispatch to a daemon thread so play_beep (sd.wait) and
96169789 # AudioRecorder.start (lock acquire) never block process_loop —
0 commit comments