Compare commits

..

3 Commits

Author SHA1 Message Date
teknium1 a1a90f3f10 feat: require runway before prune-only compaction
Make prune-first compression cache-aware by only accepting prune-only
compaction when it gets comfortably below threshold. If pruning merely
dips under threshold, fall through to the existing summary compaction
so we avoid frequent near-threshold recompressions.

Tests cover both the conservative fallback and the prune-only fast path.
2026-03-13 21:46:09 -07:00
teknium1 55729670be docs: add cache-aware compaction design note 2026-03-13 21:46:03 -07:00
teyrebaz33 119bad65fc feat: prune old tool outputs before context compaction
Port the useful part of PR #588 onto current main without regressing
summary role alternation or the centralized call_llm-based summary path.

This adds a prune-first compression pass that:
- protects recent tool outputs with adaptive thresholds
- never prunes key tool outputs like read_file/memory/clarify
- skips the LLM summary call entirely when pruning alone is enough
- keeps head/tail protected windows untouched

Tests cover prune-only compaction, protected tools, and tail protection.

Co-authored-by: teyrebaz33 <hakanerten02@hotmail.com>
2026-03-13 21:21:28 -07:00
96 changed files with 2326 additions and 7864 deletions
+2 -10
View File
@@ -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
+2 -37
View File
@@ -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))
+114 -8
View File
@@ -7,7 +7,7 @@ protecting head and tail context.
import logging
import os
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from agent.auxiliary_client import call_llm
from agent.model_metadata import (
@@ -17,6 +17,24 @@ from agent.model_metadata import (
logger = logging.getLogger(__name__)
NEVER_PRUNE_TOOLS = {"clarify", "memory", "skill_view", "todo", "read_file"}
def _adaptive_prune_protect(context_length: int) -> int:
"""Scale the recent-tool-output protection window to the model context size."""
if context_length >= 500_000:
return 100_000
if context_length >= 128_000:
return 40_000
if context_length >= 64_000:
return 20_000
return 10_000
def _adaptive_prune_minimum(context_length: int) -> int:
"""Only prune when it reclaims a meaningful amount of prompt budget."""
return max(5_000, context_length // 20)
class ContextCompressor:
"""Compresses conversation context when approaching the model's context limit.
@@ -54,6 +72,10 @@ class ContextCompressor:
self.last_total_tokens = 0
self.summary_model = summary_model_override or ""
self._prune_protect_tokens = _adaptive_prune_protect(self.context_length)
self._prune_minimum_tokens = _adaptive_prune_minimum(self.context_length)
self._prune_runway_tokens = max(self._prune_minimum_tokens, int(self.threshold_tokens * 0.15))
self._prune_target_tokens = max(0, self.threshold_tokens - self._prune_runway_tokens)
def update_from_response(self, usage: Dict[str, Any]):
"""Update tracked token usage from API response."""
@@ -81,6 +103,58 @@ class ContextCompressor:
"compression_count": self.compression_count,
}
def _is_protected_tool(self, message: Dict[str, Any]) -> bool:
"""Return True when a tool output should never be pruned."""
return (message.get("name") or "") in NEVER_PRUNE_TOOLS
def _prune_tool_outputs(self, messages: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]:
"""Replace older middle tool outputs with compact placeholders.
Only prunes tool outputs from the same middle region that would be eligible
for summarization. The head/tail protected windows are left untouched.
Returns:
(messages_after_prune, chars_saved)
"""
n_messages = len(messages)
compress_start = self.protect_first_n
compress_end = n_messages - self.protect_last_n
if compress_start >= compress_end:
return messages, 0
compress_start = self._align_boundary_forward(messages, compress_start)
compress_end = self._align_boundary_backward(messages, compress_end)
if compress_start >= compress_end:
return messages, 0
pruned = [msg.copy() for msg in messages]
chars_saved = 0
recent_tool_tokens = 0
for i in range(compress_end - 1, compress_start - 1, -1):
msg = pruned[i]
if msg.get("role") != "tool" or self._is_protected_tool(msg):
continue
content = msg.get("content")
content_text = content if isinstance(content, str) else str(content or "")
token_estimate = max(1, len(content_text) // 4)
if recent_tool_tokens < self._prune_protect_tokens:
recent_tool_tokens += token_estimate
continue
original_len = len(content_text)
placeholder = f"[Tool output pruned — was {original_len:,} chars]"
pruned[i]["content"] = placeholder
chars_saved += max(0, original_len - len(placeholder))
tokens_saved = chars_saved // 4
if tokens_saved < self._prune_minimum_tokens:
return messages, 0
return pruned, chars_saved
def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]]) -> Optional[str]:
"""Generate a concise summary of conversation turns.
@@ -132,11 +206,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
@@ -271,13 +341,49 @@ Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
if compress_start >= compress_end:
return messages
turns_to_summarize = messages[compress_start:compress_end]
display_tokens = current_tokens if current_tokens else self.last_prompt_tokens or estimate_messages_tokens_rough(messages)
display_tokens = current_tokens if current_tokens is not None else self.last_prompt_tokens or estimate_messages_tokens_rough(messages)
if not self.quiet_mode:
print(f"\n📦 Context compression triggered ({display_tokens:,} tokens ≥ {self.threshold_tokens:,} threshold)")
print(f" 📊 Model context limit: {self.context_length:,} tokens ({self.threshold_percent*100:.0f}% = {self.threshold_tokens:,})")
pruned_messages, chars_saved = self._prune_tool_outputs(messages)
if chars_saved > 0:
pruned_tokens = estimate_messages_tokens_rough(pruned_messages)
tokens_saved_phase1 = max(0, display_tokens - pruned_tokens)
if not self.quiet_mode:
print(
f" ✂️ Phase 1 (prune): removed {chars_saved:,} chars of old tool outputs "
f"(~{tokens_saved_phase1:,} tokens saved)"
)
if pruned_tokens <= self._prune_target_tokens:
self.compression_count += 1
pruned_messages = self._sanitize_tool_pairs(pruned_messages)
if not self.quiet_mode:
print(
f" ✅ Phase 1 sufficient: {n_messages}{len(pruned_messages)} messages, "
f"now {pruned_tokens:,} tokens"
)
print(f" 💡 Compression #{self.compression_count} complete (prune only — no LLM call needed)")
return pruned_messages
if not self.quiet_mode and pruned_tokens < self.threshold_tokens:
print(
f" ↪️ Phase 1 recovered tokens but not enough runway "
f"({pruned_tokens:,} > target {self._prune_target_tokens:,}); continuing to compaction"
)
messages = pruned_messages
n_messages = len(messages)
compress_start = self.protect_first_n
compress_end = n_messages - self.protect_last_n
if compress_start >= compress_end:
return messages
compress_start = self._align_boundary_forward(messages, compress_start)
compress_end = self._align_boundary_backward(messages, compress_end)
if compress_start >= compress_end:
return messages
turns_to_summarize = messages[compress_start:compress_end]
if not self.quiet_mode:
print(f" 🗜️ Summarizing turns {compress_start+1}-{compress_end} ({len(turns_to_summarize)} turns)")
+1 -1
View File
@@ -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
+2 -4
View File
@@ -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:
+26 -65
View File
@@ -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)
@@ -3725,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}, "
@@ -4033,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}, "
@@ -4294,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")
@@ -0,0 +1,192 @@
# Cache-Aware Context Compaction Design Note
> For Hermes: this note is a design/implementation sketch for revisiting prune-first compaction without optimizing token spend at the expense of prompt-cache stability.
Goal: reduce compression cost while keeping cache-break frequency as low as possible.
Architecture: keep Hermes' current invariant that conversation history is only mutated during context compression, then make prune-first compaction conservative enough that it only short-circuits when it buys meaningful runway. If pruning only gets us barely below threshold, fall through to the existing summary compaction immediately.
Tech Stack: `agent/context_compressor.py`, existing `call_llm()`-based summary path, pytest coverage in `tests/agent/test_context_compressor.py`.
---
## 1. Baseline behavior on current main
Today Hermes behaves like this:
1. Prompt crosses the compression threshold.
2. We mutate transcript history once by summarizing the middle region with an LLM.
3. We preserve role alternation and tool-call/tool-result integrity.
4. We continue the conversation from the compressed transcript.
This is expensive in two ways:
- an auxiliary summary call is often required
- the entire compressed middle region is rewritten even when the real problem was just a few huge old tool outputs
But it has one strong cache property:
- it tends to reclaim a lot of headroom per compression event, so the next compression is usually farther away
---
## 2. Why naive prune-first compaction is not enough
A naive prune-first policy says:
- prune old tool outputs
- if prompt is now below threshold, stop
This improves per-event token cost, but it can hurt cache economics:
- prune-only may reclaim less headroom than full compaction
- smaller headroom means the next compression may happen sooner
- each compression event is still a cache-breaking transcript mutation
So there is a real failure mode:
- fewer tokens per compression
- more compression events overall
- worse cache break cadence
That is exactly the tradeoff we want to avoid.
---
## 3. Cache-aware principle
Prune-first compaction should only short-circuit when it buys real runway, not when it merely dips under threshold.
Rule of thumb:
- compression frequency matters as much as compression size
- a smaller mutation is not automatically cheaper if it causes another mutation a few turns later
So the design target is:
- fewer auxiliary summary calls
- without materially increasing compression frequency
---
## 4. Conservative prototype policy
The conservative prototype keeps all existing compression invariants and only changes the acceptance rule for prune-only compaction.
### Phase 1: prune old middle tool outputs
Only prune tool outputs that are:
- in the compressible middle region
- not in protected head/tail windows
- not from protected tools (`read_file`, `memory`, `clarify`, `skill_view`, `todo`)
### Phase 2: require a low-water mark
Do not accept prune-only just because it lands below threshold.
Instead require:
- `post_prune_tokens <= prune_target_tokens`
Where:
- `prune_runway_tokens = max(prune_minimum_tokens, 15% of threshold_tokens)`
- `prune_target_tokens = threshold_tokens - prune_runway_tokens`
Interpretation:
- pruning must get us comfortably below threshold
- otherwise we immediately fall through to normal LLM summary compaction
Why this helps:
- protects cache by avoiding "micro-compactions" that would be followed by another compression shortly after
- still avoids the summary call when pruning truly buys useful runway
---
## 5. What the prototype currently does
The prototype branch currently:
- keeps prune-first compaction
- adds the low-water / runway requirement above
- preserves current main behavior for summary role alternation
- preserves the centralized `call_llm()` summary path
- keeps head/tail and tool-call/result integrity handling unchanged
This means the branch is no longer optimizing only for token reduction per event; it is explicitly biased toward fewer compression events.
---
## 6. Metrics we should evaluate before merging any future version
A serious cache-aware review should measure all of these, not just token savings:
1. Compression events per 100 conversation turns
2. Average turns between compressions
3. Auxiliary summary calls per session
4. Average tokens reclaimed per compression event
5. Total prompt+auxiliary tokens spent over a long session
6. Earliest changed message index during compression
7. Ratio of prune-only compressions to full summary compressions
The most important comparison is:
- baseline main vs conservative prune-first
Success is not:
- "fewer tokens in one compression"
Success is:
- "equal or better total session cost without increasing compression/cache-break cadence in a meaningful way"
---
## 7. Better long-term directions
If we want a stronger cache story than conservative prune-first, these are the real next-step options:
### A. Insertion-time trimming
Best cache-preserving option.
Idea:
- trim or summarize giant tool outputs before they become durable transcript history
- keep a compact representation from the start instead of mutating history later
Pros:
- avoids later cache-breaking rewrites for those blobs
- makes transcript size stable earlier
Cons:
- more invasive design change
- requires careful UX and provenance handling
### B. Provider/backend-aware compaction policy
Different providers may reward:
- preserving a longer stable prefix
- or simply reducing total prompt size
We may eventually want backend-specific heuristics for:
- prune runway targets
- compression thresholds
- when to prefer summary vs pruning
### C. Explicit compression telemetry
If compression remains a core feature, `ContextCompressor` should expose enough telemetry to understand real-world cadence:
- prune-only count
- full summary count
- average recovered tokens
- last compression mode
This is not required for the conservative prototype, but it would make future tuning much easier.
---
## 8. Recommended next steps
1. Keep the conservative prototype local for review.
2. Run targeted tests plus long-session manual trials.
3. If it looks promising, add telemetry before opening another PR.
4. If cache stability remains the top priority, pursue insertion-time trimming instead of further read-time pruning tweaks.
---
## 9. Review question for Teknium
The key product question is:
"Should Hermes optimize compression primarily for per-event token cost, or for minimizing the number of transcript mutations over the lifetime of a session?"
This prototype assumes the answer is:
- prioritize fewer transcript mutations unless pruning buys substantial runway.
+2 -4
View 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 -14
View File
@@ -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 {}
@@ -484,7 +478,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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+4 -5
View File
@@ -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",
+1 -3
View File
@@ -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
-20
View File
@@ -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:
+1 -3
View File
@@ -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 = (
+3 -33
View File
@@ -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
+3 -3
View File
@@ -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)",
+2 -7
View File
@@ -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": {
+4 -4
View File
@@ -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"
+1 -3
View File
@@ -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.
-1
View File
@@ -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",
+1 -10
View File
@@ -654,7 +654,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,
)
@@ -1267,15 +1266,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:
+3 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+274 -453
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -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))
# ============================================================================
-92
View File
@@ -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
View File
@@ -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",
+8 -41
View File
@@ -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.
@@ -408,7 +382,7 @@ class AIAgent:
# Persistent error log -- always writes WARNING+ to ~/.hermes/logs/errors.log
# so tool failures, API errors, etc. are inspectable after the fact.
from agent.redact import RedactingFormatter
_error_log_dir = _hermes_home / "logs"
_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
@@ -2755,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:
@@ -3935,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")
@@ -3957,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 ''}'")
@@ -4107,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":
@@ -4142,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
-249
View File
@@ -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.
+10 -16
View File
@@ -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.10.25 | Atmosphere, texture. Never competes with content. |
| **Content** | md (balanced) | 0.40.8 | The main visual idea. Carries the scene's concept. |
| **Accent** | lg or sm (sparse) | 0.51.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}},
]
```
+43 -328
View File
@@ -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
-297
View File
@@ -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
+7 -18
View File
@@ -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), \
+140 -41
View File
@@ -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()
@@ -355,3 +314,143 @@ class TestCompressWithClient:
for msg in result:
if msg.get("role") == "tool" and msg.get("tool_call_id"):
assert msg["tool_call_id"] in called_ids
class TestPruneToolOutputs:
def _make_compressor(self, *, context_length=128000, protect_first_n=2, protect_last_n=2):
with patch("agent.context_compressor.get_model_context_length", return_value=context_length):
return ContextCompressor(
model="test/model",
threshold_percent=0.50,
protect_first_n=protect_first_n,
protect_last_n=protect_last_n,
quiet_mode=True,
)
def test_prune_replaces_old_middle_tool_outputs(self):
c = self._make_compressor(protect_last_n=1)
big_content = "x" * (c._prune_protect_tokens * 4)
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "task"},
{"role": "assistant", "content": "older"},
{"role": "tool", "content": big_content, "name": "terminal"},
{"role": "assistant", "content": "newer"},
{"role": "tool", "content": big_content, "name": "terminal"},
{"role": "assistant", "content": "tail"},
]
pruned, chars_saved = c._prune_tool_outputs(messages)
assert chars_saved > 0
assert pruned[3]["content"].startswith("[Tool output pruned")
assert pruned[5]["content"] == big_content
def test_protected_tools_are_never_pruned(self):
c = self._make_compressor()
big_content = "x" * (c._prune_protect_tokens * 8)
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "task"},
{"role": "assistant", "content": "older"},
{"role": "tool", "content": big_content, "name": "read_file"},
{"role": "assistant", "content": "middle"},
{"role": "tool", "content": big_content, "name": "terminal"},
{"role": "assistant", "content": "tail"},
]
pruned, _ = c._prune_tool_outputs(messages)
read_file_msg = next(msg for msg in pruned if msg.get("name") == "read_file")
assert read_file_msg["content"] == big_content
def test_prune_only_path_skips_summary_call_when_sufficient(self):
c = self._make_compressor(protect_first_n=2, protect_last_n=1)
huge_content = "x" * 180000
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "task"},
{"role": "assistant", "content": "older"},
{"role": "tool", "content": huge_content, "name": "terminal"},
{"role": "assistant", "content": "newer"},
{"role": "tool", "content": huge_content, "name": "terminal"},
{"role": "assistant", "content": "tail"},
]
with patch.object(ContextCompressor, "_generate_summary", side_effect=AssertionError("summary should not be called")):
result = c.compress(messages, current_tokens=200000)
assert result[3]["content"].startswith("[Tool output pruned")
assert result[5]["content"] == huge_content
assert c.compression_count == 1
def test_prune_does_not_touch_protected_tail_messages(self):
c = self._make_compressor(context_length=128000, protect_first_n=2, protect_last_n=3)
huge_content = "x" * (c._prune_protect_tokens * 8)
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "task"},
{"role": "assistant", "content": "older"},
{"role": "tool", "content": huge_content, "name": "terminal"},
{"role": "assistant", "content": "tail assistant"},
{"role": "tool", "content": huge_content, "name": "terminal"},
{"role": "assistant", "content": "latest"},
]
pruned, _ = c._prune_tool_outputs(messages)
assert pruned[-2]["content"] == huge_content
assert pruned[-1]["content"] == "latest"
class TestPruneAcceptancePolicy:
def _make_compressor(self, *, context_length=128000):
with patch("agent.context_compressor.get_model_context_length", return_value=context_length):
return ContextCompressor(
model="test/model",
threshold_percent=0.50,
protect_first_n=2,
protect_last_n=1,
quiet_mode=True,
)
def test_prune_near_threshold_still_falls_back_to_summary(self):
c = self._make_compressor()
huge_content = "x" * 180000
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "task"},
{"role": "assistant", "content": "older"},
{"role": "tool", "content": huge_content, "name": "terminal"},
{"role": "assistant", "content": "newer"},
{"role": "tool", "content": huge_content, "name": "terminal"},
{"role": "assistant", "content": "tail"},
]
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: compacted"
with patch("agent.context_compressor.estimate_messages_tokens_rough", return_value=62000), \
patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(messages, current_tokens=68000)
assert any("CONTEXT SUMMARY" in (msg.get("content") or "") for msg in result)
def test_prune_only_is_allowed_when_it_buys_real_runway(self):
c = self._make_compressor()
huge_content = "x" * 180000
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "task"},
{"role": "assistant", "content": "older"},
{"role": "tool", "content": huge_content, "name": "terminal"},
{"role": "assistant", "content": "newer"},
{"role": "tool", "content": huge_content, "name": "terminal"},
{"role": "assistant", "content": "tail"},
]
with patch("agent.context_compressor.estimate_messages_tokens_rough", return_value=48000), \
patch.object(ContextCompressor, "_generate_summary", side_effect=AssertionError("summary should not be called")):
result = c.compress(messages, current_tokens=68000)
assert result[3]["content"].startswith("[Tool output pruned")
assert result[5]["content"] == huge_content
-8
View File
@@ -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)
+5 -6
View File
@@ -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}
@@ -425,7 +425,6 @@ def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path):
}))
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
+1 -22
View File
@@ -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."""
-47
View File
@@ -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"
-50
View File
@@ -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
-139
View File
@@ -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
View File
@@ -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 ──────────────────────────
+25 -25
View File
@@ -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
-34
View File
@@ -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
+2 -13
View File
@@ -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({})
+1 -48
View File
@@ -14,7 +14,7 @@ from unittest.mock import MagicMock, patch
import pytest
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
@@ -1441,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 = []
+1 -1
View File
@@ -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,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)
-223
View File
@@ -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"]
+2 -4
View File
@@ -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
+1 -2
View File
@@ -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]:
+1 -2
View File
@@ -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]:
+1 -3
View File
@@ -47,13 +47,11 @@ 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
+16 -34
View File
@@ -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))
# =============================================================================
@@ -1126,62 +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":
return bool(
shutil.which("bash")
or ("/usr/bin/bash" if Path("/usr/bin/bash").is_file() else None)
or ("/bin/bash" if Path("/bin/bash").is_file() else None)
or (os.environ.get("SHELL") if os.environ.get("SHELL") else None)
or ("/bin/sh" if Path("/bin/sh").is_file() else None)
)
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:
+124 -220
View File
@@ -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
View File
@@ -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
+4 -9
View File
@@ -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
+217 -335
View File
@@ -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).
:::
+18 -43
View File
@@ -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,48 @@ 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 |
| `SIGNAL_GROUP_ALLOWED_USERS` | Comma-separated group IDs, or `*` for all groups (omit to disable groups) |
| `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: `~`) |
| `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 +145,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)
@@ -1,51 +0,0 @@
---
sidebar_position: 6
title: "Official Optional Skills Catalog"
description: "Catalog of official optional skills available from the repository"
---
# Official Optional Skills Catalog
Official optional skills live in the repository under `optional-skills/`. Install them with `hermes skills install official/<category>/<skill>` or browse them with `hermes skills browse --source official`.
## autonomous-ai-agents
| Skill | Description | Path |
|-------|-------------|------|
| `blackbox` | Delegate coding tasks to Blackbox AI CLI agent. Multi-model agent with built-in judge that runs tasks through multiple LLMs and picks the best result. Requires the blackbox CLI and a Blackbox AI API key. | `autonomous-ai-agents/blackbox` |
## blockchain
| Skill | Description | Path |
|-------|-------------|------|
| `solana` | Query Solana blockchain data with USD pricing — wallet balances, token portfolios with values, transaction details, NFTs, whale detection, and live network stats. Uses Solana RPC + CoinGecko. No API key required. | `blockchain/solana` |
## email
| Skill | Description | Path |
|-------|-------------|------|
| `agentmail` | Give the agent its own dedicated email inbox via AgentMail. Send, receive, and manage email autonomously using agent-owned email addresses (e.g. hermes-agent@agentmail.to). | `email/agentmail` |
## health
| Skill | Description | Path |
|-------|-------------|------|
| `neuroskill-bci` | Connect to a running NeuroSkill instance and incorporate the user's real-time cognitive and emotional state (focus, relaxation, mood, cognitive load, drowsiness, heart rate, HRV, sleep staging, and 40+ derived EXG scores) into responses. Requires a BCI wearable (Muse 2/S or Open… | `health/neuroskill-bci` |
## migration
| Skill | Description | Path |
|-------|-------------|------|
| `openclaw-migration` | Migrate a user's OpenClaw customization footprint into Hermes Agent. Imports Hermes-compatible memories, SOUL.md, command allowlists, user skills, and selected workspace assets from ~/.openclaw, then reports exactly what could not be migrated and why. | `migration/openclaw-migration` |
## research
| Skill | Description | Path |
|-------|-------------|------|
| `qmd` | Search personal knowledge bases, notes, docs, and meeting transcripts locally using qmd — a hybrid retrieval engine with BM25, vector search, and LLM reranking. Supports CLI and MCP integration. | `research/qmd` |
## security
| Skill | Description | Path |
|-------|-------------|------|
| `1password` | Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in, and reading/injecting secrets for commands. | `security/1password` |
-243
View File
@@ -1,243 +0,0 @@
---
sidebar_position: 5
title: "Bundled Skills Catalog"
description: "Catalog of bundled skills that ship with Hermes Agent"
---
# Bundled Skills Catalog
Hermes ships with a large built-in skill library copied into `~/.hermes/skills/` on install. This page catalogs the bundled skills that live in the repository under `skills/`.
## apple
Apple/macOS-specific skills — iMessage, Reminders, Notes, FindMy, and macOS automation. These skills only load on macOS systems.
| Skill | Description | Path |
|-------|-------------|------|
| `apple-notes` | Manage Apple Notes via the memo CLI on macOS (create, view, search, edit). | `apple/apple-notes` |
| `apple-reminders` | Manage Apple Reminders via remindctl CLI (list, add, complete, delete). | `apple/apple-reminders` |
| `findmy` | Track Apple devices and AirTags via FindMy.app on macOS using AppleScript and screen capture. | `apple/findmy` |
| `imessage` | Send and receive iMessages/SMS via the imsg CLI on macOS. | `apple/imessage` |
## autonomous-ai-agents
Skills for spawning and orchestrating autonomous AI coding agents and multi-agent workflows — running independent agent processes, delegating tasks, and coordinating parallel workstreams.
| Skill | Description | Path |
|-------|-------------|------|
| `claude-code` | Delegate coding tasks to Claude Code (Anthropic's CLI agent). Use for building features, refactoring, PR reviews, and iterative coding. Requires the claude CLI installed. | `autonomous-ai-agents/claude-code` |
| `codex` | Delegate coding tasks to OpenAI Codex CLI agent. Use for building features, refactoring, PR reviews, and batch issue fixing. Requires the codex CLI and a git repository. | `autonomous-ai-agents/codex` |
| `hermes-agent-spawning` | Spawn additional Hermes Agent instances as autonomous subprocesses for independent long-running tasks. Supports non-interactive one-shot mode (-q) and interactive PTY mode for multi-turn collaboration. Different from delegate_task — this runs a full separate hermes process. | `autonomous-ai-agents/hermes-agent` |
| `opencode` | 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. | `autonomous-ai-agents/opencode` |
## creative
Creative content generation — ASCII art, hand-drawn style diagrams, and visual design tools.
| Skill | Description | Path |
|-------|-------------|------|
| `ascii-art` | Generate ASCII art using pyfiglet (571 fonts), cowsay, boxes, toilet, image-to-ascii, remote APIs (asciified, ascii.co.uk), and LLM fallback. No API keys required. | `creative/ascii-art` |
| `ascii-video` | "Production pipeline for ASCII art video — any format. Converts video/audio/images/generative input into colored ASCII character video output (MP4, GIF, image sequence). Covers: video-to-ASCII conversion, audio-reactive music visualizers, generative ASCII art animations, hybrid… | `creative/ascii-video` |
| `excalidraw` | Create hand-drawn style diagrams using Excalidraw JSON format. Generate .excalidraw files for architecture diagrams, flowcharts, sequence diagrams, concept maps, and more. Files can be opened at excalidraw.com or uploaded for shareable links. | `creative/excalidraw` |
## dogfood
| Skill | Description | Path |
|-------|-------------|------|
| `dogfood` | Systematic exploratory QA testing of web applications — find bugs, capture evidence, and generate structured reports | `dogfood` |
## email
Skills for sending, receiving, searching, and managing email from the terminal.
| Skill | Description | Path |
|-------|-------------|------|
| `himalaya` | CLI to manage emails via IMAP/SMTP. Use himalaya to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language). | `email/himalaya` |
## gaming
Skills for setting up, configuring, and managing game servers, modpacks, and gaming-related infrastructure.
| Skill | Description | Path |
|-------|-------------|------|
| `minecraft-modpack-server` | Set up a modded Minecraft server from a CurseForge/Modrinth server pack zip. Covers NeoForge/Forge install, Java version, JVM tuning, firewall, LAN config, backups, and launch scripts. | `gaming/minecraft-modpack-server` |
| `pokemon-player` | Play Pokemon games autonomously via headless emulation. Starts a game server, reads structured game state from RAM, makes strategic decisions, and sends button inputs — all from the terminal. | `gaming/pokemon-player` |
## github
GitHub workflow skills for managing repositories, pull requests, code reviews, issues, and CI/CD pipelines using the gh CLI and git via terminal.
| Skill | Description | Path |
|-------|-------------|------|
| `codebase-inspection` | Inspect and analyze codebases using pygount for LOC counting, language breakdown, and code-vs-comment ratios. Use when asked to check lines of code, repo size, language composition, or codebase stats. | `github/codebase-inspection` |
| `github-auth` | Set up GitHub authentication for the agent using git (universally available) or the gh CLI. Covers HTTPS tokens, SSH keys, credential helpers, and gh auth — with a detection flow to pick the right method automatically. | `github/github-auth` |
| `github-code-review` | Review code changes by analyzing git diffs, leaving inline comments on PRs, and performing thorough pre-push review. Works with gh CLI or falls back to git + GitHub REST API via curl. | `github/github-code-review` |
| `github-issues` | Create, manage, triage, and close GitHub issues. Search existing issues, add labels, assign people, and link to PRs. Works with gh CLI or falls back to git + GitHub REST API via curl. | `github/github-issues` |
| `github-pr-workflow` | Full pull request lifecycle — create branches, commit changes, open PRs, monitor CI status, auto-fix failures, and merge. Works with gh CLI or falls back to git + GitHub REST API via curl. | `github/github-pr-workflow` |
| `github-repo-management` | Clone, create, fork, configure, and manage GitHub repositories. Manage remotes, secrets, releases, and workflows. Works with gh CLI or falls back to git + GitHub REST API via curl. | `github/github-repo-management` |
## leisure
| Skill | Description | Path |
|-------|-------------|------|
| `find-nearby` | Find nearby places (restaurants, cafes, bars, pharmacies, etc.) using OpenStreetMap. Works with coordinates, addresses, cities, zip codes, or Telegram location pins. No API keys needed. | `leisure/find-nearby` |
## mcp
Skills for working with MCP (Model Context Protocol) servers, tools, and integrations. Includes the built-in native MCP client (configure servers in config.yaml for automatic tool discovery) and the mcporter CLI bridge for ad-hoc server interaction.
| Skill | Description | Path |
|-------|-------------|------|
| `mcporter` | Use the mcporter CLI to list, configure, auth, and call MCP servers/tools directly (HTTP or stdio), including ad-hoc servers, config edits, and CLI/type generation. | `mcp/mcporter` |
| `native-mcp` | Built-in MCP (Model Context Protocol) client that connects to external MCP servers, discovers their tools, and registers them as native Hermes Agent tools. Supports stdio and HTTP transports with automatic reconnection, security filtering, and zero-config tool injection. | `mcp/native-mcp` |
## media
Skills for working with media content — YouTube transcripts, GIF search, music generation, and audio visualization.
| Skill | Description | Path |
|-------|-------------|------|
| `gif-search` | Search and download GIFs from Tenor using curl. No dependencies beyond curl and jq. Useful for finding reaction GIFs, creating visual content, and sending GIFs in chat. | `media/gif-search` |
| `heartmula` | Set up and run HeartMuLa, the open-source music generation model family (Suno-like). Generates full songs from lyrics + tags with multilingual support. | `media/heartmula` |
| `songsee` | Generate spectrograms and audio feature visualizations (mel, chroma, MFCC, tempogram, etc.) from audio files via CLI. Useful for audio analysis, music production debugging, and visual documentation. | `media/songsee` |
| `youtube-content` | Fetch YouTube video transcripts and transform them into structured content (chapters, summaries, threads, blog posts). | `media/youtube-content` |
## mlops/cloud
GPU cloud providers and serverless compute platforms for ML workloads.
| Skill | Description | Path |
|-------|-------------|------|
| `lambda-labs-gpu-cloud` | Reserved and on-demand GPU cloud instances for ML training and inference. Use when you need dedicated GPU instances with simple SSH access, persistent filesystems, or high-performance multi-node clusters for large-scale training. | `mlops/cloud/lambda-labs` |
| `modal-serverless-gpu` | Serverless GPU cloud platform for running ML workloads. Use when you need on-demand GPU access without infrastructure management, deploying ML models as APIs, or running batch jobs with automatic scaling. | `mlops/cloud/modal` |
## mlops/evaluation
Model evaluation benchmarks, experiment tracking, data curation, tokenizers, and interpretability tools.
| Skill | Description | Path |
|-------|-------------|------|
| `evaluating-llms-harness` | Evaluates LLMs across 60+ academic benchmarks (MMLU, HumanEval, GSM8K, TruthfulQA, HellaSwag). Use when benchmarking model quality, comparing models, reporting academic results, or tracking training progress. Industry standard used by EleutherAI, HuggingFace, and major labs. Sup… | `mlops/evaluation/lm-evaluation-harness` |
| `huggingface-tokenizers` | Fast tokenizers optimized for research and production. Rust-based implementation tokenizes 1GB in &lt;20 seconds. Supports BPE, WordPiece, and Unigram algorithms. Train custom vocabularies, track alignments, handle padding/truncation. Integrates seamlessly with transformers. Use… | `mlops/evaluation/huggingface-tokenizers` |
| `nemo-curator` | GPU-accelerated data curation for LLM training. Supports text/image/video/audio. Features fuzzy deduplication (16× faster), quality filtering (30+ heuristics), semantic deduplication, PII redaction, NSFW detection. Scales across GPUs with RAPIDS. Use for preparing high-quality t… | `mlops/evaluation/nemo-curator` |
| `sparse-autoencoder-training` | Provides guidance for training and analyzing Sparse Autoencoders (SAEs) using SAELens to decompose neural network activations into interpretable features. Use when discovering interpretable features, analyzing superposition, or studying monosemantic representations in language m… | `mlops/evaluation/saelens` |
| `weights-and-biases` | Track ML experiments with automatic logging, visualize training in real-time, optimize hyperparameters with sweeps, and manage model registry with W&B - collaborative MLOps platform | `mlops/evaluation/weights-and-biases` |
## mlops/inference
Model serving, quantization (GGUF/GPTQ), structured output, inference optimization, and model surgery tools for deploying and running LLMs.
| Skill | Description | Path |
|-------|-------------|------|
| `gguf-quantization` | GGUF format and llama.cpp quantization for efficient CPU/GPU inference. Use when deploying models on consumer hardware, Apple Silicon, or when needing flexible quantization from 2-8 bit without GPU requirements. | `mlops/inference/gguf` |
| `guidance` | Control LLM output with regex and grammars, guarantee valid JSON/XML/code generation, enforce structured formats, and build multi-step workflows with Guidance - Microsoft Research's constrained generation framework | `mlops/inference/guidance` |
| `instructor` | Extract structured data from LLM responses with Pydantic validation, retry failed extractions automatically, parse complex JSON with type safety, and stream partial results with Instructor - battle-tested structured output library | `mlops/inference/instructor` |
| `llama-cpp` | Runs LLM inference on CPU, Apple Silicon, and consumer GPUs without NVIDIA hardware. Use for edge deployment, M1/M2/M3 Macs, AMD/Intel GPUs, or when CUDA is unavailable. Supports GGUF quantization (1.5-8 bit) for reduced memory and 4-10× speedup vs PyTorch on CPU. | `mlops/inference/llama-cpp` |
| `obliteratus` | Remove refusal behaviors from open-weight LLMs using OBLITERATUS — mechanistic interpretability techniques (diff-in-means, SVD, whitened SVD, LEACE, SAE decomposition, etc.) to excise guardrails while preserving reasoning. 9 CLI methods, 28 analysis modules, 116 model presets ac… | `mlops/inference/obliteratus` |
| `outlines` | Guarantee valid JSON/XML/code structure during generation, use Pydantic models for type-safe outputs, support local models (Transformers, vLLM), and maximize inference speed with Outlines - dottxt.ai's structured generation library | `mlops/inference/outlines` |
| `serving-llms-vllm` | Serves LLMs with high throughput using vLLM's PagedAttention and continuous batching. Use when deploying production LLM APIs, optimizing inference latency/throughput, or serving models with limited GPU memory. Supports OpenAI-compatible endpoints, quantization (GPTQ/AWQ/FP8), an… | `mlops/inference/vllm` |
| `tensorrt-llm` | Optimizes LLM inference with NVIDIA TensorRT for maximum throughput and lowest latency. Use for production deployment on NVIDIA GPUs (A100/H100), when you need 10-100x faster inference than PyTorch, or for serving models with quantization (FP8/INT4), in-flight batching, and mult… | `mlops/inference/tensorrt-llm` |
## mlops/models
Specific model architectures and tools — computer vision (CLIP, SAM, Stable Diffusion), speech (Whisper), audio generation (AudioCraft), and multimodal models (LLaVA).
| Skill | Description | Path |
|-------|-------------|------|
| `audiocraft-audio-generation` | PyTorch library for audio generation including text-to-music (MusicGen) and text-to-sound (AudioGen). Use when you need to generate music from text descriptions, create sound effects, or perform melody-conditioned music generation. | `mlops/models/audiocraft` |
| `clip` | OpenAI's model connecting vision and language. Enables zero-shot image classification, image-text matching, and cross-modal retrieval. Trained on 400M image-text pairs. Use for image search, content moderation, or vision-language tasks without fine-tuning. Best for general-purpo… | `mlops/models/clip` |
| `llava` | Large Language and Vision Assistant. Enables visual instruction tuning and image-based conversations. Combines CLIP vision encoder with Vicuna/LLaMA language models. Supports multi-turn image chat, visual question answering, and instruction following. Use for vision-language cha… | `mlops/models/llava` |
| `segment-anything-model` | Foundation model for image segmentation with zero-shot transfer. Use when you need to segment any object in images using points, boxes, or masks as prompts, or automatically generate all object masks in an image. | `mlops/models/segment-anything` |
| `stable-diffusion-image-generation` | State-of-the-art text-to-image generation with Stable Diffusion models via HuggingFace Diffusers. Use when generating images from text prompts, performing image-to-image translation, inpainting, or building custom diffusion pipelines. | `mlops/models/stable-diffusion` |
| `whisper` | OpenAI's general-purpose speech recognition model. Supports 99 languages, transcription, translation to English, and language identification. Six model sizes from tiny (39M params) to large (1550M params). Use for speech-to-text, podcast transcription, or multilingual audio proc… | `mlops/models/whisper` |
## mlops/research
ML research frameworks for building and optimizing AI systems with declarative programming.
| Skill | Description | Path |
|-------|-------------|------|
| `dspy` | Build complex AI systems with declarative programming, optimize prompts automatically, create modular RAG systems and agents with DSPy - Stanford NLP's framework for systematic LM programming | `mlops/research/dspy` |
## mlops/training
Fine-tuning, RLHF/DPO/GRPO training, distributed training frameworks, and optimization tools for training LLMs and other models.
| Skill | Description | Path |
|-------|-------------|------|
| `axolotl` | Expert guidance for fine-tuning LLMs with Axolotl - YAML configs, 100+ models, LoRA/QLoRA, DPO/KTO/ORPO/GRPO, multimodal support | `mlops/training/axolotl` |
| `distributed-llm-pretraining-torchtitan` | Provides PyTorch-native distributed LLM pretraining using torchtitan with 4D parallelism (FSDP2, TP, PP, CP). Use when pretraining Llama 3.1, DeepSeek V3, or custom models at scale from 8 to 512+ GPUs with Float8, torch.compile, and distributed checkpointing. | `mlops/training/torchtitan` |
| `fine-tuning-with-trl` | Fine-tune LLMs using reinforcement learning with TRL - SFT for instruction tuning, DPO for preference alignment, PPO/GRPO for reward optimization, and reward model training. Use when need RLHF, align model with preferences, or train from human feedback. Works with HuggingFace Tr… | `mlops/training/trl-fine-tuning` |
| `grpo-rl-training` | Expert guidance for GRPO/RL fine-tuning with TRL for reasoning and task-specific model training | `mlops/training/grpo-rl-training` |
| `hermes-atropos-environments` | Build, test, and debug Hermes Agent RL environments for Atropos training. Covers the HermesAgentBaseEnv interface, reward functions, agent loop integration, evaluation with tools, wandb logging, and the three CLI modes (serve/process/evaluate). Use when creating, reviewing, or f… | `mlops/training/hermes-atropos-environments` |
| `huggingface-accelerate` | Simplest distributed training API. 4 lines to add distributed support to any PyTorch script. Unified API for DeepSpeed/FSDP/Megatron/DDP. Automatic device placement, mixed precision (FP16/BF16/FP8). Interactive config, single launch command. HuggingFace ecosystem standard. | `mlops/training/accelerate` |
| `optimizing-attention-flash` | Optimizes transformer attention with Flash Attention for 2-4x speedup and 10-20x memory reduction. Use when training/running transformers with long sequences (&gt;512 tokens), encountering GPU memory issues with attention, or need faster inference. Supports PyTorch native SDPA,… | `mlops/training/flash-attention` |
| `peft-fine-tuning` | Parameter-efficient fine-tuning for LLMs using LoRA, QLoRA, and 25+ methods. Use when fine-tuning large models (7B-70B) with limited GPU memory, when you need to train &lt;1% of parameters with minimal accuracy loss, or for multi-adapter serving. HuggingFace's official library i… | `mlops/training/peft` |
| `pytorch-fsdp` | Expert guidance for Fully Sharded Data Parallel training with PyTorch FSDP - parameter sharding, mixed precision, CPU offloading, FSDP2 | `mlops/training/pytorch-fsdp` |
| `pytorch-lightning` | High-level PyTorch framework with Trainer class, automatic distributed training (DDP/FSDP/DeepSpeed), callbacks system, and minimal boilerplate. Scales from laptop to supercomputer with same code. Use when you want clean training loops with built-in best practices. | `mlops/training/pytorch-lightning` |
| `simpo-training` | Simple Preference Optimization for LLM alignment. Reference-free alternative to DPO with better performance (+6.4 points on AlpacaEval 2.0). No reference model needed, more efficient than DPO. Use for preference alignment when want simpler, faster training than DPO/PPO. | `mlops/training/simpo` |
| `slime-rl-training` | Provides guidance for LLM post-training with RL using slime, a Megatron+SGLang framework. Use when training GLM models, implementing custom data generation workflows, or needing tight Megatron-LM integration for RL scaling. | `mlops/training/slime` |
| `unsloth` | Expert guidance for fast fine-tuning with Unsloth - 2-5x faster training, 50-80% less memory, LoRA/QLoRA optimization | `mlops/training/unsloth` |
## mlops/vector-databases
Vector similarity search and embedding databases for RAG, semantic search, and AI application backends.
| Skill | Description | Path |
|-------|-------------|------|
| `chroma` | Open-source embedding database for AI applications. Store embeddings and metadata, perform vector and full-text search, filter by metadata. Simple 4-function API. Scales from notebooks to production clusters. Use for semantic search, RAG applications, or document retrieval. Best… | `mlops/vector-databases/chroma` |
| `faiss` | Facebook's library for efficient similarity search and clustering of dense vectors. Supports billions of vectors, GPU acceleration, and various index types (Flat, IVF, HNSW). Use for fast k-NN search, large-scale vector retrieval, or when you need pure similarity search without… | `mlops/vector-databases/faiss` |
| `pinecone` | Managed vector database for production AI applications. Fully managed, auto-scaling, with hybrid search (dense + sparse), metadata filtering, and namespaces. Low latency (&lt;100ms p95). Use for production RAG, recommendation systems, or semantic search at scale. Best for server… | `mlops/vector-databases/pinecone` |
| `qdrant-vector-search` | High-performance vector similarity search engine for RAG and semantic search. Use when building production RAG systems requiring fast nearest neighbor search, hybrid search with filtering, or scalable vector storage with Rust-powered performance. | `mlops/vector-databases/qdrant` |
## note-taking
Note taking skills, to save information, assist with research, and collab on multi-session planning and information sharing.
| Skill | Description | Path |
|-------|-------------|------|
| `obsidian` | Read, search, and create notes in the Obsidian vault. | `note-taking/obsidian` |
## productivity
Skills for document creation, presentations, spreadsheets, and other productivity workflows.
| Skill | Description | Path |
|-------|-------------|------|
| `google-workspace` | Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration via Python. Uses OAuth2 with automatic token refresh. No external binaries needed — runs entirely with Google's Python client libraries in the Hermes venv. | `productivity/google-workspace` |
| `nano-pdf` | Edit PDFs with natural-language instructions using the nano-pdf CLI. Modify text, fix typos, update titles, and make content changes to specific pages without manual editing. | `productivity/nano-pdf` |
| `notion` | Notion API for creating and managing pages, databases, and blocks via curl. Search, create, update, and query Notion workspaces directly from the terminal. | `productivity/notion` |
| `ocr-and-documents` | Extract text from PDFs and scanned documents. Use web_extract for remote URLs, pymupdf for local text-based PDFs, marker-pdf for OCR/scanned docs. For DOCX use python-docx, for PPTX see the powerpoint skill. | `productivity/ocr-and-documents` |
| `powerpoint` | "Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in a… | `productivity/powerpoint` |
## research
Skills for academic research, paper discovery, literature review, domain reconnaissance, market data, content monitoring, and scientific knowledge retrieval.
| Skill | Description | Path |
|-------|-------------|------|
| `arxiv` | Search and retrieve academic papers from arXiv using their free REST API. No API key needed. Search by keyword, author, category, or ID. Combine with web_extract or the ocr-and-documents skill to read full paper content. | `research/arxiv` |
| `blogwatcher` | Monitor blogs and RSS/Atom feeds for updates using the blogwatcher CLI. Add blogs, scan for new articles, and track what you've read. | `research/blogwatcher` |
| `domain-intel` | Passive domain reconnaissance using Python stdlib. Subdomain discovery, SSL certificate inspection, WHOIS lookups, DNS records, domain availability checks, and bulk multi-domain analysis. No API keys required. | `research/domain-intel` |
| `duckduckgo-search` | Free web search via DuckDuckGo — text, news, images, videos. No API key needed. Use the Python DDGS library or CLI to search, then web_extract for full content. | `research/duckduckgo-search` |
| `ml-paper-writing` | Write publication-ready ML/AI papers for NeurIPS, ICML, ICLR, ACL, AAAI, COLM. Use when drafting papers from research repos, structuring arguments, verifying citations, or preparing camera-ready submissions. Includes LaTeX templates, reviewer guidelines, and citation verificatio… | `research/ml-paper-writing` |
| `polymarket` | Query Polymarket prediction market data — search markets, get prices, orderbooks, and price history. Read-only via public REST APIs, no API key needed. | `research/polymarket` |
## smart-home
Skills for controlling smart home devices — lights, switches, sensors, and home automation systems.
| Skill | Description | Path |
|-------|-------------|------|
| `openhue` | Control Philips Hue lights, rooms, and scenes via the OpenHue CLI. Turn lights on/off, adjust brightness, color, color temperature, and activate scenes. | `smart-home/openhue` |
## software-development
| Skill | Description | Path |
|-------|-------------|------|
| `code-review` | Guidelines for performing thorough code reviews with security and quality focus | `software-development/code-review` |
| `requesting-code-review` | Use when completing tasks, implementing major features, or before merging. Validates work meets requirements through systematic review process. | `software-development/requesting-code-review` |
| `subagent-driven-development` | Use when executing implementation plans with independent tasks. Dispatches fresh delegate_task per task with two-stage review (spec compliance then code quality). | `software-development/subagent-driven-development` |
| `systematic-debugging` | Use when encountering any bug, test failure, or unexpected behavior. 4-phase root cause investigation — NO fixes without understanding the problem first. | `software-development/systematic-debugging` |
| `test-driven-development` | Use when implementing any feature or bugfix, before writing implementation code. Enforces RED-GREEN-REFACTOR cycle with test-first approach. | `software-development/test-driven-development` |
| `writing-plans` | Use when you have a spec or requirements for a multi-step task. Creates comprehensive implementation plans with bite-sized tasks, exact file paths, and complete code examples. | `software-development/writing-plans` |
-119
View File
@@ -1,119 +0,0 @@
---
sidebar_position: 2
title: "Slash Commands Reference"
description: "Complete reference for interactive CLI and messaging slash commands"
---
# Slash Commands Reference
Hermes has two slash-command surfaces:
- **Interactive CLI slash commands** — handled by `cli.py` / `hermes_cli/commands.py`
- **Messaging slash commands** — handled by `gateway/run.py`
Installed skills are also exposed as dynamic slash commands on both surfaces.
## Interactive CLI slash commands
Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-insensitive.
### Session
| Command | Description |
|---------|-------------|
| `/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) |
| `/undo` | Remove the last user/assistant exchange |
| `/title` | Set a title for the current session (usage: /title My Session Name) |
| `/compress` | Manually compress conversation context (flush memories + summarize) |
| `/rollback` | List or restore filesystem checkpoints (usage: /rollback [number]) |
| `/background` | Run a prompt in the background (usage: /background &lt;prompt&gt;) |
### Configuration
| Command | Description |
|---------|-------------|
| `/config` | Show current configuration |
| `/model` | Show or change the current model |
| `/provider` | Show available providers and current provider |
| `/prompt` | View/set custom system prompt |
| `/personality` | Set a predefined personality |
| `/verbose` | Cycle tool progress display: off → new → all → verbose |
| `/reasoning` | Manage reasoning effort and display (usage: /reasoning [level\|show\|hide]) |
| `/skin` | Show or change the display skin/theme |
### Tools & Skills
| Command | Description |
|---------|-------------|
| `/tools` | List available tools |
| `/toolsets` | List available toolsets |
| `/skills` | Search, install, inspect, or manage skills from online registries |
| `/cron` | Manage scheduled tasks (list, add, remove) |
| `/reload-mcp` | Reload MCP servers from config.yaml |
### Info
| Command | Description |
|---------|-------------|
| `/help` | Show this help message |
| `/usage` | Show token usage for the current session |
| `/insights` | Show usage insights and analytics (last 30 days) |
| `/platforms` | Show gateway/messaging platform status |
| `/paste` | Check clipboard for an image and attach it |
### Exit
| Command | Description |
|---------|-------------|
| `/quit` | Exit the CLI (also: /exit, /q) |
### Dynamic CLI slash commands
| Command | Description |
|---------|-------------|
| `/<skill-name>` | Load any installed skill as an on-demand command. Example: `/gif-search`, `/github-pr-workflow`, `/excalidraw`. |
| `/skills ...` | Search, browse, inspect, install, audit, publish, and configure skills from registries and the official optional-skills catalog. |
### Quick commands
User-defined quick commands from `quick_commands` in `~/.hermes/config.yaml` are also available as slash commands. These are resolved at dispatch time, not shown in the built-in autocomplete/help tables.
## Messaging slash commands
The messaging gateway supports the following built-in commands inside Telegram, Discord, Slack, WhatsApp, Signal, Email, and Home Assistant chats:
| Command | Description |
|---------|-------------|
| `/new` | Start a new conversation. |
| `/reset` | Reset conversation history. |
| `/status` | Show session info. |
| `/stop` | Interrupt the running agent without queuing a follow-up prompt. |
| `/model [provider:model]` | Show or change the model, including provider switches. |
| `/provider` | Show provider availability and auth status. |
| `/personality [name]` | Set a personality overlay for the session. |
| `/retry` | Retry the last message. |
| `/undo` | Remove the last exchange. |
| `/sethome` | Mark the current chat as the platform home channel for deliveries. |
| `/compress` | Manually compress conversation context. |
| `/title [name]` | Set or show the session title. |
| `/resume [name]` | Resume a previously named session. |
| `/usage` | Show token usage for the current session. |
| `/insights [days]` | Show usage analytics. |
| `/reasoning [level\|show\|hide]` | Change reasoning effort or toggle reasoning display. |
| `/rollback [number]` | List or restore filesystem checkpoints. |
| `/background &lt;prompt&gt;` | Run a prompt in a separate background session. |
| `/reload-mcp` | Reload MCP servers from config. |
| `/update` | Update Hermes Agent to the latest version. |
| `/help` | Show messaging help. |
| `/<skill-name>` | Invoke any installed skill by name. |
## Notes
- `/skin`, `/tools`, `/toolsets`, `/config`, `/prompt`, `/cron`, `/skills`, `/platforms`, `/paste`, and `/verbose` are **CLI-only** commands.
- `/status`, `/stop`, `/sethome`, `/resume`, `/background`, and `/update` are **messaging-only** commands.
- `/reload-mcp` and `/rollback` work in **both** the CLI and the messaging gateway.
-163
View File
@@ -1,163 +0,0 @@
---
sidebar_position: 3
title: "Built-in Tools Reference"
description: "Authoritative reference for Hermes built-in tools, grouped by toolset"
---
# Built-in Tools Reference
This page documents the built-in Hermes tool registry as it exists in code. Availability can still vary by platform, credentials, and enabled toolsets.
## `browser` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `browser_back` | Navigate back to the previous page in browser history. Requires browser_navigate to be called first. | — |
| `browser_click` | Click on an element identified by its ref ID from the snapshot (e.g., '@e5'). The ref IDs are shown in square brackets in the snapshot output. Requires browser_navigate and browser_snapshot to be called first. | — |
| `browser_close` | Close the browser session and release resources. Call this when done with browser tasks to free up Browserbase session quota. | — |
| `browser_console` | Get browser console output and JavaScript errors from the current page. Returns console.log/warn/error/info messages and uncaught JS exceptions. Use this to detect silent JavaScript errors, failed API calls, and application warnings. Requi… | — |
| `browser_get_images` | Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first. | — |
| `browser_navigate` | Navigate to a URL in the browser. Initializes the session and loads the page. Must be called before other browser tools. For simple information retrieval, prefer web_search or web_extract (faster, cheaper). Use browser tools when you need… | — |
| `browser_press` | Press a keyboard key. Useful for submitting forms (Enter), navigating (Tab), or keyboard shortcuts. Requires browser_navigate to be called first. | — |
| `browser_scroll` | Scroll the page in a direction. Use this to reveal more content that may be below or above the current viewport. Requires browser_navigate to be called first. | — |
| `browser_snapshot` | Get a text-based snapshot of the current page's accessibility tree. Returns interactive elements with ref IDs (like @e1, @e2) for browser_click and browser_type. full=false (default): compact view with interactive elements. full=true: comp… | — |
| `browser_type` | Type text into an input field identified by its ref ID. Clears the field first, then types the new text. Requires browser_navigate and browser_snapshot to be called first. | — |
| `browser_vision` | Take a screenshot of the current page and analyze it with vision AI. Use this when you need to visually understand what's on the page - especially useful for CAPTCHAs, visual verification challenges, complex layouts, or when the text snaps… | — |
## `clarify` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `clarify` | Ask the user a question when you need clarification, feedback, or a decision before proceeding. Supports two modes: 1. **Multiple choice** — provide up to 4 choices. The user picks one or types their own answer via a 5th 'Other' option. 2.… | — |
## `code_execution` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `execute_code` | Run a Python script that can call Hermes tools programmatically. Use this when you need 3+ tool calls with processing logic between them, need to filter/reduce large tool outputs before they enter your context, need conditional branching (… | — |
## `cronjob` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `list_cronjobs` | List all scheduled cronjobs with their IDs, schedules, and status. Use this to: - See what jobs are currently scheduled - Find job IDs for removal with remove_cronjob - Check job status and next run times Returns job_id, name, schedule, re… | — |
| `remove_cronjob` | Remove a scheduled cronjob by its ID. Use list_cronjobs first to find the job_id of the job you want to remove. Jobs that have completed their repeat count are auto-removed, but you can use this to cancel a job before it completes. | — |
| `schedule_cronjob` | Schedule an automated task to run the agent on a schedule. ⚠️ CRITICAL: The cronjob runs in a FRESH SESSION with NO CONTEXT from this conversation. The prompt must be COMPLETELY SELF-CONTAINED with ALL necessary information including: - Fu… | — |
## `delegation` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `delegate_task` | Spawn one or more subagents to work on tasks in isolated contexts. Each subagent gets its own conversation, terminal session, and toolset. Only the final summary is returned -- intermediate tool results never enter your context window. TWO… | — |
## `file` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `patch` | Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing… | — |
| `read_file` | Read a text file with line numbers and pagination. Use this instead of cat/head/tail in terminal. Output format: 'LINE_NUM\|CONTENT'. Suggests similar filenames if not found. Use offset and limit for large files. NOTE: Cannot read images o… | — |
| `search_files` | Search file contents or find files by name. Use this instead of grep/rg/find/ls in terminal. Ripgrep-backed, faster than shell equivalents. Content search (target='content'): Regex search inside files. Output modes: full matches with line… | — |
| `write_file` | Write content to a file, completely replacing existing content. Use this instead of echo/cat heredoc in terminal. Creates parent directories automatically. OVERWRITES the entire file — use 'patch' for targeted edits. | — |
## `homeassistant` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `ha_call_service` | Call a Home Assistant service to control a device. Use ha_list_services to discover available services and their parameters for each domain. | — |
| `ha_get_state` | Get the detailed state of a single Home Assistant entity, including all attributes (brightness, color, temperature setpoint, sensor readings, etc.). | — |
| `ha_list_entities` | List Home Assistant entities. Optionally filter by domain (light, switch, climate, sensor, binary_sensor, cover, fan, etc.) or by area name (living room, kitchen, bedroom, etc.). | — |
| `ha_list_services` | List available Home Assistant services (actions) for device control. Shows what actions can be performed on each device type and what parameters they accept. Use this to discover how to control devices found via ha_list_entities. | — |
## `honcho` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `honcho_conclude` | Write a conclusion about the user back to Honcho's memory. Conclusions are persistent facts that build the user's profile — preferences, corrections, clarifications, project context, or anything the user tells you that should be remembered… | — |
| `honcho_context` | Ask Honcho a natural language question and get a synthesized answer. Uses Honcho's LLM (dialectic reasoning) — higher cost than honcho_profile or honcho_search. Can query about any peer: the user (default), the AI assistant, or any named p… | — |
| `honcho_profile` | Retrieve the user's peer card from Honcho — a curated list of key facts about them (name, role, preferences, communication style, patterns). Fast, no LLM reasoning, minimal cost. Use this at conversation start or when you need a quick fact… | — |
| `honcho_search` | Semantic search over Honcho's stored context about the user. Returns raw excerpts ranked by relevance to your query — no LLM synthesis. Cheaper and faster than honcho_context. Good when you want to find specific past facts and reason over… | — |
## `image_gen` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `image_generate` | Generate high-quality images from text prompts using FLUX 2 Pro model with automatic 2x upscaling. Creates detailed, artistic images that are automatically upscaled for hi-rez results. Returns a single upscaled image URL. Display it using… | FAL_KEY |
## `memory` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `memory` | Save important information to persistent memory that survives across sessions. Your memory appears in your system prompt at session start -- it's how you remember things about the user and your environment between conversations. WHEN TO SA… | — |
## `messaging` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `send_message` | Send a message to a connected messaging platform, or list available targets. IMPORTANT: When the user asks to send to a specific channel or person (not just a bare platform name), call send_message(action='list') FIRST to see available tar… | — |
## `moa` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `mixture_of_agents` | Route a hard problem through multiple frontier LLMs collaboratively. Makes 5 API calls (4 reference models + 1 aggregator) with maximum reasoning effort — use sparingly for genuinely difficult problems. Best for: complex math, advanced alg… | OPENROUTER_API_KEY |
## `rl` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `rl_check_status` | Get status and metrics for a training run. RATE LIMITED: enforces 30-minute minimum between checks for the same run. Returns WandB metrics: step, state, reward_mean, loss, percent_correct. | TINKER_API_KEY, WANDB_API_KEY |
| `rl_edit_config` | Update a configuration field. Use rl_get_current_config() first to see all available fields for the selected environment. Each environment has different configurable options. Infrastructure settings (tokenizer, URLs, lora_rank, learning_ra… | TINKER_API_KEY, WANDB_API_KEY |
| `rl_get_current_config` | Get the current environment configuration. Returns only fields that can be modified: group_size, max_token_length, total_steps, steps_per_eval, use_wandb, wandb_name, max_num_workers. | TINKER_API_KEY, WANDB_API_KEY |
| `rl_get_results` | Get final results and metrics for a completed training run. Returns final metrics and path to trained weights. | TINKER_API_KEY, WANDB_API_KEY |
| `rl_list_environments` | List all available RL environments. Returns environment names, paths, and descriptions. TIP: Read the file_path with file tools to understand how each environment works (verifiers, data loading, rewards). | TINKER_API_KEY, WANDB_API_KEY |
| `rl_list_runs` | List all training runs (active and completed) with their status. | TINKER_API_KEY, WANDB_API_KEY |
| `rl_select_environment` | Select an RL environment for training. Loads the environment's default configuration. After selecting, use rl_get_current_config() to see settings and rl_edit_config() to modify them. | TINKER_API_KEY, WANDB_API_KEY |
| `rl_start_training` | Start a new RL training run with the current environment and config. Most training parameters (lora_rank, learning_rate, etc.) are fixed. Use rl_edit_config() to set group_size, batch_size, wandb_project before starting. WARNING: Training… | TINKER_API_KEY, WANDB_API_KEY |
| `rl_stop_training` | Stop a running training job. Use if metrics look bad, training is stagnant, or you want to try different settings. | TINKER_API_KEY, WANDB_API_KEY |
| `rl_test_inference` | Quick inference test for any environment. Runs a few steps of inference + scoring using OpenRouter. Default: 3 steps x 16 completions = 48 rollouts per model, testing 3 models = 144 total. Tests environment loading, prompt construction, in… | TINKER_API_KEY, WANDB_API_KEY |
## `session_search` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `session_search` | Search your long-term memory of past conversations. This is your recall -- every past session is searchable, and this tool summarizes what happened. USE THIS PROACTIVELY when: - The user says 'we did this before', 'remember when', 'last ti… | — |
## `skills` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `skill_manage` | Manage skills (create, update, delete). Skills are your procedural memory — reusable approaches for recurring task types. New skills go to ~/.hermes/skills/; existing skills can be modified wherever they live. Actions: create (full SKILL.m… | — |
| `skill_view` | Skills allow for loading information about specific tasks and workflows, as well as scripts and templates. Load a skill's full content or access its linked files (references, templates, scripts). First call returns SKILL.md content plus a… | — |
| `skills_list` | List available skills (name + description). Use skill_view(name) to load full content. | — |
## `terminal` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `process` | Manage background processes started with terminal(background=true). Actions: 'list' (show all), 'poll' (check status + new output), 'log' (full output with pagination), 'wait' (block until done or timeout), 'kill' (terminate), 'write' (sen… | — |
| `terminal` | Execute shell commands on a Linux environment. Filesystem persists between calls. Do NOT use cat/head/tail to read files — use read_file instead. Do NOT use grep/rg/find to search — use search_files instead. Do NOT use ls to list directori… | — |
## `todo` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `todo` | Manage your task list for the current session. Use for complex tasks with 3+ steps or when the user provides multiple tasks. Call with no parameters to read the current list. Writing: - Provide 'todos' array to create/update items - merge=… | — |
## `tts` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `text_to_speech` | Convert text to speech audio. Returns a MEDIA: path that the platform delivers as a voice message. On Telegram it plays as a voice bubble, on Discord/WhatsApp as an audio attachment. In CLI mode, saves to ~/voice-memos/. Voice and provider… | — |
## `vision` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `vision_analyze` | Analyze images using AI vision. Provides a comprehensive description and answers a specific question about the image content. | — |
## `web` toolset
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `web_extract` | Extract content from web page URLs. Returns page content in markdown format. Also works with PDF URLs (arxiv papers, documents, etc.) — pass the PDF link directly and it converts to markdown text. Pages under 5000 chars return full markdow… | FIRECRAWL_API_KEY |
| `web_search` | Search the web for information on any topic. Returns up to 5 relevant results with titles, URLs, and descriptions. | FIRECRAWL_API_KEY |
@@ -1,49 +0,0 @@
---
sidebar_position: 4
title: "Toolsets Reference"
description: "Reference for Hermes core, composite, platform, and dynamic toolsets"
---
# Toolsets Reference
Toolsets are named bundles of tools that you can enable with `hermes chat --toolsets ...`, configure per platform, or resolve inside the agent runtime.
| Toolset | Kind | Resolves to |
|---------|------|-------------|
| `browser` | core | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` |
| `clarify` | core | `clarify` |
| `code_execution` | core | `execute_code` |
| `cronjob` | core | `list_cronjobs`, `remove_cronjob`, `schedule_cronjob` |
| `debugging` | composite | `patch`, `process`, `read_file`, `search_files`, `terminal`, `web_extract`, `web_search`, `write_file` |
| `delegation` | core | `delegate_task` |
| `file` | core | `patch`, `read_file`, `search_files`, `write_file` |
| `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-discord` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-email` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-gateway` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-homeassistant` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-signal` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-slack` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-telegram` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-whatsapp` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `homeassistant` | core | `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services` |
| `honcho` | core | `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search` |
| `image_gen` | core | `image_generate` |
| `memory` | core | `memory` |
| `moa` | core | `mixture_of_agents` |
| `rl` | core | `rl_check_status`, `rl_edit_config`, `rl_get_current_config`, `rl_get_results`, `rl_list_environments`, `rl_list_runs`, `rl_select_environment`, `rl_start_training`, `rl_stop_training`, `rl_test_inference` |
| `safe` | composite | `image_generate`, `mixture_of_agents`, `vision_analyze`, `web_extract`, `web_search` |
| `search` | core | `web_search` |
| `session_search` | core | `session_search` |
| `skills` | core | `skill_manage`, `skill_view`, `skills_list` |
| `terminal` | core | `process`, `terminal` |
| `todo` | core | `todo` |
| `tts` | core | `text_to_speech` |
| `vision` | core | `vision_analyze` |
| `web` | core | `web_extract`, `web_search` |
## Dynamic toolsets
- `mcp-<server>` — generated at runtime for each configured MCP server.
- Custom toolsets can be created in configuration and resolved at startup.
- Wildcards: `all` and `*` expand to every registered toolset.
+51 -23
View File
@@ -75,33 +75,61 @@ When resuming a previous session (`hermes -c` or `hermes --resume <id>`), a "Pre
|-----|--------|
| `Enter` | Send message |
| `Alt+Enter` or `Ctrl+J` | New line (multi-line input) |
| `Alt+V` | Paste an image from the clipboard when supported by the terminal |
| `Ctrl+V` | Paste text and opportunistically attach clipboard images |
| `Ctrl+C` | Interrupt agent (double-press within 2s to force exit) |
| `Ctrl+D` | Exit |
| `Tab` | Autocomplete slash commands |
## Slash Commands
Type `/` to see the autocomplete dropdown. Hermes supports a large set of CLI slash commands, dynamic skill commands, and user-defined quick commands.
Type `/` to see an autocomplete dropdown of all available commands.
Common examples:
### Navigation & Control
| Command | Description |
|---------|-------------|
| `/help` | Show command help |
| `/model` | Show or change the current model |
| `/tools` | List currently available tools |
| `/skills browse` | Browse the skills hub and official optional skills |
| `/background <prompt>` | Run a prompt in a separate background session |
| `/skin` | Show or switch the active CLI skin |
| `/reasoning high` | Increase reasoning effort |
| `/title My Session` | Name the current session |
| `/help` | Show available commands |
| `/quit` | Exit the CLI (also: `/exit`, `/q`) |
| `/clear` | Clear screen and reset conversation |
| `/new` | Start a new conversation |
| `/reset` | Reset conversation only (keep screen) |
For the full built-in CLI and messaging lists, see [Slash Commands Reference](../reference/slash-commands.md).
### Tools & Configuration
| Command | Description |
|---------|-------------|
| `/tools` | List all available tools grouped by toolset |
| `/toolsets` | List available toolsets with descriptions |
| `/model [provider:model]` | Show or change the current model (supports `provider:model` syntax) |
| `/provider` | Show available providers with auth status |
| `/config` | Show current configuration |
| `/prompt [text]` | View/set/clear custom system prompt |
| `/personality [name]` | Set a predefined personality |
| `/reasoning [arg]` | Manage reasoning effort (`none`/`low`/`medium`/`high`/`xhigh`) and display (`show`/`hide`) |
### Conversation Management
| 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 |
| `/usage` | Show token usage for this session |
| `/insights [--days N]` | Show usage insights and analytics (last 30 days) |
### 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 display: off → new → all → verbose |
| `/<skill-name>` | Invoke any installed skill (e.g., `/axolotl`, `/gif-search`) |
:::tip
Commands are case-insensitive — `/HELP` works the same as `/help`. Installed skills also become slash commands automatically.
Commands are case-insensitive — `/HELP` works the same as `/help`. Most commands work mid-conversation.
:::
## Quick Commands
@@ -233,16 +261,16 @@ Resuming restores the full conversation history from SQLite. The agent sees all
Use `/title My Session Name` inside a chat to name the current session, or `hermes sessions rename <id> <title>` from the command line. Use `hermes sessions list` to browse past sessions.
### Session Storage
### Session Logging
CLI sessions are stored in Hermes's SQLite state database under `~/.hermes/state.db`. The database keeps:
Sessions are automatically logged to `~/.hermes/sessions/`:
- session metadata (ID, title, timestamps, token counters)
- message history
- lineage across compressed/resumed sessions
- full-text search indexes used by `session_search`
Some messaging adapters also keep per-platform transcript files alongside the database, but the CLI itself resumes from the SQLite session store.
```
sessions/
├── session_20260201_143052_a1b2c3.json
├── session_20260201_150217_d4e5f6.json
└── ...
```
### Context Compression
@@ -252,7 +280,7 @@ Long conversations are automatically summarized when approaching context limits:
# In ~/.hermes/config.yaml
compression:
enabled: true
threshold: 0.50 # Compress at 50% of context limit by default
threshold: 0.85 # Compress at 85% of context limit
summary_model: "google/gemini-3-flash-preview" # Model used for summarization
```
+9 -11
View File
@@ -72,7 +72,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
| **Custom Endpoint** | `OPENAI_BASE_URL` + `OPENAI_API_KEY` in `~/.hermes/.env` |
:::info Codex Note
The OpenAI Codex provider authenticates via device code (open a URL, enter a code). Hermes stores the resulting credentials in its own auth store under `~/.hermes/auth.json` and can import existing Codex CLI credentials from `~/.codex/auth.json` when present. No Codex CLI installation is required.
The OpenAI Codex provider authenticates via device code (open a URL, enter a code). Credentials are stored at `~/.codex/auth.json` and auto-refresh. No Codex CLI installation required.
:::
:::warning
@@ -493,7 +493,7 @@ node_modules/
```yaml
compression:
enabled: true
threshold: 0.50 # Compress at 50% of context limit by default
threshold: 0.85 # Compress at 85% of context limit
summary_model: "google/gemini-3-flash-preview" # Model for summarization
# summary_provider: "auto" # "auto", "openrouter", "nous", "main"
```
@@ -666,13 +666,12 @@ tts:
```yaml
display:
tool_progress: all # off | new | all | verbose
skin: default # Built-in or custom CLI skin (see user-guide/features/skins)
personality: "kawaii" # Legacy cosmetic field still surfaced in some summaries
compact: false # Compact output mode (less whitespace)
resume_display: full # full (show previous messages on resume) | minimal (one-liner only)
bell_on_complete: false # Play terminal bell when agent finishes (great for long tasks)
show_reasoning: false # Show model reasoning/thinking above each response (toggle with /reasoning show|hide)
tool_progress: all # off | new | all | verbose
personality: "kawaii" # Default personality for the CLI
compact: false # Compact output mode (less whitespace)
resume_display: full # full (show previous messages on resume) | minimal (one-liner only)
bell_on_complete: false # Play terminal bell when agent finishes (great for long tasks)
show_reasoning: false # Show model reasoning/thinking above each response (toggle with /reasoning show|hide)
```
| Mode | What you see |
@@ -715,9 +714,8 @@ Usage: type `/status`, `/disk`, `/update`, or `/gpu` in the CLI or any messaging
- **30-second timeout** — long-running commands are killed with an error message
- **Priority** — quick commands are checked before skill commands, so you can override skill names
- **Autocomplete** — quick commands are resolved at dispatch time and are not shown in the built-in slash-command autocomplete tables
- **Type** — only `exec` is supported (runs a shell command); other types show an error
- **Works everywhere** — CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant
- **Works everywhere** — CLI, Telegram, Discord, Slack, WhatsApp, Signal
## Human Delay
@@ -94,7 +94,7 @@ Entries can optionally include:
Each prompt gets a randomly sampled set of toolsets from a **distribution**. This ensures training data covers diverse tool combinations. Use `--list_distributions` to see all available distributions.
In the current implementation, distributions assign a probability to **each individual toolset**. The sampler flips each toolset independently, then guarantees that at least one toolset is enabled. This is different from a hand-authored table of prebuilt combinations.
Distributions define probability weights for each toolset combination. For example, a "default" distribution might assign high probability to `["terminal", "file", "web"]` and lower probability to web-only or file-only combinations.
## Output Format
+4 -15
View File
@@ -7,16 +7,11 @@ sidebar_position: 5
# Browser Automation
Hermes Agent includes a full browser automation toolset that can run in two modes:
- **Browserbase cloud mode** via [Browserbase](https://browserbase.com) for managed cloud browsers and anti-bot tooling
- **Local browser mode** via the `agent-browser` CLI and a local Chromium installation
In both modes, the agent can navigate websites, interact with page elements, fill forms, and extract information.
Hermes Agent includes a full browser automation toolset powered by [Browserbase](https://browserbase.com), enabling the agent to navigate websites, interact with page elements, fill forms, and extract information — all running in cloud-hosted browsers with built-in anti-bot stealth features.
## Overview
The browser tools use the `agent-browser` CLI. In Browserbase mode, `agent-browser` connects to Browserbase cloud sessions. In local mode, it drives a local Chromium installation. Pages are represented as **accessibility trees** (text-based snapshots), making them ideal for LLM agents. Interactive elements get ref IDs (like `@e1`, `@e2`) that the agent uses for clicking and typing.
The browser tools use the `agent-browser` CLI with Browserbase cloud execution. Pages are represented as **accessibility trees** (text-based snapshots), making them ideal for LLM agents. Interactive elements get ref IDs (like `@e1`, `@e2`) that the agent uses for clicking and typing.
Key capabilities:
@@ -28,22 +23,16 @@ Key capabilities:
## Setup
### Browserbase cloud mode
To use Browserbase-managed cloud browsers, add:
### Required Environment Variables
```bash
# Add to ~/.hermes/.env
BROWSERBASE_API_KEY=***
BROWSERBASE_API_KEY=your-api-key-here
BROWSERBASE_PROJECT_ID=your-project-id-here
```
Get your credentials at [browserbase.com](https://browserbase.com).
### Local browser mode
If you do **not** set Browserbase credentials, Hermes can still use the browser tools through a local Chromium install driven by `agent-browser`.
### Optional Environment Variables
```bash
+1 -1
View File
@@ -298,7 +298,7 @@ hermes honcho peer --user NAME # Set user peer name
hermes honcho peer --ai NAME # Set AI peer name
hermes honcho peer --reasoning LEVEL # Set dialectic reasoning level
hermes honcho mode # Show current memory mode
hermes honcho mode [hybrid|honcho|local] # Set memory mode
hermes honcho mode [hybrid|honcho] # Set memory mode
hermes honcho tokens # Show token budget settings
hermes honcho tokens --context N # Set context token cap
hermes honcho tokens --dialectic N # Set dialectic char cap
-2
View File
@@ -161,8 +161,6 @@ Tools appear alongside built-in tools — the agent calls them like any other to
:::info
In addition to the server's own tools, each MCP server also gets 4 utility tools auto-registered: `list_resources`, `read_resource`, `list_prompts`, and `get_prompt`. These allow the agent to discover and use MCP resources and prompts exposed by the server.
Each configured server also creates a **runtime toolset** named `mcp-<server>`. This means you can filter or reason about MCP servers at the toolset level in the same way you do with built-in toolsets.
:::
### Reconnection
@@ -216,17 +216,13 @@ The system prompt is assembled in layers (from `agent/prompt_builder.py` and `ru
**SOUL.md vs agent.system_prompt**: SOUL.md is part of the "Project Context" section and coexists with the default identity. The `agent.system_prompt` (set via `/personality` or config) is an ephemeral overlay. Both can be active simultaneously — SOUL.md for tone/personality, system_prompt for additional instructions.
:::
## CLI Appearance vs Conversational Personality
## Display Personality (CLI Banner)
Conversational personality and CLI appearance are separate:
- `agent.system_prompt`, `/personality`, and `SOUL.md` affect how Hermes **speaks**.
- `display.skin` and `/skin` affect how Hermes **looks in the terminal**.
The `display.personality` config option controls the CLI's **visual** personality (banner art, spinner messages), independent of the agent's conversational personality:
```yaml
display:
skin: default
# personality: kawaii # legacy cosmetic setting still shown in some summaries
personality: kawaii # Affects CLI banner and spinner art
```
For the full theming system — built-in skins, custom YAML skins, spinner branding, and `/skin` — see [Skins & Themes](./skins.md).
This is purely cosmetic and doesn't affect the agent's responses — only the ASCII art and loading messages shown in the terminal.
@@ -10,11 +10,6 @@ Skills are on-demand knowledge documents the agent can load when needed. They fo
All skills live in **`~/.hermes/skills/`** — a single directory that serves as the source of truth. On fresh install, bundled skills are copied from the repo. Hub-installed and agent-created skills also go here. The agent can modify or delete any skill.
See also:
- [Bundled Skills Catalog](/docs/reference/skills-catalog)
- [Official Optional Skills Catalog](/docs/reference/optional-skills-catalog)
## Using Skills
Every installed skill is automatically available as a slash command:
@@ -144,7 +139,6 @@ When a missing value is encountered, Hermes asks for it securely only when the s
│ │ ├── SKILL.md # Main instructions (required)
│ │ ├── references/ # Additional docs
│ │ ├── templates/ # Output formats
│ │ ├── scripts/ # Helper scripts callable from the skill
│ │ └── assets/ # Supplementary files
│ └── vllm/
│ └── SKILL.md
@@ -205,8 +199,6 @@ hermes skills tap add myorg/skills-repo # Add a custom source
All hub-installed skills go through a **security scanner** that checks for data exfiltration, prompt injection, destructive commands, and other threats.
Official optional skills use identifiers like `official/security/1password` and `official/migration/openclaw-migration`.
### Trust Levels
| Level | Source | Policy |
-81
View File
@@ -1,81 +0,0 @@
---
sidebar_position: 10
title: "Skins & Themes"
description: "Customize the Hermes CLI with built-in and user-defined skins"
---
# Skins & Themes
Skins control the **visual presentation** of the Hermes CLI: banner colors, spinner faces and verbs, response-box labels, branding text, and the tool activity prefix.
Conversational style and visual style are separate concepts:
- **Personality** changes the agent's tone and wording.
- **Skin** changes the CLI's appearance.
## Change skins
```bash
/skin # show the current skin and list available skins
/skin ares # switch to a built-in skin
/skin mytheme # switch to a custom skin from ~/.hermes/skins/mytheme.yaml
```
Or set the default skin in `~/.hermes/config.yaml`:
```yaml
display:
skin: default
```
## Built-in skins
| Skin | Description | Agent branding |
|------|-------------|----------------|
| `default` | Classic Hermes — gold and kawaii | `Hermes Agent` |
| `ares` | War-god theme — crimson and bronze | `Ares Agent` |
| `mono` | Monochrome — clean grayscale | `Hermes Agent` |
| `slate` | Cool blue — developer-focused | `Hermes Agent` |
| `poseidon` | Ocean-god theme — deep blue and seafoam | `Poseidon Agent` |
| `sisyphus` | Sisyphean theme — austere grayscale with persistence | `Sisyphus Agent` |
| `charizard` | Volcanic theme — burnt orange and ember | `Charizard Agent` |
## What a skin can customize
| Area | Keys |
|------|------|
| Banner + response colors | `colors.banner_*`, `colors.response_border` |
| Spinner animation | `spinner.waiting_faces`, `spinner.thinking_faces`, `spinner.thinking_verbs`, `spinner.wings` |
| Branding text | `branding.agent_name`, `branding.welcome`, `branding.response_label`, `branding.prompt_symbol` |
| Tool activity prefix | `tool_prefix` |
## Custom skins
Create YAML files under `~/.hermes/skins/`. User skins inherit missing values from the built-in `default` skin.
```yaml
name: cyberpunk
description: Neon terminal theme
colors:
banner_border: "#FF00FF"
banner_title: "#00FFFF"
banner_accent: "#FF1493"
spinner:
thinking_verbs: ["jacking in", "decrypting", "uploading"]
wings:
- ["⟨⚡", "⚡⟩"]
branding:
agent_name: "Cyber Agent"
response_label: " ⚡ Cyber "
tool_prefix: "▏"
```
## Operational notes
- Built-in skins load from `hermes_cli/skin_engine.py`.
- Unknown skins automatically fall back to `default`.
- `/skin` updates the active CLI theme immediately for the current session.
+20 -20
View File
@@ -10,22 +10,25 @@ Tools are functions that extend the agent's capabilities. They're organized into
## Available Tools
Hermes ships with a broad built-in tool registry covering web search, browser automation, terminal execution, file editing, memory, delegation, RL training, messaging delivery, Home Assistant, Honcho memory, and more.
High-level categories:
| Category | Examples | Description |
|----------|----------|-------------|
| **Web** | `web_search`, `web_extract` | Search the web and extract page content. |
| **Terminal & Files** | `terminal`, `process`, `read_file`, `patch` | Execute commands and manipulate files. |
| **Browser** | `browser_navigate`, `browser_snapshot`, `browser_vision` | Interactive browser automation with text and vision support. |
| **Media** | `vision_analyze`, `image_generate`, `text_to_speech` | Multimodal analysis and generation. |
| **Agent orchestration** | `todo`, `clarify`, `execute_code`, `delegate_task` | Planning, clarification, code execution, and subagent delegation. |
| **Memory & recall** | `memory`, `session_search`, `honcho_*` | Persistent memory, session search, and Honcho cross-session context. |
| **Automation & delivery** | `schedule_cronjob`, `send_message` | Scheduled tasks and outbound messaging delivery. |
| **Integrations** | `ha_*`, MCP server tools, `rl_*` | Home Assistant, MCP, RL training, and other integrations. |
For the authoritative code-derived registry, see [Built-in Tools Reference](/docs/reference/tools-reference) and [Toolsets Reference](/docs/reference/toolsets-reference).
| Category | Tools | Description |
|----------|-------|-------------|
| **Web** | `web_search`, `web_extract` | Search the web, extract page content |
| **Terminal** | `terminal`, `process` | Execute commands (local/docker/singularity/modal/daytona/ssh backends), manage background processes |
| **File** | `read_file`, `write_file`, `patch`, `search_files` | Read, write, edit, and search files |
| **Browser** | `browser_navigate`, `browser_click`, `browser_type`, `browser_console`, etc. | Full browser automation via Browserbase |
| **Vision** | `vision_analyze` | Image analysis via multimodal models |
| **Image Gen** | `image_generate` | Generate images (FLUX via FAL) |
| **TTS** | `text_to_speech` | Text-to-speech (Edge TTS / ElevenLabs / OpenAI) |
| **Reasoning** | `mixture_of_agents` | Multi-model reasoning |
| **Skills** | `skills_list`, `skill_view`, `skill_manage` | Find, view, create, and manage skills |
| **Todo** | `todo` | Read/write task list for multi-step planning |
| **Memory** | `memory` | Persistent notes + user profile across sessions |
| **Session Search** | `session_search` | Search + summarize past conversations (FTS5) |
| **Cronjob** | `schedule_cronjob`, `list_cronjobs`, `remove_cronjob` | Scheduled task management |
| **Code Execution** | `execute_code` | Run Python scripts that call tools via RPC sandbox |
| **Delegation** | `delegate_task` | Spawn subagents with isolated context |
| **Clarify** | `clarify` | Ask the user multiple-choice or open-ended questions |
| **MCP** | Auto-discovered | External tools from MCP servers |
## Using Toolsets
@@ -40,9 +43,7 @@ hermes tools
hermes tools
```
Common toolsets include `web`, `terminal`, `file`, `browser`, `vision`, `image_gen`, `moa`, `skills`, `tts`, `todo`, `memory`, `session_search`, `cronjob`, `code_execution`, `delegation`, `clarify`, `honcho`, `homeassistant`, and `rl`.
See [Toolsets Reference](/docs/reference/toolsets-reference) for the full set, including platform presets such as `hermes-cli`, `hermes-telegram`, and dynamic MCP toolsets like `mcp-<server>`.
**Available toolsets:** `web`, `terminal`, `file`, `browser`, `vision`, `image_gen`, `moa`, `skills`, `tts`, `todo`, `memory`, `session_search`, `cronjob`, `code_execution`, `delegation`, `clarify`, and more.
## Terminal Backends
@@ -55,7 +56,6 @@ The terminal tool can execute commands in different environments:
| `ssh` | Remote server | Sandboxing, keep agent away from its own code |
| `singularity` | HPC containers | Cluster computing, rootless |
| `modal` | Cloud execution | Serverless, scale |
| `daytona` | Cloud sandbox workspace | Persistent remote dev environments |
### Configuration
+10 -35
View File
@@ -67,48 +67,23 @@ Without ffmpeg, Edge TTS audio is sent as a regular audio file (playable, but sh
If you want voice bubbles without installing ffmpeg, switch to the OpenAI or ElevenLabs provider.
:::
## Voice Message Transcription (STT)
## Voice Message Transcription
Voice messages sent on Telegram, Discord, WhatsApp, Slack, or Signal are automatically transcribed and injected as text into the conversation. The agent sees the transcript as normal text.
Voice messages sent on Telegram, Discord, WhatsApp, or Slack are automatically transcribed and injected as text into the conversation. The agent sees the transcript as normal text.
| Provider | Quality | Cost | API Key |
|----------|---------|------|---------|
| **Local Whisper** (default) | Good | Free | None needed |
| **OpenAI Whisper API** | GoodBest | Paid | `VOICE_TOOLS_OPENAI_KEY` |
| Provider | Model | Quality | Cost |
|----------|-------|---------|------|
| **OpenAI Whisper** | `whisper-1` (default) | Good | Low |
| **OpenAI GPT-4o** | `gpt-4o-mini-transcribe` | Better | Medium |
| **OpenAI GPT-4o** | `gpt-4o-transcribe` | Best | Higher |
:::info Zero Config
Local transcription works out of the box — no API key needed. The `faster-whisper` model (~150 MB for `base`) is auto-downloaded on first voice message.
:::
Requires `VOICE_TOOLS_OPENAI_KEY` in `~/.hermes/.env`.
### Configuration
```yaml
# In ~/.hermes/config.yaml
stt:
provider: "local" # "local" (free, faster-whisper) | "openai" (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"
```
### Provider Details
**Local (faster-whisper)** — Runs Whisper locally via [faster-whisper](https://github.com/SYSTRAN/faster-whisper). Uses CPU by default, GPU if available. Model sizes:
| Model | Size | Speed | Quality |
|-------|------|-------|---------|
| `tiny` | ~75 MB | Fastest | Basic |
| `base` | ~150 MB | Fast | Good (default) |
| `small` | ~500 MB | Medium | Better |
| `medium` | ~1.5 GB | Slower | Great |
| `large-v3` | ~3 GB | Slowest | Best |
**OpenAI API** — Requires `VOICE_TOOLS_OPENAI_KEY`. Supports `whisper-1`, `gpt-4o-mini-transcribe`, and `gpt-4o-transcribe`.
### Fallback Behavior
If your configured provider isn't available, Hermes automatically falls back:
- **Local not installed** → Falls back to OpenAI API (if key is set)
- **OpenAI key not set** → Falls back to local Whisper (if installed)
- **Neither available** → Voice messages pass through with a note to the user
+1 -1
View File
@@ -202,7 +202,7 @@ Replace the ID with the actual channel ID (right-click → Copy Channel ID with
## Bot Behavior
- **Server channels**: By default the bot requires an `@mention` before it responds in server channels. You can disable that globally with `DISCORD_REQUIRE_MENTION=false` or allow specific channels to be mention-free via `DISCORD_FREE_RESPONSE_CHANNELS`.
- **Server channels**: The bot responds to all messages from allowed users in channels it can access. It does **not** require a mention or prefix — any message from an allowed user is treated as a prompt.
- **Direct messages**: DMs always work, even without the Message Content Intent enabled (Discord exempts DMs from this requirement). However, you should still enable the intent for server channel support.
- **Conversations**: Each channel or DM maintains its own conversation context.
@@ -130,22 +130,33 @@ The Home Assistant gateway adapter connects via WebSocket and subscribes to `sta
By default, **no events are forwarded**. You must configure at least one of `watch_domains`, `watch_entities`, or `watch_all` to receive events. Without filters, a warning is logged at startup and all state changes are silently dropped.
:::
Configure which events the agent sees in `~/.hermes/gateway.json` under the Home Assistant platform's `extra` section:
Configure which events the agent sees in `~/.hermes/config.yaml` under the Home Assistant platform's `extra` section:
```json
{
"platforms": {
"homeassistant": {
"enabled": true,
"extra": {
"watch_domains": ["climate", "binary_sensor", "alarm_control_panel", "light"],
"watch_entities": ["sensor.front_door_battery"],
"ignore_entities": ["sensor.uptime", "sensor.cpu_usage", "sensor.memory_usage"],
"cooldown_seconds": 30
}
}
}
}
```yaml
# ~/.hermes/config.yaml
messaging:
platforms:
homeassistant:
extra:
# Watch specific domains (recommended)
watch_domains:
- climate
- binary_sensor
- alarm_control_panel
- light
# Watch specific entities (in addition to domains)
watch_entities:
- sensor.front_door_battery
# Ignore noisy entities
ignore_entities:
- sensor.uptime
- sensor.cpu_usage
- sensor.memory_usage
# Per-entity cooldown (seconds)
cooldown_seconds: 30
```
| Setting | Default | Description |
+3 -8
View File
@@ -62,7 +62,7 @@ hermes gateway status # Check service status
| Command | Description |
|---------|-------------|
| `/new` or `/reset` | Start a fresh conversation |
| `/new` or `/reset` | Start fresh conversation |
| `/model [provider:model]` | Show or change the model (supports `provider:model` syntax) |
| `/provider` | Show available providers with auth status |
| `/personality [name]` | Set a personality |
@@ -72,13 +72,8 @@ hermes gateway status # Check service status
| `/stop` | Stop the running agent |
| `/sethome` | Set this chat as the home channel |
| `/compress` | Manually compress conversation context |
| `/title [name]` | Set or show the session title |
| `/resume [name]` | Resume a previously named session |
| `/usage` | Show token usage for this session |
| `/insights [days]` | Show usage insights and analytics |
| `/reasoning [level\|show\|hide]` | Change reasoning effort or toggle reasoning display |
| `/rollback [number]` | List or restore filesystem checkpoints |
| `/background <prompt>` | Run a prompt in a separate background session |
| `/reload-mcp` | Reload MCP servers from config |
| `/update` | Update Hermes Agent to the latest version |
| `/help` | Show available commands |
@@ -97,7 +92,7 @@ Sessions reset based on configurable policies:
| Policy | Default | Description |
|--------|---------|-------------|
| Daily | 4:00 AM | Reset at a specific hour each day |
| Idle | 1440 min | Reset after N minutes of inactivity |
| Idle | 120 min | Reset after N minutes of inactivity |
| Both | (combined) | Whichever triggers first |
Configure per-platform overrides in `~/.hermes/gateway.json`:
@@ -209,7 +204,7 @@ Each platform has its own toolset:
| Slack | `hermes-slack` | Full tools including terminal |
| Signal | `hermes-signal` | Full tools including terminal |
| Email | `hermes-email` | Full tools including terminal |
| Home Assistant | `hermes-homeassistant` | Full tools + HA device control (ha_list_entities, ha_get_state, ha_call_service, ha_list_services) |
| Home Assistant | `hermes-gateway` | Full tools + HA device control (ha_list_entities, ha_get_state, ha_call_service, ha_list_services) |
## Next Steps
+4 -4
View File
@@ -192,8 +192,8 @@ The adapter monitors the SSE connection and automatically reconnects if:
| **Messages not received** | Check that `SIGNAL_ALLOWED_USERS` includes the sender's number in E.164 format (with `+` prefix) |
| **"signal-cli not found on PATH"** | Install signal-cli and ensure it's in your PATH, or use Docker |
| **Connection keeps dropping** | Check signal-cli logs for errors. Ensure Java 17+ is installed. |
| **Group messages ignored** | Configure `SIGNAL_GROUP_ALLOWED_USERS` with specific group IDs, or `*` to allow all groups. |
| **Bot responds to no one** | Configure `SIGNAL_ALLOWED_USERS`, use DM pairing, or explicitly allow all users through gateway policy if you want broader access. |
| **Group messages ignored** | `SIGNAL_GROUP_POLICY` defaults to `disabled`. Set to `allowlist` or `open`. |
| **Bot responds to everyone** | Set `SIGNAL_DM_POLICY=pairing` or `allowlist` and configure `SIGNAL_ALLOWED_USERS` |
| **Duplicate messages** | Ensure only one signal-cli instance is listening on your phone number |
---
@@ -205,8 +205,8 @@ The adapter monitors the SSE connection and automatically reconnects if:
:::
- Phone numbers are redacted in all log output
- Use DM pairing or explicit allowlists for safe onboarding of new users
- Keep groups disabled unless you specifically need group support, or allowlist only the groups you trust
- Use `SIGNAL_DM_POLICY=pairing` (default) for safe onboarding of new users
- Keep groups disabled unless you specifically need group support
- Signal's end-to-end encryption protects message content in transit
- The signal-cli session data in `~/.local/share/signal-cli/` contains account credentials — protect it like a password
+1 -1
View File
@@ -20,7 +20,7 @@ the steps below.
| Component | Value |
|-----------|-------|
| **Library** | `slack-bolt` / `slack_sdk` for Python (Socket Mode) |
| **Library** | `@slack/bolt` (Socket Mode) |
| **Connection** | WebSocket — no public URL required |
| **Auth tokens needed** | Bot Token (`xoxb-`) + App-Level Token (`xapp-`) |
| **User identification** | Slack Member IDs (e.g., `U01ABC2DEF3`) |
+32 -11
View File
@@ -6,10 +6,13 @@ description: "Set up Hermes Agent as a WhatsApp bot via the built-in Baileys bri
# WhatsApp Setup
Hermes connects to WhatsApp through a built-in bridge based on **Baileys**. This works by emulating a WhatsApp Web session — **not** through the official WhatsApp Business API. No Meta developer account or Business verification is required.
Hermes connects to WhatsApp through a built-in bridge using [whatsapp-web.js](https://github.com/pedroslopez/whatsapp-web.js)
(Baileys-based). This works by emulating a WhatsApp Web session — **not** through the official
WhatsApp Business API. No Meta developer account or Business verification is required.
:::warning Unofficial API — Ban Risk
WhatsApp does **not** officially support third-party bots outside the Business API. Using a third-party bridge carries a small risk of account restrictions. To minimize risk:
WhatsApp does **not** officially support third-party bots outside the Business API. Using
whatsapp-web.js carries a small risk of account restrictions. To minimize risk:
- **Use a dedicated phone number** for the bot (not your personal number)
- **Don't send bulk/spam messages** — keep usage conversational
- **Don't automate outbound messaging** to people who haven't messaged first
@@ -17,7 +20,7 @@ WhatsApp does **not** officially support third-party bots outside the Business A
:::warning WhatsApp Web Protocol Updates
WhatsApp periodically updates their Web protocol, which can temporarily break compatibility
with third-party bridges. When this happens, Hermes will update the bridge dependency. If the
with whatsapp-web.js. When this happens, Hermes will update the bridge dependency. If the
bot stops working after a WhatsApp update, pull the latest Hermes version and re-pair.
:::
@@ -35,7 +38,21 @@ bot stops working after a WhatsApp update, pull the latest Hermes version and re
- **Node.js v18+** and **npm** — the WhatsApp bridge runs as a Node.js process
- **A phone with WhatsApp** installed (for scanning the QR code)
Unlike older browser-driven bridges, the current Baileys-based bridge does **not** require a local Chromium or Puppeteer dependency stack.
**On Linux headless servers**, you also need Chromium/Puppeteer dependencies:
```bash
# Debian / Ubuntu
sudo apt-get install -y \
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 \
libpango-1.0-0 libcairo2 libasound2 libxshmfence1
# Fedora / RHEL
sudo dnf install -y \
nss atk at-spi2-atk cups-libs libdrm libxkbcommon \
libXcomposite libXdamage libXrandr mesa-libgbm \
pango cairo alsa-lib
```
---
@@ -95,6 +112,9 @@ Add the following to your `~/.hermes/.env` file:
WHATSAPP_ENABLED=true
WHATSAPP_MODE=bot # "bot" or "self-chat"
WHATSAPP_ALLOWED_USERS=15551234567 # Comma-separated phone numbers (with country code, no +)
# Optional
WHATSAPP_HOME_CONTACT=15551234567 # Default contact for proactive/scheduled messages
```
Then start the gateway:
@@ -110,11 +130,12 @@ The gateway starts the WhatsApp bridge automatically using the saved session.
## Session Persistence
The Baileys bridge saves its session under `~/.hermes/whatsapp/session`. This means:
The whatsapp-web.js `LocalAuth` strategy saves your session to the `.wwebjs_auth` folder inside
your Hermes data directory (`~/.hermes/`). This means:
- **Sessions survive restarts** — you don't need to re-scan the QR code every time
- The session data includes encryption keys and device credentials
- **Do not share or commit this session directory** — it grants full access to the WhatsApp account
- **Do not share or commit the `.wwebjs_auth` folder** — it grants full access to the WhatsApp account
---
@@ -149,9 +170,9 @@ Hermes supports voice on WhatsApp:
|---------|----------|
| **QR code not scanning** | Ensure terminal is wide enough (60+ columns). Try a different terminal. Make sure you're scanning from the correct WhatsApp account (bot number, not personal). |
| **QR code expires** | QR codes refresh every ~20 seconds. If it times out, restart `hermes whatsapp`. |
| **Session not persisting** | Check that `~/.hermes/whatsapp/session` exists and is writable. If containerized, mount it as a persistent volume. |
| **Logged out unexpectedly** | WhatsApp unlinks devices after long inactivity. Keep the phone on and connected to the network, then re-pair with `hermes whatsapp` if needed. |
| **Bridge crashes or reconnect loops** | Restart the gateway, update Hermes, and re-pair if the session was invalidated by a WhatsApp protocol change. |
| **Session not persisting** | Check that `~/.hermes/.wwebjs_auth/` exists and is writable. On Docker, mount this as a volume. |
| **Logged out unexpectedly** | WhatsApp unlinks devices after ~14 days of phone inactivity. Keep the phone on and connected to WiFi. Re-pair with `hermes whatsapp`. |
| **"Execution context was destroyed"** | Chromium crashed. Install the Puppeteer dependencies listed in Prerequisites. On low-RAM servers, add swap space. |
| **Bot stops working after WhatsApp update** | Update Hermes to get the latest bridge version, then re-pair. |
| **Messages not being received** | Verify `WHATSAPP_ALLOWED_USERS` includes the sender's number (with country code, no `+` or spaces). |
@@ -165,8 +186,8 @@ of authorized users. Without this setting, the gateway will **deny all incoming
safety measure.
:::
- The `~/.hermes/whatsapp/session` directory contains full session credentials — protect it like a password
- Set file permissions: `chmod 700 ~/.hermes/whatsapp/session`
- The `.wwebjs_auth` folder contains full session credentials — protect it like a password
- Set file permissions: `chmod 700 ~/.hermes/.wwebjs_auth`
- Use a **dedicated phone number** for the bot to isolate risk from your personal account
- If you suspect compromise, unlink the device from WhatsApp → Settings → Linked Devices
- Phone numbers in logs are partially redacted, but review your log retention policy
+1 -1
View File
@@ -271,7 +271,7 @@ Total messages: 3847
Database size: 12.4 MB
```
For deeper analytics — token usage, cost estimates, tool breakdown, and activity patterns — use [`hermes insights`](/docs/reference/cli-commands#hermes-insights).
For deeper analytics — token usage, cost estimates, tool breakdown, and activity patterns — use [`hermes insights`](/docs/reference/cli-commands#insights).
## Session Search Tool
-6
View File
@@ -54,7 +54,6 @@ const sidebars: SidebarsConfig = {
'user-guide/features/memory',
'user-guide/features/context-files',
'user-guide/features/personality',
'user-guide/features/skins',
],
},
{
@@ -112,11 +111,6 @@ const sidebars: SidebarsConfig = {
label: 'Reference',
items: [
'reference/cli-commands',
'reference/slash-commands',
'reference/tools-reference',
'reference/toolsets-reference',
'reference/skills-catalog',
'reference/optional-skills-catalog',
'reference/environment-variables',
'reference/faq',
],