mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
chore: merge main
This commit is contained in:
commit
f805323517
107 changed files with 5928 additions and 1195 deletions
568
cli.py
568
cli.py
|
|
@ -1046,7 +1046,7 @@ def _cprint(text: str):
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-drop detection — extracted as a pure function for testability.
|
||||
# File-drop / local attachment detection — extracted as pure helpers for tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IMAGE_EXTENSIONS = frozenset({
|
||||
|
|
@ -1055,12 +1055,103 @@ _IMAGE_EXTENSIONS = frozenset({
|
|||
})
|
||||
|
||||
|
||||
def _detect_file_drop(user_input: str) -> "dict | None":
|
||||
"""Detect if *user_input* is a dragged/pasted file path, not a slash command.
|
||||
from hermes_constants import is_termux as _is_termux_environment
|
||||
|
||||
When a user drags a file into the terminal, macOS pastes the absolute path
|
||||
(e.g. ``/Users/roland/Desktop/file.png``) which starts with ``/`` and would
|
||||
otherwise be mistaken for a slash command.
|
||||
|
||||
def _termux_example_image_path(filename: str = "cat.png") -> str:
|
||||
"""Return a realistic example media path for the current Termux setup."""
|
||||
candidates = [
|
||||
os.path.expanduser("~/storage/shared"),
|
||||
"/sdcard",
|
||||
"/storage/emulated/0",
|
||||
"/storage/self/primary",
|
||||
]
|
||||
for root in candidates:
|
||||
if os.path.isdir(root):
|
||||
return os.path.join(root, "Pictures", filename)
|
||||
return os.path.join("~/storage/shared", "Pictures", filename)
|
||||
|
||||
|
||||
def _split_path_input(raw: str) -> tuple[str, str]:
|
||||
"""Split a leading file path token from trailing free-form text.
|
||||
|
||||
Supports quoted paths and backslash-escaped spaces so callers can accept
|
||||
inputs like:
|
||||
/tmp/pic.png describe this
|
||||
~/storage/shared/My\ Photos/cat.png what is this?
|
||||
"/storage/emulated/0/DCIM/Camera/cat 1.png" summarize
|
||||
"""
|
||||
raw = str(raw or "").strip()
|
||||
if not raw:
|
||||
return "", ""
|
||||
|
||||
if raw[0] in {'"', "'"}:
|
||||
quote = raw[0]
|
||||
pos = 1
|
||||
while pos < len(raw):
|
||||
ch = raw[pos]
|
||||
if ch == '\\' and pos + 1 < len(raw):
|
||||
pos += 2
|
||||
continue
|
||||
if ch == quote:
|
||||
token = raw[1:pos]
|
||||
remainder = raw[pos + 1 :].strip()
|
||||
return token, remainder
|
||||
pos += 1
|
||||
return raw[1:], ""
|
||||
|
||||
pos = 0
|
||||
while pos < len(raw):
|
||||
ch = raw[pos]
|
||||
if ch == '\\' and pos + 1 < len(raw) and raw[pos + 1] == ' ':
|
||||
pos += 2
|
||||
elif ch == ' ':
|
||||
break
|
||||
else:
|
||||
pos += 1
|
||||
|
||||
token = raw[:pos].replace('\\ ', ' ')
|
||||
remainder = raw[pos:].strip()
|
||||
return token, remainder
|
||||
|
||||
|
||||
def _resolve_attachment_path(raw_path: str) -> Path | None:
|
||||
"""Resolve a user-supplied local attachment path.
|
||||
|
||||
Accepts quoted or unquoted paths, expands ``~`` and env vars, and resolves
|
||||
relative paths from ``TERMINAL_CWD`` when set (matching terminal tool cwd).
|
||||
Returns ``None`` when the path does not resolve to an existing file.
|
||||
"""
|
||||
token = str(raw_path or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
if (token.startswith('"') and token.endswith('"')) or (token.startswith("'") and token.endswith("'")):
|
||||
token = token[1:-1].strip()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
expanded = os.path.expandvars(os.path.expanduser(token))
|
||||
path = Path(expanded)
|
||||
if not path.is_absolute():
|
||||
base_dir = Path(os.getenv("TERMINAL_CWD", os.getcwd()))
|
||||
path = base_dir / path
|
||||
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
except Exception:
|
||||
resolved = path
|
||||
|
||||
if not resolved.exists() or not resolved.is_file():
|
||||
return None
|
||||
return resolved
|
||||
|
||||
|
||||
def _detect_file_drop(user_input: str) -> "dict | None":
|
||||
"""Detect if *user_input* starts with a real local file path.
|
||||
|
||||
This catches dragged/pasted paths before they are mistaken for slash
|
||||
commands, and also supports Termux-friendly paths like ``~/storage/...``.
|
||||
|
||||
Returns a dict on match::
|
||||
|
||||
|
|
@ -1072,29 +1163,31 @@ def _detect_file_drop(user_input: str) -> "dict | None":
|
|||
|
||||
Returns ``None`` when the input is not a real file path.
|
||||
"""
|
||||
if not isinstance(user_input, str) or not user_input.startswith("/"):
|
||||
if not isinstance(user_input, str):
|
||||
return None
|
||||
|
||||
# Walk the string absorbing backslash-escaped spaces ("\ ").
|
||||
raw = user_input
|
||||
pos = 0
|
||||
while pos < len(raw):
|
||||
ch = raw[pos]
|
||||
if ch == '\\' and pos + 1 < len(raw) and raw[pos + 1] == ' ':
|
||||
pos += 2 # skip escaped space
|
||||
elif ch == ' ':
|
||||
break
|
||||
else:
|
||||
pos += 1
|
||||
|
||||
first_token_raw = raw[:pos]
|
||||
first_token = first_token_raw.replace('\\ ', ' ')
|
||||
drop_path = Path(first_token)
|
||||
|
||||
if not drop_path.exists() or not drop_path.is_file():
|
||||
stripped = user_input.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
|
||||
starts_like_path = (
|
||||
stripped.startswith("/")
|
||||
or stripped.startswith("~")
|
||||
or stripped.startswith("./")
|
||||
or stripped.startswith("../")
|
||||
or stripped.startswith('"/')
|
||||
or stripped.startswith('"~')
|
||||
or stripped.startswith("'/")
|
||||
or stripped.startswith("'~")
|
||||
)
|
||||
if not starts_like_path:
|
||||
return None
|
||||
|
||||
first_token, remainder = _split_path_input(stripped)
|
||||
drop_path = _resolve_attachment_path(first_token)
|
||||
if drop_path is None:
|
||||
return None
|
||||
|
||||
remainder = raw[pos:].strip()
|
||||
return {
|
||||
"path": drop_path,
|
||||
"is_image": drop_path.suffix.lower() in _IMAGE_EXTENSIONS,
|
||||
|
|
@ -1102,6 +1195,69 @@ def _detect_file_drop(user_input: str) -> "dict | None":
|
|||
}
|
||||
|
||||
|
||||
def _format_image_attachment_badges(attached_images: list[Path], image_counter: int, width: int | None = None) -> str:
|
||||
"""Format the attached-image badge row for the interactive CLI.
|
||||
|
||||
Narrow terminals such as Termux should get a compact summary that fits on a
|
||||
single row, while wider terminals can show the classic per-image badges.
|
||||
"""
|
||||
if not attached_images:
|
||||
return ""
|
||||
|
||||
width = width or shutil.get_terminal_size((80, 24)).columns
|
||||
|
||||
def _trunc(name: str, limit: int) -> str:
|
||||
return name if len(name) <= limit else name[: max(1, limit - 3)] + "..."
|
||||
|
||||
if width < 52:
|
||||
if len(attached_images) == 1:
|
||||
return f"[📎 {_trunc(attached_images[0].name, 20)}]"
|
||||
return f"[📎 {len(attached_images)} images attached]"
|
||||
|
||||
if width < 80:
|
||||
if len(attached_images) == 1:
|
||||
return f"[📎 {_trunc(attached_images[0].name, 32)}]"
|
||||
first = _trunc(attached_images[0].name, 20)
|
||||
extra = len(attached_images) - 1
|
||||
return f"[📎 {first}] [+{extra}]"
|
||||
|
||||
base = image_counter - len(attached_images) + 1
|
||||
return " ".join(
|
||||
f"[📎 Image #{base + i}]"
|
||||
for i in range(len(attached_images))
|
||||
)
|
||||
|
||||
|
||||
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
|
||||
"""Collect local image attachments for single-query CLI flows."""
|
||||
message = query or ""
|
||||
images: list[Path] = []
|
||||
|
||||
if isinstance(message, str):
|
||||
dropped = _detect_file_drop(message)
|
||||
if dropped and dropped.get("is_image"):
|
||||
images.append(dropped["path"])
|
||||
message = dropped["remainder"] or f"[User attached image: {dropped['path'].name}]"
|
||||
|
||||
if image_arg:
|
||||
explicit_path = _resolve_attachment_path(image_arg)
|
||||
if explicit_path is None:
|
||||
raise ValueError(f"Image file not found: {image_arg}")
|
||||
if explicit_path.suffix.lower() not in _IMAGE_EXTENSIONS:
|
||||
raise ValueError(f"Not a supported image file: {explicit_path}")
|
||||
images.append(explicit_path)
|
||||
|
||||
deduped: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for img in images:
|
||||
key = str(img)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(img)
|
||||
return message, deduped
|
||||
|
||||
|
||||
class ChatConsole:
|
||||
"""Rich Console adapter for prompt_toolkit's patch_stdout context.
|
||||
|
||||
|
|
@ -1641,7 +1797,12 @@ class HermesCLI:
|
|||
return f"[{('█' * filled) + ('░' * max(0, width - filled))}]"
|
||||
|
||||
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
|
||||
model_name = self.model or "unknown"
|
||||
# Prefer the agent's model name — it updates on fallback.
|
||||
# self.model reflects the originally configured model and never
|
||||
# changes mid-session, so the TUI would show a stale name after
|
||||
# _try_activate_fallback() switches provider/model.
|
||||
agent = getattr(self, "agent", None)
|
||||
model_name = (getattr(agent, "model", None) or self.model or "unknown")
|
||||
model_short = model_name.split("/")[-1] if "/" in model_name else model_name
|
||||
if model_short.endswith(".gguf"):
|
||||
model_short = model_short[:-5]
|
||||
|
|
@ -1667,7 +1828,6 @@ class HermesCLI:
|
|||
"compressions": 0,
|
||||
}
|
||||
|
||||
agent = getattr(self, "agent", None)
|
||||
if not agent:
|
||||
return snapshot
|
||||
|
||||
|
|
@ -1735,15 +1895,70 @@ class HermesCLI:
|
|||
width += ch_width
|
||||
return "".join(out).rstrip() + ellipsis
|
||||
|
||||
@staticmethod
|
||||
def _get_tui_terminal_width(default: tuple[int, int] = (80, 24)) -> int:
|
||||
"""Return the live prompt_toolkit width, falling back to ``shutil``.
|
||||
|
||||
The TUI layout can be narrower than ``shutil.get_terminal_size()`` reports,
|
||||
especially on Termux/mobile shells, so prefer prompt_toolkit's width whenever
|
||||
an app is active.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
return get_app().output.get_size().columns
|
||||
except Exception:
|
||||
return shutil.get_terminal_size(default).columns
|
||||
|
||||
def _use_minimal_tui_chrome(self, width: Optional[int] = None) -> bool:
|
||||
"""Hide low-value chrome on narrow/mobile terminals to preserve rows."""
|
||||
if width is None:
|
||||
width = self._get_tui_terminal_width()
|
||||
return width < 64
|
||||
|
||||
def _tui_input_rule_height(self, position: str, width: Optional[int] = None) -> int:
|
||||
"""Return the visible height for the top/bottom input separator rules."""
|
||||
if position not in {"top", "bottom"}:
|
||||
raise ValueError(f"Unknown input rule position: {position}")
|
||||
if position == "top":
|
||||
return 1
|
||||
return 0 if self._use_minimal_tui_chrome(width=width) else 1
|
||||
|
||||
def _agent_spacer_height(self, width: Optional[int] = None) -> int:
|
||||
"""Return the spacer height shown above the status bar while the agent runs."""
|
||||
if not getattr(self, "_agent_running", False):
|
||||
return 0
|
||||
return 0 if self._use_minimal_tui_chrome(width=width) else 1
|
||||
|
||||
def _spinner_widget_height(self, width: Optional[int] = None) -> int:
|
||||
"""Return the visible height for the spinner/status text line above the status bar."""
|
||||
if not getattr(self, "_spinner_text", ""):
|
||||
return 0
|
||||
return 0 if self._use_minimal_tui_chrome(width=width) else 1
|
||||
|
||||
def _get_voice_status_fragments(self, width: Optional[int] = None):
|
||||
"""Return the voice status bar fragments for the interactive TUI."""
|
||||
width = width or self._get_tui_terminal_width()
|
||||
compact = self._use_minimal_tui_chrome(width=width)
|
||||
if self._voice_recording:
|
||||
if compact:
|
||||
return [("class:voice-status-recording", " ● REC ")]
|
||||
return [("class:voice-status-recording", " ● REC Ctrl+B to stop ")]
|
||||
if self._voice_processing:
|
||||
if compact:
|
||||
return [("class:voice-status", " ◉ STT ")]
|
||||
return [("class:voice-status", " ◉ Transcribing... ")]
|
||||
if compact:
|
||||
return [("class:voice-status", " 🎤 Ctrl+B ")]
|
||||
tts = " | TTS on" if self._voice_tts else ""
|
||||
cont = " | Continuous" if self._voice_continuous else ""
|
||||
return [("class:voice-status", f" 🎤 Voice mode{tts}{cont} — Ctrl+B to record ")]
|
||||
|
||||
def _build_status_bar_text(self, width: Optional[int] = None) -> str:
|
||||
"""Return a compact one-line session status string for the TUI footer."""
|
||||
try:
|
||||
snapshot = self._get_status_bar_snapshot()
|
||||
if width is None:
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
width = get_app().output.get_size().columns
|
||||
except Exception:
|
||||
width = shutil.get_terminal_size((80, 24)).columns
|
||||
width = self._get_tui_terminal_width()
|
||||
percent = snapshot["context_percent"]
|
||||
percent_label = f"{percent}%" if percent is not None else "--"
|
||||
duration_label = snapshot["duration"]
|
||||
|
|
@ -1779,11 +1994,7 @@ class HermesCLI:
|
|||
# values (especially on SSH) that differ from what prompt_toolkit
|
||||
# actually renders, causing the fragments to overflow to a second
|
||||
# line and produce duplicated status bar rows over long sessions.
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
width = get_app().output.get_size().columns
|
||||
except Exception:
|
||||
width = shutil.get_terminal_size((80, 24)).columns
|
||||
width = self._get_tui_terminal_width()
|
||||
duration_label = snapshot["duration"]
|
||||
|
||||
if width < 52:
|
||||
|
|
@ -2985,6 +3196,14 @@ class HermesCLI:
|
|||
doesn't fire for image-only clipboard content (e.g., VSCode terminal,
|
||||
Windows Terminal with WSL2).
|
||||
"""
|
||||
if _is_termux_environment():
|
||||
_cprint(
|
||||
f" {_DIM}Clipboard image paste is not available on Termux — "
|
||||
f"use /image <path> or paste a local image path like "
|
||||
f"{_termux_example_image_path()}{_RST}"
|
||||
)
|
||||
return
|
||||
|
||||
from hermes_cli.clipboard import has_clipboard_image
|
||||
if has_clipboard_image():
|
||||
if self._try_attach_clipboard_image():
|
||||
|
|
@ -2995,6 +3214,7 @@ class HermesCLI:
|
|||
else:
|
||||
_cprint(f" {_DIM}(._.) No image found in clipboard{_RST}")
|
||||
|
||||
<<<<<<< HEAD
|
||||
def _write_osc52_clipboard(self, text: str) -> None:
|
||||
"""Copy *text* to terminal clipboard via OSC 52."""
|
||||
payload = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
|
|
@ -3051,6 +3271,33 @@ class HermesCLI:
|
|||
_cprint(f" Clipboard copy failed: {e}")
|
||||
|
||||
def _preprocess_images_with_vision(self, text: str, images: list) -> str:
|
||||
=======
|
||||
def _handle_image_command(self, cmd_original: str):
|
||||
"""Handle /image <path> — attach a local image file for the next prompt."""
|
||||
raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "")
|
||||
if not raw_args:
|
||||
hint = _termux_example_image_path() if _is_termux_environment() else "/path/to/image.png"
|
||||
_cprint(f" {_DIM}Usage: /image <path> e.g. /image {hint}{_RST}")
|
||||
return
|
||||
|
||||
path_token, _remainder = _split_path_input(raw_args)
|
||||
image_path = _resolve_attachment_path(path_token)
|
||||
if image_path is None:
|
||||
_cprint(f" {_DIM}(>_<) File not found: {path_token}{_RST}")
|
||||
return
|
||||
if image_path.suffix.lower() not in _IMAGE_EXTENSIONS:
|
||||
_cprint(f" {_DIM}(._.) Not a supported image file: {image_path.name}{_RST}")
|
||||
return
|
||||
|
||||
self._attached_images.append(image_path)
|
||||
_cprint(f" 📎 Attached image: {image_path.name}")
|
||||
if _remainder:
|
||||
_cprint(f" {_DIM}Now type your prompt (or use --image in single-query mode): {_remainder}{_RST}")
|
||||
elif _is_termux_environment():
|
||||
_cprint(f" {_DIM}Tip: type your next message, or run hermes chat -q --image {_termux_example_image_path(image_path.name)} \"What do you see?\"{_RST}")
|
||||
|
||||
def _preprocess_images_with_vision(self, text: str, images: list, *, announce: bool = True) -> str:
|
||||
>>>>>>> main
|
||||
"""Analyze attached images via the vision tool and return enriched text.
|
||||
|
||||
Instead of embedding raw base64 ``image_url`` content parts in the
|
||||
|
|
@ -3077,7 +3324,8 @@ class HermesCLI:
|
|||
if not img_path.exists():
|
||||
continue
|
||||
size_kb = img_path.stat().st_size // 1024
|
||||
_cprint(f" {_DIM}👁️ analyzing {img_path.name} ({size_kb}KB)...{_RST}")
|
||||
if announce:
|
||||
_cprint(f" {_DIM}👁️ analyzing {img_path.name} ({size_kb}KB)...{_RST}")
|
||||
try:
|
||||
result_json = _asyncio.run(
|
||||
vision_analyze_tool(image_url=str(img_path), user_prompt=analysis_prompt)
|
||||
|
|
@ -3090,21 +3338,24 @@ class HermesCLI:
|
|||
f"[If you need a closer look, use vision_analyze with "
|
||||
f"image_url: {img_path}]"
|
||||
)
|
||||
_cprint(f" {_DIM}✓ image analyzed{_RST}")
|
||||
if announce:
|
||||
_cprint(f" {_DIM}✓ image analyzed{_RST}")
|
||||
else:
|
||||
enriched_parts.append(
|
||||
f"[The user attached an image but it couldn't be analyzed. "
|
||||
f"You can try examining it with vision_analyze using "
|
||||
f"image_url: {img_path}]"
|
||||
)
|
||||
_cprint(f" {_DIM}⚠ vision analysis failed — path included for retry{_RST}")
|
||||
if announce:
|
||||
_cprint(f" {_DIM}⚠ vision analysis failed — path included for retry{_RST}")
|
||||
except Exception as e:
|
||||
enriched_parts.append(
|
||||
f"[The user attached an image but analysis failed ({e}). "
|
||||
f"You can try examining it with vision_analyze using "
|
||||
f"image_url: {img_path}]"
|
||||
)
|
||||
_cprint(f" {_DIM}⚠ vision analysis error — path included for retry{_RST}")
|
||||
if announce:
|
||||
_cprint(f" {_DIM}⚠ vision analysis error — path included for retry{_RST}")
|
||||
|
||||
# Combine: vision descriptions first, then the user's original text
|
||||
user_text = text if isinstance(text, str) and text else ""
|
||||
|
|
@ -3198,7 +3449,10 @@ class HermesCLI:
|
|||
|
||||
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
||||
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
||||
_cprint(f" {_DIM}Paste image: Alt+V (or /paste){_RST}\n")
|
||||
if _is_termux_environment():
|
||||
_cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n")
|
||||
else:
|
||||
_cprint(f" {_DIM}Paste image: Alt+V (or /paste){_RST}\n")
|
||||
|
||||
def show_tools(self):
|
||||
"""Display available tools with kawaii ASCII art."""
|
||||
|
|
@ -4102,59 +4356,7 @@ class HermesCLI:
|
|||
|
||||
print(" To change model or provider, use: hermes model")
|
||||
|
||||
def _handle_prompt_command(self, cmd: str):
|
||||
"""Handle the /prompt command to view or set system prompt."""
|
||||
parts = cmd.split(maxsplit=1)
|
||||
|
||||
if len(parts) > 1:
|
||||
# Set new prompt
|
||||
new_prompt = parts[1].strip()
|
||||
|
||||
if new_prompt.lower() == "clear":
|
||||
self.system_prompt = ""
|
||||
self.agent = None # Force re-init
|
||||
if save_config_value("agent.system_prompt", ""):
|
||||
print("(^_^)b System prompt cleared (saved to config)")
|
||||
else:
|
||||
print("(^_^) System prompt cleared (session only)")
|
||||
else:
|
||||
self.system_prompt = new_prompt
|
||||
self.agent = None # Force re-init
|
||||
if save_config_value("agent.system_prompt", new_prompt):
|
||||
print("(^_^)b System prompt set (saved to config)")
|
||||
else:
|
||||
print("(^_^) System prompt set (session only)")
|
||||
print(f" \"{new_prompt[:60]}{'...' if len(new_prompt) > 60 else ''}\"")
|
||||
else:
|
||||
# Show current prompt
|
||||
print()
|
||||
print("+" + "-" * 50 + "+")
|
||||
print("|" + " " * 15 + "(^_^) System Prompt" + " " * 15 + "|")
|
||||
print("+" + "-" * 50 + "+")
|
||||
print()
|
||||
if self.system_prompt:
|
||||
# Word wrap the prompt for display
|
||||
words = self.system_prompt.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
for word in words:
|
||||
if len(current_line) + len(word) + 1 <= 50:
|
||||
current_line += (" " if current_line else "") + word
|
||||
else:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
for line in lines:
|
||||
print(f" {line}")
|
||||
else:
|
||||
print(" (no custom prompt set - using default)")
|
||||
print()
|
||||
print(" Usage:")
|
||||
print(" /prompt <text> - Set a custom system prompt")
|
||||
print(" /prompt clear - Remove custom prompt")
|
||||
print(" /personality - Use a predefined personality")
|
||||
print()
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -4654,9 +4856,7 @@ class HermesCLI:
|
|||
self._handle_model_switch(cmd_original)
|
||||
elif canonical == "provider":
|
||||
self._show_model_and_providers()
|
||||
elif canonical == "prompt":
|
||||
# Use original case so prompt text isn't lowercased
|
||||
self._handle_prompt_command(cmd_original)
|
||||
|
||||
elif canonical == "personality":
|
||||
# Use original case (handler lowercases the personality name itself)
|
||||
self._handle_personality_command(cmd_original)
|
||||
|
|
@ -4700,6 +4900,8 @@ class HermesCLI:
|
|||
self._handle_copy_command(cmd_original)
|
||||
elif canonical == "paste":
|
||||
self._handle_paste_command()
|
||||
elif canonical == "image":
|
||||
self._handle_image_command(cmd_original)
|
||||
elif canonical == "reload-mcp":
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_mcp()
|
||||
|
|
@ -5140,6 +5342,9 @@ class HermesCLI:
|
|||
def _try_launch_chrome_debug(port: int, system: str) -> bool:
|
||||
"""Try to launch Chrome/Chromium with remote debugging enabled.
|
||||
|
||||
Uses a dedicated user-data-dir so the debug instance doesn't conflict
|
||||
with an already-running Chrome using the default profile.
|
||||
|
||||
Returns True if a launch command was executed (doesn't guarantee success).
|
||||
"""
|
||||
import subprocess as _sp
|
||||
|
|
@ -5149,10 +5354,20 @@ class HermesCLI:
|
|||
if not candidates:
|
||||
return False
|
||||
|
||||
# Dedicated profile dir so debug Chrome won't collide with normal Chrome
|
||||
data_dir = str(_hermes_home / "chrome-debug")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
chrome = candidates[0]
|
||||
try:
|
||||
_sp.Popen(
|
||||
[chrome, f"--remote-debugging-port={port}"],
|
||||
[
|
||||
chrome,
|
||||
f"--remote-debugging-port={port}",
|
||||
f"--user-data-dir={data_dir}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
],
|
||||
stdout=_sp.DEVNULL,
|
||||
stderr=_sp.DEVNULL,
|
||||
start_new_session=True, # detach from terminal
|
||||
|
|
@ -5227,18 +5442,33 @@ class HermesCLI:
|
|||
print(f" ✓ Chrome launched and listening on port {_port}")
|
||||
else:
|
||||
print(f" ⚠ Chrome launched but port {_port} isn't responding yet")
|
||||
print(" You may need to close existing Chrome windows first and retry")
|
||||
print(" Try again in a few seconds — the debug instance may still be starting")
|
||||
else:
|
||||
print(" ⚠ Could not auto-launch Chrome")
|
||||
# Show manual instructions as fallback
|
||||
_data_dir = str(_hermes_home / "chrome-debug")
|
||||
sys_name = _plat.system()
|
||||
if sys_name == "Darwin":
|
||||
chrome_cmd = 'open -a "Google Chrome" --args --remote-debugging-port=9222'
|
||||
chrome_cmd = (
|
||||
'open -a "Google Chrome" --args'
|
||||
f" --remote-debugging-port=9222"
|
||||
f' --user-data-dir="{_data_dir}"'
|
||||
" --no-first-run --no-default-browser-check"
|
||||
)
|
||||
elif sys_name == "Windows":
|
||||
chrome_cmd = 'chrome.exe --remote-debugging-port=9222'
|
||||
chrome_cmd = (
|
||||
f'chrome.exe --remote-debugging-port=9222'
|
||||
f' --user-data-dir="{_data_dir}"'
|
||||
f" --no-first-run --no-default-browser-check"
|
||||
)
|
||||
else:
|
||||
chrome_cmd = "google-chrome --remote-debugging-port=9222"
|
||||
print(f" Launch Chrome manually: {chrome_cmd}")
|
||||
chrome_cmd = (
|
||||
f"google-chrome --remote-debugging-port=9222"
|
||||
f' --user-data-dir="{_data_dir}"'
|
||||
f" --no-first-run --no-default-browser-check"
|
||||
)
|
||||
print(f" Launch Chrome manually:")
|
||||
print(f" {chrome_cmd}")
|
||||
else:
|
||||
print(f" ⚠ Port {_port} is not reachable at {cdp_url}")
|
||||
|
||||
|
|
@ -5411,7 +5641,7 @@ class HermesCLI:
|
|||
|
||||
Usage:
|
||||
/reasoning Show current effort level and display state
|
||||
/reasoning <level> Set reasoning effort (none, low, medium, high, xhigh)
|
||||
/reasoning <level> Set reasoning effort (none, minimal, low, medium, high, xhigh)
|
||||
/reasoning show|on Show model thinking/reasoning in output
|
||||
/reasoning hide|off Hide model thinking/reasoning from output
|
||||
"""
|
||||
|
|
@ -5429,7 +5659,7 @@ class HermesCLI:
|
|||
display_state = "on ✓" if self.show_reasoning else "off"
|
||||
_cprint(f" {_GOLD}Reasoning effort: {level}{_RST}")
|
||||
_cprint(f" {_GOLD}Reasoning display: {display_state}{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /reasoning <none|low|medium|high|xhigh|show|hide>{_RST}")
|
||||
_cprint(f" {_DIM}Usage: /reasoning <none|minimal|low|medium|high|xhigh|show|hide>{_RST}")
|
||||
return
|
||||
|
||||
arg = parts[1].strip().lower()
|
||||
|
|
@ -5455,7 +5685,7 @@ class HermesCLI:
|
|||
parsed = _parse_reasoning_config(arg)
|
||||
if parsed is None:
|
||||
_cprint(f" {_DIM}(._.) Unknown argument: {arg}{_RST}")
|
||||
_cprint(f" {_DIM}Valid levels: none, low, minimal, medium, high, xhigh{_RST}")
|
||||
_cprint(f" {_DIM}Valid levels: none, minimal, low, medium, high, xhigh{_RST}")
|
||||
_cprint(f" {_DIM}Display: show, hide{_RST}")
|
||||
return
|
||||
|
||||
|
|
@ -5867,10 +6097,23 @@ class HermesCLI:
|
|||
"""Start capturing audio from the microphone."""
|
||||
if getattr(self, '_should_exit', False):
|
||||
return
|
||||
from tools.voice_mode import AudioRecorder, check_voice_requirements
|
||||
from tools.voice_mode import create_audio_recorder, check_voice_requirements
|
||||
|
||||
reqs = check_voice_requirements()
|
||||
if not reqs["audio_available"]:
|
||||
if _is_termux_environment():
|
||||
details = reqs.get("details", "")
|
||||
if "Termux:API Android app is not installed" in details:
|
||||
raise RuntimeError(
|
||||
"Termux:API command package detected, but the Android app is missing.\n"
|
||||
"Install/update the Termux:API Android app, then retry /voice on.\n"
|
||||
"Fallback: pkg install python-numpy portaudio && python -m pip install sounddevice"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Voice mode requires either Termux:API microphone access or Python audio libraries.\n"
|
||||
"Option 1: pkg install termux-api and install the Termux:API Android app\n"
|
||||
"Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Voice mode requires sounddevice and numpy.\n"
|
||||
"Install with: pip install sounddevice numpy\n"
|
||||
|
|
@ -5899,7 +6142,7 @@ class HermesCLI:
|
|||
pass
|
||||
|
||||
if self._voice_recorder is None:
|
||||
self._voice_recorder = AudioRecorder()
|
||||
self._voice_recorder = create_audio_recorder()
|
||||
|
||||
# Apply config-driven silence params
|
||||
self._voice_recorder._silence_threshold = voice_cfg.get("silence_threshold", 200)
|
||||
|
|
@ -5928,7 +6171,13 @@ class HermesCLI:
|
|||
with self._voice_lock:
|
||||
self._voice_recording = False
|
||||
raise
|
||||
_cprint(f"\n{_GOLD}● Recording...{_RST} {_DIM}(auto-stops on silence | Ctrl+B to stop & exit continuous){_RST}")
|
||||
if getattr(self._voice_recorder, "supports_silence_autostop", True):
|
||||
_recording_hint = "auto-stops on silence | Ctrl+B to stop & exit continuous"
|
||||
elif _is_termux_environment():
|
||||
_recording_hint = "Termux:API capture | Ctrl+B to stop"
|
||||
else:
|
||||
_recording_hint = "Ctrl+B to stop"
|
||||
_cprint(f"\n{_GOLD}● Recording...{_RST} {_DIM}({_recording_hint}){_RST}")
|
||||
|
||||
# Periodically refresh prompt to update audio level indicator
|
||||
def _refresh_level():
|
||||
|
|
@ -6136,8 +6385,13 @@ class HermesCLI:
|
|||
for line in reqs["details"].split("\n"):
|
||||
_cprint(f" {_DIM}{line}{_RST}")
|
||||
if reqs["missing_packages"]:
|
||||
_cprint(f"\n {_BOLD}Install: pip install {' '.join(reqs['missing_packages'])}{_RST}")
|
||||
_cprint(f" {_DIM}Or: pip install hermes-agent[voice]{_RST}")
|
||||
if _is_termux_environment():
|
||||
_cprint(f"\n {_BOLD}Option 1: pkg install termux-api{_RST}")
|
||||
_cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}")
|
||||
_cprint(f" {_BOLD}Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice{_RST}")
|
||||
else:
|
||||
_cprint(f"\n {_BOLD}Install: pip install {' '.join(reqs['missing_packages'])}{_RST}")
|
||||
_cprint(f" {_DIM}Or: pip install hermes-agent[voice]{_RST}")
|
||||
return
|
||||
|
||||
with self._voice_lock:
|
||||
|
|
@ -7091,27 +7345,39 @@ class HermesCLI:
|
|||
def _get_tui_prompt_fragments(self):
|
||||
"""Return the prompt_toolkit fragments for the current interactive state."""
|
||||
symbol, state_suffix = self._get_tui_prompt_symbols()
|
||||
compact = self._use_minimal_tui_chrome(width=self._get_tui_terminal_width())
|
||||
|
||||
def _state_fragment(style: str, icon: str, extra: str = ""):
|
||||
if compact:
|
||||
text = icon
|
||||
if extra:
|
||||
text = f"{text} {extra.strip()}".rstrip()
|
||||
return [(style, text + " ")]
|
||||
if extra:
|
||||
return [(style, f"{icon} {extra} {state_suffix}")]
|
||||
return [(style, f"{icon} {state_suffix}")]
|
||||
|
||||
if self._voice_recording:
|
||||
bar = self._audio_level_bar()
|
||||
return [("class:voice-recording", f"● {bar} {state_suffix}")]
|
||||
return _state_fragment("class:voice-recording", "●", bar)
|
||||
if self._voice_processing:
|
||||
return [("class:voice-processing", f"◉ {state_suffix}")]
|
||||
return _state_fragment("class:voice-processing", "◉")
|
||||
if self._sudo_state:
|
||||
return [("class:sudo-prompt", f"🔐 {state_suffix}")]
|
||||
return _state_fragment("class:sudo-prompt", "🔐")
|
||||
if self._secret_state:
|
||||
return [("class:sudo-prompt", f"🔑 {state_suffix}")]
|
||||
return _state_fragment("class:sudo-prompt", "🔑")
|
||||
if self._approval_state:
|
||||
return [("class:prompt-working", f"⚠ {state_suffix}")]
|
||||
return _state_fragment("class:prompt-working", "⚠")
|
||||
if self._clarify_freetext:
|
||||
return [("class:clarify-selected", f"✎ {state_suffix}")]
|
||||
return _state_fragment("class:clarify-selected", "✎")
|
||||
if self._clarify_state:
|
||||
return [("class:prompt-working", f"? {state_suffix}")]
|
||||
return _state_fragment("class:prompt-working", "?")
|
||||
if self._command_running:
|
||||
return [("class:prompt-working", f"{self._command_spinner_frame()} {state_suffix}")]
|
||||
return _state_fragment("class:prompt-working", self._command_spinner_frame())
|
||||
if self._agent_running:
|
||||
return [("class:prompt-working", f"⚕ {state_suffix}")]
|
||||
return _state_fragment("class:prompt-working", "⚕")
|
||||
if self._voice_mode:
|
||||
return [("class:voice-prompt", f"🎤 {state_suffix}")]
|
||||
return _state_fragment("class:voice-prompt", "🎤")
|
||||
return [("class:prompt", symbol)]
|
||||
|
||||
def _get_tui_prompt_text(self) -> str:
|
||||
|
|
@ -7967,9 +8233,9 @@ class HermesCLI:
|
|||
def get_hint_height():
|
||||
if cli_ref._sudo_state or cli_ref._secret_state or cli_ref._approval_state or cli_ref._clarify_state or cli_ref._command_running:
|
||||
return 1
|
||||
# Keep a 1-line spacer while agent runs so output doesn't push
|
||||
# right up against the top rule of the input area
|
||||
return 1 if cli_ref._agent_running else 0
|
||||
# Keep a spacer while the agent runs on roomy terminals, but reclaim
|
||||
# the row on narrow/mobile screens where every line matters.
|
||||
return cli_ref._agent_spacer_height()
|
||||
|
||||
def get_spinner_text():
|
||||
txt = cli_ref._spinner_text
|
||||
|
|
@ -7978,7 +8244,7 @@ class HermesCLI:
|
|||
return [('class:hint', f' {txt}')]
|
||||
|
||||
def get_spinner_height():
|
||||
return 1 if cli_ref._spinner_text else 0
|
||||
return cli_ref._spinner_widget_height()
|
||||
|
||||
spinner_widget = Window(
|
||||
content=FormattedTextControl(get_spinner_text),
|
||||
|
|
@ -8169,18 +8435,17 @@ class HermesCLI:
|
|||
filter=Condition(lambda: cli_ref._approval_state is not None),
|
||||
)
|
||||
|
||||
# Horizontal rules above and below the input (bronze, 1 line each).
|
||||
# The bottom rule moves down as the TextArea grows with newlines.
|
||||
# Using char='─' instead of hardcoded repetition so the rule
|
||||
# always spans the full terminal width on any screen size.
|
||||
# Horizontal rules above and below the input.
|
||||
# On narrow/mobile terminals we keep the top separator for structure but
|
||||
# hide the bottom one to recover a full row for conversation content.
|
||||
input_rule_top = Window(
|
||||
char='─',
|
||||
height=1,
|
||||
height=lambda: cli_ref._tui_input_rule_height("top"),
|
||||
style='class:input-rule',
|
||||
)
|
||||
input_rule_bot = Window(
|
||||
char='─',
|
||||
height=1,
|
||||
height=lambda: cli_ref._tui_input_rule_height("bottom"),
|
||||
style='class:input-rule',
|
||||
)
|
||||
|
||||
|
|
@ -8190,10 +8455,9 @@ class HermesCLI:
|
|||
def _get_image_bar():
|
||||
if not cli_ref._attached_images:
|
||||
return []
|
||||
base = cli_ref._image_counter - len(cli_ref._attached_images) + 1
|
||||
badges = " ".join(
|
||||
f"[📎 Image #{base + i}]"
|
||||
for i in range(len(cli_ref._attached_images))
|
||||
badges = _format_image_attachment_badges(
|
||||
cli_ref._attached_images,
|
||||
cli_ref._image_counter,
|
||||
)
|
||||
return [("class:image-badge", f" {badges} ")]
|
||||
|
||||
|
|
@ -8204,13 +8468,7 @@ class HermesCLI:
|
|||
|
||||
# Persistent voice mode status bar (visible only when voice mode is on)
|
||||
def _get_voice_status():
|
||||
if cli_ref._voice_recording:
|
||||
return [('class:voice-status-recording', ' ● REC Ctrl+B to stop ')]
|
||||
if cli_ref._voice_processing:
|
||||
return [('class:voice-status', ' ◉ Transcribing... ')]
|
||||
tts = " | TTS on" if cli_ref._voice_tts else ""
|
||||
cont = " | Continuous" if cli_ref._voice_continuous else ""
|
||||
return [('class:voice-status', f' 🎤 Voice mode{tts}{cont} — Ctrl+B to record ')]
|
||||
return cli_ref._get_voice_status_fragments()
|
||||
|
||||
voice_status_bar = ConditionalContainer(
|
||||
Window(
|
||||
|
|
@ -8666,6 +8924,7 @@ class HermesCLI:
|
|||
def main(
|
||||
query: str = None,
|
||||
q: str = None,
|
||||
image: str = None,
|
||||
toolsets: str = None,
|
||||
skills: str | list[str] | tuple[str, ...] = None,
|
||||
model: str = None,
|
||||
|
|
@ -8691,6 +8950,7 @@ def main(
|
|||
Args:
|
||||
query: Single query to execute (then exit). Alias: -q
|
||||
q: Shorthand for --query
|
||||
image: Optional local image path to attach to a single query
|
||||
toolsets: Comma-separated list of toolsets to enable (e.g., "web,terminal")
|
||||
skills: Comma-separated or repeated list of skills to preload for the session
|
||||
model: Model to use (default: anthropic/claude-opus-4-20250514)
|
||||
|
|
@ -8711,6 +8971,7 @@ def main(
|
|||
python cli.py --toolsets web,terminal # Use specific toolsets
|
||||
python cli.py --skills hermes-agent-dev,github-auth
|
||||
python cli.py -q "What is Python?" # Single query mode
|
||||
python cli.py -q "Describe this" --image ~/storage/shared/Pictures/cat.png
|
||||
python cli.py --list-tools # List tools and exit
|
||||
python cli.py --resume 20260225_143052_a1b2c3 # Resume session
|
||||
python cli.py -w # Start in isolated git worktree
|
||||
|
|
@ -8833,13 +9094,21 @@ def main(
|
|||
atexit.register(_run_cleanup)
|
||||
|
||||
# Handle single query mode
|
||||
if query:
|
||||
if query or image:
|
||||
query, single_query_images = _collect_query_images(query, image)
|
||||
if quiet:
|
||||
# Quiet mode: suppress banner, spinner, tool previews.
|
||||
# Only print the final response and parseable session info.
|
||||
cli.tool_progress_mode = "off"
|
||||
if cli._ensure_runtime_credentials():
|
||||
turn_route = cli._resolve_turn_agent_config(query)
|
||||
effective_query = query
|
||||
if single_query_images:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query,
|
||||
single_query_images,
|
||||
announce=False,
|
||||
)
|
||||
turn_route = cli._resolve_turn_agent_config(effective_query)
|
||||
if turn_route["signature"] != cli._active_agent_route_signature:
|
||||
cli.agent = None
|
||||
if cli._init_agent(
|
||||
|
|
@ -8848,8 +9117,9 @@ def main(
|
|||
route_label=turn_route["label"],
|
||||
):
|
||||
cli.agent.quiet_mode = True
|
||||
cli.agent.suppress_status_output = True
|
||||
result = cli.agent.run_conversation(
|
||||
user_message=query,
|
||||
user_message=effective_query,
|
||||
conversation_history=cli.conversation_history,
|
||||
)
|
||||
response = result.get("final_response", "") if isinstance(result, dict) else str(result)
|
||||
|
|
@ -8864,8 +9134,10 @@ def main(
|
|||
sys.exit(1)
|
||||
else:
|
||||
cli.show_banner()
|
||||
cli.console.print(f"[bold blue]Query:[/] {query}")
|
||||
cli.chat(query)
|
||||
_query_label = query or ("[image attached]" if single_query_images else "")
|
||||
if _query_label:
|
||||
cli.console.print(f"[bold blue]Query:[/] {_query_label}")
|
||||
cli.chat(query, images=single_query_images or None)
|
||||
cli._print_exit_summary()
|
||||
return
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue