Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccf471b9b4 |
@@ -1 +0,0 @@
|
||||
"""ACP (Agent Communication Protocol) adapter for hermes-agent."""
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Allow running the ACP adapter as ``python -m acp_adapter``."""
|
||||
|
||||
from .entry import main
|
||||
|
||||
main()
|
||||
@@ -1,24 +0,0 @@
|
||||
"""ACP auth helpers — detect the currently configured Hermes provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def detect_provider() -> Optional[str]:
|
||||
"""Resolve the active Hermes runtime provider, or None if unavailable."""
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
runtime = resolve_runtime_provider()
|
||||
api_key = runtime.get("api_key")
|
||||
provider = runtime.get("provider")
|
||||
if isinstance(api_key, str) and api_key.strip() and isinstance(provider, str) and provider.strip():
|
||||
return provider.strip().lower()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def has_provider() -> bool:
|
||||
"""Return True if Hermes can resolve any runtime provider credentials."""
|
||||
return detect_provider() is not None
|
||||
@@ -1,88 +0,0 @@
|
||||
"""CLI entry point for the hermes-agent ACP adapter.
|
||||
|
||||
Loads environment variables from ``~/.hermes/.env``, configures logging
|
||||
to write to stderr (so stdout is reserved for ACP JSON-RPC transport),
|
||||
and starts the ACP agent server.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m acp_adapter.entry
|
||||
# or
|
||||
hermes acp
|
||||
# or
|
||||
hermes-acp
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
"""Route all logging to stderr so stdout stays clean for ACP stdio."""
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(logging.INFO)
|
||||
|
||||
# Quiet down noisy libraries
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logging.getLogger("openai").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
"""Load .env from HERMES_HOME (default ``~/.hermes``)."""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
env_file = hermes_home / ".env"
|
||||
if env_file.exists():
|
||||
try:
|
||||
load_dotenv(dotenv_path=env_file, encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
load_dotenv(dotenv_path=env_file, encoding="latin-1")
|
||||
logging.getLogger(__name__).info("Loaded env from %s", env_file)
|
||||
else:
|
||||
logging.getLogger(__name__).info(
|
||||
"No .env found at %s, using system env", env_file
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point: load env, configure logging, run the ACP agent."""
|
||||
_setup_logging()
|
||||
_load_env()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Starting hermes-agent ACP adapter")
|
||||
|
||||
# Ensure the project root is on sys.path so ``from run_agent import AIAgent`` works
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
import acp
|
||||
from .server import HermesACPAgent
|
||||
|
||||
agent = HermesACPAgent()
|
||||
try:
|
||||
asyncio.run(acp.run_agent(agent))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down (KeyboardInterrupt)")
|
||||
except Exception:
|
||||
logger.exception("ACP agent crashed")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Callback factories for bridging AIAgent events to ACP notifications.
|
||||
|
||||
Each factory returns a callable with the signature that AIAgent expects
|
||||
for its callbacks. Internally, the callbacks push ACP session updates
|
||||
to the client via ``conn.session_update()`` using
|
||||
``asyncio.run_coroutine_threadsafe()`` (since AIAgent runs in a worker
|
||||
thread while the event loop lives on the main thread).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any, Callable, Deque, Dict
|
||||
|
||||
import acp
|
||||
|
||||
from .tools import (
|
||||
build_tool_complete,
|
||||
build_tool_start,
|
||||
make_tool_call_id,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _send_update(
|
||||
conn: acp.Client,
|
||||
session_id: str,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
update: Any,
|
||||
) -> None:
|
||||
"""Fire-and-forget an ACP session update from a worker thread."""
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
conn.session_update(session_id, update), loop
|
||||
)
|
||||
future.result(timeout=5)
|
||||
except Exception:
|
||||
logger.debug("Failed to send ACP update", exc_info=True)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tool progress callback
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def make_tool_progress_cb(
|
||||
conn: acp.Client,
|
||||
session_id: str,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
tool_call_ids: Dict[str, Deque[str]],
|
||||
) -> Callable:
|
||||
"""Create a ``tool_progress_callback`` for AIAgent.
|
||||
|
||||
Signature expected by AIAgent::
|
||||
|
||||
tool_progress_callback(name: str, preview: str, args: dict)
|
||||
|
||||
Emits ``ToolCallStart`` for each tool invocation and tracks IDs in a FIFO
|
||||
queue per tool name so duplicate/parallel same-name calls still complete
|
||||
against the correct ACP tool call.
|
||||
"""
|
||||
|
||||
def _tool_progress(name: str, preview: str, args: Any = None) -> None:
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {"raw": args}
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
|
||||
tc_id = make_tool_call_id()
|
||||
queue = tool_call_ids.get(name)
|
||||
if queue is None:
|
||||
queue = deque()
|
||||
tool_call_ids[name] = queue
|
||||
elif isinstance(queue, str):
|
||||
queue = deque([queue])
|
||||
tool_call_ids[name] = queue
|
||||
queue.append(tc_id)
|
||||
|
||||
update = build_tool_start(tc_id, name, args)
|
||||
_send_update(conn, session_id, loop, update)
|
||||
|
||||
return _tool_progress
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Thinking callback
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def make_thinking_cb(
|
||||
conn: acp.Client,
|
||||
session_id: str,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Callable:
|
||||
"""Create a ``thinking_callback`` for AIAgent."""
|
||||
|
||||
def _thinking(text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
update = acp.update_agent_thought_text(text)
|
||||
_send_update(conn, session_id, loop, update)
|
||||
|
||||
return _thinking
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step callback
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def make_step_cb(
|
||||
conn: acp.Client,
|
||||
session_id: str,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
tool_call_ids: Dict[str, Deque[str]],
|
||||
) -> Callable:
|
||||
"""Create a ``step_callback`` for AIAgent.
|
||||
|
||||
Signature expected by AIAgent::
|
||||
|
||||
step_callback(api_call_count: int, prev_tools: list)
|
||||
"""
|
||||
|
||||
def _step(api_call_count: int, prev_tools: Any = None) -> None:
|
||||
if prev_tools and isinstance(prev_tools, list):
|
||||
for tool_info in prev_tools:
|
||||
tool_name = None
|
||||
result = None
|
||||
|
||||
if isinstance(tool_info, dict):
|
||||
tool_name = tool_info.get("name") or tool_info.get("function_name")
|
||||
result = tool_info.get("result") or tool_info.get("output")
|
||||
elif isinstance(tool_info, str):
|
||||
tool_name = tool_info
|
||||
|
||||
queue = tool_call_ids.get(tool_name or "")
|
||||
if isinstance(queue, str):
|
||||
queue = deque([queue])
|
||||
tool_call_ids[tool_name] = queue
|
||||
if tool_name and queue:
|
||||
tc_id = queue.popleft()
|
||||
update = build_tool_complete(
|
||||
tc_id, tool_name, result=str(result) if result is not None else None
|
||||
)
|
||||
_send_update(conn, session_id, loop, update)
|
||||
if not queue:
|
||||
tool_call_ids.pop(tool_name, None)
|
||||
|
||||
return _step
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Agent message callback
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def make_message_cb(
|
||||
conn: acp.Client,
|
||||
session_id: str,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> Callable:
|
||||
"""Create a callback that streams agent response text to the editor."""
|
||||
|
||||
def _message(text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
update = acp.update_agent_message_text(text)
|
||||
_send_update(conn, session_id, loop, update)
|
||||
|
||||
return _message
|
||||
@@ -1,80 +0,0 @@
|
||||
"""ACP permission bridging — maps ACP approval requests to hermes approval callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from concurrent.futures import TimeoutError as FutureTimeout
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from acp.schema import (
|
||||
AllowedOutcome,
|
||||
DeniedOutcome,
|
||||
PermissionOption,
|
||||
RequestPermissionRequest,
|
||||
SelectedPermissionOutcome,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maps ACP PermissionOptionKind -> hermes approval result strings
|
||||
_KIND_TO_HERMES = {
|
||||
"allow_once": "once",
|
||||
"allow_always": "always",
|
||||
"reject_once": "deny",
|
||||
"reject_always": "deny",
|
||||
}
|
||||
|
||||
|
||||
def make_approval_callback(
|
||||
request_permission_fn: Callable,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
session_id: str,
|
||||
timeout: float = 60.0,
|
||||
) -> Callable[[str, str], str]:
|
||||
"""
|
||||
Return a hermes-compatible ``approval_callback(command, description) -> str``
|
||||
that bridges to the ACP client's ``request_permission`` call.
|
||||
|
||||
Args:
|
||||
request_permission_fn: The ACP connection's ``request_permission`` coroutine.
|
||||
loop: The event loop on which the ACP connection lives.
|
||||
session_id: Current ACP session id.
|
||||
timeout: Seconds to wait for a response before auto-denying.
|
||||
"""
|
||||
|
||||
def _callback(command: str, description: str) -> str:
|
||||
options = [
|
||||
PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
|
||||
PermissionOption(option_id="allow_always", kind="allow_always", name="Allow always"),
|
||||
PermissionOption(option_id="deny", kind="reject_once", name="Deny"),
|
||||
]
|
||||
import acp as _acp
|
||||
|
||||
tool_call = _acp.start_tool_call("perm-check", command, kind="execute")
|
||||
|
||||
coro = request_permission_fn(
|
||||
session_id=session_id,
|
||||
tool_call=tool_call,
|
||||
options=options,
|
||||
)
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
response = future.result(timeout=timeout)
|
||||
except (FutureTimeout, Exception) as exc:
|
||||
logger.warning("Permission request timed out or failed: %s", exc)
|
||||
return "deny"
|
||||
|
||||
outcome = response.outcome
|
||||
if isinstance(outcome, AllowedOutcome):
|
||||
option_id = outcome.option_id
|
||||
# Look up the kind from our options list
|
||||
for opt in options:
|
||||
if opt.option_id == option_id:
|
||||
return _KIND_TO_HERMES.get(opt.kind, "deny")
|
||||
return "once" # fallback for unknown option_id
|
||||
else:
|
||||
return "deny"
|
||||
|
||||
return _callback
|
||||
@@ -1,333 +0,0 @@
|
||||
"""ACP agent server — exposes Hermes Agent via the Agent Client Protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict, deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Deque, Optional
|
||||
|
||||
import acp
|
||||
from acp.schema import (
|
||||
AgentCapabilities,
|
||||
AuthenticateResponse,
|
||||
AuthMethod,
|
||||
ClientCapabilities,
|
||||
EmbeddedResourceContentBlock,
|
||||
ForkSessionResponse,
|
||||
ImageContentBlock,
|
||||
AudioContentBlock,
|
||||
Implementation,
|
||||
InitializeResponse,
|
||||
ListSessionsResponse,
|
||||
LoadSessionResponse,
|
||||
NewSessionResponse,
|
||||
PromptResponse,
|
||||
ResumeSessionResponse,
|
||||
ResourceContentBlock,
|
||||
SessionCapabilities,
|
||||
SessionForkCapabilities,
|
||||
SessionListCapabilities,
|
||||
SessionInfo,
|
||||
TextContentBlock,
|
||||
Usage,
|
||||
)
|
||||
|
||||
from acp_adapter.auth import detect_provider, has_provider
|
||||
from acp_adapter.events import (
|
||||
make_message_cb,
|
||||
make_step_cb,
|
||||
make_thinking_cb,
|
||||
make_tool_progress_cb,
|
||||
)
|
||||
from acp_adapter.permissions import make_approval_callback
|
||||
from acp_adapter.session import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from hermes_cli import __version__ as HERMES_VERSION
|
||||
except Exception:
|
||||
HERMES_VERSION = "0.0.0"
|
||||
|
||||
# Thread pool for running AIAgent (synchronous) in parallel.
|
||||
_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent")
|
||||
|
||||
|
||||
def _extract_text(
|
||||
prompt: list[
|
||||
TextContentBlock
|
||||
| ImageContentBlock
|
||||
| AudioContentBlock
|
||||
| ResourceContentBlock
|
||||
| EmbeddedResourceContentBlock
|
||||
],
|
||||
) -> str:
|
||||
"""Extract plain text from ACP content blocks."""
|
||||
parts: list[str] = []
|
||||
for block in prompt:
|
||||
if isinstance(block, TextContentBlock):
|
||||
parts.append(block.text)
|
||||
elif hasattr(block, "text"):
|
||||
parts.append(str(block.text))
|
||||
# Non-text blocks are ignored for now.
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
class HermesACPAgent(acp.Agent):
|
||||
"""ACP Agent implementation wrapping Hermes AIAgent."""
|
||||
|
||||
def __init__(self, session_manager: SessionManager | None = None):
|
||||
super().__init__()
|
||||
self.session_manager = session_manager or SessionManager()
|
||||
self._conn: Optional[acp.Client] = None
|
||||
|
||||
# ---- Connection lifecycle -----------------------------------------------
|
||||
|
||||
def on_connect(self, conn: acp.Client) -> None:
|
||||
"""Store the client connection for sending session updates."""
|
||||
self._conn = conn
|
||||
logger.info("ACP client connected")
|
||||
|
||||
# ---- ACP lifecycle ------------------------------------------------------
|
||||
|
||||
async def initialize(
|
||||
self,
|
||||
protocol_version: int,
|
||||
client_capabilities: ClientCapabilities | None = None,
|
||||
client_info: Implementation | None = None,
|
||||
**kwargs: Any,
|
||||
) -> InitializeResponse:
|
||||
provider = detect_provider()
|
||||
auth_methods = None
|
||||
if provider:
|
||||
auth_methods = [
|
||||
AuthMethod(
|
||||
id=provider,
|
||||
name=f"{provider} runtime credentials",
|
||||
description=f"Authenticate Hermes using the currently configured {provider} runtime credentials.",
|
||||
)
|
||||
]
|
||||
|
||||
client_name = client_info.name if client_info else "unknown"
|
||||
logger.info("Initialize from %s (protocol v%s)", client_name, protocol_version)
|
||||
|
||||
return InitializeResponse(
|
||||
protocol_version=acp.PROTOCOL_VERSION,
|
||||
agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
|
||||
agent_capabilities=AgentCapabilities(
|
||||
session_capabilities=SessionCapabilities(
|
||||
fork=SessionForkCapabilities(),
|
||||
list=SessionListCapabilities(),
|
||||
),
|
||||
),
|
||||
auth_methods=auth_methods,
|
||||
)
|
||||
|
||||
async def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateResponse | None:
|
||||
if has_provider():
|
||||
return AuthenticateResponse()
|
||||
return None
|
||||
|
||||
# ---- Session management -------------------------------------------------
|
||||
|
||||
async def new_session(
|
||||
self,
|
||||
cwd: str,
|
||||
mcp_servers: list | None = None,
|
||||
**kwargs: Any,
|
||||
) -> NewSessionResponse:
|
||||
state = self.session_manager.create_session(cwd=cwd)
|
||||
logger.info("New session %s (cwd=%s)", state.session_id, cwd)
|
||||
return NewSessionResponse(session_id=state.session_id)
|
||||
|
||||
async def load_session(
|
||||
self,
|
||||
cwd: str,
|
||||
session_id: str,
|
||||
mcp_servers: list | None = None,
|
||||
**kwargs: Any,
|
||||
) -> LoadSessionResponse | None:
|
||||
state = self.session_manager.update_cwd(session_id, cwd)
|
||||
if state is None:
|
||||
logger.warning("load_session: session %s not found", session_id)
|
||||
return None
|
||||
logger.info("Loaded session %s", session_id)
|
||||
return LoadSessionResponse()
|
||||
|
||||
async def resume_session(
|
||||
self,
|
||||
cwd: str,
|
||||
session_id: str,
|
||||
mcp_servers: list | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ResumeSessionResponse:
|
||||
state = self.session_manager.update_cwd(session_id, cwd)
|
||||
if state is None:
|
||||
logger.warning("resume_session: session %s not found, creating new", session_id)
|
||||
state = self.session_manager.create_session(cwd=cwd)
|
||||
logger.info("Resumed session %s", state.session_id)
|
||||
return ResumeSessionResponse()
|
||||
|
||||
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
||||
state = self.session_manager.get_session(session_id)
|
||||
if state and state.cancel_event:
|
||||
state.cancel_event.set()
|
||||
try:
|
||||
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
|
||||
state.agent.interrupt()
|
||||
except Exception:
|
||||
logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True)
|
||||
logger.info("Cancelled session %s", session_id)
|
||||
|
||||
async def fork_session(
|
||||
self,
|
||||
cwd: str,
|
||||
session_id: str,
|
||||
mcp_servers: list | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ForkSessionResponse:
|
||||
state = self.session_manager.fork_session(session_id, cwd=cwd)
|
||||
new_id = state.session_id if state else ""
|
||||
logger.info("Forked session %s -> %s", session_id, new_id)
|
||||
return ForkSessionResponse(session_id=new_id)
|
||||
|
||||
async def list_sessions(
|
||||
self,
|
||||
cursor: str | None = None,
|
||||
cwd: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> ListSessionsResponse:
|
||||
infos = self.session_manager.list_sessions()
|
||||
sessions = [
|
||||
SessionInfo(session_id=s["session_id"], cwd=s["cwd"])
|
||||
for s in infos
|
||||
]
|
||||
return ListSessionsResponse(sessions=sessions)
|
||||
|
||||
# ---- Prompt (core) ------------------------------------------------------
|
||||
|
||||
async def prompt(
|
||||
self,
|
||||
prompt: list[
|
||||
TextContentBlock
|
||||
| ImageContentBlock
|
||||
| AudioContentBlock
|
||||
| ResourceContentBlock
|
||||
| EmbeddedResourceContentBlock
|
||||
],
|
||||
session_id: str,
|
||||
**kwargs: Any,
|
||||
) -> PromptResponse:
|
||||
"""Run Hermes on the user's prompt and stream events back to the editor."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
if state is None:
|
||||
logger.error("prompt: session %s not found", session_id)
|
||||
return PromptResponse(stop_reason="refusal")
|
||||
|
||||
user_text = _extract_text(prompt)
|
||||
if not user_text.strip():
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
logger.info("Prompt on session %s: %s", session_id, user_text[:100])
|
||||
|
||||
conn = self._conn
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
if state.cancel_event:
|
||||
state.cancel_event.clear()
|
||||
|
||||
tool_call_ids: dict[str, Deque[str]] = defaultdict(deque)
|
||||
previous_approval_cb = None
|
||||
|
||||
if conn:
|
||||
tool_progress_cb = make_tool_progress_cb(conn, session_id, loop, tool_call_ids)
|
||||
thinking_cb = make_thinking_cb(conn, session_id, loop)
|
||||
step_cb = make_step_cb(conn, session_id, loop, tool_call_ids)
|
||||
message_cb = make_message_cb(conn, session_id, loop)
|
||||
approval_cb = make_approval_callback(conn.request_permission, loop, session_id)
|
||||
else:
|
||||
tool_progress_cb = None
|
||||
thinking_cb = None
|
||||
step_cb = None
|
||||
message_cb = None
|
||||
approval_cb = None
|
||||
|
||||
agent = state.agent
|
||||
agent.tool_progress_callback = tool_progress_cb
|
||||
agent.thinking_callback = thinking_cb
|
||||
agent.step_callback = step_cb
|
||||
agent.message_callback = message_cb
|
||||
|
||||
if approval_cb:
|
||||
try:
|
||||
from tools import terminal_tool as _terminal_tool
|
||||
previous_approval_cb = getattr(_terminal_tool, "_approval_callback", None)
|
||||
_terminal_tool.set_approval_callback(approval_cb)
|
||||
except Exception:
|
||||
logger.debug("Could not set ACP approval callback", exc_info=True)
|
||||
|
||||
def _run_agent() -> dict:
|
||||
try:
|
||||
result = agent.run_conversation(
|
||||
user_message=user_text,
|
||||
conversation_history=state.history,
|
||||
task_id=session_id,
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("Agent error in session %s", session_id)
|
||||
return {"final_response": f"Error: {e}", "messages": state.history}
|
||||
finally:
|
||||
if approval_cb:
|
||||
try:
|
||||
from tools import terminal_tool as _terminal_tool
|
||||
_terminal_tool.set_approval_callback(previous_approval_cb)
|
||||
except Exception:
|
||||
logger.debug("Could not restore approval callback", exc_info=True)
|
||||
|
||||
try:
|
||||
result = await loop.run_in_executor(_executor, _run_agent)
|
||||
except Exception:
|
||||
logger.exception("Executor error for session %s", session_id)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
if result.get("messages"):
|
||||
state.history = result["messages"]
|
||||
|
||||
final_response = result.get("final_response", "")
|
||||
if final_response and conn:
|
||||
update = acp.update_agent_message_text(final_response)
|
||||
await conn.session_update(session_id, update)
|
||||
|
||||
usage = None
|
||||
usage_data = result.get("usage")
|
||||
if usage_data and isinstance(usage_data, dict):
|
||||
usage = Usage(
|
||||
input_tokens=usage_data.get("prompt_tokens", 0),
|
||||
output_tokens=usage_data.get("completion_tokens", 0),
|
||||
total_tokens=usage_data.get("total_tokens", 0),
|
||||
thought_tokens=usage_data.get("reasoning_tokens"),
|
||||
cached_read_tokens=usage_data.get("cached_tokens"),
|
||||
)
|
||||
|
||||
stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn"
|
||||
return PromptResponse(stop_reason=stop_reason, usage=usage)
|
||||
|
||||
# ---- Model switching ----------------------------------------------------
|
||||
|
||||
async def set_session_model(
|
||||
self, model_id: str, session_id: str, **kwargs: Any
|
||||
):
|
||||
"""Switch the model for a session."""
|
||||
state = self.session_manager.get_session(session_id)
|
||||
if state:
|
||||
state.model = model_id
|
||||
state.agent = self.session_manager._make_agent(
|
||||
session_id=session_id,
|
||||
cwd=state.cwd,
|
||||
model=model_id,
|
||||
)
|
||||
logger.info("Session %s: model switched to %s", session_id, model_id)
|
||||
return None
|
||||
@@ -1,203 +0,0 @@
|
||||
"""ACP session manager — maps ACP sessions to Hermes AIAgent instances."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _register_task_cwd(task_id: str, cwd: str) -> None:
|
||||
"""Bind a task/session id to the editor's working directory for tools."""
|
||||
if not task_id:
|
||||
return
|
||||
try:
|
||||
from tools.terminal_tool import register_task_env_overrides
|
||||
register_task_env_overrides(task_id, {"cwd": cwd})
|
||||
except Exception:
|
||||
logger.debug("Failed to register ACP task cwd override", exc_info=True)
|
||||
|
||||
|
||||
def _clear_task_cwd(task_id: str) -> None:
|
||||
"""Remove task-specific cwd overrides for an ACP session."""
|
||||
if not task_id:
|
||||
return
|
||||
try:
|
||||
from tools.terminal_tool import clear_task_env_overrides
|
||||
clear_task_env_overrides(task_id)
|
||||
except Exception:
|
||||
logger.debug("Failed to clear ACP task cwd override", exc_info=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
"""Tracks per-session state for an ACP-managed Hermes agent."""
|
||||
|
||||
session_id: str
|
||||
agent: Any # AIAgent instance
|
||||
cwd: str = "."
|
||||
model: str = ""
|
||||
history: List[Dict[str, Any]] = field(default_factory=list)
|
||||
cancel_event: Any = None # threading.Event
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Thread-safe manager for ACP sessions backed by Hermes AIAgent instances."""
|
||||
|
||||
def __init__(self, agent_factory=None):
|
||||
"""
|
||||
Args:
|
||||
agent_factory: Optional callable that creates an AIAgent-like object.
|
||||
Used by tests. When omitted, a real AIAgent is created
|
||||
using the current Hermes runtime provider configuration.
|
||||
"""
|
||||
self._sessions: Dict[str, SessionState] = {}
|
||||
self._lock = Lock()
|
||||
self._agent_factory = agent_factory
|
||||
|
||||
# ---- public API ---------------------------------------------------------
|
||||
|
||||
def create_session(self, cwd: str = ".") -> SessionState:
|
||||
"""Create a new session with a unique ID and a fresh AIAgent."""
|
||||
import threading
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
agent = self._make_agent(session_id=session_id, cwd=cwd)
|
||||
state = SessionState(
|
||||
session_id=session_id,
|
||||
agent=agent,
|
||||
cwd=cwd,
|
||||
model=getattr(agent, "model", "") or "",
|
||||
cancel_event=threading.Event(),
|
||||
)
|
||||
with self._lock:
|
||||
self._sessions[session_id] = state
|
||||
_register_task_cwd(session_id, cwd)
|
||||
logger.info("Created ACP session %s (cwd=%s)", session_id, cwd)
|
||||
return state
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[SessionState]:
|
||||
"""Return the session for *session_id*, or ``None``."""
|
||||
with self._lock:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def remove_session(self, session_id: str) -> bool:
|
||||
"""Remove a session. Returns True if it existed."""
|
||||
with self._lock:
|
||||
existed = self._sessions.pop(session_id, None) is not None
|
||||
if existed:
|
||||
_clear_task_cwd(session_id)
|
||||
return existed
|
||||
|
||||
def fork_session(self, session_id: str, cwd: str = ".") -> Optional[SessionState]:
|
||||
"""Deep-copy a session's history into a new session."""
|
||||
import threading
|
||||
|
||||
with self._lock:
|
||||
original = self._sessions.get(session_id)
|
||||
if original is None:
|
||||
return None
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
agent = self._make_agent(
|
||||
session_id=new_id,
|
||||
cwd=cwd,
|
||||
model=original.model or None,
|
||||
)
|
||||
state = SessionState(
|
||||
session_id=new_id,
|
||||
agent=agent,
|
||||
cwd=cwd,
|
||||
model=getattr(agent, "model", original.model) or original.model,
|
||||
history=copy.deepcopy(original.history),
|
||||
cancel_event=threading.Event(),
|
||||
)
|
||||
self._sessions[new_id] = state
|
||||
_register_task_cwd(new_id, cwd)
|
||||
logger.info("Forked ACP session %s -> %s", session_id, new_id)
|
||||
return state
|
||||
|
||||
def list_sessions(self) -> List[Dict[str, Any]]:
|
||||
"""Return lightweight info dicts for all sessions."""
|
||||
with self._lock:
|
||||
return [
|
||||
{
|
||||
"session_id": s.session_id,
|
||||
"cwd": s.cwd,
|
||||
"model": s.model,
|
||||
"history_len": len(s.history),
|
||||
}
|
||||
for s in self._sessions.values()
|
||||
]
|
||||
|
||||
def update_cwd(self, session_id: str, cwd: str) -> Optional[SessionState]:
|
||||
"""Update the working directory for a session and its tool overrides."""
|
||||
with self._lock:
|
||||
state = self._sessions.get(session_id)
|
||||
if state is None:
|
||||
return None
|
||||
state.cwd = cwd
|
||||
_register_task_cwd(session_id, cwd)
|
||||
return state
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Remove all sessions and clear task-specific cwd overrides."""
|
||||
with self._lock:
|
||||
session_ids = list(self._sessions.keys())
|
||||
self._sessions.clear()
|
||||
for session_id in session_ids:
|
||||
_clear_task_cwd(session_id)
|
||||
|
||||
# ---- internal -----------------------------------------------------------
|
||||
|
||||
def _make_agent(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
cwd: str,
|
||||
model: str | None = None,
|
||||
):
|
||||
if self._agent_factory is not None:
|
||||
return self._agent_factory()
|
||||
|
||||
from run_agent import AIAgent
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
config = load_config()
|
||||
model_cfg = config.get("model")
|
||||
default_model = "anthropic/claude-opus-4.6"
|
||||
requested_provider = None
|
||||
if isinstance(model_cfg, dict):
|
||||
default_model = str(model_cfg.get("default") or default_model)
|
||||
requested_provider = model_cfg.get("provider")
|
||||
elif isinstance(model_cfg, str) and model_cfg.strip():
|
||||
default_model = model_cfg.strip()
|
||||
|
||||
kwargs = {
|
||||
"platform": "acp",
|
||||
"enabled_toolsets": ["hermes-acp"],
|
||||
"quiet_mode": True,
|
||||
"session_id": session_id,
|
||||
"model": model or default_model,
|
||||
}
|
||||
|
||||
try:
|
||||
runtime = resolve_runtime_provider(requested=requested_provider)
|
||||
kwargs.update(
|
||||
{
|
||||
"provider": runtime.get("provider"),
|
||||
"api_mode": runtime.get("api_mode"),
|
||||
"base_url": runtime.get("base_url"),
|
||||
"api_key": runtime.get("api_key"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("ACP session falling back to default provider resolution", exc_info=True)
|
||||
|
||||
_register_task_cwd(session_id, cwd)
|
||||
return AIAgent(**kwargs)
|
||||
@@ -1,215 +0,0 @@
|
||||
"""ACP tool-call helpers for mapping hermes tools to ACP ToolKind and building content."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import acp
|
||||
from acp.schema import (
|
||||
ToolCallLocation,
|
||||
ToolCallStart,
|
||||
ToolCallProgress,
|
||||
ToolKind,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map hermes tool names -> ACP ToolKind
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOL_KIND_MAP: Dict[str, ToolKind] = {
|
||||
# File operations
|
||||
"read_file": "read",
|
||||
"write_file": "edit",
|
||||
"patch": "edit",
|
||||
"search_files": "search",
|
||||
# Terminal / execution
|
||||
"terminal": "execute",
|
||||
"process": "execute",
|
||||
"execute_code": "execute",
|
||||
# Web / fetch
|
||||
"web_search": "fetch",
|
||||
"web_extract": "fetch",
|
||||
# Browser
|
||||
"browser_navigate": "fetch",
|
||||
"browser_click": "execute",
|
||||
"browser_type": "execute",
|
||||
"browser_snapshot": "read",
|
||||
"browser_vision": "read",
|
||||
"browser_scroll": "execute",
|
||||
"browser_press": "execute",
|
||||
"browser_back": "execute",
|
||||
"browser_close": "execute",
|
||||
"browser_get_images": "read",
|
||||
# Agent internals
|
||||
"delegate_task": "execute",
|
||||
"vision_analyze": "read",
|
||||
"image_generate": "execute",
|
||||
"text_to_speech": "execute",
|
||||
# Thinking / meta
|
||||
"_thinking": "think",
|
||||
}
|
||||
|
||||
|
||||
def get_tool_kind(tool_name: str) -> ToolKind:
|
||||
"""Return the ACP ToolKind for a hermes tool, defaulting to 'other'."""
|
||||
return TOOL_KIND_MAP.get(tool_name, "other")
|
||||
|
||||
|
||||
def make_tool_call_id() -> str:
|
||||
"""Generate a unique tool call ID."""
|
||||
return f"tc-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:
|
||||
"""Build a human-readable title for a tool call."""
|
||||
if tool_name == "terminal":
|
||||
cmd = args.get("command", "")
|
||||
if len(cmd) > 80:
|
||||
cmd = cmd[:77] + "..."
|
||||
return f"terminal: {cmd}"
|
||||
if tool_name == "read_file":
|
||||
return f"read: {args.get('path', '?')}"
|
||||
if tool_name == "write_file":
|
||||
return f"write: {args.get('path', '?')}"
|
||||
if tool_name == "patch":
|
||||
mode = args.get("mode", "replace")
|
||||
path = args.get("path", "?")
|
||||
return f"patch ({mode}): {path}"
|
||||
if tool_name == "search_files":
|
||||
return f"search: {args.get('pattern', '?')}"
|
||||
if tool_name == "web_search":
|
||||
return f"web search: {args.get('query', '?')}"
|
||||
if tool_name == "web_extract":
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "")
|
||||
return "web extract"
|
||||
if tool_name == "delegate_task":
|
||||
goal = args.get("goal", "")
|
||||
if goal and len(goal) > 60:
|
||||
goal = goal[:57] + "..."
|
||||
return f"delegate: {goal}" if goal else "delegate task"
|
||||
if tool_name == "execute_code":
|
||||
return "execute code"
|
||||
if tool_name == "vision_analyze":
|
||||
return f"analyze image: {args.get('question', '?')[:50]}"
|
||||
return tool_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build ACP content objects for tool-call events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_tool_start(
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
) -> ToolCallStart:
|
||||
"""Create a ToolCallStart event for the given hermes tool invocation."""
|
||||
kind = get_tool_kind(tool_name)
|
||||
title = build_tool_title(tool_name, arguments)
|
||||
locations = extract_locations(arguments)
|
||||
|
||||
if tool_name == "patch":
|
||||
mode = arguments.get("mode", "replace")
|
||||
if mode == "replace":
|
||||
path = arguments.get("path", "")
|
||||
old = arguments.get("old_string", "")
|
||||
new = arguments.get("new_string", "")
|
||||
content = [acp.tool_diff_content(path=path, new_text=new, old_text=old)]
|
||||
else:
|
||||
# Patch mode — show the patch content as text
|
||||
patch_text = arguments.get("patch", "")
|
||||
content = [acp.tool_content(acp.text_block(patch_text))]
|
||||
return acp.start_tool_call(
|
||||
tool_call_id, title, kind=kind, content=content, locations=locations,
|
||||
raw_input=arguments,
|
||||
)
|
||||
|
||||
if tool_name == "write_file":
|
||||
path = arguments.get("path", "")
|
||||
file_content = arguments.get("content", "")
|
||||
content = [acp.tool_diff_content(path=path, new_text=file_content)]
|
||||
return acp.start_tool_call(
|
||||
tool_call_id, title, kind=kind, content=content, locations=locations,
|
||||
raw_input=arguments,
|
||||
)
|
||||
|
||||
if tool_name == "terminal":
|
||||
command = arguments.get("command", "")
|
||||
content = [acp.tool_content(acp.text_block(f"$ {command}"))]
|
||||
return acp.start_tool_call(
|
||||
tool_call_id, title, kind=kind, content=content, locations=locations,
|
||||
raw_input=arguments,
|
||||
)
|
||||
|
||||
if tool_name == "read_file":
|
||||
path = arguments.get("path", "")
|
||||
content = [acp.tool_content(acp.text_block(f"Reading {path}"))]
|
||||
return acp.start_tool_call(
|
||||
tool_call_id, title, kind=kind, content=content, locations=locations,
|
||||
raw_input=arguments,
|
||||
)
|
||||
|
||||
if tool_name == "search_files":
|
||||
pattern = arguments.get("pattern", "")
|
||||
target = arguments.get("target", "content")
|
||||
content = [acp.tool_content(acp.text_block(f"Searching for '{pattern}' ({target})"))]
|
||||
return acp.start_tool_call(
|
||||
tool_call_id, title, kind=kind, content=content, locations=locations,
|
||||
raw_input=arguments,
|
||||
)
|
||||
|
||||
# Generic fallback
|
||||
import json
|
||||
try:
|
||||
args_text = json.dumps(arguments, indent=2, default=str)
|
||||
except (TypeError, ValueError):
|
||||
args_text = str(arguments)
|
||||
content = [acp.tool_content(acp.text_block(args_text))]
|
||||
return acp.start_tool_call(
|
||||
tool_call_id, title, kind=kind, content=content, locations=locations,
|
||||
raw_input=arguments,
|
||||
)
|
||||
|
||||
|
||||
def build_tool_complete(
|
||||
tool_call_id: str,
|
||||
tool_name: str,
|
||||
result: Optional[str] = None,
|
||||
) -> ToolCallProgress:
|
||||
"""Create a ToolCallUpdate (progress) event for a completed tool call."""
|
||||
kind = get_tool_kind(tool_name)
|
||||
|
||||
# Truncate very large results for the UI
|
||||
display_result = result or ""
|
||||
if len(display_result) > 5000:
|
||||
display_result = display_result[:4900] + f"\n... ({len(result)} chars total, truncated)"
|
||||
|
||||
content = [acp.tool_content(acp.text_block(display_result))]
|
||||
return acp.update_tool_call(
|
||||
tool_call_id,
|
||||
kind=kind,
|
||||
status="completed",
|
||||
content=content,
|
||||
raw_output=result,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_locations(
|
||||
arguments: Dict[str, Any],
|
||||
) -> List[ToolCallLocation]:
|
||||
"""Extract file-system locations from tool arguments."""
|
||||
locations: List[ToolCallLocation] = []
|
||||
path = arguments.get("path")
|
||||
if path:
|
||||
line = arguments.get("offset") or arguments.get("line")
|
||||
locations.append(ToolCallLocation(path=path, line=line))
|
||||
return locations
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "hermes-agent",
|
||||
"display_name": "Hermes Agent",
|
||||
"description": "AI agent by Nous Research with 90+ tools, persistent memory, and multi-platform support",
|
||||
"icon": "icon.svg",
|
||||
"distribution": {
|
||||
"type": "command",
|
||||
"command": "hermes",
|
||||
"args": ["acp"]
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="gold" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Staff -->
|
||||
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#gold)" />
|
||||
<!-- Wings (left) -->
|
||||
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9" />
|
||||
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8" />
|
||||
<!-- Wings (right) -->
|
||||
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9" />
|
||||
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8" />
|
||||
<!-- Left serpent -->
|
||||
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42"
|
||||
fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round" />
|
||||
<!-- Right serpent -->
|
||||
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42"
|
||||
fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round" />
|
||||
<!-- Orb at top -->
|
||||
<circle cx="32" cy="10" r="4" fill="#F5C542" />
|
||||
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -404,14 +404,8 @@ def convert_messages_to_anthropic(
|
||||
if role == "assistant":
|
||||
blocks = []
|
||||
if content:
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
blocks.append(dict(part))
|
||||
elif part is not None:
|
||||
blocks.append({"type": "text", "text": str(part)})
|
||||
else:
|
||||
blocks.append({"type": "text", "text": str(content)})
|
||||
text = content if isinstance(content, str) else json.dumps(content)
|
||||
blocks.append({"type": "text", "text": text})
|
||||
for tc in m.get("tool_calls", []):
|
||||
fn = tc.get("function", {})
|
||||
args = fn.get("arguments", "{}")
|
||||
@@ -442,8 +436,6 @@ def convert_messages_to_anthropic(
|
||||
"tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
|
||||
"content": result_content,
|
||||
}
|
||||
if isinstance(m.get("cache_control"), dict):
|
||||
tool_result["cache_control"] = dict(m["cache_control"])
|
||||
# Merge consecutive tool results into one user message
|
||||
if (
|
||||
result
|
||||
|
||||
@@ -41,7 +41,6 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -74,7 +73,7 @@ auxiliary_is_nous: bool = False
|
||||
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
|
||||
_NOUS_MODEL = "gemini-3-flash"
|
||||
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
|
||||
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
|
||||
_AUTH_JSON_PATH = Path.home() / ".hermes" / "auth.json"
|
||||
|
||||
# Codex fallback: uses the Responses API (the only endpoint the Codex
|
||||
# OAuth token can access) with a fast model for auxiliary tasks.
|
||||
@@ -440,37 +439,12 @@ def _try_nous() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
)
|
||||
|
||||
|
||||
def _read_main_model() -> str:
|
||||
"""Read the user's configured main model from config/env.
|
||||
|
||||
Falls back through HERMES_MODEL → LLM_MODEL → config.yaml model.default
|
||||
so the auxiliary client can use the same model as the main agent when no
|
||||
dedicated auxiliary model is available.
|
||||
"""
|
||||
from_env = os.getenv("OPENAI_MODEL") or os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL")
|
||||
if from_env:
|
||||
return from_env.strip()
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, str) and model_cfg.strip():
|
||||
return model_cfg.strip()
|
||||
if isinstance(model_cfg, dict):
|
||||
default = model_cfg.get("default", "")
|
||||
if isinstance(default, str) and default.strip():
|
||||
return default.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _try_custom_endpoint() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
custom_base = os.getenv("OPENAI_BASE_URL")
|
||||
custom_key = os.getenv("OPENAI_API_KEY")
|
||||
if not custom_base or not custom_key:
|
||||
return None, None
|
||||
model = _read_main_model() or "gpt-4o-mini"
|
||||
model = os.getenv("OPENAI_MODEL") or "gpt-4o-mini"
|
||||
logger.debug("Auxiliary client: custom endpoint (%s)", model)
|
||||
return OpenAI(api_key=custom_key, base_url=custom_base), model
|
||||
|
||||
@@ -601,15 +575,6 @@ def resolve_provider_client(
|
||||
client, resolved = _resolve_auto()
|
||||
if client is None:
|
||||
return None, None
|
||||
# When auto-detection lands on a non-OpenRouter provider (e.g. a
|
||||
# local server), an OpenRouter-formatted model override like
|
||||
# "google/gemini-3-flash-preview" won't work. Drop it and use
|
||||
# the provider's own default model instead.
|
||||
if model and "/" in model and resolved and "/" not in resolved:
|
||||
logger.debug(
|
||||
"Dropping OpenRouter-format model %r for non-OpenRouter "
|
||||
"auxiliary provider (using %r instead)", model, resolved)
|
||||
model = None
|
||||
final_model = model or resolved
|
||||
return (_to_async_client(client, final_model) if async_mode
|
||||
else (client, final_model))
|
||||
|
||||
+17
-23
@@ -102,24 +102,22 @@ class ContextCompressor:
|
||||
parts.append(f"[{role.upper()}]: {content}")
|
||||
|
||||
content_to_summarize = "\n\n".join(parts)
|
||||
prompt = (
|
||||
"You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff "
|
||||
"summary for the AI assistant that will resume this conversation.\n\n"
|
||||
"Include:\n"
|
||||
"- Current progress and key decisions made\n"
|
||||
"- Important context, constraints, or user preferences discovered\n"
|
||||
"- What remains to be done (clear next steps)\n"
|
||||
"- Any critical data: file paths, variable names, URLs, error messages, "
|
||||
"or code snippets needed to continue\n"
|
||||
"- Tool calls made and their key results\n\n"
|
||||
"Be concise, structured, and focused on helping the assistant seamlessly "
|
||||
"continue the work without re-doing what's already been done.\n\n"
|
||||
f"Target roughly {self.summary_target_tokens} tokens.\n\n"
|
||||
"---\n"
|
||||
f"TURNS TO SUMMARIZE:\n{content_to_summarize}\n"
|
||||
"---\n\n"
|
||||
'Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.'
|
||||
)
|
||||
prompt = f"""Summarize these conversation turns concisely. This summary will replace these turns in the conversation history.
|
||||
|
||||
Write from a neutral perspective describing:
|
||||
1. What actions were taken (tool calls, searches, file operations)
|
||||
2. Key information or results obtained
|
||||
3. Important decisions or findings
|
||||
4. Relevant data, file names, or outputs
|
||||
|
||||
Keep factual and informative. Target ~{self.summary_target_tokens} tokens.
|
||||
|
||||
---
|
||||
TURNS TO SUMMARIZE:
|
||||
{content_to_summarize}
|
||||
---
|
||||
|
||||
Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix."""
|
||||
|
||||
# Use the centralized LLM router — handles provider resolution,
|
||||
# auth, and fallback internally.
|
||||
@@ -134,11 +132,7 @@ class ContextCompressor:
|
||||
if self.summary_model:
|
||||
call_kwargs["model"] = self.summary_model
|
||||
response = call_llm(**call_kwargs)
|
||||
content = response.choices[0].message.content
|
||||
# Handle cases where content is not a string (e.g., dict from llama.cpp)
|
||||
if not isinstance(content, str):
|
||||
content = str(content) if content else ""
|
||||
summary = content.strip()
|
||||
summary = response.choices[0].message.content.strip()
|
||||
if not summary.startswith("[CONTEXT SUMMARY]:"):
|
||||
summary = "[CONTEXT SUMMARY]: " + summary
|
||||
return summary
|
||||
|
||||
@@ -420,7 +420,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str:
|
||||
soul_path = candidate
|
||||
break
|
||||
if not soul_path:
|
||||
global_soul = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "SOUL.md"
|
||||
global_soul = Path.home() / ".hermes" / "SOUL.md"
|
||||
if global_soul.exists():
|
||||
soul_path = global_soul
|
||||
|
||||
|
||||
@@ -21,14 +21,12 @@ def _apply_cache_marker(msg: dict, cache_marker: dict) -> None:
|
||||
msg["cache_control"] = cache_marker
|
||||
return
|
||||
|
||||
if content is None or content == "":
|
||||
if content is None:
|
||||
msg["cache_control"] = cache_marker
|
||||
return
|
||||
|
||||
if isinstance(content, str):
|
||||
msg["content"] = [
|
||||
{"type": "text", "text": content, "cache_control": cache_marker}
|
||||
]
|
||||
msg["content"] = [{"type": "text", "text": content, "cache_control": cache_marker}]
|
||||
return
|
||||
|
||||
if isinstance(content, list) and content:
|
||||
|
||||
@@ -178,20 +178,6 @@ terminal:
|
||||
# Example (add to your terminal section):
|
||||
# sudo_password: "your-password-here"
|
||||
|
||||
# =============================================================================
|
||||
# Security Scanning (tirith)
|
||||
# =============================================================================
|
||||
# Optional pre-exec command security scanning via tirith.
|
||||
# Detects homograph URLs, pipe-to-shell, terminal injection, env manipulation.
|
||||
# Install: brew install sheeki03/tap/tirith
|
||||
# Docs: https://github.com/sheeki03/tirith
|
||||
#
|
||||
# security:
|
||||
# tirith_enabled: true # Enable/disable tirith scanning
|
||||
# tirith_path: "tirith" # Path to tirith binary (supports ~ expansion)
|
||||
# tirith_timeout: 5 # Scan timeout in seconds
|
||||
# tirith_fail_open: true # Allow commands if tirith unavailable
|
||||
|
||||
# =============================================================================
|
||||
# Browser Tool Configuration
|
||||
# =============================================================================
|
||||
|
||||
@@ -96,7 +96,7 @@ def _load_prefill_messages(file_path: str) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
path = Path(file_path).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = _hermes_home / path
|
||||
path = Path.home() / ".hermes" / path
|
||||
if not path.exists():
|
||||
logger.warning("Prefill messages file not found: %s", path)
|
||||
return []
|
||||
@@ -141,16 +141,16 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
Environment variables take precedence over config file values.
|
||||
Returns default values if no config file exists.
|
||||
"""
|
||||
# Check user config first ({HERMES_HOME}/config.yaml)
|
||||
user_config_path = _hermes_home / 'config.yaml'
|
||||
# Check user config first (~/.hermes/config.yaml)
|
||||
user_config_path = Path.home() / '.hermes' / 'config.yaml'
|
||||
project_config_path = Path(__file__).parent / 'cli-config.yaml'
|
||||
|
||||
|
||||
# Use user config if it exists, otherwise project config
|
||||
if user_config_path.exists():
|
||||
config_path = user_config_path
|
||||
else:
|
||||
config_path = project_config_path
|
||||
|
||||
|
||||
# Default configuration
|
||||
defaults = {
|
||||
"model": {
|
||||
@@ -1037,7 +1037,7 @@ def save_config_value(key_path: str, value: any) -> bool:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Use the same precedence as load_cli_config: user config first, then project config
|
||||
user_config_path = _hermes_home / 'config.yaml'
|
||||
user_config_path = Path.home() / '.hermes' / 'config.yaml'
|
||||
project_config_path = Path(__file__).parent / 'cli-config.yaml'
|
||||
config_path = user_config_path if user_config_path.exists() else project_config_path
|
||||
|
||||
@@ -1151,8 +1151,8 @@ class HermesCLI:
|
||||
# Provider selection is resolved lazily at use-time via _ensure_runtime_credentials().
|
||||
self.requested_provider = (
|
||||
provider
|
||||
or CLI_CONFIG["model"].get("provider")
|
||||
or os.getenv("HERMES_INFERENCE_PROVIDER")
|
||||
or CLI_CONFIG["model"].get("provider")
|
||||
or "auto"
|
||||
)
|
||||
self._provider_source: Optional[str] = None
|
||||
@@ -1259,7 +1259,7 @@ class HermesCLI:
|
||||
self.session_id = f"{timestamp_str}_{short_uuid}"
|
||||
|
||||
# History file for persistent input recall across sessions
|
||||
self._history_file = _hermes_home / ".hermes_history"
|
||||
self._history_file = Path.home() / ".hermes_history"
|
||||
self._last_invalidate: float = 0.0 # throttle UI repaints
|
||||
self._app = None
|
||||
self._secret_state = None
|
||||
@@ -1778,7 +1778,7 @@ class HermesCLI:
|
||||
"""
|
||||
from hermes_cli.clipboard import save_clipboard_image
|
||||
|
||||
img_dir = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / "images"
|
||||
img_dir = Path.home() / ".hermes" / "images"
|
||||
self._image_counter += 1
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
img_path = img_dir / f"clip_{ts}_{self._image_counter}.png"
|
||||
@@ -2074,7 +2074,7 @@ class HermesCLI:
|
||||
terminal_cwd = os.getenv("TERMINAL_CWD", os.getcwd())
|
||||
terminal_timeout = os.getenv("TERMINAL_TIMEOUT", "60")
|
||||
|
||||
user_config_path = _hermes_home / 'config.yaml'
|
||||
user_config_path = Path.home() / '.hermes' / 'config.yaml'
|
||||
project_config_path = Path(__file__).parent / 'cli-config.yaml'
|
||||
if user_config_path.exists():
|
||||
config_path = user_config_path
|
||||
@@ -2183,63 +2183,15 @@ class HermesCLI:
|
||||
flush_tool_summary()
|
||||
print()
|
||||
|
||||
def new_session(self, silent=False):
|
||||
"""Start a fresh session with a new session ID and cleared agent state."""
|
||||
def reset_conversation(self):
|
||||
"""Reset the conversation history."""
|
||||
if self.agent and self.conversation_history:
|
||||
try:
|
||||
self.agent.flush_memories(self.conversation_history)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
old_session_id = self.session_id
|
||||
if self._session_db and old_session_id:
|
||||
try:
|
||||
self._session_db.end_session(old_session_id, "new_session")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.session_start = datetime.now()
|
||||
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
|
||||
short_uuid = uuid.uuid4().hex[:6]
|
||||
self.session_id = f"{timestamp_str}_{short_uuid}"
|
||||
self.conversation_history = []
|
||||
self._pending_title = None
|
||||
self._resumed = False
|
||||
|
||||
if self.agent:
|
||||
self.agent.session_id = self.session_id
|
||||
self.agent.session_start = self.session_start
|
||||
if hasattr(self.agent, "_last_flushed_db_idx"):
|
||||
self.agent._last_flushed_db_idx = 0
|
||||
if hasattr(self.agent, "_todo_store"):
|
||||
try:
|
||||
from tools.todo_tool import TodoStore
|
||||
self.agent._todo_store = TodoStore()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(self.agent, "_invalidate_system_prompt"):
|
||||
self.agent._invalidate_system_prompt()
|
||||
|
||||
if self._session_db:
|
||||
try:
|
||||
self._session_db.create_session(
|
||||
session_id=self.session_id,
|
||||
source="cli",
|
||||
model=self.model,
|
||||
model_config={
|
||||
"max_iterations": self.max_turns,
|
||||
"reasoning_config": self.reasoning_config,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not silent:
|
||||
print("(^_^)v New session started!")
|
||||
|
||||
def reset_conversation(self):
|
||||
"""Reset the conversation by starting a new session."""
|
||||
self.new_session()
|
||||
print("(^_^)b Conversation reset!")
|
||||
|
||||
def save_conversation(self):
|
||||
"""Save the current conversation to a file."""
|
||||
@@ -2723,7 +2675,12 @@ class HermesCLI:
|
||||
elif cmd_lower == "/config":
|
||||
self.show_config()
|
||||
elif cmd_lower == "/clear":
|
||||
self.new_session(silent=True)
|
||||
# Flush memories before clearing
|
||||
if self.agent and self.conversation_history:
|
||||
try:
|
||||
self.agent.flush_memories(self.conversation_history)
|
||||
except Exception:
|
||||
pass
|
||||
# Clear terminal screen. Inside the TUI, Rich's console.clear()
|
||||
# goes through patch_stdout's StdoutProxy which swallows the
|
||||
# screen-clear escape sequences. Use prompt_toolkit's output
|
||||
@@ -2735,6 +2692,8 @@ class HermesCLI:
|
||||
out.flush()
|
||||
else:
|
||||
self.console.clear()
|
||||
# Reset conversation
|
||||
self.conversation_history = []
|
||||
# Show fresh banner. Inside the TUI we must route Rich output
|
||||
# through ChatConsole (which uses prompt_toolkit's native ANSI
|
||||
# renderer) instead of self.console (which writes raw to stdout
|
||||
@@ -2837,7 +2796,7 @@ class HermesCLI:
|
||||
else:
|
||||
_cprint(" Session database not available.")
|
||||
elif cmd_lower in ("/reset", "/new"):
|
||||
self.new_session()
|
||||
self.reset_conversation()
|
||||
elif cmd_lower.startswith("/model"):
|
||||
# Use original case so model names like "Anthropic/Claude-Opus-4" are preserved
|
||||
parts = cmd_original.split(maxsplit=1)
|
||||
@@ -3565,59 +3524,54 @@ class HermesCLI:
|
||||
_cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}")
|
||||
return ""
|
||||
|
||||
def _approval_callback(self, command: str, description: str,
|
||||
*, allow_permanent: bool = True) -> str:
|
||||
def _approval_callback(self, command: str, description: str) -> str:
|
||||
"""
|
||||
Prompt for dangerous command approval through the prompt_toolkit UI.
|
||||
|
||||
|
||||
Called from the agent thread. Shows a selection UI similar to clarify
|
||||
with choices: once / session / always / deny. When allow_permanent
|
||||
is False (tirith warnings present), the 'always' option is hidden.
|
||||
|
||||
Uses _approval_lock to serialize concurrent requests (e.g. from
|
||||
parallel delegation subtasks) so each prompt gets its own turn
|
||||
and the shared _approval_state / _approval_deadline aren't clobbered.
|
||||
with choices: once / session / always / deny.
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
with self._approval_lock:
|
||||
timeout = 60
|
||||
response_queue = queue.Queue()
|
||||
choices = ["once", "session", "always", "deny"] if allow_permanent else ["once", "session", "deny"]
|
||||
timeout = 60
|
||||
response_queue = queue.Queue()
|
||||
choices = ["once", "session", "always", "deny"]
|
||||
|
||||
self._approval_state = {
|
||||
"command": command,
|
||||
"description": description,
|
||||
"choices": choices,
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
self._approval_deadline = _time.monotonic() + timeout
|
||||
self._approval_state = {
|
||||
"command": command,
|
||||
"description": description,
|
||||
"choices": choices,
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
self._approval_deadline = _time.monotonic() + timeout
|
||||
|
||||
self._invalidate()
|
||||
self._invalidate()
|
||||
|
||||
_last_countdown_refresh = _time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
result = response_queue.get(timeout=1)
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
# Same throttled countdown as _clarify_callback — repaint only
|
||||
# every 5 s to avoid flicker in Kitty / ghostty / etc.
|
||||
_last_countdown_refresh = _time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
result = response_queue.get(timeout=1)
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
self._invalidate()
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = self._approval_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
now = _time.monotonic()
|
||||
if now - _last_countdown_refresh >= 5.0:
|
||||
_last_countdown_refresh = now
|
||||
self._invalidate()
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = self._approval_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
now = _time.monotonic()
|
||||
if now - _last_countdown_refresh >= 5.0:
|
||||
_last_countdown_refresh = now
|
||||
self._invalidate()
|
||||
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
self._invalidate()
|
||||
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
||||
return "deny"
|
||||
self._approval_state = None
|
||||
self._approval_deadline = 0
|
||||
self._invalidate()
|
||||
_cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
||||
return "deny"
|
||||
|
||||
def _secret_capture_callback(self, var_name: str, prompt: str, metadata=None) -> dict:
|
||||
return prompt_for_secret(self, var_name, prompt, metadata)
|
||||
@@ -3730,7 +3684,8 @@ class HermesCLI:
|
||||
self.agent.interrupt(interrupt_msg)
|
||||
# Debug: log to file (stdout may be devnull from redirect_stdout)
|
||||
try:
|
||||
_dbg = _hermes_home / "interrupt_debug.log"
|
||||
import pathlib as _pl
|
||||
_dbg = _pl.Path.home() / ".hermes" / "interrupt_debug.log"
|
||||
with open(_dbg, "a") as _f:
|
||||
import time as _t
|
||||
_f.write(f"{_t.strftime('%H:%M:%S')} interrupt fired: msg={str(interrupt_msg)[:60]!r}, "
|
||||
@@ -3925,7 +3880,6 @@ class HermesCLI:
|
||||
# Dangerous command approval state (similar mechanism to clarify)
|
||||
self._approval_state = None # dict with command, description, choices, selected, response_queue
|
||||
self._approval_deadline = 0
|
||||
self._approval_lock = threading.Lock() # serialize concurrent approval prompts (delegation race fix)
|
||||
|
||||
# Slash command loading state
|
||||
self._command_running = False
|
||||
@@ -3943,13 +3897,6 @@ class HermesCLI:
|
||||
set_sudo_password_callback(self._sudo_password_callback)
|
||||
set_approval_callback(self._approval_callback)
|
||||
set_secret_capture_callback(self._secret_capture_callback)
|
||||
|
||||
# Ensure tirith security scanner is available (downloads if needed)
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed
|
||||
ensure_installed()
|
||||
except Exception:
|
||||
pass # Non-fatal — fail-open at scan time if unavailable
|
||||
|
||||
# Key bindings for the input area
|
||||
kb = KeyBindings()
|
||||
@@ -4046,7 +3993,8 @@ class HermesCLI:
|
||||
self._interrupt_queue.put(payload)
|
||||
# Debug: log to file when message enters interrupt queue
|
||||
try:
|
||||
_dbg = _hermes_home / "interrupt_debug.log"
|
||||
import pathlib as _pl
|
||||
_dbg = _pl.Path.home() / ".hermes" / "interrupt_debug.log"
|
||||
with open(_dbg, "a") as _f:
|
||||
import time as _t
|
||||
_f.write(f"{_t.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, "
|
||||
@@ -4307,7 +4255,7 @@ class HermesCLI:
|
||||
if line_count >= 5 and chars_added > 1 and not text.startswith('/'):
|
||||
_paste_counter[0] += 1
|
||||
# Save to temp file
|
||||
paste_dir = _hermes_home / "pastes"
|
||||
paste_dir = Path(os.path.expanduser("~/.hermes/pastes"))
|
||||
paste_dir.mkdir(parents=True, exist_ok=True)
|
||||
paste_file = paste_dir / f"paste_{_paste_counter[0]}_{datetime.now().strftime('%H%M%S')}.txt"
|
||||
paste_file.write_text(text, encoding="utf-8")
|
||||
|
||||
+3
-14
@@ -431,19 +431,8 @@ def save_job_output(job_id: str, output: str):
|
||||
timestamp = _hermes_now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
output_file = job_output_dir / f"{timestamp}.md"
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(job_output_dir), suffix='.tmp', prefix='.output_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, output_file)
|
||||
_secure_file(output_file)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
_secure_file(output_file)
|
||||
|
||||
return output_file
|
||||
|
||||
+1
-17
@@ -156,15 +156,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
"""
|
||||
from run_agent import AIAgent
|
||||
|
||||
# Initialize SQLite session store so cron job messages are persisted
|
||||
# and discoverable via session_search (same pattern as gateway/run.py).
|
||||
_session_db = None
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
_session_db = SessionDB()
|
||||
except Exception as e:
|
||||
logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e)
|
||||
|
||||
job_id = job["id"]
|
||||
job_name = job["name"]
|
||||
prompt = job["prompt"]
|
||||
@@ -269,9 +260,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
providers_order=pr.get("order"),
|
||||
provider_sort=pr.get("sort"),
|
||||
quiet_mode=True,
|
||||
platform="cron",
|
||||
session_id=f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}",
|
||||
session_db=_session_db,
|
||||
session_id=f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
|
||||
)
|
||||
|
||||
result = agent.run_conversation(prompt)
|
||||
@@ -326,11 +315,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
# Clean up injected env vars so they don't leak to other jobs
|
||||
for key in ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME"):
|
||||
os.environ.pop(key, None)
|
||||
if _session_db:
|
||||
try:
|
||||
_session_db.close()
|
||||
except Exception as e:
|
||||
logger.debug("Job '%s': failed to close SQLite session store: %s", job_id, e)
|
||||
|
||||
|
||||
def tick(verbose: bool = True) -> int:
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
# Hermes Agent — ACP (Agent Client Protocol) Setup Guide
|
||||
|
||||
Hermes Agent supports the **Agent Client Protocol (ACP)**, allowing it to run as
|
||||
a coding agent inside your editor. ACP lets your IDE send tasks to Hermes, and
|
||||
Hermes responds with file edits, terminal commands, and explanations — all shown
|
||||
natively in the editor UI.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Hermes Agent installed and configured (`hermes setup` completed)
|
||||
- An API key / provider set up in `~/.hermes/.env` or via `hermes login`
|
||||
- Python 3.11+
|
||||
|
||||
Install the ACP extra:
|
||||
|
||||
```bash
|
||||
pip install -e ".[acp]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VS Code Setup
|
||||
|
||||
### 1. Install the ACP Client extension
|
||||
|
||||
Open VS Code and install **ACP Client** from the marketplace:
|
||||
|
||||
- Press `Ctrl+Shift+X` (or `Cmd+Shift+X` on macOS)
|
||||
- Search for **"ACP Client"**
|
||||
- Click **Install**
|
||||
|
||||
Or install from the command line:
|
||||
|
||||
```bash
|
||||
code --install-extension anysphere.acp-client
|
||||
```
|
||||
|
||||
### 2. Configure settings.json
|
||||
|
||||
Open your VS Code settings (`Ctrl+,` → click the `{}` icon for JSON) and add:
|
||||
|
||||
```json
|
||||
{
|
||||
"acpClient.agents": [
|
||||
{
|
||||
"name": "hermes-agent",
|
||||
"registryDir": "/path/to/hermes-agent/acp_registry"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Replace `/path/to/hermes-agent` with the actual path to your Hermes Agent
|
||||
installation (e.g. `~/.hermes/hermes-agent`).
|
||||
|
||||
Alternatively, if `hermes` is on your PATH, the ACP Client can discover it
|
||||
automatically via the registry directory.
|
||||
|
||||
### 3. Restart VS Code
|
||||
|
||||
After configuring, restart VS Code. You should see **Hermes Agent** appear in
|
||||
the ACP agent picker in the chat/agent panel.
|
||||
|
||||
---
|
||||
|
||||
## Zed Setup
|
||||
|
||||
Zed has built-in ACP support.
|
||||
|
||||
### 1. Configure Zed settings
|
||||
|
||||
Open Zed settings (`Cmd+,` on macOS or `Ctrl+,` on Linux) and add to your
|
||||
`settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"acp": {
|
||||
"agents": [
|
||||
{
|
||||
"name": "hermes-agent",
|
||||
"registry_dir": "/path/to/hermes-agent/acp_registry"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Restart Zed
|
||||
|
||||
Hermes Agent will appear in the agent panel. Select it and start a conversation.
|
||||
|
||||
---
|
||||
|
||||
## JetBrains Setup (IntelliJ, PyCharm, WebStorm, etc.)
|
||||
|
||||
### 1. Install the ACP plugin
|
||||
|
||||
- Open **Settings** → **Plugins** → **Marketplace**
|
||||
- Search for **"ACP"** or **"Agent Client Protocol"**
|
||||
- Install and restart the IDE
|
||||
|
||||
### 2. Configure the agent
|
||||
|
||||
- Open **Settings** → **Tools** → **ACP Agents**
|
||||
- Click **+** to add a new agent
|
||||
- Set the registry directory to your `acp_registry/` folder:
|
||||
`/path/to/hermes-agent/acp_registry`
|
||||
- Click **OK**
|
||||
|
||||
### 3. Use the agent
|
||||
|
||||
Open the ACP panel (usually in the right sidebar) and select **Hermes Agent**.
|
||||
|
||||
---
|
||||
|
||||
## What You Will See
|
||||
|
||||
Once connected, your editor provides a native interface to Hermes Agent:
|
||||
|
||||
### Chat Panel
|
||||
A conversational interface where you can describe tasks, ask questions, and
|
||||
give instructions. Hermes responds with explanations and actions.
|
||||
|
||||
### File Diffs
|
||||
When Hermes edits files, you see standard diffs in the editor. You can:
|
||||
- **Accept** individual changes
|
||||
- **Reject** changes you don't want
|
||||
- **Review** the full diff before applying
|
||||
|
||||
### Terminal Commands
|
||||
When Hermes needs to run shell commands (builds, tests, installs), the editor
|
||||
shows them in an integrated terminal. Depending on your settings:
|
||||
- Commands may run automatically
|
||||
- Or you may be prompted to **approve** each command
|
||||
|
||||
### Approval Flow
|
||||
For potentially destructive operations, the editor will prompt you for
|
||||
approval before Hermes proceeds. This includes:
|
||||
- File deletions
|
||||
- Shell commands
|
||||
- Git operations
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Hermes Agent under ACP uses the **same configuration** as the CLI:
|
||||
|
||||
- **API keys / providers**: `~/.hermes/.env`
|
||||
- **Agent config**: `~/.hermes/config.yaml`
|
||||
- **Skills**: `~/.hermes/skills/`
|
||||
- **Sessions**: `~/.hermes/state.db`
|
||||
|
||||
You can run `hermes setup` to configure providers, or edit `~/.hermes/.env`
|
||||
directly.
|
||||
|
||||
### Changing the model
|
||||
|
||||
Edit `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
model: openrouter/nous/hermes-3-llama-3.1-70b
|
||||
```
|
||||
|
||||
Or set the `HERMES_MODEL` environment variable.
|
||||
|
||||
### Toolsets
|
||||
|
||||
ACP sessions use the curated `hermes-acp` toolset by default. It is designed for editor workflows and intentionally excludes things like messaging delivery, cronjob management, and audio-first UX features.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent doesn't appear in the editor
|
||||
|
||||
1. **Check the registry path** — make sure the `acp_registry/` directory path
|
||||
in your editor settings is correct and contains `agent.json`.
|
||||
2. **Check `hermes` is on PATH** — run `which hermes` in a terminal. If not
|
||||
found, you may need to activate your virtualenv or add it to PATH.
|
||||
3. **Restart the editor** after changing settings.
|
||||
|
||||
### Agent starts but errors immediately
|
||||
|
||||
1. Run `hermes doctor` to check your configuration.
|
||||
2. Check that you have a valid API key: `hermes status`
|
||||
3. Try running `hermes acp` directly in a terminal to see error output.
|
||||
|
||||
### "Module not found" errors
|
||||
|
||||
Make sure you installed the ACP extra:
|
||||
|
||||
```bash
|
||||
pip install -e ".[acp]"
|
||||
```
|
||||
|
||||
### Slow responses
|
||||
|
||||
- ACP streams responses, so you should see incremental output. If the agent
|
||||
appears stuck, check your network connection and API provider status.
|
||||
- Some providers have rate limits. Try switching to a different model/provider.
|
||||
|
||||
### Permission denied for terminal commands
|
||||
|
||||
If the editor blocks terminal commands, check your ACP Client extension
|
||||
settings for auto-approval or manual-approval preferences.
|
||||
|
||||
### Logs
|
||||
|
||||
Hermes logs are written to stderr when running in ACP mode. Check:
|
||||
- VS Code: **Output** panel → select **ACP Client** or **Hermes Agent**
|
||||
- Zed: **View** → **Toggle Terminal** and check the process output
|
||||
- JetBrains: **Event Log** or the ACP tool window
|
||||
|
||||
You can also enable verbose logging:
|
||||
|
||||
```bash
|
||||
HERMES_LOG_LEVEL=DEBUG hermes acp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [ACP Specification](https://github.com/anysphere/acp)
|
||||
- [Hermes Agent Documentation](https://github.com/NousResearch/hermes-agent)
|
||||
- Run `hermes --help` for all CLI options
|
||||
@@ -12,11 +12,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DIRECTORY_PATH = get_hermes_home() / "channel_directory.json"
|
||||
DIRECTORY_PATH = Path.home() / ".hermes" / "channel_directory.json"
|
||||
|
||||
|
||||
def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]:
|
||||
@@ -131,7 +129,7 @@ def _build_slack(adapter) -> List[Dict[str, str]]:
|
||||
|
||||
def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
|
||||
"""Pull known channels/contacts from sessions.json origin data."""
|
||||
sessions_path = get_hermes_home() / "sessions" / "sessions.json"
|
||||
sessions_path = Path.home() / ".hermes" / "sessions" / "sessions.json"
|
||||
if not sessions_path.exists():
|
||||
return []
|
||||
|
||||
|
||||
+8
-16
@@ -16,8 +16,6 @@ from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Any
|
||||
from enum import Enum
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -85,13 +83,10 @@ class SessionResetPolicy:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
|
||||
# Handle both missing keys and explicit null values (YAML null → None)
|
||||
at_hour = data.get("at_hour")
|
||||
idle_minutes = data.get("idle_minutes")
|
||||
return cls(
|
||||
mode=data.get("mode", "both"),
|
||||
at_hour=at_hour if at_hour is not None else 4,
|
||||
idle_minutes=idle_minutes if idle_minutes is not None else 1440,
|
||||
at_hour=data.get("at_hour", 4),
|
||||
idle_minutes=data.get("idle_minutes", 1440),
|
||||
)
|
||||
|
||||
|
||||
@@ -153,7 +148,7 @@ class GatewayConfig:
|
||||
reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
|
||||
|
||||
# Storage paths
|
||||
sessions_dir: Path = field(default_factory=lambda: get_hermes_home() / "sessions")
|
||||
sessions_dir: Path = field(default_factory=lambda: Path.home() / ".hermes" / "sessions")
|
||||
|
||||
# Delivery settings
|
||||
always_log_local: bool = True # Always save cron outputs to local files
|
||||
@@ -248,7 +243,7 @@ class GatewayConfig:
|
||||
if "default_reset_policy" in data:
|
||||
default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"])
|
||||
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
sessions_dir = Path.home() / ".hermes" / "sessions"
|
||||
if "sessions_dir" in data:
|
||||
sessions_dir = Path(data["sessions_dir"])
|
||||
|
||||
@@ -276,8 +271,7 @@ def load_gateway_config() -> GatewayConfig:
|
||||
config = GatewayConfig()
|
||||
|
||||
# Try loading from ~/.hermes/gateway.json
|
||||
_home = get_hermes_home()
|
||||
gateway_config_path = _home / "gateway.json"
|
||||
gateway_config_path = Path.home() / ".hermes" / "gateway.json"
|
||||
if gateway_config_path.exists():
|
||||
try:
|
||||
with open(gateway_config_path, "r", encoding="utf-8") as f:
|
||||
@@ -285,13 +279,13 @@ def load_gateway_config() -> GatewayConfig:
|
||||
config = GatewayConfig.from_dict(data)
|
||||
except Exception as e:
|
||||
print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}")
|
||||
|
||||
|
||||
# Bridge session_reset from config.yaml (the user-facing config file)
|
||||
# into the gateway config. config.yaml takes precedence over gateway.json
|
||||
# for session reset policy since that's where hermes setup writes it.
|
||||
try:
|
||||
import yaml
|
||||
config_yaml_path = _home / "config.yaml"
|
||||
config_yaml_path = Path.home() / ".hermes" / "config.yaml"
|
||||
if config_yaml_path.exists():
|
||||
with open(config_yaml_path, encoding="utf-8") as f:
|
||||
yaml_cfg = yaml.safe_load(f) or {}
|
||||
@@ -310,8 +304,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -484,7 +476,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
|
||||
def save_gateway_config(config: GatewayConfig) -> None:
|
||||
"""Save gateway configuration to ~/.hermes/gateway.json."""
|
||||
gateway_config_path = get_hermes_home() / "gateway.json"
|
||||
gateway_config_path = Path.home() / ".hermes" / "gateway.json"
|
||||
gateway_config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(gateway_config_path, "w", encoding="utf-8") as f:
|
||||
|
||||
+2
-4
@@ -15,8 +15,6 @@ from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
from enum import Enum
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_PLATFORM_OUTPUT = 4000
|
||||
@@ -118,7 +116,7 @@ class DeliveryRouter:
|
||||
"""
|
||||
self.config = config
|
||||
self.adapters = adapters or {}
|
||||
self.output_dir = get_hermes_home() / "cron" / "output"
|
||||
self.output_dir = Path.home() / ".hermes" / "cron" / "output"
|
||||
|
||||
def resolve_targets(
|
||||
self,
|
||||
@@ -258,7 +256,7 @@ class DeliveryRouter:
|
||||
def _save_full_output(self, content: str, job_id: str) -> Path:
|
||||
"""Save full cron output to disk and return the file path."""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = get_hermes_home() / "cron" / "output"
|
||||
out_dir = Path.home() / ".hermes" / "cron" / "output"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / f"{job_id}_{timestamp}.txt"
|
||||
path.write_text(content)
|
||||
|
||||
+1
-3
@@ -26,10 +26,8 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
HOOKS_DIR = get_hermes_home() / "hooks"
|
||||
HOOKS_DIR = Path(os.path.expanduser("~/.hermes/hooks"))
|
||||
|
||||
|
||||
class HookRegistry:
|
||||
|
||||
+1
-3
@@ -15,11 +15,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SESSIONS_DIR = get_hermes_home() / "sessions"
|
||||
_SESSIONS_DIR = Path.home() / ".hermes" / "sessions"
|
||||
_SESSIONS_INDEX = _SESSIONS_DIR / "sessions.json"
|
||||
|
||||
|
||||
|
||||
+1
-3
@@ -25,8 +25,6 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
# Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion
|
||||
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
@@ -41,7 +39,7 @@ LOCKOUT_SECONDS = 3600 # Lockout duration after too many failures
|
||||
MAX_PENDING_PER_PLATFORM = 3 # Max pending codes per platform
|
||||
MAX_FAILED_ATTEMPTS = 5 # Failed approvals before lockout
|
||||
|
||||
PAIRING_DIR = get_hermes_home() / "pairing"
|
||||
PAIRING_DIR = Path(os.path.expanduser("~/.hermes/pairing"))
|
||||
|
||||
|
||||
def _secure_write(path: Path, data: str) -> None:
|
||||
|
||||
@@ -25,7 +25,6 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||
@@ -43,8 +42,8 @@ GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||
# (e.g. Telegram file URLs expire after ~1 hour).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Default location: {HERMES_HOME}/image_cache/
|
||||
IMAGE_CACHE_DIR = get_hermes_home() / "image_cache"
|
||||
# Default location: ~/.hermes/image_cache/
|
||||
IMAGE_CACHE_DIR = Path(os.path.expanduser("~/.hermes/image_cache"))
|
||||
|
||||
|
||||
def get_image_cache_dir() -> Path:
|
||||
@@ -126,7 +125,7 @@ def cleanup_image_cache(max_age_hours: int = 24) -> int:
|
||||
# here so the STT tool (OpenAI Whisper) can transcribe them from local files.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AUDIO_CACHE_DIR = get_hermes_home() / "audio_cache"
|
||||
AUDIO_CACHE_DIR = Path(os.path.expanduser("~/.hermes/audio_cache"))
|
||||
|
||||
|
||||
def get_audio_cache_dir() -> Path:
|
||||
@@ -185,7 +184,7 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg") -> str:
|
||||
# here so the agent can reference them by local file path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DOCUMENT_CACHE_DIR = get_hermes_home() / "document_cache"
|
||||
DOCUMENT_CACHE_DIR = Path(os.path.expanduser("~/.hermes/document_cache"))
|
||||
|
||||
SUPPORTED_DOCUMENT_TYPES = {
|
||||
".pdf": "application/pdf",
|
||||
|
||||
@@ -14,8 +14,6 @@ from typing import Dict, List, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080}
|
||||
|
||||
try:
|
||||
import discord
|
||||
from discord import Message as DiscordMessage, Intents
|
||||
@@ -43,23 +41,6 @@ from gateway.platforms.base import (
|
||||
)
|
||||
|
||||
|
||||
def _clean_discord_id(entry: str) -> str:
|
||||
"""Strip common prefixes from a Discord user ID or username entry.
|
||||
|
||||
Users sometimes paste IDs with prefixes like ``user:123``, ``<@123>``,
|
||||
or ``<@!123>`` from Discord's UI or other tools. This normalises the
|
||||
entry to just the bare ID or username.
|
||||
"""
|
||||
entry = entry.strip()
|
||||
# Strip Discord mention syntax: <@123> or <@!123>
|
||||
if entry.startswith("<@") and entry.endswith(">"):
|
||||
entry = entry.lstrip("<@!").rstrip(">")
|
||||
# Strip "user:" prefix (seen in some Discord tools / onboarding pastes)
|
||||
if entry.lower().startswith("user:"):
|
||||
entry = entry[5:]
|
||||
return entry.strip()
|
||||
|
||||
|
||||
def check_discord_requirements() -> bool:
|
||||
"""Check if Discord dependencies are available."""
|
||||
return DISCORD_AVAILABLE
|
||||
@@ -116,8 +97,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "")
|
||||
if allowed_env:
|
||||
self._allowed_user_ids = {
|
||||
_clean_discord_id(uid) for uid in allowed_env.split(",")
|
||||
if uid.strip()
|
||||
uid.strip() for uid in allowed_env.split(",") if uid.strip()
|
||||
}
|
||||
|
||||
adapter_self = self # capture for closure
|
||||
@@ -271,7 +251,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
audio_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send audio as a Discord file attachment."""
|
||||
if not self._client:
|
||||
@@ -310,7 +289,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
image_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a local image file natively as a Discord file attachment."""
|
||||
if not self._client:
|
||||
@@ -348,7 +326,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an image natively as a Discord file attachment."""
|
||||
if not self._client:
|
||||
@@ -734,21 +711,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
logger.debug("Discord followup failed: %s", e)
|
||||
|
||||
@tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
|
||||
@discord.app_commands.describe(
|
||||
name="Thread name",
|
||||
message="Optional first message to send to Hermes in the thread",
|
||||
auto_archive_duration="Auto-archive in minutes (60, 1440, 4320, 10080)",
|
||||
)
|
||||
async def slash_thread(
|
||||
interaction: discord.Interaction,
|
||||
name: str,
|
||||
message: str = "",
|
||||
auto_archive_duration: int = 1440,
|
||||
):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration)
|
||||
|
||||
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
|
||||
"""Build a MessageEvent from a Discord slash command interaction."""
|
||||
is_dm = isinstance(interaction.channel, discord.DMChannel)
|
||||
@@ -779,188 +741,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
raw_message=interaction,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Thread creation helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_thread_create_slash(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
name: str,
|
||||
message: str = "",
|
||||
auto_archive_duration: int = 1440,
|
||||
) -> None:
|
||||
"""Create a Discord thread from a slash command and start a session in it."""
|
||||
result = await self._create_thread(
|
||||
interaction,
|
||||
name=name,
|
||||
message=message,
|
||||
auto_archive_duration=auto_archive_duration,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
error = result.get("error", "unknown error")
|
||||
await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True)
|
||||
return
|
||||
|
||||
thread_id = result.get("thread_id")
|
||||
thread_name = result.get("thread_name") or name
|
||||
|
||||
# Tell the user where the thread is
|
||||
link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**"
|
||||
await interaction.followup.send(f"Created thread {link}", ephemeral=True)
|
||||
|
||||
# If a message was provided, kick off a new Hermes session in the thread
|
||||
starter = (message or "").strip()
|
||||
if starter and thread_id:
|
||||
await self._dispatch_thread_session(interaction, thread_id, thread_name, starter)
|
||||
|
||||
async def _dispatch_thread_session(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
thread_id: str,
|
||||
thread_name: str,
|
||||
text: str,
|
||||
) -> None:
|
||||
"""Build a MessageEvent pointing at a thread and send it through handle_message."""
|
||||
guild_name = ""
|
||||
if hasattr(interaction, "guild") and interaction.guild:
|
||||
guild_name = interaction.guild.name
|
||||
|
||||
chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=thread_id,
|
||||
chat_name=chat_name,
|
||||
chat_type="thread",
|
||||
user_id=str(interaction.user.id),
|
||||
user_name=interaction.user.display_name,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
event = MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
raw_message=interaction,
|
||||
)
|
||||
await self.handle_message(event)
|
||||
|
||||
def _thread_parent_channel(self, channel: Any) -> Any:
|
||||
"""Return the parent text channel when invoked from a thread."""
|
||||
return getattr(channel, "parent", None) or channel
|
||||
|
||||
async def _resolve_interaction_channel(self, interaction: discord.Interaction) -> Optional[Any]:
|
||||
"""Return the interaction channel, fetching it if the payload is partial."""
|
||||
channel = getattr(interaction, "channel", None)
|
||||
if channel is not None:
|
||||
return channel
|
||||
if not self._client:
|
||||
return None
|
||||
channel_id = getattr(interaction, "channel_id", None)
|
||||
if channel_id is None:
|
||||
return None
|
||||
channel = self._client.get_channel(int(channel_id))
|
||||
if channel is not None:
|
||||
return channel
|
||||
try:
|
||||
return await self._client.fetch_channel(int(channel_id))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def _create_thread(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
*,
|
||||
name: str,
|
||||
message: str = "",
|
||||
auto_archive_duration: int = 1440,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a thread in the current Discord channel.
|
||||
|
||||
Tries ``parent_channel.create_thread()`` first. If Discord rejects
|
||||
that (e.g. permission issues), falls back to sending a seed message
|
||||
and creating the thread from it.
|
||||
"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return {"error": "Thread name is required."}
|
||||
|
||||
if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES:
|
||||
allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES))
|
||||
return {"error": f"auto_archive_duration must be one of: {allowed}."}
|
||||
|
||||
channel = await self._resolve_interaction_channel(interaction)
|
||||
if channel is None:
|
||||
return {"error": "Could not resolve the current Discord channel."}
|
||||
if isinstance(channel, discord.DMChannel):
|
||||
return {"error": "Discord threads can only be created inside server text channels, not DMs."}
|
||||
|
||||
parent_channel = self._thread_parent_channel(channel)
|
||||
if parent_channel is None:
|
||||
return {"error": "Could not determine a parent text channel for the new thread."}
|
||||
|
||||
display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user"
|
||||
reason = f"Requested by {display_name} via /thread"
|
||||
starter_message = (message or "").strip()
|
||||
|
||||
try:
|
||||
thread = await parent_channel.create_thread(
|
||||
name=name,
|
||||
auto_archive_duration=auto_archive_duration,
|
||||
reason=reason,
|
||||
)
|
||||
if starter_message:
|
||||
await thread.send(starter_message)
|
||||
return {
|
||||
"success": True,
|
||||
"thread_id": str(thread.id),
|
||||
"thread_name": getattr(thread, "name", None) or name,
|
||||
}
|
||||
except Exception as direct_error:
|
||||
try:
|
||||
seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**"
|
||||
seed_msg = await parent_channel.send(seed_content)
|
||||
thread = await seed_msg.create_thread(
|
||||
name=name,
|
||||
auto_archive_duration=auto_archive_duration,
|
||||
reason=reason,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"thread_id": str(thread.id),
|
||||
"thread_name": getattr(thread, "name", None) or name,
|
||||
}
|
||||
except Exception as fallback_error:
|
||||
return {
|
||||
"error": (
|
||||
"Discord rejected direct thread creation and the fallback also failed. "
|
||||
f"Direct error: {direct_error}. Fallback error: {fallback_error}"
|
||||
)
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-thread helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
|
||||
"""Create a thread from a user message for auto-threading.
|
||||
|
||||
Returns the created thread object, or ``None`` on failure.
|
||||
"""
|
||||
# Build a short thread name from the message
|
||||
content = (message.content or "").strip()
|
||||
thread_name = content[:80] if content else "Hermes"
|
||||
if len(content) > 80:
|
||||
thread_name = thread_name[:77] + "..."
|
||||
|
||||
try:
|
||||
thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
|
||||
return thread
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Auto-thread creation failed: %s", self.name, e)
|
||||
return None
|
||||
|
||||
async def send_exec_approval(
|
||||
self, chat_id: str, command: str, approval_id: str
|
||||
) -> SendResult:
|
||||
@@ -1072,19 +852,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip()
|
||||
message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip()
|
||||
|
||||
# Auto-thread: when enabled, automatically create a thread for every
|
||||
# new message in a text channel so each conversation is isolated.
|
||||
# Messages already inside threads or DMs are unaffected.
|
||||
auto_threaded_channel = None
|
||||
if not is_thread and not isinstance(message.channel, discord.DMChannel):
|
||||
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "").lower() in ("true", "1", "yes")
|
||||
if auto_thread:
|
||||
thread = await self._auto_create_thread(message)
|
||||
if thread:
|
||||
is_thread = True
|
||||
thread_id = str(thread.id)
|
||||
auto_threaded_channel = thread
|
||||
|
||||
# Determine message type
|
||||
msg_type = MessageType.TEXT
|
||||
if message.content.startswith("/"):
|
||||
@@ -1103,16 +870,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
msg_type = MessageType.DOCUMENT
|
||||
break
|
||||
|
||||
# When auto-threading kicked in, route responses to the new thread
|
||||
effective_channel = auto_threaded_channel or message.channel
|
||||
|
||||
# Determine chat type
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
chat_type = "dm"
|
||||
chat_name = message.author.name
|
||||
elif is_thread:
|
||||
chat_type = "thread"
|
||||
chat_name = self._format_thread_chat_name(effective_channel)
|
||||
chat_name = self._format_thread_chat_name(message.channel)
|
||||
else:
|
||||
chat_type = "group"
|
||||
chat_name = getattr(message.channel, "name", str(message.channel.id))
|
||||
@@ -1124,7 +888,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
# Build source
|
||||
source = self.build_source(
|
||||
chat_id=str(effective_channel.id),
|
||||
chat_id=str(message.channel.id),
|
||||
chat_name=chat_name,
|
||||
chat_type=chat_type,
|
||||
user_id=str(message.author.id),
|
||||
|
||||
@@ -83,7 +83,6 @@ class HomeAssistantAdapter(BasePlatformAdapter):
|
||||
self._watch_domains: Set[str] = set(extra.get("watch_domains", []))
|
||||
self._watch_entities: Set[str] = set(extra.get("watch_entities", []))
|
||||
self._ignore_entities: Set[str] = set(extra.get("ignore_entities", []))
|
||||
self._watch_all: bool = bool(extra.get("watch_all", False))
|
||||
self._cooldown_seconds: int = int(extra.get("cooldown_seconds", 30))
|
||||
|
||||
# Cooldown tracking: entity_id -> last_event_timestamp
|
||||
@@ -116,15 +115,6 @@ class HomeAssistantAdapter(BasePlatformAdapter):
|
||||
# Dedicated REST session for send() calls
|
||||
self._rest_session = aiohttp.ClientSession()
|
||||
|
||||
# Warn if no event filters are configured
|
||||
if not self._watch_domains and not self._watch_entities and not self._watch_all:
|
||||
logger.warning(
|
||||
"[%s] No watch_domains, watch_entities, or watch_all configured. "
|
||||
"All state_changed events will be dropped. Configure filters in "
|
||||
"your HA platform config to receive events.",
|
||||
self.name,
|
||||
)
|
||||
|
||||
# Start background listener
|
||||
self._listen_task = asyncio.create_task(self._listen_loop())
|
||||
self._running = True
|
||||
@@ -267,17 +257,13 @@ class HomeAssistantAdapter(BasePlatformAdapter):
|
||||
if entity_id in self._ignore_entities:
|
||||
return
|
||||
|
||||
# Apply domain/entity watch filters (closed by default — require
|
||||
# explicit watch_domains, watch_entities, or watch_all to forward)
|
||||
# Apply domain/entity watch filters
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
if self._watch_domains or self._watch_entities:
|
||||
domain_match = domain in self._watch_domains if self._watch_domains else False
|
||||
entity_match = entity_id in self._watch_entities if self._watch_entities else False
|
||||
if not domain_match and not entity_match:
|
||||
return
|
||||
elif not self._watch_all:
|
||||
# No filters configured and watch_all is off — drop the event
|
||||
return
|
||||
|
||||
# Apply cooldown
|
||||
now = time.time()
|
||||
|
||||
@@ -26,8 +26,6 @@ _IS_WINDOWS = platform.system() == "Windows"
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -134,7 +132,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
)
|
||||
self._session_path: Path = Path(config.extra.get(
|
||||
"session_path",
|
||||
get_hermes_home() / "whatsapp" / "session"
|
||||
Path.home() / ".hermes" / "whatsapp" / "session"
|
||||
))
|
||||
self._message_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._bridge_log_fh = None
|
||||
|
||||
+15
-28
@@ -248,21 +248,14 @@ class GatewayRunner:
|
||||
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
|
||||
|
||||
# Track pending exec approvals per session
|
||||
# Key: session_key, Value: {"command": str, "pattern_key": str, ...}
|
||||
self._pending_approvals: Dict[str, Dict[str, Any]] = {}
|
||||
# Key: session_key, Value: {"command": str, "pattern_key": str}
|
||||
self._pending_approvals: Dict[str, Dict[str, str]] = {}
|
||||
|
||||
# Persistent Honcho managers keyed by gateway session key.
|
||||
# This preserves write_frequency="session" semantics across short-lived
|
||||
# per-message AIAgent instances.
|
||||
self._honcho_managers: Dict[str, Any] = {}
|
||||
self._honcho_configs: Dict[str, Any] = {}
|
||||
|
||||
# Ensure tirith security scanner is available (downloads if needed)
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed
|
||||
ensure_installed()
|
||||
except Exception:
|
||||
pass # Non-fatal — fail-open at scan time if unavailable
|
||||
|
||||
# Initialize session database for session_search tool support
|
||||
self._session_db = None
|
||||
@@ -1056,15 +1049,11 @@ class GatewayRunner:
|
||||
if user_text in ("yes", "y", "approve", "ok", "go", "do it"):
|
||||
approval = self._pending_approvals.pop(session_key_preview)
|
||||
cmd = approval["command"]
|
||||
pattern_keys = approval.get("pattern_keys", [])
|
||||
if not pattern_keys:
|
||||
pk = approval.get("pattern_key", "")
|
||||
pattern_keys = [pk] if pk else []
|
||||
pattern_key = approval.get("pattern_key", "")
|
||||
logger.info("User approved dangerous command: %s...", cmd[:60])
|
||||
from tools.terminal_tool import terminal_tool
|
||||
from tools.approval import approve_session
|
||||
for pk in pattern_keys:
|
||||
approve_session(session_key_preview, pk)
|
||||
approve_session(session_key_preview, pattern_key)
|
||||
result = terminal_tool(command=cmd, force=True)
|
||||
return f"✅ Command approved and executed.\n\n```\n{result[:3500]}\n```"
|
||||
elif user_text in ("no", "n", "deny", "cancel", "nope"):
|
||||
@@ -1136,16 +1125,10 @@ class GatewayRunner:
|
||||
get_model_context_length,
|
||||
)
|
||||
|
||||
# Read model + compression config from config.yaml.
|
||||
# NOTE: hygiene threshold is intentionally HIGHER than the agent's
|
||||
# own compressor (0.85 vs 0.50). Hygiene is a safety net for
|
||||
# sessions that grew too large between turns — it fires pre-agent
|
||||
# to prevent API failures. The agent's own compressor handles
|
||||
# normal context management during its tool loop with accurate
|
||||
# real token counts. Having hygiene at 0.50 caused premature
|
||||
# compression on every turn in long gateway sessions.
|
||||
# Read model + compression config from config.yaml — same
|
||||
# source of truth the agent itself uses.
|
||||
_hyg_model = "anthropic/claude-sonnet-4.6"
|
||||
_hyg_threshold_pct = 0.85
|
||||
_hyg_threshold_pct = 0.50
|
||||
_hyg_compression_enabled = True
|
||||
try:
|
||||
_hyg_cfg_path = _hermes_home / "config.yaml"
|
||||
@@ -1161,18 +1144,22 @@ class GatewayRunner:
|
||||
elif isinstance(_model_cfg, dict):
|
||||
_hyg_model = _model_cfg.get("default", _hyg_model)
|
||||
|
||||
# Read compression settings — only use enabled flag.
|
||||
# The threshold is intentionally separate from the agent's
|
||||
# compression.threshold (hygiene runs higher).
|
||||
# Read compression settings
|
||||
_comp_cfg = _hyg_data.get("compression", {})
|
||||
if isinstance(_comp_cfg, dict):
|
||||
_hyg_threshold_pct = float(
|
||||
_comp_cfg.get("threshold", _hyg_threshold_pct)
|
||||
)
|
||||
_hyg_compression_enabled = str(
|
||||
_comp_cfg.get("enabled", True)
|
||||
).lower() in ("true", "1", "yes")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check env override for disabling compression entirely
|
||||
# Also check env overrides (same as run_agent.py)
|
||||
_hyg_threshold_pct = float(
|
||||
os.getenv("CONTEXT_COMPRESSION_THRESHOLD", str(_hyg_threshold_pct))
|
||||
)
|
||||
if os.getenv("CONTEXT_COMPRESSION_ENABLED", "").lower() in ("false", "0", "no"):
|
||||
_hyg_compression_enabled = False
|
||||
|
||||
|
||||
@@ -177,26 +177,6 @@ def build_session_context_prompt(context: SessionContext) -> str:
|
||||
elif context.source.user_id:
|
||||
lines.append(f"**User ID:** {context.source.user_id}")
|
||||
|
||||
# Platform-specific behavioral notes
|
||||
if context.source.platform == Platform.SLACK:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"**Platform notes:** You are running inside Slack. "
|
||||
"You do NOT have access to Slack-specific APIs — you cannot search "
|
||||
"channel history, pin/unpin messages, manage channels, or list users. "
|
||||
"Do not promise to perform these actions. If the user asks, explain "
|
||||
"that you can only read messages sent directly to you and respond."
|
||||
)
|
||||
elif context.source.platform == Platform.DISCORD:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"**Platform notes:** You are running inside Discord. "
|
||||
"You do NOT have access to Discord-specific APIs — you cannot search "
|
||||
"channel history, pin messages, manage roles, or list server members. "
|
||||
"Do not promise to perform these actions. If the user asks, explain "
|
||||
"that you can only read messages sent directly to you and respond."
|
||||
)
|
||||
|
||||
# Connected platforms
|
||||
platforms_list = ["local (files on this machine)"]
|
||||
for p in context.connected_platforms:
|
||||
|
||||
@@ -14,10 +14,8 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
CACHE_PATH = get_hermes_home() / "sticker_cache.json"
|
||||
CACHE_PATH = Path(os.path.expanduser("~/.hermes/sticker_cache.json"))
|
||||
|
||||
# Vision prompt for describing stickers -- kept concise to save tokens
|
||||
STICKER_VISION_PROMPT = (
|
||||
|
||||
+2
-23
@@ -1541,20 +1541,8 @@ def detect_external_credentials() -> List[Dict[str, Any]]:
|
||||
# CLI Commands — login / logout
|
||||
# =============================================================================
|
||||
|
||||
def _update_config_for_provider(
|
||||
provider_id: str,
|
||||
inference_base_url: str,
|
||||
default_model: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""Update config.yaml and auth.json to reflect the active provider.
|
||||
|
||||
When *default_model* is provided the function also writes it as the
|
||||
``model.default`` value. This prevents a race condition where the
|
||||
gateway (which re-reads config per-message) picks up the new provider
|
||||
before the caller has finished model selection, resulting in a
|
||||
mismatched model/provider (e.g. ``anthropic/claude-opus-4.6`` sent to
|
||||
MiniMax's API).
|
||||
"""
|
||||
def _update_config_for_provider(provider_id: str, inference_base_url: str) -> Path:
|
||||
"""Update config.yaml and auth.json to reflect the active provider."""
|
||||
# Set active_provider in auth.json so auto-resolution picks this provider
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
@@ -1588,15 +1576,6 @@ def _update_config_for_provider(
|
||||
else:
|
||||
# Clear stale base_url to prevent contamination when switching providers
|
||||
model_cfg.pop("base_url", None)
|
||||
|
||||
# When switching to a non-OpenRouter provider, ensure model.default is
|
||||
# valid for the new provider. An OpenRouter-formatted name like
|
||||
# "anthropic/claude-opus-4.6" will fail on direct-API providers.
|
||||
if default_model:
|
||||
cur_default = model_cfg.get("default", "")
|
||||
if not cur_default or "/" in cur_default:
|
||||
model_cfg["default"] = default_model
|
||||
|
||||
config["model"] = model_cfg
|
||||
|
||||
config_path.write_text(yaml.safe_dump(config, sort_keys=False))
|
||||
|
||||
+35
-45
@@ -227,53 +227,43 @@ def approval_callback(cli, command: str, description: str) -> str:
|
||||
Shows a selection UI with choices: once / session / always / deny.
|
||||
When the command is longer than 70 characters, a "view" option is
|
||||
included so the user can reveal the full text before deciding.
|
||||
|
||||
Uses cli._approval_lock to serialize concurrent requests (e.g. from
|
||||
parallel delegation subtasks) so each prompt gets its own turn.
|
||||
"""
|
||||
lock = getattr(cli, "_approval_lock", None)
|
||||
if lock is None:
|
||||
import threading
|
||||
cli._approval_lock = threading.Lock()
|
||||
lock = cli._approval_lock
|
||||
timeout = 60
|
||||
response_queue = queue.Queue()
|
||||
choices = ["once", "session", "always", "deny"]
|
||||
if len(command) > 70:
|
||||
choices.append("view")
|
||||
|
||||
with lock:
|
||||
timeout = 60
|
||||
response_queue = queue.Queue()
|
||||
choices = ["once", "session", "always", "deny"]
|
||||
if len(command) > 70:
|
||||
choices.append("view")
|
||||
cli._approval_state = {
|
||||
"command": command,
|
||||
"description": description,
|
||||
"choices": choices,
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
cli._approval_deadline = _time.monotonic() + timeout
|
||||
|
||||
cli._approval_state = {
|
||||
"command": command,
|
||||
"description": description,
|
||||
"choices": choices,
|
||||
"selected": 0,
|
||||
"response_queue": response_queue,
|
||||
}
|
||||
cli._approval_deadline = _time.monotonic() + timeout
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
while True:
|
||||
try:
|
||||
result = response_queue.get(timeout=1)
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = cli._approval_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
|
||||
while True:
|
||||
try:
|
||||
result = response_queue.get(timeout=1)
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
return result
|
||||
except queue.Empty:
|
||||
remaining = cli._approval_deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
||||
return "deny"
|
||||
cli._approval_state = None
|
||||
cli._approval_deadline = 0
|
||||
if hasattr(cli, "_app") and cli._app:
|
||||
cli._app.invalidate()
|
||||
cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}")
|
||||
return "deny"
|
||||
|
||||
@@ -18,36 +18,6 @@ DEFAULT_CODEX_MODELS: List[str] = [
|
||||
"gpt-5.1-codex-mini",
|
||||
]
|
||||
|
||||
_FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
|
||||
("gpt-5.3-codex", ("gpt-5.2-codex",)),
|
||||
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
|
||||
]
|
||||
|
||||
|
||||
def _add_forward_compat_models(model_ids: List[str]) -> List[str]:
|
||||
"""Add Clawdbot-style synthetic forward-compat Codex models.
|
||||
|
||||
If a newer Codex slug isn't returned by live discovery, surface it when an
|
||||
older compatible template model is present. This mirrors Clawdbot's
|
||||
synthetic catalog / forward-compat behavior for GPT-5 Codex variants.
|
||||
"""
|
||||
ordered: List[str] = []
|
||||
seen: set[str] = set()
|
||||
for model_id in model_ids:
|
||||
if model_id not in seen:
|
||||
ordered.append(model_id)
|
||||
seen.add(model_id)
|
||||
|
||||
for synthetic_model, template_models in _FORWARD_COMPAT_TEMPLATE_MODELS:
|
||||
if synthetic_model in seen:
|
||||
continue
|
||||
if any(template in seen for template in template_models):
|
||||
ordered.append(synthetic_model)
|
||||
seen.add(synthetic_model)
|
||||
|
||||
return ordered
|
||||
|
||||
|
||||
def _fetch_models_from_api(access_token: str) -> List[str]:
|
||||
"""Fetch available models from the Codex API. Returns visible models sorted by priority."""
|
||||
@@ -84,7 +54,7 @@ def _fetch_models_from_api(access_token: str) -> List[str]:
|
||||
sortable.append((rank, slug))
|
||||
|
||||
sortable.sort(key=lambda x: (x[0], x[1]))
|
||||
return _add_forward_compat_models([slug for _, slug in sortable])
|
||||
return [slug for _, slug in sortable]
|
||||
|
||||
|
||||
def _read_default_model(codex_home: Path) -> Optional[str]:
|
||||
@@ -155,7 +125,7 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
|
||||
if access_token:
|
||||
api_models = _fetch_models_from_api(access_token)
|
||||
if api_models:
|
||||
return _add_forward_compat_models(api_models)
|
||||
return api_models
|
||||
|
||||
# Fall back to local sources
|
||||
default_model = _read_default_model(codex_home)
|
||||
@@ -170,4 +140,4 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]:
|
||||
if model_id not in ordered:
|
||||
ordered.append(model_id)
|
||||
|
||||
return _add_forward_compat_models(ordered)
|
||||
return ordered
|
||||
|
||||
@@ -16,9 +16,9 @@ from prompt_toolkit.completion import Completer, Completion
|
||||
# Commands organized by category for better help display
|
||||
COMMANDS_BY_CATEGORY = {
|
||||
"Session": {
|
||||
"/new": "Start a new session (fresh session ID + history)",
|
||||
"/reset": "Start a new session (alias for /new)",
|
||||
"/clear": "Clear screen and start a new session",
|
||||
"/new": "Start a new conversation (reset history)",
|
||||
"/reset": "Reset conversation only (keep screen)",
|
||||
"/clear": "Clear screen and reset conversation (fresh start)",
|
||||
"/history": "Show conversation history",
|
||||
"/save": "Save the current conversation",
|
||||
"/retry": "Retry the last message (resend to agent)",
|
||||
|
||||
+7
-30
@@ -194,13 +194,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
|
||||
"stt": {
|
||||
"provider": "local", # "local" (free, faster-whisper) | "openai" (Whisper API)
|
||||
"local": {
|
||||
"model": "base", # tiny, base, small, medium, large-v3
|
||||
},
|
||||
"openai": {
|
||||
"model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe
|
||||
},
|
||||
"enabled": True,
|
||||
"model": "whisper-1",
|
||||
},
|
||||
|
||||
"human_delay": {
|
||||
@@ -255,15 +250,6 @@ DEFAULT_CONFIG = {
|
||||
# Or dict format: {"name": {"description": "...", "system_prompt": "...", "tone": "...", "style": "..."}}
|
||||
"personalities": {},
|
||||
|
||||
# Pre-exec security scanning via tirith
|
||||
"security": {
|
||||
"redact_secrets": True,
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
"tirith_timeout": 5,
|
||||
"tirith_fail_open": True,
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 7,
|
||||
}
|
||||
@@ -894,23 +880,14 @@ def load_config() -> Dict[str, Any]:
|
||||
return _normalize_max_turns_config(config)
|
||||
|
||||
|
||||
_SECURITY_COMMENT = """
|
||||
_COMMENTED_SECTIONS = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# API keys, tokens, and passwords are redacted from tool output by default.
|
||||
# Set to false to see full values (useful for debugging auth issues).
|
||||
# tirith pre-exec scanning is enabled by default when the tirith binary
|
||||
# is available. Configure via security.tirith_* keys or env vars
|
||||
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: false
|
||||
# tirith_enabled: true
|
||||
# tirith_path: "tirith"
|
||||
# tirith_timeout: 5
|
||||
# tirith_fail_open: true
|
||||
"""
|
||||
|
||||
_FALLBACK_COMMENT = """
|
||||
# ── Fallback Model ────────────────────────────────────────────────────
|
||||
# Automatic provider failover when primary is unavailable.
|
||||
# Uncomment and configure to enable. Triggers on rate limits (429),
|
||||
@@ -973,18 +950,18 @@ def save_config(config: Dict[str, Any]):
|
||||
|
||||
# Build optional commented-out sections for features that are off by
|
||||
# default or only relevant when explicitly configured.
|
||||
parts = []
|
||||
sections = []
|
||||
sec = normalized.get("security", {})
|
||||
if not sec or sec.get("redact_secrets") is None:
|
||||
parts.append(_SECURITY_COMMENT)
|
||||
sections.append("security")
|
||||
fb = normalized.get("fallback_model", {})
|
||||
if not fb or not (fb.get("provider") and fb.get("model")):
|
||||
parts.append(_FALLBACK_COMMENT)
|
||||
sections.append("fallback")
|
||||
|
||||
atomic_yaml_write(
|
||||
config_path,
|
||||
normalized,
|
||||
extra_content="".join(parts) if parts else None,
|
||||
extra_content=_COMMENTED_SECTIONS if sections else None,
|
||||
)
|
||||
_secure_file(config_path)
|
||||
|
||||
|
||||
@@ -97,10 +97,6 @@ def check_info(text: str):
|
||||
def run_doctor(args):
|
||||
"""Run diagnostic checks."""
|
||||
should_fix = getattr(args, 'fix', False)
|
||||
|
||||
# Doctor runs from the interactive CLI, so CLI-gated tool availability
|
||||
# checks (like cronjob management) should see the same context as `hermes`.
|
||||
os.environ.setdefault("HERMES_INTERACTIVE", "1")
|
||||
|
||||
issues = []
|
||||
manual_issues = [] # issues that can't be auto-fixed
|
||||
|
||||
+4
-16
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
from hermes_cli.setup import (
|
||||
print_header, print_info, print_success, print_warning, print_error,
|
||||
prompt, prompt_choice, prompt_yes_no,
|
||||
@@ -283,7 +283,7 @@ def systemd_status(deep: bool = False):
|
||||
def generate_launchd_plist() -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
log_dir = Path.home() / ".hermes" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -380,7 +380,7 @@ def launchd_status(deep: bool = False):
|
||||
print("✗ Gateway service is not loaded")
|
||||
|
||||
if deep:
|
||||
log_file = get_hermes_home() / "logs" / "gateway.log"
|
||||
log_file = Path.home() / ".hermes" / "logs" / "gateway.log"
|
||||
if log_file.exists():
|
||||
print()
|
||||
print("Recent logs:")
|
||||
@@ -557,7 +557,7 @@ def _platform_status(platform: dict) -> str:
|
||||
val = get_env_value(token_var)
|
||||
if token_var == "WHATSAPP_ENABLED":
|
||||
if val and val.lower() == "true":
|
||||
session_file = get_hermes_home() / "whatsapp" / "session" / "creds.json"
|
||||
session_file = Path.home() / ".hermes" / "whatsapp" / "session" / "creds.json"
|
||||
if session_file.exists():
|
||||
return "configured + paired"
|
||||
return "enabled, not paired"
|
||||
@@ -623,18 +623,6 @@ def _setup_standard_platform(platform: dict):
|
||||
value = prompt(f" {var['prompt']}", password=False)
|
||||
if value:
|
||||
cleaned = value.replace(" ", "")
|
||||
# For Discord, strip common prefixes (user:123, <@123>, <@!123>)
|
||||
if "DISCORD" in var["name"]:
|
||||
parts = []
|
||||
for uid in cleaned.split(","):
|
||||
uid = uid.strip()
|
||||
if uid.startswith("<@") and uid.endswith(">"):
|
||||
uid = uid.lstrip("<@!").rstrip(">")
|
||||
if uid.lower().startswith("user:"):
|
||||
uid = uid[5:]
|
||||
if uid:
|
||||
parts.append(uid)
|
||||
cleaned = ",".join(parts)
|
||||
save_env_value(var["name"], cleaned)
|
||||
print_success(f" Saved — only these users can interact with the bot.")
|
||||
allowed_val_set = cleaned
|
||||
|
||||
+8
-32
@@ -34,12 +34,11 @@ Usage:
|
||||
hermes honcho identity # Show AI peer identity representation
|
||||
hermes honcho identity <file> # Seed AI peer identity from a file (SOUL.md etc.)
|
||||
hermes honcho migrate # Step-by-step migration guide: OpenClaw native → Hermes + Honcho
|
||||
hermes version Show version
|
||||
hermes update Update to latest version
|
||||
hermes uninstall Uninstall Hermes Agent
|
||||
hermes acp Run as an ACP server for editor integration
|
||||
hermes sessions browse Interactive session picker with search
|
||||
|
||||
hermes version # Show version
|
||||
hermes update # Update to latest version
|
||||
hermes uninstall # Uninstall Hermes Agent
|
||||
hermes sessions browse # Interactive session picker with search
|
||||
hermes claw migrate # Migrate from OpenClaw to Hermes
|
||||
hermes claw migrate --dry-run # Preview migration without changes
|
||||
"""
|
||||
|
||||
@@ -649,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():
|
||||
@@ -746,8 +745,8 @@ def cmd_model(args):
|
||||
config_provider = model_cfg.get("provider")
|
||||
|
||||
effective_provider = (
|
||||
config_provider
|
||||
or os.getenv("HERMES_INFERENCE_PROVIDER")
|
||||
os.getenv("HERMES_INFERENCE_PROVIDER")
|
||||
or config_provider
|
||||
or "auto"
|
||||
)
|
||||
try:
|
||||
@@ -1058,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)
|
||||
@@ -1074,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.
|
||||
|
||||
@@ -3103,27 +3100,6 @@ For more help on a command:
|
||||
help="Skip confirmation prompts"
|
||||
)
|
||||
uninstall_parser.set_defaults(func=cmd_uninstall)
|
||||
|
||||
# =========================================================================
|
||||
# acp command
|
||||
# =========================================================================
|
||||
acp_parser = subparsers.add_parser(
|
||||
"acp",
|
||||
help="Run Hermes Agent as an ACP (Agent Client Protocol) server",
|
||||
description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)",
|
||||
)
|
||||
|
||||
def cmd_acp(args):
|
||||
"""Launch Hermes Agent as an ACP server."""
|
||||
try:
|
||||
from acp_adapter.entry import main as acp_main
|
||||
acp_main()
|
||||
except ImportError:
|
||||
print("ACP dependencies not installed.")
|
||||
print("Install them with: pip install -e '.[acp]'")
|
||||
sys.exit(1)
|
||||
|
||||
acp_parser.set_defaults(func=cmd_acp)
|
||||
|
||||
# =========================================================================
|
||||
# Parse and execute
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -29,21 +29,19 @@ def _get_model_config() -> Dict[str, Any]:
|
||||
|
||||
|
||||
def resolve_requested_provider(requested: Optional[str] = None) -> str:
|
||||
"""Resolve provider request from explicit arg, config, then env."""
|
||||
"""Resolve provider request from explicit arg, env, then config."""
|
||||
if requested and requested.strip():
|
||||
return requested.strip().lower()
|
||||
|
||||
env_provider = os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
|
||||
if env_provider:
|
||||
return env_provider
|
||||
|
||||
model_cfg = _get_model_config()
|
||||
cfg_provider = model_cfg.get("provider")
|
||||
if isinstance(cfg_provider, str) and cfg_provider.strip():
|
||||
return cfg_provider.strip().lower()
|
||||
|
||||
# Prefer the persisted config selection over any stale shell/.env
|
||||
# provider override so chat uses the endpoint the user last saved.
|
||||
env_provider = os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower()
|
||||
if env_provider:
|
||||
return env_provider
|
||||
|
||||
return "auto"
|
||||
|
||||
|
||||
|
||||
+9
-48
@@ -111,17 +111,7 @@ def _setup_provider_model_selection(config, provider_id, current_model, prompt_c
|
||||
custom = prompt_fn("Enter model name")
|
||||
if custom:
|
||||
_set_default_model(config, custom)
|
||||
else:
|
||||
# "Keep current" selected — validate it's compatible with the new
|
||||
# provider. OpenRouter-formatted names (containing "/") won't work
|
||||
# on direct-API providers and would silently break the gateway.
|
||||
if "/" in (current_model or "") and provider_models:
|
||||
print_warning(
|
||||
f"Current model \"{current_model}\" looks like an OpenRouter model "
|
||||
f"and won't work with {pconfig.name}. "
|
||||
f"Switching to {provider_models[0]}."
|
||||
)
|
||||
_set_default_model(config, provider_models[0])
|
||||
# else: keep current
|
||||
|
||||
|
||||
def _sync_model_from_disk(config: Dict[str, Any]) -> None:
|
||||
@@ -654,7 +644,6 @@ def setup_model_provider(config: dict):
|
||||
_update_config_for_provider,
|
||||
_login_openai_codex,
|
||||
get_codex_auth_status,
|
||||
resolve_codex_runtime_credentials,
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
detect_external_credentials,
|
||||
)
|
||||
@@ -978,7 +967,7 @@ def setup_model_provider(config: dict):
|
||||
if existing_custom:
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_update_config_for_provider("zai", zai_base_url, default_model="glm-5")
|
||||
_update_config_for_provider("zai", zai_base_url)
|
||||
_set_model_provider(config, "zai", zai_base_url)
|
||||
|
||||
elif provider_idx == 5: # Kimi / Moonshot
|
||||
@@ -1011,7 +1000,7 @@ def setup_model_provider(config: dict):
|
||||
if existing_custom:
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_update_config_for_provider("kimi-coding", pconfig.inference_base_url, default_model="kimi-k2.5")
|
||||
_update_config_for_provider("kimi-coding", pconfig.inference_base_url)
|
||||
_set_model_provider(config, "kimi-coding", pconfig.inference_base_url)
|
||||
|
||||
elif provider_idx == 6: # MiniMax
|
||||
@@ -1044,7 +1033,7 @@ def setup_model_provider(config: dict):
|
||||
if existing_custom:
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_update_config_for_provider("minimax", pconfig.inference_base_url, default_model="MiniMax-M2.5")
|
||||
_update_config_for_provider("minimax", pconfig.inference_base_url)
|
||||
_set_model_provider(config, "minimax", pconfig.inference_base_url)
|
||||
|
||||
elif provider_idx == 7: # MiniMax China
|
||||
@@ -1077,7 +1066,7 @@ def setup_model_provider(config: dict):
|
||||
if existing_custom:
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
_update_config_for_provider("minimax-cn", pconfig.inference_base_url, default_model="MiniMax-M2.5")
|
||||
_update_config_for_provider("minimax-cn", pconfig.inference_base_url)
|
||||
_set_model_provider(config, "minimax-cn", pconfig.inference_base_url)
|
||||
|
||||
elif provider_idx == 8: # Anthropic
|
||||
@@ -1181,7 +1170,7 @@ def setup_model_provider(config: dict):
|
||||
save_env_value("OPENAI_API_KEY", "")
|
||||
# Don't save base_url for Anthropic — resolve_runtime_provider()
|
||||
# always hardcodes it. Stale base_urls contaminate other providers.
|
||||
_update_config_for_provider("anthropic", "", default_model="claude-opus-4-6")
|
||||
_update_config_for_provider("anthropic", "")
|
||||
_set_model_provider(config, "anthropic")
|
||||
|
||||
# else: provider_idx == 9 (Keep current) — only shown when a provider already exists
|
||||
@@ -1267,15 +1256,7 @@ def setup_model_provider(config: dict):
|
||||
elif selected_provider == "openai-codex":
|
||||
from hermes_cli.codex_models import get_codex_model_ids
|
||||
|
||||
codex_token = None
|
||||
try:
|
||||
codex_creds = resolve_codex_runtime_credentials()
|
||||
codex_token = codex_creds.get("api_key")
|
||||
except Exception as exc:
|
||||
logger.debug("Could not resolve Codex runtime credentials for model list: %s", exc)
|
||||
|
||||
codex_models = get_codex_model_ids(access_token=codex_token)
|
||||
|
||||
codex_models = get_codex_model_ids()
|
||||
model_choices = codex_models + [f"Keep current ({current_model})"]
|
||||
default_codex = 0
|
||||
if current_model in codex_models:
|
||||
@@ -1944,17 +1925,7 @@ def setup_gateway(config: dict):
|
||||
"Allowed user IDs or usernames (comma-separated, leave empty for open access)"
|
||||
)
|
||||
if allowed_users:
|
||||
# Clean up common prefixes (user:123, <@123>, <@!123>)
|
||||
cleaned_ids = []
|
||||
for uid in allowed_users.replace(" ", "").split(","):
|
||||
uid = uid.strip()
|
||||
if uid.startswith("<@") and uid.endswith(">"):
|
||||
uid = uid.lstrip("<@!").rstrip(">")
|
||||
if uid.lower().startswith("user:"):
|
||||
uid = uid[5:]
|
||||
if uid:
|
||||
cleaned_ids.append(uid)
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("Discord allowlist configured")
|
||||
else:
|
||||
print_info(
|
||||
@@ -1989,18 +1960,8 @@ def setup_gateway(config: dict):
|
||||
)
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated)")
|
||||
if allowed_users:
|
||||
# Clean up common prefixes (user:123, <@123>, <@!123>)
|
||||
cleaned_ids = []
|
||||
for uid in allowed_users.replace(" ", "").split(","):
|
||||
uid = uid.strip()
|
||||
if uid.startswith("<@") and uid.endswith(">"):
|
||||
uid = uid.lstrip("<@!").rstrip(">")
|
||||
if uid.lower().startswith("user:"):
|
||||
uid = uid[5:]
|
||||
if uid:
|
||||
cleaned_ids.append(uid)
|
||||
save_env_value(
|
||||
"DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)
|
||||
"DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")
|
||||
)
|
||||
print_success("Discord allowlist configured")
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home
|
||||
from hermes_cli.config import get_env_path, get_env_value
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
def check_mark(ok: bool) -> str:
|
||||
@@ -267,7 +267,7 @@ def show_status(args):
|
||||
print()
|
||||
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
jobs_file = get_hermes_home() / "cron" / "jobs.json"
|
||||
jobs_file = Path.home() / ".hermes" / "cron" / "jobs.json"
|
||||
if jobs_file.exists():
|
||||
import json
|
||||
try:
|
||||
@@ -287,7 +287,7 @@ def show_status(args):
|
||||
print()
|
||||
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
|
||||
sessions_file = Path.home() / ".hermes" / "sessions" / "sessions.json"
|
||||
if sessions_file.exists():
|
||||
import json
|
||||
try:
|
||||
|
||||
+457
-617
File diff suppressed because it is too large
Load Diff
+274
-453
@@ -4,518 +4,339 @@
|
||||
|
||||
// --- Platform install commands ---
|
||||
const PLATFORMS = {
|
||||
linux: {
|
||||
command:
|
||||
"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash",
|
||||
prompt: "$",
|
||||
note: "Works on Linux, macOS & WSL2 · No prerequisites · Installs everything automatically",
|
||||
stepNote:
|
||||
"Installs uv, Python 3.11, clones the repo, sets up everything. No sudo needed.",
|
||||
},
|
||||
linux: {
|
||||
command: 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash',
|
||||
prompt: '$',
|
||||
note: 'Works on Linux, macOS & WSL2 · No prerequisites · Installs everything automatically',
|
||||
stepNote: 'Installs uv, Python 3.11, clones the repo, sets up everything. No sudo needed.',
|
||||
},
|
||||
};
|
||||
|
||||
function detectPlatform() {
|
||||
return "linux";
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
function switchPlatform(platform) {
|
||||
const cfg = PLATFORMS[platform];
|
||||
if (!cfg) return;
|
||||
const cfg = PLATFORMS[platform];
|
||||
if (!cfg) return;
|
||||
|
||||
// Update hero install widget
|
||||
const commandEl = document.getElementById("install-command");
|
||||
const promptEl = document.getElementById("install-prompt");
|
||||
const noteEl = document.getElementById("install-note");
|
||||
// Update hero install widget
|
||||
const commandEl = document.getElementById('install-command');
|
||||
const promptEl = document.getElementById('install-prompt');
|
||||
const noteEl = document.getElementById('install-note');
|
||||
|
||||
if (commandEl) commandEl.textContent = cfg.command;
|
||||
if (promptEl) promptEl.textContent = cfg.prompt;
|
||||
if (noteEl) noteEl.textContent = cfg.note;
|
||||
if (commandEl) commandEl.textContent = cfg.command;
|
||||
if (promptEl) promptEl.textContent = cfg.prompt;
|
||||
if (noteEl) noteEl.textContent = cfg.note;
|
||||
|
||||
// Update active tab in hero
|
||||
document.querySelectorAll(".install-tab").forEach((tab) => {
|
||||
tab.classList.toggle("active", tab.dataset.platform === platform);
|
||||
});
|
||||
// Update active tab in hero
|
||||
document.querySelectorAll('.install-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.platform === platform);
|
||||
});
|
||||
|
||||
// Sync the step section tabs too
|
||||
switchStepPlatform(platform);
|
||||
// Sync the step section tabs too
|
||||
switchStepPlatform(platform);
|
||||
}
|
||||
|
||||
function switchStepPlatform(platform) {
|
||||
const cfg = PLATFORMS[platform];
|
||||
if (!cfg) return;
|
||||
const cfg = PLATFORMS[platform];
|
||||
if (!cfg) return;
|
||||
|
||||
const commandEl = document.getElementById("step1-command");
|
||||
const copyBtn = document.getElementById("step1-copy");
|
||||
const noteEl = document.getElementById("step1-note");
|
||||
const commandEl = document.getElementById('step1-command');
|
||||
const copyBtn = document.getElementById('step1-copy');
|
||||
const noteEl = document.getElementById('step1-note');
|
||||
|
||||
if (commandEl) commandEl.textContent = cfg.command;
|
||||
if (copyBtn) copyBtn.setAttribute("data-text", cfg.command);
|
||||
if (noteEl) noteEl.textContent = cfg.stepNote;
|
||||
if (commandEl) commandEl.textContent = cfg.command;
|
||||
if (copyBtn) copyBtn.setAttribute('data-text', cfg.command);
|
||||
if (noteEl) noteEl.textContent = cfg.stepNote;
|
||||
|
||||
// Update active tab in step section
|
||||
document.querySelectorAll(".code-tab").forEach((tab) => {
|
||||
tab.classList.toggle("active", tab.dataset.platform === platform);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleMobileNav() {
|
||||
document.getElementById("nav-mobile").classList.toggle("open");
|
||||
document.getElementById("nav-hamburger").classList.toggle("open");
|
||||
}
|
||||
|
||||
function toggleSpecs() {
|
||||
const wrapper = document.getElementById("specs-wrapper");
|
||||
const btn = document.getElementById("specs-toggle");
|
||||
const label = btn.querySelector(".toggle-label");
|
||||
const isOpen = wrapper.classList.contains("open");
|
||||
|
||||
if (isOpen) {
|
||||
wrapper.style.maxHeight = wrapper.scrollHeight + "px";
|
||||
requestAnimationFrame(() => {
|
||||
wrapper.style.maxHeight = "0";
|
||||
// Update active tab in step section
|
||||
document.querySelectorAll('.code-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.platform === platform);
|
||||
});
|
||||
wrapper.classList.remove("open");
|
||||
btn.classList.remove("open");
|
||||
if (label) label.textContent = "More details";
|
||||
} else {
|
||||
wrapper.classList.add("open");
|
||||
wrapper.style.maxHeight = wrapper.scrollHeight + "px";
|
||||
btn.classList.add("open");
|
||||
if (label) label.textContent = "Less";
|
||||
wrapper.addEventListener(
|
||||
"transitionend",
|
||||
() => {
|
||||
if (wrapper.classList.contains("open")) {
|
||||
wrapper.style.maxHeight = "none";
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Copy to clipboard ---
|
||||
function copyInstall() {
|
||||
const text = document.getElementById("install-command").textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.querySelector(".install-widget-body .copy-btn");
|
||||
const original = btn.querySelector(".copy-text").textContent;
|
||||
btn.querySelector(".copy-text").textContent = "Copied!";
|
||||
btn.style.color = "var(--primary-light)";
|
||||
setTimeout(() => {
|
||||
btn.querySelector(".copy-text").textContent = original;
|
||||
btn.style.color = "";
|
||||
}, 2000);
|
||||
});
|
||||
const text = document.getElementById('install-command').textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.querySelector('.install-widget-body .copy-btn');
|
||||
const original = btn.querySelector('.copy-text').textContent;
|
||||
btn.querySelector('.copy-text').textContent = 'Copied!';
|
||||
btn.style.color = 'var(--gold)';
|
||||
setTimeout(() => {
|
||||
btn.querySelector('.copy-text').textContent = original;
|
||||
btn.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function copyText(btn) {
|
||||
const text = btn.getAttribute("data-text");
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = "Copied!";
|
||||
btn.style.color = "var(--primary-light)";
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
btn.style.color = "";
|
||||
}, 2000);
|
||||
});
|
||||
const text = btn.getAttribute('data-text');
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
btn.style.color = 'var(--gold)';
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
btn.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Scroll-triggered fade-in ---
|
||||
function initScrollAnimations() {
|
||||
const elements = document.querySelectorAll(
|
||||
".feature-card, .install-step, " +
|
||||
".section-header, .terminal-window",
|
||||
);
|
||||
const elements = document.querySelectorAll(
|
||||
'.feature-card, .tool-pill, .platform-group, .skill-category, ' +
|
||||
'.install-step, .research-card, .footer-card, .section-header, ' +
|
||||
'.lead-text, .section-desc, .terminal-window'
|
||||
);
|
||||
|
||||
elements.forEach((el) => el.classList.add("fade-in"));
|
||||
elements.forEach(el => el.classList.add('fade-in'));
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
// Stagger children within grids
|
||||
const parent = entry.target.parentElement;
|
||||
if (parent) {
|
||||
const siblings = parent.querySelectorAll(".fade-in");
|
||||
let idx = Array.from(siblings).indexOf(entry.target);
|
||||
if (idx < 0) idx = 0;
|
||||
setTimeout(() => {
|
||||
entry.target.classList.add("visible");
|
||||
}, idx * 60);
|
||||
} else {
|
||||
entry.target.classList.add("visible");
|
||||
}
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: "0px 0px -40px 0px" },
|
||||
);
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
// Stagger children within grids
|
||||
const parent = entry.target.parentElement;
|
||||
if (parent) {
|
||||
const siblings = parent.querySelectorAll('.fade-in');
|
||||
let idx = Array.from(siblings).indexOf(entry.target);
|
||||
if (idx < 0) idx = 0;
|
||||
setTimeout(() => {
|
||||
entry.target.classList.add('visible');
|
||||
}, idx * 60);
|
||||
} else {
|
||||
entry.target.classList.add('visible');
|
||||
}
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1, rootMargin: '0px 0px -40px 0px' });
|
||||
|
||||
elements.forEach((el) => observer.observe(el));
|
||||
elements.forEach(el => observer.observe(el));
|
||||
}
|
||||
|
||||
// --- Terminal Demo ---
|
||||
const CURSOR = '<span class="terminal-cursor">█</span>';
|
||||
|
||||
const demoSequence = [
|
||||
{ type: "prompt", text: "❯ " },
|
||||
{
|
||||
type: "type",
|
||||
text: "Research the latest approaches to GRPO training and write a summary",
|
||||
delay: 30,
|
||||
},
|
||||
{ type: "pause", ms: 600 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-dim"> web_search "GRPO reinforcement learning 2026" 1.2s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> web_extract arxiv.org/abs/2402.03300 3.1s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> web_search "GRPO vs PPO ablation results" 0.9s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> web_extract huggingface.co/blog/grpo 2.8s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> write_file ~/research/grpo-summary.md 0.1s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 500 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-text">Done! I\'ve written a summary covering:</span>',
|
||||
"",
|
||||
'<span class="t-text"> <span class="t-green">✓</span> GRPO\'s group-relative advantage (no critic model needed)</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Comparison with PPO/DPO on reasoning benchmarks</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Implementation notes for Axolotl and TRL</span>',
|
||||
"",
|
||||
'<span class="t-text">Saved to</span> <span class="t-accent">~/research/grpo-summary.md</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 2500 },
|
||||
// Scene 1: Research task with delegation
|
||||
{ type: 'prompt', text: '❯ ' },
|
||||
{ type: 'type', text: 'Research the latest approaches to GRPO training and write a summary', delay: 30 },
|
||||
{ type: 'pause', ms: 600 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-dim">┊ 🔍 web_search "GRPO reinforcement learning 2026" 1.2s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 📄 web_extract arxiv.org/abs/2402.03300 3.1s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 🔍 web_search "GRPO vs PPO ablation results" 0.9s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 📄 web_extract huggingface.co/blog/grpo 2.8s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ ✍️ write_file ~/research/grpo-summary.md 0.1s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 500 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-text">Done! I\'ve written a summary covering:</span>',
|
||||
'',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> GRPO\'s group-relative advantage (no critic model needed)</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Comparison with PPO/DPO on reasoning benchmarks</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Implementation notes for Axolotl and TRL</span>',
|
||||
'',
|
||||
'<span class="t-text">Saved to</span> <span class="t-amber">~/research/grpo-summary.md</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 2500 },
|
||||
|
||||
{ type: "clear" },
|
||||
{ type: "prompt", text: "❯ " },
|
||||
{
|
||||
type: "type",
|
||||
text: "Review the PR at NousResearch/hermes-agent#42 and fix any issues",
|
||||
delay: 30,
|
||||
},
|
||||
{ type: "pause", ms: 600 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-dim"> delegate_task "review PR #42 changes" 2.1s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 500 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> git diff main..pr-42 0.4s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> patch tools/registry.py 0.1s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> python -m pytest tests/ -x 3.2s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 400 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
'<span class="t-dim"> git commit -m "fix: handle empty tool schemas" 0.3s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 500 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-text">Found 2 issues in the PR and fixed both:</span>',
|
||||
"",
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Empty tool schema crash in registry.py — added guard</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Missing error handling in delegate_tool.py — added try/except</span>',
|
||||
"",
|
||||
'<span class="t-text">Tests pass. Committed the fix and pushed to the PR branch.</span>',
|
||||
'<span class="t-text">I also saved a</span> <span class="t-accent">skill</span> <span class="t-text">for this PR review pattern.</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 2500 },
|
||||
// Scene 2: Quick delegation
|
||||
{ type: 'clear' },
|
||||
{ type: 'prompt', text: '❯ ' },
|
||||
{ type: 'type', text: 'Review the PR at NousResearch/hermes-agent#42 and fix any issues', delay: 30 },
|
||||
{ type: 'pause', ms: 600 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-dim">┊ 🔀 delegate_task "review PR #42 changes" 2.1s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 500 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 💻 git diff main..pr-42 0.4s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ ✏️ patch tools/registry.py 0.1s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 💻 python -m pytest tests/ -x 3.2s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 400 },
|
||||
{ type: 'output', lines: [
|
||||
'<span class="t-dim">┊ 💻 git commit -m "fix: handle empty tool schemas" 0.3s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 500 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-text">Found 2 issues in the PR and fixed both:</span>',
|
||||
'',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Empty tool schema crash in registry.py — added guard</span>',
|
||||
'<span class="t-text"> <span class="t-green">✓</span> Missing error handling in delegate_tool.py — added try/except</span>',
|
||||
'',
|
||||
'<span class="t-text">Tests pass. Committed the fix and pushed to the PR branch.</span>',
|
||||
'<span class="t-text">I also saved a</span> <span class="t-amber">skill</span> <span class="t-text">for this PR review pattern.</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 2500 },
|
||||
|
||||
{ type: "clear" },
|
||||
{ type: "prompt", text: "❯ " },
|
||||
{
|
||||
type: "type",
|
||||
text: "How did we fix that Docker networking issue?",
|
||||
delay: 35,
|
||||
},
|
||||
{ type: "pause", ms: 500 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-dim"> session_search "Docker networking" 1.4s</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 500 },
|
||||
{
|
||||
type: "output",
|
||||
lines: [
|
||||
"",
|
||||
'<span class="t-text">Found it — from a session on February 12th:</span>',
|
||||
"",
|
||||
'<span class="t-text">The containers couldn\'t reach each other because the compose</span>',
|
||||
'<span class="t-text">file was using the default bridge network. We switched to a</span>',
|
||||
'<span class="t-text">custom network with</span> <span class="t-accent">driver: overlay</span><span class="t-text">, added explicit</span>',
|
||||
'<span class="t-text">aliases, and set</span> <span class="t-accent">dns: 8.8.8.8</span> <span class="t-text">as a fallback.</span>',
|
||||
"",
|
||||
'<span class="t-text">The fix was committed in</span> <span class="t-accent">docker-compose.prod.yml</span><span class="t-text">.</span>',
|
||||
],
|
||||
},
|
||||
{ type: "pause", ms: 3000 },
|
||||
// Scene 3: Session search by keyword
|
||||
{ type: 'clear' },
|
||||
{ type: 'prompt', text: '❯ ' },
|
||||
{ type: 'type', text: 'How did we fix that Docker networking issue?', delay: 35 },
|
||||
{ type: 'pause', ms: 500 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-dim">┊ 🔎 session_search "Docker networking" 1.4s</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 500 },
|
||||
{ type: 'output', lines: [
|
||||
'',
|
||||
'<span class="t-text">Found it — from a session on February 12th:</span>',
|
||||
'',
|
||||
'<span class="t-text">The containers couldn\'t reach each other because the compose</span>',
|
||||
'<span class="t-text">file was using the default bridge network. We switched to a</span>',
|
||||
'<span class="t-text">custom network with</span> <span class="t-amber">driver: overlay</span><span class="t-text">, added explicit</span>',
|
||||
'<span class="t-text">aliases, and set</span> <span class="t-amber">dns: 8.8.8.8</span> <span class="t-text">as a fallback.</span>',
|
||||
'',
|
||||
'<span class="t-text">The fix was committed in</span> <span class="t-amber">docker-compose.prod.yml</span><span class="t-text">.</span>',
|
||||
]},
|
||||
{ type: 'pause', ms: 3000 },
|
||||
];
|
||||
|
||||
class TerminalDemo {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.running = false;
|
||||
this.content = "";
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
while (this.running) {
|
||||
for (const step of demoSequence) {
|
||||
if (!this.running) return;
|
||||
await this.execute(step);
|
||||
}
|
||||
this.clear();
|
||||
await this.sleep(1000);
|
||||
constructor(element, cursorElement) {
|
||||
this.el = element;
|
||||
this.cursor = cursorElement;
|
||||
this.running = false;
|
||||
this.content = '';
|
||||
this.observer = null;
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
async execute(step) {
|
||||
switch (step.type) {
|
||||
case "prompt":
|
||||
this.append(`<span class="t-prompt">${step.text}</span>`);
|
||||
break;
|
||||
case "type":
|
||||
for (const char of step.text) {
|
||||
if (!this.running) return;
|
||||
this.append(`<span class="t-cmd">${char}</span>`);
|
||||
await this.sleep(step.delay || 30);
|
||||
async start() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
while (this.running) {
|
||||
for (const step of demoSequence) {
|
||||
if (!this.running) return;
|
||||
await this.execute(step);
|
||||
}
|
||||
// Loop
|
||||
this.clear();
|
||||
await this.sleep(1000);
|
||||
}
|
||||
break;
|
||||
case "output":
|
||||
for (const line of step.lines) {
|
||||
if (!this.running) return;
|
||||
this.append("\n" + line);
|
||||
await this.sleep(50);
|
||||
}
|
||||
break;
|
||||
case "pause":
|
||||
await this.sleep(step.ms);
|
||||
break;
|
||||
case "clear":
|
||||
this.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
append(html) {
|
||||
this.content += html;
|
||||
this.render();
|
||||
}
|
||||
stop() {
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = this.content + CURSOR;
|
||||
this.container.scrollTop = this.container.scrollHeight;
|
||||
}
|
||||
async execute(step) {
|
||||
switch (step.type) {
|
||||
case 'prompt':
|
||||
this.append(`<span class="t-prompt">${step.text}</span>`);
|
||||
break;
|
||||
|
||||
clear() {
|
||||
this.content = "";
|
||||
this.container.innerHTML = "";
|
||||
}
|
||||
case 'type':
|
||||
for (const char of step.text) {
|
||||
if (!this.running) return;
|
||||
this.append(`<span class="t-cmd">${char}</span>`);
|
||||
await this.sleep(step.delay || 30);
|
||||
}
|
||||
break;
|
||||
|
||||
sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
case 'output':
|
||||
for (const line of step.lines) {
|
||||
if (!this.running) return;
|
||||
this.append('\n' + line);
|
||||
await this.sleep(50);
|
||||
}
|
||||
break;
|
||||
|
||||
// --- Noise Overlay (ported from hermes-chat NoiseOverlay) ---
|
||||
function initNoiseOverlay() {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
if (typeof THREE === "undefined") return;
|
||||
case 'pause':
|
||||
await this.sleep(step.ms);
|
||||
break;
|
||||
|
||||
const canvas = document.getElementById("noise-overlay");
|
||||
if (!canvas) return;
|
||||
|
||||
const vertexShader = `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
case 'clear':
|
||||
this.clear();
|
||||
break;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
const fragmentShader = `
|
||||
uniform vec2 uRes;
|
||||
uniform float uDpr, uSize, uDensity, uOpacity;
|
||||
uniform vec3 uColor;
|
||||
varying vec2 vUv;
|
||||
append(html) {
|
||||
this.content += html;
|
||||
this.el.innerHTML = this.content;
|
||||
// Keep cursor at end
|
||||
this.el.parentElement.scrollTop = this.el.parentElement.scrollHeight;
|
||||
}
|
||||
|
||||
float hash(vec2 p) {
|
||||
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
||||
p3 += dot(p3, p3.yzx + 33.33);
|
||||
return fract((p3.x + p3.y) * p3.z);
|
||||
}
|
||||
clear() {
|
||||
this.content = '';
|
||||
this.el.innerHTML = '';
|
||||
}
|
||||
|
||||
void main() {
|
||||
float n = hash(floor(vUv * uRes / (uSize * uDpr)));
|
||||
gl_FragColor = vec4(uColor, step(1.0 - uDensity, n)) * uOpacity;
|
||||
}
|
||||
`;
|
||||
|
||||
function hexToVec3(hex) {
|
||||
const c = hex.replace("#", "");
|
||||
return new THREE.Vector3(
|
||||
parseInt(c.substring(0, 2), 16) / 255,
|
||||
parseInt(c.substring(2, 4), 16) / 255,
|
||||
parseInt(c.substring(4, 6), 16) / 255,
|
||||
);
|
||||
}
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
canvas,
|
||||
premultipliedAlpha: false,
|
||||
});
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
const geo = new THREE.PlaneGeometry(2, 2);
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
transparent: true,
|
||||
uniforms: {
|
||||
uColor: { value: hexToVec3("#8090BB") },
|
||||
uDensity: { value: 0.1 },
|
||||
uDpr: { value: 1 },
|
||||
uOpacity: { value: 0.4 },
|
||||
uRes: { value: new THREE.Vector2() },
|
||||
uSize: { value: 1.0 },
|
||||
},
|
||||
});
|
||||
|
||||
scene.add(new THREE.Mesh(geo, mat));
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio;
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
renderer.setSize(w, h);
|
||||
renderer.setPixelRatio(dpr);
|
||||
mat.uniforms.uRes.value.set(w * dpr, h * dpr);
|
||||
mat.uniforms.uDpr.value = dpr;
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
function loop() {
|
||||
requestAnimationFrame(loop);
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
loop();
|
||||
sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initialize ---
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const detectedPlatform = detectPlatform();
|
||||
switchPlatform(detectedPlatform);
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Auto-detect platform and set the right install command
|
||||
const detectedPlatform = detectPlatform();
|
||||
switchPlatform(detectedPlatform);
|
||||
|
||||
initScrollAnimations();
|
||||
initNoiseOverlay();
|
||||
initScrollAnimations();
|
||||
|
||||
const terminalEl = document.getElementById("terminal-demo");
|
||||
// Terminal demo - start when visible
|
||||
const terminalEl = document.getElementById('terminal-content');
|
||||
const cursorEl = document.getElementById('terminal-cursor');
|
||||
|
||||
if (terminalEl && cursorEl) {
|
||||
const demo = new TerminalDemo(terminalEl, cursorEl);
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
demo.start();
|
||||
} else {
|
||||
demo.stop();
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
if (terminalEl) {
|
||||
const demo = new TerminalDemo(terminalEl);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
demo.start();
|
||||
} else {
|
||||
demo.stop();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.3 },
|
||||
);
|
||||
|
||||
observer.observe(document.querySelector(".terminal-window"));
|
||||
}
|
||||
|
||||
const nav = document.querySelector(".nav");
|
||||
let ticking = false;
|
||||
window.addEventListener("scroll", () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
if (window.scrollY > 50) {
|
||||
nav.style.borderBottomColor = "rgba(48, 80, 255, 0.15)";
|
||||
} else {
|
||||
nav.style.borderBottomColor = "";
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
observer.observe(document.querySelector('.terminal-window'));
|
||||
}
|
||||
});
|
||||
|
||||
// Smooth nav background on scroll
|
||||
const nav = document.querySelector('.nav');
|
||||
let ticking = false;
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(() => {
|
||||
if (window.scrollY > 50) {
|
||||
nav.style.borderBottomColor = 'rgba(255, 215, 0, 0.1)';
|
||||
} else {
|
||||
nav.style.borderBottomColor = '';
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+376
-318
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -42,11 +42,10 @@ from dotenv import load_dotenv
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Add mini-swe-agent to path if not installed. In git worktrees the populated
|
||||
# submodule may live in the main checkout rather than the worktree itself.
|
||||
from minisweagent_path import ensure_minisweagent_on_path
|
||||
|
||||
ensure_minisweagent_on_path(Path(__file__).resolve().parent)
|
||||
# Add mini-swe-agent to path if not installed
|
||||
mini_swe_path = Path(__file__).parent / "mini-swe-agent" / "src"
|
||||
if mini_swe_path.exists():
|
||||
sys.path.insert(0, str(mini_swe_path))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Helpers for locating the mini-swe-agent source tree.
|
||||
|
||||
Hermes often runs from git worktrees. In that layout the worktree root may have
|
||||
an empty ``mini-swe-agent/`` placeholder while the real populated submodule
|
||||
lives under the main checkout that owns the shared ``.git`` directory.
|
||||
|
||||
These helpers locate a usable ``mini-swe-agent/src`` directory and optionally
|
||||
prepend it to ``sys.path`` so imports like ``import minisweagent`` work from
|
||||
both normal checkouts and worktrees.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _read_gitdir(repo_root: Path) -> Optional[Path]:
|
||||
"""Resolve the gitdir referenced by ``repo_root/.git`` when it is a file."""
|
||||
git_marker = repo_root / ".git"
|
||||
if not git_marker.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
raw = git_marker.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
prefix = "gitdir:"
|
||||
if not raw.lower().startswith(prefix):
|
||||
return None
|
||||
|
||||
target = raw[len(prefix):].strip()
|
||||
gitdir = Path(target)
|
||||
if not gitdir.is_absolute():
|
||||
gitdir = (repo_root / gitdir).resolve()
|
||||
else:
|
||||
gitdir = gitdir.resolve()
|
||||
return gitdir
|
||||
|
||||
|
||||
def discover_minisweagent_src(repo_root: Optional[Path] = None) -> Optional[Path]:
|
||||
"""Return the best available ``mini-swe-agent/src`` path, if any.
|
||||
|
||||
Search order:
|
||||
1. Current checkout/worktree root
|
||||
2. Main checkout that owns the shared ``.git`` directory (for worktrees)
|
||||
"""
|
||||
repo_root = (repo_root or Path(__file__).resolve().parent).resolve()
|
||||
|
||||
candidates: list[Path] = [repo_root / "mini-swe-agent" / "src"]
|
||||
|
||||
gitdir = _read_gitdir(repo_root)
|
||||
if gitdir is not None:
|
||||
# Worktree layout: <main>/.git/worktrees/<name>
|
||||
if len(gitdir.parents) >= 3 and gitdir.parent.name == "worktrees":
|
||||
candidates.append(gitdir.parents[2] / "mini-swe-agent" / "src")
|
||||
# Direct checkout with .git file pointing elsewhere
|
||||
elif gitdir.name == ".git":
|
||||
candidates.append(gitdir.parent / "mini-swe-agent" / "src")
|
||||
|
||||
seen = set()
|
||||
for candidate in candidates:
|
||||
candidate = candidate.resolve()
|
||||
if candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def ensure_minisweagent_on_path(repo_root: Optional[Path] = None) -> Optional[Path]:
|
||||
"""Ensure ``minisweagent`` is importable by prepending its src dir to sys.path.
|
||||
|
||||
Returns the inserted/discovered path, or ``None`` if the package is already
|
||||
importable or no local source tree could be found.
|
||||
"""
|
||||
if importlib.util.find_spec("minisweagent") is not None:
|
||||
return None
|
||||
|
||||
src = discover_minisweagent_src(repo_root)
|
||||
if src is None:
|
||||
return None
|
||||
|
||||
src_str = str(src)
|
||||
if src_str not in sys.path:
|
||||
sys.path.insert(0, src_str)
|
||||
return src
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
name: 1password
|
||||
description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in, and reading/injecting secrets for commands.
|
||||
version: 1.0.0
|
||||
author: arceus77-7, enhanced by Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [security, secrets, 1password, op, cli]
|
||||
category: security
|
||||
setup:
|
||||
help: "Create a service account at https://my.1password.com → Settings → Service Accounts"
|
||||
collect_secrets:
|
||||
- env_var: OP_SERVICE_ACCOUNT_TOKEN
|
||||
prompt: "1Password Service Account Token"
|
||||
provider_url: "https://developer.1password.com/docs/service-accounts/"
|
||||
secret: true
|
||||
---
|
||||
|
||||
# 1Password CLI
|
||||
|
||||
Use this skill when the user wants secrets managed through 1Password instead of plaintext env vars or files.
|
||||
|
||||
## Requirements
|
||||
|
||||
- 1Password account
|
||||
- 1Password CLI (`op`) installed
|
||||
- One of: desktop app integration, service account token (`OP_SERVICE_ACCOUNT_TOKEN`), or Connect server
|
||||
- `tmux` available for stable authenticated sessions during Hermes terminal calls (desktop app flow only)
|
||||
|
||||
## When to Use
|
||||
|
||||
- Install or configure 1Password CLI
|
||||
- Sign in with `op signin`
|
||||
- Read secret references like `op://Vault/Item/field`
|
||||
- Inject secrets into config/templates using `op inject`
|
||||
- Run commands with secret env vars via `op run`
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### Service Account (recommended for Hermes)
|
||||
|
||||
Set `OP_SERVICE_ACCOUNT_TOKEN` in `~/.hermes/.env` (the skill will prompt for this on first load).
|
||||
No desktop app needed. Supports `op read`, `op inject`, `op run`.
|
||||
|
||||
```bash
|
||||
export OP_SERVICE_ACCOUNT_TOKEN="your-token-here"
|
||||
op whoami # verify — should show Type: SERVICE_ACCOUNT
|
||||
```
|
||||
|
||||
### Desktop App Integration (interactive)
|
||||
|
||||
1. Enable in 1Password desktop app: Settings → Developer → Integrate with 1Password CLI
|
||||
2. Ensure app is unlocked
|
||||
3. Run `op signin` and approve the biometric prompt
|
||||
|
||||
### Connect Server (self-hosted)
|
||||
|
||||
```bash
|
||||
export OP_CONNECT_HOST="http://localhost:8080"
|
||||
export OP_CONNECT_TOKEN="your-connect-token"
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install CLI:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install 1password-cli
|
||||
|
||||
# Linux (official package/install docs)
|
||||
# See references/get-started.md for distro-specific links.
|
||||
|
||||
# Windows (winget)
|
||||
winget install AgileBits.1Password.CLI
|
||||
```
|
||||
|
||||
2. Verify:
|
||||
|
||||
```bash
|
||||
op --version
|
||||
```
|
||||
|
||||
3. Choose an auth method above and configure it.
|
||||
|
||||
## Hermes Execution Pattern (desktop app flow)
|
||||
|
||||
Hermes terminal commands are non-interactive by default and can lose auth context between calls.
|
||||
For reliable `op` use with desktop app integration, run sign-in and secret operations inside a dedicated tmux session.
|
||||
|
||||
Note: This is NOT needed when using `OP_SERVICE_ACCOUNT_TOKEN` — the token persists across terminal calls automatically.
|
||||
|
||||
```bash
|
||||
SOCKET_DIR="${TMPDIR:-/tmp}/hermes-tmux-sockets"
|
||||
mkdir -p "$SOCKET_DIR"
|
||||
SOCKET="$SOCKET_DIR/hermes-op.sock"
|
||||
SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
|
||||
|
||||
# Sign in (approve in desktop app when prompted)
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "eval \"\$(op signin --account my.1password.com)\"" Enter
|
||||
|
||||
# Verify auth
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter
|
||||
|
||||
# Example read
|
||||
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op read 'op://Private/Npmjs/one-time password?attribute=otp'" Enter
|
||||
|
||||
# Capture output when needed
|
||||
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
|
||||
|
||||
# Cleanup
|
||||
tmux -S "$SOCKET" kill-session -t "$SESSION"
|
||||
```
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Read a secret
|
||||
|
||||
```bash
|
||||
op read "op://app-prod/db/password"
|
||||
```
|
||||
|
||||
### Get OTP
|
||||
|
||||
```bash
|
||||
op read "op://app-prod/npm/one-time password?attribute=otp"
|
||||
```
|
||||
|
||||
### Inject into template
|
||||
|
||||
```bash
|
||||
echo "db_password: {{ op://app-prod/db/password }}" | op inject
|
||||
```
|
||||
|
||||
### Run a command with secret env var
|
||||
|
||||
```bash
|
||||
export DB_PASSWORD="op://app-prod/db/password"
|
||||
op run -- sh -c '[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD is set" || echo "DB_PASSWORD missing"'
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never print raw secrets back to user unless they explicitly request the value.
|
||||
- Prefer `op run` / `op inject` instead of writing secrets into files.
|
||||
- If command fails with "account is not signed in", run `op signin` again in the same tmux session.
|
||||
- If desktop app integration is unavailable (headless/CI), use service account token flow.
|
||||
|
||||
## CI / Headless note
|
||||
|
||||
For non-interactive use, authenticate with `OP_SERVICE_ACCOUNT_TOKEN` and avoid interactive `op signin`.
|
||||
Service accounts require CLI v2.18.0+.
|
||||
|
||||
## References
|
||||
|
||||
- `references/get-started.md`
|
||||
- `references/cli-examples.md`
|
||||
- https://developer.1password.com/docs/cli/
|
||||
- https://developer.1password.com/docs/service-accounts/
|
||||
@@ -1,31 +0,0 @@
|
||||
# op CLI examples
|
||||
|
||||
## Sign-in and identity
|
||||
|
||||
```bash
|
||||
op signin
|
||||
op signin --account my.1password.com
|
||||
op whoami
|
||||
op account list
|
||||
```
|
||||
|
||||
## Read secrets
|
||||
|
||||
```bash
|
||||
op read "op://app-prod/db/password"
|
||||
op read "op://app-prod/npm/one-time password?attribute=otp"
|
||||
```
|
||||
|
||||
## Inject secrets
|
||||
|
||||
```bash
|
||||
echo "api_key: {{ op://app-prod/openai/api key }}" | op inject
|
||||
op inject -i config.tpl.yml -o config.yml
|
||||
```
|
||||
|
||||
## Run command with secrets
|
||||
|
||||
```bash
|
||||
export DB_PASSWORD="op://app-prod/db/password"
|
||||
op run -- sh -c '[ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD is set"'
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
# 1Password CLI get-started (summary)
|
||||
|
||||
Official docs: https://developer.1password.com/docs/cli/get-started/
|
||||
|
||||
## Core flow
|
||||
|
||||
1. Install `op` CLI.
|
||||
2. Enable desktop app integration in 1Password app.
|
||||
3. Unlock app.
|
||||
4. Run `op signin` and approve prompt.
|
||||
5. Verify with `op whoami`.
|
||||
|
||||
## Multiple accounts
|
||||
|
||||
- Use `op signin --account <subdomain.1password.com>`
|
||||
- Or set `OP_ACCOUNT`
|
||||
|
||||
## Non-interactive / automation
|
||||
|
||||
- Use service accounts and `OP_SERVICE_ACCOUNT_TOKEN`
|
||||
- Prefer `op run` and `op inject` for runtime secret handling
|
||||
@@ -1,3 +0,0 @@
|
||||
# Security
|
||||
|
||||
Skills for secrets management, credential handling, and security tooling integrations.
|
||||
+1
-5
@@ -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",
|
||||
@@ -55,7 +54,6 @@ pty = [
|
||||
honcho = ["honcho-ai>=2.0.1"]
|
||||
mcp = ["mcp>=1.2.0"]
|
||||
homeassistant = ["aiohttp>=3.9.0"]
|
||||
acp = ["agent-client-protocol>=0.8.1,<1.0"]
|
||||
rl = [
|
||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
|
||||
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git",
|
||||
@@ -77,19 +75,17 @@ all = [
|
||||
"hermes-agent[honcho]",
|
||||
"hermes-agent[mcp]",
|
||||
"hermes-agent[homeassistant]",
|
||||
"hermes-agent[acp]",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
hermes = "hermes_cli.main:main"
|
||||
hermes-agent = "run_agent:main"
|
||||
hermes-acp = "acp_adapter.entry:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "mini_swe_runner", "rl_cli", "utils"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration", "acp_adapter"]
|
||||
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
+19
-99
@@ -202,32 +202,6 @@ _NEVER_PARALLEL_TOOLS = frozenset({"clarify"})
|
||||
_MAX_TOOL_WORKERS = 8
|
||||
|
||||
|
||||
def _inject_honcho_turn_context(content, turn_context: str):
|
||||
"""Append Honcho recall to the current-turn user message without mutating history.
|
||||
|
||||
The returned content is sent to the API for this turn only. Keeping Honcho
|
||||
recall out of the system prompt preserves the stable cache prefix while
|
||||
still giving the model continuity context.
|
||||
"""
|
||||
if not turn_context:
|
||||
return content
|
||||
|
||||
note = (
|
||||
"[System note: The following Honcho memory was retrieved from prior "
|
||||
"sessions. It is continuity context for this turn only, not new user "
|
||||
"input.]\n\n"
|
||||
f"{turn_context}"
|
||||
)
|
||||
|
||||
if isinstance(content, list):
|
||||
return list(content) + [{"type": "text", "text": note}]
|
||||
|
||||
text = "" if content is None else str(content)
|
||||
if not text.strip():
|
||||
return note
|
||||
return f"{text}\n\n{note}"
|
||||
|
||||
|
||||
class AIAgent:
|
||||
"""
|
||||
AI Agent with tool calling capabilities.
|
||||
@@ -407,30 +381,19 @@ class AIAgent:
|
||||
|
||||
# Persistent error log -- always writes WARNING+ to ~/.hermes/logs/errors.log
|
||||
# so tool failures, API errors, etc. are inspectable after the fact.
|
||||
# In gateway mode, each incoming message creates a new AIAgent instance,
|
||||
# while the root logger is process-global. Re-adding the same errors.log
|
||||
# handler would cause each warning/error line to be written multiple times.
|
||||
from agent.redact import RedactingFormatter
|
||||
_error_log_dir = Path.home() / ".hermes" / "logs"
|
||||
_error_log_dir.mkdir(parents=True, exist_ok=True)
|
||||
_error_log_path = _error_log_dir / "errors.log"
|
||||
from logging.handlers import RotatingFileHandler
|
||||
root_logger = logging.getLogger()
|
||||
error_log_dir = _hermes_home / "logs"
|
||||
error_log_path = error_log_dir / "errors.log"
|
||||
resolved_error_log_path = error_log_path.resolve()
|
||||
has_errors_log_handler = any(
|
||||
isinstance(handler, RotatingFileHandler)
|
||||
and Path(getattr(handler, "baseFilename", "")).resolve() == resolved_error_log_path
|
||||
for handler in root_logger.handlers
|
||||
_error_file_handler = RotatingFileHandler(
|
||||
_error_log_path, maxBytes=2 * 1024 * 1024, backupCount=2,
|
||||
)
|
||||
if not has_errors_log_handler:
|
||||
from agent.redact import RedactingFormatter
|
||||
error_log_dir.mkdir(parents=True, exist_ok=True)
|
||||
error_file_handler = RotatingFileHandler(
|
||||
error_log_path, maxBytes=2 * 1024 * 1024, backupCount=2,
|
||||
)
|
||||
error_file_handler.setLevel(logging.WARNING)
|
||||
error_file_handler.setFormatter(RedactingFormatter(
|
||||
'%(asctime)s %(levelname)s %(name)s: %(message)s',
|
||||
))
|
||||
root_logger.addHandler(error_file_handler)
|
||||
_error_file_handler.setLevel(logging.WARNING)
|
||||
_error_file_handler.setFormatter(RedactingFormatter(
|
||||
'%(asctime)s %(levelname)s %(name)s: %(message)s',
|
||||
))
|
||||
logging.getLogger().addHandler(_error_file_handler)
|
||||
|
||||
if self.verbose_logging:
|
||||
logging.basicConfig(
|
||||
@@ -2748,42 +2711,6 @@ class AIAgent:
|
||||
|
||||
return kwargs
|
||||
|
||||
sanitized_messages = api_messages
|
||||
needs_sanitization = False
|
||||
for msg in api_messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if "codex_reasoning_items" in msg:
|
||||
needs_sanitization = True
|
||||
break
|
||||
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list):
|
||||
for tool_call in tool_calls:
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
if "call_id" in tool_call or "response_item_id" in tool_call:
|
||||
needs_sanitization = True
|
||||
break
|
||||
if needs_sanitization:
|
||||
break
|
||||
|
||||
if needs_sanitization:
|
||||
sanitized_messages = copy.deepcopy(api_messages)
|
||||
for msg in sanitized_messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
|
||||
# Codex-only replay state must not leak into strict chat-completions APIs.
|
||||
msg.pop("codex_reasoning_items", None)
|
||||
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if isinstance(tool_calls, list):
|
||||
for tool_call in tool_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
tool_call.pop("call_id", None)
|
||||
tool_call.pop("response_item_id", None)
|
||||
|
||||
provider_preferences = {}
|
||||
if self.providers_allowed:
|
||||
provider_preferences["only"] = self.providers_allowed
|
||||
@@ -2800,9 +2727,9 @@ class AIAgent:
|
||||
|
||||
api_kwargs = {
|
||||
"model": self.model,
|
||||
"messages": sanitized_messages,
|
||||
"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:
|
||||
@@ -3982,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")
|
||||
@@ -4004,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 ''}'")
|
||||
@@ -4154,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":
|
||||
@@ -4189,11 +4109,11 @@ class AIAgent:
|
||||
|
||||
# Build the final system message: cached prompt + ephemeral system prompt.
|
||||
# Ephemeral additions are API-call-time only (not persisted to session DB).
|
||||
# Honcho later-turn recall is intentionally kept OUT of the system prompt
|
||||
# so the stable cache prefix remains unchanged.
|
||||
effective_system = active_system_prompt or ""
|
||||
if self.ephemeral_system_prompt:
|
||||
effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip()
|
||||
if self._honcho_turn_context:
|
||||
effective_system = (effective_system + "\n\n" + self._honcho_turn_context).strip()
|
||||
if effective_system:
|
||||
api_messages = [{"role": "system", "content": effective_system}] + api_messages
|
||||
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
---
|
||||
name: opencode
|
||||
description: Delegate coding tasks to OpenCode CLI agent for feature implementation, refactoring, PR review, and long-running autonomous sessions. Requires the opencode CLI installed and authenticated.
|
||||
version: 1.2.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Coding-Agent, OpenCode, Autonomous, Refactoring, Code-Review]
|
||||
related_skills: [claude-code, codex, hermes-agent]
|
||||
---
|
||||
|
||||
# OpenCode CLI
|
||||
|
||||
Use [OpenCode](https://opencode.ai) as an autonomous coding worker orchestrated by Hermes terminal/process tools. OpenCode is a provider-agnostic, open-source AI coding agent with a TUI and CLI.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User explicitly asks to use OpenCode
|
||||
- You want an external coding agent to implement/refactor/review code
|
||||
- You need long-running coding sessions with progress checks
|
||||
- You want parallel task execution in isolated workdirs/worktrees
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenCode installed: `npm i -g opencode-ai@latest` or `brew install anomalyco/tap/opencode`
|
||||
- Auth configured: `opencode auth login` or set provider env vars (OPENROUTER_API_KEY, etc.)
|
||||
- Verify: `opencode auth list` should show at least one provider
|
||||
- Git repository for code tasks (recommended)
|
||||
- `pty=true` for interactive TUI sessions
|
||||
|
||||
## Binary Resolution (Important)
|
||||
|
||||
Shell environments may resolve different OpenCode binaries. If behavior differs between your terminal and Hermes, check:
|
||||
|
||||
```
|
||||
terminal(command="which -a opencode")
|
||||
terminal(command="opencode --version")
|
||||
```
|
||||
|
||||
If needed, pin an explicit binary path:
|
||||
|
||||
```
|
||||
terminal(command="$HOME/.opencode/bin/opencode run '...'", workdir="~/project", pty=true)
|
||||
```
|
||||
|
||||
## One-Shot Tasks
|
||||
|
||||
Use `opencode run` for bounded, non-interactive tasks:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Add retry logic to API calls and update tests'", workdir="~/project")
|
||||
```
|
||||
|
||||
Attach context files with `-f`:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Review this config for security issues' -f config.yaml -f .env.example", workdir="~/project")
|
||||
```
|
||||
|
||||
Show model thinking with `--thinking`:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Debug why tests fail in CI' --thinking", workdir="~/project")
|
||||
```
|
||||
|
||||
Force a specific model:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Refactor auth module' --model openrouter/anthropic/claude-sonnet-4", workdir="~/project")
|
||||
```
|
||||
|
||||
## Interactive Sessions (Background)
|
||||
|
||||
For iterative work requiring multiple exchanges, start the TUI in background:
|
||||
|
||||
```
|
||||
terminal(command="opencode", workdir="~/project", background=true, pty=true)
|
||||
# Returns session_id
|
||||
|
||||
# Send a prompt
|
||||
process(action="submit", session_id="<id>", data="Implement OAuth refresh flow and add tests")
|
||||
|
||||
# Monitor progress
|
||||
process(action="poll", session_id="<id>")
|
||||
process(action="log", session_id="<id>")
|
||||
|
||||
# Send follow-up input
|
||||
process(action="submit", session_id="<id>", data="Now add error handling for token expiry")
|
||||
|
||||
# Exit cleanly — Ctrl+C
|
||||
process(action="write", session_id="<id>", data="\x03")
|
||||
# Or just kill the process
|
||||
process(action="kill", session_id="<id>")
|
||||
```
|
||||
|
||||
**Important:** Do NOT use `/exit` — it is not a valid OpenCode command and will open an agent selector dialog instead. Use Ctrl+C (`\x03`) or `process(action="kill")` to exit.
|
||||
|
||||
### TUI Keybindings
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Enter` | Submit message (press twice if needed) |
|
||||
| `Tab` | Switch between agents (build/plan) |
|
||||
| `Ctrl+P` | Open command palette |
|
||||
| `Ctrl+X L` | Switch session |
|
||||
| `Ctrl+X M` | Switch model |
|
||||
| `Ctrl+X N` | New session |
|
||||
| `Ctrl+X E` | Open editor |
|
||||
| `Ctrl+C` | Exit OpenCode |
|
||||
|
||||
### Resuming Sessions
|
||||
|
||||
After exiting, OpenCode prints a session ID. Resume with:
|
||||
|
||||
```
|
||||
terminal(command="opencode -c", workdir="~/project", background=true, pty=true) # Continue last session
|
||||
terminal(command="opencode -s ses_abc123", workdir="~/project", background=true, pty=true) # Specific session
|
||||
```
|
||||
|
||||
## Common Flags
|
||||
|
||||
| Flag | Use |
|
||||
|------|-----|
|
||||
| `run 'prompt'` | One-shot execution and exit |
|
||||
| `--continue` / `-c` | Continue the last OpenCode session |
|
||||
| `--session <id>` / `-s` | Continue a specific session |
|
||||
| `--agent <name>` | Choose OpenCode agent (build or plan) |
|
||||
| `--model provider/model` | Force specific model |
|
||||
| `--format json` | Machine-readable output/events |
|
||||
| `--file <path>` / `-f` | Attach file(s) to the message |
|
||||
| `--thinking` | Show model thinking blocks |
|
||||
| `--variant <level>` | Reasoning effort (high, max, minimal) |
|
||||
| `--title <name>` | Name the session |
|
||||
| `--attach <url>` | Connect to a running opencode server |
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Verify tool readiness:
|
||||
- `terminal(command="opencode --version")`
|
||||
- `terminal(command="opencode auth list")`
|
||||
2. For bounded tasks, use `opencode run '...'` (no pty needed).
|
||||
3. For iterative tasks, start `opencode` with `background=true, pty=true`.
|
||||
4. Monitor long tasks with `process(action="poll"|"log")`.
|
||||
5. If OpenCode asks for input, respond via `process(action="submit", ...)`.
|
||||
6. Exit with `process(action="write", data="\x03")` or `process(action="kill")`.
|
||||
7. Summarize file changes, test results, and next steps back to user.
|
||||
|
||||
## PR Review Workflow
|
||||
|
||||
OpenCode has a built-in PR command:
|
||||
|
||||
```
|
||||
terminal(command="opencode pr 42", workdir="~/project", pty=true)
|
||||
```
|
||||
|
||||
Or review in a temporary clone for isolation:
|
||||
|
||||
```
|
||||
terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && opencode run 'Review this PR vs main. Report bugs, security risks, test gaps, and style issues.' -f $(git diff origin/main --name-only | head -20 | tr '\n' ' ')", pty=true)
|
||||
```
|
||||
|
||||
## Parallel Work Pattern
|
||||
|
||||
Use separate workdirs/worktrees to avoid collisions:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Fix issue #101 and commit'", workdir="/tmp/issue-101", background=true, pty=true)
|
||||
terminal(command="opencode run 'Add parser regression tests and commit'", workdir="/tmp/issue-102", background=true, pty=true)
|
||||
process(action="list")
|
||||
```
|
||||
|
||||
## Session & Cost Management
|
||||
|
||||
List past sessions:
|
||||
|
||||
```
|
||||
terminal(command="opencode session list")
|
||||
```
|
||||
|
||||
Check token usage and costs:
|
||||
|
||||
```
|
||||
terminal(command="opencode stats")
|
||||
terminal(command="opencode stats --days 7 --models anthropic/claude-sonnet-4")
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Interactive `opencode` (TUI) sessions require `pty=true`. The `opencode run` command does NOT need pty.
|
||||
- `/exit` is NOT a valid command — it opens an agent selector. Use Ctrl+C to exit the TUI.
|
||||
- PATH mismatch can select the wrong OpenCode binary/model config.
|
||||
- If OpenCode appears stuck, inspect logs before killing:
|
||||
- `process(action="log", session_id="<id>")`
|
||||
- Avoid sharing one working directory across parallel OpenCode sessions.
|
||||
- Enter may need to be pressed twice to submit in the TUI (once to finalize text, once to send).
|
||||
|
||||
## Verification
|
||||
|
||||
Smoke test:
|
||||
|
||||
```
|
||||
terminal(command="opencode run 'Respond with exactly: OPENCODE_SMOKE_OK'")
|
||||
```
|
||||
|
||||
Success criteria:
|
||||
- Output includes `OPENCODE_SMOKE_OK`
|
||||
- Command exits without provider/model errors
|
||||
- For code tasks: expected files changed and tests pass
|
||||
|
||||
## Rules
|
||||
|
||||
1. Prefer `opencode run` for one-shot automation — it's simpler and doesn't need pty.
|
||||
2. Use interactive background mode only when iteration is needed.
|
||||
3. Always scope OpenCode sessions to a single repo/workdir.
|
||||
4. For long tasks, provide progress updates from `process` logs.
|
||||
5. Report concrete outcomes (files changed, tests, remaining risks).
|
||||
6. Exit interactive sessions with Ctrl+C or kill, never `/exit`.
|
||||
@@ -1,249 +0,0 @@
|
||||
# ☤ ASCII Video
|
||||
|
||||
Renders any content as colored ASCII character video. Audio, video, images, text, or pure math in, MP4/GIF/PNG sequence out. Full RGB color per character cell, 1080p 24fps default. No GPU.
|
||||
|
||||
Built for [Hermes Agent](https://github.com/NousResearch/hermes-agent). Usable in any coding agent.
|
||||
|
||||
## What this is
|
||||
|
||||
A skill that teaches an agent how to build single-file Python renderers for ASCII video from scratch. The agent gets the full pipeline: grid system, font rasterization, effect library, shader chain, audio analysis, parallel encoding. It writes the renderer, runs it, gets video.
|
||||
|
||||
The output is actual video. Not terminal escape codes. Frames are computed as grids of colored characters, composited onto pixel canvases with pre-rasterized font bitmaps, post-processed through shaders, piped to ffmpeg.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Input | Output |
|
||||
|------|-------|--------|
|
||||
| Video-to-ASCII | A video file | ASCII recreation of the footage |
|
||||
| Audio-reactive | An audio file | Visuals driven by frequency bands, beats, energy |
|
||||
| Generative | Nothing | Procedural animation from math |
|
||||
| Hybrid | Video + audio | ASCII video with audio-reactive overlays |
|
||||
| Lyrics/text | Audio + timed text (SRT) | Karaoke-style text with effects |
|
||||
| TTS narration | Text quotes + API key | Narrated video with typewriter text and generated speech |
|
||||
|
||||
## Pipeline
|
||||
|
||||
Every mode follows the same 6-stage path:
|
||||
|
||||
```
|
||||
INPUT --> ANALYZE --> SCENE_FN --> TONEMAP --> SHADE --> ENCODE
|
||||
```
|
||||
|
||||
1. **Input** loads source material (or nothing for generative).
|
||||
2. **Analyze** extracts per-frame features. Audio gets 6-band FFT, RMS, spectral centroid, flatness, flux, beat detection with exponential decay. Video gets luminance, edges, motion.
|
||||
3. **Scene function** returns a pixel canvas directly. Composes multiple character grids at different densities, value/hue fields, pixel blend modes. This is where the visuals happen.
|
||||
4. **Tonemap** does adaptive percentile-based brightness normalization with per-scene gamma. ASCII on black is inherently dark. Linear multipliers don't work. This does.
|
||||
5. **Shade** runs a `ShaderChain` (38 composable shaders) plus a `FeedbackBuffer` for temporal recursion with spatial transforms.
|
||||
6. **Encode** pipes raw RGB frames to ffmpeg for H.264 encoding. Segments concatenated, audio muxed.
|
||||
|
||||
## Grid system
|
||||
|
||||
Characters render on fixed-size grids. Layer multiple densities for depth.
|
||||
|
||||
| Size | Font | Grid at 1080p | Use |
|
||||
|------|------|---------------|-----|
|
||||
| xs | 8px | 400x108 | Ultra-dense data fields |
|
||||
| sm | 10px | 320x83 | Rain, starfields |
|
||||
| md | 16px | 192x56 | Default balanced |
|
||||
| lg | 20px | 160x45 | Readable text |
|
||||
| xl | 24px | 137x37 | Large titles |
|
||||
| xxl | 40px | 80x22 | Giant minimal |
|
||||
|
||||
Rendering the same scene on `sm` and `lg` then screen-blending them creates natural texture interference. Fine detail shows through gaps in coarse characters. Most scenes use two or three grids.
|
||||
|
||||
## Character palettes (20+)
|
||||
|
||||
Each sorted dark-to-bright, each a different visual texture. Validated against the font at init so broken glyphs get dropped silently.
|
||||
|
||||
| Family | Examples | Feel |
|
||||
|--------|----------|------|
|
||||
| Density ramps | ` .:-=+#@█` | Classic ASCII art gradient |
|
||||
| Block elements | ` ░▒▓█▄▀▐▌` | Chunky, digital |
|
||||
| Braille | ` ⠁⠂⠃...⠿` | Fine-grained pointillism |
|
||||
| Dots | ` ⋅∘∙●◉◎` | Smooth, organic |
|
||||
| Stars | ` ·✧✦✩✨★✶` | Sparkle, celestial |
|
||||
| Half-fills | ` ◔◑◕◐◒◓◖◗◙` | Directional fill progression |
|
||||
| Crosshatch | ` ▣▤▥▦▧▨▩` | Hatched density ramp |
|
||||
| Math | ` ·∘∙•°±×÷≈≠≡∞∫∑Ω` | Scientific, abstract |
|
||||
| Box drawing | ` ─│┌┐└┘├┤┬┴┼` | Structural, circuit-like |
|
||||
| Katakana | ` ·ヲァィゥェォャュ...` | Matrix rain |
|
||||
| Greek | ` αβγδεζηθ...ω` | Classical, academic |
|
||||
| Runes | ` ᚠᚢᚦᚱᚷᛁᛇᛒᛖᛚᛞᛟ` | Mystical, ancient |
|
||||
| Alchemical | ` ☉☽♀♂♃♄♅♆♇` | Esoteric |
|
||||
| Arrows | ` ←↑→↓↔↕↖↗↘↙` | Directional, kinetic |
|
||||
| Music | ` ♪♫♬♩♭♮♯○●` | Musical |
|
||||
| Project-specific | ` .·~=≈∞⚡☿✦★⊕◊◆▲▼●■` | Themed per project |
|
||||
|
||||
Custom palettes are built per project to match the content.
|
||||
|
||||
## Color strategies
|
||||
|
||||
| Strategy | How it maps hue | Good for |
|
||||
|----------|----------------|----------|
|
||||
| Angle-mapped | Position angle from center | Rainbow radial effects |
|
||||
| Distance-mapped | Distance from center | Depth, tunnels |
|
||||
| Frequency-mapped | Audio spectral centroid | Timbral shifting |
|
||||
| Value-mapped | Brightness level | Heat maps, fire |
|
||||
| Time-cycled | Slow rotation over time | Ambient, chill |
|
||||
| Source-sampled | Original video pixel colors | Video-to-ASCII |
|
||||
| Palette-indexed | Discrete lookup table | Retro, flat graphic |
|
||||
| Temperature | Warm-to-cool blend | Emotional tone |
|
||||
| Complementary | Hue + opposite | Bold, dramatic |
|
||||
| Triadic | Three equidistant hues | Psychedelic, vibrant |
|
||||
| Analogous | Neighboring hues | Harmonious, subtle |
|
||||
| Monochrome | Fixed hue, vary S/V | Noir, focused |
|
||||
|
||||
Plus 10 discrete RGB palettes (neon, pastel, cyberpunk, vaporwave, earth, ice, blood, forest, mono-green, mono-amber).
|
||||
|
||||
## Effects
|
||||
|
||||
### Backgrounds
|
||||
|
||||
| Effect | Description | Parameters |
|
||||
|--------|-------------|------------|
|
||||
| Sine field | Layered sinusoidal interference | freq, speed, octave count |
|
||||
| Smooth noise | Multi-octave Perlin approximation | octaves, scale |
|
||||
| Cellular | Voronoi-like moving cells | n_centers, speed |
|
||||
| Noise/static | Random per-cell flicker | density |
|
||||
| Video source | Downsampled video frame | brightness |
|
||||
|
||||
### Primary effects
|
||||
|
||||
| Effect | Description |
|
||||
|--------|-------------|
|
||||
| Concentric rings | Bass-driven pulsing rings with wobble |
|
||||
| Radial rays | Spoke pattern, beat-triggered |
|
||||
| Spiral arms | Logarithmic spiral, configurable arm count/tightness |
|
||||
| Tunnel | Infinite depth perspective |
|
||||
| Vortex | Twisting radial distortion |
|
||||
| Frequency waves | Per-band sine waves at different heights |
|
||||
| Interference | Overlapping sine waves creating moire |
|
||||
| Aurora | Horizontal flowing bands |
|
||||
| Ripple | Point-source concentric waves |
|
||||
| Fire columns | Rising flames with heat-color gradient |
|
||||
| Spectrum bars | Mirrored frequency visualizer |
|
||||
| Waveform | Oscilloscope-style trace |
|
||||
|
||||
### Particle systems
|
||||
|
||||
| Type | Behavior | Character sets |
|
||||
|------|----------|---------------|
|
||||
| Explosion | Beat-triggered radial burst | `*+#@⚡✦★█▓` |
|
||||
| Sparks | Short-lived bright dots | `·•●★✶*+` |
|
||||
| Embers | Rising from bottom with drift | `·•●★` |
|
||||
| Snow | Falling with wind sway | `❄❅❆·•*○` |
|
||||
| Rain | Fast vertical streaks | `│┃║/\` |
|
||||
| Bubbles | Rising, expanding | `○◎◉●∘∙°` |
|
||||
| Data | Falling hex/binary | `01{}[]<>/\` |
|
||||
| Runes | Mystical floating symbols | `ᚠᚢᚦᚱᚷᛁ✦★` |
|
||||
| Orbit | Circular/elliptical paths | `·•●` |
|
||||
| Gravity well | Attracted to point sources | configurable |
|
||||
| Dissolve | Spread across screen, fade | configurable |
|
||||
| Starfield | 3D projected, approaching | configurable |
|
||||
|
||||
## Shader pipeline
|
||||
|
||||
38 composable shaders, applied to the pixel canvas after character rendering. Configurable per section.
|
||||
|
||||
| Category | Shaders |
|
||||
|----------|---------|
|
||||
| Geometry | CRT barrel, pixelate, wave distort, displacement map, kaleidoscope, mirror (h/v/quad/diag) |
|
||||
| Channel | Chromatic aberration (beat-reactive), channel shift, channel swap, RGB split radial |
|
||||
| Color | Invert, posterize, threshold, solarize, hue rotate, saturation, color grade, color wobble, color ramp |
|
||||
| Glow/Blur | Bloom, edge glow, soft focus, radial blur |
|
||||
| Noise | Film grain (beat-reactive), static noise |
|
||||
| Lines/Patterns | Scanlines, halftone |
|
||||
| Tone | Vignette, contrast, gamma, levels, brightness |
|
||||
| Glitch/Data | Glitch bands (beat-reactive), block glitch, pixel sort, data bend |
|
||||
|
||||
12 color tint presets: warm, cool, matrix green, amber, sepia, neon pink, ice, blood, forest, void, sunset, neutral.
|
||||
|
||||
7 mood presets for common shader combos:
|
||||
|
||||
| Mood | Shaders |
|
||||
|------|---------|
|
||||
| Retro terminal | CRT + scanlines + grain + amber/green tint |
|
||||
| Clean modern | Light bloom + subtle vignette |
|
||||
| Glitch art | Heavy chromatic + glitch bands + color wobble |
|
||||
| Cinematic | Bloom + vignette + grain + color grade |
|
||||
| Dreamy | Heavy bloom + soft focus + color wobble |
|
||||
| Harsh/industrial | High contrast + grain + scanlines, no bloom |
|
||||
| Psychedelic | Color wobble + chromatic + kaleidoscope mirror |
|
||||
|
||||
## Blend modes and composition
|
||||
|
||||
20 pixel blend modes for layering canvases: normal, add, subtract, multiply, screen, overlay, softlight, hardlight, difference, exclusion, colordodge, colorburn, linearlight, vividlight, pin_light, hard_mix, lighten, darken, grain_extract, grain_merge.
|
||||
|
||||
Mirror modes: horizontal, vertical, quad, diagonal, kaleidoscope (6-fold radial). Beat-triggered.
|
||||
|
||||
Transitions: crossfade, directional wipe, radial wipe, dissolve, glitch cut.
|
||||
|
||||
## Hardware adaptation
|
||||
|
||||
Auto-detects CPU count, RAM, platform, ffmpeg. Adapts worker count, resolution, FPS.
|
||||
|
||||
| Profile | Resolution | FPS | When |
|
||||
|---------|-----------|-----|------|
|
||||
| `draft` | 960x540 | 12 | Check timing/layout |
|
||||
| `preview` | 1280x720 | 15 | Review effects |
|
||||
| `production` | 1920x1080 | 24 | Final output |
|
||||
| `max` | 3840x2160 | 30 | Ultra-high |
|
||||
| `auto` | Detected | 24 | Adapts to hardware + duration |
|
||||
|
||||
`auto` estimates render time and downgrades if it would take over an hour. Low-memory systems drop to 720p automatically.
|
||||
|
||||
### Render times (1080p 24fps, ~180ms/frame/worker)
|
||||
|
||||
| Duration | 4 workers | 8 workers | 16 workers |
|
||||
|----------|-----------|-----------|------------|
|
||||
| 30s | ~3 min | ~2 min | ~1 min |
|
||||
| 2 min | ~13 min | ~7 min | ~4 min |
|
||||
| 5 min | ~33 min | ~17 min | ~9 min |
|
||||
| 10 min | ~65 min | ~33 min | ~17 min |
|
||||
|
||||
720p roughly halves these. 4K roughly quadruples them.
|
||||
|
||||
## Known pitfalls
|
||||
|
||||
**Brightness.** ASCII characters are small bright dots on black. Most frame pixels are background. Linear `* N` multipliers clip highlights and wash out. Use `tonemap()` with per-scene gamma instead. Default gamma 0.75, solarize scenes 0.55, posterize 0.50.
|
||||
|
||||
**Render bottleneck.** The per-cell Python loop compositing font bitmaps runs at ~100-150ms/frame. Unavoidable without Cython/C. Everything else must be vectorized numpy. Python for-loops over rows/cols in effect functions will tank performance.
|
||||
|
||||
**ffmpeg deadlock.** Never `stderr=subprocess.PIPE` on long-running encodes. Buffer fills at ~64KB, process hangs. Redirect stderr to a file.
|
||||
|
||||
**Font cell height.** Pillow's `textbbox()` returns wrong height on macOS. Use `font.getmetrics()` for `ascent + descent`.
|
||||
|
||||
**Font compatibility.** Not all Unicode renders in all fonts. Palettes validated at init, blank glyphs silently removed.
|
||||
|
||||
## Requirements
|
||||
|
||||
◆ Python 3.10+
|
||||
◆ NumPy, Pillow, SciPy (audio modes)
|
||||
◆ ffmpeg on PATH
|
||||
◆ A monospace font (Menlo, Courier, Monaco, auto-detected)
|
||||
◆ Optional: OpenCV, ElevenLabs API key (TTS mode)
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
├── SKILL.md # Modes, workflow, creative direction
|
||||
├── README.md # This file
|
||||
└── references/
|
||||
├── architecture.md # Grid system, fonts, palettes, color, _render_vf()
|
||||
├── effects.md # Value fields, hue fields, backgrounds, particles
|
||||
├── shaders.md # 38 shaders, ShaderChain, tint presets, transitions
|
||||
├── composition.md # Blend modes, multi-grid, tonemap, FeedbackBuffer
|
||||
├── scenes.md # Scene protocol, SCENES table, render_clip(), examples
|
||||
├── design-patterns.md # Layer hierarchy, directional arcs, scene concepts
|
||||
├── inputs.md # Audio analysis, video sampling, text, TTS
|
||||
├── optimization.md # Hardware detection, vectorized patterns, parallelism
|
||||
└── troubleshooting.md # Broadcasting traps, blend pitfalls, diagnostics
|
||||
```
|
||||
|
||||
## Projects built with this
|
||||
|
||||
✦ 85-second highlight reel. 15 scenes (14×5s + 15s crescendo finale), randomized order, directional parameter arcs, layer hierarchy composition. Showcases the full effect vocabulary: fBM, voronoi fragmentation, reaction-diffusion, cellular automata, dual counter-rotating spirals, wave collision, domain warping, tunnel descent, kaleidoscope symmetry, boid flocking, fire simulation, glitch corruption, and a 7-layer crescendo buildup.
|
||||
|
||||
✦ Audio-reactive music visualizer. 3.5 min, 8 sections with distinct effects, beat-triggered particles and glitch, cycling palettes.
|
||||
|
||||
✦ TTS narrated testimonial video. 23 quotes, per-quote ElevenLabs voices, background music at 15% wide stereo, per-clip re-rendering for iterative editing.
|
||||
@@ -59,20 +59,16 @@ Every mode follows the same 6-stage pipeline. See `references/architecture.md` f
|
||||
| Dimension | Options | Reference |
|
||||
|-----------|---------|-----------|
|
||||
| **Character palette** | Density ramps, block elements, symbols, scripts (katakana, Greek, runes, braille), dots, project-specific | `architecture.md` § Character Palettes |
|
||||
| **Color strategy** | HSV (angle/distance/time/value mapped), OKLAB/OKLCH (perceptually uniform), discrete RGB palettes, auto-generated harmony (complementary/triadic/analogous/tetradic), monochrome, temperature | `architecture.md` § Color System |
|
||||
| **Color strategy** | HSV (angle/distance/time/value mapped), discrete RGB palettes, monochrome, complementary, triadic, temperature | `architecture.md` § Color System |
|
||||
| **Color tint** | Warm, cool, amber, matrix green, neon pink, sepia, ice, blood, void, sunset | `shaders.md` § Color Grade |
|
||||
| **Background texture** | Sine fields, fBM noise, domain warp, voronoi cells, reaction-diffusion, cellular automata, video source | `effects.md` § Background Fills, Noise-Based Fields, Simulation-Based Fields |
|
||||
| **Primary effects** | Rings, spirals, tunnel, vortex, waves, interference, aurora, ripple, fire, strange attractors, SDFs (geometric shapes with smooth booleans) | `effects.md` § Radial / Wave / Fire / SDF-Based Fields |
|
||||
| **Particles** | Energy sparks, snow, rain, bubbles, runes, binary data, orbits, gravity wells, flocking boids, flow-field followers, trail-drawing particles | `effects.md` § Particle Systems |
|
||||
| **Background texture** | Sine fields, noise, smooth noise, cellular/voronoi, video source | `effects.md` § Background Fills |
|
||||
| **Primary effects** | Rings, spirals, tunnel, vortex, waves, interference, aurora, ripple, fire | `effects.md` § Radial / Wave / Fire |
|
||||
| **Particles** | Energy sparks, snow, rain, bubbles, runes, binary data, orbits, gravity wells | `effects.md` § Particle Systems |
|
||||
| **Shader mood** | Retro CRT, clean modern, glitch art, cinematic, dreamy, harsh industrial, psychedelic | `shaders.md` § Design Philosophy |
|
||||
| **Grid density** | xs(8px) through xxl(40px), mixed per layer | `architecture.md` § Grid System |
|
||||
| **Font** | Menlo, Monaco, Courier, SF Mono, JetBrains Mono, Fira Code, IBM Plex | `architecture.md` § Font Selection |
|
||||
| **Coordinate space** | Cartesian, polar, tiled, rotated, skewed, fisheye, twisted, Möbius, domain-warped | `effects.md` § Coordinate Transforms |
|
||||
| **Mirror mode** | None, horizontal, vertical, quad, diagonal, kaleidoscope | `shaders.md` § Mirror Effects |
|
||||
| **Masking** | Circle, rect, ring, gradient, text stencil, value-field-as-mask, animated iris/wipe/dissolve | `composition.md` § Masking |
|
||||
| **Temporal motion** | Static, audio-reactive, eased keyframes, morphing between fields, temporal noise (smooth in-place evolution) | `effects.md` § Temporal Coherence |
|
||||
| **Transition style** | Crossfade, wipe (directional/radial), dissolve, glitch cut, iris open/close, mask-based reveal | `shaders.md` § Transitions, `composition.md` § Animated Masks |
|
||||
| **Aspect ratio** | Landscape (16:9), portrait (9:16), square (1:1), ultrawide (21:9) | `architecture.md` § Resolution Presets |
|
||||
| **Transition style** | Crossfade, wipe (directional/radial), dissolve, glitch cut | `shaders.md` § Transitions |
|
||||
|
||||
### Per-Section Variation
|
||||
|
||||
@@ -99,11 +95,10 @@ Establish with user:
|
||||
- **Input source** — file path, format, duration
|
||||
- **Mode** — which of the 6 modes above
|
||||
- **Sections** — time-mapped style changes (timestamps → effect names)
|
||||
- **Resolution** — landscape 1920x1080 (default), portrait 1080x1920, square 1080x1080 @ 24fps; GIFs typically 640x360 @ 15fps
|
||||
- **Resolution** — default 1920x1080 @ 24fps; GIFs typically 640x360 @ 15fps
|
||||
- **Style direction** — dense/sparse, bright/dark, chaotic/minimal, color palette
|
||||
- **Text/branding** — easter eggs, overlays, credits, themed character sets
|
||||
- **Output format** — MP4 (default), GIF, PNG sequence
|
||||
- **Aspect ratio** — landscape (16:9), portrait (9:16 for TikTok/Reels/Stories), square (1:1 for IG feed)
|
||||
|
||||
### Step 2: Detect Hardware and Set Quality
|
||||
|
||||
@@ -245,12 +240,11 @@ Image.fromarray(canvas).save("test.png")
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `references/architecture.md` | Grid system (landscape/portrait/square resolution presets), font selection, character palettes (library of 20+), color system (HSV + OKLAB/OKLCH + discrete RGB + color harmony generation + perceptual gradient interpolation), `_render_vf()` helper, compositing, v2 effect function contract |
|
||||
| `references/architecture.md` | Grid system, font selection, character palettes (library of 20+), color system (HSV + discrete RGB), `_render_vf()` helper, compositing, v2 effect function contract |
|
||||
| `references/inputs.md` | All input sources: audio analysis, video sampling, image conversion, text/lyrics, TTS integration (ElevenLabs, voice assignment, audio mixing) |
|
||||
| `references/effects.md` | Effect building blocks: 20+ value field generators (trig, noise/fBM, domain warp, voronoi, reaction-diffusion, cellular automata, strange attractors, SDFs), 8 hue field generators, coordinate transforms (rotate/tile/polar/Möbius), temporal coherence (easing, keyframes, morphing), radial/wave/fire effects, advanced particles (flocking, flow fields, trails), composing guide |
|
||||
| `references/effects.md` | Effect building blocks: 12 value field generators (`vf_sinefield` through `vf_noise_static`), 8 hue field generators (`hf_fixed` through `hf_plasma`), radial/wave/fire effects, particles, composing guide |
|
||||
| `references/shaders.md` | 38 shader implementations (geometry, channel, color, glow, noise, pattern, tone, glitch, mirror), `ShaderChain` class, full `_apply_shader_step()` dispatch, audio-reactive scaling, transitions, tint presets |
|
||||
| `references/composition.md` | **v2 core**: pixel blend modes (20 modes with implementations), multi-grid composition, `_render_vf()` helper, adaptive `tonemap()`, per-scene gamma, `FeedbackBuffer` with spatial transforms, `PixelBlendStack`, masking/stencil system (shape masks, text stencils, animated masks, boolean ops) |
|
||||
| `references/scenes.md` | **v2 scene protocol**: scene function contract (local time convention), `Renderer` class, `SCENES` table structure, `render_clip()` loop, beat-synced cutting, parallel rendering + pickling constraints, 4 complete scene examples, scene design checklist |
|
||||
| `references/design-patterns.md` | **Scene composition patterns**: layer hierarchy (bg/content/accent), directional parameter arcs vs oscillation, scene concepts and visual metaphors, counter-rotating dual systems, wave collision, progressive fragmentation, entropy/consumption, staggered layer entry (crescendo), scene ordering |
|
||||
| `references/composition.md` | **v2 core**: pixel blend modes (20 modes with implementations), multi-grid composition, `_render_vf()` helper, adaptive `tonemap()`, per-scene gamma, `FeedbackBuffer` with spatial transforms, `PixelBlendStack` |
|
||||
| `references/scenes.md` | **v2 scene protocol**: scene function contract, `Renderer` class, `SCENES` table structure, `render_clip()` loop, beat-synced cutting, parallel rendering + pickling constraints, 4 complete scene examples, scene design checklist |
|
||||
| `references/troubleshooting.md` | NumPy broadcasting traps, blend mode pitfalls, multiprocessing/pickling issues, brightness diagnostics, ffmpeg deadlocks, font issues, performance bottlenecks, common mistakes |
|
||||
| `references/optimization.md` | Hardware detection, adaptive quality profiles (draft/preview/production/max), CLI integration, vectorized effect patterns, parallel rendering, memory management |
|
||||
|
||||
@@ -1,43 +1,12 @@
|
||||
# Architecture Reference
|
||||
|
||||
**Cross-references:**
|
||||
- Effect building blocks (value fields, noise, SDFs, particles): `effects.md`
|
||||
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
|
||||
- Scene protocol, render_clip, SCENES table: `scenes.md`
|
||||
- Shader pipeline, feedback buffer, output encoding: `shaders.md`
|
||||
- Complete scene examples: `examples.md`
|
||||
- Input sources (audio analysis, video, TTS): `inputs.md`
|
||||
- Performance tuning, hardware detection: `optimization.md`
|
||||
- Common bugs (broadcasting, font, encoding): `troubleshooting.md`
|
||||
|
||||
## Grid System
|
||||
|
||||
### Resolution Presets
|
||||
|
||||
```python
|
||||
RESOLUTION_PRESETS = {
|
||||
"landscape": (1920, 1080), # 16:9 — YouTube, default
|
||||
"portrait": (1080, 1920), # 9:16 — TikTok, Reels, Stories
|
||||
"square": (1080, 1080), # 1:1 — Instagram feed
|
||||
"ultrawide": (2560, 1080), # 21:9 — cinematic
|
||||
"landscape4k":(3840, 2160), # 16:9 — 4K
|
||||
"portrait4k": (2160, 3840), # 9:16 — 4K portrait
|
||||
}
|
||||
|
||||
def get_resolution(preset="landscape", custom=None):
|
||||
"""Returns (VW, VH) tuple."""
|
||||
if custom:
|
||||
return custom
|
||||
return RESOLUTION_PRESETS.get(preset, RESOLUTION_PRESETS["landscape"])
|
||||
```
|
||||
|
||||
### Multi-Density Grids
|
||||
|
||||
Pre-initialize multiple grid sizes. Switch per section for visual variety. Grid dimensions auto-compute from resolution:
|
||||
Pre-initialize multiple grid sizes. Switch per section for visual variety.
|
||||
|
||||
**Landscape (1920x1080):**
|
||||
|
||||
| Key | Font Size | Grid (cols x rows) | Use |
|
||||
| Key | Font Size | Grid (1920x1080) | Use |
|
||||
|-----|-----------|-------------------|-----|
|
||||
| xs | 8 | 400x108 | Ultra-dense data fields |
|
||||
| sm | 10 | 320x83 | Dense detail, rain, starfields |
|
||||
@@ -46,34 +15,7 @@ Pre-initialize multiple grid sizes. Switch per section for visual variety. Grid
|
||||
| xl | 24 | 137x37 | Short quotes, large titles |
|
||||
| xxl | 40 | 80x22 | Giant text, minimal |
|
||||
|
||||
**Portrait (1080x1920):**
|
||||
|
||||
| Key | Font Size | Grid (cols x rows) | Use |
|
||||
|-----|-----------|-------------------|-----|
|
||||
| xs | 8 | 225x192 | Ultra-dense, tall data columns |
|
||||
| sm | 10 | 180x148 | Dense detail, vertical rain |
|
||||
| md | 16 | 112x100 | Default balanced |
|
||||
| lg | 20 | 90x80 | Readable text (~30 chars/line centered) |
|
||||
| xl | 24 | 75x66 | Short quotes, stacked |
|
||||
| xxl | 40 | 45x39 | Giant text, minimal |
|
||||
|
||||
**Square (1080x1080):**
|
||||
|
||||
| Key | Font Size | Grid (cols x rows) | Use |
|
||||
|-----|-----------|-------------------|-----|
|
||||
| sm | 10 | 180x83 | Dense detail |
|
||||
| md | 16 | 112x56 | Default balanced |
|
||||
| lg | 20 | 90x45 | Readable text |
|
||||
|
||||
**Key differences in portrait mode:**
|
||||
- Fewer columns (90 at `lg` vs 160) — lines must be shorter or wrap
|
||||
- Many more rows (80 at `lg` vs 45) — vertical stacking is natural
|
||||
- Aspect ratio correction flips: `asp = cw / ch` still works but the visual emphasis is vertical
|
||||
- Radial effects appear as tall ellipses unless corrected
|
||||
- Vertical effects (rain, embers, fire columns) are naturally enhanced
|
||||
- Horizontal effects (spectrum bars, waveforms) need rotation or compression
|
||||
|
||||
**Grid sizing for text in portrait**: Use `lg` (20px) for 2-3 word lines. Max comfortable line length is ~25-30 chars. For longer quotes, break aggressively into many short lines stacked vertically — portrait has vertical space to spare. `xl` (24px) works for single words or very short phrases.
|
||||
**Grid sizing for text-heavy content**: When displaying readable text (quotes, lyrics, testimonials), use 20px (`lg`) as the primary grid. This gives 160 columns -- plenty for lines up to ~50 chars centered. For very short quotes (< 60 chars, <= 3 lines), 24px (`xl`) makes them more impactful. Only init the grids you actually use -- each grid pre-rasterizes all characters which costs ~0.3-0.5s.
|
||||
|
||||
Grid dimensions: `cols = VW // cell_width`, `rows = VH // cell_height`.
|
||||
|
||||
@@ -117,23 +59,7 @@ FONT_PREFS_LINUX = [
|
||||
("Noto Sans Mono", "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf"),
|
||||
("Ubuntu Mono", "/usr/share/fonts/truetype/ubuntu/UbuntuMono-R.ttf"),
|
||||
]
|
||||
FONT_PREFS_WINDOWS = [
|
||||
("Consolas", r"C:\Windows\Fonts\consola.ttf"),
|
||||
("Courier New", r"C:\Windows\Fonts\cour.ttf"),
|
||||
("Lucida Console", r"C:\Windows\Fonts\lucon.ttf"),
|
||||
("Cascadia Code", os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Windows\Fonts\CascadiaCode.ttf")),
|
||||
("Cascadia Mono", os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Windows\Fonts\CascadiaMono.ttf")),
|
||||
]
|
||||
|
||||
def _get_font_prefs():
|
||||
s = platform.system()
|
||||
if s == "Darwin":
|
||||
return FONT_PREFS_MACOS
|
||||
elif s == "Windows":
|
||||
return FONT_PREFS_WINDOWS
|
||||
return FONT_PREFS_LINUX
|
||||
|
||||
FONT_PREFS = _get_font_prefs()
|
||||
FONT_PREFS = FONT_PREFS_MACOS if platform.system() == "Darwin" else FONT_PREFS_LINUX
|
||||
```
|
||||
|
||||
**Multi-font rendering**: use different fonts for different layers (e.g., monospace for background, a bolder variant for overlay text). Each GridLayer owns its own font:
|
||||
@@ -151,8 +77,8 @@ Before initializing grids, gather all characters that need bitmap pre-rasterizat
|
||||
all_chars = set()
|
||||
for pal in [PAL_DEFAULT, PAL_DENSE, PAL_BLOCKS, PAL_RUNE, PAL_KATA,
|
||||
PAL_GREEK, PAL_MATH, PAL_DOTS, PAL_BRAILLE, PAL_STARS,
|
||||
PAL_HALFFILL, PAL_HATCH, PAL_BINARY, PAL_MUSIC, PAL_BOX,
|
||||
PAL_CIRCUIT, PAL_ARROWS, PAL_HERMES]: # ... all palettes used in project
|
||||
PAL_BINARY, PAL_MUSIC, PAL_BOX, PAL_CIRCUIT, PAL_ARROWS,
|
||||
PAL_HERMES]: # ... all palettes used in project
|
||||
all_chars.update(pal)
|
||||
# Add any overlay text characters
|
||||
all_chars.update("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,-:;!?/|")
|
||||
@@ -161,31 +87,21 @@ all_chars.discard(" ") # space is never rendered
|
||||
|
||||
### GridLayer Initialization
|
||||
|
||||
Each grid pre-computes coordinate arrays for vectorized effect math. The grid automatically adapts to any resolution (landscape, portrait, square):
|
||||
Each grid pre-computes coordinate arrays for vectorized effect math:
|
||||
|
||||
```python
|
||||
class GridLayer:
|
||||
def __init__(self, font_path, font_size, vw=None, vh=None):
|
||||
"""Initialize grid for any resolution.
|
||||
vw, vh: video width/height in pixels. Defaults to global VW, VH."""
|
||||
vw = vw or VW; vh = vh or VH
|
||||
self.vw = vw; self.vh = vh
|
||||
|
||||
def __init__(self, font_path, font_size):
|
||||
self.font = ImageFont.truetype(font_path, font_size)
|
||||
asc, desc = self.font.getmetrics()
|
||||
bbox = self.font.getbbox("M")
|
||||
self.cw = bbox[2] - bbox[0] # character cell width
|
||||
self.ch = asc + desc # CRITICAL: not textbbox height
|
||||
|
||||
self.cols = vw // self.cw
|
||||
self.rows = vh // self.ch
|
||||
self.ox = (vw - self.cols * self.cw) // 2 # centering
|
||||
self.oy = (vh - self.rows * self.ch) // 2
|
||||
|
||||
# Aspect ratio metadata
|
||||
self.aspect = vw / vh # >1 = landscape, <1 = portrait, 1 = square
|
||||
self.is_portrait = vw < vh
|
||||
self.is_landscape = vw > vh
|
||||
self.cols = VW // self.cw
|
||||
self.rows = VH // self.ch
|
||||
self.ox = (VW - self.cols * self.cw) // 2 # centering
|
||||
self.oy = (VH - self.rows * self.ch) // 2
|
||||
|
||||
# Index arrays
|
||||
self.rr = np.arange(self.rows, dtype=np.float32)[:, None]
|
||||
@@ -303,11 +219,9 @@ PAL_ARABIC = " \u0627\u0628\u062a\u062b\u062c\u062d\u062e\u062f\u0630\u0631\u0
|
||||
|
||||
#### Dot / Point Progressions
|
||||
```python
|
||||
PAL_DOTS = " ⋅∘∙●◉◎◆✦★" # dot size progression
|
||||
PAL_BRAILLE = " ⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠿" # braille patterns
|
||||
PAL_STARS = " ·✧✦✩✨★✶✳✸" # star progression
|
||||
PAL_HALFFILL = " ◔◑◕◐◒◓◖◗◙" # directional half-fill progression
|
||||
PAL_HATCH = " ▣▤▥▦▧▨▩" # crosshatch density ramp
|
||||
PAL_DOTS = " \u22c5\u2218\u2219\u25cf\u25c9\u25ce\u25c6\u2726\u2605" # dot size progression
|
||||
PAL_BRAILLE = " \u2801\u2802\u2803\u2804\u2805\u2806\u2807\u2808\u2809\u280a\u280b\u280c\u280d\u280e\u280f\u2810\u2811\u2812\u2813\u2814\u2815\u2816\u2817\u2818\u2819\u281a\u281b\u281c\u281d\u281e\u281f\u283f" # braille patterns
|
||||
PAL_STARS = " \u00b7\u2727\u2726\u2729\u2728\u2605\u2736\u2733\u2738" # star progression
|
||||
```
|
||||
|
||||
#### Project-Specific (examples -- invent new ones per project)
|
||||
@@ -439,202 +353,6 @@ def rgb_palette_map(val, mask, palette):
|
||||
return R, G, B
|
||||
```
|
||||
|
||||
### OKLAB Color Space (Perceptually Uniform)
|
||||
|
||||
HSV hue is perceptually non-uniform: green occupies far more visual range than blue. OKLAB / OKLCH provide perceptually even color steps — hue increments of 0.1 look equally different regardless of starting hue. Use OKLAB for:
|
||||
- Gradient interpolation (no unwanted intermediate hues)
|
||||
- Color harmony generation (perceptually balanced palettes)
|
||||
- Smooth color transitions over time
|
||||
|
||||
```python
|
||||
# --- sRGB <-> Linear sRGB ---
|
||||
|
||||
def srgb_to_linear(c):
|
||||
"""Convert sRGB [0,1] to linear light. c: float32 array."""
|
||||
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
||||
|
||||
def linear_to_srgb(c):
|
||||
"""Convert linear light to sRGB [0,1]."""
|
||||
return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.power(np.maximum(c, 0), 1/2.4) - 0.055)
|
||||
|
||||
# --- Linear sRGB <-> OKLAB ---
|
||||
|
||||
def linear_rgb_to_oklab(r, g, b):
|
||||
"""Linear sRGB to OKLAB. r,g,b: float32 arrays [0,1].
|
||||
Returns (L, a, b) where L=[0,1], a,b=[-0.4, 0.4] approx."""
|
||||
l_ = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
|
||||
m_ = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
|
||||
s_ = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
|
||||
l_c = np.cbrt(l_); m_c = np.cbrt(m_); s_c = np.cbrt(s_)
|
||||
L = 0.2104542553 * l_c + 0.7936177850 * m_c - 0.0040720468 * s_c
|
||||
a = 1.9779984951 * l_c - 2.4285922050 * m_c + 0.4505937099 * s_c
|
||||
b_ = 0.0259040371 * l_c + 0.7827717662 * m_c - 0.8086757660 * s_c
|
||||
return L, a, b_
|
||||
|
||||
def oklab_to_linear_rgb(L, a, b):
|
||||
"""OKLAB to linear sRGB. Returns (r, g, b) float32 arrays [0,1]."""
|
||||
l_ = L + 0.3963377774 * a + 0.2158037573 * b
|
||||
m_ = L - 0.1055613458 * a - 0.0638541728 * b
|
||||
s_ = L - 0.0894841775 * a - 1.2914855480 * b
|
||||
l_c = l_ ** 3; m_c = m_ ** 3; s_c = s_ ** 3
|
||||
r = +4.0767416621 * l_c - 3.3077115913 * m_c + 0.2309699292 * s_c
|
||||
g = -1.2684380046 * l_c + 2.6097574011 * m_c - 0.3413193965 * s_c
|
||||
b_ = -0.0041960863 * l_c - 0.7034186147 * m_c + 1.7076147010 * s_c
|
||||
return np.clip(r, 0, 1), np.clip(g, 0, 1), np.clip(b_, 0, 1)
|
||||
|
||||
# --- Convenience: sRGB uint8 <-> OKLAB ---
|
||||
|
||||
def rgb_to_oklab(R, G, B):
|
||||
"""sRGB uint8 arrays to OKLAB."""
|
||||
r = srgb_to_linear(R.astype(np.float32) / 255.0)
|
||||
g = srgb_to_linear(G.astype(np.float32) / 255.0)
|
||||
b = srgb_to_linear(B.astype(np.float32) / 255.0)
|
||||
return linear_rgb_to_oklab(r, g, b)
|
||||
|
||||
def oklab_to_rgb(L, a, b):
|
||||
"""OKLAB to sRGB uint8 arrays."""
|
||||
r, g, b_ = oklab_to_linear_rgb(L, a, b)
|
||||
R = np.clip(linear_to_srgb(r) * 255, 0, 255).astype(np.uint8)
|
||||
G = np.clip(linear_to_srgb(g) * 255, 0, 255).astype(np.uint8)
|
||||
B = np.clip(linear_to_srgb(b_) * 255, 0, 255).astype(np.uint8)
|
||||
return R, G, B
|
||||
|
||||
# --- OKLCH (cylindrical form of OKLAB) ---
|
||||
|
||||
def oklab_to_oklch(L, a, b):
|
||||
"""OKLAB to OKLCH. Returns (L, C, H) where H is in [0, 1] (normalized)."""
|
||||
C = np.sqrt(a**2 + b**2)
|
||||
H = (np.arctan2(b, a) / (2 * np.pi)) % 1.0
|
||||
return L, C, H
|
||||
|
||||
def oklch_to_oklab(L, C, H):
|
||||
"""OKLCH to OKLAB. H in [0, 1]."""
|
||||
angle = H * 2 * np.pi
|
||||
a = C * np.cos(angle)
|
||||
b = C * np.sin(angle)
|
||||
return L, a, b
|
||||
```
|
||||
|
||||
### Gradient Interpolation (OKLAB vs HSV)
|
||||
|
||||
Interpolating colors through OKLAB avoids the hue detours that HSV produces:
|
||||
|
||||
```python
|
||||
def lerp_oklab(color_a, color_b, t_array):
|
||||
"""Interpolate between two sRGB colors through OKLAB.
|
||||
color_a, color_b: (R, G, B) tuples 0-255
|
||||
t_array: float32 array [0,1] — interpolation parameter per pixel.
|
||||
Returns (R, G, B) uint8 arrays."""
|
||||
La, aa, ba = rgb_to_oklab(
|
||||
np.full_like(t_array, color_a[0], dtype=np.uint8),
|
||||
np.full_like(t_array, color_a[1], dtype=np.uint8),
|
||||
np.full_like(t_array, color_a[2], dtype=np.uint8))
|
||||
Lb, ab, bb = rgb_to_oklab(
|
||||
np.full_like(t_array, color_b[0], dtype=np.uint8),
|
||||
np.full_like(t_array, color_b[1], dtype=np.uint8),
|
||||
np.full_like(t_array, color_b[2], dtype=np.uint8))
|
||||
L = La + (Lb - La) * t_array
|
||||
a = aa + (ab - aa) * t_array
|
||||
b = ba + (bb - ba) * t_array
|
||||
return oklab_to_rgb(L, a, b)
|
||||
|
||||
def lerp_oklch(color_a, color_b, t_array, short_path=True):
|
||||
"""Interpolate through OKLCH (preserves chroma, smooth hue path).
|
||||
short_path: take the shorter arc around the hue wheel."""
|
||||
La, aa, ba = rgb_to_oklab(
|
||||
np.full_like(t_array, color_a[0], dtype=np.uint8),
|
||||
np.full_like(t_array, color_a[1], dtype=np.uint8),
|
||||
np.full_like(t_array, color_a[2], dtype=np.uint8))
|
||||
Lb, ab, bb = rgb_to_oklab(
|
||||
np.full_like(t_array, color_b[0], dtype=np.uint8),
|
||||
np.full_like(t_array, color_b[1], dtype=np.uint8),
|
||||
np.full_like(t_array, color_b[2], dtype=np.uint8))
|
||||
L1, C1, H1 = oklab_to_oklch(La, aa, ba)
|
||||
L2, C2, H2 = oklab_to_oklch(Lb, ab, bb)
|
||||
# Shortest hue path
|
||||
if short_path:
|
||||
dh = H2 - H1
|
||||
dh = np.where(dh > 0.5, dh - 1.0, np.where(dh < -0.5, dh + 1.0, dh))
|
||||
H = (H1 + dh * t_array) % 1.0
|
||||
else:
|
||||
H = H1 + (H2 - H1) * t_array
|
||||
L = L1 + (L2 - L1) * t_array
|
||||
C = C1 + (C2 - C1) * t_array
|
||||
Lout, aout, bout = oklch_to_oklab(L, C, H)
|
||||
return oklab_to_rgb(Lout, aout, bout)
|
||||
```
|
||||
|
||||
### Color Harmony Generation
|
||||
|
||||
Auto-generate harmonious palettes from a seed color:
|
||||
|
||||
```python
|
||||
def harmony_complementary(seed_rgb):
|
||||
"""Two colors: seed + opposite hue."""
|
||||
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
|
||||
_, C, H = oklab_to_oklch(L, a, b)
|
||||
return [seed_rgb, _oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.5) % 1.0)]
|
||||
|
||||
def harmony_triadic(seed_rgb):
|
||||
"""Three colors: seed + two at 120-degree offsets."""
|
||||
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
|
||||
_, C, H = oklab_to_oklch(L, a, b)
|
||||
return [seed_rgb,
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.333) % 1.0),
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.667) % 1.0)]
|
||||
|
||||
def harmony_analogous(seed_rgb, spread=0.08, n=5):
|
||||
"""N colors spread evenly around seed hue."""
|
||||
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
|
||||
_, C, H = oklab_to_oklch(L, a, b)
|
||||
offsets = np.linspace(-spread * (n-1)/2, spread * (n-1)/2, n)
|
||||
return [_oklch_to_srgb_tuple(L[0], C[0], (H[0] + off) % 1.0) for off in offsets]
|
||||
|
||||
def harmony_split_complementary(seed_rgb, split=0.08):
|
||||
"""Three colors: seed + two flanking the complement."""
|
||||
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
|
||||
_, C, H = oklab_to_oklch(L, a, b)
|
||||
comp = (H[0] + 0.5) % 1.0
|
||||
return [seed_rgb,
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (comp - split) % 1.0),
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (comp + split) % 1.0)]
|
||||
|
||||
def harmony_tetradic(seed_rgb):
|
||||
"""Four colors: two complementary pairs at 90-degree offset."""
|
||||
L, a, b = rgb_to_oklab(np.array([seed_rgb[0]]), np.array([seed_rgb[1]]), np.array([seed_rgb[2]]))
|
||||
_, C, H = oklab_to_oklch(L, a, b)
|
||||
return [seed_rgb,
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.25) % 1.0),
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.5) % 1.0),
|
||||
_oklch_to_srgb_tuple(L[0], C[0], (H[0] + 0.75) % 1.0)]
|
||||
|
||||
def _oklch_to_srgb_tuple(L, C, H):
|
||||
"""Helper: single OKLCH -> sRGB (R,G,B) int tuple."""
|
||||
La = np.array([L]); Ca = np.array([C]); Ha = np.array([H])
|
||||
Lo, ao, bo = oklch_to_oklab(La, Ca, Ha)
|
||||
R, G, B = oklab_to_rgb(Lo, ao, bo)
|
||||
return (int(R[0]), int(G[0]), int(B[0]))
|
||||
```
|
||||
|
||||
### OKLAB Hue Fields
|
||||
|
||||
Drop-in replacements for `hf_*` generators that produce perceptually uniform hue variation:
|
||||
|
||||
```python
|
||||
def hf_oklch_angle(offset=0.0, chroma=0.12, lightness=0.7):
|
||||
"""OKLCH hue mapped to angle from center. Perceptually uniform rainbow.
|
||||
Returns (R, G, B) uint8 color array instead of a float hue.
|
||||
NOTE: Use with _render_vf_rgb() variant, not standard _render_vf()."""
|
||||
def fn(g, f, t, S):
|
||||
H = (g.angle / (2 * np.pi) + offset + t * 0.05) % 1.0
|
||||
L = np.full_like(H, lightness)
|
||||
C = np.full_like(H, chroma)
|
||||
Lo, ao, bo = oklch_to_oklab(L, C, H)
|
||||
R, G, B = oklab_to_rgb(Lo, ao, bo)
|
||||
return mkc(R, G, B, g.rows, g.cols)
|
||||
return fn
|
||||
```
|
||||
|
||||
### Compositing Helpers
|
||||
|
||||
```python
|
||||
@@ -740,7 +458,7 @@ subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_path,
|
||||
|
||||
### v2 Protocol (Current)
|
||||
|
||||
Every scene function: `(r, f, t, S) -> canvas_uint8` — where `r` = Renderer, `f` = features dict, `t` = time float, `S` = persistent state dict
|
||||
Every scene function: `(renderer, features_dict, time_float, state_dict) -> canvas_uint8`
|
||||
|
||||
```python
|
||||
def fx_example(r, f, t, S):
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
# Composition & Brightness Reference
|
||||
|
||||
The composable system is the core of visual complexity. It operates at three levels: pixel-level blend modes, multi-grid composition, and adaptive brightness management. This document covers all three, plus the masking/stencil system for spatial control.
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
|
||||
- Effect building blocks (value fields, hue fields, particles): `effects.md`
|
||||
- Scene protocol, render_clip, SCENES table: `scenes.md`
|
||||
- Shader pipeline, feedback buffer: `shaders.md`
|
||||
- Complete scene examples with blend/mask usage: `examples.md`
|
||||
- Blend mode pitfalls (overlay crush, division by zero): `troubleshooting.md`
|
||||
The composable system is the core of visual complexity. It operates at three levels: pixel-level blend modes, multi-grid composition, and adaptive brightness management. This document covers all three.
|
||||
|
||||
## Pixel-Level Blend Modes
|
||||
|
||||
@@ -110,69 +102,6 @@ result = blend_canvas(result, canvas_c, "difference", 0.6)
|
||||
|
||||
Order matters: `screen(A, B)` is commutative, but `difference(screen(A,B), C)` differs from `difference(A, screen(B,C))`.
|
||||
|
||||
### Linear-Light Blend Modes
|
||||
|
||||
Standard `blend_canvas()` operates in sRGB space — the raw byte values. This is fine for most uses, but sRGB is perceptually non-linear: blending in sRGB darkens midtones and shifts hues slightly. For physically accurate blending (matching how light actually combines), convert to linear light first.
|
||||
|
||||
Uses `srgb_to_linear()` / `linear_to_srgb()` from `architecture.md` § OKLAB Color System.
|
||||
|
||||
```python
|
||||
def blend_canvas_linear(base, top, mode="normal", opacity=1.0):
|
||||
"""Blend in linear light space for physically accurate results.
|
||||
|
||||
Identical API to blend_canvas(), but converts sRGB → linear before
|
||||
blending and linear → sRGB after. More expensive (~2x) due to the
|
||||
gamma conversions, but produces correct results for additive blending,
|
||||
screen, and any mode where brightness matters.
|
||||
"""
|
||||
af = srgb_to_linear(base.astype(np.float32) / 255.0)
|
||||
bf = srgb_to_linear(top.astype(np.float32) / 255.0)
|
||||
fn = BLEND_MODES.get(mode, BLEND_MODES["normal"])
|
||||
result = fn(af, bf)
|
||||
if opacity < 1.0:
|
||||
result = af * (1 - opacity) + result * opacity
|
||||
result = linear_to_srgb(np.clip(result, 0, 1))
|
||||
return np.clip(result * 255, 0, 255).astype(np.uint8)
|
||||
```
|
||||
|
||||
**When to use `blend_canvas_linear()` vs `blend_canvas()`:**
|
||||
|
||||
| Scenario | Use | Why |
|
||||
|----------|-----|-----|
|
||||
| Screen-blending two bright layers | `linear` | sRGB screen over-brightens highlights |
|
||||
| Add mode for glow/bloom effects | `linear` | Additive light follows linear physics |
|
||||
| Blending text overlay at low opacity | `srgb` | Perceptual blending looks more natural for text |
|
||||
| Multiply for shadow/darkening | `srgb` | Differences are minimal for darken ops |
|
||||
| Color-critical work (matching reference) | `linear` | Avoids sRGB hue shifts in midtones |
|
||||
| Performance-critical inner loop | `srgb` | ~2x faster, good enough for most ASCII art |
|
||||
|
||||
**Batch version** for compositing many layers (converts once, blends multiple, converts back):
|
||||
|
||||
```python
|
||||
def blend_many_linear(layers, modes, opacities):
|
||||
"""Blend a stack of layers in linear light space.
|
||||
|
||||
Args:
|
||||
layers: list of uint8 (H,W,3) canvases
|
||||
modes: list of blend mode strings (len = len(layers) - 1)
|
||||
opacities: list of floats (len = len(layers) - 1)
|
||||
Returns:
|
||||
uint8 (H,W,3) canvas
|
||||
"""
|
||||
# Convert all to linear at once
|
||||
linear = [srgb_to_linear(l.astype(np.float32) / 255.0) for l in layers]
|
||||
result = linear[0]
|
||||
for i in range(1, len(linear)):
|
||||
fn = BLEND_MODES.get(modes[i-1], BLEND_MODES["normal"])
|
||||
blended = fn(result, linear[i])
|
||||
op = opacities[i-1]
|
||||
if op < 1.0:
|
||||
blended = result * (1 - op) + blended * op
|
||||
result = np.clip(blended, 0, 1)
|
||||
result = linear_to_srgb(result)
|
||||
return np.clip(result * 255, 0, 255).astype(np.uint8)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Grid Composition
|
||||
@@ -290,22 +219,19 @@ def tonemap(canvas, target_mean=90, gamma=0.75, black_point=2, white_point=253):
|
||||
"""Adaptive tone-mapping: normalizes + gamma-corrects so no frame is
|
||||
fully dark or washed out.
|
||||
|
||||
1. Compute 1st and 99.5th percentile on 4x subsample (16x fewer values,
|
||||
negligible accuracy loss, major speedup at 1080p+)
|
||||
1. Compute 1st and 99.5th percentile (ignores outlier pixels)
|
||||
2. Stretch that range to [0, 1]
|
||||
3. Apply gamma curve (< 1 lifts shadows, > 1 darkens)
|
||||
4. Rescale to [black_point, white_point]
|
||||
"""
|
||||
f = canvas.astype(np.float32)
|
||||
sub = f[::4, ::4] # 4x subsample: ~390K values vs ~6.2M at 1080p
|
||||
lo = np.percentile(sub, 1)
|
||||
hi = np.percentile(sub, 99.5)
|
||||
lo = np.percentile(f, 1)
|
||||
hi = np.percentile(f, 99.5)
|
||||
if hi - lo < 10:
|
||||
hi = max(hi, lo + 10) # near-uniform frame fallback
|
||||
f = np.clip((f - lo) / (hi - lo), 0.0, 1.0)
|
||||
np.power(f, gamma, out=f) # in-place: avoids allocation
|
||||
np.multiply(f, (white_point - black_point), out=f)
|
||||
np.add(f, black_point, out=f)
|
||||
f = np.power(f, gamma)
|
||||
f = f * (white_point - black_point) + black_point
|
||||
return np.clip(f, 0, 255).astype(np.uint8)
|
||||
```
|
||||
|
||||
@@ -527,208 +453,6 @@ class FeedbackBuffer:
|
||||
|
||||
---
|
||||
|
||||
## Masking / Stencil System
|
||||
|
||||
Masks are float32 arrays `(rows, cols)` or `(VH, VW)` in range [0, 1]. They control where effects are visible: 1.0 = fully visible, 0.0 = fully hidden. Use masks to create figure/ground relationships, focal points, and shaped reveals.
|
||||
|
||||
### Shape Masks
|
||||
|
||||
```python
|
||||
def mask_circle(g, cx_frac=0.5, cy_frac=0.5, radius=0.3, feather=0.05):
|
||||
"""Circular mask centered at (cx_frac, cy_frac) in normalized coords.
|
||||
feather: width of soft edge (0 = hard cutoff)."""
|
||||
asp = g.cw / g.ch if hasattr(g, 'cw') else 1.0
|
||||
dx = (g.cc / g.cols - cx_frac)
|
||||
dy = (g.rr / g.rows - cy_frac) * asp
|
||||
d = np.sqrt(dx**2 + dy**2)
|
||||
if feather > 0:
|
||||
return np.clip(1.0 - (d - radius) / feather, 0, 1)
|
||||
return (d <= radius).astype(np.float32)
|
||||
|
||||
def mask_rect(g, x0=0.2, y0=0.2, x1=0.8, y1=0.8, feather=0.03):
|
||||
"""Rectangular mask. Coordinates in [0,1] normalized."""
|
||||
dx = np.maximum(x0 - g.cc / g.cols, g.cc / g.cols - x1)
|
||||
dy = np.maximum(y0 - g.rr / g.rows, g.rr / g.rows - y1)
|
||||
d = np.maximum(dx, dy)
|
||||
if feather > 0:
|
||||
return np.clip(1.0 - d / feather, 0, 1)
|
||||
return (d <= 0).astype(np.float32)
|
||||
|
||||
def mask_ring(g, cx_frac=0.5, cy_frac=0.5, inner_r=0.15, outer_r=0.35,
|
||||
feather=0.03):
|
||||
"""Ring / annulus mask."""
|
||||
inner = mask_circle(g, cx_frac, cy_frac, inner_r, feather)
|
||||
outer = mask_circle(g, cx_frac, cy_frac, outer_r, feather)
|
||||
return outer - inner
|
||||
|
||||
def mask_gradient_h(g, start=0.0, end=1.0):
|
||||
"""Left-to-right gradient mask."""
|
||||
return np.clip((g.cc / g.cols - start) / (end - start + 1e-10), 0, 1).astype(np.float32)
|
||||
|
||||
def mask_gradient_v(g, start=0.0, end=1.0):
|
||||
"""Top-to-bottom gradient mask."""
|
||||
return np.clip((g.rr / g.rows - start) / (end - start + 1e-10), 0, 1).astype(np.float32)
|
||||
|
||||
def mask_gradient_radial(g, cx_frac=0.5, cy_frac=0.5, inner=0.0, outer=0.5):
|
||||
"""Radial gradient mask — bright at center, dark at edges."""
|
||||
d = np.sqrt((g.cc / g.cols - cx_frac)**2 + (g.rr / g.rows - cy_frac)**2)
|
||||
return np.clip(1.0 - (d - inner) / (outer - inner + 1e-10), 0, 1)
|
||||
```
|
||||
|
||||
### Value Field as Mask
|
||||
|
||||
Use any `vf_*` function's output as a spatial mask:
|
||||
|
||||
```python
|
||||
def mask_from_vf(vf_result, threshold=0.5, feather=0.1):
|
||||
"""Convert a value field to a mask by thresholding.
|
||||
feather: smooth edge width around threshold."""
|
||||
if feather > 0:
|
||||
return np.clip((vf_result - threshold + feather) / (2 * feather), 0, 1)
|
||||
return (vf_result > threshold).astype(np.float32)
|
||||
|
||||
def mask_select(mask, vf_a, vf_b):
|
||||
"""Spatial conditional: show vf_a where mask is 1, vf_b where mask is 0.
|
||||
mask: float32 [0,1] array. Intermediate values blend."""
|
||||
return vf_a * mask + vf_b * (1 - mask)
|
||||
```
|
||||
|
||||
### Text Stencil
|
||||
|
||||
Render text to a mask. Effects are visible only through the letterforms:
|
||||
|
||||
```python
|
||||
def mask_text(grid, text, row_frac=0.5, font=None, font_size=None):
|
||||
"""Render text string as a float32 mask [0,1] at grid resolution.
|
||||
Characters = 1.0, background = 0.0.
|
||||
|
||||
row_frac: vertical position as fraction of grid height.
|
||||
font: PIL ImageFont (defaults to grid's font if None).
|
||||
font_size: override font size for the mask text (for larger stencil text).
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
f = font or grid.font
|
||||
if font_size and font != grid.font:
|
||||
f = ImageFont.truetype(font.path, font_size)
|
||||
|
||||
# Render text to image at pixel resolution, then downsample to grid
|
||||
img = Image.new("L", (grid.cols * grid.cw, grid.ch), 0)
|
||||
draw = ImageDraw.Draw(img)
|
||||
bbox = draw.textbbox((0, 0), text, font=f)
|
||||
tw = bbox[2] - bbox[0]
|
||||
x = (grid.cols * grid.cw - tw) // 2
|
||||
draw.text((x, 0), text, fill=255, font=f)
|
||||
row_mask = np.array(img, dtype=np.float32) / 255.0
|
||||
|
||||
# Place in full grid mask
|
||||
mask = np.zeros((grid.rows, grid.cols), dtype=np.float32)
|
||||
target_row = int(grid.rows * row_frac)
|
||||
# Downsample rendered text to grid cells
|
||||
for c in range(grid.cols):
|
||||
px = c * grid.cw
|
||||
if px + grid.cw <= row_mask.shape[1]:
|
||||
cell = row_mask[:, px:px + grid.cw]
|
||||
if cell.mean() > 0.1:
|
||||
mask[target_row, c] = cell.mean()
|
||||
return mask
|
||||
|
||||
def mask_text_block(grid, lines, start_row_frac=0.3, font=None):
|
||||
"""Multi-line text stencil. Returns full grid mask."""
|
||||
mask = np.zeros((grid.rows, grid.cols), dtype=np.float32)
|
||||
for i, line in enumerate(lines):
|
||||
row_frac = start_row_frac + i / grid.rows
|
||||
line_mask = mask_text(grid, line, row_frac, font)
|
||||
mask = np.maximum(mask, line_mask)
|
||||
return mask
|
||||
```
|
||||
|
||||
### Animated Masks
|
||||
|
||||
Masks that change over time for reveals, wipes, and morphing:
|
||||
|
||||
```python
|
||||
def mask_iris(g, t, t_start, t_end, cx_frac=0.5, cy_frac=0.5,
|
||||
max_radius=0.7, ease_fn=None):
|
||||
"""Iris open/close: circle that grows from 0 to max_radius.
|
||||
ease_fn: easing function (default: ease_in_out_cubic from effects.md)."""
|
||||
if ease_fn is None:
|
||||
ease_fn = lambda x: x * x * (3 - 2 * x) # smoothstep fallback
|
||||
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
|
||||
radius = ease_fn(progress) * max_radius
|
||||
return mask_circle(g, cx_frac, cy_frac, radius, feather=0.03)
|
||||
|
||||
def mask_wipe_h(g, t, t_start, t_end, direction="right"):
|
||||
"""Horizontal wipe reveal."""
|
||||
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
|
||||
if direction == "left":
|
||||
progress = 1 - progress
|
||||
return mask_gradient_h(g, start=progress - 0.05, end=progress + 0.05)
|
||||
|
||||
def mask_wipe_v(g, t, t_start, t_end, direction="down"):
|
||||
"""Vertical wipe reveal."""
|
||||
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
|
||||
if direction == "up":
|
||||
progress = 1 - progress
|
||||
return mask_gradient_v(g, start=progress - 0.05, end=progress + 0.05)
|
||||
|
||||
def mask_dissolve(g, t, t_start, t_end, seed=42):
|
||||
"""Random pixel dissolve — noise threshold sweeps from 0 to 1."""
|
||||
progress = np.clip((t - t_start) / (t_end - t_start), 0, 1)
|
||||
rng = np.random.RandomState(seed)
|
||||
noise = rng.random((g.rows, g.cols)).astype(np.float32)
|
||||
return (noise < progress).astype(np.float32)
|
||||
```
|
||||
|
||||
### Mask Boolean Operations
|
||||
|
||||
```python
|
||||
def mask_union(a, b):
|
||||
"""OR — visible where either mask is active."""
|
||||
return np.maximum(a, b)
|
||||
|
||||
def mask_intersect(a, b):
|
||||
"""AND — visible only where both masks are active."""
|
||||
return np.minimum(a, b)
|
||||
|
||||
def mask_subtract(a, b):
|
||||
"""A minus B — visible where A is active but B is not."""
|
||||
return np.clip(a - b, 0, 1)
|
||||
|
||||
def mask_invert(m):
|
||||
"""NOT — flip mask."""
|
||||
return 1.0 - m
|
||||
```
|
||||
|
||||
### Applying Masks to Canvases
|
||||
|
||||
```python
|
||||
def apply_mask_canvas(canvas, mask, bg_canvas=None):
|
||||
"""Apply a grid-resolution mask to a pixel canvas.
|
||||
Expands mask from (rows, cols) to (VH, VW) via nearest-neighbor.
|
||||
|
||||
canvas: uint8 (VH, VW, 3)
|
||||
mask: float32 (rows, cols) [0,1]
|
||||
bg_canvas: what shows through where mask=0. None = black.
|
||||
"""
|
||||
# Expand mask to pixel resolution
|
||||
mask_px = np.repeat(np.repeat(mask, canvas.shape[0] // mask.shape[0] + 1, axis=0),
|
||||
canvas.shape[1] // mask.shape[1] + 1, axis=1)
|
||||
mask_px = mask_px[:canvas.shape[0], :canvas.shape[1]]
|
||||
|
||||
if bg_canvas is not None:
|
||||
return np.clip(canvas * mask_px[:, :, None] +
|
||||
bg_canvas * (1 - mask_px[:, :, None]), 0, 255).astype(np.uint8)
|
||||
return np.clip(canvas * mask_px[:, :, None], 0, 255).astype(np.uint8)
|
||||
|
||||
def apply_mask_vf(vf_a, vf_b, mask):
|
||||
"""Apply mask at value-field level — blend two value fields spatially.
|
||||
All arrays are (rows, cols) float32."""
|
||||
return vf_a * mask + vf_b * (1 - mask)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PixelBlendStack
|
||||
|
||||
Higher-level wrapper for multi-layer compositing:
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
# Scene Design Patterns
|
||||
|
||||
**Cross-references:**
|
||||
- Scene protocol, SCENES table: `scenes.md`
|
||||
- Blend modes, multi-grid composition, tonemap: `composition.md`
|
||||
- Effect building blocks (value fields, noise, SDFs): `effects.md`
|
||||
- Shader pipeline, feedback buffer: `shaders.md`
|
||||
- Complete scene examples: `examples.md`
|
||||
|
||||
Higher-order patterns for composing scenes that feel intentional rather than random. These patterns use the existing building blocks (value fields, blend modes, shaders, feedback) but organize them with compositional intent.
|
||||
|
||||
## Layer Hierarchy
|
||||
|
||||
Every scene should have clear visual layers with distinct roles:
|
||||
|
||||
| Layer | Grid | Brightness | Purpose |
|
||||
|-------|------|-----------|---------|
|
||||
| **Background** | xs or sm (dense) | 0.1–0.25 | Atmosphere, texture. Never competes with content. |
|
||||
| **Content** | md (balanced) | 0.4–0.8 | The main visual idea. Carries the scene's concept. |
|
||||
| **Accent** | lg or sm (sparse) | 0.5–1.0 (sparse coverage) | Highlights, punctuation, sparse bright points. |
|
||||
|
||||
The background sets mood. The content layer is what the scene *is about*. The accent adds visual interest without overwhelming.
|
||||
|
||||
```python
|
||||
def fx_example(r, f, t, S):
|
||||
local = t
|
||||
progress = min(local / 5.0, 1.0)
|
||||
|
||||
g_bg = r.get_grid("sm")
|
||||
g_main = r.get_grid("md")
|
||||
g_accent = r.get_grid("lg")
|
||||
|
||||
# --- Background: dim atmosphere ---
|
||||
bg_val = vf_smooth_noise(g_bg, f, t * 0.3, S, octaves=2, bri=0.15)
|
||||
# ... render bg to canvas
|
||||
|
||||
# --- Content: the main visual idea ---
|
||||
content_val = vf_spiral(g_main, f, t, S, n_arms=n_arms, tightness=tightness)
|
||||
# ... render content on top of canvas
|
||||
|
||||
# --- Accent: sparse highlights ---
|
||||
accent_val = vf_noise_static(g_accent, f, t, S, density=0.05)
|
||||
# ... render accent on top
|
||||
|
||||
return canvas
|
||||
```
|
||||
|
||||
## Directional Parameter Arcs
|
||||
|
||||
Parameters should *go somewhere* over the scene's duration — not oscillate aimlessly with `sin(t * N)`.
|
||||
|
||||
**Bad:** `twist = 3.0 + 2.0 * math.sin(t * 0.6)` — wobbles back and forth, feels aimless.
|
||||
|
||||
**Good:** `twist = 2.0 + progress * 5.0` — starts gentle, ends intense. The scene *builds*.
|
||||
|
||||
Use `progress = min(local / duration, 1.0)` (0→1 over the scene) to drive directional change:
|
||||
|
||||
| Pattern | Formula | Feel |
|
||||
|---------|---------|------|
|
||||
| Linear ramp | `progress * range` | Steady buildup |
|
||||
| Ease-out | `1 - (1 - progress) ** 2` | Fast start, gentle finish |
|
||||
| Ease-in | `progress ** 2` | Slow start, accelerating |
|
||||
| Step reveal | `np.clip((progress - 0.5) / 0.25, 0, 1)` | Nothing until 50%, then fades in |
|
||||
| Build + plateau | `min(1.0, progress * 1.5)` | Reaches full at 67%, holds |
|
||||
|
||||
Oscillation is fine for *secondary* parameters (saturation shimmer, hue drift). But the *defining* parameter of the scene should have a direction.
|
||||
|
||||
### Examples of Directional Arcs
|
||||
|
||||
| Scene concept | Parameter | Arc |
|
||||
|--------------|-----------|-----|
|
||||
| Emergence | Ring radius | 0 → max (ease-out) |
|
||||
| Shatter | Voronoi cell count | 8 → 38 (linear) |
|
||||
| Descent | Tunnel speed | 2.0 → 10.0 (linear) |
|
||||
| Mandala | Shape complexity | ring → +polygon → +star → +rosette (step reveals) |
|
||||
| Crescendo | Layer count | 1 → 7 (staggered entry) |
|
||||
| Entropy | Geometry visibility | 1.0 → 0.0 (consumed) |
|
||||
|
||||
## Scene Concepts
|
||||
|
||||
Each scene should be built around a *visual idea*, not an effect name.
|
||||
|
||||
**Bad:** "fx_plasma_cascade" — named after the effect. No concept.
|
||||
**Good:** "fx_emergence" — a point of light expands into a field. The name tells you *what happens*.
|
||||
|
||||
Good scene concepts have:
|
||||
1. A **visual metaphor** (emergence, descent, collision, entropy)
|
||||
2. A **directional arc** (things change from A to B, not oscillate)
|
||||
3. **Motivated layer choices** (each layer serves the concept)
|
||||
4. **Motivated feedback** (transform direction matches the metaphor)
|
||||
|
||||
| Concept | Metaphor | Feedback transform | Why |
|
||||
|---------|----------|-------------------|-----|
|
||||
| Emergence | Birth, expansion | zoom-out | Past frames expand outward |
|
||||
| Descent | Falling, acceleration | zoom-in | Past frames rush toward center |
|
||||
| Inferno | Rising fire | shift-up | Past frames rise with the flames |
|
||||
| Entropy | Decay, dissolution | none | Clean, no persistence — things disappear |
|
||||
| Crescendo | Accumulation | zoom + hue_shift | Everything compounds and shifts |
|
||||
|
||||
## Compositional Techniques
|
||||
|
||||
### Counter-Rotating Dual Systems
|
||||
|
||||
Two instances of the same effect rotating in opposite directions create visual interference:
|
||||
|
||||
```python
|
||||
# Primary spiral (clockwise)
|
||||
s1_val = vf_spiral(g_main, f, t * 1.5, S, n_arms=n_arms_1, tightness=tightness_1)
|
||||
|
||||
# Counter-rotating spiral (counter-clockwise via negative time)
|
||||
s2_val = vf_spiral(g_accent, f, -t * 1.2, S, n_arms=n_arms_2, tightness=tightness_2)
|
||||
|
||||
# Screen blend creates bright interference at crossing points
|
||||
canvas = blend_canvas(canvas_with_s1, c2, "screen", 0.7)
|
||||
```
|
||||
|
||||
Works with spirals, vortexes, rings. The counter-rotation creates constantly shifting interference patterns.
|
||||
|
||||
### Wave Collision
|
||||
|
||||
Two wave fronts converging from opposite sides, meeting at a collision point:
|
||||
|
||||
```python
|
||||
collision_phase = abs(progress - 0.5) * 2 # 1→0→1 (0 at collision)
|
||||
|
||||
# Wave A approaches from left
|
||||
offset_a = (1 - progress) * g.cols * 0.4
|
||||
wave_a = np.sin((g.cc + offset_a) * 0.08 + t * 2) * 0.5 + 0.5
|
||||
|
||||
# Wave B approaches from right
|
||||
offset_b = -(1 - progress) * g.cols * 0.4
|
||||
wave_b = np.sin((g.cc + offset_b) * 0.08 - t * 2) * 0.5 + 0.5
|
||||
|
||||
# Interference peaks at collision
|
||||
combined = wave_a * 0.5 + wave_b * 0.5 + np.abs(wave_a - wave_b) * (1 - collision_phase) * 0.5
|
||||
```
|
||||
|
||||
### Progressive Fragmentation
|
||||
|
||||
Voronoi with cell count increasing over time — visual shattering:
|
||||
|
||||
```python
|
||||
n_pts = int(8 + progress * 30) # 8 cells → 38 cells
|
||||
# Pre-generate enough points, slice to n_pts
|
||||
px = base_x[:n_pts] + np.sin(t * 0.3 + np.arange(n_pts) * 0.7) * (3 + progress * 3)
|
||||
```
|
||||
|
||||
The edge glow width can also increase with progress to emphasize the cracks.
|
||||
|
||||
### Entropy / Consumption
|
||||
|
||||
A clean geometric pattern being overtaken by an organic process:
|
||||
|
||||
```python
|
||||
# Geometry fades out
|
||||
geo_val = clean_pattern * max(0.05, 1.0 - progress * 0.9)
|
||||
|
||||
# Organic process grows in
|
||||
rd_val = vf_reaction_diffusion(g, f, t, S) * min(1.0, progress * 1.5)
|
||||
|
||||
# Render geometry first, organic on top — organic consumes geometry
|
||||
```
|
||||
|
||||
### Staggered Layer Entry (Crescendo)
|
||||
|
||||
Layers enter one at a time, building to overwhelming density:
|
||||
|
||||
```python
|
||||
def layer_strength(enter_t, ramp=1.5):
|
||||
"""0.0 until enter_t, ramps to 1.0 over ramp seconds."""
|
||||
return max(0.0, min(1.0, (local - enter_t) / ramp))
|
||||
|
||||
# Layer 1: always present
|
||||
s1 = layer_strength(0.0)
|
||||
# Layer 2: enters at 2s
|
||||
s2 = layer_strength(2.0)
|
||||
# Layer 3: enters at 4s
|
||||
s3 = layer_strength(4.0)
|
||||
# ... etc
|
||||
|
||||
# Each layer uses a different effect, grid, palette, and blend mode
|
||||
# Screen blend between layers so they accumulate light
|
||||
```
|
||||
|
||||
For a 15-second crescendo, 7 layers entering every 2 seconds works well. Use different blend modes (screen for most, add for energy, colordodge for the final wash).
|
||||
|
||||
## Scene Ordering
|
||||
|
||||
For a multi-scene reel or video:
|
||||
- **Vary mood between adjacent scenes** — don't put two calm scenes next to each other
|
||||
- **Randomize order** rather than grouping by type — prevents "effect demo" feel
|
||||
- **End on the strongest scene** — crescendo or something with a clear payoff
|
||||
- **Open with energy** — grab attention in the first 2 seconds
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,416 +0,0 @@
|
||||
# Scene Examples
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
|
||||
- Effect building blocks (value fields, noise, SDFs, particles): `effects.md`
|
||||
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
|
||||
- Scene protocol, render_clip, SCENES table: `scenes.md`
|
||||
- Shader pipeline, feedback buffer, ShaderChain: `shaders.md`
|
||||
- Input sources (audio features, video features): `inputs.md`
|
||||
- Performance tuning: `optimization.md`
|
||||
- Common bugs: `troubleshooting.md`
|
||||
|
||||
Copy-paste-ready scene functions at increasing complexity. Each is a complete, working v2 scene function that returns a pixel canvas. See `scenes.md` for the scene protocol and `composition.md` for blend modes and tonemap.
|
||||
|
||||
---
|
||||
|
||||
## Minimal — Single Grid, Single Effect
|
||||
|
||||
### Breathing Plasma
|
||||
|
||||
One grid, one value field, one hue field. The simplest possible scene.
|
||||
|
||||
```python
|
||||
def fx_breathing_plasma(r, f, t, S):
|
||||
"""Plasma field with time-cycling hue. Audio modulates brightness."""
|
||||
canvas = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_plasma(g, f, t, S) * 1.3,
|
||||
hf_time_cycle(0.08), PAL_DENSE, f, t, S, sat=0.8)
|
||||
return canvas
|
||||
```
|
||||
|
||||
### Reaction-Diffusion Coral
|
||||
|
||||
Single grid, simulation-based field. Evolves organically over time.
|
||||
|
||||
```python
|
||||
def fx_coral(r, f, t, S):
|
||||
"""Gray-Scott reaction-diffusion — coral branching pattern.
|
||||
Slow-evolving, organic. Best for ambient/chill sections."""
|
||||
canvas = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_reaction_diffusion(g, f, t, S,
|
||||
feed=0.037, kill=0.060, steps_per_frame=6, init_mode="center"),
|
||||
hf_distance(0.55, 0.015), PAL_DOTS, f, t, S, sat=0.7)
|
||||
return canvas
|
||||
```
|
||||
|
||||
### SDF Geometry
|
||||
|
||||
Geometric shapes from SDFs. Clean, precise, graphic.
|
||||
|
||||
```python
|
||||
def fx_sdf_rings(r, f, t, S):
|
||||
"""Concentric SDF rings with smooth pulsing."""
|
||||
def val_fn(g, f, t, S):
|
||||
d1 = sdf_ring(g, radius=0.15 + f.get("bass", 0.3) * 0.05, thickness=0.015)
|
||||
d2 = sdf_ring(g, radius=0.25 + f.get("mid", 0.3) * 0.05, thickness=0.012)
|
||||
d3 = sdf_ring(g, radius=0.35 + f.get("hi", 0.3) * 0.04, thickness=0.010)
|
||||
combined = sdf_smooth_union(sdf_smooth_union(d1, d2, 0.05), d3, 0.05)
|
||||
return sdf_glow(combined, falloff=0.08) * (0.5 + f.get("rms", 0.3) * 0.8)
|
||||
canvas = _render_vf(r, "md", val_fn, hf_angle(0.0), PAL_STARS, f, t, S, sat=0.85)
|
||||
return canvas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standard — Two Grids + Blend
|
||||
|
||||
### Tunnel Through Noise
|
||||
|
||||
Two grids at different densities, screen blended. The fine noise texture shows through the coarser tunnel characters.
|
||||
|
||||
```python
|
||||
def fx_tunnel_noise(r, f, t, S):
|
||||
"""Tunnel depth on md grid + fBM noise on sm grid, screen blended."""
|
||||
canvas_a = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_tunnel(g, f, t, S, speed=4.0, complexity=8) * 1.2,
|
||||
hf_distance(0.5, 0.02), PAL_BLOCKS, f, t, S, sat=0.7)
|
||||
|
||||
canvas_b = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_fbm(g, f, t, S, octaves=4, freq=0.05, speed=0.15) * 1.3,
|
||||
hf_time_cycle(0.06), PAL_RUNE, f, t, S, sat=0.6)
|
||||
|
||||
return blend_canvas(canvas_a, canvas_b, "screen", 0.7)
|
||||
```
|
||||
|
||||
### Voronoi Cells + Spiral Overlay
|
||||
|
||||
Voronoi cell edges with a spiral arm pattern overlaid.
|
||||
|
||||
```python
|
||||
def fx_voronoi_spiral(r, f, t, S):
|
||||
"""Voronoi edge detection on md + logarithmic spiral on lg."""
|
||||
canvas_a = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_voronoi(g, f, t, S,
|
||||
n_cells=15, mode="edge", edge_width=2.0, speed=0.4),
|
||||
hf_angle(0.2), PAL_CIRCUIT, f, t, S, sat=0.75)
|
||||
|
||||
canvas_b = _render_vf(r, "lg",
|
||||
lambda g, f, t, S: vf_spiral(g, f, t, S, n_arms=4, tightness=3.0) * 1.2,
|
||||
hf_distance(0.1, 0.03), PAL_BLOCKS, f, t, S, sat=0.9)
|
||||
|
||||
return blend_canvas(canvas_a, canvas_b, "exclusion", 0.6)
|
||||
```
|
||||
|
||||
### Domain-Warped fBM
|
||||
|
||||
Two layers of the same fBM, one domain-warped, difference-blended for psychedelic organic texture.
|
||||
|
||||
```python
|
||||
def fx_organic_warp(r, f, t, S):
|
||||
"""Clean fBM vs domain-warped fBM, difference blended."""
|
||||
canvas_a = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_fbm(g, f, t, S, octaves=5, freq=0.04, speed=0.1),
|
||||
hf_plasma(0.2), PAL_DENSE, f, t, S, sat=0.6)
|
||||
|
||||
canvas_b = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_domain_warp(g, f, t, S,
|
||||
warp_strength=20.0, freq=0.05, speed=0.15),
|
||||
hf_time_cycle(0.05), PAL_BRAILLE, f, t, S, sat=0.7)
|
||||
|
||||
return blend_canvas(canvas_a, canvas_b, "difference", 0.7)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complex — Three Grids + Conditional + Feedback
|
||||
|
||||
### Psychedelic Cathedral
|
||||
|
||||
Three-grid composition with beat-triggered kaleidoscope and feedback zoom tunnel. The most visually complex pattern.
|
||||
|
||||
```python
|
||||
def fx_cathedral(r, f, t, S):
|
||||
"""Three-layer cathedral: interference + rings + noise, kaleidoscope on beat,
|
||||
feedback zoom tunnel."""
|
||||
# Layer 1: interference pattern on sm grid
|
||||
canvas_a = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_interference(g, f, t, S, n_waves=7) * 1.3,
|
||||
hf_angle(0.0), PAL_MATH, f, t, S, sat=0.8)
|
||||
|
||||
# Layer 2: pulsing rings on md grid
|
||||
canvas_b = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_rings(g, f, t, S, n_base=10, spacing_base=3) * 1.4,
|
||||
hf_distance(0.3, 0.02), PAL_STARS, f, t, S, sat=0.9)
|
||||
|
||||
# Layer 3: temporal noise on lg grid (slow morph)
|
||||
canvas_c = _render_vf(r, "lg",
|
||||
lambda g, f, t, S: vf_temporal_noise(g, f, t, S,
|
||||
freq=0.04, t_freq=0.2, octaves=3),
|
||||
hf_time_cycle(0.12), PAL_BLOCKS, f, t, S, sat=0.7)
|
||||
|
||||
# Blend: A screen B, then difference with C
|
||||
result = blend_canvas(canvas_a, canvas_b, "screen", 0.8)
|
||||
result = blend_canvas(result, canvas_c, "difference", 0.5)
|
||||
|
||||
# Beat-triggered kaleidoscope
|
||||
if f.get("bdecay", 0) > 0.3:
|
||||
folds = 6 if f.get("sub_r", 0.3) > 0.4 else 8
|
||||
result = sh_kaleidoscope(result.copy(), folds=folds)
|
||||
|
||||
return result
|
||||
|
||||
# Scene table entry with feedback:
|
||||
# {"start": 30.0, "end": 50.0, "name": "cathedral", "fx": fx_cathedral,
|
||||
# "gamma": 0.65, "shaders": [("bloom", {"thr": 110}), ("chromatic", {"amt": 4}),
|
||||
# ("vignette", {"s": 0.2}), ("grain", {"amt": 8})],
|
||||
# "feedback": {"decay": 0.75, "blend": "screen", "opacity": 0.35,
|
||||
# "transform": "zoom", "transform_amt": 0.012, "hue_shift": 0.015}}
|
||||
```
|
||||
|
||||
### Masked Reaction-Diffusion with Attractor Overlay
|
||||
|
||||
Reaction-diffusion visible only through an animated iris mask, with a strange attractor density field underneath.
|
||||
|
||||
```python
|
||||
def fx_masked_life(r, f, t, S):
|
||||
"""Attractor base + reaction-diffusion visible through iris mask + particles."""
|
||||
g_sm = r.get_grid("sm")
|
||||
g_md = r.get_grid("md")
|
||||
|
||||
# Layer 1: strange attractor density field (background)
|
||||
canvas_bg = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_strange_attractor(g, f, t, S,
|
||||
attractor="clifford", n_points=30000),
|
||||
hf_time_cycle(0.04), PAL_DOTS, f, t, S, sat=0.5)
|
||||
|
||||
# Layer 2: reaction-diffusion (foreground, will be masked)
|
||||
canvas_rd = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_reaction_diffusion(g, f, t, S,
|
||||
feed=0.046, kill=0.063, steps_per_frame=4, init_mode="ring"),
|
||||
hf_angle(0.15), PAL_HALFFILL, f, t, S, sat=0.85)
|
||||
|
||||
# Animated iris mask — opens over first 5 seconds of scene
|
||||
scene_start = S.get("_scene_start", t)
|
||||
if "_scene_start" not in S:
|
||||
S["_scene_start"] = t
|
||||
mask = mask_iris(g_md, t, scene_start, scene_start + 5.0,
|
||||
max_radius=0.6)
|
||||
canvas_rd = apply_mask_canvas(canvas_rd, mask, bg_canvas=canvas_bg)
|
||||
|
||||
# Layer 3: flow-field particles following the R-D gradient
|
||||
rd_field = vf_reaction_diffusion(g_sm, f, t, S,
|
||||
feed=0.046, kill=0.063, steps_per_frame=0) # read without stepping
|
||||
ch_p, co_p = update_flow_particles(S, g_sm, f, rd_field,
|
||||
n=300, speed=0.8, char_set=list("·•◦∘°"))
|
||||
canvas_p = g_sm.render(ch_p, co_p)
|
||||
|
||||
result = blend_canvas(canvas_rd, canvas_p, "add", 0.7)
|
||||
return result
|
||||
```
|
||||
|
||||
### Morphing Field Sequence with Eased Keyframes
|
||||
|
||||
Demonstrates temporal coherence: smooth morphing between effects with keyframed parameters.
|
||||
|
||||
```python
|
||||
def fx_morphing_journey(r, f, t, S):
|
||||
"""Morphs through 4 value fields over 20 seconds with eased transitions.
|
||||
Parameters (twist, arm count) also keyframed."""
|
||||
# Keyframed twist parameter
|
||||
twist = keyframe(t, [(0, 1.0), (5, 5.0), (10, 2.0), (15, 8.0), (20, 1.0)],
|
||||
ease_fn=ease_in_out_cubic, loop=True)
|
||||
|
||||
# Sequence of value fields with 2s crossfade
|
||||
fields = [
|
||||
lambda g, f, t, S: vf_plasma(g, f, t, S),
|
||||
lambda g, f, t, S: vf_vortex(g, f, t, S, twist=twist),
|
||||
lambda g, f, t, S: vf_fbm(g, f, t, S, octaves=5, freq=0.04),
|
||||
lambda g, f, t, S: vf_domain_warp(g, f, t, S, warp_strength=15),
|
||||
]
|
||||
durations = [5.0, 5.0, 5.0, 5.0]
|
||||
|
||||
val_fn = lambda g, f, t, S: vf_sequence(g, f, t, S, fields, durations,
|
||||
crossfade=2.0)
|
||||
|
||||
# Render with slowly rotating hue
|
||||
canvas = _render_vf(r, "md", val_fn, hf_time_cycle(0.06),
|
||||
PAL_DENSE, f, t, S, sat=0.8)
|
||||
|
||||
# Second layer: tiled version of same sequence at smaller grid
|
||||
tiled_fn = lambda g, f, t, S: vf_sequence(
|
||||
make_tgrid(g, *uv_tile(g, 3, 3, mirror=True)),
|
||||
f, t, S, fields, durations, crossfade=2.0)
|
||||
canvas_b = _render_vf(r, "sm", tiled_fn, hf_angle(0.1),
|
||||
PAL_RUNE, f, t, S, sat=0.6)
|
||||
|
||||
return blend_canvas(canvas, canvas_b, "screen", 0.5)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Specialized — Unique State Patterns
|
||||
|
||||
### Game of Life with Ghost Trails
|
||||
|
||||
Cellular automaton with analog fade trails. Beat injects random cells.
|
||||
|
||||
```python
|
||||
def fx_life(r, f, t, S):
|
||||
"""Conway's Game of Life with fading ghost trails.
|
||||
Beat events inject random live cells for disruption."""
|
||||
canvas = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_game_of_life(g, f, t, S,
|
||||
rule="life", steps_per_frame=1, fade=0.92, density=0.25),
|
||||
hf_fixed(0.33), PAL_BLOCKS, f, t, S, sat=0.8)
|
||||
|
||||
# Overlay: coral automaton on lg grid for chunky texture
|
||||
canvas_b = _render_vf(r, "lg",
|
||||
lambda g, f, t, S: vf_game_of_life(g, f, t, S,
|
||||
rule="coral", steps_per_frame=1, fade=0.85, density=0.15, seed=99),
|
||||
hf_time_cycle(0.1), PAL_HATCH, f, t, S, sat=0.6)
|
||||
|
||||
return blend_canvas(canvas, canvas_b, "screen", 0.5)
|
||||
```
|
||||
|
||||
### Boids Flock Over Voronoi
|
||||
|
||||
Emergent swarm movement over a cellular background.
|
||||
|
||||
```python
|
||||
def fx_boid_swarm(r, f, t, S):
|
||||
"""Flocking boids over animated voronoi cells."""
|
||||
# Background: voronoi cells
|
||||
canvas_bg = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_voronoi(g, f, t, S,
|
||||
n_cells=20, mode="distance", speed=0.2),
|
||||
hf_distance(0.4, 0.02), PAL_CIRCUIT, f, t, S, sat=0.5)
|
||||
|
||||
# Foreground: boids
|
||||
g = r.get_grid("md")
|
||||
ch_b, co_b = update_boids(S, g, f, n_boids=150, perception=6.0,
|
||||
max_speed=1.5, char_set=list("▸▹►▻→⟶"))
|
||||
canvas_boids = g.render(ch_b, co_b)
|
||||
|
||||
# Trails for the boids
|
||||
# (boid positions are stored in S["boid_x"], S["boid_y"])
|
||||
S["px"] = list(S.get("boid_x", []))
|
||||
S["py"] = list(S.get("boid_y", []))
|
||||
ch_t, co_t = draw_particle_trails(S, g, max_trail=6, fade=0.6)
|
||||
canvas_trails = g.render(ch_t, co_t)
|
||||
|
||||
result = blend_canvas(canvas_bg, canvas_trails, "add", 0.3)
|
||||
result = blend_canvas(result, canvas_boids, "add", 0.9)
|
||||
return result
|
||||
```
|
||||
|
||||
### Fire Rising Through SDF Text Stencil
|
||||
|
||||
Fire effect visible only through text letterforms.
|
||||
|
||||
```python
|
||||
def fx_fire_text(r, f, t, S):
|
||||
"""Fire columns visible through text stencil. Text acts as window."""
|
||||
g = r.get_grid("lg")
|
||||
|
||||
# Full-screen fire (will be masked)
|
||||
canvas_fire = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: np.clip(
|
||||
vf_fbm(g, f, t, S, octaves=4, freq=0.08, speed=0.8) *
|
||||
(1.0 - g.rr / g.rows) * # fade toward top
|
||||
(0.6 + f.get("bass", 0.3) * 0.8), 0, 1),
|
||||
hf_fixed(0.05), PAL_BLOCKS, f, t, S, sat=0.9) # fire hue
|
||||
|
||||
# Background: dark domain warp
|
||||
canvas_bg = _render_vf(r, "md",
|
||||
lambda g, f, t, S: vf_domain_warp(g, f, t, S,
|
||||
warp_strength=8, freq=0.03, speed=0.05) * 0.3,
|
||||
hf_fixed(0.6), PAL_DENSE, f, t, S, sat=0.4)
|
||||
|
||||
# Text stencil mask
|
||||
mask = mask_text(g, "FIRE", row_frac=0.45)
|
||||
# Expand vertically for multi-row coverage
|
||||
for offset in range(-2, 3):
|
||||
shifted = mask_text(g, "FIRE", row_frac=0.45 + offset / g.rows)
|
||||
mask = mask_union(mask, shifted)
|
||||
|
||||
canvas_masked = apply_mask_canvas(canvas_fire, mask, bg_canvas=canvas_bg)
|
||||
return canvas_masked
|
||||
```
|
||||
|
||||
### Portrait Mode: Vertical Rain + Quote
|
||||
|
||||
Optimized for 9:16. Uses vertical space for long rain trails and stacked text.
|
||||
|
||||
```python
|
||||
def fx_portrait_rain_quote(r, f, t, S):
|
||||
"""Portrait-optimized: matrix rain (long vertical trails) with stacked quote.
|
||||
Designed for 1080x1920 (9:16)."""
|
||||
g = r.get_grid("md") # ~112x100 in portrait
|
||||
|
||||
# Matrix rain — long trails benefit from portrait's extra rows
|
||||
ch, co, S = eff_matrix_rain(g, f, t, S,
|
||||
hue=0.33, bri=0.6, pal=PAL_KATA, speed_base=0.4, speed_beat=2.5)
|
||||
canvas_rain = g.render(ch, co)
|
||||
|
||||
# Tunnel depth underneath for texture
|
||||
canvas_tunnel = _render_vf(r, "sm",
|
||||
lambda g, f, t, S: vf_tunnel(g, f, t, S, speed=3.0, complexity=6) * 0.8,
|
||||
hf_fixed(0.33), PAL_BLOCKS, f, t, S, sat=0.5)
|
||||
|
||||
result = blend_canvas(canvas_tunnel, canvas_rain, "screen", 0.8)
|
||||
|
||||
# Quote text — portrait layout: short lines, many of them
|
||||
g_text = r.get_grid("lg") # ~90x80 in portrait
|
||||
quote_lines = layout_text_portrait(
|
||||
"The code is the art and the art is the code",
|
||||
max_chars_per_line=20)
|
||||
# Center vertically
|
||||
block_start = (g_text.rows - len(quote_lines)) // 2
|
||||
ch_t = np.full((g_text.rows, g_text.cols), " ", dtype="U1")
|
||||
co_t = np.zeros((g_text.rows, g_text.cols, 3), dtype=np.uint8)
|
||||
total_chars = sum(len(l) for l in quote_lines)
|
||||
progress = min(1.0, (t - S.get("_scene_start", t)) / 3.0)
|
||||
if "_scene_start" not in S: S["_scene_start"] = t
|
||||
render_typewriter(ch_t, co_t, quote_lines, block_start, g_text.cols,
|
||||
progress, total_chars, (200, 255, 220), t)
|
||||
canvas_text = g_text.render(ch_t, co_t)
|
||||
|
||||
result = blend_canvas(result, canvas_text, "add", 0.9)
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scene Table Template
|
||||
|
||||
Wire scenes into a complete video:
|
||||
|
||||
```python
|
||||
SCENES = [
|
||||
{"start": 0.0, "end": 5.0, "name": "coral",
|
||||
"fx": fx_coral, "grid": "sm", "gamma": 0.70,
|
||||
"shaders": [("bloom", {"thr": 110}), ("vignette", {"s": 0.2})],
|
||||
"feedback": {"decay": 0.8, "blend": "screen", "opacity": 0.3,
|
||||
"transform": "zoom", "transform_amt": 0.01}},
|
||||
|
||||
{"start": 5.0, "end": 15.0, "name": "tunnel_noise",
|
||||
"fx": fx_tunnel_noise, "grid": "md", "gamma": 0.75,
|
||||
"shaders": [("chromatic", {"amt": 3}), ("bloom", {"thr": 120}),
|
||||
("scanlines", {"intensity": 0.06}), ("grain", {"amt": 8})],
|
||||
"feedback": None},
|
||||
|
||||
{"start": 15.0, "end": 35.0, "name": "cathedral",
|
||||
"fx": fx_cathedral, "grid": "sm", "gamma": 0.65,
|
||||
"shaders": [("bloom", {"thr": 100}), ("chromatic", {"amt": 5}),
|
||||
("color_wobble", {"amt": 0.2}), ("vignette", {"s": 0.18})],
|
||||
"feedback": {"decay": 0.75, "blend": "screen", "opacity": 0.35,
|
||||
"transform": "zoom", "transform_amt": 0.012, "hue_shift": 0.015}},
|
||||
|
||||
{"start": 35.0, "end": 50.0, "name": "morphing",
|
||||
"fx": fx_morphing_journey, "grid": "md", "gamma": 0.70,
|
||||
"shaders": [("bloom", {"thr": 110}), ("grain", {"amt": 6})],
|
||||
"feedback": {"decay": 0.7, "blend": "screen", "opacity": 0.25,
|
||||
"transform": "rotate_cw", "transform_amt": 0.003}},
|
||||
]
|
||||
```
|
||||
@@ -1,14 +1,5 @@
|
||||
# Input Sources
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, resolution presets: `architecture.md`
|
||||
- Effect building blocks (audio-reactive modulation): `effects.md`
|
||||
- Scene protocol, SCENES table (feature routing): `scenes.md`
|
||||
- Shader pipeline, output encoding: `shaders.md`
|
||||
- Performance tuning (audio chunking, WAV caching): `optimization.md`
|
||||
- Common bugs (sample rate, dtype, silence handling): `troubleshooting.md`
|
||||
- Complete scene examples with feature usage: `examples.md`
|
||||
|
||||
## Audio Analysis
|
||||
|
||||
### Loading
|
||||
@@ -303,73 +294,23 @@ For narrated videos (testimonials, quotes, storytelling), generate speech audio
|
||||
### ElevenLabs Voice Generation
|
||||
|
||||
```python
|
||||
import requests, time, os
|
||||
import requests
|
||||
|
||||
def generate_tts(text, voice_id, api_key, output_path, model="eleven_multilingual_v2"):
|
||||
"""Generate TTS audio via ElevenLabs API. Streams response to disk."""
|
||||
# Skip if already generated (idempotent re-runs)
|
||||
if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
|
||||
return
|
||||
|
||||
"""Generate TTS audio via ElevenLabs API."""
|
||||
url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}"
|
||||
headers = {"xi-api-key": api_key, "Content-Type": "application/json"}
|
||||
data = {
|
||||
"text": text,
|
||||
"model_id": model,
|
||||
"voice_settings": {
|
||||
"stability": 0.65,
|
||||
"similarity_boost": 0.80,
|
||||
"style": 0.15,
|
||||
"use_speaker_boost": True,
|
||||
},
|
||||
}
|
||||
resp = requests.post(url, json=data, headers=headers, stream=True)
|
||||
data = {"text": text, "model_id": model,
|
||||
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75}}
|
||||
resp = requests.post(url, json=data, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
with open(output_path, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=4096):
|
||||
f.write(chunk)
|
||||
time.sleep(0.3) # rate limit: avoid 429s on batch generation
|
||||
```
|
||||
|
||||
Voice settings notes:
|
||||
- `stability` 0.65 gives natural variation without drift. Lower (0.3-0.5) for more expressive reads, higher (0.7-0.9) for monotone/narration.
|
||||
- `similarity_boost` 0.80 keeps it close to the voice profile. Lower for more generic sound.
|
||||
- `style` 0.15 adds slight stylistic variation. Keep low (0-0.2) for straightforward reads.
|
||||
- `use_speaker_boost` True improves clarity at the cost of slightly more processing time.
|
||||
|
||||
### Voice Pool
|
||||
|
||||
ElevenLabs has ~20 built-in voices. Use multiple voices for variety across quotes. Reference pool:
|
||||
|
||||
```python
|
||||
VOICE_POOL = [
|
||||
("JBFqnCBsd6RMkjVDRZzb", "George"),
|
||||
("nPczCjzI2devNBz1zQrb", "Brian"),
|
||||
("pqHfZKP75CvOlQylNhV4", "Bill"),
|
||||
("CwhRBWXzGAHq8TQ4Fs17", "Roger"),
|
||||
("cjVigY5qzO86Huf0OWal", "Eric"),
|
||||
("onwK4e9ZLuTAKqWW03F9", "Daniel"),
|
||||
("IKne3meq5aSn9XLyUdCD", "Charlie"),
|
||||
("iP95p4xoKVk53GoZ742B", "Chris"),
|
||||
("bIHbv24MWmeRgasZH58o", "Will"),
|
||||
("TX3LPaxmHKxFdv7VOQHJ", "Liam"),
|
||||
("SAz9YHcvj6GT2YYXdXww", "River"),
|
||||
("EXAVITQu4vr4xnSDxMaL", "Sarah"),
|
||||
("Xb7hH8MSUJpSbSDYk0k2", "Alice"),
|
||||
("pFZP5JQG7iQjIQuC4Bku", "Lily"),
|
||||
("XrExE9yKIg1WjnnlVkGX", "Matilda"),
|
||||
("FGY2WhTYpPnrIDTdsKH5", "Laura"),
|
||||
("SOYHLrjzK2X1ezoPC6cr", "Harry"),
|
||||
("hpp4J3VqNfWAUOO0d1Us", "Bella"),
|
||||
("N2lVS1w4EtoT3dr4eOWO", "Callum"),
|
||||
("cgSgspJ2msm6clMCkdW9", "Jessica"),
|
||||
("pNInz6obpgDQGcFmaJgB", "Adam"),
|
||||
]
|
||||
f.write(resp.content)
|
||||
```
|
||||
|
||||
### Voice Assignment
|
||||
|
||||
Shuffle deterministically so re-runs produce the same voice mapping:
|
||||
Use multiple voices for variety. Shuffle deterministically so re-runs are consistent:
|
||||
|
||||
```python
|
||||
import random as _rng
|
||||
@@ -377,199 +318,83 @@ import random as _rng
|
||||
def assign_voices(n_quotes, voice_pool, seed=42):
|
||||
"""Assign a different voice to each quote, cycling if needed."""
|
||||
r = _rng.Random(seed)
|
||||
ids = [v[0] for v in voice_pool]
|
||||
r.shuffle(ids)
|
||||
return [ids[i % len(ids)] for i in range(n_quotes)]
|
||||
shuffled = list(voice_pool)
|
||||
r.shuffle(shuffled)
|
||||
return [shuffled[i % len(shuffled)] for i in range(n_quotes)]
|
||||
```
|
||||
|
||||
### Pronunciation Control
|
||||
|
||||
TTS text must be separate from display text. The display text has line breaks for visual layout; the TTS text is a flat sentence with phonetic fixes.
|
||||
|
||||
Common fixes:
|
||||
TTS text should be separate from display text. Common fixes:
|
||||
- Brand names: spell phonetically ("Nous" -> "Noose", "nginx" -> "engine-x")
|
||||
- Abbreviations: expand ("API" -> "A P I", "CLI" -> "C L I")
|
||||
- Technical terms: add phonetic hints
|
||||
- Punctuation for pacing: periods create pauses, commas create slight pauses
|
||||
|
||||
```python
|
||||
# Display text: line breaks control visual layout
|
||||
QUOTES = [
|
||||
("It can do far more than the Claws,\nand you don't need to buy a Mac Mini.\nNous Research has a winner here.", "Brian Roemmele"),
|
||||
]
|
||||
|
||||
# TTS text: flat, phonetically corrected for speech
|
||||
QUOTES_TTS = [
|
||||
"It can do far more than the Claws, and you don't need to buy a Mac Mini. Noose Research has a winner here.",
|
||||
]
|
||||
QUOTES = [("Display text here", "Author")]
|
||||
QUOTES_TTS = ["TTS text with phonetic spelling here"]
|
||||
# Keep both arrays in sync -- same indices
|
||||
```
|
||||
|
||||
### Audio Pipeline
|
||||
|
||||
1. Generate individual TTS clips (MP3 per quote, skipping existing)
|
||||
2. Convert each to WAV (mono, 22050 Hz) for duration measurement and concatenation
|
||||
3. Calculate timing: intro pad + speech + gaps + outro pad = target duration
|
||||
1. Generate individual TTS clips (MP3/WAV per quote)
|
||||
2. Get duration of each clip
|
||||
3. Calculate timing: speech start/end per quote with gaps
|
||||
4. Concatenate into single TTS track with silence padding
|
||||
5. Mix with background music
|
||||
|
||||
```python
|
||||
def build_tts_track(tts_clips, target_duration, intro_pad=5.0, outro_pad=4.0):
|
||||
"""Concatenate TTS clips with calculated gaps, pad to target duration.
|
||||
|
||||
Returns:
|
||||
timing: list of (start_time, end_time, quote_index) tuples
|
||||
"""
|
||||
sr = 22050
|
||||
|
||||
# Convert MP3s to WAV for duration and sample-level concatenation
|
||||
def build_tts_track(tts_clips, target_duration, gap_seconds=2.0):
|
||||
"""Concatenate TTS clips with gaps, pad to target duration."""
|
||||
# Get durations
|
||||
durations = []
|
||||
for clip in tts_clips:
|
||||
wav = clip.replace(".mp3", ".wav")
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-i", clip, "-ac", "1", "-ar", str(sr),
|
||||
"-sample_fmt", "s16", wav],
|
||||
capture_output=True, check=True)
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", wav],
|
||||
"-of", "csv=p=0", clip],
|
||||
capture_output=True, text=True)
|
||||
durations.append(float(result.stdout.strip()))
|
||||
|
||||
# Calculate gap to fill target duration
|
||||
|
||||
# Calculate timing
|
||||
total_speech = sum(durations)
|
||||
n_gaps = len(tts_clips) - 1
|
||||
remaining = target_duration - total_speech - intro_pad - outro_pad
|
||||
gap = max(1.0, remaining / max(1, n_gaps))
|
||||
|
||||
# Build timing and concatenate samples
|
||||
timing = []
|
||||
t = intro_pad
|
||||
all_audio = [np.zeros(int(sr * intro_pad), dtype=np.int16)]
|
||||
|
||||
total_gaps = target_duration - total_speech
|
||||
gap = max(0.5, total_gaps / (len(tts_clips) + 1))
|
||||
|
||||
timing = [] # (start, end, quote_index)
|
||||
t = gap # start after initial gap
|
||||
for i, dur in enumerate(durations):
|
||||
wav = tts_clips[i].replace(".mp3", ".wav")
|
||||
with wave.open(wav) as wf:
|
||||
samples = np.frombuffer(wf.readframes(wf.getnframes()), dtype=np.int16)
|
||||
timing.append((t, t + dur, i))
|
||||
all_audio.append(samples)
|
||||
t += dur
|
||||
if i < len(tts_clips) - 1:
|
||||
all_audio.append(np.zeros(int(sr * gap), dtype=np.int16))
|
||||
t += gap
|
||||
|
||||
all_audio.append(np.zeros(int(sr * outro_pad), dtype=np.int16))
|
||||
|
||||
# Pad or trim to exactly target_duration
|
||||
full = np.concatenate(all_audio)
|
||||
target_samples = int(sr * target_duration)
|
||||
if len(full) < target_samples:
|
||||
full = np.pad(full, (0, target_samples - len(full)))
|
||||
else:
|
||||
full = full[:target_samples]
|
||||
|
||||
# Write concatenated TTS track
|
||||
with wave.open("tts_full.wav", "w") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sr)
|
||||
wf.writeframes(full.tobytes())
|
||||
|
||||
t += dur + gap
|
||||
|
||||
# Concatenate with ffmpeg
|
||||
# ... silence padding + concat filter
|
||||
return timing
|
||||
```
|
||||
|
||||
### Audio Mixing
|
||||
|
||||
Mix TTS (center) with background music (wide stereo, low volume). The filter chain:
|
||||
1. TTS mono duplicated to both channels (centered)
|
||||
2. BGM loudness-normalized, volume reduced to 15%, stereo widened with `extrastereo`
|
||||
3. Mixed together with dropout transition for smooth endings
|
||||
Mix TTS (center) with background music (wide stereo, low volume):
|
||||
|
||||
```python
|
||||
def mix_audio(tts_path, bgm_path, output_path, bgm_volume=0.15):
|
||||
"""Mix TTS centered with BGM panned wide stereo."""
|
||||
filter_complex = (
|
||||
# TTS: mono -> stereo center
|
||||
"[0:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=mono,"
|
||||
"pan=stereo|c0=c0|c1=c0[tts];"
|
||||
# BGM: normalize loudness, reduce volume, widen stereo
|
||||
f"[1:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,"
|
||||
f"loudnorm=I=-16:TP=-1.5:LRA=11,"
|
||||
f"volume={bgm_volume},"
|
||||
f"extrastereo=m=2.5[bgm];"
|
||||
# Mix with smooth dropout at end
|
||||
"[tts][bgm]amix=inputs=2:duration=longest:dropout_transition=3,"
|
||||
"aformat=sample_fmts=s16:sample_rates=44100:channel_layouts=stereo[out]"
|
||||
)
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", tts_path,
|
||||
"-i", bgm_path,
|
||||
"-filter_complex", filter_complex,
|
||||
"-map", "[out]", output_path,
|
||||
"-i", tts_path, # mono TTS
|
||||
"-i", bgm_path, # stereo BGM
|
||||
"-filter_complex",
|
||||
f"[0:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=mono,"
|
||||
f"pan=stereo|c0=c0|c1=c0[tts];" # TTS center
|
||||
f"[1:a]loudnorm=I=-16:TP=-1.5:LRA=11,"
|
||||
f"volume={bgm_volume},"
|
||||
f"extrastereo=2.5[bgm];" # BGM wide stereo
|
||||
f"[tts][bgm]amix=inputs=2:duration=longest[out]",
|
||||
"-map", "[out]", "-c:a", "pcm_s16le", output_path
|
||||
]
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
```
|
||||
|
||||
### Per-Quote Visual Style
|
||||
|
||||
Cycle through visual presets per quote for variety. Each preset defines a background effect, color scheme, and text color:
|
||||
|
||||
```python
|
||||
QUOTE_STYLES = [
|
||||
{"hue": 0.08, "accent": 0.7, "bg": "spiral", "text_rgb": (255, 220, 140)}, # warm gold
|
||||
{"hue": 0.55, "accent": 0.6, "bg": "rings", "text_rgb": (180, 220, 255)}, # cool blue
|
||||
{"hue": 0.75, "accent": 0.7, "bg": "wave", "text_rgb": (220, 180, 255)}, # purple
|
||||
{"hue": 0.35, "accent": 0.6, "bg": "matrix", "text_rgb": (140, 255, 180)}, # green
|
||||
{"hue": 0.95, "accent": 0.8, "bg": "fire", "text_rgb": (255, 180, 160)}, # red/coral
|
||||
{"hue": 0.12, "accent": 0.5, "bg": "interference", "text_rgb": (255, 240, 200)}, # amber
|
||||
{"hue": 0.60, "accent": 0.7, "bg": "tunnel", "text_rgb": (160, 210, 255)}, # cyan
|
||||
{"hue": 0.45, "accent": 0.6, "bg": "aurora", "text_rgb": (180, 255, 220)}, # teal
|
||||
]
|
||||
|
||||
style = QUOTE_STYLES[quote_index % len(QUOTE_STYLES)]
|
||||
```
|
||||
|
||||
This guarantees no two adjacent quotes share the same look, even without randomness.
|
||||
|
||||
### Typewriter Text Rendering
|
||||
|
||||
Display quote text character-by-character synced to speech progress. Recently revealed characters are brighter, creating a "just typed" glow:
|
||||
|
||||
```python
|
||||
def render_typewriter(ch, co, lines, block_start, cols, progress, total_chars, text_rgb, t):
|
||||
"""Overlay typewriter text onto character/color grids.
|
||||
progress: 0.0 (nothing visible) to 1.0 (all text visible)."""
|
||||
chars_visible = int(total_chars * min(1.0, progress * 1.2)) # slight overshoot for snappy feel
|
||||
tr, tg, tb = text_rgb
|
||||
char_count = 0
|
||||
for li, line in enumerate(lines):
|
||||
row = block_start + li
|
||||
col = (cols - len(line)) // 2
|
||||
for ci, c in enumerate(line):
|
||||
if char_count < chars_visible:
|
||||
age = chars_visible - char_count
|
||||
bri_factor = min(1.0, 0.5 + 0.5 / (1 + age * 0.015)) # newer = brighter
|
||||
hue_shift = math.sin(char_count * 0.3 + t * 2) * 0.05
|
||||
stamp(ch, co, c, row, col + ci,
|
||||
(int(min(255, tr * bri_factor * (1.0 + hue_shift))),
|
||||
int(min(255, tg * bri_factor)),
|
||||
int(min(255, tb * bri_factor * (1.0 - hue_shift)))))
|
||||
char_count += 1
|
||||
|
||||
# Blinking cursor at insertion point
|
||||
if progress < 1.0 and int(t * 3) % 2 == 0:
|
||||
# Find cursor position (char_count == chars_visible)
|
||||
cc = 0
|
||||
for li, line in enumerate(lines):
|
||||
for ci, c in enumerate(line):
|
||||
if cc == chars_visible:
|
||||
stamp(ch, co, "\u258c", block_start + li,
|
||||
(cols - len(line)) // 2 + ci, (255, 220, 100))
|
||||
return
|
||||
cc += 1
|
||||
```
|
||||
|
||||
### Feature Analysis on Mixed Audio
|
||||
|
||||
Run the standard audio analysis (FFT, beat detection) on the final mixed track so visual effects react to both TTS and music:
|
||||
@@ -579,114 +404,4 @@ Run the standard audio analysis (FFT, beat detection) on the final mixed track s
|
||||
features = analyze_audio("mixed_final.wav", fps=24)
|
||||
```
|
||||
|
||||
Visuals pulse with both the music beats and the speech energy.
|
||||
|
||||
---
|
||||
|
||||
## Audio-Video Sync Verification
|
||||
|
||||
After rendering, verify that visual beat markers align with actual audio beats. Drift accumulates from frame timing errors, ffmpeg concat boundaries, and rounding in `fi / fps`.
|
||||
|
||||
### Beat Timestamp Extraction
|
||||
|
||||
```python
|
||||
def extract_beat_timestamps(features, fps, threshold=0.5):
|
||||
"""Extract timestamps where beat feature exceeds threshold."""
|
||||
beat = features["beat"]
|
||||
timestamps = []
|
||||
for fi in range(len(beat)):
|
||||
if beat[fi] > threshold:
|
||||
timestamps.append(fi / fps)
|
||||
return timestamps
|
||||
|
||||
def extract_visual_beat_timestamps(video_path, fps, brightness_jump=30):
|
||||
"""Detect visual beats by brightness jumps between consecutive frames.
|
||||
Returns timestamps where mean brightness increases by more than threshold."""
|
||||
import subprocess
|
||||
cmd = ["ffmpeg", "-i", video_path, "-f", "rawvideo", "-pix_fmt", "gray", "-"]
|
||||
proc = subprocess.run(cmd, capture_output=True)
|
||||
frames = np.frombuffer(proc.stdout, dtype=np.uint8)
|
||||
# Infer frame dimensions from total byte count
|
||||
n_pixels = len(frames)
|
||||
# For 1080p: 1920*1080 pixels per frame
|
||||
# Auto-detect from video metadata is more robust:
|
||||
probe = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height",
|
||||
"-of", "csv=p=0", video_path],
|
||||
capture_output=True, text=True)
|
||||
w, h = map(int, probe.stdout.strip().split(","))
|
||||
ppf = w * h # pixels per frame
|
||||
n_frames = n_pixels // ppf
|
||||
frames = frames[:n_frames * ppf].reshape(n_frames, ppf)
|
||||
means = frames.mean(axis=1)
|
||||
|
||||
timestamps = []
|
||||
for i in range(1, len(means)):
|
||||
if means[i] - means[i-1] > brightness_jump:
|
||||
timestamps.append(i / fps)
|
||||
return timestamps
|
||||
```
|
||||
|
||||
### Sync Report
|
||||
|
||||
```python
|
||||
def sync_report(audio_beats, visual_beats, tolerance_ms=50):
|
||||
"""Compare audio beat timestamps to visual beat timestamps.
|
||||
|
||||
Args:
|
||||
audio_beats: list of timestamps (seconds) from audio analysis
|
||||
visual_beats: list of timestamps (seconds) from video brightness analysis
|
||||
tolerance_ms: max acceptable drift in milliseconds
|
||||
|
||||
Returns:
|
||||
dict with matched/unmatched/drift statistics
|
||||
"""
|
||||
tolerance = tolerance_ms / 1000.0
|
||||
matched = []
|
||||
unmatched_audio = []
|
||||
unmatched_visual = list(visual_beats)
|
||||
|
||||
for at in audio_beats:
|
||||
best_match = None
|
||||
best_delta = float("inf")
|
||||
for vt in unmatched_visual:
|
||||
delta = abs(at - vt)
|
||||
if delta < best_delta:
|
||||
best_delta = delta
|
||||
best_match = vt
|
||||
if best_match is not None and best_delta < tolerance:
|
||||
matched.append({"audio": at, "visual": best_match, "drift_ms": best_delta * 1000})
|
||||
unmatched_visual.remove(best_match)
|
||||
else:
|
||||
unmatched_audio.append(at)
|
||||
|
||||
drifts = [m["drift_ms"] for m in matched]
|
||||
return {
|
||||
"matched": len(matched),
|
||||
"unmatched_audio": len(unmatched_audio),
|
||||
"unmatched_visual": len(unmatched_visual),
|
||||
"total_audio_beats": len(audio_beats),
|
||||
"total_visual_beats": len(visual_beats),
|
||||
"mean_drift_ms": np.mean(drifts) if drifts else 0,
|
||||
"max_drift_ms": np.max(drifts) if drifts else 0,
|
||||
"p95_drift_ms": np.percentile(drifts, 95) if len(drifts) > 1 else 0,
|
||||
}
|
||||
|
||||
# Usage:
|
||||
audio_beats = extract_beat_timestamps(features, fps=24)
|
||||
visual_beats = extract_visual_beat_timestamps("output.mp4", fps=24)
|
||||
report = sync_report(audio_beats, visual_beats)
|
||||
print(f"Matched: {report['matched']}/{report['total_audio_beats']} beats")
|
||||
print(f"Mean drift: {report['mean_drift_ms']:.1f}ms, Max: {report['max_drift_ms']:.1f}ms")
|
||||
# Target: mean drift < 20ms, max drift < 42ms (1 frame at 24fps)
|
||||
```
|
||||
|
||||
### Common Sync Issues
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| Consistent late visual beats | ffmpeg concat adds frames at boundaries | Use `-vsync cfr` flag; pad segments to exact frame count |
|
||||
| Drift increases over time | Floating-point accumulation in `t = fi / fps` | Use integer frame counter, compute `t` fresh each frame |
|
||||
| Random missed beats | Beat threshold too high / feature smoothing too aggressive | Lower threshold; reduce EMA alpha for beat feature |
|
||||
| Beats land on wrong frame | Off-by-one in frame indexing | Verify: frame 0 = t=0, frame 1 = t=1/fps (not t=0) |
|
||||
This means visuals will pulse with both the music beats and the speech energy -- creating natural synchronization.
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
# Optimization Reference
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, resolution presets, portrait GridLayer: `architecture.md`
|
||||
- Effect building blocks (pre-computation strategies): `effects.md`
|
||||
- `_render_vf()`, tonemap (subsampled percentile): `composition.md`
|
||||
- Scene protocol, render_clip: `scenes.md`
|
||||
- Shader pipeline, encoding (ffmpeg flags): `shaders.md`
|
||||
- Input sources (audio chunking, WAV extraction): `inputs.md`
|
||||
- Common bugs (memory, OOM, frame drops): `troubleshooting.md`
|
||||
- Complete scene examples: `examples.md`
|
||||
|
||||
## Hardware Detection
|
||||
|
||||
Detect the user's hardware at script startup and adapt rendering parameters automatically. Never hardcode worker counts or resolution.
|
||||
@@ -134,8 +124,6 @@ def apply_quality_profile(profile):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--quality", choices=["draft", "preview", "production", "max", "auto"],
|
||||
default="auto", help="Render quality preset")
|
||||
parser.add_argument("--aspect", choices=["landscape", "portrait", "square"],
|
||||
default="landscape", help="Aspect ratio preset")
|
||||
parser.add_argument("--workers", type=int, default=0, help="Override worker count (0=auto)")
|
||||
parser.add_argument("--resolution", type=str, default="", help="Override resolution e.g. 1280x720")
|
||||
args = parser.parse_args()
|
||||
@@ -144,16 +132,6 @@ hw = detect_hardware()
|
||||
if args.workers > 0:
|
||||
hw["workers"] = args.workers
|
||||
profile = quality_profile(hw, target_duration, args.quality)
|
||||
|
||||
# Apply aspect ratio preset (before manual resolution override)
|
||||
ASPECT_PRESETS = {
|
||||
"landscape": (1920, 1080),
|
||||
"portrait": (1080, 1920),
|
||||
"square": (1080, 1080),
|
||||
}
|
||||
if args.aspect != "landscape" and not args.resolution:
|
||||
profile["vw"], profile["vh"] = ASPECT_PRESETS[args.aspect]
|
||||
|
||||
if args.resolution:
|
||||
w, h = args.resolution.split("x")
|
||||
profile["vw"], profile["vh"] = int(w), int(h)
|
||||
@@ -164,47 +142,6 @@ log(f"Render: {profile['vw']}x{profile['vh']} @{profile['fps']}fps, "
|
||||
f"CRF {profile['crf']}, {profile['workers']} workers")
|
||||
```
|
||||
|
||||
### Portrait Mode Considerations
|
||||
|
||||
Portrait (1080x1920) has the same pixel count as landscape 1080p, so performance is equivalent. But composition patterns differ:
|
||||
|
||||
| Concern | Landscape | Portrait |
|
||||
|---------|-----------|----------|
|
||||
| Grid cols at `lg` | 160 | 90 |
|
||||
| Grid rows at `lg` | 45 | 80 |
|
||||
| Max text line chars | ~50 centered | ~25-30 centered |
|
||||
| Vertical rain | Short travel | Long, dramatic travel |
|
||||
| Horizontal spectrum | Full width | Needs rotation or compression |
|
||||
| Radial effects | Natural circles | Tall ellipses (aspect correction handles this) |
|
||||
| Particle explosions | Wide spread | Tall spread |
|
||||
| Text stacking | 3-4 lines comfortable | 8-10 lines comfortable |
|
||||
| Quote layout | 2-3 wide lines | 5-6 short lines |
|
||||
|
||||
**Portrait-optimized patterns:**
|
||||
- Vertical rain/matrix effects are naturally enhanced — longer column travel
|
||||
- Fire columns rise through more screen space
|
||||
- Rising embers/particles have more vertical runway
|
||||
- Text can be stacked more aggressively with more lines
|
||||
- Radial effects work if aspect correction is applied (GridLayer handles this automatically)
|
||||
- Spectrum bars can be rotated 90 degrees (vertical bars from bottom)
|
||||
|
||||
**Portrait text layout:**
|
||||
```python
|
||||
def layout_text_portrait(text, max_chars_per_line=25, grid=None):
|
||||
"""Break text into short lines for portrait display."""
|
||||
words = text.split()
|
||||
lines = []; current = ""
|
||||
for w in words:
|
||||
if len(current) + len(w) + 1 > max_chars_per_line:
|
||||
lines.append(current.strip())
|
||||
current = w + " "
|
||||
else:
|
||||
current += w + " "
|
||||
if current.strip():
|
||||
lines.append(current.strip())
|
||||
return lines
|
||||
```
|
||||
|
||||
## Performance Budget
|
||||
|
||||
Target: 100-200ms per frame (5-10 fps single-threaded, 40-80 fps across 8 workers).
|
||||
@@ -236,74 +173,6 @@ canvas[y:y+ch, x:x+cw] = np.maximum(canvas[y:y+ch, x:x+cw],
|
||||
|
||||
Collect all characters from all palettes + overlay text into the init set. Lazy-init for any missed characters.
|
||||
|
||||
## Pre-Rendered Background Textures
|
||||
|
||||
Alternative to `_render_vf()` for backgrounds where characters don't need to change every frame. Pre-bake a static ASCII texture once at init, then multiply by a per-cell color field each frame. One matrix multiply vs thousands of bitmap blits.
|
||||
|
||||
Use when: background layer uses a fixed character palette and only color/brightness varies per frame. NOT suitable for layers where character selection depends on a changing value field.
|
||||
|
||||
### Init: Bake the Texture
|
||||
|
||||
```python
|
||||
# In GridLayer.__init__:
|
||||
self._bg_row_idx = np.clip(
|
||||
(np.arange(VH) - self.oy) // self.ch, 0, self.rows - 1
|
||||
)
|
||||
self._bg_col_idx = np.clip(
|
||||
(np.arange(VW) - self.ox) // self.cw, 0, self.cols - 1
|
||||
)
|
||||
self._bg_textures = {}
|
||||
|
||||
def make_bg_texture(self, palette):
|
||||
"""Pre-render a static ASCII texture (grayscale float32) once."""
|
||||
if palette not in self._bg_textures:
|
||||
texture = np.zeros((VH, VW), dtype=np.float32)
|
||||
rng = random.Random(12345)
|
||||
ch_list = [c for c in palette if c != " " and c in self.bm]
|
||||
if not ch_list:
|
||||
ch_list = list(self.bm.keys())[:5]
|
||||
for row in range(self.rows):
|
||||
y = self.oy + row * self.ch
|
||||
if y + self.ch > VH:
|
||||
break
|
||||
for col in range(self.cols):
|
||||
x = self.ox + col * self.cw
|
||||
if x + self.cw > VW:
|
||||
break
|
||||
bm = self.bm[rng.choice(ch_list)]
|
||||
texture[y:y+self.ch, x:x+self.cw] = bm
|
||||
self._bg_textures[palette] = texture
|
||||
return self._bg_textures[palette]
|
||||
```
|
||||
|
||||
### Render: Color Field x Cached Texture
|
||||
|
||||
```python
|
||||
def render_bg(self, color_field, palette=PAL_CIRCUIT):
|
||||
"""Fast background: pre-rendered ASCII texture * per-cell color field.
|
||||
color_field: (rows, cols, 3) uint8. Returns (VH, VW, 3) uint8."""
|
||||
texture = self.make_bg_texture(palette)
|
||||
# Expand cell colors to pixel coords via pre-computed index maps
|
||||
color_px = color_field[
|
||||
self._bg_row_idx[:, None], self._bg_col_idx[None, :]
|
||||
].astype(np.float32)
|
||||
return (texture[:, :, None] * color_px).astype(np.uint8)
|
||||
```
|
||||
|
||||
### Usage in a Scene
|
||||
|
||||
```python
|
||||
# Build per-cell color from effect fields (cheap — rows*cols, not VH*VW)
|
||||
hue = ((t * 0.05 + val * 0.2) % 1.0).astype(np.float32)
|
||||
R, G, B = hsv2rgb(hue, np.full_like(val, 0.5), val)
|
||||
color_field = mkc(R, G, B, g.rows, g.cols) # (rows, cols, 3) uint8
|
||||
|
||||
# Render background — single matrix multiply, no per-cell loop
|
||||
canvas_bg = g.render_bg(color_field, PAL_DENSE)
|
||||
```
|
||||
|
||||
The texture init loop runs once and is cached per palette. Per-frame cost is one fancy-index lookup + one broadcast multiply — orders of magnitude faster than the per-cell bitmap blit loop in `render()` for dense backgrounds.
|
||||
|
||||
## Coordinate Array Caching
|
||||
|
||||
Pre-compute all grid-relative coordinate arrays at init, not per-frame:
|
||||
@@ -346,8 +215,8 @@ all_rows = []
|
||||
all_cols = []
|
||||
all_fades = []
|
||||
for c in range(cols):
|
||||
head = int(S["ry"][c])
|
||||
trail_len = S["rln"][c]
|
||||
head = int(state["ry"][c])
|
||||
trail_len = state["rln"][c]
|
||||
for i in range(trail_len):
|
||||
row = head - i
|
||||
if 0 <= row < rows:
|
||||
@@ -385,57 +254,6 @@ for fi in range(n_cols):
|
||||
# Now map fire_val to chars and colors in one vectorized pass
|
||||
```
|
||||
|
||||
## PIL String Rendering for Text-Heavy Scenes
|
||||
|
||||
Alternative to per-cell bitmap blitting when rendering many long text strings (scrolling tickers, typewriter sequences, idea floods). Uses PIL's native `ImageDraw.text()` which renders an entire string in one C call, vs one Python-loop bitmap blit per character.
|
||||
|
||||
Typical win: a scene with 56 ticker rows renders 56 PIL `text()` calls instead of ~10K individual bitmap blits.
|
||||
|
||||
Use when: scene renders many rows of readable text strings. NOT suitable for sparse or spatially-scattered single characters (use normal `render()` for those).
|
||||
|
||||
```python
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
def render_text_layer(grid, rows_data, font):
|
||||
"""Render dense text rows via PIL instead of per-cell bitmap blitting.
|
||||
|
||||
Args:
|
||||
grid: GridLayer instance (for oy, ch, ox, font metrics)
|
||||
rows_data: list of (row_index, text_string, rgb_tuple) — one per row
|
||||
font: PIL ImageFont instance (grid.font)
|
||||
|
||||
Returns:
|
||||
uint8 array (VH, VW, 3) — canvas with rendered text
|
||||
"""
|
||||
img = Image.new("RGB", (VW, VH), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
for row_idx, text, color in rows_data:
|
||||
y = grid.oy + row_idx * grid.ch
|
||||
if y + grid.ch > VH:
|
||||
break
|
||||
draw.text((grid.ox, y), text, fill=color, font=font)
|
||||
return np.array(img)
|
||||
```
|
||||
|
||||
### Usage in a Ticker Scene
|
||||
|
||||
```python
|
||||
# Build ticker data (text + color per row)
|
||||
rows_data = []
|
||||
for row in range(n_tickers):
|
||||
text = build_ticker_text(row, t) # scrolling substring
|
||||
color = hsv2rgb_scalar(hue, 0.85, bri) # (R, G, B) tuple
|
||||
rows_data.append((row, text, color))
|
||||
|
||||
# One PIL pass instead of thousands of bitmap blits
|
||||
canvas_tickers = render_text_layer(g_md, rows_data, g_md.font)
|
||||
|
||||
# Blend with other layers normally
|
||||
result = blend_canvas(canvas_bg, canvas_tickers, "screen", 0.9)
|
||||
```
|
||||
|
||||
This is purely a rendering optimization — same visual output, fewer draw calls. The grid's `render()` method is still needed for sparse character fields where characters are placed individually based on value fields.
|
||||
|
||||
## Bloom Optimization
|
||||
|
||||
**Do NOT use `scipy.ndimage.uniform_filter`** -- measured at 424ms/frame.
|
||||
@@ -615,82 +433,3 @@ Scale with hardware. Baseline: 1080p, 24fps, ~180ms/frame/worker.
|
||||
At 720p: multiply times by ~0.5. At 4K: multiply by ~4.
|
||||
|
||||
Heavier effects (many particles, dense grids, extra shader passes) add ~20-50%.
|
||||
|
||||
---
|
||||
|
||||
## Temp File Cleanup
|
||||
|
||||
Rendering generates intermediate files that accumulate across runs. Clean up after the final concat/mux step.
|
||||
|
||||
### Files to Clean
|
||||
|
||||
| File type | Source | Location |
|
||||
|-----------|--------|----------|
|
||||
| WAV extracts | `ffmpeg -i input.mp3 ... tmp.wav` | `tempfile.mktemp()` or project dir |
|
||||
| Segment clips | `render_clip()` output | `segments/seg_00.mp4` etc. |
|
||||
| Concat list | ffmpeg concat demuxer input | `segments/concat.txt` |
|
||||
| ffmpeg stderr logs | piped to file for debugging | `*.log` in project dir |
|
||||
| Feature cache | pickled numpy arrays | `*.pkl` or `*.npz` |
|
||||
|
||||
### Cleanup Function
|
||||
|
||||
```python
|
||||
import glob
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
def cleanup_render_artifacts(segments_dir="segments", keep_final=True):
|
||||
"""Remove intermediate files after successful render.
|
||||
|
||||
Call this AFTER verifying the final output exists and plays correctly.
|
||||
|
||||
Args:
|
||||
segments_dir: directory containing segment clips and concat list
|
||||
keep_final: if True, only delete intermediates (not the final output)
|
||||
"""
|
||||
removed = []
|
||||
|
||||
# 1. Segment clips
|
||||
if os.path.isdir(segments_dir):
|
||||
shutil.rmtree(segments_dir)
|
||||
removed.append(f"directory: {segments_dir}")
|
||||
|
||||
# 2. Temporary WAV files
|
||||
for wav in glob.glob("*.wav"):
|
||||
if wav.startswith("tmp") or wav.startswith("extracted_"):
|
||||
os.remove(wav)
|
||||
removed.append(wav)
|
||||
|
||||
# 3. ffmpeg stderr logs
|
||||
for log in glob.glob("ffmpeg_*.log"):
|
||||
os.remove(log)
|
||||
removed.append(log)
|
||||
|
||||
# 4. Feature cache (optional — useful to keep for re-renders)
|
||||
# for cache in glob.glob("features_*.npz"):
|
||||
# os.remove(cache)
|
||||
# removed.append(cache)
|
||||
|
||||
print(f"Cleaned {len(removed)} artifacts: {removed}")
|
||||
return removed
|
||||
```
|
||||
|
||||
### Integration with Render Pipeline
|
||||
|
||||
Call cleanup at the end of the main render script, after the final output is verified:
|
||||
|
||||
```python
|
||||
# At end of main()
|
||||
if os.path.exists(output_path) and os.path.getsize(output_path) > 1000:
|
||||
cleanup_render_artifacts(segments_dir="segments")
|
||||
print(f"Done. Output: {output_path}")
|
||||
else:
|
||||
print("WARNING: final output missing or empty — skipping cleanup")
|
||||
```
|
||||
|
||||
### Temp File Best Practices
|
||||
|
||||
- Use `tempfile.mkdtemp()` for segment directories — avoids polluting the project dir
|
||||
- Name WAV extracts with `tempfile.mktemp(suffix=".wav")` so they're in the OS temp dir
|
||||
- For debugging, set `KEEP_INTERMEDIATES=1` env var to skip cleanup
|
||||
- Feature caches (`.npz`) are cheap to store and expensive to recompute — default to keeping them
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
# Scene System Reference
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
|
||||
- Effect building blocks (value fields, noise, SDFs, particles): `effects.md`
|
||||
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
|
||||
- Shader pipeline, feedback buffer, ShaderChain: `shaders.md`
|
||||
- Complete scene examples at every complexity level: `examples.md`
|
||||
- Input sources (audio features, video features): `inputs.md`
|
||||
- Performance tuning, portrait CLI: `optimization.md`
|
||||
- Common bugs (state leaks, frame drops): `troubleshooting.md`
|
||||
|
||||
Scenes are the top-level creative unit. Each scene is a time-bounded segment with its own effect function, shader chain, feedback configuration, and tone-mapping gamma.
|
||||
|
||||
## Scene Protocol (v2)
|
||||
@@ -22,7 +12,7 @@ def fx_scene_name(r, f, t, S) -> canvas:
|
||||
Args:
|
||||
r: Renderer instance — access multiple grids via r.get_grid("sm")
|
||||
f: dict of audio/video features, all values normalized to [0, 1]
|
||||
t: time in seconds — local to scene (0.0 at scene start)
|
||||
t: time in seconds (global, not local to scene)
|
||||
S: dict for persistent state (particles, rain columns, etc.)
|
||||
|
||||
Returns:
|
||||
@@ -30,20 +20,6 @@ def fx_scene_name(r, f, t, S) -> canvas:
|
||||
"""
|
||||
```
|
||||
|
||||
**Local time convention:** Scene functions receive `t` starting at 0.0 for the first frame of the scene, regardless of where the scene appears in the timeline. The render loop subtracts the scene's start time before calling the function:
|
||||
|
||||
```python
|
||||
# In render_clip:
|
||||
t_local = fi / FPS - scene_start
|
||||
canvas = fx_fn(r, feat, t_local, S)
|
||||
```
|
||||
|
||||
This makes scenes reorderable without modifying their code. Compute scene progress as:
|
||||
|
||||
```python
|
||||
progress = min(t / scene_duration, 1.0) # 0→1 over the scene
|
||||
```
|
||||
|
||||
This replaces the v1 protocol where scenes returned `(chars, colors)` tuples. The v2 protocol gives scenes full control over multi-grid rendering and pixel-level composition internally.
|
||||
|
||||
### The Renderer Class
|
||||
|
||||
@@ -2,15 +2,6 @@
|
||||
|
||||
Post-processing effects applied to the pixel canvas (`numpy uint8 array, shape (H,W,3)`) after character rendering and before encoding. Also covers **pixel-level blend modes**, **feedback buffers**, and the **ShaderChain** compositor.
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, palettes, color (HSV + OKLAB): `architecture.md`
|
||||
- Effect building blocks (value fields, noise, SDFs): `effects.md`
|
||||
- `_render_vf()`, blend modes, tonemap, masking: `composition.md`
|
||||
- Scene protocol, render_clip, SCENES table: `scenes.md`
|
||||
- Complete scene examples with shader usage: `examples.md`
|
||||
- Performance tuning (frame budget, worker count): `optimization.md`
|
||||
- Encoding pitfalls (ffmpeg flags, color space): `troubleshooting.md`
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
The shader pipeline turns raw ASCII renders into cinematic output. The system is designed for **composability** — every shader, blend mode, and feedback transform is an independent building block. Combining them creates infinite visual variety from a small set of primitives.
|
||||
@@ -1034,324 +1025,3 @@ cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
|
||||
"-vf", f"fps={fps},scale={W}:{H}:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
|
||||
"-loop", "0", output_gif]
|
||||
```
|
||||
|
||||
### PNG Sequence
|
||||
|
||||
For frame-accurate editing, compositing in external tools (After Effects, Nuke), or lossless archival:
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
def output_png_sequence(frames, output_dir, W, H, fps, prefix="frame"):
|
||||
"""Write frames as numbered PNGs. frames = iterable of uint8 (H,W,3) arrays."""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Method 1: Direct PIL write (no ffmpeg dependency)
|
||||
from PIL import Image
|
||||
for i, frame in enumerate(frames):
|
||||
img = Image.fromarray(frame)
|
||||
img.save(os.path.join(output_dir, f"{prefix}_{i:06d}.png"))
|
||||
|
||||
# Method 2: ffmpeg pipe (faster for large sequences)
|
||||
cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
|
||||
"-s", f"{W}x{H}", "-r", str(fps), "-i", "pipe:0",
|
||||
os.path.join(output_dir, f"{prefix}_%06d.png")]
|
||||
```
|
||||
|
||||
Reassemble PNG sequence to video:
|
||||
```bash
|
||||
ffmpeg -framerate 24 -i frame_%06d.png -c:v libx264 -crf 18 -pix_fmt yuv420p output.mp4
|
||||
```
|
||||
|
||||
### Alpha Channel / Transparent Background (RGBA)
|
||||
|
||||
For compositing ASCII art over other video or images. Uses RGBA canvas (4 channels) instead of RGB (3 channels):
|
||||
|
||||
```python
|
||||
def create_rgba_canvas(H, W):
|
||||
"""Transparent canvas — alpha channel starts at 0 (fully transparent)."""
|
||||
return np.zeros((H, W, 4), dtype=np.uint8)
|
||||
|
||||
def render_char_rgba(canvas, row, col, char_img, color_rgb, alpha=255):
|
||||
"""Render a character with alpha. char_img = PIL glyph mask (grayscale).
|
||||
Alpha comes from the glyph mask — background stays transparent."""
|
||||
r, g, b = color_rgb
|
||||
y0, x0 = row * cell_h, col * cell_w
|
||||
mask = np.array(char_img) # grayscale 0-255
|
||||
canvas[y0:y0+cell_h, x0:x0+cell_w, 0] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 0], (mask * r / 255).astype(np.uint8))
|
||||
canvas[y0:y0+cell_h, x0:x0+cell_w, 1] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 1], (mask * g / 255).astype(np.uint8))
|
||||
canvas[y0:y0+cell_h, x0:x0+cell_w, 2] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 2], (mask * b / 255).astype(np.uint8))
|
||||
canvas[y0:y0+cell_h, x0:x0+cell_w, 3] = np.maximum(canvas[y0:y0+cell_h, x0:x0+cell_w, 3], mask)
|
||||
|
||||
def blend_onto_background(rgba_canvas, bg_rgb):
|
||||
"""Composite RGBA canvas over a solid or image background."""
|
||||
alpha = rgba_canvas[:, :, 3:4].astype(np.float32) / 255.0
|
||||
fg = rgba_canvas[:, :, :3].astype(np.float32)
|
||||
bg = bg_rgb.astype(np.float32)
|
||||
result = fg * alpha + bg * (1.0 - alpha)
|
||||
return result.astype(np.uint8)
|
||||
```
|
||||
|
||||
RGBA output via ffmpeg (ProRes 4444 for editing, WebM VP9 for web):
|
||||
```bash
|
||||
# ProRes 4444 — preserves alpha, widely supported in NLEs
|
||||
ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \
|
||||
-c:v prores_ks -profile:v 4444 -pix_fmt yuva444p10le output.mov
|
||||
|
||||
# WebM VP9 — alpha support for web/browser compositing
|
||||
ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \
|
||||
-c:v libvpx-vp9 -pix_fmt yuva420p -crf 30 -b:v 0 output.webm
|
||||
|
||||
# PNG sequence with alpha (lossless)
|
||||
ffmpeg -y -f rawvideo -pix_fmt rgba -s {W}x{H} -r {fps} -i pipe:0 \
|
||||
frame_%06d.png
|
||||
```
|
||||
|
||||
**Key constraint**: shaders that operate on `(H,W,3)` arrays need adaptation for RGBA. Either apply shaders to the RGB channels only and preserve alpha, or write RGBA-aware versions:
|
||||
|
||||
```python
|
||||
def apply_shader_rgba(canvas_rgba, shader_fn, **kwargs):
|
||||
"""Apply an RGB shader to the color channels of an RGBA canvas."""
|
||||
rgb = canvas_rgba[:, :, :3]
|
||||
alpha = canvas_rgba[:, :, 3:4]
|
||||
rgb_out = shader_fn(rgb, **kwargs)
|
||||
return np.concatenate([rgb_out, alpha], axis=2)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-Time Terminal Rendering
|
||||
|
||||
Live ASCII display in the terminal using ANSI escape codes. Useful for previewing scenes during development, live performances, and interactive parameter tuning.
|
||||
|
||||
### ANSI Color Escape Codes
|
||||
|
||||
```python
|
||||
def rgb_to_ansi(r, g, b):
|
||||
"""24-bit true color ANSI escape (supported by most modern terminals)."""
|
||||
return f"\033[38;2;{r};{g};{b}m"
|
||||
|
||||
ANSI_RESET = "\033[0m"
|
||||
ANSI_CLEAR = "\033[2J\033[H" # clear screen + cursor home
|
||||
ANSI_HIDE_CURSOR = "\033[?25l"
|
||||
ANSI_SHOW_CURSOR = "\033[?25h"
|
||||
```
|
||||
|
||||
### Frame-to-ANSI Conversion
|
||||
|
||||
```python
|
||||
def frame_to_ansi(chars, colors):
|
||||
"""Convert char+color arrays to a single ANSI string for terminal output.
|
||||
|
||||
Args:
|
||||
chars: (rows, cols) array of single characters
|
||||
colors: (rows, cols, 3) uint8 RGB array
|
||||
Returns:
|
||||
str: ANSI-encoded frame ready for sys.stdout.write()
|
||||
"""
|
||||
rows, cols = chars.shape
|
||||
lines = []
|
||||
for r in range(rows):
|
||||
parts = []
|
||||
prev_color = None
|
||||
for c in range(cols):
|
||||
rgb = tuple(colors[r, c])
|
||||
ch = chars[r, c]
|
||||
if ch == " " or rgb == (0, 0, 0):
|
||||
parts.append(" ")
|
||||
else:
|
||||
if rgb != prev_color:
|
||||
parts.append(rgb_to_ansi(*rgb))
|
||||
prev_color = rgb
|
||||
parts.append(ch)
|
||||
parts.append(ANSI_RESET)
|
||||
lines.append("".join(parts))
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
### Optimized: Delta Updates
|
||||
|
||||
Only redraw characters that changed since the last frame. Eliminates redundant terminal writes for static regions:
|
||||
|
||||
```python
|
||||
def frame_to_ansi_delta(chars, colors, prev_chars, prev_colors):
|
||||
"""Emit ANSI escapes only for cells that changed."""
|
||||
rows, cols = chars.shape
|
||||
parts = []
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
if (chars[r, c] != prev_chars[r, c] or
|
||||
not np.array_equal(colors[r, c], prev_colors[r, c])):
|
||||
parts.append(f"\033[{r+1};{c+1}H") # move cursor
|
||||
rgb = tuple(colors[r, c])
|
||||
parts.append(rgb_to_ansi(*rgb))
|
||||
parts.append(chars[r, c])
|
||||
return "".join(parts)
|
||||
```
|
||||
|
||||
### Live Render Loop
|
||||
|
||||
```python
|
||||
import sys
|
||||
import time
|
||||
|
||||
def render_live(scene_fn, r, fps=24, duration=None):
|
||||
"""Render a scene function live in the terminal.
|
||||
|
||||
Args:
|
||||
scene_fn: v2 scene function (r, f, t, S) -> canvas
|
||||
OR v1-style function that populates a grid
|
||||
r: Renderer instance
|
||||
fps: target frame rate
|
||||
duration: seconds to run (None = run until Ctrl+C)
|
||||
"""
|
||||
frame_time = 1.0 / fps
|
||||
S = {}
|
||||
f = {} # synthesize features or connect to live audio
|
||||
|
||||
sys.stdout.write(ANSI_HIDE_CURSOR + ANSI_CLEAR)
|
||||
sys.stdout.flush()
|
||||
|
||||
t0 = time.monotonic()
|
||||
frame_count = 0
|
||||
try:
|
||||
while True:
|
||||
t = time.monotonic() - t0
|
||||
if duration and t > duration:
|
||||
break
|
||||
|
||||
# Synthesize features from time (or connect to live audio via pyaudio)
|
||||
f = synthesize_features(t)
|
||||
|
||||
# Render scene — for terminal, use a small grid
|
||||
g = r.get_grid("sm")
|
||||
# Option A: v2 scene → extract chars/colors from canvas (reverse render)
|
||||
# Option B: call effect functions directly for chars/colors
|
||||
canvas = scene_fn(r, f, t, S)
|
||||
|
||||
# For terminal display, render chars+colors directly
|
||||
# (bypassing the pixel canvas — terminal uses character cells)
|
||||
chars, colors = scene_to_terminal(scene_fn, r, f, t, S, g)
|
||||
|
||||
frame_str = ANSI_CLEAR + frame_to_ansi(chars, colors)
|
||||
sys.stdout.write(frame_str)
|
||||
sys.stdout.flush()
|
||||
|
||||
# Frame timing
|
||||
elapsed = time.monotonic() - t0 - (frame_count * frame_time)
|
||||
sleep_time = frame_time - elapsed
|
||||
if sleep_time > 0:
|
||||
time.sleep(sleep_time)
|
||||
frame_count += 1
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
sys.stdout.write(ANSI_SHOW_CURSOR + ANSI_RESET + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def scene_to_terminal(scene_fn, r, f, t, S, g):
|
||||
"""Run effect functions and return (chars, colors) for terminal display.
|
||||
For terminal mode, skip the pixel canvas and work with character arrays directly."""
|
||||
# Effects that return (chars, colors) work directly
|
||||
# For vf-based effects, render the value field + hue field to chars/colors:
|
||||
val = vf_plasma(g, f, t, S)
|
||||
hue = hf_time_cycle(0.08)(g, t)
|
||||
mask = val > 0.03
|
||||
chars = val2char(val, mask, PAL_DENSE)
|
||||
R, G, B = hsv2rgb(hue, np.full_like(val, 0.8), val)
|
||||
colors = mkc(R, G, B, g.rows, g.cols)
|
||||
return chars, colors
|
||||
```
|
||||
|
||||
### Curses-Based Rendering (More Robust)
|
||||
|
||||
For full-featured terminal UIs with proper resize handling and input:
|
||||
|
||||
```python
|
||||
import curses
|
||||
|
||||
def render_curses(scene_fn, r, fps=24):
|
||||
"""Curses-based live renderer with resize handling and key input."""
|
||||
|
||||
def _main(stdscr):
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.curs_set(0) # hide cursor
|
||||
stdscr.nodelay(True) # non-blocking input
|
||||
|
||||
# Initialize color pairs (curses supports 256 colors)
|
||||
# Map RGB to nearest curses color pair
|
||||
color_cache = {}
|
||||
next_pair = [1]
|
||||
|
||||
def get_color_pair(r, g, b):
|
||||
key = (r >> 4, g >> 4, b >> 4) # quantize to reduce pairs
|
||||
if key not in color_cache:
|
||||
if next_pair[0] < curses.COLOR_PAIRS - 1:
|
||||
ci = 16 + (r // 51) * 36 + (g // 51) * 6 + (b // 51) # 6x6x6 cube
|
||||
curses.init_pair(next_pair[0], ci, -1)
|
||||
color_cache[key] = next_pair[0]
|
||||
next_pair[0] += 1
|
||||
else:
|
||||
return 0
|
||||
return curses.color_pair(color_cache[key])
|
||||
|
||||
S = {}
|
||||
f = {}
|
||||
frame_time = 1.0 / fps
|
||||
t0 = time.monotonic()
|
||||
|
||||
while True:
|
||||
t = time.monotonic() - t0
|
||||
f = synthesize_features(t)
|
||||
|
||||
# Adapt grid to terminal size
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
g = r.get_grid_for_size(max_x, max_y) # dynamic grid sizing
|
||||
|
||||
chars, colors = scene_to_terminal(scene_fn, r, f, t, S, g)
|
||||
rows, cols = chars.shape
|
||||
|
||||
for row in range(min(rows, max_y - 1)):
|
||||
for col in range(min(cols, max_x - 1)):
|
||||
ch = chars[row, col]
|
||||
rgb = tuple(colors[row, col])
|
||||
try:
|
||||
stdscr.addch(row, col, ch, get_color_pair(*rgb))
|
||||
except curses.error:
|
||||
pass # ignore writes outside terminal bounds
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
# Handle input
|
||||
key = stdscr.getch()
|
||||
if key == ord('q'):
|
||||
break
|
||||
|
||||
time.sleep(max(0, frame_time - (time.monotonic() - t0 - t)))
|
||||
|
||||
curses.wrapper(_main)
|
||||
```
|
||||
|
||||
### Terminal Rendering Constraints
|
||||
|
||||
| Constraint | Value | Notes |
|
||||
|-----------|-------|-------|
|
||||
| Max practical grid | ~200x60 | Depends on terminal size |
|
||||
| Color support | 24-bit (modern), 256 (fallback), 16 (minimal) | Check `$COLORTERM` for truecolor |
|
||||
| Frame rate ceiling | ~30 fps | Terminal I/O is the bottleneck |
|
||||
| Delta updates | 2-5x faster | Only worth it when <30% of cells change per frame |
|
||||
| SSH latency | Kills performance | Local terminals only for real-time |
|
||||
|
||||
**Detect color support:**
|
||||
```python
|
||||
import os
|
||||
def get_terminal_color_depth():
|
||||
ct = os.environ.get("COLORTERM", "")
|
||||
if ct in ("truecolor", "24bit"):
|
||||
return 24
|
||||
term = os.environ.get("TERM", "")
|
||||
if "256color" in term:
|
||||
return 8 # 256 colors
|
||||
return 4 # 16 colors basic ANSI
|
||||
```
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
# Troubleshooting Reference
|
||||
|
||||
**Cross-references:**
|
||||
- Grid system, palettes, font selection: `architecture.md`
|
||||
- Effect building blocks (value fields, noise, SDFs): `effects.md`
|
||||
- `_render_vf()`, blend modes, tonemap: `composition.md`
|
||||
- Scene protocol, render_clip, SCENES table: `scenes.md`
|
||||
- Shader pipeline, feedback buffer, encoding: `shaders.md`
|
||||
- Input sources (audio, video, TTS): `inputs.md`
|
||||
- Performance tuning, hardware detection: `optimization.md`
|
||||
- Complete scene examples: `examples.md`
|
||||
|
||||
Common bugs, gotchas, and platform-specific issues encountered during ASCII video development.
|
||||
|
||||
## NumPy Broadcasting
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
description: Skills for data science workflows — interactive exploration, Jupyter notebooks, data analysis, and visualization.
|
||||
---
|
||||
@@ -1,171 +0,0 @@
|
||||
---
|
||||
name: jupyter-live-kernel
|
||||
description: >
|
||||
Use a live Jupyter kernel for stateful, iterative Python execution via hamelnb.
|
||||
Load this skill when the task involves exploration, iteration, or inspecting
|
||||
intermediate results — data science, ML experimentation, API exploration, or
|
||||
building up complex code step-by-step. Uses terminal to run CLI commands against
|
||||
a live Jupyter kernel. No new tools required.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [jupyter, notebook, repl, data-science, exploration, iterative]
|
||||
category: data-science
|
||||
---
|
||||
|
||||
# Jupyter Live Kernel (hamelnb)
|
||||
|
||||
Gives you a **stateful Python REPL** via a live Jupyter kernel. Variables persist
|
||||
across executions. Use this instead of `execute_code` when you need to build up
|
||||
state incrementally, explore APIs, inspect DataFrames, or iterate on complex code.
|
||||
|
||||
## When to Use This vs Other Tools
|
||||
|
||||
| Tool | Use When |
|
||||
|------|----------|
|
||||
| **This skill** | Iterative exploration, state across steps, data science, ML, "let me try this and check" |
|
||||
| `execute_code` | One-shot scripts needing hermes tool access (web_search, file ops). Stateless. |
|
||||
| `terminal` | Shell commands, builds, installs, git, process management |
|
||||
|
||||
**Rule of thumb:** If you'd want a Jupyter notebook for the task, use this skill.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **uv** must be installed (check: `which uv`)
|
||||
2. **JupyterLab** must be installed: `uv tool install jupyterlab`
|
||||
3. A Jupyter server must be running (see Setup below)
|
||||
|
||||
## Setup
|
||||
|
||||
The hamelnb script location:
|
||||
```
|
||||
SCRIPT="$HOME/.agent-skills/hamelnb/skills/jupyter-live-kernel/scripts/jupyter_live_kernel.py"
|
||||
```
|
||||
|
||||
If not cloned yet:
|
||||
```
|
||||
git clone https://github.com/hamelsmu/hamelnb.git ~/.agent-skills/hamelnb
|
||||
```
|
||||
|
||||
### Starting JupyterLab
|
||||
|
||||
Check if a server is already running:
|
||||
```
|
||||
uv run "$SCRIPT" servers
|
||||
```
|
||||
|
||||
If no servers found, start one:
|
||||
```
|
||||
jupyter-lab --no-browser --port=8888 --notebook-dir=$HOME/notebooks \
|
||||
--IdentityProvider.token='' --ServerApp.password='' > /tmp/jupyter.log 2>&1 &
|
||||
sleep 3
|
||||
```
|
||||
|
||||
Note: Token/password disabled for local agent access. The server runs headless.
|
||||
|
||||
### Creating a Notebook for REPL Use
|
||||
|
||||
If you just need a REPL (no existing notebook), create a minimal notebook file:
|
||||
```
|
||||
mkdir -p ~/notebooks
|
||||
```
|
||||
Write a minimal .ipynb JSON file with one empty code cell, then start a kernel
|
||||
session via the Jupyter REST API:
|
||||
```
|
||||
curl -s -X POST http://127.0.0.1:8888/api/sessions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"path":"scratch.ipynb","type":"notebook","name":"scratch.ipynb","kernel":{"name":"python3"}}'
|
||||
```
|
||||
|
||||
## Core Workflow
|
||||
|
||||
All commands return structured JSON. Always use `--compact` to save tokens.
|
||||
|
||||
### 1. Discover servers and notebooks
|
||||
|
||||
```
|
||||
uv run "$SCRIPT" servers --compact
|
||||
uv run "$SCRIPT" notebooks --compact
|
||||
```
|
||||
|
||||
### 2. Execute code (primary operation)
|
||||
|
||||
```
|
||||
uv run "$SCRIPT" execute --path <notebook.ipynb> --code '<python code>' --compact
|
||||
```
|
||||
|
||||
State persists across execute calls. Variables, imports, objects all survive.
|
||||
|
||||
Multi-line code works with $'...' quoting:
|
||||
```
|
||||
uv run "$SCRIPT" execute --path scratch.ipynb --code $'import os\nfiles = os.listdir(".")\nprint(f"Found {len(files)} files")' --compact
|
||||
```
|
||||
|
||||
### 3. Inspect live variables
|
||||
|
||||
```
|
||||
uv run "$SCRIPT" variables --path <notebook.ipynb> list --compact
|
||||
uv run "$SCRIPT" variables --path <notebook.ipynb> preview --name <varname> --compact
|
||||
```
|
||||
|
||||
### 4. Edit notebook cells
|
||||
|
||||
```
|
||||
# View current cells
|
||||
uv run "$SCRIPT" contents --path <notebook.ipynb> --compact
|
||||
|
||||
# Insert a new cell
|
||||
uv run "$SCRIPT" edit --path <notebook.ipynb> insert \
|
||||
--at-index <N> --cell-type code --source '<code>' --compact
|
||||
|
||||
# Replace cell source (use cell-id from contents output)
|
||||
uv run "$SCRIPT" edit --path <notebook.ipynb> replace-source \
|
||||
--cell-id <id> --source '<new code>' --compact
|
||||
|
||||
# Delete a cell
|
||||
uv run "$SCRIPT" edit --path <notebook.ipynb> delete --cell-id <id> --compact
|
||||
```
|
||||
|
||||
### 5. Verification (restart + run all)
|
||||
|
||||
Only use when the user asks for a clean verification or you need to confirm
|
||||
the notebook runs top-to-bottom:
|
||||
|
||||
```
|
||||
uv run "$SCRIPT" restart-run-all --path <notebook.ipynb> --save-outputs --compact
|
||||
```
|
||||
|
||||
## Practical Tips from Experience
|
||||
|
||||
1. **First execution after server start may timeout** — the kernel needs a moment
|
||||
to initialize. If you get a timeout, just retry.
|
||||
|
||||
2. **The kernel Python is JupyterLab's Python** — packages must be installed in
|
||||
that environment. If you need additional packages, install them into the
|
||||
JupyterLab tool environment first.
|
||||
|
||||
3. **--compact flag saves significant tokens** — always use it. JSON output can
|
||||
be very verbose without it.
|
||||
|
||||
4. **For pure REPL use**, create a scratch.ipynb and don't bother with cell editing.
|
||||
Just use `execute` repeatedly.
|
||||
|
||||
5. **Argument order matters** — subcommand flags like `--path` go BEFORE the
|
||||
sub-subcommand. E.g.: `variables --path nb.ipynb list` not `variables list --path nb.ipynb`.
|
||||
|
||||
6. **If a session doesn't exist yet**, you need to start one via the REST API
|
||||
(see Setup section). The tool can't execute without a live kernel session.
|
||||
|
||||
7. **Errors are returned as JSON** with traceback — read the `ename` and `evalue`
|
||||
fields to understand what went wrong.
|
||||
|
||||
8. **Occasional websocket timeouts** — some operations may timeout on first try,
|
||||
especially after a kernel restart. Retry once before escalating.
|
||||
|
||||
## Timeout Defaults
|
||||
|
||||
The script has a 30-second default timeout per execution. For long-running
|
||||
operations, pass `--timeout 120`. Use generous timeouts (60+) for initial
|
||||
setup or heavy computation.
|
||||
@@ -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
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Tests for acp_adapter.auth — provider detection."""
|
||||
|
||||
from acp_adapter.auth import has_provider, detect_provider
|
||||
|
||||
|
||||
class TestHasProvider:
|
||||
def test_has_provider_with_resolved_runtime(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": "openrouter", "api_key": "sk-or-test"},
|
||||
)
|
||||
assert has_provider() is True
|
||||
|
||||
def test_has_no_provider_when_runtime_has_no_key(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": "openrouter", "api_key": ""},
|
||||
)
|
||||
assert has_provider() is False
|
||||
|
||||
def test_has_no_provider_when_runtime_resolution_fails(self, monkeypatch):
|
||||
def _boom():
|
||||
raise RuntimeError("no provider")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom)
|
||||
assert has_provider() is False
|
||||
|
||||
|
||||
class TestDetectProvider:
|
||||
def test_detect_openrouter(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": "openrouter", "api_key": "sk-or-test"},
|
||||
)
|
||||
assert detect_provider() == "openrouter"
|
||||
|
||||
def test_detect_anthropic(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": "anthropic", "api_key": "sk-ant-test"},
|
||||
)
|
||||
assert detect_provider() == "anthropic"
|
||||
|
||||
def test_detect_none_when_no_key(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda: {"provider": "kimi-coding", "api_key": ""},
|
||||
)
|
||||
assert detect_provider() is None
|
||||
|
||||
def test_detect_none_on_resolution_error(self, monkeypatch):
|
||||
def _boom():
|
||||
raise RuntimeError("broken")
|
||||
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _boom)
|
||||
assert detect_provider() is None
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Tests for acp_adapter.events — callback factories for ACP notifications."""
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import Future
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import acp
|
||||
from acp.schema import ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk
|
||||
|
||||
from acp_adapter.events import (
|
||||
make_message_cb,
|
||||
make_step_cb,
|
||||
make_thinking_cb,
|
||||
make_tool_progress_cb,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_conn():
|
||||
"""Mock ACP Client connection."""
|
||||
conn = MagicMock(spec=acp.Client)
|
||||
conn.session_update = AsyncMock()
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def event_loop_fixture():
|
||||
"""Create a real event loop for testing threadsafe coroutine submission."""
|
||||
loop = asyncio.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool progress callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolProgressCallback:
|
||||
def test_emits_tool_call_start(self, mock_conn, event_loop_fixture):
|
||||
"""Tool progress should emit a ToolCallStart update."""
|
||||
tool_call_ids = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
|
||||
# Run callback in the event loop context
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb("terminal", "$ ls -la", {"command": "ls -la"})
|
||||
|
||||
# Should have tracked the tool call ID
|
||||
assert "terminal" in tool_call_ids
|
||||
|
||||
# Should have called run_coroutine_threadsafe
|
||||
mock_rcts.assert_called_once()
|
||||
coro = mock_rcts.call_args[0][0]
|
||||
# The coroutine should be conn.session_update
|
||||
assert mock_conn.session_update.called or coro is not None
|
||||
|
||||
def test_handles_string_args(self, mock_conn, event_loop_fixture):
|
||||
"""If args is a JSON string, it should be parsed."""
|
||||
tool_call_ids = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb("read_file", "Reading /etc/hosts", '{"path": "/etc/hosts"}')
|
||||
|
||||
assert "read_file" in tool_call_ids
|
||||
|
||||
def test_handles_non_dict_args(self, mock_conn, event_loop_fixture):
|
||||
"""If args is not a dict, it should be wrapped."""
|
||||
tool_call_ids = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb("terminal", "$ echo hi", None)
|
||||
|
||||
assert "terminal" in tool_call_ids
|
||||
|
||||
def test_duplicate_same_name_tool_calls_use_fifo_ids(self, mock_conn, event_loop_fixture):
|
||||
"""Multiple same-name tool calls should be tracked independently in order."""
|
||||
tool_call_ids = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
progress_cb = make_tool_progress_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
step_cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
progress_cb("terminal", "$ ls", {"command": "ls"})
|
||||
progress_cb("terminal", "$ pwd", {"command": "pwd"})
|
||||
assert len(tool_call_ids["terminal"]) == 2
|
||||
|
||||
step_cb(1, [{"name": "terminal", "result": "ok-1"}])
|
||||
assert len(tool_call_ids["terminal"]) == 1
|
||||
|
||||
step_cb(2, [{"name": "terminal", "result": "ok-2"}])
|
||||
assert "terminal" not in tool_call_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thinking callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThinkingCallback:
|
||||
def test_emits_thought_chunk(self, mock_conn, event_loop_fixture):
|
||||
"""Thinking callback should emit AgentThoughtChunk."""
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_thinking_cb(mock_conn, "session-1", loop)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb("Analyzing the code...")
|
||||
|
||||
mock_rcts.assert_called_once()
|
||||
|
||||
def test_ignores_empty_text(self, mock_conn, event_loop_fixture):
|
||||
"""Empty text should not emit any update."""
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_thinking_cb(mock_conn, "session-1", loop)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
cb("")
|
||||
|
||||
mock_rcts.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStepCallback:
|
||||
def test_completes_tracked_tool_calls(self, mock_conn, event_loop_fixture):
|
||||
"""Step callback should mark tracked tools as completed."""
|
||||
tool_call_ids = {"terminal": "tc-abc123"}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb(1, [{"name": "terminal", "result": "success"}])
|
||||
|
||||
# Tool should have been removed from tracking
|
||||
assert "terminal" not in tool_call_ids
|
||||
mock_rcts.assert_called_once()
|
||||
|
||||
def test_ignores_untracked_tools(self, mock_conn, event_loop_fixture):
|
||||
"""Tools not in tool_call_ids should be silently ignored."""
|
||||
tool_call_ids = {}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
cb(1, [{"name": "unknown_tool", "result": "ok"}])
|
||||
|
||||
mock_rcts.assert_not_called()
|
||||
|
||||
def test_handles_string_tool_info(self, mock_conn, event_loop_fixture):
|
||||
"""Tool info as a string (just the name) should work."""
|
||||
tool_call_ids = {"read_file": "tc-def456"}
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_step_cb(mock_conn, "session-1", loop, tool_call_ids)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb(2, ["read_file"])
|
||||
|
||||
assert "read_file" not in tool_call_ids
|
||||
mock_rcts.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message callback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMessageCallback:
|
||||
def test_emits_agent_message_chunk(self, mock_conn, event_loop_fixture):
|
||||
"""Message callback should emit AgentMessageChunk."""
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_message_cb(mock_conn, "session-1", loop)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = None
|
||||
mock_rcts.return_value = future
|
||||
|
||||
cb("Here is your answer.")
|
||||
|
||||
mock_rcts.assert_called_once()
|
||||
|
||||
def test_ignores_empty_message(self, mock_conn, event_loop_fixture):
|
||||
"""Empty text should not emit any update."""
|
||||
loop = event_loop_fixture
|
||||
|
||||
cb = make_message_cb(mock_conn, "session-1", loop)
|
||||
|
||||
with patch("acp_adapter.events.asyncio.run_coroutine_threadsafe") as mock_rcts:
|
||||
cb("")
|
||||
|
||||
mock_rcts.assert_not_called()
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Tests for acp_adapter.permissions — ACP approval bridging."""
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import Future
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from acp.schema import (
|
||||
AllowedOutcome,
|
||||
DeniedOutcome,
|
||||
RequestPermissionResponse,
|
||||
)
|
||||
from acp_adapter.permissions import make_approval_callback
|
||||
|
||||
|
||||
def _make_response(outcome):
|
||||
"""Helper to build a RequestPermissionResponse with the given outcome."""
|
||||
return RequestPermissionResponse(outcome=outcome)
|
||||
|
||||
|
||||
def _setup_callback(outcome, timeout=60.0):
|
||||
"""
|
||||
Create a callback wired to a mock request_permission coroutine
|
||||
that resolves to the given outcome.
|
||||
|
||||
Returns:
|
||||
(callback, mock_request_permission_fn)
|
||||
"""
|
||||
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
||||
mock_rp = MagicMock(name="request_permission")
|
||||
|
||||
response = _make_response(outcome)
|
||||
|
||||
# Patch asyncio.run_coroutine_threadsafe so it returns a future
|
||||
# that immediately yields the response.
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.return_value = response
|
||||
|
||||
with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", return_value=future):
|
||||
cb = make_approval_callback(mock_rp, loop, session_id="s1", timeout=timeout)
|
||||
result = cb("rm -rf /", "dangerous command")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TestApprovalMapping:
|
||||
def test_approval_allow_once_maps_correctly(self):
|
||||
outcome = AllowedOutcome(option_id="allow_once", outcome="selected")
|
||||
result = _setup_callback(outcome)
|
||||
assert result == "once"
|
||||
|
||||
def test_approval_allow_always_maps_correctly(self):
|
||||
outcome = AllowedOutcome(option_id="allow_always", outcome="selected")
|
||||
result = _setup_callback(outcome)
|
||||
assert result == "always"
|
||||
|
||||
def test_approval_deny_maps_correctly(self):
|
||||
outcome = DeniedOutcome(outcome="cancelled")
|
||||
result = _setup_callback(outcome)
|
||||
assert result == "deny"
|
||||
|
||||
def test_approval_timeout_returns_deny(self):
|
||||
"""When the future times out, the callback should return 'deny'."""
|
||||
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
||||
mock_rp = MagicMock(name="request_permission")
|
||||
|
||||
future = MagicMock(spec=Future)
|
||||
future.result.side_effect = TimeoutError("timed out")
|
||||
|
||||
with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", return_value=future):
|
||||
cb = make_approval_callback(mock_rp, loop, session_id="s1", timeout=0.01)
|
||||
result = cb("rm -rf /", "dangerous")
|
||||
|
||||
assert result == "deny"
|
||||
@@ -1,297 +0,0 @@
|
||||
"""Tests for acp_adapter.server — HermesACPAgent ACP server."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import acp
|
||||
from acp.schema import (
|
||||
AgentCapabilities,
|
||||
AuthenticateResponse,
|
||||
Implementation,
|
||||
InitializeResponse,
|
||||
ListSessionsResponse,
|
||||
LoadSessionResponse,
|
||||
NewSessionResponse,
|
||||
PromptResponse,
|
||||
ResumeSessionResponse,
|
||||
SessionInfo,
|
||||
TextContentBlock,
|
||||
Usage,
|
||||
)
|
||||
from acp_adapter.server import HermesACPAgent, HERMES_VERSION
|
||||
from acp_adapter.session import SessionManager
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_manager():
|
||||
"""SessionManager with a mock agent factory."""
|
||||
return SessionManager(agent_factory=lambda: MagicMock(name="MockAIAgent"))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def agent(mock_manager):
|
||||
"""HermesACPAgent backed by a mock session manager."""
|
||||
return HermesACPAgent(session_manager=mock_manager)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# initialize
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitialize:
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_returns_correct_protocol_version(self, agent):
|
||||
resp = await agent.initialize(protocol_version=1)
|
||||
assert isinstance(resp, InitializeResponse)
|
||||
assert resp.protocol_version == acp.PROTOCOL_VERSION
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_returns_agent_info(self, agent):
|
||||
resp = await agent.initialize(protocol_version=1)
|
||||
assert resp.agent_info is not None
|
||||
assert isinstance(resp.agent_info, Implementation)
|
||||
assert resp.agent_info.name == "hermes-agent"
|
||||
assert resp.agent_info.version == HERMES_VERSION
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_returns_capabilities(self, agent):
|
||||
resp = await agent.initialize(protocol_version=1)
|
||||
caps = resp.agent_capabilities
|
||||
assert isinstance(caps, AgentCapabilities)
|
||||
assert caps.session_capabilities is not None
|
||||
assert caps.session_capabilities.fork is not None
|
||||
assert caps.session_capabilities.list is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# authenticate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_with_provider_configured(self, agent, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"acp_adapter.server.has_provider",
|
||||
lambda: True,
|
||||
)
|
||||
resp = await agent.authenticate(method_id="openrouter")
|
||||
assert isinstance(resp, AuthenticateResponse)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_without_provider(self, agent, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"acp_adapter.server.has_provider",
|
||||
lambda: False,
|
||||
)
|
||||
resp = await agent.authenticate(method_id="openrouter")
|
||||
assert resp is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_session / cancel / load / resume
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSessionOps:
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_session_creates_session(self, agent):
|
||||
resp = await agent.new_session(cwd="/home/user/project")
|
||||
assert isinstance(resp, NewSessionResponse)
|
||||
assert resp.session_id
|
||||
# Session should be retrievable from the manager
|
||||
state = agent.session_manager.get_session(resp.session_id)
|
||||
assert state is not None
|
||||
assert state.cwd == "/home/user/project"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_sets_event(self, agent):
|
||||
resp = await agent.new_session(cwd=".")
|
||||
state = agent.session_manager.get_session(resp.session_id)
|
||||
assert not state.cancel_event.is_set()
|
||||
await agent.cancel(session_id=resp.session_id)
|
||||
assert state.cancel_event.is_set()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_nonexistent_session_is_noop(self, agent):
|
||||
# Should not raise
|
||||
await agent.cancel(session_id="does-not-exist")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_returns_response(self, agent):
|
||||
resp = await agent.new_session(cwd="/tmp")
|
||||
load_resp = await agent.load_session(cwd="/tmp", session_id=resp.session_id)
|
||||
assert isinstance(load_resp, LoadSessionResponse)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_session_not_found_returns_none(self, agent):
|
||||
resp = await agent.load_session(cwd="/tmp", session_id="bogus")
|
||||
assert resp is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_session_returns_response(self, agent):
|
||||
resp = await agent.new_session(cwd="/tmp")
|
||||
resume_resp = await agent.resume_session(cwd="/tmp", session_id=resp.session_id)
|
||||
assert isinstance(resume_resp, ResumeSessionResponse)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_session_creates_new_if_missing(self, agent):
|
||||
resume_resp = await agent.resume_session(cwd="/tmp", session_id="nonexistent")
|
||||
assert isinstance(resume_resp, ResumeSessionResponse)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list / fork
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListAndFork:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions(self, agent):
|
||||
await agent.new_session(cwd="/a")
|
||||
await agent.new_session(cwd="/b")
|
||||
resp = await agent.list_sessions()
|
||||
assert isinstance(resp, ListSessionsResponse)
|
||||
assert len(resp.sessions) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_session(self, agent):
|
||||
new_resp = await agent.new_session(cwd="/original")
|
||||
fork_resp = await agent.fork_session(cwd="/forked", session_id=new_resp.session_id)
|
||||
assert fork_resp.session_id
|
||||
assert fork_resp.session_id != new_resp.session_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPrompt:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_returns_refusal_for_unknown_session(self, agent):
|
||||
prompt = [TextContentBlock(type="text", text="hello")]
|
||||
resp = await agent.prompt(prompt=prompt, session_id="nonexistent")
|
||||
assert isinstance(resp, PromptResponse)
|
||||
assert resp.stop_reason == "refusal"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_returns_end_turn_for_empty_message(self, agent):
|
||||
new_resp = await agent.new_session(cwd=".")
|
||||
prompt = [TextContentBlock(type="text", text=" ")]
|
||||
resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
|
||||
assert resp.stop_reason == "end_turn"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_runs_agent(self, agent):
|
||||
"""The prompt method should call run_conversation on the agent."""
|
||||
new_resp = await agent.new_session(cwd=".")
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
|
||||
# Mock the agent's run_conversation
|
||||
state.agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "Hello! How can I help?",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "Hello! How can I help?"},
|
||||
],
|
||||
})
|
||||
|
||||
# Set up a mock connection
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
agent._conn = mock_conn
|
||||
|
||||
prompt = [TextContentBlock(type="text", text="hello")]
|
||||
resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
|
||||
|
||||
assert isinstance(resp, PromptResponse)
|
||||
assert resp.stop_reason == "end_turn"
|
||||
state.agent.run_conversation.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_updates_history(self, agent):
|
||||
"""After a prompt, session history should be updated."""
|
||||
new_resp = await agent.new_session(cwd=".")
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
|
||||
expected_history = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hey"},
|
||||
]
|
||||
state.agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "hey",
|
||||
"messages": expected_history,
|
||||
})
|
||||
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
agent._conn = mock_conn
|
||||
|
||||
prompt = [TextContentBlock(type="text", text="hi")]
|
||||
await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
|
||||
|
||||
assert state.history == expected_history
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_sends_final_message_update(self, agent):
|
||||
"""The final response should be sent as an AgentMessageChunk."""
|
||||
new_resp = await agent.new_session(cwd=".")
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
|
||||
state.agent.run_conversation = MagicMock(return_value={
|
||||
"final_response": "I can help with that!",
|
||||
"messages": [],
|
||||
})
|
||||
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
agent._conn = mock_conn
|
||||
|
||||
prompt = [TextContentBlock(type="text", text="help me")]
|
||||
await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
|
||||
|
||||
# session_update should have been called with the final message
|
||||
mock_conn.session_update.assert_called()
|
||||
# Get the last call's update argument
|
||||
last_call = mock_conn.session_update.call_args_list[-1]
|
||||
update = last_call[1].get("update") or last_call[0][1]
|
||||
assert update.session_update == "agent_message_chunk"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_cancelled_returns_cancelled_stop_reason(self, agent):
|
||||
"""If cancel is called during prompt, stop_reason should be 'cancelled'."""
|
||||
new_resp = await agent.new_session(cwd=".")
|
||||
state = agent.session_manager.get_session(new_resp.session_id)
|
||||
|
||||
def mock_run(*args, **kwargs):
|
||||
# Simulate cancel being set during execution
|
||||
state.cancel_event.set()
|
||||
return {"final_response": "interrupted", "messages": []}
|
||||
|
||||
state.agent.run_conversation = mock_run
|
||||
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
mock_conn.session_update = AsyncMock()
|
||||
agent._conn = mock_conn
|
||||
|
||||
prompt = [TextContentBlock(type="text", text="do something")]
|
||||
resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)
|
||||
|
||||
assert resp.stop_reason == "cancelled"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# on_connect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOnConnect:
|
||||
def test_on_connect_stores_client(self, agent):
|
||||
mock_conn = MagicMock(spec=acp.Client)
|
||||
agent.on_connect(mock_conn)
|
||||
assert agent._conn is mock_conn
|
||||
@@ -1,112 +0,0 @@
|
||||
"""Tests for acp_adapter.session — SessionManager and SessionState."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from acp_adapter.session import SessionManager, SessionState
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def manager():
|
||||
"""SessionManager with a mock agent factory (avoids needing API keys)."""
|
||||
return SessionManager(agent_factory=lambda: MagicMock(name="MockAIAgent"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create / get
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateSession:
|
||||
def test_create_session_returns_state(self, manager):
|
||||
state = manager.create_session(cwd="/tmp/work")
|
||||
assert isinstance(state, SessionState)
|
||||
assert state.cwd == "/tmp/work"
|
||||
assert state.session_id
|
||||
assert state.history == []
|
||||
assert state.agent is not None
|
||||
|
||||
def test_create_session_registers_task_cwd(self, manager, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr("acp_adapter.session._register_task_cwd", lambda task_id, cwd: calls.append((task_id, cwd)))
|
||||
state = manager.create_session(cwd="/tmp/work")
|
||||
assert calls == [(state.session_id, "/tmp/work")]
|
||||
|
||||
def test_session_ids_are_unique(self, manager):
|
||||
s1 = manager.create_session()
|
||||
s2 = manager.create_session()
|
||||
assert s1.session_id != s2.session_id
|
||||
|
||||
def test_get_session(self, manager):
|
||||
state = manager.create_session()
|
||||
fetched = manager.get_session(state.session_id)
|
||||
assert fetched is state
|
||||
|
||||
def test_get_nonexistent_session_returns_none(self, manager):
|
||||
assert manager.get_session("does-not-exist") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fork
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForkSession:
|
||||
def test_fork_session_deep_copies_history(self, manager):
|
||||
original = manager.create_session()
|
||||
original.history.append({"role": "user", "content": "hello"})
|
||||
original.history.append({"role": "assistant", "content": "hi"})
|
||||
|
||||
forked = manager.fork_session(original.session_id, cwd="/new")
|
||||
assert forked is not None
|
||||
|
||||
# History should be equal in content
|
||||
assert len(forked.history) == 2
|
||||
assert forked.history[0]["content"] == "hello"
|
||||
|
||||
# But a deep copy — mutating one doesn't affect the other
|
||||
forked.history.append({"role": "user", "content": "extra"})
|
||||
assert len(original.history) == 2
|
||||
assert len(forked.history) == 3
|
||||
|
||||
def test_fork_session_has_new_id(self, manager):
|
||||
original = manager.create_session()
|
||||
forked = manager.fork_session(original.session_id)
|
||||
assert forked is not None
|
||||
assert forked.session_id != original.session_id
|
||||
|
||||
def test_fork_nonexistent_returns_none(self, manager):
|
||||
assert manager.fork_session("bogus-id") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list / cleanup / remove
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListAndCleanup:
|
||||
def test_list_sessions_empty(self, manager):
|
||||
assert manager.list_sessions() == []
|
||||
|
||||
def test_list_sessions_returns_created(self, manager):
|
||||
s1 = manager.create_session(cwd="/a")
|
||||
s2 = manager.create_session(cwd="/b")
|
||||
listing = manager.list_sessions()
|
||||
ids = {s["session_id"] for s in listing}
|
||||
assert s1.session_id in ids
|
||||
assert s2.session_id in ids
|
||||
assert len(listing) == 2
|
||||
|
||||
def test_cleanup_clears_all(self, manager):
|
||||
manager.create_session()
|
||||
manager.create_session()
|
||||
assert len(manager.list_sessions()) == 2
|
||||
manager.cleanup()
|
||||
assert manager.list_sessions() == []
|
||||
|
||||
def test_remove_session(self, manager):
|
||||
state = manager.create_session()
|
||||
assert manager.remove_session(state.session_id) is True
|
||||
assert manager.get_session(state.session_id) is None
|
||||
# Removing again returns False
|
||||
assert manager.remove_session(state.session_id) is False
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Tests for acp_adapter.tools — tool kind mapping and ACP content building."""
|
||||
|
||||
import pytest
|
||||
|
||||
from acp_adapter.tools import (
|
||||
TOOL_KIND_MAP,
|
||||
build_tool_complete,
|
||||
build_tool_start,
|
||||
build_tool_title,
|
||||
extract_locations,
|
||||
get_tool_kind,
|
||||
make_tool_call_id,
|
||||
)
|
||||
from acp.schema import (
|
||||
FileEditToolCallContent,
|
||||
ContentToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolCallStart,
|
||||
ToolCallProgress,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TOOL_KIND_MAP coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
COMMON_HERMES_TOOLS = ["read_file", "search_files", "terminal", "patch", "write_file", "process"]
|
||||
|
||||
|
||||
class TestToolKindMap:
|
||||
def test_all_hermes_tools_have_kind(self):
|
||||
"""Every common hermes tool should appear in TOOL_KIND_MAP."""
|
||||
for tool in COMMON_HERMES_TOOLS:
|
||||
assert tool in TOOL_KIND_MAP, f"{tool} missing from TOOL_KIND_MAP"
|
||||
|
||||
def test_tool_kind_read_file(self):
|
||||
assert get_tool_kind("read_file") == "read"
|
||||
|
||||
def test_tool_kind_terminal(self):
|
||||
assert get_tool_kind("terminal") == "execute"
|
||||
|
||||
def test_tool_kind_patch(self):
|
||||
assert get_tool_kind("patch") == "edit"
|
||||
|
||||
def test_tool_kind_write_file(self):
|
||||
assert get_tool_kind("write_file") == "edit"
|
||||
|
||||
def test_tool_kind_web_search(self):
|
||||
assert get_tool_kind("web_search") == "fetch"
|
||||
|
||||
def test_tool_kind_execute_code(self):
|
||||
assert get_tool_kind("execute_code") == "execute"
|
||||
|
||||
def test_tool_kind_browser_navigate(self):
|
||||
assert get_tool_kind("browser_navigate") == "fetch"
|
||||
|
||||
def test_unknown_tool_returns_other_kind(self):
|
||||
assert get_tool_kind("nonexistent_tool_xyz") == "other"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# make_tool_call_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMakeToolCallId:
|
||||
def test_returns_string(self):
|
||||
tc_id = make_tool_call_id()
|
||||
assert isinstance(tc_id, str)
|
||||
|
||||
def test_starts_with_tc_prefix(self):
|
||||
tc_id = make_tool_call_id()
|
||||
assert tc_id.startswith("tc-")
|
||||
|
||||
def test_ids_are_unique(self):
|
||||
ids = {make_tool_call_id() for _ in range(100)}
|
||||
assert len(ids) == 100
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_tool_title
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildToolTitle:
|
||||
def test_terminal_title_includes_command(self):
|
||||
title = build_tool_title("terminal", {"command": "ls -la /tmp"})
|
||||
assert "ls -la /tmp" in title
|
||||
|
||||
def test_terminal_title_truncates_long_command(self):
|
||||
long_cmd = "x" * 200
|
||||
title = build_tool_title("terminal", {"command": long_cmd})
|
||||
assert len(title) < 120
|
||||
assert "..." in title
|
||||
|
||||
def test_read_file_title(self):
|
||||
title = build_tool_title("read_file", {"path": "/etc/hosts"})
|
||||
assert "/etc/hosts" in title
|
||||
|
||||
def test_patch_title(self):
|
||||
title = build_tool_title("patch", {"path": "main.py", "mode": "replace"})
|
||||
assert "main.py" in title
|
||||
|
||||
def test_search_title(self):
|
||||
title = build_tool_title("search_files", {"pattern": "TODO"})
|
||||
assert "TODO" in title
|
||||
|
||||
def test_web_search_title(self):
|
||||
title = build_tool_title("web_search", {"query": "python asyncio"})
|
||||
assert "python asyncio" in title
|
||||
|
||||
def test_unknown_tool_uses_name(self):
|
||||
title = build_tool_title("some_new_tool", {"foo": "bar"})
|
||||
assert title == "some_new_tool"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_tool_start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildToolStart:
|
||||
def test_build_tool_start_for_patch(self):
|
||||
"""patch should produce a FileEditToolCallContent (diff)."""
|
||||
args = {
|
||||
"path": "src/main.py",
|
||||
"old_string": "print('hello')",
|
||||
"new_string": "print('world')",
|
||||
}
|
||||
result = build_tool_start("tc-1", "patch", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "edit"
|
||||
# The first content item should be a diff
|
||||
assert len(result.content) >= 1
|
||||
diff_item = result.content[0]
|
||||
assert isinstance(diff_item, FileEditToolCallContent)
|
||||
assert diff_item.path == "src/main.py"
|
||||
assert diff_item.new_text == "print('world')"
|
||||
assert diff_item.old_text == "print('hello')"
|
||||
|
||||
def test_build_tool_start_for_write_file(self):
|
||||
"""write_file should produce a FileEditToolCallContent (diff)."""
|
||||
args = {"path": "new_file.py", "content": "print('hello')"}
|
||||
result = build_tool_start("tc-w1", "write_file", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "edit"
|
||||
assert len(result.content) >= 1
|
||||
diff_item = result.content[0]
|
||||
assert isinstance(diff_item, FileEditToolCallContent)
|
||||
assert diff_item.path == "new_file.py"
|
||||
|
||||
def test_build_tool_start_for_terminal(self):
|
||||
"""terminal should produce text content with the command."""
|
||||
args = {"command": "ls -la /tmp"}
|
||||
result = build_tool_start("tc-2", "terminal", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "execute"
|
||||
assert len(result.content) >= 1
|
||||
content_item = result.content[0]
|
||||
assert isinstance(content_item, ContentToolCallContent)
|
||||
# The wrapped text block should contain the command
|
||||
text = content_item.content.text
|
||||
assert "ls -la /tmp" in text
|
||||
|
||||
def test_build_tool_start_for_read_file(self):
|
||||
"""read_file should include the path in content."""
|
||||
args = {"path": "/etc/hosts", "offset": 1, "limit": 50}
|
||||
result = build_tool_start("tc-3", "read_file", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "read"
|
||||
assert len(result.content) >= 1
|
||||
content_item = result.content[0]
|
||||
assert isinstance(content_item, ContentToolCallContent)
|
||||
assert "/etc/hosts" in content_item.content.text
|
||||
|
||||
def test_build_tool_start_for_search(self):
|
||||
"""search_files should include pattern in content."""
|
||||
args = {"pattern": "TODO", "target": "content"}
|
||||
result = build_tool_start("tc-4", "search_files", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "search"
|
||||
assert "TODO" in result.content[0].content.text
|
||||
|
||||
def test_build_tool_start_generic_fallback(self):
|
||||
"""Unknown tools should get a generic text representation."""
|
||||
args = {"foo": "bar", "baz": 42}
|
||||
result = build_tool_start("tc-5", "some_tool", args)
|
||||
assert isinstance(result, ToolCallStart)
|
||||
assert result.kind == "other"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_tool_complete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildToolComplete:
|
||||
def test_build_tool_complete_for_terminal(self):
|
||||
"""Completed terminal call should include output text."""
|
||||
result = build_tool_complete("tc-2", "terminal", "total 42\ndrwxr-xr-x 2 root root 4096 ...")
|
||||
assert isinstance(result, ToolCallProgress)
|
||||
assert result.status == "completed"
|
||||
assert len(result.content) >= 1
|
||||
content_item = result.content[0]
|
||||
assert isinstance(content_item, ContentToolCallContent)
|
||||
assert "total 42" in content_item.content.text
|
||||
|
||||
def test_build_tool_complete_truncates_large_output(self):
|
||||
"""Very large outputs should be truncated."""
|
||||
big_output = "x" * 10000
|
||||
result = build_tool_complete("tc-6", "read_file", big_output)
|
||||
assert isinstance(result, ToolCallProgress)
|
||||
display_text = result.content[0].content.text
|
||||
assert len(display_text) < 6000
|
||||
assert "truncated" in display_text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_locations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractLocations:
|
||||
def test_extract_locations_with_path(self):
|
||||
args = {"path": "src/app.py", "offset": 42}
|
||||
locs = extract_locations(args)
|
||||
assert len(locs) == 1
|
||||
assert isinstance(locs[0], ToolCallLocation)
|
||||
assert locs[0].path == "src/app.py"
|
||||
assert locs[0].line == 42
|
||||
|
||||
def test_extract_locations_without_path(self):
|
||||
args = {"command": "echo hi"}
|
||||
locs = extract_locations(args)
|
||||
assert locs == []
|
||||
@@ -129,7 +129,6 @@ class TestGetTextAuxiliaryClient:
|
||||
def test_custom_endpoint_over_codex(self, monkeypatch, codex_auth_dir):
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "lm-studio-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
|
||||
# Override the autouse monkeypatch for codex
|
||||
monkeypatch.setattr(
|
||||
"agent.auxiliary_client._read_codex_access_token",
|
||||
@@ -138,7 +137,7 @@ class TestGetTextAuxiliaryClient:
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_text_auxiliary_client()
|
||||
assert model == "my-local-model"
|
||||
assert model == "gpt-4o-mini"
|
||||
call_kwargs = mock_openai.call_args
|
||||
assert call_kwargs.kwargs["base_url"] == "http://localhost:1234/v1"
|
||||
|
||||
@@ -151,13 +150,9 @@ class TestGetTextAuxiliaryClient:
|
||||
from agent.auxiliary_client import CodexAuxiliaryClient
|
||||
assert isinstance(client, CodexAuxiliaryClient)
|
||||
|
||||
def test_returns_none_when_nothing_available(self, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
def test_returns_none_when_nothing_available(self):
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
|
||||
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None):
|
||||
client, model = get_text_auxiliary_client()
|
||||
assert client is None
|
||||
assert model is None
|
||||
@@ -214,21 +209,17 @@ class TestVisionClientFallback:
|
||||
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://localhost:1234/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert client is not None
|
||||
assert model == "my-local-model"
|
||||
assert model == "gpt-4o-mini"
|
||||
|
||||
def test_vision_forced_main_returns_none_without_creds(self, monkeypatch):
|
||||
"""Forced main with no credentials still returns None."""
|
||||
monkeypatch.setenv("AUXILIARY_VISION_PROVIDER", "main")
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None), \
|
||||
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)):
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None):
|
||||
client, model = get_vision_auxiliary_client()
|
||||
assert client is None
|
||||
assert model is None
|
||||
@@ -314,23 +305,21 @@ class TestResolveForcedProvider:
|
||||
def test_forced_main_uses_custom(self, monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://local:8080/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = _resolve_forced_provider("main")
|
||||
assert model == "my-local-model"
|
||||
assert model == "gpt-4o-mini"
|
||||
|
||||
def test_forced_main_skips_openrouter_nous(self, monkeypatch):
|
||||
"""Even if OpenRouter key is set, 'main' skips it."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://local:8080/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "local-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "my-local-model")
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
patch("agent.auxiliary_client.OpenAI") as mock_openai:
|
||||
client, model = _resolve_forced_provider("main")
|
||||
# Should use custom endpoint, not OpenRouter
|
||||
assert model == "my-local-model"
|
||||
assert model == "gpt-4o-mini"
|
||||
|
||||
def test_forced_main_falls_to_codex(self, codex_auth_dir, monkeypatch):
|
||||
with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \
|
||||
|
||||
@@ -153,74 +153,6 @@ class TestGenerateSummaryNoneContent:
|
||||
assert len(result) < len(msgs)
|
||||
|
||||
|
||||
class TestSummaryPrompt:
|
||||
def test_generate_summary_uses_handoff_compaction_prompt(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: compacted"
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True, summary_target_tokens=1234)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Investigate the failing test"},
|
||||
{"role": "assistant", "content": "I'll inspect the traceback."},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call_llm:
|
||||
c._generate_summary(messages)
|
||||
|
||||
prompt = mock_call_llm.call_args.kwargs["messages"][0]["content"]
|
||||
assert "CONTEXT CHECKPOINT COMPACTION" in prompt
|
||||
assert "Current progress and key decisions made" in prompt
|
||||
assert "Important context, constraints, or user preferences discovered" in prompt
|
||||
assert "What remains to be done (clear next steps)" in prompt
|
||||
assert "Tool calls made and their key results" in prompt
|
||||
assert "Target roughly 1234 tokens." in prompt
|
||||
assert 'Write only the summary, starting with "[CONTEXT SUMMARY]:" prefix.' in prompt
|
||||
|
||||
|
||||
class TestNonStringContent:
|
||||
"""Regression: content as dict (e.g., llama.cpp tool calls) must not crash."""
|
||||
|
||||
def test_dict_content_coerced_to_string(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = {"text": "some summary"}
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "do something"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
||||
summary = c._generate_summary(messages)
|
||||
assert isinstance(summary, str)
|
||||
assert "CONTEXT SUMMARY" in summary
|
||||
|
||||
def test_none_content_coerced_to_empty(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = None
|
||||
|
||||
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||
c = ContextCompressor(model="test", quiet_mode=True)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "do something"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
|
||||
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
||||
summary = c._generate_summary(messages)
|
||||
# None content → empty string → "[CONTEXT SUMMARY]: " prefix added
|
||||
assert summary is not None
|
||||
assert "CONTEXT SUMMARY" in summary
|
||||
|
||||
|
||||
class TestCompressWithClient:
|
||||
def test_summarization_path(self):
|
||||
mock_client = MagicMock()
|
||||
|
||||
@@ -23,14 +23,6 @@ class TestApplyCacheMarker:
|
||||
_apply_cache_marker(msg, MARKER)
|
||||
assert msg["cache_control"] == MARKER
|
||||
|
||||
def test_empty_string_content_gets_top_level_marker(self):
|
||||
"""Empty text blocks cannot have cache_control (Anthropic rejects them)."""
|
||||
msg = {"role": "assistant", "content": ""}
|
||||
_apply_cache_marker(msg, MARKER)
|
||||
assert msg["cache_control"] == MARKER
|
||||
# Must NOT wrap into [{"type": "text", "text": "", "cache_control": ...}]
|
||||
assert msg["content"] == ""
|
||||
|
||||
def test_string_content_wrapped_in_list(self):
|
||||
msg = {"role": "user", "content": "Hello"}
|
||||
_apply_cache_marker(msg, MARKER)
|
||||
|
||||
@@ -106,47 +106,6 @@ class TestDeliverResultMirrorLogging:
|
||||
)
|
||||
|
||||
|
||||
class TestRunJobSessionPersistence:
|
||||
def test_run_job_passes_session_db_and_cron_platform(self, tmp_path):
|
||||
job = {
|
||||
"id": "test-job",
|
||||
"name": "test",
|
||||
"prompt": "hello",
|
||||
}
|
||||
fake_db = MagicMock()
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value={
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
},
|
||||
), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
|
||||
success, output, final_response, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == "ok"
|
||||
assert "ok" in output
|
||||
|
||||
kwargs = mock_agent_cls.call_args.kwargs
|
||||
assert kwargs["session_db"] is fake_db
|
||||
assert kwargs["platform"] == "cron"
|
||||
assert kwargs["session_id"].startswith("cron_test-job_")
|
||||
fake_db.close.assert_called_once()
|
||||
|
||||
|
||||
class TestRunJobConfigLogging:
|
||||
"""Verify that config.yaml parse failures are logged, not silently swallowed."""
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for gateway/channel_directory.py — channel resolution and display."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -123,7 +122,7 @@ class TestResolveChannelName:
|
||||
class TestBuildFromSessions:
|
||||
def _write_sessions(self, tmp_path, sessions_data):
|
||||
"""Write sessions.json at the path _build_from_sessions expects."""
|
||||
sessions_path = tmp_path / "sessions" / "sessions.json"
|
||||
sessions_path = tmp_path / ".hermes" / "sessions" / "sessions.json"
|
||||
sessions_path.parent.mkdir(parents=True)
|
||||
sessions_path.write_text(json.dumps(sessions_data))
|
||||
|
||||
@@ -153,7 +152,7 @@ class TestBuildFromSessions:
|
||||
},
|
||||
})
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
entries = _build_from_sessions("telegram")
|
||||
|
||||
assert len(entries) == 2
|
||||
@@ -162,7 +161,7 @@ class TestBuildFromSessions:
|
||||
assert "Bob" in names
|
||||
|
||||
def test_missing_sessions_file(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
entries = _build_from_sessions("telegram")
|
||||
assert entries == []
|
||||
|
||||
@@ -172,7 +171,7 @@ class TestBuildFromSessions:
|
||||
"s2": {"origin": {"platform": "telegram", "chat_id": "123", "chat_name": "X"}},
|
||||
})
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
entries = _build_from_sessions("telegram")
|
||||
|
||||
assert len(entries) == 1
|
||||
@@ -203,7 +202,7 @@ class TestBuildFromSessions:
|
||||
},
|
||||
})
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
entries = _build_from_sessions("telegram")
|
||||
|
||||
ids = {entry["id"] for entry in entries}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
"""Tests for the config.yaml → env var bridge logic in gateway/run.py.
|
||||
|
||||
Specifically tests that top-level `cwd:` and `backend:` in config.yaml
|
||||
are correctly bridged to TERMINAL_CWD / TERMINAL_ENV env vars as
|
||||
convenience aliases for `terminal.cwd` / `terminal.backend`.
|
||||
|
||||
The bridge logic is module-level code in gateway/run.py, so we test
|
||||
the semantics by reimplementing the relevant config bridge snippet and
|
||||
asserting the expected env var outcomes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
|
||||
"""Simulate the gateway config bridge logic from gateway/run.py.
|
||||
|
||||
Returns the resulting env dict (only TERMINAL_* and MESSAGING_CWD keys).
|
||||
"""
|
||||
env = dict(initial_env or {})
|
||||
|
||||
# --- Replicate lines 54-56: generic top-level bridge (for context) ---
|
||||
for key, val in cfg.items():
|
||||
if isinstance(val, (str, int, float, bool)) and key not in env:
|
||||
env[key] = str(val)
|
||||
|
||||
# --- Replicate lines 59-87: terminal config bridge ---
|
||||
terminal_cfg = cfg.get("terminal", {})
|
||||
if terminal_cfg and isinstance(terminal_cfg, dict):
|
||||
terminal_env_map = {
|
||||
"backend": "TERMINAL_ENV",
|
||||
"cwd": "TERMINAL_CWD",
|
||||
"timeout": "TERMINAL_TIMEOUT",
|
||||
}
|
||||
for cfg_key, env_var in terminal_env_map.items():
|
||||
if cfg_key in terminal_cfg:
|
||||
val = terminal_cfg[cfg_key]
|
||||
if isinstance(val, list):
|
||||
env[env_var] = json.dumps(val)
|
||||
else:
|
||||
env[env_var] = str(val)
|
||||
|
||||
# --- NEW: top-level aliases (the fix being tested) ---
|
||||
top_level_aliases = {
|
||||
"cwd": "TERMINAL_CWD",
|
||||
"backend": "TERMINAL_ENV",
|
||||
}
|
||||
for alias_key, alias_env in top_level_aliases.items():
|
||||
if alias_env not in env:
|
||||
alias_val = cfg.get(alias_key)
|
||||
if isinstance(alias_val, str) and alias_val.strip():
|
||||
env[alias_env] = alias_val.strip()
|
||||
|
||||
# --- Replicate lines 144-147: MESSAGING_CWD fallback ---
|
||||
configured_cwd = env.get("TERMINAL_CWD", "")
|
||||
if not configured_cwd or configured_cwd in (".", "auto", "cwd"):
|
||||
messaging_cwd = env.get("MESSAGING_CWD") or "/root" # Path.home() for root
|
||||
env["TERMINAL_CWD"] = messaging_cwd
|
||||
|
||||
return env
|
||||
|
||||
|
||||
class TestTopLevelCwdAlias:
|
||||
"""Top-level `cwd:` should be treated as `terminal.cwd`."""
|
||||
|
||||
def test_top_level_cwd_sets_terminal_cwd(self):
|
||||
cfg = {"cwd": "/home/hermes/projects"}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes/projects"
|
||||
|
||||
def test_top_level_backend_sets_terminal_env(self):
|
||||
cfg = {"backend": "docker"}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_ENV"] == "docker"
|
||||
|
||||
def test_top_level_cwd_and_backend(self):
|
||||
cfg = {"backend": "local", "cwd": "/home/hermes/projects"}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes/projects"
|
||||
assert result["TERMINAL_ENV"] == "local"
|
||||
|
||||
def test_nested_terminal_takes_precedence_over_top_level(self):
|
||||
"""terminal.cwd should win over top-level cwd."""
|
||||
cfg = {
|
||||
"cwd": "/should/not/use",
|
||||
"terminal": {"cwd": "/home/hermes/real"},
|
||||
}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes/real"
|
||||
|
||||
def test_nested_terminal_backend_takes_precedence(self):
|
||||
cfg = {
|
||||
"backend": "should-not-use",
|
||||
"terminal": {"backend": "docker"},
|
||||
}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_ENV"] == "docker"
|
||||
|
||||
def test_no_cwd_falls_back_to_messaging_cwd(self):
|
||||
cfg = {}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes/projects"})
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes/projects"
|
||||
|
||||
def test_no_cwd_no_messaging_cwd_falls_back_to_home(self):
|
||||
cfg = {}
|
||||
result = _simulate_config_bridge(cfg)
|
||||
assert result["TERMINAL_CWD"] == "/root" # Path.home() for root user
|
||||
|
||||
def test_dot_cwd_triggers_messaging_fallback(self):
|
||||
"""cwd: '.' should trigger MESSAGING_CWD fallback."""
|
||||
cfg = {"cwd": "."}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes"})
|
||||
# "." is stripped but truthy, so it gets set as TERMINAL_CWD
|
||||
# Then the MESSAGING_CWD fallback does NOT trigger since TERMINAL_CWD
|
||||
# is set and not in (".", "auto", "cwd").
|
||||
# Wait — "." IS in the fallback list! So this should fall through.
|
||||
# Actually the alias sets it to ".", then the messaging fallback
|
||||
# checks if it's in (".", "auto", "cwd") and overrides.
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes"
|
||||
|
||||
def test_auto_cwd_triggers_messaging_fallback(self):
|
||||
cfg = {"cwd": "auto"}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes"})
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes"
|
||||
|
||||
def test_empty_cwd_ignored(self):
|
||||
cfg = {"cwd": ""}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes"})
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes"
|
||||
|
||||
def test_whitespace_only_cwd_ignored(self):
|
||||
cfg = {"cwd": " "}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/fallback"})
|
||||
assert result["TERMINAL_CWD"] == "/fallback"
|
||||
|
||||
def test_messaging_cwd_env_var_works(self):
|
||||
"""MESSAGING_CWD in initial env should be picked up as fallback."""
|
||||
cfg = {}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/home/hermes/projects"})
|
||||
assert result["TERMINAL_CWD"] == "/home/hermes/projects"
|
||||
|
||||
def test_top_level_cwd_beats_messaging_cwd(self):
|
||||
"""Explicit top-level cwd should take precedence over MESSAGING_CWD."""
|
||||
cfg = {"cwd": "/from/config"}
|
||||
result = _simulate_config_bridge(cfg, {"MESSAGING_CWD": "/from/env"})
|
||||
assert result["TERMINAL_CWD"] == "/from/config"
|
||||
@@ -27,9 +27,6 @@ def _ensure_discord_mock():
|
||||
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4)
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.Embed = MagicMock
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import inspect
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
|
||||
|
||||
def test_discord_media_methods_accept_metadata_kwarg():
|
||||
for method_name in ("send_voice", "send_image_file", "send_image"):
|
||||
signature = inspect.signature(getattr(DiscordAdapter, method_name))
|
||||
assert "metadata" in signature.parameters, method_name
|
||||
@@ -1,435 +0,0 @@
|
||||
"""Tests for native Discord slash command fast-paths (thread creation & auto-thread)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
def _ensure_discord_mock():
|
||||
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
||||
return
|
||||
|
||||
discord_mod = MagicMock()
|
||||
discord_mod.Intents.default.return_value = MagicMock()
|
||||
discord_mod.DMChannel = type("DMChannel", (), {})
|
||||
discord_mod.Thread = type("Thread", (), {})
|
||||
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
)
|
||||
|
||||
ext_mod = MagicMock()
|
||||
commands_mod = MagicMock()
|
||||
commands_mod.Bot = MagicMock
|
||||
ext_mod.commands = commands_mod
|
||||
|
||||
sys.modules.setdefault("discord", discord_mod)
|
||||
sys.modules.setdefault("discord.ext", ext_mod)
|
||||
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
||||
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeTree:
|
||||
def __init__(self):
|
||||
self.commands = {}
|
||||
|
||||
def command(self, *, name, description):
|
||||
def decorator(fn):
|
||||
self.commands[name] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
config = PlatformConfig(enabled=True, token="***")
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter._client = SimpleNamespace(
|
||||
tree=FakeTree(),
|
||||
get_channel=lambda _id: None,
|
||||
fetch_channel=AsyncMock(),
|
||||
user=SimpleNamespace(id=99999, name="HermesBot"),
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /thread slash command registration
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registers_native_thread_slash_command(adapter):
|
||||
adapter._handle_thread_create_slash = AsyncMock()
|
||||
adapter._register_slash_commands()
|
||||
|
||||
command = adapter._client.tree.commands["thread"]
|
||||
interaction = SimpleNamespace(
|
||||
response=SimpleNamespace(defer=AsyncMock()),
|
||||
)
|
||||
|
||||
await command(interaction, name="Planning", message="", auto_archive_duration=1440)
|
||||
|
||||
interaction.response.defer.assert_awaited_once_with(ephemeral=True)
|
||||
adapter._handle_thread_create_slash.assert_awaited_once_with(interaction, "Planning", "", 1440)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _handle_thread_create_slash — success, session dispatch, failure
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_reports_success(adapter):
|
||||
created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock())
|
||||
parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread), send=AsyncMock())
|
||||
interaction_channel = SimpleNamespace(parent=parent_channel)
|
||||
interaction = SimpleNamespace(
|
||||
channel=interaction_channel,
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "Kickoff", 1440)
|
||||
|
||||
parent_channel.create_thread.assert_awaited_once_with(
|
||||
name="Planning",
|
||||
auto_archive_duration=1440,
|
||||
reason="Requested by Jezza via /thread",
|
||||
)
|
||||
created_thread.send.assert_awaited_once_with("Kickoff")
|
||||
# Thread link shown to user
|
||||
interaction.followup.send.assert_awaited()
|
||||
args, kwargs = interaction.followup.send.await_args
|
||||
assert "<#555>" in args[0]
|
||||
assert kwargs["ephemeral"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_dispatches_session_when_message_provided(adapter):
|
||||
"""When a message is given, _dispatch_thread_session should be called."""
|
||||
created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock())
|
||||
parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread))
|
||||
interaction = SimpleNamespace(
|
||||
channel=SimpleNamespace(parent=parent_channel),
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
adapter._dispatch_thread_session = AsyncMock()
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "Hello Hermes", 1440)
|
||||
|
||||
adapter._dispatch_thread_session.assert_awaited_once_with(
|
||||
interaction, "555", "Planning", "Hello Hermes",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_no_dispatch_without_message(adapter):
|
||||
"""Without a message, no session dispatch should occur."""
|
||||
created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock())
|
||||
parent_channel = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread))
|
||||
interaction = SimpleNamespace(
|
||||
channel=SimpleNamespace(parent=parent_channel),
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
adapter._dispatch_thread_session = AsyncMock()
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "", 1440)
|
||||
|
||||
adapter._dispatch_thread_session.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_falls_back_to_seed_message(adapter):
|
||||
created_thread = SimpleNamespace(id=555, name="Planning")
|
||||
seed_message = SimpleNamespace(id=777, create_thread=AsyncMock(return_value=created_thread))
|
||||
channel = SimpleNamespace(
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("direct failed")),
|
||||
send=AsyncMock(return_value=seed_message),
|
||||
)
|
||||
interaction = SimpleNamespace(
|
||||
channel=channel,
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "Kickoff", 1440)
|
||||
|
||||
channel.send.assert_awaited_once_with("Kickoff")
|
||||
seed_message.create_thread.assert_awaited_once_with(
|
||||
name="Planning",
|
||||
auto_archive_duration=1440,
|
||||
reason="Requested by Jezza via /thread",
|
||||
)
|
||||
interaction.followup.send.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_thread_create_slash_reports_failure(adapter):
|
||||
channel = SimpleNamespace(
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("direct failed")),
|
||||
send=AsyncMock(side_effect=RuntimeError("nope")),
|
||||
)
|
||||
interaction = SimpleNamespace(
|
||||
channel=channel,
|
||||
channel_id=123,
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
followup=SimpleNamespace(send=AsyncMock()),
|
||||
)
|
||||
|
||||
await adapter._handle_thread_create_slash(interaction, "Planning", "", 1440)
|
||||
|
||||
interaction.followup.send.assert_awaited_once()
|
||||
args, kwargs = interaction.followup.send.await_args
|
||||
assert "Failed to create thread:" in args[0]
|
||||
assert "nope" in args[0]
|
||||
assert kwargs["ephemeral"] is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# _dispatch_thread_session — builds correct event and routes it
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_thread_session_builds_thread_event(adapter):
|
||||
"""Dispatched event should have chat_type=thread and chat_id=thread_id."""
|
||||
interaction = SimpleNamespace(
|
||||
user=SimpleNamespace(display_name="Jezza", id=42),
|
||||
guild=SimpleNamespace(name="TestGuild"),
|
||||
)
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
await adapter._dispatch_thread_session(interaction, "555", "Planning", "Hello!")
|
||||
|
||||
assert len(captured_events) == 1
|
||||
event = captured_events[0]
|
||||
assert event.text == "Hello!"
|
||||
assert event.source.chat_id == "555"
|
||||
assert event.source.chat_type == "thread"
|
||||
assert event.source.thread_id == "555"
|
||||
assert "TestGuild" in event.source.chat_name
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-thread: _auto_create_thread
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_uses_message_content_as_name(adapter):
|
||||
thread = SimpleNamespace(id=999, name="Hello world")
|
||||
message = SimpleNamespace(
|
||||
content="Hello world, how are you?",
|
||||
create_thread=AsyncMock(return_value=thread),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
|
||||
assert result is thread
|
||||
message.create_thread.assert_awaited_once()
|
||||
call_kwargs = message.create_thread.await_args[1]
|
||||
assert call_kwargs["name"] == "Hello world, how are you?"
|
||||
assert call_kwargs["auto_archive_duration"] == 1440
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_truncates_long_names(adapter):
|
||||
long_text = "a" * 200
|
||||
thread = SimpleNamespace(id=999, name="truncated")
|
||||
message = SimpleNamespace(
|
||||
content=long_text,
|
||||
create_thread=AsyncMock(return_value=thread),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
|
||||
assert result is thread
|
||||
call_kwargs = message.create_thread.await_args[1]
|
||||
assert len(call_kwargs["name"]) <= 80
|
||||
assert call_kwargs["name"].endswith("...")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_create_thread_returns_none_on_failure(adapter):
|
||||
message = SimpleNamespace(
|
||||
content="Hello",
|
||||
create_thread=AsyncMock(side_effect=RuntimeError("no perms")),
|
||||
)
|
||||
|
||||
result = await adapter._auto_create_thread(message)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auto-thread integration in _handle_message
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
import discord as _discord_mod # noqa: E402 — mock or real, used below
|
||||
|
||||
|
||||
class _FakeTextChannel:
|
||||
"""A channel that is NOT a discord.Thread or discord.DMChannel."""
|
||||
|
||||
def __init__(self, channel_id=100, name="general", guild_name="TestGuild"):
|
||||
self.id = channel_id
|
||||
self.name = name
|
||||
self.guild = SimpleNamespace(name=guild_name, id=1)
|
||||
self.topic = None
|
||||
|
||||
|
||||
class _FakeThreadChannel(_discord_mod.Thread):
|
||||
"""isinstance(ch, discord.Thread) → True."""
|
||||
|
||||
def __init__(self, channel_id=200, name="existing-thread", guild_name="TestGuild", parent_id=100):
|
||||
# Don't call super().__init__ — mock Thread is just an empty type
|
||||
self.id = channel_id
|
||||
self.name = name
|
||||
self.guild = SimpleNamespace(name=guild_name, id=1)
|
||||
self.topic = None
|
||||
self.parent = SimpleNamespace(id=parent_id, name="general", guild=SimpleNamespace(name=guild_name, id=1))
|
||||
|
||||
|
||||
def _fake_message(channel, *, content="Hello", author_id=42, display_name="Jezza"):
|
||||
return SimpleNamespace(
|
||||
author=SimpleNamespace(id=author_id, display_name=display_name, bot=False),
|
||||
content=content,
|
||||
channel=channel,
|
||||
attachments=[],
|
||||
mentions=[],
|
||||
reference=None,
|
||||
created_at=None,
|
||||
id=12345,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_creates_thread_and_redirects(adapter, monkeypatch):
|
||||
"""When DISCORD_AUTO_THREAD=true, a new thread is created and the event routes there."""
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||
|
||||
thread = SimpleNamespace(id=999, name="Hello")
|
||||
adapter._auto_create_thread = AsyncMock(return_value=thread)
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
msg = _fake_message(_FakeTextChannel(), content="Hello world")
|
||||
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
adapter._auto_create_thread.assert_awaited_once_with(msg)
|
||||
assert len(captured_events) == 1
|
||||
event = captured_events[0]
|
||||
assert event.source.chat_id == "999" # redirected to thread
|
||||
assert event.source.chat_type == "thread"
|
||||
assert event.source.thread_id == "999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_disabled_by_default(adapter, monkeypatch):
|
||||
"""Without DISCORD_AUTO_THREAD, messages stay in the channel."""
|
||||
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||
|
||||
adapter._auto_create_thread = AsyncMock()
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
msg = _fake_message(_FakeTextChannel())
|
||||
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
adapter._auto_create_thread.assert_not_awaited()
|
||||
assert len(captured_events) == 1
|
||||
assert captured_events[0].source.chat_id == "100" # stays in channel
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_skips_threads_and_dms(adapter, monkeypatch):
|
||||
"""Auto-thread should not create threads inside existing threads."""
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||
|
||||
adapter._auto_create_thread = AsyncMock()
|
||||
|
||||
captured_events = []
|
||||
|
||||
async def capture_handle(event):
|
||||
captured_events.append(event)
|
||||
|
||||
adapter.handle_message = capture_handle
|
||||
|
||||
msg = _fake_message(_FakeThreadChannel())
|
||||
|
||||
await adapter._handle_message(msg)
|
||||
|
||||
adapter._auto_create_thread.assert_not_awaited() # should NOT auto-thread
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config bridge
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_discord_auto_thread_config_bridge(monkeypatch, tmp_path):
|
||||
"""discord.auto_thread in config.yaml should be bridged to DISCORD_AUTO_THREAD env var."""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
# Write a config.yaml the loader will find
|
||||
hermes_dir = tmp_path / ".hermes"
|
||||
hermes_dir.mkdir()
|
||||
config_path = hermes_dir / "config.yaml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"discord": {"auto_thread": True},
|
||||
}))
|
||||
|
||||
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_dir))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
from gateway.config import load_gateway_config
|
||||
load_gateway_config()
|
||||
|
||||
import os
|
||||
assert os.getenv("DISCORD_AUTO_THREAD") == "true"
|
||||
@@ -208,7 +208,7 @@ class TestAdapterInit:
|
||||
|
||||
def test_watch_filters_parsed(self):
|
||||
config = PlatformConfig(
|
||||
enabled=True, token="***",
|
||||
enabled=True, token="t",
|
||||
extra={
|
||||
"watch_domains": ["climate", "binary_sensor"],
|
||||
"watch_entities": ["sensor.special"],
|
||||
@@ -220,25 +220,15 @@ class TestAdapterInit:
|
||||
assert adapter._watch_domains == {"climate", "binary_sensor"}
|
||||
assert adapter._watch_entities == {"sensor.special"}
|
||||
assert adapter._ignore_entities == {"sensor.uptime", "sensor.cpu"}
|
||||
assert adapter._watch_all is False
|
||||
assert adapter._cooldown_seconds == 120
|
||||
|
||||
def test_watch_all_parsed(self):
|
||||
config = PlatformConfig(
|
||||
enabled=True, token="***",
|
||||
extra={"watch_all": True},
|
||||
)
|
||||
adapter = HomeAssistantAdapter(config)
|
||||
assert adapter._watch_all is True
|
||||
|
||||
def test_defaults_when_no_extra(self, monkeypatch):
|
||||
monkeypatch.setenv("HASS_TOKEN", "tok")
|
||||
config = PlatformConfig(enabled=True, token="***")
|
||||
config = PlatformConfig(enabled=True, token="tok")
|
||||
adapter = HomeAssistantAdapter(config)
|
||||
assert adapter._watch_domains == set()
|
||||
assert adapter._watch_entities == set()
|
||||
assert adapter._ignore_entities == set()
|
||||
assert adapter._watch_all is False
|
||||
assert adapter._cooldown_seconds == 30
|
||||
|
||||
|
||||
@@ -270,7 +260,7 @@ def _make_event(entity_id, old_state, new_state, old_attrs=None, new_attrs=None)
|
||||
class TestEventFilteringPipeline:
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignored_entity_not_forwarded(self):
|
||||
adapter = _make_adapter(watch_all=True, ignore_entities=["sensor.uptime"])
|
||||
adapter = _make_adapter(ignore_entities=["sensor.uptime"])
|
||||
await adapter._handle_ha_event(_make_event("sensor.uptime", "100", "101"))
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
@@ -308,34 +298,26 @@ class TestEventFilteringPipeline:
|
||||
assert "10W" in msg_event.text and "20W" in msg_event.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_filters_blocks_everything(self):
|
||||
"""Without watch_domains, watch_entities, or watch_all, events are dropped."""
|
||||
async def test_no_filters_passes_everything(self):
|
||||
adapter = _make_adapter(cooldown_seconds=0)
|
||||
await adapter._handle_ha_event(_make_event("cover.blinds", "closed", "open"))
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watch_all_passes_everything(self):
|
||||
"""With watch_all=True and no specific filters, all events pass through."""
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=0)
|
||||
await adapter._handle_ha_event(_make_event("cover.blinds", "closed", "open"))
|
||||
adapter.handle_message.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_state_not_forwarded(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=0)
|
||||
adapter = _make_adapter(cooldown_seconds=0)
|
||||
await adapter._handle_ha_event(_make_event("light.x", "on", "on"))
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_entity_id_skipped(self):
|
||||
adapter = _make_adapter(watch_all=True)
|
||||
adapter = _make_adapter()
|
||||
await adapter._handle_ha_event({"data": {"entity_id": ""}})
|
||||
adapter.handle_message.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_event_has_correct_source(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=0)
|
||||
adapter = _make_adapter(cooldown_seconds=0)
|
||||
await adapter._handle_ha_event(
|
||||
_make_event("light.test", "off", "on",
|
||||
new_attrs={"friendly_name": "Test Light"})
|
||||
@@ -354,7 +336,7 @@ class TestEventFilteringPipeline:
|
||||
class TestCooldown:
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooldown_blocks_rapid_events(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=60)
|
||||
adapter = _make_adapter(cooldown_seconds=60)
|
||||
|
||||
event = _make_event("sensor.temp", "20", "21",
|
||||
new_attrs={"friendly_name": "Temp"})
|
||||
@@ -369,7 +351,7 @@ class TestCooldown:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooldown_expires(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=1)
|
||||
adapter = _make_adapter(cooldown_seconds=1)
|
||||
|
||||
event = _make_event("sensor.temp", "20", "21",
|
||||
new_attrs={"friendly_name": "Temp"})
|
||||
@@ -386,7 +368,7 @@ class TestCooldown:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_entities_independent_cooldowns(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=60)
|
||||
adapter = _make_adapter(cooldown_seconds=60)
|
||||
|
||||
await adapter._handle_ha_event(
|
||||
_make_event("sensor.a", "1", "2", new_attrs={"friendly_name": "A"})
|
||||
@@ -405,7 +387,7 @@ class TestCooldown:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_cooldown_passes_all(self):
|
||||
adapter = _make_adapter(watch_all=True, cooldown_seconds=0)
|
||||
adapter = _make_adapter(cooldown_seconds=0)
|
||||
|
||||
for i in range(5):
|
||||
await adapter._handle_ha_event(
|
||||
|
||||
@@ -182,7 +182,7 @@ class TestBuildSessionContextPrompt:
|
||||
platforms={
|
||||
Platform.DISCORD: PlatformConfig(
|
||||
enabled=True,
|
||||
token="fake-d...oken",
|
||||
token="fake-discord-token",
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -197,27 +197,6 @@ class TestBuildSessionContextPrompt:
|
||||
prompt = build_session_context_prompt(ctx)
|
||||
|
||||
assert "Discord" in prompt
|
||||
assert "cannot search" in prompt.lower() or "do not have access" in prompt.lower()
|
||||
|
||||
def test_slack_prompt_includes_platform_notes(self):
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.SLACK: PlatformConfig(enabled=True, token="fake"),
|
||||
},
|
||||
)
|
||||
source = SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id="C123",
|
||||
chat_name="general",
|
||||
chat_type="group",
|
||||
user_name="bob",
|
||||
)
|
||||
ctx = build_session_context(source, config)
|
||||
prompt = build_session_context_prompt(ctx)
|
||||
|
||||
assert "Slack" in prompt
|
||||
assert "cannot search" in prompt.lower()
|
||||
assert "pin" in prompt.lower()
|
||||
|
||||
def test_discord_prompt_with_channel_topic(self):
|
||||
"""Channel topic should appear in the session context prompt."""
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
"""Tests for hermes_cli.doctor."""
|
||||
"""Tests for hermes doctor helpers."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.doctor as doctor
|
||||
from hermes_cli import doctor as doctor_mod
|
||||
from hermes_cli.doctor import _has_provider_env_config
|
||||
|
||||
|
||||
class TestProviderEnvDetection:
|
||||
def test_detects_openai_api_key(self):
|
||||
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=***"
|
||||
content = "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=sk-test-key\n"
|
||||
assert _has_provider_env_config(content)
|
||||
|
||||
def test_detects_custom_endpoint_without_openrouter_key(self):
|
||||
@@ -54,7 +47,7 @@ class TestDoctorToolAvailabilityOverrides:
|
||||
|
||||
class TestHonchoDoctorConfigDetection:
|
||||
def test_reports_configured_when_enabled_with_api_key(self, monkeypatch):
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="***")
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="honcho-test-key")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"honcho_integration.client.HonchoClientConfig.from_global_config",
|
||||
@@ -64,7 +57,7 @@ class TestHonchoDoctorConfigDetection:
|
||||
assert doctor._honcho_is_configured_for_doctor()
|
||||
|
||||
def test_reports_not_configured_without_api_key(self, monkeypatch):
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="")
|
||||
fake_config = SimpleNamespace(enabled=True, api_key=None)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"honcho_integration.client.HonchoClientConfig.from_global_config",
|
||||
@@ -72,32 +65,3 @@ class TestHonchoDoctorConfigDetection:
|
||||
)
|
||||
|
||||
assert not doctor._honcho_is_configured_for_doctor()
|
||||
|
||||
|
||||
def test_run_doctor_sets_interactive_env_for_tool_checks(monkeypatch, tmp_path):
|
||||
"""Doctor should present CLI-gated tools as available in CLI context."""
|
||||
project_root = tmp_path / "project"
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
project_root.mkdir()
|
||||
hermes_home.mkdir()
|
||||
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project_root)
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", hermes_home)
|
||||
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_check_tool_availability(*args, **kwargs):
|
||||
seen["interactive"] = os.getenv("HERMES_INTERACTIVE")
|
||||
raise SystemExit(0)
|
||||
|
||||
fake_model_tools = types.SimpleNamespace(
|
||||
check_tool_availability=fake_check_tool_availability,
|
||||
TOOLSET_REQUIREMENTS={},
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
|
||||
assert seen["interactive"] == "1"
|
||||
|
||||
@@ -95,50 +95,3 @@ def test_custom_setup_clears_active_oauth_provider(tmp_path, monkeypatch):
|
||||
assert reloaded["model"]["provider"] == "custom"
|
||||
assert reloaded["model"]["base_url"] == "https://custom.example/v1"
|
||||
assert reloaded["model"]["default"] == "custom/model"
|
||||
|
||||
|
||||
def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
||||
_clear_provider_env(monkeypatch)
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
|
||||
|
||||
config = load_config()
|
||||
|
||||
prompt_choices = iter([1, 0])
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.setup.prompt_choice",
|
||||
lambda *args, **kwargs: next(prompt_choices),
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
||||
monkeypatch.setattr("hermes_cli.auth._login_openai_codex", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_codex_runtime_credentials",
|
||||
lambda *args, **kwargs: {
|
||||
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"api_key": "codex-access-token",
|
||||
},
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_get_codex_model_ids(access_token=None):
|
||||
captured["access_token"] = access_token
|
||||
return ["gpt-5.2-codex", "gpt-5.2"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models.get_codex_model_ids",
|
||||
_fake_get_codex_model_ids,
|
||||
)
|
||||
|
||||
setup_model_provider(config)
|
||||
save_config(config)
|
||||
|
||||
reloaded = load_config()
|
||||
|
||||
assert captured["access_token"] == "codex-access-token"
|
||||
assert isinstance(reloaded["model"], dict)
|
||||
assert reloaded["model"]["provider"] == "openai-codex"
|
||||
assert reloaded["model"]["default"] == "gpt-5.2-codex"
|
||||
assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
@@ -7,7 +7,6 @@ from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.anthropic_adapter import (
|
||||
_is_oauth_token,
|
||||
_refresh_oauth_token,
|
||||
@@ -492,55 +491,6 @@ class TestConvertMessages:
|
||||
assert isinstance(system, list)
|
||||
assert system[0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_assistant_cache_control_blocks_are_preserved(self):
|
||||
messages = apply_anthropic_cache_control([
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "assistant", "content": "Hello from assistant"},
|
||||
])
|
||||
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
assistant_blocks = result[0]["content"]
|
||||
|
||||
assert assistant_blocks[0]["type"] == "text"
|
||||
assert assistant_blocks[0]["text"] == "Hello from assistant"
|
||||
assert assistant_blocks[0]["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_tool_cache_control_is_preserved_on_tool_result_block(self):
|
||||
messages = apply_anthropic_cache_control([
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
|
||||
])
|
||||
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
tool_block = result[0]["content"][0]
|
||||
|
||||
assert tool_block["type"] == "tool_result"
|
||||
assert tool_block["tool_use_id"] == "tc_1"
|
||||
assert tool_block["content"] == "result"
|
||||
assert tool_block["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
def test_empty_cached_assistant_tool_turn_converts_without_empty_text_block(self):
|
||||
messages = apply_anthropic_cache_control([
|
||||
{"role": "system", "content": "System prompt"},
|
||||
{"role": "user", "content": "Find the skill"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"id": "tc_1", "function": {"name": "skill_view", "arguments": "{}"}},
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "tc_1", "content": "result"},
|
||||
])
|
||||
|
||||
_, result = convert_messages_to_anthropic(messages)
|
||||
|
||||
assistant_turn = next(msg for msg in result if msg["role"] == "assistant")
|
||||
assistant_blocks = assistant_turn["content"]
|
||||
|
||||
assert all(not (b.get("type") == "text" and b.get("text") == "") for b in assistant_blocks)
|
||||
assert any(b.get("type") == "tool_use" for b in assistant_blocks)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build kwargs
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from multiprocessing import Lock
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -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 == []
|
||||
@@ -162,22 +162,6 @@ def test_runtime_resolution_rebuilds_agent_on_routing_change(monkeypatch):
|
||||
assert shell.api_mode == "codex_responses"
|
||||
|
||||
|
||||
def test_cli_prefers_config_provider_over_stale_env_override(monkeypatch):
|
||||
cli = _import_cli()
|
||||
|
||||
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "openrouter")
|
||||
config_copy = dict(cli.CLI_CONFIG)
|
||||
model_copy = dict(config_copy.get("model", {}))
|
||||
model_copy["provider"] = "custom"
|
||||
model_copy["base_url"] = "https://api.fireworks.ai/inference/v1"
|
||||
config_copy["model"] = model_copy
|
||||
monkeypatch.setattr(cli, "CLI_CONFIG", config_copy)
|
||||
|
||||
shell = cli.HermesCLI(model="fireworks/minimax-m2p5", compact=True, max_turns=1)
|
||||
|
||||
assert shell.requested_provider == "custom"
|
||||
|
||||
|
||||
def test_codex_provider_replaces_incompatible_default_model(monkeypatch):
|
||||
"""When provider resolves to openai-codex and no model was explicitly
|
||||
chosen, the global config default (e.g. anthropic/claude-opus-4.6) must
|
||||
@@ -326,4 +310,4 @@ def test_cmd_model_falls_back_to_auto_on_invalid_provider(monkeypatch, capsys):
|
||||
|
||||
assert "Warning:" in output
|
||||
assert "falling back to auto provider detection" in output.lower()
|
||||
assert "No change." in output
|
||||
assert "No change." in output
|
||||
|
||||
@@ -52,58 +52,6 @@ def test_get_codex_model_ids_falls_back_to_curated_defaults(tmp_path, monkeypatc
|
||||
models = get_codex_model_ids()
|
||||
|
||||
assert models[: len(DEFAULT_CODEX_MODELS)] == DEFAULT_CODEX_MODELS
|
||||
assert "gpt-5.4" in models
|
||||
assert "gpt-5.3-codex-spark" in models
|
||||
|
||||
|
||||
def test_get_codex_model_ids_adds_forward_compat_models_from_templates(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models._fetch_models_from_api",
|
||||
lambda access_token: ["gpt-5.2-codex"],
|
||||
)
|
||||
|
||||
models = get_codex_model_ids(access_token="codex-access-token")
|
||||
|
||||
assert models == ["gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.4", "gpt-5.3-codex-spark"]
|
||||
|
||||
|
||||
def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
|
||||
from hermes_cli.main import _model_flow_openai_codex
|
||||
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.get_codex_auth_status",
|
||||
lambda: {"logged_in": True},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_codex_runtime_credentials",
|
||||
lambda *args, **kwargs: {"api_key": "codex-access-token"},
|
||||
)
|
||||
|
||||
def _fake_get_codex_model_ids(access_token=None):
|
||||
captured["access_token"] = access_token
|
||||
return ["gpt-5.2-codex", "gpt-5.2"]
|
||||
|
||||
def _fake_prompt_model_selection(model_ids, current_model=""):
|
||||
captured["model_ids"] = list(model_ids)
|
||||
captured["current_model"] = current_model
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.codex_models.get_codex_model_ids",
|
||||
_fake_get_codex_model_ids,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._prompt_model_selection",
|
||||
_fake_prompt_model_selection,
|
||||
)
|
||||
|
||||
_model_flow_openai_codex({}, current_model="openai/gpt-5.4")
|
||||
|
||||
assert captured["access_token"] == "codex-access-token"
|
||||
assert captured["model_ids"] == ["gpt-5.2-codex", "gpt-5.2"]
|
||||
assert captured["current_model"] == "openai/gpt-5.4"
|
||||
|
||||
|
||||
# ── Tests for _normalize_model_for_provider ──────────────────────────
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""
|
||||
Tests for ManagedServer / tool-parser integration.
|
||||
Tests for ManagedServer tool_call_parser integration.
|
||||
|
||||
Validates that:
|
||||
1. The installed atroposlib API still matches Hermes's expectations
|
||||
2. Hermes's parser registry remains compatible with ManagedServer parsing
|
||||
3. HermesAgentBaseEnv wires the selected parser into ServerManager correctly
|
||||
1. ManagedServer accepts tool_call_parser parameter (tool_call_support branch)
|
||||
2. ServerManager.managed_server() passes tool_call_parser through
|
||||
3. The parser's parse() output is correctly attached to ChatCompletion responses
|
||||
4. hermes-agent's tool_call_parsers are compatible with ManagedServer's expectations
|
||||
|
||||
These tests verify the contract between hermes-agent's environments/ code
|
||||
and atroposlib's ManagedServer. They detect API incompatibilities early.
|
||||
@@ -141,38 +142,37 @@ class TestParserCompatibility:
|
||||
|
||||
|
||||
class TestBaseEnvCompatibility:
|
||||
"""Test that hermes_base_env.py's tool-parser wiring matches the current API."""
|
||||
"""Test that hermes_base_env.py's managed_server() call matches the API."""
|
||||
|
||||
def test_hermes_base_env_sets_server_manager_tool_parser(self):
|
||||
"""Hermes wires parser selection through ServerManager.tool_parser."""
|
||||
def test_hermes_base_env_managed_server_call_pattern(self):
|
||||
"""
|
||||
Verify that hermes_base_env.py passes tool_call_parser to managed_server().
|
||||
This is a source-level check — the actual managed_server() call must match.
|
||||
"""
|
||||
import ast
|
||||
|
||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
||||
source = base_env_path.read_text()
|
||||
tree = ast.parse(source)
|
||||
|
||||
found_assignment = False
|
||||
# Find the managed_server() call
|
||||
found_tool_call_parser_kwarg = False
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Attribute) and target.attr == "tool_parser":
|
||||
parent = target.value
|
||||
if (
|
||||
isinstance(parent, ast.Attribute)
|
||||
and parent.attr == "server"
|
||||
and isinstance(parent.value, ast.Name)
|
||||
and parent.value.id == "self"
|
||||
):
|
||||
found_assignment = True
|
||||
if isinstance(node, ast.Call):
|
||||
# Look for self.server.managed_server(...)
|
||||
if isinstance(node.func, ast.Attribute) and node.func.attr == "managed_server":
|
||||
for kw in node.keywords:
|
||||
if kw.arg == "tool_call_parser":
|
||||
found_tool_call_parser_kwarg = True
|
||||
|
||||
assert found_assignment, (
|
||||
"hermes_base_env.py should set self.server.tool_parser from config.tool_call_parser"
|
||||
assert found_tool_call_parser_kwarg, (
|
||||
"hermes_base_env.py should pass tool_call_parser= to managed_server()"
|
||||
)
|
||||
|
||||
def test_hermes_base_env_uses_config_tool_call_parser(self):
|
||||
"""Verify hermes_base_env uses the config field rather than a local parser instance."""
|
||||
def test_hermes_base_env_uses_get_parser(self):
|
||||
"""Verify hermes_base_env imports and uses get_parser from tool_call_parsers."""
|
||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
||||
source = base_env_path.read_text()
|
||||
|
||||
assert 'tool_call_parser: str = Field(' in source
|
||||
assert 'self.server.tool_parser = config.tool_call_parser' in source
|
||||
assert "from environments.tool_call_parsers import get_parser" in source
|
||||
assert "get_parser(" in source
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Tests for minisweagent_path.py."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from minisweagent_path import discover_minisweagent_src
|
||||
|
||||
|
||||
def test_discover_minisweagent_src_in_current_checkout(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
src = repo / "mini-swe-agent" / "src"
|
||||
src.mkdir(parents=True)
|
||||
|
||||
assert discover_minisweagent_src(repo) == src.resolve()
|
||||
|
||||
|
||||
def test_discover_minisweagent_src_falls_back_from_worktree_to_main_checkout(tmp_path):
|
||||
main_repo = tmp_path / "main-repo"
|
||||
(main_repo / ".git" / "worktrees" / "wt1").mkdir(parents=True)
|
||||
main_src = main_repo / "mini-swe-agent" / "src"
|
||||
main_src.mkdir(parents=True)
|
||||
|
||||
worktree = tmp_path / "worktree"
|
||||
worktree.mkdir()
|
||||
(worktree / ".git").write_text(f"gitdir: {main_repo / '.git' / 'worktrees' / 'wt1'}\n", encoding="utf-8")
|
||||
(worktree / "mini-swe-agent").mkdir() # empty placeholder, no src/
|
||||
|
||||
assert discover_minisweagent_src(worktree) == main_src.resolve()
|
||||
|
||||
|
||||
def test_discover_minisweagent_src_returns_none_when_missing(tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
|
||||
assert discover_minisweagent_src(repo) is None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user